다크 전용으로 만든 화면에 라이트 모드를 붙였다 — 흰 글씨가 사라진 이유

사내 SaaS 제품의 운영 화면은 처음부터 다크 모드 전용으로 만들었다. 물류 운영자가 하루 종일 띄워 두는 화면이라 어두운 쪽이 낫다고 판단했고, 그래서 뷰 전체가 bg-surface-100, text-white 같은 “다크 기준” 클래스로 굳어 있었다.

그러다 라이트 모드가 필요해졌다. 밝은 사무실에서 쓰는 사람이 있었고, 시연 일정도 정해져 있었다. 문제는 방식이었다.

왜 dark: 접두어를 다 붙이지 않았나

정석은 뷰마다 dark: 접두어를 붙이는 것이다. bg-white dark:bg-surface-100 식으로 라이트/다크 페어를 명시하면 동작은 확실하다.

문제는 규모다. 이미 수백 군데에 색 클래스가 박혀 있었고, 전부 손대면 (1) 시연 전에 못 끝내고 (2) 한 곳만 빠뜨려도 그 자리가 깨지고 (3) 앞으로 새 뷰를 쓸 때마다 두 번 생각해야 한다.

그래서 반대로 갔다. 뷰는 그대로 두고, 유틸리티 클래스 자체의 의미를 CSS 변수로 재정의하는 방식이다.

@theme {
  /* 라이트 모드 기본값 */
  --color-surface-0:   #ffffff;
  --color-surface-100: #f9fafb;
  --color-surface-200: #f3f4f6;
  --color-border-dark: #e5e7eb;
}
.dark {
  --color-surface-0:   #171717;
  --color-surface-100: #1c1c1c;
  --color-surface-200: #232323;
  --color-border-dark: #2e2e2e;
}

