개발자들은 빠른 성과를 좋아합니다. 티켓에 "다크 모드 추가"라고 적혀 있으면, 가장 쉬운 방법은 명확해 보입니다. light.css를 작성하고, dark.css를 작성한 뒤, 두 파일을 전환하는 것이죠. 깔끔하게 느껴지고, 빠르게 배포할 수 있습니다. 컴포넌트가 세 개뿐인 아주 작은 사이드 프로젝트라면 이 방식이 통할지도 모릅니다. 하지만 애플리케이션이 몇 개의 모듈을 넘어 성장하기 시작하면, 두 번째 파일은 자산이 아니라 중복으로 관리해야 하는 부채가 됩니다.

두 파일의 함정

논리는 언뜻 보기에 타당해 보입니다. 관심사의 분리(Separation of concerns) 아닌가요? 밝은 요소는 여기, 어두운 요소는 저기에 두는 식이죠. 에디터에서 두 개의 버퍼를 엽니다. 라이트 파일의 카드 스타일을 다크 파일로 복사하고, #ffffff#1a1a1a로 바꾼 뒤 작업을 마칩니다.

문제는 첫 주에 발생하는 것이 아닙니다. 문제는 6개월 뒤, 디자이너가 기본 버튼의 테두리 반경(border radius)을 약간 다르게 해달라고 요청하거나, 제품 팀이 결제 양식에 새로운 경고 상태를 추가하고 싶어 할 때 발생합니다. 라이트 스타일시트를 업데이트합니다. 다크 스타일시트를 대충 훑어봅니다. 변경 사항을 복사하는 것을 기억할 수도 있고, 아닐 수도 있습니다. 그 간극에서 품질이 무너집니다. 당신은 더 이상 하나의 인터페이스를 유지보수하는 것이 아닙니다. 동일한 HTML 구조를 공유하는 두 개의 병렬 인터페이스를 유지보수하고 있는 것입니다.

테마 드리프트는 불가피하다

이 간극에는 프론트엔드 팀들이 인식하기 시작한 이름이 있습니다. 바로 테마 드리프트(theme drift)입니다. 두 스타일시트가 서로 다른 속도로 진화할 때 발생합니다. 여기서는 패딩을 조정하고, 저기서는 그림자를 수정합니다. 다크 파일은 방치된 형제가 됩니다. 더 나쁘게는, 두려움의 대상이 됩니다. 개발자들은 변경 사항을 적용하는 것을 피하기 시작합니다. 한 테마를 건드린다는 것은 다른 파일까지 뒤져서 작업을 똑같이 반복해야 한다는 의미이기 때문입니다.

인지적 부하(cognitive overhead)는 빠르게 누적됩니다. CSS를 한 번만 쓰고 싶었을 뿐인데, 대신 두 번 쓰게 되었고, 이제 디자인 시스템이 바뀔 때마다 그 부채에 대한 이자를 지불하고 있습니다. 누군가 라이트 파일에서 flex gap을 업데이트하고 다크 파일에 반영하는 것을 잊어버리면 다크 모드에서 아이콘 정렬이 어긋납니다. 새로운 접근성 규칙이 한쪽 시트에만 적용되면 포커스 링(focus rings)이 사라집니다. UI는 단순히 잘못되어 보이는 것을 넘어, 망가진 것처럼 느껴지기 시작합니다.

시맨틱 토큰을 사용하라

해결책은 더 나은 diff 도구나 엄격한 코드 리뷰가 아닙니다. 해결책은 색상을 생각하는 방식을 바꾸는 것입니다. 스타일을 실제 외형(literal appearance)에 따라 정리하는 것을 멈추고, 목적(purpose)에 따라 정리하기 시작하십시오. 여기서 시맨틱 토큰(semantic tokens)이 등장합니다.

카드에 흰색 배경을 할당하는 대신, 서피스(surface) 배경을 할당하십시오. 텍스트 색상으로 검은색과 오프화이트(off-white) 중 하나를 선택하는 대신, '텍스트 색상' 토큰을 선택하십시오. 컴포넌트는 사용자가 라이트 모드를 선호하는지 다크 모드를 선호하는지 알 필요도 없고 상관도 없습니다. 그저 자신의 역할에 맞는 토큰을 요청할 뿐입니다.

표준 버튼을 생각해 봅시다. 두 파일 방식에서는 .btn이 라이트 스타일시트에 존재하며 흰색 배경과 어두운 테두리를 가집니다. 그 쌍둥이는 다크 스타일시트에 존재하며 거의 검은색에 가까운 배경과 밝은 테두리를 가집니다. 버튼 하나를 위해 코드를 두 번 쓰는 셈입니다. 토큰을 사용하면 .btn은 하나의 선언만 가집니다. 배경은 var(--color-surface-secondary)이고 테두리는 var(--color-border-default)입니다. 값 자체는 루트(root)에 존재합니다. 사이트가 라이트 모드일 때 --color-surface-secondary#f8f9fa와 같은 값으로 결정됩니다. 다크 모드에서는 동일한 토큰이 #2d2d2d로 결정됩니다. 버튼 컴포넌트는 전혀 변하지 않습니다. 그 아래에 있는 데이터만 변할 뿐입니다.

구조와 데이터 사이의 이러한 구분은 미묘하지만 강력합니다. 카드 컴포넌트는 레이아웃, 간격, 타이포그래피, 그리고 고도(elevation)를 한 번만 정의합니다. 테마 레이어는 팔레트를 정의합니다. 이러한 분리가 바로 CSS 커스텀 프로퍼티(custom properties)가 만들어진 목적입니다.

