작은 버튼 하나가 드러내는 CSS의 구조적 불안
버튼 하나를 생각해보자. :hover일 때 파란색, [disabled]일 때 회색. 두 셀렉터의 특이성은 동일하다. 그러면 버튼이 동시에 호버되고 비활성화되는 순간, 어떤 색이 나타나는가? 정답은 소스 순서에 달려 있다. CSS 파일에서 어떤 규칙이 나중에 선언됐느냐에 따라 결과가 달라진다. 로직을 바꾼 게 아닌데, 규칙의 순서만 바꿔도 컴포넌트가 망가진다.
이건 단순한 버튼 색상 문제가 아니다. 실제로 디자인 시스템을 구축해본 사람이라면 안다. :hover, :active, disabled, 다크 모드, 브레이크포인트, 데이터 어트리뷰트, 컨테이너 쿼리, 테마 오버라이드—이 모든 상태가 교차하기 시작하면 CSS는 더 이상 스타일 언어가 아니라 개발자의 머릿속에서 동작하는 충돌 해소 시스템이 된다.
문제의 본질: CSS 상태는 '겹침'으로 작동한다
dev.to에 소개된 Tasty 프로젝트의 저자는 이 문제를 수년간 프로덕션 수준의 컴포넌트 시스템을 구축하며 정면으로 마주쳤다. 그가 내린 진단은 명확하다. CSS의 상태 관리는 구조적으로 겹침(overlap) 에 의존한다. 상태가 한두 개일 때는 그 겹침이 통제 가능해 보이지만, 실제 제품에서 상태가 복잡해지는 순간 이 모델은 붕괴한다.
그의 해답은 질문 자체를 바꾸는 것이었다. "이 셀렉터를 어떻게 써야 하는가" 가 아니라 "상태를 선언적으로 기술하면, 컴파일러가 결정론적 셀렉터를 생성할 수 있지 않을까" 라는 질문으로. 이 발상이 Tasty라는 스타일 컴파일러로 이어졌다.
Tasty의 핵심은 상태 우선순위 맵을 작성자가 명시하면, 컴파일러가 상호 배타적인 셀렉터를 자동으로 생성한다는 것이다. :hover:not(:active):not([disabled]) 같은 형태로—두 브랜치가 동시에 매칭되는 상황 자체를 제거한다. 카스케이드가 개입할 여지가 없다. 소스 순서는 더 이상 결과를 바꾸지 못한다. 100개 이상의 컴포넌트와 실제 엔터프라이즈 제품을 구동하는 Cube UI Kit에서 수년간 프로덕션 검증을 거친 이 모델은, 설계가 예측 가능성을 보장할 수 있다는 것을 증명한다.
격리의 환상: Shadow DOM이 막지 못하는 스타일 누수
CSS의 예측 불가능성은 단일 앱 안에서만 발생하지 않는다. 마이크로프론트엔드 환경에서는 한 차원 더 복잡한 문제가 터진다. dev.to에 공개된 마이크로프론트엔드 CSS 격리 디버깅 기록은 그 실체를 잘 보여준다.
증상은 단순해 보였다. child app의 스타일이 host app으로 새어 나와 .MuiTypography-body1 같은 MUI 시맨틱 클래스가 전역 DOM에 영향을 미치고 있었다. 직관적인 해결책으로 Shadow DOM이 제안됐다. 그런데 Shadow DOM은 이 문제를 해결하지 못한다.
이유는 타이밍에 있다. Emotion 같은 CSS-in-JS는 React 렌더링 시점에 스타일을 주입하기 때문에 CacheProvider로 삽입 대상을 제어할 수 있다. 반면 style-loader가 처리하는 정적 CSS는 번들이 실행되는 순간—React 렌더링이 시작되기 전, Shadow DOM이 생성되기 전—이미 document.head에 주입된다. Shadow DOM은 아직 존재조차 하지 않는다. 타이밍 싸움에서 이미 진 것이다.
제약을 설계로: postcss-prefix-selector의 해법
이 문제의 해결책은 Shadow DOM이 아닌 빌드 타임 접두사 전략이다. postcss-prefix-selector를 통해 child app의 모든 정적 CSS 셀렉터 앞에 마운트 포인트 ID(#analytics-container 등)를 자동으로 추가하면, 스타일이 해당 DOM 서브트리 안에서만 작동하도록 강제할 수 있다.
webpack 설정에서 핵심은 규칙을 둘로 분리하는 것이다. node_modules의 CSS는 접두사 없이, 앱 자체의 CSS에만 postcss-loader를 통해 접두사를 적용한다. 서드파티 라이브러리 스타일에 접두사를 붙이면 해당 라이브러리가 작동하지 않게 되기 때문이다. 마운트 포인트 ID가 정확히 일치해야 한다는 조건과 함께, 이 접근법은 빌드 과정에서 격리를 구조적으로 보장한다.
두 문제—Tasty의 상태 충돌과 마이크로프론트엔드의 스타일 누수—는 표면적으로 달라 보이지만 같은 진단을 가리킨다. CSS를 신뢰할 수 없는 건 언어의 결함이 아니라, 런타임에 풀어야 할 문제를 빌드/설계 단계로 끌어올리지 않았기 때문이다.
제약 자체를 UI로: MacBook 노치가 가르치는 것
조금 다른 각도에서 같은 이야기를 하는 사례가 있다. DynamicNotch 프로젝트는 MacBook의 노치—많은 개발자와 사용자가 그저 참아야 할 화면의 '구멍'으로 여겨왔던—를 SwiftUI/AppKit으로 구현한 동적 UI 앵커로 전환한다.
Apple이 iPhone에서 Dynamic Island로 노치를 UI의 일부로 편입시킨 것처럼, 이 프로젝트는 macOS에서도 같은 개념을 탐구한다. 미디어 재생 정보, 다운로드 상태, AirDrop 알림, 배터리 상태 등을 노치 주변 영역에 큐 기반으로 표시하며, 시스템 이벤트와 동기화된다.
기술적 난관은 UI 렌더링이 아니었다. 노치 위치 기준 정확한 레이어 배치, 시스템 창과의 계층 관계, 시스템 이벤트와의 동기화—이 모든 것이 macOS의 경계를 탐색하는 작업이었다. 프로젝트가 던지는 질문은 결국 하나다. 플랫폼이 부여한 제약을 어떻게 바라볼 것인가.
시사점: '예측 가능한 스타일 시스템'은 선택이 아니라 설계다
세 사례가 하나의 방향을 가리킨다. CSS의 예측 불가능성, 마이크로프론트엔드의 스타일 누수, 플랫폼의 물리적 제약—이 모두는 피할 수 없는 숙명이 아니라 설계 결정의 부재가 낳은 증상이다.
Tasty가 보여준 것처럼, 상태 우선순위를 명시적으로 선언하고 컴파일러가 결정론적 셀렉터를 생성하게 하면 소스 순서 버그는 카테고리 자체가 사라진다. postcss-prefix-selector가 보여준 것처럼, 격리를 런타임 메커니즘(Shadow DOM)에 의존하지 않고 빌드 타임에 구조화하면 타이밍 문제가 원천 차단된다. DynamicNotch가 보여준 것처럼, 제약을 수용하는 대신 인터페이스의 요소로 재정의하면 새로운 UX 가능성이 열린다.
디자인 시스템을 구축하거나 마이크로프론트엔드 아키텍처를 설계하는 팀이라면 이 흐름이 선명하게 보일 것이다. 스타일의 신뢰성은 브라우저에게 맡길 수 없다. 설계로 확보해야 한다.
전망: 컴파일러와 빌드 타임이 CSS의 미래를 쓴다
Tailwind의 유틸리티 우선 접근, CSS Modules의 스코프 격리, 그리고 이제 Tasty 같은 상태 컴파일러까지—CSS 생태계의 흐름은 일관되다. 런타임 카스케이드의 불확실성을 빌드 타임 결정론으로 대체하는 방향이다.
React Server Components와 Next.js의 App Router가 서버-클라이언트 경계를 명시적으로 선언하게 강제하듯, 스타일 시스템도 상태 간 우선순위를 명시적으로 선언하는 방향으로 수렴하고 있다. 예측 가능성은 더 이상 '잘 짠 CSS'의 결과가 아니라, 시스템이 보장하는 속성이 돼야 한다.
CSS가 항상 예측을 배신한다고 느꼈다면, 그건 CSS의 문제가 아닐 수 있다. 우선순위를 암묵적으로 남겨둔 설계의 문제일 가능성이 높다.