E-4: Tailwind 다크모드 — 중단점 토큰과 dark:
목차 54
안녕하세요, 홍순구입니다. 지난 시간에 유틸리티 앞에 조건을 붙이는 법을 배웠죠. 잠겼을 때, 마우스를 올렸을 때, 키보드로 왔을 때, 화면이 넓을 때까지 배웠는데, 마지막에 두 가지를 남겨뒀습니다.
첫째, 화면 크기의 기준으로 lg를 썼는데 그 1024px 은 Tailwind가 정해준 숫자였어요. 우리 화면에 맞는 선을 우리가 정하려면 어떻게 할까요.
둘째, 조건들이 출력되는 순서를 그리면서 제가 하나를 비워뒀는데, 모든 조건보다 뒤에 오는 것이 하나 더 있다고요.
오늘 둘 다 채우는데, 두 번째 것이 오늘의 본론입니다. 화면이 어두울 때요.
오늘의 여정
Step 1 두 열로 갈리는 폭을 우리가 정한다
│
Step 2 그런데 이 토큰만 :root 에 없다
│
Step 3 조건이 화면 밖에 있을 때 — dark:
│
Step 4 다크 값을 어디에 둘까 ① 유틸리티마다 붙이기
│
Step 5 다크 값을 어디에 둘까 ② 이름은 그대로, 값만
│
Step 6 다크 값은 눈대중으로 고르지 않는다
│
Step 7 값만 갈아끼워서 안 되는 곳
▼
className 을 한 글자도 안 고치고 화면 전체가 어두워진다
💡 오늘 수업의 핵심 — "같은 이름에 다른 값을 담으면, 유틸리티는 그대로 두고 화면 전체가 바뀐다"
bg-surface는 var(--color-surface)를 보고 있습니다. 그러니 그 이름이 가리키는 값만 어두울 때 갈아끼우면, bg-surface라고 적힌 여덟 곳이 한꺼번에 따라옵니다.
🎯 학습 목표
- 중단점을
--breakpoint-*토큰으로 직접 정하고, 그 숫자가 어디서 나왔는지 설명할 수 있다 - 중단점 토큰만
:root에 안 나타나는 이유를 미디어 쿼리의 성질로 설명할 수 있다 dark:유틸리티를 컴포넌트마다 붙이는 방법과 토큰 값을 한 곳에서 재정의하는 방법을 견주어 고를 수 있다- 다크 값을 대비로 계산해 고르고, 값 교체만으로는 안 되는 곳을 찾아낼 수 있다
Step 1: "두 열로 갈리는 폭을 우리가 정한다"
지난 시간에 피드를 두 열로 만들면서 lg:를 썼는데, 코드를 다시 볼게요.
// apps/web-spa/src/App.tsx
<main className="mx-auto max-w-[470px] py-4 sm:px-4 lg:max-w-[964px]">
lg는 1024px 인데, 왜 하필 1024일까요. 우리 피드가 1024에서 두 열이 되어야 할 이유가 있었나요.
없습니다. Tailwind가 미리 정해둔 다섯 개 중에 우리 상황에 제일 가까운 것을 고른 것뿐이어서, 오늘은 이 숫자를 우리가 계산해서 정해봅시다.
필요한 폭을 거꾸로 계산해봅시다
두 열이 들어가려면 무엇이 필요한지 세어보면 됩니다. 카드 한 장이 470px 이고, 두 장 사이에 24px 간격이 있고, 바깥 통에 좌우 16px 씩 여백이 있어요.
카드 470 + 간격 24 + 카드 470 = 964 ← 통 안쪽에 필요한 폭
여백 16 + 964 + 여백 16 = 996 ← 통 전체에 필요한 폭
996px 입니다. lg의 1024보다 28px 이른데, 그만큼은 두 열이 들어갈 수 있는데도 한 열로 버티고 있었던 겁니다.
통의 폭도 함께 손봐야 합니다
여기서 짚고 갈 것이 있어요. 지난 시간에 통의 상한을 964px 로 뒀는데, 그때 카드 폭을 재보니 470이 아니라 454px 이었습니다.
지난 시간에도 이 값을 확인하고 넘어갔죠. 964에서 좌우 여백 32와 간격 24를 빼고 둘로 나눈 값이라고요. 그 계산은 맞지만, 그때는 964라는 숫자를 먼저 두고 카드가 얼마가 되는지 확인하는 방향이었어요. 오늘은 반대로, 카드를 470으로 두고 통이 얼마여야 하는지를 구합니다.
차이가 생기는 이유는 main에 붙은 sm:px-4입니다. Preflight가 모든 요소를 border-box로 만들어 두기 때문에, max-w로 정한 964 안에 좌우 여백 16씩이 포함되니까, 안쪽에 남는 폭은 932이고 카드는 (932 − 24) ÷ 2 = 454가 됩니다.
max-w-[964px] 일 때
통 964 = 여백 16 + 안쪽 932 + 여백 16
안쪽 932 = 카드 454 + 간격 24 + 카드 454 ← 470 이 아니다
max-w-[996px] 일 때
통 996 = 여백 16 + 안쪽 964 + 여백 16
안쪽 964 = 카드 470 + 간격 24 + 카드 470 ← 의도대로
우리가 카드 폭을 470으로 잡아 뒀으니 두 열일 때도 470이어야 자연스럽죠. 통도 996으로 함께 올립니다.
이름을 주고 씁니다
지난 시간에 색에 이름을 줬던 것과 똑같이 합니다. 중단점도 @theme 안에서 이름을 얻어요.
/* apps/web-spa/src/styles/globals.css */
@theme {
/* 카드 470 두 장 + 사이 24 + 통의 좌우 여백 32 = 996px 부터 두 열이 들어간다 */
--breakpoint-2col: 62.25rem;
}
--breakpoint- 뒤에 붙인 이름이 그대로 변형 이름이 되고, 이제 2col:을 쓸 수 있어요.
// apps/web-spa/src/App.tsx
<main className="mx-auto max-w-[470px] py-4 sm:px-4 2col:max-w-[996px]">
// apps/web-spa/src/components/Feed.tsx
<List
className="2col:grid 2col:grid-cols-2 2col:gap-6"
// apps/web-spa/src/components/PostCard.tsx
className="mb-6 overflow-hidden rounded-lg border border-line bg-surface 2col:mb-0"
// apps/web-spa/src/components/SignUpForm.tsx
className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 2col:max-w-[470px]"
62.25rem은 996 ÷ 16입니다. 중단점은 rem으로 적는 게 기본인데, 사용자가 브라우저 기본 글자 크기를 키웠을 때 함께 따라가야 하기 때문이에요.
경계를 확인해봅시다
창 폭을 바꿔가며 재보면 996에서 정확히 갈립니다.
| 뷰포트 | 통 폭 | 열 | 카드 폭 | 카드 아래 여백 |
|---|---|---|---|---|
| 390 | 390 | 1 | 390 | 24px |
| 500 | 470 | 1 | 470 | 24px |
| 640 | 470 | 1 | 438 | 24px |
| 995 | 470 | 1 | 438 | 24px |
| 996 | 996 | 2 | 470 | 0px |
| 1024 | 996 | 2 | 470 | 0px |
| 1400 | 996 | 2 | 470 | 0px |
995 까지는 한 열이고 996부터 두 열인데, 그 순간 카드가 정확히 470이 됩니다. 우리가 계산한 숫자가 화면에 그대로 나온 겁니다.
500과 640 사이에서 카드가 470에서 438로 줄어드는 것도 보이실 텐데, 640부터 sm:px-4가 걸리면서 통 안쪽을 좌우 16씩 먹기 때문입니다. 방금 이야기한 그 border-box예요.
💡 한 줄 정리
중단점도 @theme 안에서 이름을 얻는 토큰입니다. 이름을 준다는 것보다 중요한 것은 그 숫자의 근거가 생긴다는 점이에요. 996은 우리 카드 폭과 간격에서 나온 값이라 누가 물어도 설명할 수 있습니다.
🙋 학생 질문 — "개발자 도구에서 클래스 이름이 .\32 col\:mb-0이라고 이상하게 보여요"
정상입니다. CSS 클래스 이름은 숫자로 시작할 수 없는데, 우리가 2col이라고 지었으니 2로 시작해서 Tailwind가 앞의 2를 CSS 이스케이프 표기인 \32 로 바꿔서 내보냅니다. 뒤에 붙은 공백까지가 한 글자를 뜻하는 표기라서 .\32 col\:mb-0처럼 보이고, : 앞의 역슬래시도 같은 이유예요.
Tailwind가 기본으로 주는 2xl:도 똑같이 .\32 xl\:로 나오니, 우리만 겪는 일이 아닙니다. 브라우저는 이 표기를 원래 이름으로 되돌려 읽어서, 동작에는 아무 문제가 없습니다.
이름이 눈에 거슬리면 숫자로 시작하지 않게 지으면 됩니다. wide 나 twocol 같은 이름은 이스케이프 없이 그대로 나오지만, 2col은 "두 열"이라고 바로 읽히니까 저는 뜻이 분명한 쪽을 골랐습니다.
Step 2: "그런데 이 토큰만 :root에 없다"
지난 시간에 우리가 만든 토큰이 어디로 갔는지 확인했던 것 기억하시죠. 개발자 도구에서 html을 선택하면 :root에 우리 색이 전부 들어 있었는데, 오늘은 방금 만든 토큰으로 같은 자리를 다시 확인해봅니다.
:root, :host {
--color-brand: oklch(65.46% 0.177 248.2);
--color-surface: oklch(100% 0 none);
...
}
방금 만든 --breakpoint-2col도 여기 있어야겠죠. 그럼 찾아봅시다.
없습니다
:root를 아무리 훑어도 --breakpoint-2col이 없는데, 우리가 안 만든 것도 아니고 2col:은 화면에서 멀쩡히 동작하는데도요.
빌드된 CSS 파일 전체에서 --breakpoint라는 글자를 찾아도 한 건도 안 나오는데, 색 토큰은 아홉 개가 전부 그대로 있는데 말이죠.
대신 이런 것이 있습니다.
@media (width>=62.25rem) {
.\32 col\:mb-0 { margin-bottom: 0; }
}
값이 변수로 남지 않고 62.25rem 이라는 숫자 그대로 미디어 조건 안에 들어가 있어요.
왜 이것만 다를까요
색 토큰과 견줘보면 차이가 뚜렷합니다.
색 토큰 .bg-surface { background-color: var(--color-surface) }
└─ 변수로 남는다
중단점 토큰 @media (width>=62.25rem) { ... }
└─ 값이 그대로 박힌다
이유는 CSS 자체의 성질인데, 미디어 쿼리의 조건에는 var()를 쓸 수 없기 때문이에요.
/* 이건 동작하지 않습니다 */
@media (width >= var(--breakpoint-2col)) { ... }
변수는 요소에 스타일을 적용할 때 값이 정해지지만, 미디어 쿼리는 어떤 요소를 그리기 훨씬 전에 이 규칙 뭉치를 쓸지 말지부터 정해야 해요. 그 시점에는 아직 참조할 요소가 없으니 변수를 읽을 방법이 없고, 그래서 Tailwind는 선택의 여지가 없어요. 빌드할 때 --breakpoint-2col의 값을 꺼내서 조건 안에 직접 써넣습니다. 변수를 내보내 봐야 미디어 쿼리가 쓸 수 없으니 내보내지 않는 거고요.
⚠️ 그래서 중단점 토큰은 런타임에 바꿀 수 없습니다. 색은 자바스크립트로 --color-brand를 바꾸면 화면이 즉시 따라오지만, 중단점은 이미 숫자로 굳어 있어서 바꿀 대상이 없어요.
조건들이 출력되는 순서를 다시 봅시다
지난 시간에 조건들이 파일 안에서 정해진 순서로 늘어선다고 했는데, 우리가 만든 중단점이 그 줄의 어디에 들어가는지 오늘 확인해봅시다.
출력 순서 (앞 → 뒤)
(hover:hover) ← 상태 변형
(width>=900px) ← min-[900px]: 임의로 적은 값
(width>=40rem) ← sm 640
(width>=62.25rem) ← 2col 996 우리가 만든 이름
(width>=64rem) ← lg 1024
우리가 만든 2col이 sm과 lg 사이에 들어갔습니다. 이름이 무엇인지는 상관없고 값의 크기순으로 자리를 찾아가서, 이름을 붙인 중단점끼리는 크기 순서대로 예측할 수 있습니다.
⚠️ 그런데 min-[900px]:처럼 임의로 적은 값은 다릅니다. 900px 인데도 640px 인 sm보다 앞에 나와서, 이름 붙은 중단점 무리보다 통째로 앞에 몰립니다.
앞에 있다는 것은 뒤에 있는 것에게 진다는 뜻이라서, 두 조건이 모두 참일 때 sm:이 min-[900px]:을 이깁니다. 900이 640보다 큰데도요.
💡 한 줄 정리
중단점 토큰은 :root에 안 나옵니다. 미디어 쿼리 조건이 var()를 못 쓰기 때문에 빌드할 때 값이 숫자로 굳어서, 색과 달리 런타임에 바꿀 수 없습니다.
🙋 학생 질문 — "그러면 중단점 값을 자바스크립트에서 읽고 싶을 땐 어떻게 하나요?"
좋은 질문입니다. 실제로 필요한 경우가 있는데, 예를 들어 "두 열일 때만 어떤 데이터를 더 불러오자" 같은 판단이요.
getComputedStyle로 --breakpoint-2col을 읽는 방법은 안 되는데, 방금 본 것처럼 그 변수가 내보내지지 않으니 빈 문자열이 돌아와요.
방법은 두 가지입니다. 하나는 미디어 쿼리를 자바스크립트에서 직접 물어보는 겁니다. window.matchMedia('(width >= 62.25rem)')처럼요. 다만 이러면 996이라는 숫자가 CSS와 자바스크립트 두 곳에 적히게 되어서, 한쪽을 고치고 다른 쪽을 잊으면 어긋납니다.
다른 하나는 값을 일반 변수로도 한 번 더 선언해두는 겁니다.
@theme {
--breakpoint-2col: 62.25rem;
}
:root {
--bp-2col: 62.25rem;
}
이러면 아래쪽은 평범한 변수라 :root에 나오고 자바스크립트가 읽을 수 있는데, 대신 같은 숫자를 두 번 적는 셈이라 둘이 어긋나지 않게 관리해야 합니다.
어느 쪽이든 숫자가 두 군데 생기는 것은 피하기 어렵습니다. 그래서 저는 되도록 자바스크립트가 화면 폭을 몰라도 되게 만드는 쪽을 먼저 고민하시길 권합니다. 지난 시간에 배치를 CSS 만으로 바꿨던 것처럼요.
Step 3: "조건이 화면 밖에 있을 때 — dark:"
지금까지 배운 조건들을 보면 공통점이 있습니다.
disabled:는 요소가 잠겼는지를 봤고, hover:는 마우스가 그 위에 있는지를 봤고요. sm:과 2col:은 창이 얼마나 넓은지를 봤는데, 전부 우리 화면 안에서 일어나는 일이죠.
오늘 만날 조건은 화면 밖에 있는데, 사용자가 자기 운영체제를 어두운 모드로 쓰고 있는지 여부요.
설정이 필요 없습니다
dark:를 그냥 쓰면 되는데, globals.css에 아무것도 안 적었는데도 동작해요.
<span className="dark:text-brand">어두울 때만 파랑</span>
빌드된 CSS를 열어보면 이렇게 나옵니다.
@media (prefers-color-scheme: dark) {
.dark\:text-brand { color: var(--color-brand); }
}
prefers-color-scheme은 브라우저가 운영체제 설정을 그대로 전해주는 값입니다. 사용자가 macOS 나 Windows 나 안드로이드에서 어두운 모드를 켜두면 dark가 되고, 아니면 light가 돼요.
즉 우리가 아무것도 안 해도 사용자의 취향이 이미 브라우저까지 와 있어서, 우리는 그 값을 조건으로 쓰기만 하면 되고요.
비워뒀던 마지막 순서
지난 시간 순서 그림에서 제가 하나를 비워뒀는데, 이제 채웁니다.
출력 순서 (앞 → 뒤)
조건 없음
상태 변형 (disabled: hover: focus-visible:)
max-* 방향
임의 중단점 (min-[900px]:)
이름 붙은 중단점 (sm: 2col: lg: …)
(prefers-color-scheme: dark) ← 여기였습니다
dark:가 가장 뒤라서, 다른 조건과 같은 속성을 다투면 dark:가 이겨요. sm:text-ink dark:text-brand를 함께 쓰면 넓은 화면이면서 어두운 모드일 때 파랑이 됩니다.
⚠️ 여기서 한 번 크게 헷갈립니다
dark:를 배우면 자연스럽게 이렇게 써보게 됩니다.
<article className="bg-surface dark:bg-surface">
"평소엔 surface, 어두울 땐 surface"라고 읽혀서 뭔가 이상하지만, 그럼 이건 어떨까요.
<article className="bg-surface dark:bg-canvas">
어두울 때는 canvas가 되는데, canvas는 지금 거의 흰색이라 어두운 화면에서 흰 카드가 나옵니다.
문제의 뿌리를 보려면 생성된 규칙을 봐야 합니다.
.bg-canvas { background-color: var(--color-canvas); }
.dark\:bg-canvas{ background-color: var(--color-canvas); }
같은 변수를 가리키는데, dark:는 "언제 걸릴지"만 정할 뿐 색을 어둡게 만들어 주지는 않아요. 우리 팔레트에는 아직 어두운 값이 하나도 없으니, 어떤 이름을 골라 붙여도 밝은 색만 나옵니다.
🌟 그러니 다크모드의 진짜 질문은 "어디에 dark:를 붙일까"가 아닙니다. "어두운 값을 어디에 둘까"예요. 다음 두 Step에서 두 가지 답을 각각 만들어 보고 견주겠습니다.
💡 한 줄 정리
dark:는 설정 없이 바로 쓸 수 있고 운영체제 설정을 따라가지만, 조건만 만들어 줄 뿐 값은 안 만들어요. 어두운 값을 어디엔가 두는 것은 우리 몫입니다.
🙋 학생 질문 — "dark:hover:랑 hover:dark:는 뭐가 다른가요?"
결과는 같고 생긴 것만 다른데, 둘 다 "어두운 모드이면서 마우스를 올렸을 때"예요.
다만 감싸는 순서가 반대로 나옵니다.
/* dark:hover:text-brand */
@media (prefers-color-scheme: dark) {
@media (hover: hover) { ... }
}
/* hover:dark:text-danger */
@media (hover: hover) {
@media (prefers-color-scheme: dark) { ... }
}
읽는 방향은 왼쪽부터 바깥이라고 보시면 됩니다. 두 조건이 모두 참이어야 걸리는 것은 어느 쪽이나 같으니, 매칭 결과는 정말로 같지만, 파일 안에서 놓이는 위치가 달라져서 아주 드물게 같은 속성을 다투는 다른 규칙이 그 사이에 있으면 승부가 갈릴 수 있어요.
실무에서는 팀 안에서 한 방향으로 통일해 두는 편이 나은데, 저는 조건이 넓은 것부터 적는 쪽을 씁니다. dark:가 화면 전체를 가르는 조건이니 먼저 쓰고, 그 안에서 hover: 같은 세부 조건을 뒤에 붙이는 식이죠. 코드 리뷰에서 눈으로 찾기가 쉬워집니다.
Step 4: "다크 값을 어디에 둘까 ① 유틸리티마다 붙이기"
첫 번째 방법부터 해보는데, 배운 대로 dark:를 붙이는 겁니다.
먼저 어두운 값에 이름이 필요합니다
방금 확인했듯이 dark:bg-canvas는 원래 canvas를 그대로 가리켜서, 어두운 값을 담을 새 이름부터 만들어야 합니다.
/* apps/web-spa/src/styles/globals.css */
@theme {
--color-canvas-dark: oklch(0% 0 none);
--color-surface-dark: oklch(18.2% 0 none);
--color-ink-dark: oklch(97% 0 none);
--color-muted-dark: oklch(73.2% 0 none);
--color-subtle-dark: oklch(83% 0 none);
--color-line-dark: oklch(33.3% 0 none);
--color-line-soft-dark: oklch(26.9% 0 none);
}
일곱 개가 늘었는데, 값을 어떻게 골랐는지는 Step 6에서 다루고 지금은 방법 자체에 집중할게요.
그다음 색을 쓰는 곳마다 짝을 붙입니다
// apps/web-spa/src/components/PostCard.tsx
className="mb-6 overflow-hidden rounded-lg border border-line dark:border-line-dark bg-surface dark:bg-surface-dark 2col:mb-0"
동작합니다. 어두운 모드에서 카드가 제대로 어두워져서, 방법 자체는 옳습니다.
문제는 여기가 한 곳이 아니라는 점이죠.
세어보면 이렇습니다
색을 쓰는 곳을 전부 세어보면 29곳, 14개 파일입니다.
index.html 2 LikeButton 2
App 1 MiniPostCard 4
ClickCounter 2 PostBody 2
CommentForm 1 PostCard 2
CommentList 2 Section 1
KeyDemo 4 SignUpForm 2
StaticPostCard 2 TextField 2
29곳에 짝을 붙이고 나면 화면은 완성되는데, 그 대가를 한번 봅시다.
| 값 | |
|---|---|
| 새로 만든 이름 | 7개 |
| 손댄 곳 | 29곳 |
| 손댄 파일 | 14개 |
| CSS 크기 | 11,171 → 11,899 B (+728) |
더 큰 문제는 끝나지 않는다는 것인데, 다음 주에 새 컴포넌트를 만들면 거기서도 bg-surface 옆에 dark:bg-surface-dark를 또 적어야 해요. 한 번이라도 빠뜨리면 그 요소만 어두운 화면에서 하얗게 남습니다.
⚠️ 빠뜨린 것을 알아차리기도 어려운데, 개발할 때 밝은 모드로 보고 있으면 아무 문제가 없거든요. 지난 시간에 포커스 표시를 한 곳 빠뜨렸던 것과 똑같은 종류의 사각지대입니다.
이름도 두 배로 늘었죠. surface와 surface-dark는 결국 같은 것을 뜻하는데 이름이 둘이고, 세 번째 테마가 생기면 셋이 되고요.
💡 한 줄 정리
dark: 유틸리티를 컴포넌트마다 붙이는 방법은 동작합니다. 다만 색을 쓰는 모든 곳을 찾아다녀야 하고, 새 컴포넌트를 만들 때마다 다시 해야 해요. 빠뜨려도 밝은 모드에서는 안 보입니다.
🙋 학생 질문 — "그럼 이 방법은 쓰면 안 되는 건가요?"
아니요, 쓸 자리가 분명히 있는데, 지금 우리 경우에 안 맞을 뿐이에요.
갈리는 기준은 전체 규칙이냐 한 곳의 예외냐입니다.
우리가 지금 하려는 것은 "모든 카드 배경이 어두울 때 어두워진다"처럼 앱 전체에 걸리는 규칙이라서, 이런 것을 29곳에 나눠 적으면 규칙이 흩어집니다.
반대로 "이 배너 하나만 어두운 모드에서 테두리를 살짝 밝게" 같은 국소 예외라면 dark: 유틸리티가 딱 맞습니다. 그 한 줄을 보면 예외라는 게 바로 보이고, 토큰을 건드려서 다른 곳까지 흔들 이유도 없으니까요.
그래서 실무에서는 둘을 같이 씁니다. 전체를 움직이는 것은 토큰으로, 한 곳의 예외는 dark: 유틸리티로요. 다음 Step에서 만들 방법이 앞쪽이고, 오늘 만든 이 방법은 뒤쪽에 남겨두시면 됩니다.
Step 5: "다크 값을 어디에 둘까 ② 이름은 그대로, 값만"
다시 생각해보면, bg-surface가 하는 일이 뭐죠.
.bg-surface { background-color: var(--color-surface); }
--color-surface를 보고 있을 뿐이라서, 그 변수에 뭐가 들어 있는지는 bg-surface가 정하지 않아요.
그렇다면 컴포넌트를 하나도 안 건드리고, 저 변수의 값만 어두울 때 바꿔치면 어떨까요. bg-surface라고 적힌 여덟 곳이 전부 따라올 텐데요.
방금 지운 것을 되돌리고, 대신 이렇게 씁니다
Step 4에서 붙인 29곳을 전부 원래대로 돌리고, 새로 만든 이름 일곱 개도 지웁니다. 대신 globals.css에 일곱 줄을 더합니다.
/* apps/web-spa/src/styles/globals.css */
@media (prefers-color-scheme: dark) {
:root {
--color-canvas: oklch(0% 0 none);
--color-surface: oklch(18.2% 0 none);
--color-ink: oklch(97% 0 none);
}
}
이게 전부인데, 어두운 모드로 바꿔보면 페이지 바닥이 검어지고 카드가 그보다 살짝 밝은 회색이 되고 글자가 하얘집니다.
컴포넌트는 한 글자도 안 고쳤습니다. PostCard는 여전히 bg-surface 라고만 적혀 있어요.
왜 우리가 쓴 값이 이기나요
@theme도 :root에 값을 넣었고, 우리도 :root에 값을 넣었습니다. 같은 변수를 두고 둘이 다투는 셈이죠.
지난 시간에 배운 것을 떠올려 보면 "나중에 쓴 것이 이긴다"라고 답하고 싶어지지만, 그게 아니에요. 먼저 써도 이깁니다.
확인해보죠. 순서를 뒤집어서 우리 것을 먼저, @theme 쪽을 나중에 두고 재봤습니다.
/* 먼저 쓴 쪽 — layer 밖 */
#probe { --c: rgb(10, 10, 10); }
/* 나중에 쓴 쪽 — layer 안 */
@layer theme { #probe { --c: rgb(200, 200, 200); } }
결과는 rgb(10, 10, 10)입니다. 먼저 쓴 쪽이 이겼어요.
이유는 지지난 시간에 우리를 괴롭혔던 그 규칙입니다. cascade layer밖에 있는 스타일은 layer 안에 있는 것을 이깁니다. 순서를 따지기 전에 이미 결판이 나요.
@layer theme { :root, :host { --color-surface: 흰색 } } ← layer 안
@media (...) { :root { --color-surface: 어두움 } } ← layer 밖 이김
@theme이 내보내는 값은 theme이라는 layer 안에 들어갑니다. 우리가 globals.css에 그냥 쓴 :root는 어느 layer에도 안 들어가고요.
🌟 지지난 시간에는 이 규칙 때문에 손으로 쓴 CSS가 유틸리티를 이겨서 골치였죠. 같은 규칙이 이번에는 우리 편으로 일합니다. 기본값 위에 우리 값을 얹기가 쉬워지니까요.
두 방법을 나란히 놓고 봅시다
| 유틸리티마다 | 토큰 한 곳에서 | |
|---|---|---|
| 새로 만든 이름 | 7개 | 0개 |
| 손댄 곳 | 29곳 | 1곳 |
| 손댄 파일 | 14개 | 1개 |
| CSS 증가 | +728 B | +282 B |
| 새 컴포넌트를 만들면 | 또 붙여야 함 | 그냥 따라옴 |
마지막 줄이 가장 중요합니다. 앞으로 만들 컴포넌트가 bg-surface를 쓰기만 하면 다크모드는 이미 되어 있어요. 아무도 잊어버릴 수 없습니다.
canvas와 surface가 드디어 갈라집니다
지지난 시간에 이름만 지어두고 안 갈라놓은 두 가지가 있었는데, canvas는 페이지 바닥이고 surface는 그 위에 얹히는 카드 배경이라 둘 다 거의 흰색이었고 나눌 이유가 없어 보였습니다.
이제 확실히 갈립니다.
밝을 때 canvas #fafafa ─ surface #ffffff 거의 안 갈림
어두울 때 canvas #000000 ─ surface #121212 카드가 바닥에서 떠 보임
어두운 화면에서는 테두리보다 밝기 차이가 카드를 구분해 줘서, 두 이름을 나눠둔 것이 이제야 값을 합니다. 만약 지지난 시간에 둘을 --color-white 하나로 합쳤다면, 지금 이 두 곳을 다시 갈라내야 했을 거예요.
💡 한 줄 정리
유틸리티는 변수를 보고 있으니, 변수 값만 갈아끼우면 그 이름을 쓰는 모든 곳이 한꺼번에 따라옵니다. @theme 값을 이기는 이유는 순서가 아니라 cascade layer예요. 우리가 쓴 :root는 layer 밖이라 먼저 써도 이깁니다.
🙋 학생 질문 — "@theme 안에서 바로 다크 값을 주면 안 되나요?"
이렇게 써보고 싶으셨을 것 같아요.
@theme {
--color-surface: oklch(100% 0 none);
@media (prefers-color-scheme: dark) {
--color-surface: oklch(18.2% 0 none);
}
}
@theme은 이런 식으로 조건을 품도록 만들어진 문법이 아닙니다. 여기 적는 것은 "우리 팔레트에 이런 이름들이 있다"는 목록이에요. 이 목록에서 유틸리티 클래스가 만들어지니까 조건이 섞이면 무엇을 만들어야 할지 정할 수 없어서, 역할을 이렇게 나눠 생각하시면 편합니다.
@theme 어떤 이름들이 있는가 (유틸리티가 여기서 만들어진다)
:root 그 이름이 지금 무슨 값인가 (조건에 따라 바뀔 수 있다)
이름을 만드는 곳과 값을 정하는 곳이 갈라져 있는 거라서, 다크 값은 @theme 밖의 :root에 씁니다.
한 가지 더 알아두시면 좋은 것이 있어요. @theme에 이름을 만들어두면 유틸리티가 생기고, 그 유틸리티는 var()로 값을 읽어서 나중에 :root에서 값을 바꿔도 유틸리티는 다시 만들 필요가 없어요. 오늘 우리가 한 일이 정확히 이겁니다.
Step 6: "다크 값은 눈대중으로 고르지 않는다"
이제 나머지 네 개를 채우는데, muted, subtle, line, line-soft요.
여기서 흔한 실수가 하나 있는데, "어두우니까 대충 밝게 하면 되겠지" 하고 눈으로 골라버리는 거예요. 지지난 시간에 색에 이름을 줄 때도 눈대중을 금지했었죠. 같은 이유가 여기서도 적용됩니다.
옮겨야 하는 것은 밝기가 아니라 대비입니다
밝은 화면에서 각 회색이 카드 위에서 어느 정도로 읽혔는지 재보면 이렇습니다.
| 토큰 | 밝을 때 대비 |
|---|---|
ink |
15.13 |
subtle |
4.74 |
muted |
3.28 |
line |
1.38 |
ink는 본문 글자라 아주 진하고, line은 테두리라 있는 듯 없는 듯합니다. 이 관계가 어두운 화면에서도 유지돼야, 사용자가 느끼는 위계가 같습니다.
그래서 값을 고를 때 "얼마나 밝게 할까"가 아니라 "이 대비를 어떻게 다시 만들까"로 물어야 합니다.
⚠️ 밝기 순서가 뒤집힙니다
여기서 한 번 헷갈리는데, 밝은 화면에서는 어두울수록 또렷하고 어두운 화면에서는 밝을수록 또렷하죠.
그래서 값을 그대로 밝게만 옮기면 위계가 조용히 뒤집히는데, 예를 들어 볼게요.
밝을 때 subtle #737373 (어둡다 = 또렷)
muted #8e8e8e (밝다 = 흐림)
→ subtle 이 muted 보다 또렷하다
값을 그대로 뒤집으면
subtle 이 muted 보다 어두워진다
→ 어두운 화면에서는 subtle 이 더 흐려진다 뒤집혔다
subtle은 폼 라벨에 쓰고 muted는 보조 문구에 씁니다. 라벨이 보조 문구보다 흐려지면 폼이 읽기 어려워져서, 어두운 화면에서는 subtle을 muted보다 밝게 잡아야 원래 관계가 지켜집니다.
정한 값
/* apps/web-spa/src/styles/globals.css */
@media (prefers-color-scheme: dark) {
:root {
--color-canvas: oklch(0% 0 none);
--color-surface: oklch(18.2% 0 none);
--color-ink: oklch(97% 0 none);
/* 회색 넷은 밝기를 뒤집되 서열은 그대로 둔다.
라이트에서 subtle 이 muted 보다 어두워 더 또렷했으므로
다크에서는 subtle 이 muted 보다 밝아야 한다. */
--color-muted: oklch(73.2% 0 none);
--color-subtle: oklch(83% 0 none);
--color-line: oklch(33.3% 0 none);
--color-line-soft: oklch(26.9% 0 none);
}
/* brand·danger 는 안 바꾼다 — 브랜드색은 양쪽 화면에서 같아야 한다 */
}
재본 결과입니다.
| 토큰 | 밝을 때 | 어두울 때 |
|---|---|---|
ink |
15.13 | 17.18 |
subtle |
4.74 | 11.08 |
muted |
3.28 | 7.88 |
line |
1.38 | 1.55 |
진한 순서가 양쪽에서 똑같은데, ink → subtle → muted → line이에요. 값은 반대편으로 갔는데 서열은 그대로죠.
안 바꾼 것도 있습니다
brand와 danger는 그대로 뒀습니다. 어두운 모드에서도 인스타그램 파랑은 같은 파랑이어야 하니까요. 브랜드 색이 화면 설정에 따라 달라지면 그건 다른 브랜드입니다.
실제로 확인해보면 어두운 화면에서도 가입하기 버튼은 0,149,246 그대로예요. 다크모드를 만든다고 해서 모든 값을 바꿀 필요는 없습니다.
⚠️ 그런데 표에서 눈에 걸리는 것이 하나 있죠. muted가 밝을 때 3.28입니다. 본문 글자에 권장되는 4.5에 못 미쳐요. 어두울 때는 7.88로 넉넉한데 말이죠.
이건 오늘 만든 문제가 아닙니다. 원래부터 그랬는데, 어두운 값을 계산하면서 나란히 놓고 보니까 드러난 겁니다. 다크모드를 만들다가 밝은 화면의 약점을 발견한 셈이죠. 이 문제를 어떻게 다룰지는 오늘 생각해볼 주제로 남기겠습니다.
💡 한 줄 정리
다크 값은 밝기를 짐작해서 정하는 게 아니라 밝은 화면에서 재둔 대비를 다시 만드는 겁니다. 밝기 방향이 뒤집히므로 회색끼리의 서열이 유지되는지 반드시 확인해야 해요.
🙋 학생 질문 — "canvas를 왜 완전한 검정으로 했나요? 눈이 아프다고 들었는데요"
좋은 지적입니다. 실제로 논쟁이 있는 부분이에요.
완전한 검정 배경에 아주 밝은 흰 글자를 올리면 대비가 지나치게 높아서 글자 경계가 번져 보인다는 이야기가 있습니다. 그래서 많은 디자인 시스템이 #121212 같은 아주 어두운 회색을 페이지 배경으로 씁니다.
우리가 검정을 고른 이유는 canvas와 surface를 갈라 보여주기에 좋아서입니다. 바닥이 검정이고 카드가 #121212 면 카드가 떠 보이는데, 글자도 완전한 흰색으로 두지 않았어요. ink가 #f5f5f5라 살짝 낮췄습니다. 번짐 문제는 배경보다 글자 쪽에서 더 크게 갈리거든요.
만약 눈이 편한 쪽을 우선한다면 canvas를 #121212, surface를 #1e1e1e 쯤으로 올리시면 됩니다. 오늘 배운 방법대로면 두 줄만 고치면 되고, 나머지는 전부 따라옵니다. 이런 판단을 나중에 싸게 바꿀 수 있다는 것이 토큰의 값어치예요.
Step 7: "값만 갈아끼워서 안 되는 곳"
여기까지 하면 화면 대부분이 어두워지는데, 전부는 아니에요.
토큰 값을 바꾸는 방식은 그 토큰을 쓰고 있는 것만 움직입니다. 토큰을 안 쓰는 곳은 그대로 남아요. 어두운 모드로 화면을 훑으면서 남은 것들을 찾아봅시다.
알림 상자가 사라집니다
좋아요를 누르면 아래쪽에 알림이 뜨는데, 그 코드를 봅시다.
// apps/web-spa/src/components/Toast.tsx
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-[20px] bg-black/82 px-4.5 py-2.5 text-sm text-white"
bg-black/82는 우리 토큰이 아닙니다. Tailwind가 원래 갖고 있는 검정이에요. 그러니 우리가 --color-*를 아무리 갈아끼워도 여기는 안 움직입니다.
밝은 화면에서는 문제가 없었습니다. 흰 페이지 위에 검은 상자가 뜨니 잘 보였죠. 그런데 어두운 화면에서는 페이지가 검정이 됩니다.
밝을 때 페이지 250,250,250 상자 45,45,45 잘 보인다
어두울 때 페이지 0, 0, 0 상자 0, 0, 0 상자와 배경이 같은 색
상자와 배경의 대비가 1.00입니다. 완전히 같은 색이에요. 글자는 흰색이라 읽히지만 상자의 윤곽이 통째로 사라져서, 알림이 아니라 글자가 허공에 떠 있는 것처럼 보입니다.
서로 뒤집히는 이름으로 고칩니다
어두운 값 하나를 더 만들어서 dark:로 붙이는 방법도 있지만, 더 나은 방법이 있어요.
이 상자가 원하는 것은 "페이지와 반대"입니다. 밝은 화면에서는 어둡고, 어두운 화면에서는 밝아야 해요.
우리 팔레트에 이미 서로 뒤집히는 짝이 있죠. ink와 canvas입니다. ink는 밝을 때 어둡고 어두울 때 밝아요. canvas는 정확히 그 반대고요.
// apps/web-spa/src/components/Toast.tsx
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-[20px] bg-ink/90 px-4.5 py-2.5 text-sm text-canvas"
이제 상자가 저절로 반전됩니다.
| 상자 | 페이지 | 대비 | |
|---|---|---|---|
| 밝을 때 | 59,59,59 | 250,250,250 | 10.73 |
| 어두울 때 | 약 220 | 0,0,0 | 15.4 |
새 이름을 안 만들고 dark:도 안 붙였는데 양쪽에서 잘 보여요. 이미 있는 두 이름의 관계를 이용한 겁니다.
⚠️ 정직하게 짚을 것이 하나 있습니다. 밝은 화면에서 상자가 45,45,45에서 59,59,59로 아주 조금 밝아졌어요. 검정 대신 ink를 깔았으니까요. 대비는 13.19에서 10.73으로 내려갔지만 여전히 넉넉합니다. 이 정도 변화를 받아들일지는 디자이너와 함께 정할 일이에요.
반쪽만 따라와서 부딪히는 곳
더 고약한 것이 있습니다. 회원가입 폼 맨 위의 프로필 사진 고르기 버튼이에요. 지지난 시간 과제로 남겨둔 것이라 아직 손으로 쓴 CSS를 쓰고 있습니다.
/* apps/web-spa/src/styles/globals.css */
.profile-picker-button {
border: 1px solid #dbdbdb;
border-radius: 6px;
background: #fafafa;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
여기엔 글자색이 없어서, 글자색은 body에 걸린 text-ink를 물려받습니다.
어두운 모드에서 무슨 일이 일어나는지 보세요.
배경 #fafafa 손으로 적은 값 안 움직인다 → 250,250,250
테두리 #dbdbdb 손으로 적은 값 안 움직인다 → 219,219,219
글자 (없음) ink 를 물려받음 따라 움직인다 → 245,245,245
한 요소 안에서 반쪽만 따라온 겁니다. 배경은 흰색으로 남고 글자만 밝아져서, 흰 버튼 위에 흰 글씨가 됐어요. 글자와 배경의 대비가 1.04입니다. 밝은 화면에서는 14.50 이었는데요.
이게 손으로 쓴 값을 남겨두면 생기는 진짜 위험입니다. 그냥 안 바뀌는 것보다 나빠요. 안 바뀌기만 하면 어색할 뿐인데, 반쪽만 바뀌면 읽을 수 없게 됩니다.
이 버튼은 오늘 고치지 않고 과제로 남기겠습니다. 지금 화면에서 직접 확인해보시라고요.
반대로 저절로 잘 따라오는 것도 있습니다
지난 시간에 잠긴 게시 버튼 색을 disabled:text-brand/30으로 옮기면서, 어두운 배경 위에서는 값이 갈린다고 하고 미뤄뒀죠. 이제 확인합니다.
밝을 때 카드 배경 255,255,255 → 잠긴 글자 179,223,252 흐린 하늘색
어두울 때 카드 배경 18, 18, 18 → 잠긴 글자 13, 57, 86 흐린 남색
알파로 적은 색은 뒤에 무엇이 오든 그것에 섞입니다. 그래서 배경이 바뀌면 저절로 따라와요. 우리가 아무것도 안 했는데 양쪽에서 자연스럽게 흐려집니다.
만약 지난 시간에 이걸 #b2dffc라는 값으로 그냥 적었다면 어땠을까요. 어두운 화면에서도 밝은 하늘색이 그대로 떠서 잠긴 버튼이 오히려 더 눈에 띄었을 겁니다.
🌟 그러니 알파는 다크모드에서 손해가 아니라 이득이에요. "값을 못 바꾸는 것"이 아니라 "배경을 따라가는 것"입니다.
그리고 아예 못 바꾸는 것
사진은 어떻게 할 수 없습니다. 사용자가 올린 밝은 사진은 어두운 화면에서도 밝아요.
이건 고칠 대상이 아닙니다. 인스타그램 실제 앱도 사진을 어둡게 하지 않아요. 사진은 콘텐츠 그 자체라, 우리가 색을 건드리면 그건 다른 사진이 되니까요. 다크모드가 손댈 수 없는 영역이 있다는 것을 알아두시면 됩니다.
💡 한 줄 정리
토큰 값을 바꾸는 방식은 그 토큰을 쓰는 것만 움직입니다. 손으로 적은 값은 안 따라오고, 상속받는 속성만 따라와서 한 요소 안에서 부딪히기도 해요. 다크모드를 켜본다는 것은 곧 토큰을 안 쓰는 곳을 찾아내는 일입니다.
🙋 학생 질문 — "어두운 모드를 켤 방법이 없는데 어떻게 확인하나요?"
세 가지 방법이 있습니다.
가장 간단한 것은 운영체제 설정을 바꾸는 겁니다. macOS는 시스템 설정의 화면 항목에서, Windows는 개인 설정의 색 항목에서 바꿀 수 있지만, 화면 전체가 바뀌니 왔다 갔다 하기엔 번거롭습니다.
두 번째는 개발자 도구입니다. 크롬 개발자 도구에서 Cmd+Shift+P를 누르고 dark를 입력하면 어두운 모드를 흉내 내는 항목이 나와요. 브라우저 안에서만 바뀌니 이 방법이 제일 편합니다.
세 번째는 조건을 잠깐 없애보는 겁니다.
/* @media (prefers-color-scheme: dark) { */
:root { --color-canvas: oklch(0% 0 none); ... }
/* } */
조건 줄을 주석으로 감싸면 항상 어두운 값이 걸립니다. 확인이 끝나면 되돌리면 되고요. 여러 화면을 훑어야 할 때 이 방법이 빠릅니다.
어느 방법을 쓰든 중요한 것은 한 번은 반드시 켜본다는 겁니다. 오늘 찾은 두 가지 문제는 켜보지 않았으면 아무도 몰랐을 거예요.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 이름은 그대로 두고 값을 갈아끼웠습니다.
지난 시간까지는 유틸리티에 조건을 붙여서 "언제 걸릴지"를 말했죠. 오늘은 조건에 따라 "무슨 값일지"가 달라지게 만들었습니다.
오늘 배운 핵심 세 가지
💡 하나 — 모든 토큰이 같은 방식으로 나오지는 않습니다. 색 토큰은 :root에 변수로 남아 런타임에 바꿀 수 있지만, 중단점 토큰은 값이 숫자로 굳어 사라집니다. 미디어 쿼리 조건이 변수를 못 읽기 때문이에요.
💡 둘 — 같은 목적지에 두 가지 길이 있고, 고르는 기준은 범위입니다. 앱 전체에 걸리는 규칙은 토큰 값을 한 곳에서 바꾸고, 한 요소만의 예외는 dark: 유틸리티로 붙입니다. 전체 규칙을 29곳에 나눠 적으면 새 컴포넌트를 만들 때마다 잊어버릴 수 있어요.
💡 셋 — 다크모드를 켜보는 것은 토큰을 안 쓰는 곳을 찾는 일입니다. 손으로 적은 값은 따라오지 않고, 그중에서도 반쪽만 따라오는 것이 가장 위험해요. 흰 버튼 위의 흰 글씨는 밝은 화면에서 아무리 봐도 안 보입니다.
시작할 때 남아 있던 두 가지
오프닝에서 지난 시간이 남긴 두 가지를 이야기했죠. 어떻게 닫혔는지 봅시다.
| 남아 있던 것 | 닫힌 곳 |
|---|---|
| 화면 크기 기준을 우리가 정하려면 | Step 1 — --breakpoint-2col: 62.25rem |
| 모든 조건보다 뒤에 오는 것 하나 | Step 3 — dark: |
그리고 지지난 시간에 이름만 지어두고 안 갈라놨던 canvas와 surface도 오늘 갈라졌습니다.
다음 시간 예고
오늘 우리 다크모드는 운영체제 설정을 그대로 따릅니다. 사용자가 시스템을 밝게 쓰면 우리 화면도 밝아요.
그런데 실제 서비스에는 대개 해와 달 모양의 버튼이 있죠. 시스템 설정과 상관없이 이 사이트만 어둡게 보고 싶은 사람이 있으니까요.
그러려면 조건이 달라져야 합니다. prefers-color-scheme은 우리가 읽을 수만 있고 바꿀 수는 없는 값이거든요.
대신 dark:라는 변형이 무슨 뜻인지를 우리가 다시 정할 수 있습니다. 지난 시간에 hover: 이야기를 하면서 변형을 손수 만드는 문법이 있다고 했죠. 그게 이겁니다.
@custom-variant dark (&:where(.dark, .dark *));
이 한 줄은 dark:라는 변형의 뜻을 "운영체제가 어두울 때"에서 "그 요소나 위쪽 어딘가에 dark라는 표시가 붙어 있을 때"로 바꾸는데, 이 줄만 넣으면 우리 화면은 꿈쩍도 안 해요. 우리는 Step 5에서 토큰 방식을 골라서 dark: 유틸리티를 한 개도 안 쓰고 있거든요. 실제로 옮겨야 하는 것은 오늘 쓴 그 @media (prefers-color-scheme: dark) 블록입니다. 그 조건을 표시 쪽으로 바꾸면 토글이 화면을 움직이게 돼요.
바꾸는 것은 조건뿐입니다. 오늘 고른 색 값은 한 개도 안 버려요. 값과 조건을 갈라 둔 덕분에 조건만 바꿔 끼울 수 있는 겁니다.
다음 시간에 이 한 줄을 실제로 쓰고, 표시를 붙였다 떼는 버튼까지 만듭니다.
그다음 시간엔 이미 만들어진 컴포넌트를 가져다 쓰는 방법도 배웁니다. 대화 상자나 드롭다운처럼 직접 만들면 접근성까지 챙기기 까다로운 것들이 있어요. 그런 것을 복사해서 내 것으로 만드는 방식이 요즘 표준인데, 우리가 오늘 만든 토큰 위에 그대로 얹힙니다.
마지막으로 오늘 우리가 쓴 조건은 전부 창이나 운영체제를 봤습니다. 지난 시간 마무리에서 이름을 소개했던 컨테이너 쿼리는 담긴 칸을 보죠. 그건 비주얼을 마감하는 시간에 실제로 써봅니다.
과제
[구현] 어두운 화면에서 사라진 버튼 되살리기
Step 7에서 프로필 사진 고르기 버튼이 흰 배경에 흰 글씨가 된다고 했죠. 직접 확인하고 고쳐 주세요.
- 먼저 어두운 모드를 켜고 그 버튼을 눈으로 확인하세요. 글자가 보이나요?
- 개발자 도구로 그 버튼의
background-color와color를 각각 읽고 두 값을 적어 주세요. 왜 하나만 바뀌었는지 한 줄로 설명하고요. globals.css에서profile-picker로 시작하는 규칙 네 개를 전부 유틸리티로 옮기세요. 우리 토큰만 써야 합니다.- 옮기고 나면 어두운 모드에서 저절로 따라오는지 확인하세요.
dark:를 한 번도 안 쓰고 해결되면 제대로 옮긴 겁니다. - 밝은 모드에서 원래 생김새가 유지되는지도 확인해 주세요.
다 하고 나면 npm run typecheck -w web-spa와 npm run lint -w web-spa를 돌려 둘 다 통과하는지 확인해 주세요.
[구현] 우리 중단점 하나 더 만들기
지금 화면은 996px 에서 한 열과 두 열이 갈립니다. 아주 넓은 화면에서는 세 열이 들어갈 텐데요.
- 세 열이 들어가려면 뷰포트가 몇 px이어야 하는지 Step 1처럼 계산해 주세요. 카드 470, 간격 24, 통의 좌우 여백 32를 그대로 씁니다.
- 계산한 값으로
--breakpoint-3col을 만드세요.rem으로 적어야 합니다. - 통의 최대 폭과 피드의 열 수를 그 중단점에서 바꾸세요.
- 계산한 폭보다 1px 좁을 때와 정확히 그 폭일 때를 각각 확인하고, 카드 폭이 470으로 나오는지 재 주세요.
- 빌드된 CSS에서
--breakpoint-3col을 찾아보세요. 있나요? 없다면 그 값이 어디로 갔는지 적어 주세요.
[탐구] 경계를 건드려 보기
각 실험 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
- 아무 요소에
2col:text-danger dark:text-brand를 함께 붙이고, 넓은 화면에서 어두운 모드를 켜 보세요. 어느 쪽이 이기나요? Step 3의 순서 그림과 맞는지 견줘 보세요. - 같은 요소에
min-[900px]:text-danger sm:text-brand를 붙이고 창을 1000px 쯤으로 맞춰 보세요. 두 조건이 모두 참인데 어느 쪽이 이기나요? 크기 순서대로 나오나요? globals.css의 다크 블록을@theme안으로 옮겨 보세요. 화면이 어떻게 되나요? 빌드가 실패하나요, 아니면 조용히 아무 일도 안 일어나나요?- 다크 블록의
:root를@layer theme { :root { ... } }로 감싸 보세요. 여전히 이기나요? 이기거나 지는 이유를 Step 5의 실험과 연결해 설명해 주세요.
생각해볼 주제
1. 새 화면을 만들다가 원래 화면의 문제를 발견했을 때
Step 6에서 muted의 대비를 재보니 밝은 화면에서 3.28 이었습니다. 본문 글자에 권장되는 4.5에 못 미치는 값이에요. 어두운 화면에서는 7.88로 넉넉하고요.
이건 오늘 만든 문제가 아닙니다. 처음부터 그랬는데 아무도 재보지 않았을 뿐이죠. 다크 값을 계산하려고 밝은 값을 나란히 놓고 나서야 드러났습니다.
여기서 판단이 갈립니다. 지금 고치면 이미 쓰고 있는 화면의 색이 바뀌고, 디자이너가 정한 값을 개발자가 임의로 바꾸는 셈이 됩니다. 안 고치면 접근성 문제를 알면서 두는 것이 되고요.
여러분이라면 어떻게 하시겠어요. 그리고 이런 발견을 팀에 어떻게 전달해야 "나중에 보자"로 묻히지 않을지도 함께 생각해 보세요.
2. 사용자의 설정을 따를 것인가, 우리가 고르게 할 것인가
오늘 우리 다크모드는 운영체제 설정을 그대로 따릅니다. 코드를 한 줄도 안 쓰고 얻은 결과라 값이 싼데, 실제 서비스 대부분은 사이트 안에 별도의 토글을 둡니다. 시스템은 밝게 쓰면서 특정 사이트만 어둡게 보고 싶은 사람이 있으니까요. 반대로 시스템을 어둡게 쓰지만 사진이 많은 사이트는 밝게 보고 싶은 사람도 있고요.
토글을 두면 결정할 것이 늘어납니다. 사용자가 고른 값을 어디에 저장할지, 다음에 방문했을 때 언제 읽을지, 그리고 시스템 설정이 바뀌면 저장해둔 선택을 따를지 시스템을 따를지요.
시스템 설정만 따르는 것으로 충분한 서비스와 토글이 꼭 필요한 서비스는 무엇이 다를까요. 그리고 토글을 둔다면 "시스템 따르기"라는 선택지도 함께 줘야 할지 생각해 보세요.
3. 규칙을 한 곳에 모으는 것과 흩어 놓는 것
오늘 우리는 다크 값을 한 곳에 모았습니다. globals.css의 블록 하나를 고치면 화면 전체가 바뀌죠.
편해 보이지만 반대편도 있습니다. PostCard.tsx만 열어본 사람은 이 카드가 어두운 모드에서 어떻게 보이는지 알 수 없어요. bg-surface 라고만 적혀 있으니까요. 알려면 다른 파일을 열어야 합니다.
Step 4의 방법은 반대였죠. dark:bg-surface-dark가 그 자리에 적혀 있으니 파일 하나만 봐도 다 보입니다. 대신 29곳에 흩어져서 전체를 바꾸기가 어렵고요.
이 맞바꿈은 다크모드만의 이야기가 아닙니다. 앞으로 여러분이 만들 거의 모든 공통 규칙에서 같은 선택을 하게 돼요.
한 곳에 모으는 편이 나은 것과 각자의 자리에 적어두는 편이 나은 것을 가르는 기준이 무엇일지, 그리고 모아둔 규칙을 나중에 읽을 사람이 찾을 수 있게 하려면 무엇을 해두어야 할지도 정리해 보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 어두운 화면에서 사라진 버튼 되살리기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 진단 | 어두운 모드를 실제로 켜서 버튼을 눈으로 확인했는가 |
| 값 확인 | 배경과 글자 두 값을 개발자 도구에서 각각 읽어 적었는가 |
| 원인 설명 | 배경은 손으로 적은 값이라 안 움직이고, 글자는 상속이라 움직였다는 것을 짚었는가 |
| 토큰 사용 | 색을 직접 적지 않고 line·canvas·subtle로 옮겼는가 |
dark: 미사용 |
dark:를 한 번도 안 쓰고 해결했는가 |
| 밝은 모드 유지 | 원래 생김새가 그대로인지 확인했는가 |
| 검사 통과 | 타입 검사와 린트를 돌렸는가 |
풀이 예시
먼저 무엇이 일어났는지 확인하는데, 어두운 모드에서 그 버튼의 두 값을 읽으면 이렇습니다.
background-color rgb(250, 250, 250) 손으로 적은 #fafafa — 안 움직였다
color rgb(245, 245, 245) body 의 text-ink 를 물려받았다 — 움직였다
한 요소인데 배경만 제자리에 남았습니다. 손으로 적은 값은 우리 토큰이 아니라 바뀔 방법이 없고, 글자색은 이 규칙에 아예 선언이 없어서 body에서 물려받거든요. 그 body는 text-ink를 쓰니 토큰을 따라 밝아진 겁니다.
이제 옮기는데, globals.css에서 네 규칙을 지우고 className으로 표현해요.
// apps/web-spa/src/components/ProfileImagePicker.tsx
<div className="flex items-center gap-2">
<input
className="hidden"
type="file"
accept="image/*"
ref={fileInputRef}
onChange={handleChange}
aria-label="프로필 사진 파일"
/>
<Button
className="cursor-pointer rounded-md border border-line bg-canvas px-3 py-1.5 text-note focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
onClick={() => fileInputRef.current?.click()}
>
프로필 사진 고르기
</Button>
<span className="text-note text-subtle">{pickedName || '아직 고르지 않았어요'}</span>
</div>
값을 이름으로 바꾼 대응은 지지난 시간에 세운 그대로입니다.
| 손으로 적었던 값 | 옮긴 이름 |
|---|---|
#dbdbdb |
border-line |
#fafafa |
bg-canvas |
#737373 |
text-subtle |
13px |
text-note |
border-radius: 6px |
rounded-md |
padding: 6px 12px |
px-3 py-1.5 |
옮기고 나서 두 모드를 다시 재면 이렇습니다.
| 밝을 때 | 어두울 때 | |
|---|---|---|
| 버튼 배경 | 250,250,250 | 0,0,0 |
| 버튼 글자 | 38,38,38 | 245,245,245 |
| 글자 대 배경 | 14.50 | 19.26 |
| 설명글 대 카드 | 4.74 | 11.08 |
고치기 전 어두운 모드의 대비가 1.04 였으니 19.26이면 완전히 해결됐고, 밝은 모드의 14.50은 그대로인데, 되돌아간 것이 아니라 처음부터 안 변한 거예요.
가장 중요한 것은 dark:를 한 번도 안 썼다는 점인데, 토큰 이름만 쓰면 어두운 값은 이미 준비돼 있으니까요.
자주 나오는 실수
dark:로 고치기. bg-[#fafafa] dark:bg-[#121212]처럼 손으로 적은 값에 어두운 짝을 붙이는 답이 많습니다. 화면은 고쳐지지만 손으로 적은 값이 하나에서 둘로 늘어난 셈이에요. 다음에 팔레트를 조정하면 이 두 곳을 또 찾아야 합니다.
bg-surface를 고르기. 카드 배경과 같은 이름을 쓰면 버튼이 카드에 묻힙니다. 이 버튼은 카드 위에 얹히는 요소라 카드보다 한 단계 눌린 색이 맞아요. 지지난 시간 답안에서 작은 회색 버튼에 bg-canvas를 쓴 것과 같은 이유입니다.
글자색을 명시적으로 넣기. text-ink를 직접 붙이는 답도 있는데, 없어도 body에서 물려받으니 결과가 같습니다. 붙여도 틀리지는 않지만, 이번 문제의 핵심이 "상속받는 값은 이미 잘 따라오고 있었다"라는 점이라 굳이 막을 이유가 없어요.
포커스 표시를 빠뜨리기. 지난 시간에 조작 가능한 요소 여덟 곳에 포커스 표시를 붙였는데 이 버튼만 빠져 있었죠. 이번에 className을 새로 쓰는 김에 함께 붙이면 그 구멍도 닫힙니다. 안 붙여도 이 과제는 통과지만, 붙였다면 잘 보신 겁니다.
💡 튜터의 한마디
이 과제가 오늘 수업에서 가장 중요한 순간인데, 다크모드가 안 되는 게 아니라 반쪽만 되는 상태를 직접 보셨으니까요.
전부 안 따라오면 오히려 눈에 잘 띄는데, 어색하니까요. 배경만 남고 글자만 따라오면 흰 버튼에 흰 글씨가 되어 아예 안 보이는데, 없어진 게 아니라 읽을 수 없게 된 거라서, "손으로 적은 값을 남겨두면 나중에 고생한다"는 말이 막연한 경고가 아닙니다. 오늘처럼 새 조건이 하나 생기는 순간, 남겨둔 값과 따라온 값이 한 요소 안에서 부딪힙니다.
🎯 [과제 2 예시답안] 우리 중단점 하나 더 만들기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 계산 | 카드·간격·여백을 세어 1490px 을 직접 구했는가 |
| 단위 | rem으로 적었는가 (93.125rem) |
| 이름 | --breakpoint- 접두사를 정확히 썼는가 |
| 적용 | 통의 최대 폭과 열 수를 둘 다 바꿨는가 |
| 경계 확인 | 1489 와 1490 을 각각 재서 갈리는 것을 확인했는가 |
| 카드 폭 | 세 열에서도 카드가 470 인지 확인했는가 |
:root 확인 |
빌드된 CSS에서 찾아보고 없다는 것을 확인했는가 |
풀이 예시
Step 1과 같은 방식으로 세면, 카드가 세 장이면 사이 간격은 두 번이에요.
카드 470 × 3 = 1410
간격 24 × 2 = 48
통의 좌우 여백 16 × 2 = 32
합계 = 1490px
rem으로 바꾸면 1490 ÷ 16 = 93.125rem 입니다.
/* apps/web-spa/src/styles/globals.css */
@theme {
--breakpoint-2col: 62.25rem;
/* 카드 470 세 장 + 사이 24 두 번 + 통의 좌우 여백 32 = 1490px */
--breakpoint-3col: 93.125rem;
}
두 곳에 적용하는데, 통을 넓히는 것과 열을 늘리는 것 둘 다 필요해요.
// apps/web-spa/src/App.tsx
<main className="mx-auto max-w-[470px] py-4 sm:px-4 2col:max-w-[996px] 3col:max-w-[1490px]">
// apps/web-spa/src/components/Feed.tsx
className="2col:grid 2col:grid-cols-2 2col:gap-6 3col:grid-cols-3"
3col:grid를 다시 안 써도 됩니다. 1490px 이면 996px 조건도 이미 참이라 2col:grid가 걸려 있거든요. 넓은 쪽 조건은 좁은 쪽 위에 얹히는 방식이라 달라지는 것만 적으면 됩니다.
폭을 바꿔가며 재면 이렇게 나옵니다.
| 뷰포트 | 통 폭 | 열 | 카드 폭 |
|---|---|---|---|
| 995 | 470 | 1 | 438 |
| 996 | 996 | 2 | 470 |
| 1489 | 996 | 2 | 470 |
| 1490 | 1490 | 3 | 470 |
| 1800 | 1490 | 3 | 470 |
1489까지 두 열이고 1490부터 세 열인데, 열이 늘어도 카드는 계속 470이라서, 한 번 근거를 세워두면 다음 중단점은 같은 식에 숫자만 바꿔 넣으면 되는 겁니다.
마지막으로 빌드된 CSS에서 --breakpoint-3col을 찾으면 한 건도 안 나옵니다. 대신 이렇게 들어가 있어요.
@media (width>=93.125rem) { ... }
Step 2에서 본 것과 같은 이유인데, 미디어 쿼리 조건은 변수를 못 읽으니 값이 숫자로 굳은 거죠.
자주 나오는 실수
간격을 세 번 세기. 카드가 세 장이면 사이는 두 곳인데, 24를 세 번 더해 1514가 나왔다면 이 부분이에요. 울타리 기둥과 칸의 개수가 다른 것과 같습니다.
px로 적기. --breakpoint-3col: 1490px도 동작은 하지만, 사용자가 브라우저 기본 글자 크기를 키웠을 때 따라가지 못해요. 글자가 커지면 같은 폭에 들어가는 내용이 줄어드니 중단점도 함께 움직이는 편이 맞습니다.
통을 안 넓히기. 3col:grid-cols-3만 붙이면 열은 세 개가 되지만 통이 996에 묶여 있어 카드가 305로 찌그러지는데, (996 − 32 − 48) ÷ 3이에요. 열 수와 통 폭은 짝이에요.
lg 나 xl을 그냥 쓰기. xl은 1280px 이라 세 열을 넣기엔 부족합니다. 카드가 좁아지죠. 오늘 배운 것은 기성 숫자에 우리 배치를 맞추는 게 아니라 우리 배치에서 숫자를 뽑는 것입니다.
💡 튜터의 한마디
이 과제의 핵심은 1490이라는 숫자가 아니라 그 숫자를 설명할 수 있다는 겁니다.
실무에서 중단점을 두고 다투는 일이 자주 있는데, "여기서 두 열로 바꾸는 게 맞나요" 같은 질문이요. 이때 "Tailwind의 lg 라서요"라고 답하면 대화가 거기서 멈추는데, 근거가 없으니 취향 싸움이 되죠.
"카드가 470이고 간격이 24라서 996부터 두 장이 들어갑니다"라고 답하면 다음 대화가 이어지는데, 카드를 좁힐지 간격을 줄일지 아니면 이 폭이 맞는지로요. 숫자에 근거가 붙으면 논의가 디자인으로 올라갑니다.
🎯 [과제 3 예시답안] 경계를 건드려 보기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 실험 1 | dark:가 이긴다는 것을 확인하고 순서 그림과 연결했는가 |
| 실험 2 | sm:이 이긴다는 것을 확인하고 크기순이 아니라는 것을 짚었는가 |
| 실험 3 | 조용히 무시되는 게 아니라 빌드가 실패한다는 것을 확인했는가 |
| 실험 4 | 여전히 이기지만 이유가 달라진다는 것까지 갔는가 |
| 되돌리기 | 실험을 전부 되돌렸는가 |
풀이 예시
실험 1 — 2col:text-danger dark:text-brand
넓은 화면에서 어두운 모드를 켜면 text-brand가 이겨서 파란 글자가 됩니다.
Step 3의 순서 그림을 보면 dark:가 이름 붙은 중단점보다 뒤에 있죠. 미디어 쿼리는 조건이 붙어도 규칙의 무게를 더하지 않으니, 둘 다 참일 때는 뒤에 나온 쪽이 이깁니다.
className에서 둘의 순서를 바꿔 써도 결과는 같습니다. 우리가 적은 순서가 아니라 Tailwind가 내보내는 순서가 결정하니까요.
어두운 모드를 켤 수 없는 환경이라면 빌드된 CSS에서 두 규칙의 위치만 확인해도 되는데, dark: 쪽이 뒤에 있으면 결론은 같아요.
실험 2 — min-[900px]:text-danger sm:text-brand를 1000px 에서
sm:이 이깁니다. 파란 글자예요.
900이 640보다 큰데도 그렇습니다. 크기순으로 나오지 않거든요. 임의로 적은 값은 이름 붙은 중단점 무리보다 통째로 앞에 몰리고, 앞에 있으면 뒤에 있는 것에게 지므로, 이름 붙은 중단점과 임의 값을 섞어 쓰면 예상이 어긋납니다. 자주 쓰는 폭이라면 이름을 주는 편이 안전해요. 오늘 2col에 이름을 준 것이 그 예입니다.
실험 3 — 다크 블록을 @theme 안으로 옮기면
빌드가 실패하는데, 이런 메시지가 나와요.
Error: `@theme` blocks must only contain custom properties or `@keyframes`.
@theme {
> @media (prefers-color-scheme: dark) {
> --color-canvas: oklch(0% 0 none);
}
조용히 무시되는 게 아니라 막아주는데, 이게 다행이에요. 만약 무시됐다면 다크모드가 안 걸리는 이유를 한참 찾았을 겁니다.
이유는 Step 5 토글에서 이야기한 그대로입니다. @theme은 어떤 이름이 있는지 적는 목록이고 거기서 유틸리티가 만들어지므로, 조건이 섞이면 무엇을 만들어야 할지 정할 수 없어요.
실험 4 — 다크 블록을 @layer theme { }로 감싸면
여전히 이겨서, 화면은 똑같이 어두워져요. 그런데 이기는 이유가 달라집니다.
감싸기 전 @theme 출력 = theme 레이어 / 우리 것 = 레이어 밖
→ 레이어 밖이 이긴다. 순서와 무관.
감싼 뒤 둘 다 theme 레이어 안. 우리 것이 뒤.
→ 같은 레이어 안이라 나중에 쓴 쪽이 이긴다.
결과가 같으니 넘어가기 쉽지만, 감싼 쪽은 순서에 기대고 있어서 더 약합니다. 누가 @import 위치를 옮기거나 뒤에서 theme 레이어를 한 번 더 쓰면 뒤집힐 수 있어요. 감싸지 않은 쪽은 순서가 어떻든 이깁니다.
자주 나오는 실수
실험 4에서 "똑같다"로 끝내기. 화면이 같으니 차이가 없다고 적은 답이 많은데, 이 실험의 목적은 결과가 아니라 이유예요. 같은 결과가 나오는 두 가지 경로가 있고 튼튼함이 다르다는 것까지 가야 합니다.
실험 3에서 에러를 안 읽기. 빌드가 깨지면 바로 되돌리고 "안 된다" 라고만 적기 쉬운데, 메시지가 이유를 정확히 알려줍니다. 에러 메시지는 읽으라고 있는 거예요.
실험 2를 좁은 창에서 하기. 창이 900px 미만이면 min-[900px]: 조건이 아예 거짓이라 비교가 안 됩니다. 두 조건이 모두 참인 폭에서 재야 승부를 볼 수 있어요.
💡 튜터의 한마디
네 실험이 전부 같은 것을 묻고 있는데, "왜 이겼는가"요.
앞으로 CSS에서 원하는 스타일이 안 걸리는 일을 수없이 겪게 되는데, 그때 되는 조합을 찾을 때까지 이것저것 붙여보는 방법으로도 해결은 돼요. 다만 다음에 같은 문제를 만나면 또 처음부터 해야 합니다.
이 과목에서 지금까지 "이겼다"가 여러 번 나왔는데 이유가 매번 달랐죠. 레이어 때문이기도 했고, 규칙이 더 구체적이라서이기도 했고, 그냥 뒤에 있어서이기도 했습니다. 셋을 갈라 볼 수 있으면 다음 문제는 짐작이 아니라 확인으로 풀립니다.
🤔 [생각해볼 주제 1] 새 화면을 만들다가 원래 화면의 문제를 발견했을 때
문제 상황 요약
다크 값을 계산하려고 밝은 화면의 대비를 재보니 muted가 3.28 이었습니다. 본문 글자에 권장되는 4.5에 못 미치는 값이에요. 어두운 화면에서는 7.88로 넉넉하고요.
원래부터 그랬는데 아무도 재보지 않아서 몰랐던 겁니다. 다크모드를 만드는 김에 나란히 놓고 보니 드러났어요.
지금 고치면 이미 서비스 중인 화면의 색이 바뀌고, 디자이너가 정한 값을 개발자가 임의로 바꾸는 셈이 됩니다. 안 고치면 접근성 문제를 알면서 두는 것이 되고요.
튜터의 가이드 및 해설
먼저 이 상황의 성격부터 정리해 봅시다. 이건 오늘 만든 문제가 아니라 오늘 발견한 문제입니다. 둘은 다르게 다뤄야 해요.
오늘 만든 문제는 내 변경이 원인이니 오늘 고치는 게 맞지만, 발견한 문제를 발견한 사람이 그 자리에서 고치기 시작하면 작업 범위가 계속 번집니다. 다크모드를 하러 왔다가 밝은 화면 팔레트를 바꾸게 되고, 그러면 다크모드 변경과 팔레트 변경이 한 덩어리로 섞여요. 나중에 문제가 생겼을 때 무엇 때문인지 가려내기 어려워져서, 저는 분리를 권합니다. 오늘 작업에는 다크모드만 담고, 발견한 것은 따로 남기는 거예요.
그럼 어떻게 남겨야 묻히지 않을까요. 여기가 진짜 어려운 부분입니다.
"나중에 고치죠"라고 말로만 남기면 반드시 묻힙니다. 말은 기록이 아니거든요. 그렇다고 별도 문서에 적어두는 것도 대체로 안 읽혀요.
효과가 있는 방법은 숫자와 재현 방법을 함께 남기는 것입니다. "muted 색이 좀 흐린 것 같아요"는 취향처럼 들리지만, "muted가 흰 배경에서 대비 3.28이고 본문 글자 기준은 4.5입니다. 개발자 도구 색 검사기로 재면 나옵니다"는 사실이에요. 사실은 반박하거나 받아들일 수는 있어도 무시하기는 어려운데, 누가 결정할 일인지를 분명히 하는 게 좋습니다. 이건 개발자가 혼자 정할 일이 아니에요. 색은 디자인 결정이고, 여기에는 브랜드 인상이라는 다른 축도 있습니다. 회색을 진하게 만들면 읽기는 쉬워지지만 화면이 무거워 보일 수 있어요. 인스타그램이 이 회색을 고른 데에는 이유가 있었을 텐데, 개발자가 할 일은 "고쳤습니다"도 "안 고쳤습니다"도 아니라 "이런 사실을 발견했고 결정이 필요합니다"를 정확히 전달하는 것입니다. 결정할 사람에게 판단할 재료를 주는 거죠.
한 가지 덧붙이면, 오늘 우리가 다크 값을 7.88로 잡은 것 자체가 이미 하나의 결정입니다. 밝은 화면의 3.28을 그대로 옮겼다면 어두운 화면도 3.28이 됐을 텐데, 그러지 않았어요. 즉 우리는 이미 "새로 만드는 쪽은 기준을 지키자"를 선택한 셈입니다.
이게 현실적인 절충입니다. 기존 것을 전부 뒤엎지 않으면서, 적어도 새로 만드는 것은 더 낫게 만드는 것인데, 그러면 시간이 지나며 좋은 쪽 비율이 늘어납니다.
🎯 면접관을 홀리는 핵심 멘트
"제가 만든 문제와 제가 발견한 문제는 다르게 다룹니다. 만든 건 그 자리에서 고치고, 발견한 건 숫자와 재현 방법을 붙여서 결정할 사람에게 넘깁니다. 발견했다는 이유로 범위를 넓히면 정작 하려던 변경이 흐려지거든요. 다만 새로 만드는 부분에는 기준을 적용합니다. 그래야 시간이 지날수록 나아지니까요."
🤔 [생각해볼 주제 2] 사용자의 설정을 따를 것인가, 우리가 고르게 할 것인가
문제 상황 요약
오늘 만든 다크모드는 운영체제 설정을 그대로 따릅니다. 코드를 한 줄도 안 쓰고 얻은 결과예요.
그런데 실제 서비스 대부분은 사이트 안에 별도의 토글을 둡니다. 시스템은 밝게 쓰면서 특정 사이트만 어둡게 보고 싶은 사람이 있으니까요.
토글을 두면 결정할 것이 늘어납니다. 고른 값을 어디에 저장할지, 다음 방문 때 언제 읽을지, 시스템 설정이 바뀌면 저장해둔 선택과 시스템 중 무엇을 따를지요.
튜터의 가이드 및 해설
먼저 지금 방식이 가진 장점을 과소평가하지 맙시다. 코드가 없다는 것은 버그가 없다는 뜻이에요. 저장할 것도, 읽을 것도, 어긋날 것도 없고, 사용자가 이미 자기 취향을 운영체제에 말해뒀는데 우리가 또 묻는 것은 어떻게 보면 중복이라서, 첫 질문은 "토글을 어떻게 만들까"가 아니라 "토글이 정말 필요한가"입니다.
필요해지는 상황이 분명히 있습니다. 콘텐츠 성격이 화면 밝기와 강하게 엮이는 서비스요. 사진이나 영상이 주인공이면 어두운 배경이 유리하고, 긴 글을 읽는 서비스면 밝은 쪽을 선호하는 사람이 많습니다. 이럴 때 사용자는 "시스템은 어둡게 쓰지만 이 사이트만은 밝게"를 원하게 돼요. 시스템 설정 하나로 모든 사이트를 똑같이 결정하기엔 사이트마다 성격이 다르니까요.
반대로 업무용 도구처럼 다른 창들과 나란히 놓고 쓰는 서비스라면 시스템을 따르는 편이 오히려 자연스럽습니다. 우리 화면만 튀면 눈이 피로해지거든요.
토글을 두기로 했다면, 가장 많이 틀리는 지점을 짚어드릴게요. 선택지가 둘이 아니라 셋이어야 한다는 겁니다.
밝게 항상 밝게
어둡게 항상 어둡게
시스템 운영체제를 따른다 ← 이걸 빠뜨리기 쉽다
밝게와 어둡게만 두면 사용자는 한 번 고른 뒤 시스템 설정으로 되돌아올 방법이 없습니다. 저녁에 시스템이 자동으로 어두워지는 사람에게는 꽤 불편한데, 기본값은 "시스템"이어야 합니다. 아무것도 안 고른 사람에게는 지금 방식이 그대로 유지되는 거죠.
또 하나 자주 놓치는 것은 처음 그려질 때의 깜빡임입니다. 저장해둔 선택을 자바스크립트로 읽어서 적용하면, 그 코드가 실행되기 전 아주 잠깐 반대 색이 보일 수 있어요. 어두운 모드를 쓰는 사람이 페이지를 열 때마다 흰 화면이 번쩍이면 상당히 거슬립니다.
그래서 오늘 배운 것이 여기서 값을 합니다. 다크 값이 토큰 한 곳에 모여 있으면, 화면을 그리기 전에 그 한 곳만 정해주면 되거든요. 값이 29곳에 흩어져 있었다면 훨씬 까다로웠을 겁니다.
마지막으로 저장 위치입니다. 이건 사용자의 취향이지 우리 서비스의 데이터가 아니에요. 그러니 서버에 계정 정보로 저장하기 전에, 이 기기에만 남겨도 충분한지 먼저 생각해 보시면 좋겠습니다. 기기마다 밝기 환경이 다르니 오히려 기기별로 다른 게 자연스러울 수도 있고요.
🎯 면접관을 홀리는 핵심 멘트
"다크모드 토글을 만들 때 선택지를 밝게와 어둡게 둘로만 두면 사용자가 시스템 설정으로 돌아올 방법이 없어집니다. 저는 시스템 따르기를 기본값으로 두고 셋으로 만듭니다. 그리고 저장한 값을 화면 그리기 전에 적용해야 첫 화면 깜빡임이 안 생기는데, 이게 가능하려면 다크 값이 한곳에 모여 있어야 합니다. 토큰으로 관리한 이유가 여기서 드러나죠."
🤔 [생각해볼 주제 3] 규칙을 한 곳에 모으는 것과 흩어 놓는 것
문제 상황 요약
오늘 다크 값을 한 곳에 모았습니다. globals.css 몇 줄을 고치면 화면 전체가 바뀌죠.
그런데 PostCard.tsx만 열어본 사람은 이 카드가 어두운 모드에서 어떻게 보이는지 알 수 없습니다. bg-surface 라고만 적혀 있으니까요.
Step 4의 방법은 반대였습니다. dark:bg-surface-dark가 그 자리에 적혀 있으니 파일 하나만 봐도 다 보이죠. 대신 29곳에 흩어져서 전체를 바꾸기가 어렵고요.
튜터의 가이드 및 해설
이 맞바꿈은 다크모드만의 이야기가 아닙니다. 앞으로 만들 거의 모든 공통 규칙에서 같은 선택을 하게 되므로, 기준을 하나 갖고 계시면 좋습니다.
제가 쓰는 기준은 바뀔 때 같이 바뀌는가입니다.
카드 배경색을 생각해 봅시다. 우리가 이 값을 바꾸기로 했다면 모든 카드가 함께 바뀌어야 하죠. 한 카드만 다른 색이 되면 그건 버그입니다. 이렇게 항상 함께 움직이는 것은 한 곳에 모으는 게 맞아요. 흩어 놓으면 언젠가 한 곳을 빠뜨리고, 그 빠뜨림이 버그가 됩니다.
반대로 이 배너 하나만 다크에서 테두리를 밝게 하고 싶다면, 그건 다른 어떤 것과도 함께 움직이지 않습니다. 이런 것을 공통 규칙으로 올리면 이름만 늘어나고 아무도 재사용하지 않아요. 그 자리에 적어두는 게 맞는데, 기준은 코드가 얼마나 반복되느냐가 아니라 변경이 함께 일어나느냐입니다. 똑같은 코드가 여러 곳에 있어도 각자 독립적으로 바뀔 수 있다면 합치면 안 돼요. 이건 중복이 아니라 우연히 닮은 겁니다. 성급하게 합쳐두면 나중에 하나만 바꾸려 할 때 억지로 조건을 넣게 됩니다.
이제 모으기로 했을 때 남는 문제를 봅시다. 질문에서 짚은 그대로예요. 모아둔 규칙은 안 보인다는 겁니다.
여기에 완전한 해결책은 없습니다. 다만 덜어내는 방법은 있어요.
첫째, 이름이 설명하게 만드는 겁니다. bg-surface라고 적혀 있으면 "이건 표면 색이고 어딘가에 정의돼 있다"가 읽힙니다. 만약 bg-white였다면 흰색이라고만 읽히고, 어두운 모드에서 흰색이 아니게 되는 순간 이름이 거짓말이 되죠. 지지난 시간에 canvas와 surface로 나눠 이름을 지은 것이 여기서 값을 합니다.
둘째, 모아둔 곳을 한 군데로 유지 하는 겁니다. 지금 우리 다크 값은 globals.css 한 곳에 있어요. 새로 들어온 사람이 "다크 값이 어디 있죠"라고 물으면 한 파일을 가리키면 됩니다. 이게 세 파일에 나뉘어 있으면 모아둔 이점이 절반은 사라져요.
셋째, 직접 확인할 방법을 쉽게 만드는 겁니다. 사실 코드를 읽어서 아는 것보다 켜보는 게 빠릅니다. 개발자 도구에서 어두운 모드를 흉내 내는 방법을 팀이 공유하고 있으면, "이 카드가 어두울 때 어떻게 보이지"는 파일을 뒤지는 대신 3초 만에 확인할 수 있어요.
마지막으로 하나 덧붙이면, 이 선택은 되돌릴 수 있습니다. 오늘 우리가 두 방법을 다 만들어 보고 하나를 골랐듯이요. 처음부터 완벽하게 고르려고 오래 고민하는 것보다, 기준을 갖고 고른 뒤 안 맞으면 옮기는 편이 대체로 빠릅니다.
🎯 면접관을 홀리는 핵심 멘트
"공통으로 뺄지 그 자리에 둘지는 코드가 반복되는 횟수가 아니라 변경이 함께 일어나는지로 판단합니다. 항상 같이 바뀌는 것만 모으고, 각자 따로 바뀔 수 있으면 닮았어도 두는 편이죠. 성급하게 합치면 나중에 하나만 바꾸려 할 때 조건이 붙기 시작하는데, 그때부터는 되돌리기가 더 비쌉니다."