아키텍처의 변화

이 접근 방식은 스타일을 작성하는 방식을 근본적으로 재구성합니다.

기존 방식은 보통 다음과 같습니다:

  • 패딩, 반경, 배경, 텍스트 색상, 그림자를 정의하는 라이트 카드 스타일시트.
  • 색상만 바꾸기 위해 동일한 속성의 대부분을 재정의하는 다크 카드 스타일시트.
  • 어떤 스타일시트를 로드할지 또는 body에 어떤 클래스를 토글할지 결정하는 로직 레이어.

새로운 방식은 다음과 같습니다:

  • 레이아웃을 정의하고 시맨틱 토큰을 할당하는 하나의 카드 스타일시트.
  • 라이트 컨텍스트에서 해당 토큰이 무엇을 의미하는지 정의하는 하나의 테마 파일.
  • 다크 컨텍스트에서 해당 토큰이 무엇을 의미하는지 정의하는 하나의 테마 파일(또는 동일한 파일 내의 블록).
  • 컴포넌트 레이어를 건드리지 않고 값 레이어만 변경하는 단일 속성(attribute) 교체.

설정은 안정적으로 유지하고 데이터만 변경합니다. 디자이너가 고대비 모드나 미드나잇 블루 변형과 같은 세 번째 테마를 도입하고 싶을 때, 카드를 다시 작성할 필요가 없습니다. 토큰 맵에 할당을 하나 더 추가하기만 하면 됩니다. 컴포넌트는 단순하고 평온한 상태를 유지합니다. 컴포넌트는 여전히 표면 색상(surface color)을 원할 뿐이며, 테마가 어떤 표면 색상을 사용할지 알려줍니다.

데이터 속성을 이용한 전환

구현은 단순하고 읽기 쉬운 상태를 유지할 수 있습니다. HTML 태그에 data-theme="dark"와 같은 데이터 속성을 적용하고, 토큰 정의가 그 아래에서 범위를 갖도록(scope) 합니다.

JavaScript가 실행되기 전에 페이지가 올바르게 렌더링되도록 :root에 기본값을 설정하여 라이트 모드 경험을 제공합니다. 그런 다음 [data-theme="dark"] 아래에서 토큰 값을 재정의합니다. 작은 스크립트가 토글 클릭을 감시하여 속성을 업데이트하면, 페이지의 모든 컴포넌트가 즉각적으로 반응합니다. 개별 요소에 클래스를 계속 갈아치울 필요도 없고, 렌더링 중간에 완전히 별개의 스타일시트를 불러올 필요도 없습니다. 브라우저는 이미 메모리에 변수를 가지고 있으므로, 새로운 값으로 다시 그리기(repaint)만 하면 됩니다.

이는 매우 실용적인 관점에서 코드를 깔끔하게 유지해 줍니다. .card의 모든 인스턴스를 찾기 위해 두 개의 디렉토리를 뒤질(grep) 필요가 없습니다. 동일한 노드에 쌓여 있는 경쟁적인 테마 클래스 간의 명시도 전쟁(specificity wars)을 걱정할 필요도 없습니다. HTML은 읽기 쉬운 상태를 유지하고, CSS는 중앙 집중화되어 검색하기 쉬워집니다.

버전이 아닌 값에 관한 것

다크 모드는 값에 관한 것입니다. UI의 두 번째 버전이 아닙니다. 밤이라고 해서 카드의 모서리가 더 둥글어지지는 않습니다. 그리드가 다른 모양으로 무너지지도 않습니다. 타이포그래피 스케일(type scale)에 새로운 리듬이 필요하지도 않습니다. 오직 색상만 바뀌고, 때로는 그림자가 조금 더 깊게 숨을 쉴 뿐입니다. 어둠을 전체적인 리스킨(reskin)으로 취급하는 것은 유지보수 악몽을 초래하는 과잉 엔지니어링입니다.

이를 제대로 이해하는 팀은 디자인 시스템을 데이터베이스처럼 다룹니다. 컴포넌트는 이름으로 속성을 쿼리(query)하고, 테마는 레코드를 제공합니다. 라이트 모드에서 다크 모드로 전환하는 것은 스키마 재작성이 아니라 쿼리 파라미터의 변경입니다.

그러한 사고방식이 테마 드리프트(theme drift)로부터 당신을 구해줍니다. 하나의 카드, 하나의 버튼, 간격과 크기에 대한 하나의 진실의 원천(source of truth). 팔레트는 논리적으로 매핑되어 한 곳에 존재하며, 사용자가 선호하는 어떤 환경에서도 즉시 사용할 준비가 되어 있습니다.

핵심 요약

라이트 모드와 다크 모드를 위해 두 개의 CSS 파일을 유지 관리하고 있다면, 그것은 테마를 만드는 것이 아니라 복제하는 것입니다. 시맨틱 토큰(semantic tokens)으로 전환하고, 루트 레벨의 데이터 속성으로 범위를 지정하며, 컴포넌트가 외형을 하드코딩하는 대신 역할을 요청하도록 만드세요. 초기 리팩토링에는 노력이 들지만, 그 대안은 병렬 스타일시트 사이에서 끝없이 '두더지 잡기(whack-a-mole)' 게임을 하는 것뿐입니다. 똑같은 카드를 두 번 작성하기에는 인생은 너무 짧습니다.