/* 텍스트 색 토큰 — Tailwind 생성 유틸리티 뒤에 선언되므로 덮어쓴다 */
:root { --color-text-primary: #111827; }
.dark { --color-text-primary: #ffffff; }

.text-white { color: var(--color-text-primary); }

이름은 다크 기준(text-white, border-dark)으로 남기고 값만 테마에 따라 바뀐다. 뷰 코드는 한 줄도 안 고치고 라이트 모드가 생겼다. 이름과 실제 색이 어긋나는 건 알고 감수한 트레이드오프다 — 리네임 비용보다 낫다고 봤다.

그런데 진짜 흰색이어야 하는 곳이 있다

파란 버튼 위의 흰 글씨는 라이트 모드에서도 흰색이어야 한다. text-white를 #111827로 바꿔 버리면 브랜드 블루 버튼의 글씨가 안 보인다.

그래서 예외 규칙을 넣었다. “유색 배경 클래스를 가진 요소의 text-white는 항상 진짜 흰색.”

[class*="bg-red-"].text-white,
[class*="bg-blue-"].text-white,
[class*="bg-amber-"].text-white,
/* ... 팔레트 22줄 ... */
[class*="bg-gray-"].text-white,
.bg-brand.text-white {
  color: #fff;
}

여기서 사고가 났다.

사이드바 active 메뉴가 라이트 모드에서 사라졌다

라이트 모드로 전환하면 현재 페이지 메뉴만 글씨가 안 보였다. 배경(bg-surface-200)은 라이트에서 #f3f4f6, 거의 흰색이다. 그 위에 진짜 흰 글씨가 올라가 있었다.

메뉴 링크 클래스는 이랬다.

class: "... #{'bg-surface-200 text-white' if current_page?(dashboard_path)}
        text-gray-500 hover:bg-gray-100 hover:text-gray-700 ..."

bg-gray-가 없다. 있는 건 hover:bg-gray-100이다.

[class*="bg-gray-"]는 class 속성 문자열을 부분매칭한다. hover:bg-gray-100이라는 문자열 안에 bg-gray-가 들어 있으니 매칭된다. hover 중인지 아닌지는 아무 상관이 없다 — 셀렉터는 “지금 회색 배경인가”를 묻는 게 아니라 “class 문자열에 이 글자가 있나”를 묻고 있었다.

즉 이 예외 규칙은 hover했을 때만 회색이 되는 요소까지, 평상시에도 흰 글씨로 강제하고 있었다.

수정은 간단했다. active 텍스트 색을 추론에 맡기지 않고 명시했다.

- #{'bg-surface-200 text-white' if current_page?(dashboard_path)}
+ #{'bg-surface-200 text-gray-900 dark:text-white' if current_page?(dashboard_path)}

뷰 12곳을 같은 패턴으로 통일했다.

같은 날 두 번째로 밟은 함정

컬럼 헤더 필터 팝오버도 라이트 모드에서 안 보였다. 급해서 먼저 이렇게 고쳤다.

class="... border-gray-200 dark:border-border-dark bg-white dark:bg-surface-100 text-gray-900 dark:text-gray-100"

동작은 했다. 그런데 이건 토큰 시스템을 만든 이유를 스스로 무너뜨리는 수정이다. 한 파일만 라이트/다크 페어로 쓰면 그 파일부터 규칙이 두 개가 된다. 되돌렸다.

원인을 다시 봤더니 범인은 딱 하나, text-gray-200이었다. 토큰으로 재정의한 텍스트 색은 text-white, text-gray-300, text-gray-400 세 개뿐이다. text-gray-200은 재정의 대상이 아니어서 Tailwind 기본 회색(#e5e7eb)이 그대로 남았고, 라이트 모드의 흰 배경 위에서 사라졌다.

그 한 줄만 text-white로 바꾸고, 나머지는 전부 세만틱 토큰으로 정리했다.

<%# 세만틱 토큰만 사용(라이트/다크 자동): bg-surface-*, text-white/gray-400, border-border-dark.
    ※ text-gray-200 같은 미리맵 회색은 라이트모드에서 안 보임 — 쓰지 말 것. %>
<div class="... border border-border-dark bg-surface-100 text-white">

배운 것

1. attribute-substring 셀렉터는 Tailwind variant까지 매칭한다. hover:, focus:, md:, group-hover: — 전부 class 속성 문자열 안에 그대로 들어 있는 글자다. 상태 조건을 substring으로 추론하려는 시도는 원리적으로 틀렸다. 조건이 “지금 배경이 유색인가”라면, CSS 셀렉터로 그걸 알 방법은 없다.

2. 토큰 재정의 방식에서는 “재정의 커버리지”가 곧 계약이다. text-white/gray-300/gray-400만 커버했다면, 뷰에서 gray-200이나 gray-600을 쓰는 순간 조용히 깨진다. 에러도 안 난다. 커버 범위를 문서화하고 쓸 수 있는 토큰을 좁히는 게 이 방식의 유지비다.

3. 테스트 879개가 전부 통과하는 동안 이 버그들은 하나도 안 잡혔다. 색은 컨트롤러 테스트의 사각지대다. 눈으로 보는 것 외에 방법이 없었고, 그래서 라이트/다크 둘 다 켜 보는 걸 수정 완료 조건에 넣었다.

한계·다음 계획

  • 예외 규칙은 아직 substring 기반이다. 단순 팔레트 배경(bg-blue-600 text-white)에는 잘 동작하지만, 근본 해법은 .text-on-color 같은 명시적 유틸리티를 만들어 유색 배경 버튼에 직접 붙이는 것이다. variant 오매칭 위험이 없어진다.
  • 미리맵되지 않은 회색(text-gray-200, bg-gray-700 등) 사용을 lint로 막고 싶다. 지금은 코드 주석과 리뷰에 의존한다.
  • 라이트 모드 시각 회귀 테스트가 없다. 스크린샷 diff를 붙이는 게 다음 후보인데, 화면 수 대비 유지비가 커서 아직 판단 보류다.

FAQ

Q. dark: 접두어를 다 붙이는 정석 방식이 낫지 않나?
새 프로젝트라면 그쪽이 맞다. 이 판단은 “이미 다크 전용으로 수백 곳이 굳어 있고 일정이 있다”는 조건에서 나온 것이다. 토큰 재정의는 마이그레이션 비용을 유지 비용으로 바꾼 선택이다.

Q. text-white를 검은 글씨로 재정의하면 헷갈리지 않나?
헷갈린다. 이름과 값이 어긋나는 게 이 방식의 가장 큰 단점이다. 그래도 뷰 수백 곳 리네임보다 싸다고 봤고, CSS 상단 주석에 매핑표(text-white → 메인 텍스트)를 명시해 완화했다. 규모가 더 커지면 text-primary 같은 이름으로 한 번은 리네임해야 한다.

Q. [class*="..."]를 아예 쓰지 말아야 하나?
클래스 이름이 데이터인 경우(예: 서버가 붙이는 status-shipped)엔 유용하다. 문제는 Tailwind처럼 상태와 조건이 클래스 이름에 문자열로 인코딩된 체계에 쓸 때다. hover:가 붙은 클래스도 평상시 class 문자열에 그대로 존재한다는 점만 기억하면 된다.

Q. 왜 CSS 변수를 Tailwind 유틸리티 뒤에 선언해야 하나?
같은 명시도(specificity)면 나중에 선언된 규칙이 이긴다. .text-white { color: var(--color-text-primary) }가 Tailwind가 생성한 .text-white보다 뒤에 와야 덮어쓴다. 빌드 순서가 바뀌면 조용히 원래 색으로 돌아간다.

Leave a Comment