JavaScript 50줄을 몇 줄의 CSS로 대체하고, PR을 병합한 뒤, 마치 코드베이스에 호의를 베푼 것 같은 기분을 느껴본 적이 있나요? ResizeObserver를 컨테이너 쿼리(container query)로 대체하면, 변경된 부분은 대부분 빨간색으로 표시되고 점심 식사 전 모두가 승인합니다.
변경하기 전의 모습은 다음과 같았습니다:
- 선택자가 의도한 것보다 더 많이 일치한다.
:has(img) 규칙은 모든 하위 이미지(descendant image)와 일치하므로, 주석의 아바타가 히어로 이미지로 계산되었습니다. 또한 p 요소 선택자는 모든 하위 단락(descendant paragraph)과 일치합니다. 그 결과, 주석의 텍스트도 이 선택자에 포착되었습니다.
- 선택자가 주석 자체 규칙보다 우선한다.
MDN의 구체성 가이드에 따르면, :has()는 그 자체로는 어떤 무게(weight)를 추가하지 않지만, 인자(argument)가 무게를 추가합니다. 이로 인해 .card:has(img) p는 구체성 점수(specificity score) 0-1-2를 가지게 되는 반면, .comment p는 단지 0-1-1에 그쳐 전투에서 패배합니다.
상태가 런타임(runtime)에 따라 변경되면서 훨씬 더 복잡하고 포착하기 어려워질 수 있습니다. 예를 들어, 사용자가 아바타를 포함하는 주석을 게시했다고 가정해 봅시다. 그 위에 있는 카드 본문 텍스트의 간격이 사라집니다. “간격이 가끔 이상하게 보인다”는 버그 리포트에서 이를 재현하는 것은 어려울 것입니다.
수정 방법은 자식 결합자(child combinators)를 사용하여 선택자를 제한하는 것입니다:
.card:has(> img) > p {
margin: 0;
}
이렇게 하면 선택자가 무엇에 대해 '소리치고' 있는지 더 구체적으로 알 수 있게 됩니다.
중첩(Nesting) 역시 무게를 빌려온다
네이티브 중첩(Native nesting)도 같은 습관을 가지고 있습니다. MDN의 중첩 및 구체성 가이드에 따르면, & 선택자는 :is()와 마찬가지로 “연관된 선택자 목록에서 가장 큰 구체성을 취합니다.” 이라는 내용입니다. MDN의 예제를 참고해 보세요:
/* 구체성 1-0-0 */
.card, #featured {
/* 구체성: 1-0-1 */
...
.card 안에 있는 .button 링크는 #featured ID가 근처에 없더라도 파란색입니다. 일반적인 반응은 더 구체적인(specific) .button 규칙을 만들고, 그 규칙에 대한 예외를 두는 것입니다. 어느새 당신은 자신이 선언조차 몰랐던 '구체성 전쟁(war of specificity)' 속에 빠지게 됩니다.
이 문제의 해결책은 선택자 목록을 유지하면서 :where()로 감싸서 그 가중치(weight)를 낮추는 것입니다:
/* :where()는 항상 0-0-0의 가중치를 갖습니다 */
:where(.card, #featured) {
/* Specificity: 0-0-1 */
...
여전히 카드 내부의 일반 링크들은 파란색이고, .button 링크는 다시 빨간색이 됩니다. 또 한 번, 시끄러운 룸메이트를 잠재우는 또 다른 방법입니다.
Layers가 기본적으로 구원해 주지 못하는 이유
구체성(specificity)이 전부는 아니라고 착각해서는 안 됩니다. MDN의 캐스케이드 소개에서는 구체성보다 오리진(origin), 중요도(importance), 그리고 캐스케이드 레이어(cascade layers)를 앞세우며, “일반적인 레이어드 스타일은 일반적인 언레이어드 스타일보다 우선순위가 낮다”고 언급합니다. 제가 테스트한 결과, 구체성이 0-4-0인 레이어드 규칙이 구체성이 0-1-0인 언레이어드 .title에 패배하는 경우가 있었습니다. 따라서 새로운 컴포넌트 스타일을 @layer components에 넣더라도 레거시(legacy) 스타일시트가 언레이어드로 남아 있다면, 아무리 신중하게 새 규칙을 작성했더라도 레거시 규칙이 모든 충돌에서 승리합니다. 예외는 !important 키워드를 사용하는 경우인데, 이는 실제로 캐스케이드 순서를 역전시키기 때문입니다.
Miriam Suzanne의 캐스케이드 레이어에 대한 원래 제안은 레이어가 제거하고자 했던 고통, 즉 “레이어와 일치하는 구체성을 매우 신중하게 다뤄야 하는 것”을 정확히 설명합니다. 레이어는 이를 제거하지만, 양쪽의 충돌 규칙이 모두 레이어드일 때만 가능합니다. 반쯤 레이어드된 코드베이스는 여전히 머릿속에 기억해야 할 또 하나의 순서(ordering)를 안겨줄 뿐입니다.
새로운 도구와 함께하는 오래된 싸움
이러한 논쟁들 중 어느 것도 :has()나 중첩(nesting)에서 시작된 것은 아니다. r/webdev 스레드 “CSS-in-JS: 실제로 가장 크게 체감한 성능 저하는 무엇인가?”에서, 가장 많은 추천을 받은 답변 중 하나는 성능에 관한 것이 아니었다. 그것은 캐스케이드(cascade)에 관한 것이다:
“재미있는 사례를 하나 들자면: JS를 리팩토링하는 것이 CSS를 망가뜨릴 수 있다. 왜냐하면 당신의 CSS 빌드는 JS 임포트 순서에 의해 결정되기 때문이다.”
— mq2thez
이것은 “출현 순서(order of appearance)”에 관한 것인데, 이는 캐스케이드의 마지막 단계이며 아무도 존재한다는 것을 알지 못했던 충돌을 결정하게 만든다. 서브레딧의 또 다른 댓글 작성자는 주입된 스타일이 개발 빌드와 프로덕션 빌드에서 다른 순서로 적용될 수 있다고 덧붙였다. “로컬에서는 작동하지만 프로(prod) 환경에서는 작동하지 않는 것을 디버깅하는 것을 누가 좋아하겠는가.”
스레드의 모든 사람이 누구를 탓해야 할지에 동의하는 것은 아니다. TheThirdRace는 “이것은 기술 문제가 아니라 코딩 문제다.”라고 답했다.
나는 둘 다 옳다고 생각하며, 그것이 바로 요점이다. CSS-in-JS는 우리가 살펴보고 있는 네이티브 기능과는 다른 기술이므로, 이것을 증거로 받아들이기보다는 근접한 예시로 간주하라. 하지만 문제의 형태는 동일하다: 도구는 약속한 대로 작동했고, 문제는 기존 규칙에 어떻게 준수했는지에서 발생했다.
이 규칙을 추가하기 전에:
- 제한하라(Constrain it). 선택자가 컴포넌트 자체의 자식 요소에서 멈추도록
>를 사용하라.
- 빌려온 가중치를 제거하라(Drop the borrowed weight). 항상 특정성(specificity)이
0-0-0인 :where()로 무거운 선택자를 감싸라.
- 레이어를 확인하라(Check the layers). 당신의 규칙은 어느 레이어에 위치하며, 경쟁자들은 어디에 살고 있는가?
시나리오 4: 월세 납부가 느린 하우스메이트
“이걸 CSS로 옮기면 더 빠해졌어.” 코드를 검토하는 과정에서 아마 들어봤거나, 어쩌면 스스로 말했을 수도 있습니다. 지난 글에서 저는 항상 그런 것은 아니라고 경고했습니다. 직접 테스트해 봅시다.
동작을 JavaScript에서 CSS로 옮기는 것은 누가 작업을 수행할지를 바꿀 뿐입니다. 그 작업의 양이 얼마나 되는지는 알려주지 않습니다. 마지막 세입자, 즉 브라우저의 렌더링 엔진이 청구서를 받는 주체입니다. 아파트에 있는 모두가 이 비용을 공유하지만, 이동 후에는 아무도 그것을 신경 쓰지 않습니다.
예시로 :has()를 들어보겠습니다. 브라우저 공급업체들은 처음부터 이의 비용에 대해 우려했습니다. :has() 제한 사항에 대한 CSS Working Group 이슈에서 Emilio Cobos Álvarez는 구현자들이 “성능상의 함정(performance footguns)과 복잡성을 방지하기 위해” 제한 사항을 두고 출시하기를 원한다고 언급했습니다. 오늘날, MDN의 :has() 페이지에는 성능에 관한 전체 섹션이 있으며 두 가지 조언이 있습니다:
:has()를 “body”, “:root”, 또는 “*”와 같이 하위 트리가 큰 요소에 연결하지 마십시오.
- 내부 선택자를
>나 +로 제한하십시오. 그렇지 않으면 “브라우저가 모든 DOM 변경 시 앵커 요소의 전체 하위 트리를 순회해야 할 수 있기” 때문입니다.
저는 숫자가 필요했기 때문에 5,000개 행으로 된 목록을 만들었습니다. 이 테스트는 같은 행에 .is-selected 클래스를 300번 토글하며, 따라서 행의 선택 상태가 토글할 때마다 변경되고 매번 스타일 및 레이아웃 업데이트를 강제합니다. 저는 2026년 10월 Mac에서 Chrome 153으로 실행했으며, 각 토글이 몇 개의 요소를 다시 스타일링하는지 계산하기 위해 DevTools에 트레이스를 기록했습니다.
Shoyombo Gabriel Ayomide가 만든 Pen [Scenario 4: The Rewrite Nobody Measured [forked]]를 확인해 보세요.
| Rule | 스타일시트 내 내용 | 토글당 중앙값 시간 | 재스타일링되는 요소 수 |
|---|
| 1 | JavaScript가 툴바에 클래스를 토글함 | 0.1ms 미만 | 1 |
| ... | | | |
| 테이블에는 두 가지 교훈이 있습니다. | | | |
첫째, '나쁜' 선택자는 괜찮았습니다. Rule 2는 MDN이 경고하는 전형적인 형태입니다: body에 고정된 :has() 선택자입니다. 이 선택자는 스크립트보다 약 1.5밀리초 더 많은 비용을 발생시켰으며, 툴바라는 단일 요소만 재스타일링했습니다. 60Hz의 프레임은 16.7ms이므로, 저는 이벤트 핸들러를 삭제하는 대신 이 정도의 트레이드오프를 기꺼이 감수할 것입니다.
둘째, 두 개의 무해한 규칙이 함께 있을 때는 무해가 아니었습니다. Rule 3는 행(rows)을 스타일링하며, 그 :has() 조건은 테스트 전반에 걸쳐 변경되지 않습니다. 단독으로 사용될 경우 측정 가능한 비용은 없습니다. 하지만 이 규칙을 두 번째 규칙과 같은 스타일시트에 넣으면, 모든 토글마다 툴바와 함께 5,000개의 행 전체가 재스타일링됩니다. 이는 스크립트가 의도적으로 모든 행을 재스타일링하는 데 드는 대략적인 비용입니다.
비교하자면 이 정도의 차이입니다. 7ms는 여전히 프레임 안에 포함되므로, 아무도 이 페이지에서 알아차리지 못할 것입니다. 하지만 브라우저는 이제 단 하나를 변경하기 위해 5,001개의 요소를 재스타일링하고 있으며, 두 규칙 중 어느 것도 그렇게 명시하지 않습니다. 또한, 비용은 목록의 크기에 따라 증가합니다. 데모를 20,000개 행으로 설정했을 때, 두 규칙이 함께 작동하면서 20,001개의 요소가 재스타일링되었고 토글당 약 31ms가 소요되어 거의 두 프레임에 달했습니다.
저는 Chrome에서 정확히 무슨 일이 일어나는지 말씀드릴 수 있지만, 그 이유는 모릅니다. 한 행의 .is-selected를 변경하는 것이 다른 :has() 규칙이 목표로 하는 모든 요소를 재스타일링합니다. 비록 해당 규칙이 .is-selected에 대해 언급하지 않았고 그 결과가 변하지 않음에도 불구하고 말입니다. 저는 테스트 케이스를 줄여서 issue로 Chromium 팀에 신고했으며, Chrome만 테스트했습니다. 원인이 무엇이든 간에, 두 규칙 모두 코드 검토(code review)에서 의심스러워 보이지 않으며, 비용은 두 조건이 모두 충족될 때만 발생합니다.
이것이 단일 테이블 내의 시스템 복잡성입니다. 그리고 이 숫자들보다는 패턴을 가져가십시오: 이것은 하나의 페이지, 하나의 브라우저, 그리고 하나의 기기에서 일어난 일입니다.
애니메이션도 마찬가지입니다. web.dev의 애니메이션 가이드는 가능한 경우 애니메이션을 opacity와 transform으로 제한할 것을 권장하며, “transform 이외의 요소를 움직이는 애니메이션은 느릴 가능성이 높다”고 경고합니다. MDN의 CSS 성능 가이드에는 width, height, margin, top, left와 같이 애니메이션 시 레이아웃을 유발하는 속성들이 나열되어 있습니다. left에 대한 CSS 트랜지션은 매 프레임마다 레이아웃을 실행하지만, transform의 JavaScript 애니메이션은 이를 건너뜁니다. 어떤 언어로 작성하든 이 속성이 비용(cost)을 결정합니다.
공정하게 말하자면, CSS가 종종 승리합니다. 레이아웃을 읽고, 스타일을 쓰고, 다시 레이아웃을 읽는 스크립트는 브라우저에게 한 프레임에 여러 번 레이아웃을 수행하도록 강요하며, 컨테이너 쿼리는 이 모든 작업을 브라우저에 맡깁니다. 하지만 이러한 승리(성능 우위)는 측정할 수 있는 것이므로, 반드시 측정해야 합니다:
- Chrome DevTools의 “Performance” 패널에서 변경 전과 후의 상호작용을 기록합니다.
- “Recalculate Style,” “Layout,” 및 “Paint”에 소요된 시간을 비교합니다.
- 패널 설정에서 CSS 선택자 통계(CSS selector stats)를 활성화하여 어떤 선택자가 가장 많은 비용을 유발하는지 확인합니다.
- 실제 규모의 데이터를 사용합니다. 20개 행으로 구성된 더미 데이터는 5,000개 행 목록이 보여주는 것을 숨깁니다.
네 가지가 모두 하나의 Pull Request에 나타날 때
이러한 각 시나리오는 그 자체로는 작습니다. 문제는 실제 마이그레이션(migration)이 이 네 가지 시나리오를 한 번에 건드릴 수 있다는 것입니다. 이미 세 가지는 보셨습니다. 그것들은 본문 상단 “after” 데모의 세 가지 버그입니다. 여기에 긴 목록을 추가하면 네 번째가 생깁니다. 하나의 움직임으로, 네 명의 룸메이트가 방해받게 됩니다.
한 팀이 카드(card)의 JavaScript 기반 반응형 레이아웃을 Container Query와 :has()로 대체합니다. 풀 리퀘스트(pull request)는 CSS 12줄을 추가하고, 스크립트 1개를 삭제하며, 다른 스크립트는 놓칩니다. 한 달 안에 버그 보고서 3개가 접수되고, 성능 추적(performance trace)이 네 번째 문제로 나타날 가능성이 있습니다:
- 태블릿에서 카드 제목이 너무 커진다. 옵저버(observer)가 여전히
.card--wide를 설정하고 있고, 하나의 규칙이 이를 읽고 있습니다. 개발자는 JavaScript 내부를 파헤치기 시작합니다.
- 대시보드의 카드가 사라졌다. 대시보드에서 카드는 플렉스 아이템(flex item)이며, 인라인 크기 포함(inline-size containment)으로 인해
0px로 축소되었습니다. 개발자는 부모의 레이아웃을 확인합니다.
- 일부 카드에서 텍스트가 압축된다. 해당 카드는 아바타가 포함된 댓글을 가지고 있어
.card:has(img) p가 일치합니다. 개발자는 DevTools에서 승리하는 선언(winning declaration)을 추적합니다.
- 행을 선택하는 것이 가장 큰 고객에게 지연(laggy)될 수 있다. 5,000개의 행에서 두 개의
:has() 규칙은 선택당 약 7ms의 비용이 발생합니다. 이는 각 선택마다 5,001개의 요소를 다시 스타일링하지만 (과거에는 하나만 스타일링했음에도 불구하고), 프레임 내에 들어옵니다. 행의 수가 증가함에 따라 스타일링되는 요소도 증가하고, 선택당 두 개의 :has() 규칙 비용 역시 증가할 것입니다. 하지만 페이지네이션 없이 많은 수의 행을 가진 테이블을 누가 렌더링합니까?