E-5: Shadcn/UI 도입 — 설치와 그 여파
목차 52
안녕하세요, 홍순구입니다. 지난 시간에 우리는 다크모드를 완성했는데, className을 한 글자도 안 고치고 화면 전체를 어둡게 만들었어요. 값과 조건을 갈라 둔 덕분이었고, 마지막에 한 줄을 보여드리면서 약속을 했습니다.
@custom-variant dark (&:is(.dark *));
이 줄을 실제로 쓰고, 화면에 해와 달 버튼까지 만들겠다고요. 오늘 그 약속을 지키는데, 순서는 조금 거꾸로라서 토글부터 만들지 않아요. 먼저 다른 사람이 만든 컴포넌트를 우리 프로젝트에 들이는데, 그랬더니 그것이 우리 다크모드를 건드리고, 그걸 고치는 과정에서 토글이 생깁니다.
왜 이 순서냐면, 실무에서 다크모드 토글을 손수 만드는 일보다 라이브러리를 들이다가 우리 것과 부딪히는 일이 훨씬 자주 일어나기 때문입니다. 오늘 배울 진짜 기술은 그쪽이에요.
오늘의 여정
Step 1 왜 "설치" 가 아니라 "복사" 인가
│
Step 2 init 이 우리 파일에 무엇을 하는가
│
Step 3 이름이 겹쳤다 — 확인은 다 통과하는데 화면이 깨진다
│
Step 4 조건을 운영체제에서 표시로 옮긴다
│
Step 5 밝게 · 어둡게 · 시스템, 그리고 기억하기
│
Step 6 새로고침할 때 한 번 번쩍이는 것
▼
남의 규칙과 우리 규칙을 화해시키고, 다크모드를 우리 손으로 켠다
💡 오늘 수업의 핵심 — "가져다 쓴다는 것은 남의 규칙을 내 집에 들이는 일이다"
설치 명령 한 줄로 끝나지 않습니다. 들어온 쪽도 이름을 갖고 있고, 우리도 이름을 갖고 있어요. 겹치는 이름을 누가 양보할지 정하는 것까지가 설치입니다.
🎯 학습 목표
- 컴포넌트 라이브러리의 두 방식(패키지로 두고 쓰기 · 소스를 복사해 소유하기)을 견주어 고를 수 있다
npx shadcn@latest init이 프로젝트 파일에 무엇을 하는지 설명하고, 겹친 토큰 이름을 찾아 화해시킬 수 있다- 다크모드의 조건을 운영체제에서 표시(
.dark)로 옮기고, 밝게·어둡게·시스템 세 갈래 토글을 만들 수 있다 - 사용자가 고른 값을 저장하고, 첫 화면이 그려지기 전에 되살릴 수 있다
Step 1: "왜 '설치'가 아니라 '복사'인가"
우리 인스타그램에 게시물을 크게 띄우는 모달을 만든다고 해봅시다. 사진을 누르면 화면 가운데에 크게 뜨는 그거요.
겉보기엔 간단해 보입니다. 검은 배경 깔고, 가운데 상자 하나 띄우고, 바깥을 누르면 닫으면 되는데, 제대로 만들려면 챙길 것이 이만큼 있습니다.
모달 하나를 제대로 만들려면
포커스를 상자 안에 가둔다 Tab 을 계속 눌러도 뒤쪽 버튼으로 안 새어나가게
Esc 를 누르면 닫는다
닫으면 원래 있던 곳으로 포커스를 되돌린다
뒤쪽 배경은 스크롤을 잠근다
화면 낭독기에 "지금 대화 상자가 열렸다" 고 알린다 aria-modal · role="dialog"
상자 바깥은 낭독기에서 숨긴다
하나하나는 어렵지 않지만, 이걸 전부 챙긴 모달을 만드는 데는 하루가 갑니다. 이런 컴포넌트가 모달 하나가 아니라서, 드롭다운, 탭, 툴팁, 셀렉트가 다 비슷한 문제를 갖고 있어요.
가져다 쓰는데, 여기서 갈림길이 나와요.
두 가지 방식이 있습니다
지금까지 우리가 라이브러리를 쓴 방식은 하나였습니다. npm install로 받아서 node_modules에 두고, 이름으로 불러 쓰는 거죠. React Hook Form도 Zod도 그랬어요.
컴포넌트 라이브러리에는 방식이 하나 더 있습니다.
(가) 패키지로 두고 쓴다 (나) 소스를 복사해 온다
node_modules/어떤라이브러리/ src/components/ui/dialog.tsx
└ Dialog.js ← 내가 못 고친다 └ 내 파일이다. 열어서 고칠 수 있다
버전 올리면 알아서 바뀐다 버전 올려도 안 바뀐다 (내가 골라서 가져온다)
생김새는 그쪽이 정한 범위 안에서 생김새를 마음대로 바꾼다
package.json 한 줄 내 저장소에 파일이 쌓인다
Shadcn/UI는 (나)입니다. 명령을 실행하면 컴포넌트 소스 파일이 내 프로젝트에 복사되는데, 그 뒤로 그 파일은 제 것이라 마음에 안 드는 부분이 있으면 그냥 열어서 고치면 돼요.
왜 이 방식이 요즘 표준이 됐을까요
패키지 방식의 오래된 고통이 있습니다. 디자인을 조금 바꾸고 싶은데 그 라이브러리가 열어준 구멍으로만 바꿀 수 있어요. 구멍이 없으면 방법이 없습니다. !important를 붙이거나, 안쪽 클래스 이름을 알아내서 억지로 덮어쓰거나요.
복사 방식은 그 문제가 아예 없습니다. 파일이 내 것이니까요.
대신 반대편 비용을 내는데, 라이브러리가 버그를 고쳐도 내 파일은 안 따라오니 내가 다시 가져와야 해요.
⚠️ "npm 의존이 없다"는 말은 절반만 맞습니다
여기서 오해하기 쉬운 부분을 미리 짚고 갈게요. "복사해서 소유한다"를 "설치되는 게 아무것도 없다"로 읽으면 곧 배신감을 느끼게 됩니다.
우리가 실제로 설치해보면 package.json에 여덟 개가 늘어납니다.
| 늘어난 것 | 하는 일 |
|---|---|
@base-ui/react |
포커스 가두기·키보드·낭독기 같은 동작 알맹이 |
class-variance-authority |
"이 컴포넌트의 large는 이 클래스 묶음" 을 정의 |
clsx + tailwind-merge |
클래스 문자열을 합치고 충돌을 정리 |
lucide-react |
아이콘 |
tw-animate-css |
열고 닫힐 때 쓰는 애니메이션 유틸리티 |
@fontsource-variable/geist |
프리셋이 고른 글꼴 |
shadcn |
컴포넌트를 가져오는 명령 자체 |
복사되는 것은 컴포넌트 소스이고, 그 소스가 기대는 부품들은 여전히 npm에서 옵니다. 위 표의 첫 줄이 특히 중요한데, 모달의 포커스 가두기 같은 진짜 어려운 동작은 @base-ui/react 안에 있고, 그건 복사되지 않습니다.
정리하면 이렇습니다. 생김새는 내가 소유하고, 동작은 빌려 씁니다.
비슷한 것들이 있습니다
이 방식이 유일한 답은 아니에요. 자주 만나게 될 이름을 한 문단으로 정리하고 갑니다.
Base UI와 Headless UI는 방금 표의 첫 줄에 해당하는 것만 주는 라이브러리입니다. 동작과 접근성만 있고 생김새는 없어서, 스타일을 처음부터 내가 입힙니다. Shadcn은 사실 이 위에 스타일을 입혀 둔 것을 복사해주는 셈이에요. MUI는 반대편인데, 생김새까지 완성된 컴포넌트를 패키지로 주는 대신 그쪽 디자인 언어를 따라가게 됩니다. 회사 디자인이 확고하면 MUI는 싸움이 되고, 디자이너 없이 빨리 만들어야 하면 MUI가 이깁니다.
💡 한 줄 정리
컴포넌트를 가져다 쓰는 데는 두 방식이 있고, Shadcn은 소스를 복사해 내 것으로 만드는 쪽입니다. 생김새는 내가 소유하지만 동작은 여전히 빌려 쓰므로, 설치되는 패키지가 없다는 뜻은 아닙니다.
🙋 학생 질문 — "튜터님, 복사한 파일이 많아지면 관리가 더 힘들어지는 것 아닌가요?"
맞습니다. 그게 이 방식의 진짜 비용이에요.
패키지 방식은 열 개를 써도 package.json 한 줄입니다. 복사 방식은 열 개를 쓰면 내 저장소에 파일 열 개가 쌓여요. 코드 리뷰할 것도 늘고, 다른 사람이 봤을 때 "이건 우리가 짠 건가 가져온 건가"가 헷갈립니다.
관례가 하나 있는데, 복사해 온 컴포넌트는 components/ui/라는 별도 폴더에 모아 둡니다. 그 폴더 안은 "가져온 것", 바깥은 "우리가 짠 것"으로 나누는 거예요. 우리도 다음 시간에 그 폴더를 쓰게 됩니다.
실제로는 생각보다 안 늘어나는데, 한 프로젝트에서 쓰는 이런 컴포넌트는 보통 열 개 안팎이에요.
Step 2: "init이 우리 파일에 무엇을 하는가"
이제 들여봅시다. 명령은 이거예요.
npx shadcn@latest init
처음 실행하면 우리 프로젝트에서는 멈춰요.
먼저 길 이름을 정해줘야 합니다
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS. Found v4.
✖ Validating import alias.
Could not find valid path aliases or package imports for init.
프레임워크가 Vite라는 것도, Tailwind가 v4라는 것도 알아서 찾아냈는데, 세 번째에서 멈췄습니다.
복사돼 올 컴포넌트들은 서로를 부릅니다. 예를 들어 대화 상자가 버튼을 부르죠. 그때 ../../components/ui/button처럼 상대 경로로 쓰면 파일 위치가 바뀔 때마다 깨지기 때문에, @/components/ui/button 같은 별명을 씁니다. @/가 src/를 가리키게 약속하는 거예요.
이 약속은 두 군데에 해줘야 합니다. 타입을 검사하는 쪽과, 실제로 파일을 찾아 묶는 쪽입니다.
// apps/web-spa/tsconfig.json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
// apps/web-spa/vite.config.ts
import { fileURLToPath } from 'node:url';
export default defineConfig({
// '@/' 로 src 아래를 가리킨다 — 가져다 쓸 컴포넌트가 이 이름으로 서로를 부른다
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
맨 위 import를 빠뜨리지 마세요. fileURLToPath는 Node가 주는 함수라 따로 가져와야 합니다.
이걸 쓰는 순간 타입 검사가 한 번 걸리는데, node:url이 무엇인지 TypeScript가 모르거든요. Node의 타입을 깔아 줍니다.
npm install -D @types/node --workspace apps/web-spa
설정 파일 하나 고치는데 패키지가 하나 늘었죠. vite.config.ts는 브라우저가 아니라 Node 위에서 도는 파일이라 그렇습니다. 같은 프로젝트 안에 두 세상이 있다는 게 여기서 드러나요.
⚠️ 인터넷에서 본 설정을 그대로 베끼면 여기서 걸립니다
별명 설정을 검색하면 대부분 baseUrl과 paths를 함께 쓰라고 나옵니다. 그대로 넣고 타입 검사를 돌리면 이런 게 나와요.
tsconfig.json(7,5): error TS5101: Option 'baseUrl' is deprecated and will
stop functioning in TypeScript 7.0.
baseUrl은 물러나는 중이라, 지금은 경고지만 다음 메이저에서는 아예 안 돕니다. paths만 써도 됩니다. 기준점은 tsconfig.json이 놓인 곳으로 알아서 잡혀요.
A-6에서 tsconfig를 훑을 때 "설정 파일도 늙는다" 고 했던 것 기억나시죠. 이게 그 예입니다. 검색해서 나온 글의 날짜를 한 번 보는 습관이 여기서 값을 합니다.
무엇을 고를지 묻습니다
별명을 깔고 다시 실행하면 두 가지를 물어봅니다.
첫째는 어떤 동작 알맹이를 쓸지입니다. Base UI · Radix · React Aria 셋 중에 고르는데, 지금은 Base UI가 기본값이에요. 셋 다 같은 일을 하고, 같은 컴포넌트가 세 종류로 다 나옵니다. 나중에 마음이 바뀌면 바꿀 수 있으니 지금은 기본값으로 갑니다.
둘째는 프리셋입니다. 색과 모서리와 글꼴을 묶어 놓은 꾸러미로, nova를 비롯해 여덟 개가 있어요. 우리는 기본값인 nova로 갑니다.
⚠️ 조금 옛날 글을 보면
new-york이나default라는 스타일을 고르라고 나옵니다. 그 개념은 지금 없어졌어요. 프리셋으로 대체됐습니다. 이런 도구는 반년이면 화면이 바뀌니, 글보다--help를 먼저 보는 편이 빠릅니다.
무엇이 생겼는지 봅시다
명령이 끝나면 이렇게 나옵니다.
✔ Writing components.json.
✔ Created 1 file:
- src/lib/utils.ts
✔ Updating src/styles/globals.css
세 가지가 일어났어요.
하나, components.json이 생깁니다. 앞으로 컴포넌트를 가져올 때마다 참고할 메모인데, 어떤 알맹이를 골랐는지 어느 폴더에 넣을지가 적혀 있습니다.
// apps/web-spa/components.json
{
"style": "base-nova",
"tailwind": {
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}
style 값이 base-nova 죠. 아까 고른 두 가지가 이렇게 한 줄로 합쳐집니다.
둘, src/lib/utils.ts가 생깁니다. 아주 짧은 파일인데, 복사돼 올 모든 컴포넌트가 이걸 씁니다. 무슨 일을 하는지는 다음 시간에 컴포넌트를 열어보면서 볼게요.
셋, 우리 globals.css를 고칩니다. 이게 오늘의 본론입니다.
우리 파일 앞뒤로 자기 것을 붙였습니다
지우지는 않았고, 우리가 쓴 내용은 그대로 있는 채로 앞에 몇 줄을 얹고 뒤에 자기 블록을 통째로 붙였습니다. 180줄이던 파일이 308줄이 됐어요.
앞에 얹힌 것부터 볼까요.
/* apps/web-spa/src/styles/globals.css */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@import 세 줄이 늘었고, 그리고 지난 시간 마지막에 예고했던 그 줄이 여기 있습니다. 우리가 직접 쓸 거라고 했는데, 도구가 먼저 써놨네요. 이 줄이 무슨 일을 하는지는 Step 4에서 제대로 봅니다.
뒤에 붙은 것은 훨씬 깁니다. 이렇게 생겼어요.
/* apps/web-spa/src/styles/globals.css — init 이 우리 내용 뒤에 붙인 부분 */
@theme inline {
--font-sans: 'Geist Variable', sans-serif;
--color-muted: var(--muted);
--color-background: var(--background);
/* ... 색 이름이 서른 개쯤 이어집니다 ... */
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
}
:root {
--muted: oklch(0.97 0 0);
--background: oklch(1 0 0);
--radius: 0.625rem;
/* ... */
}
.dark {
--muted: oklch(0.269 0 0);
--background: oklch(0.145 0 0);
/* ... */
}
여기서 두 가지를 눈여겨보세요.
첫째, 이름이 두 겹입니다. --color-muted가 --muted를 가리키고, 실제 색은 :root와 .dark에 따로 있어요. 왜 이렇게 했을까요. 지난 시간에 우리가 한 것과 같은 이유입니다. 값을 한 곳에 모아 두면 .dark 블록 하나만 갈아끼워 전체를 바꿀 수 있으니까요. 우리가 스스로 찾아낸 방법을 이 라이브러리도 쓰고 있는 겁니다.
둘째, @theme 뒤에 inline이 붙었는데, 이게 Step 3의 원인이 됩니다.
💡 한 줄 정리
init은 별명 설정을 먼저 요구하고, 그 다음 components.json과 lib/utils.ts를 만들고 우리 globals.css 앞뒤에 자기 내용을 붙입니다. 지우지는 않지만 뒤에 붙는다는 점이 다음 이야기의 씨앗입니다.
🙋 학생 질문 — "튜터님, 제 프로젝트는 별명 없이도 잘 돌아가는데 왜 굳이 만들어야 하나요?"
우리 것도 지금까지 잘 돌아갔죠. import { Avatar } from './Avatar'처럼 상대 경로로 잘 쓰고 있었습니다.
문제는 복사돼 올 파일들이 그 약속을 이미 하고 왔다는 데 있어요. 대화 상자 파일 안에는 import { cn } from "@/lib/utils"라고 이미 적혀 있습니다. 우리가 @/를 모르면 그 파일은 그냥 안 돌아가서, init이 별명부터 확인하고 없으면 멈추는 겁니다. 나중에 컴포넌트를 가져온 뒤에 "왜 안 되지" 하고 헤매는 것보다, 시작할 때 막아주는 편이 친절하죠.
Step 3: "이름이 겹쳤다 — 확인은 다 통과하는데 화면이 깨진다"
설치가 끝났습니다. 화면을 열어볼까요.
깨져 있습니다.
글자가 사라졌어요
머리말 오른쪽의 "좋아요 누른 게시물 1개"가 안 보입니다. "회원가입", "피드" 같은 섹션 제목도요. 자세히 보면 아주 흐릿하게 있긴 한데, 흰 배경에 거의 흰 글씨입니다.
이상한 점이 있습니다. 우리는 아직 shadcn 컴포넌트를 하나도 안 썼어요. 설치만 했을 뿐인데 원래 있던 화면이 망가졌고, 더 이상한 것은 코드베이스에 걸어둔 확인들은 하나도 안 걸렸다는 겁니다. 전부 통과했어요.
왜 그럴까요. 그 확인들은 컴포넌트가 어떤 클래스 이름을 붙였는지를 보는데, text-muted라고 잘 적혀 있으면 통과이지만 그 이름이 무슨 색을 가리키는지는 안 봅니다. 이름은 그대로인데 이름이 가리키는 값이 바뀐 상황을, 그 확인들은 볼 수 없습니다.
⚠️ 이건 오늘 배울 것 중에 가장 실무적인 부분입니다. 자동 확인이 통과했다고 화면이 멀쩡한 게 아니에요. 스타일을 바꾼 뒤에는 눈으로 한 번 봐야 합니다.
범인을 찾아봅시다
개발자 도구에서 그 글자의 색을 만드는 규칙을 따라가 보면 이렇게 나옵니다.
우리가 쓴 것 .text-muted { color: var(--color-muted) }
그런데 지금은 .text-muted { color: var(--muted) }
↑ 우리 이름이 아니다
--color-muted가 사라지고 --muted를 가리키고 있는데, --muted는 shadcn이 가져온 값입니다. 흐린 배경면에 쓰라고 만든 아주 밝은 회색이에요.
우리 muted는 보조 글자색이었습니다. 댓글 개수나 섹션 제목처럼 조금 옅게 쓰는 글자인데, shadcn의 muted는 글자색이 아니라 배경색입니다. 뜻이 완전히 다른데 이름만 같았던 거예요.
대비를 재보면 이렇습니다.
우리 값 #8e8e8e 흰 카드 위에서 대비 3.28
덮인 값 #f5f5f5 흰 카드 위에서 대비 1.09 ← 사실상 안 보인다
왜 우리 것이 졌을까요
두 가지가 겹쳤습니다.
첫째, shadcn 블록이 우리 뒤에 붙었습니다. 같은 층에서 같은 이름을 두 번 쓰면 나중 것이 이기는데, 이건 CSS의 기본 규칙입니다.
둘째, 아까 눈여겨본 inline입니다. @theme에 inline을 붙이면 그 이름은 변수로 내보내지 않고 값을 유틸리티 안에 곧바로 써 넣기 때문에, 빌드된 CSS를 뒤져보면 --color-muted라는 변수 자체가 아예 없어요. 우리가 지난 시간에 .dark 블록에 적어둔 --color-muted도 이제 아무도 안 읽는 죽은 줄이 됐습니다.
겹친 것이 하나 더 있습니다. 아니 둘입니다
한 곳이 깨졌으면 다른 곳도 의심해야죠. 우리 토큰은 아홉 개였는데, 전부 확인해봤어요.
| 우리 토큰 | 결과 |
|---|---|
brand danger line line-soft subtle ink canvas surface |
무사합니다 |
muted |
덮였습니다 |
색은 하나만 겹쳤는데, 색이 아닌 것에서 두 개가 더 나왔습니다.
글꼴 목록이 통째로 갈렸습니다. 우리는 --font-sans에 한글 글꼴까지 넣은 목록을 적어 뒀는데, 프리셋이 자기 글꼴 한 줄로 덮었어요.
우리가 적어둔 것 system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif
덮인 뒤 'Geist Variable', sans-serif
여기서 성급하게 결론 내리기 쉽습니다. "한글 글꼴이 목록에서 사라졌으니 한글이 이상해졌겠구나" 하고요. 저도 그렇게 짐작했는데, 재보니 아니었습니다.
글자 폭을 재서 견줘 봤어요.
| 잰 것 | 덮이기 전 | 덮인 뒤 |
|---|---|---|
| 한글 (공백 없이) | 179.92 | 179.92 |
| 로마자 | 160.58 | 163.22 |
한글은 한 글자도 안 바뀌었습니다. 덮이기 전에도 목록의 첫 번째는 system-ui였고, 그 글꼴에도 한글이 없어서 어차피 뒤로 흘러가고 있었거든요. 덮인 뒤에도 똑같이 흘러가서 같은 글꼴에 도착합니다.
실제로 바뀐 것은 로마자라서, 사용자 이름 같은 영문이 Geist로 그려지게 됐어요.
⚠️ 그렇다고 이 충돌을 넘어가면 안 됩니다. 지금 결과가 같은 것은 이 컴퓨터에서 우연히 그런 거예요. 덮인 목록에는 한글 글꼴 이름이 아예 없으니,
sans-serif가 다른 것을 가리키는 환경에서는 갈라집니다. 화면이 같아 보여도 우리가 적어둔 의도가 사라진 것은 되돌려야 합니다.
모서리가 바뀌었습니다. 이건 더 놀라워요. 우리는 rounded-lg 같은 클래스를 손대지 않았는데 카드 모서리가 달라졌습니다.
클래스 원래 지금
rounded-md 6px → 8px
rounded-lg 8px → 10px
shadcn이 모서리 눈금 전체를 자기 값으로 다시 정의했기 때문입니다. 눈금에서 값을 받아 쓰는 곳이 우리 화면에 열 군데 있으니, 그만큼이 조용히 바뀐 거예요.
rounded-full과 알림 상자의 rounded-[20px]는 안 바뀝니다. 앞의 것은 눈금과 상관없이 "완전히 둥글게"이고, 뒤의 것은 눈금을 안 거치고 값을 직접 적었으니까요. E-2에서 임의 값을 쓰면 토큰의 통제를 벗어난다고 했던 것이, 여기서는 오히려 안 흔들리는 쪽으로 작동한 셈입니다.
화해시킵니다 — 겹친 것만 골라서
세 개를 각각 다르게 처리합니다. 여기서 판단 기준은 누가 이 이름을 더 필요로 하는가예요.
muted는 우리가 양보합니다. 앞으로 가져올 shadcn 컴포넌트들은 안에 bg-muted라고 이미 적혀 있어서, 그 파일들을 전부 고치느니 우리 이름을 옮기는 편이 쌉니다.
/* apps/web-spa/src/styles/globals.css */
@theme {
/* muted 는 shadcn 이 자기 이름으로 쓰고 있어서 faint 로 옮겼다.
진하기 순서는 ink > subtle > faint 로 이름만 봐도 읽힌다. */
--color-faint: oklch(64.7% 0 none);
--color-subtle: oklch(55.6% 0 none);
--color-ink: oklch(26.9% 0 none);
이름을 바꾸면서 덤을 하나 얻었는데, 지난 시간에 조금 어색했던 부분이 풀렸어요. muted(#8e8e8e)가 subtle(#737373)보다 연한데 이름만 들으면 반대로 읽혔거든요. 진하기 순서가 이제 이름 순서와 맞습니다.
ink #262626 진한 글자
subtle #737373 은은한 글자
faint #8e8e8e 희미한 글자
text-muted라고 적힌 일곱 곳을 이제 text-faint로 바꿉니다.
글꼴은 싸우지 않고 합칩니다. 프리셋이 고른 Geist도 나쁘지 않아서 로마자에는 잘 어울리지만, 우리가 적어둔 한글 글꼴 이름이 사라진 것은 되살려야 하니 프리셋 줄 뒤에 우리 목록을 이어 붙입니다.
/* apps/web-spa/src/styles/globals.css */
@theme inline {
--font-heading: var(--font-sans);
/* Geist 에는 한글 글리프가 없다. 프리셋이 준 줄에 우리 한글 글꼴을 도로 이어 붙인다.
로마자는 Geist 로, 한글은 그 뒤로 흘러 우리가 고른 글꼴로 그려진다. */
--font-sans: 'Geist Variable', system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;
글꼴 목록은 앞에서부터 찾다가 글자가 없으면 다음으로 넘어갑니다. 한 글자씩 따로 판단해서, 같은 문장 안에서도 영문은 Geist로 한글은 뒤쪽 글꼴로 그려집니다. 한 줄로 두 언어를 다 챙길 수 있는 이유예요.
모서리는 받아들입니다. 이건 되돌릴 수도 있었지만 그러지 않았는데, 앞으로 들어올 컴포넌트들이 저 눈금을 쓰거든요. 우리만 옛 값을 고집하면 우리 카드와 가져온 대화 상자의 모서리가 미묘하게 달라지는데, 모르고 당하는 것과 알고 받아들이는 것은 다릅니다. 위 표처럼 재보고 "2px 커지는구나, 괜찮다" 하고 넘어가는 것과, 나중에 디자이너가 "여기 왜 달라졌어요"라고 물었을 때 대답을 못 하는 것은 완전히 다른 상황이에요.
💡 한 줄 정리
라이브러리를 들이면 그쪽 이름과 우리 이름이 같은 공간에서 만납니다. 겹친 이름은 뒤에 온 쪽이 이기고, 클래스 이름만 보는 자동 확인은 이 사고를 못 잡습니다. 겹친 것마다 양보할지, 합칠지, 받아들일지를 따로 정하세요.
🙋 학생 질문 — "튜터님, 우리 토큰 이름을 전부 ig- 같은 걸로 시작하게 하면 안 겹치지 않나요?"
정확한 발상입니다. 실무에서 실제로 그렇게 합니다.
--color-ig-muted처럼 우리만의 표시를 앞에 붙여두면 남의 라이브러리와 절대 안 부딪혀요. 규모가 큰 팀일수록 이렇게 합니다.
우리가 오늘 그렇게 안 한 이유는 두 가지예요.
하나는 지금 겹친 게 아홉 개 중 하나뿐이라서입니다. 하나 때문에 여덟 개를 전부 고치고, 그것들을 쓰는 곳도 전부 고치는 건 손해가 커요.
다른 하나는 이름이 길어지면 매번 쓰기 번거롭기 때문입니다. text-ig-faint를 하루에 스무 번 쓰는 것과 text-faint를 쓰는 것은 꽤 다릅니다.
기준을 잡자면 이렇습니다. 라이브러리를 여러 개 들일 예정이면 처음부터 표시를 붙이고, 하나만 쓸 거면 겹칠 때 그때 옮기세요.
Step 4: "조건을 운영체제에서 표시로 옮긴다"
약속했던 그 줄로 이제 갑니다.
지난 시간에 우리가 만든 다크모드는 운영체제 설정을 그대로 따랐어요. 코드가 이렇게 생겼었죠.
@media (prefers-color-scheme: dark) {
:root {
--color-canvas: oklch(0% 0 none);
/* ... */
}
}
이 조건은 우리가 읽을 수만 있고 바꿀 수는 없습니다. 버튼을 눌러서 운영체제 설정을 바꿀 수는 없으니, 조건 자체를 우리가 만질 수 있는 것으로 바꿔야 합니다.
그 줄이 하는 일
이미 파일 위쪽에 들어와 있는 줄입니다.
/* apps/web-spa/src/styles/globals.css */
@custom-variant dark (&:is(.dark *));
이 줄은 dark:라는 변형의 뜻을 다시 정합니다. 원래 "운영체제가 어두울 때" 였던 것을 "위쪽 어딘가에 dark라는 표시가 붙어 있을 때"로 바꿔요.
표시는 우리가 붙였다 뗄 수 있으니, 이제 우리가 다크모드를 켜고 끌 수 있게 됐어요.
⚠️ 지난 시간에 본 줄과 글자가 다릅니다
여기서 꼭 짚고 가야 할 것이 있습니다. 지난 시간 마무리에서 제가 보여드린 줄은 이거였어요.
@custom-variant dark (&:where(.dark, .dark *));
지금 파일에 있는 것은 :where가 아니라 :is입니다. 여러분이 파일을 열어보고 "어? 튜터님이 보여준 거랑 다른데" 하실까 봐 정확히 말씀드립니다. 둘 다 동작하지만 같지는 않습니다.
차이가 두 개예요. 직접 재봤습니다.
하나, :is는 표시를 단 요소 자기 자신에는 안 걸립니다. .dark *는 ".dark의 자손"이라는 뜻이라 자손만 잡아요. :where(.dark, .dark *) 쪽은 목록에 .dark 자신도 들어 있어서 자기 자신에도 걸립니다.
둘, 힘이 다릅니다. E-3에서 특정도 이야기를 했었죠. 같은 급의 규칙이 부딪히면 더 구체적인 쪽이 이긴다고요.
.dark\:text-x:is(.dark *) 클래스 두 개 몫의 힘을 갖는다
.dark\:text-x:where(.dark, …) 힘이 0 이다 (:where 는 특정도를 안 더한다)
뒤에 오는 평범한 클래스 규칙과 부딪히면 :is 쪽은 이기고 :where 쪽은 집니다. shadcn이 :is를 고른 이유가 이거예요. 자기 컴포넌트의 dark: 유틸리티가 확실히 이기길 원한 겁니다.
우리는 이 줄을 그대로 둡니다. 도구가 골라준 쪽이 더 안전하니까요.
그런데 이 줄만으로는 우리 화면이 안 움직입니다
여기가 핵심입니다.
@custom-variant는 dark: 유틸리티의 뜻만 바꿉니다. dark:bg-canvas 같은 클래스가 언제 걸릴지를 정하는 거예요.
우리는 지난 시간에 dark: 유틸리티를 붙이는 대신 토큰 값을 한 곳에서 갈아끼우는 쪽을 골랐기 때문에, 우리 앱에는 dark:로 시작하는 클래스가 하나도 없습니다.
이 줄을 넣고 표시를 붙였다 떼봐도 화면은 꿈쩍도 안 해요.
실제로 움직여야 하는 것은 손으로 쓴 그 @media 블록입니다. 그 감싸개를 걷어내고 표시 조건으로 바꿉니다.
/* apps/web-spa/src/styles/globals.css */
/* 어두운 화면일 때 같은 이름에 다른 값을 담는다.
값은 한 개도 안 바뀌었다. 조건만 운영체제에서 표시(.dark)로 옮겨왔다.
누가 이 표시를 붙일지는 이제 화면 쪽에서 정한다. */
.dark {
--color-canvas: oklch(0% 0 none);
--color-surface: oklch(18.2% 0 none);
--color-ink: oklch(97% 0 none);
/* 회색 넷은 밝기를 뒤집되 서열은 그대로 둔다.
라이트에서 subtle 이 faint 보다 어두워 더 또렷했으므로
다크에서는 subtle 이 faint 보다 밝아야 한다. */
--color-faint: 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 는 안 바꾼다 — 브랜드색은 양쪽 화면에서 같아야 한다 */
}
@media (prefers-color-scheme: dark) { 한 줄과 짝이 되는 닫는 괄호를 지우고, :root를 .dark로 바꿨습니다. 색 값은 한 개도 안 건드렸어요. 지난 시간에 대비를 계산해서 고른 값들이 그대로 살아 있습니다.
빌드된 CSS를 뒤져보면 prefers-color-scheme이라는 글자가 이제 한 번도 안 나옵니다. 운영체제를 직접 보던 조건이 완전히 사라진 거예요.
손으로 붙였다 떼며 확인해봅시다
아직 버튼은 없지만 개발자 도구에서 <html>에 클래스를 직접 넣어볼 수 있습니다.
표시 없음 배경 #fafafa 카드 #ffffff 테두리 #dbdbdb
dark 붙임 배경 #000000 카드 #121212 테두리 #363636
다시 뗌 처음 값과 완전히 동일
지난 시간에 만든 팔레트가 그대로 나옵니다. 그리고 className은 여전히 한 글자도 안 고쳤어요. 값과 조건을 갈라 둔 설계가 여기서 한 번 더 값을 합니다. 조건을 통째로 갈아끼웠는데 값은 손도 안 댔으니까요.
💡 한 줄 정리
@custom-variant는 dark: 유틸리티의 뜻을 바꾸는 줄이고, 우리처럼 토큰을 갈아끼우는 방식에서는 그 줄이 아니라 @media 블록의 조건을 표시로 옮겨야 화면이 움직입니다.
🙋 학생 질문 — "튜터님, 그럼 @custom-variant 줄은 우리한테 필요 없는 거 아닌가요?"
지금 이 순간만 보면 그렇습니다. 우리 앱에 dark: 유틸리티가 하나도 없으니 그 줄은 놀고 있는데, 다음 시간에 컴포넌트를 가져오기 시작하면 사정이 달라집니다. 복사돼 오는 파일들을 열어보면 안에 dark:border-input 같은 클래스가 적혀 있어요. 그 컴포넌트들은 표시 방식을 전제하고 만들어졌거든요.
그러니까 저 줄은 우리를 위한 게 아니라 앞으로 들어올 컴포넌트를 위한 것입니다. 도구가 미리 깔아 둔 거예요.
만약 저 줄을 지우면 어떻게 될까요. 우리 화면은 멀쩡한데 가져온 컴포넌트만 어두운 모드에서 이상해집니다. 찾기 어려운 종류의 문제죠. 그래서 놀고 있어도 그냥 둡니다.
Step 5: "밝게 · 어둡게 · 시스템, 그리고 기억하기"
표시를 붙였다 뗄 버튼을 이제 만듭니다.
선택지는 둘이 아니라 셋입니다
해와 달을 오가는 버튼을 떠올리기 쉽지만, 그렇게 만들면 문제가 생깁니다.
지난 시간까지 우리 화면은 운영체제를 따라갔고, 그게 기본 동작이었죠. 켜고 끄는 버튼만 두면 한 번 누르는 순간 그 기본 동작으로 돌아갈 길이 없어집니다.
낮에는 밝게 밤에는 어둡게 자동으로 바뀌길 원하는 사람이 있어요. 그 사람에게 "밝게"와 "어둡게"만 주면 둘 다 틀린 답입니다.
밝게 운영체제가 어두워도 밝게
어둡게 운영체제가 밝아도 어둡게
시스템 운영체제를 따라간다 ← "내가 안 고르겠다" 는 선택
세 번째는 값이 아니라 "고르지 않음"이라는 상태예요. 이게 있어야 원래 자리로 돌아올 수 있습니다.
고른 것과 그릴 것을 나눕니다
여기서 설계가 하나 필요합니다. 사용자가 고른 것은 셋 중 하나지만, 화면에 실제로 그릴 수 있는 것은 둘뿐이에요. 밝거나 어둡거나죠.
고른 것(choice) 운영체제 그릴 것(resolved)
───────────────────── ────────── ─────────────────
밝게 (안 본다) 밝게
어둡게 (안 본다) 어둡게
시스템 어두움 → 어둡게
시스템 밝음 → 밝게
이 표를 그대로 함수로 옮깁니다.
// apps/web-spa/src/lib/theme.ts
/** 사용자가 고를 수 있는 것은 셋이다. '시스템' 은 "내가 안 고르겠다" 는 선택이다. */
export type ThemeChoice = 'light' | 'dark' | 'system';
/** 실제로 화면에 그릴 것은 둘뿐이다. */
export type ResolvedTheme = 'light' | 'dark';
/** 고른 것 + 운영체제 설정 → 실제로 그릴 화면. */
export function resolveTheme(choice: ThemeChoice, systemPrefersDark: boolean): ResolvedTheme {
if (choice === 'system') {
return systemPrefersDark ? 'dark' : 'light';
}
return choice;
}
A-2에서 배운 리터럴 유니온이 여기서 일하는데, ThemeChoice는 세 글자만 받아들이고 ResolvedTheme은 두 글자만 받아들여요. 둘을 다른 타입으로 둔 덕분에 "고른 것"을 실수로 "그릴 것" 쪽에 넣으면 타입이 막아줍니다.
저장소에서 온 값은 믿지 않습니다
사용자가 고른 값을 저장해야 다음에 왔을 때 되살릴 수 있습니다. 브라우저의 localStorage를 쓸 건데, 여기서 읽은 값은 조심해야 해요.
localStorage는 사용자가 개발자 도구로 열어서 손으로 고칠 수 있어서, '보라색' 같은 게 들어 있을 수도 있어요. 타입스크립트는 이걸 못 막는데, 실행할 때 벌어지는 일이니까요.
A-2에서 배운 사용자 정의 타입 가드를 씁니다.
// apps/web-spa/src/lib/theme.ts
const CHOICES: readonly ThemeChoice[] = ['light', 'dark', 'system'];
/** 저장소에서 온 값은 사용자가 손으로 고쳐 넣을 수 있으니 좁히고 나서 쓴다. */
export function isThemeChoice(value: unknown): value is ThemeChoice {
return typeof value === 'string' && CHOICES.includes(value as ThemeChoice);
}
/**
* 저장된 선택을 읽는다. 없거나 못 읽거나 모르는 값이면 '시스템' 으로 돌아온다.
* 사파리 시크릿 모드처럼 localStorage 접근 자체가 막히는 곳이 있어서 감싸 둔다.
*/
export function readStoredChoice(storage: Pick<Storage, 'getItem'>): ThemeChoice {
try {
const stored = storage.getItem(THEME_STORAGE_KEY);
return isThemeChoice(stored) ? stored : 'system';
} catch {
return 'system';
}
}
try로 감싼 이유를 짚고 갈게요. localStorage는 읽는 것만으로도 예외가 날 수 있습니다. 브라우저 설정에서 저장소를 막아둔 경우가 그런데, 그때 화면이 아예 안 뜨면 곤란하죠. 못 읽으면 그냥 시스템 설정을 따르게 두는 편이 낫습니다.
Pick<Storage, 'getItem'>은 A-3에서 배운 유틸리티 타입이에요. 이 함수는 getItem만 쓰니까 필요한 것만 받겠다는 뜻입니다.
운영체제 설정이 바뀌는 것도 지켜봅니다
'시스템'을 고른 사람은 운영체제 설정이 바뀌면 화면도 따라가야 합니다. 브라우저가 알려주는 방법이 있어요.
// apps/web-spa/src/hooks/useTheme.ts
const DARK_QUERY = '(prefers-color-scheme: dark)';
export function useTheme() {
const [choice, setChoice] = useState<ThemeChoice>(() => readStoredChoice(window.localStorage));
const [systemPrefersDark, setSystemPrefersDark] = useState(
() => window.matchMedia(DARK_QUERY).matches,
);
// 운영체제 설정은 우리가 바꿀 수 없고 그쪽에서 바뀌었다고 알려준다.
// '시스템' 을 고른 사람은 이 알림을 받아야 화면이 따라 움직인다.
useEffect(() => {
const query = window.matchMedia(DARK_QUERY);
const handleChange = (event: MediaQueryListEvent) => setSystemPrefersDark(event.matches);
query.addEventListener('change', handleChange);
return () => query.removeEventListener('change', handleChange);
}, []);
B-4에서 배운 모양 그대로입니다. 바깥 시스템에 귀를 붙이고, 정리 함수로 뗍니다. 안 떼면 컴포넌트가 사라진 뒤에도 알림이 계속 와요.
useState에 함수를 넘긴 것도 눈여겨보세요. A-5에서 다룬 게으른 초기화입니다. window.matchMedia(...)를 매번 부르지 않고 처음 한 번만 부릅니다.
나머지는 짧습니다.
// apps/web-spa/src/hooks/useTheme.ts
const resolved = resolveTheme(choice, systemPrefersDark);
// 색을 고르는 것은 CSS 가 하고, 여기서는 표시만 붙였다 뗀다.
useEffect(() => {
applyResolvedTheme(document.documentElement, resolved);
}, [resolved]);
function select(next: ThemeChoice) {
setChoice(next);
storeChoice(window.localStorage, next);
}
return { choice, resolved, select };
}
resolved를 상태로 두지 않았다는 점이 중요합니다. 고른 것과 운영체제 설정에서 계산되는 값이라 따로 저장할 이유가 없어요. B-4에서 "다른 상태로부터 계산되는 것은 상태로 두지 않는다" 고 했던 그 원칙입니다.
표시를 붙이는 일 자체는 한 줄이에요.
// apps/web-spa/src/lib/theme.ts
/** 표시를 붙이거나 뗀다. 색을 정하는 것은 CSS 쪽이고 여기는 표시만 만진다. */
export function applyResolvedTheme(root: Element, resolved: ResolvedTheme): void {
root.classList.toggle('dark', resolved === 'dark');
}
classList.toggle에 두 번째 인자를 주면 "이 조건이 참이면 붙이고 거짓이면 뗀다"가 됩니다. if로 나눠 쓸 필요가 없어요.
화면을 만듭니다
// apps/web-spa/src/components/ThemeToggle.tsx
// '시스템' 이 함께 있어야 "안 고르겠다" 로 돌아올 길이 생긴다
const OPTIONS: ReadonlyArray<{ value: ThemeChoice; label: string }> = [
{ value: 'light', label: '밝게' },
{ value: 'dark', label: '어둡게' },
{ value: 'system', label: '시스템' },
];
export function ThemeToggle() {
const { choice, select } = useTheme();
return (
<div className="flex gap-0.5 rounded-md border border-line p-0.5" role="group" aria-label="화면 밝기">
{OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={choice === option.value}
onClick={() => select(option.value)}
className={`cursor-pointer rounded-md px-2 py-0.5 text-note focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand ${
choice === option.value ? 'bg-ink font-semibold text-canvas' : 'text-faint'
}`}
>
{option.label}
</button>
))}
</div>
);
}
몇 가지만 짚을게요.
aria-pressed는 낭독기에게 "이 버튼은 지금 눌린 상태다"를 알려주는데, 색깔로만 표시하면 눈으로 보는 사람만 알 수 있잖아요. role="group"과 aria-label은 버튼 셋이 한 덩어리라는 것을 알려줍니다.
focus-visible:은 E-3에서 여덟 곳에 붙였던 그 포커스 링이에요. 새로 만드는 조작 가능한 요소에는 항상 따라옵니다.
그리고 고른 칸의 색을 보세요. bg-ink text-canvas인데, 지난 시간에 알림 상자를 고칠 때 쓴 짝이죠. 이 둘은 어두운 화면에서 서로 반대편으로 뒤집히니까, 다크모드용 색을 따로 안 정해도 저절로 반전됩니다.
마지막으로 머리말에 답니다.
// apps/web-spa/src/App.tsx
<header className="mb-4 flex items-baseline justify-between">
<h1 className="mb-4 text-2xl font-bold">인스타그램</h1>
<span className="text-sm text-faint">좋아요 누른 게시물 {likedCount}개</span>
<ThemeToggle />
</header>
이제 눌러보면 화면 전체가 바뀝니다. 새로고침해도 고른 것이 남아 있고요.
💡 한 줄 정리
선택지는 밝게·어둡게·시스템 셋이어야 원래 동작으로 돌아올 길이 생깁니다. 고른 것과 실제로 그릴 것을 다른 타입으로 나누면, 운영체제를 따라갈지 말지가 한 함수 안에서 정리됩니다.
🙋 학생 질문 — "튜터님, 저장은 localStorage 말고 다른 방법도 있지 않나요?"
있습니다. 그리고 서비스가 커지면 실제로 바꾸게 돼요.
localStorage는 브라우저 한 대에만 남습니다. 회사 컴퓨터에서 어둡게 골라도 집 노트북에서는 다시 물어봐요. 로그인이 있는 서비스라면 사용자 정보에 함께 저장하는 편이 나은데, 그러면 어느 기기에서 접속해도 같은 화면이 뜨죠.
쿠키를 쓰는 방법도 있습니다. 이건 Next.js를 배울 때 다시 만나게 될 텐데, 서버가 HTML을 만들어 보내는 방식에서는 서버도 그 값을 알아야 하거든요. localStorage는 브라우저 안에만 있어서 서버가 못 읽습니다.
지금 우리는 브라우저에서만 도는 앱이라 localStorage로 충분합니다. 저장하는 곳만 바꾸면 되도록 readStoredChoice가 저장소를 인자로 받게 해뒀어요.
Step 6: "새로고침할 때 한 번 번쩍이는 것"
거의 다 됐는데 하나가 남았습니다. 어둡게 골라둔 상태에서 새로고침을 해보세요. 어두운 화면이 뜨기 직전에 흰 화면이 한 번 스칩니다.
왜 그럴까요
순서를 따라가 보면 답이 나옵니다.
1 브라우저가 HTML 을 읽는다 <html> 에 표시 없음 → 밝은 화면
2 CSS 를 읽는다 밝은 값이 준비된다
3 화면을 한 번 그린다 ← 여기서 흰 화면이 보인다
4 자바스크립트를 읽고 React 가 뜬다
5 useTheme 이 저장된 값을 읽는다
6 <html> 에 dark 를 붙인다 → 이제야 어두워진다
3번과 6번 사이가 문제입니다. 우리가 표시를 붙이는 코드는 React 안에 있는데, React는 화면이 한 번 그려진 다음에 뜨거든요.
실제로 재봤는데, 저장값을 dark로 두고 새로고침을 세 번 하면서 각 시점에 표시가 있는지 확인했어요.
회차 HTML 을 다 읽은 시점 React 가 표시를 붙인 시각
1 표시 없음 80.5 ms
2 표시 없음 42.6 ms
3 표시 없음 55.9 ms
시각은 회차마다 널뛰는데, 기계 상태나 캐시에 따라 다릅니다. 그러니 "몇 밀리초 동안 번쩍인다"는 식으로 외우지 마세요.
변하지 않는 것은 왼쪽 칸입니다. HTML 을 다 읽은 시점에 표시가 한 번도 없었어요. 이게 확실한 사실입니다. 그리고 이 틈은 느린 기기나 느린 인터넷에서 더 벌어지는데, 자바스크립트 파일이 늦게 도착할수록 흰 화면을 오래 보게 돼요.
그리기 전으로 옮깁니다
해결은 간단합니다. 표시를 붙이는 일을 화면이 그려지기 전으로 옮기면 돼요. HTML의 <head> 안에 작은 스크립트를 두면 됩니다.
<!-- apps/web-spa/index.html -->
<script>
// 저장해 둔 선택을 화면이 그려지기 전에 읽어 표시를 붙인다.
// React 가 붙이면 이미 늦다 — 그 사이에 밝은 화면이 한 번 그려진다.
// 여기서는 모듈을 못 부른다(기다리는 순간 늦어진다). 그래서 lib/theme.ts 의
// 이름과 규칙을 이 몇 줄이 그대로 베껴 쓴다. 한쪽을 고치면 다른 쪽도 고쳐야 한다.
try {
var stored = localStorage.getItem('ig-theme');
var choice = stored === 'light' || stored === 'dark' ? stored : 'system';
if (choice === 'dark' || (choice === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch (e) {
// 저장소를 못 읽는 곳에서는 그냥 밝게 시작한다
}
</script>
고친 뒤에 다시 재보면 HTML을 읽는 시점에 이미 dark가 붙어 있어서, 흰 화면이 안 보여요.
이 스크립트에는 조건이 몇 개 있습니다.
<head> 안에 있어야 합니다. 몸통이 그려지기 전에 실행돼야 하니까요.
type="module"을 붙이면 안 됩니다. 모듈 스크립트는 HTML을 다 읽은 뒤에 실행되도록 정해져 있어서, 붙여서 확인해보면 깜빡임이 그대로 돌아옵니다. 게다가 Vite가 그 코드를 별도 파일로 빼내기 때문에 파일을 받아오는 시간까지 더해집니다.
defer는 어떨까요. 붙여도 아무 일도 안 일어나고, 깜빡임도 안 돌아와요. defer는 src로 파일을 불러오는 스크립트에만 적용되고, 내용을 직접 적은 스크립트에서는 무시되도록 정해져 있거든요. 효과가 없다고 붙여둘 이유는 없으니 빼는 편이 낫습니다. 읽는 사람이 "이건 미뤄지는구나" 하고 오해하니까요.
파일을 부르지 않고 내용을 직접 씁니다. 별도 파일로 두면 그 파일을 받아오는 동안 또 기다리게 되거든요.
⚠️ 대신 같은 규칙이 두 곳에 살게 됐습니다
정직하게 짚고 갈 부분이 있습니다. 방금 그 몇 줄은 lib/theme.ts가 하는 일을 그대로 베껴 쓴 것이에요. 저장소 이름 'ig-theme'도 두 곳에 적혀 있고, "시스템이면 운영체제를 본다"는 규칙도 두 곳에 있습니다.
좋은 코드의 원칙에는 어긋납니다. 그런데 피할 방법이 없어요. import를 쓰는 순간 그 파일을 받아오길 기다려야 하고, 기다리면 늦어지니까요.
이럴 때 하는 일은 중복을 없애는 게 아니라 중복이 어긋나는 것을 막는 것입니다. 한쪽 이름만 바꾸고 다른 쪽을 잊으면, 저장은 되는데 안 읽히는 상태가 됩니다. 화면은 멀쩡히 뜨고 새로고침할 때만 이상해지니 찾기도 어려워요.
그래서 두 곳의 이름이 갈라지면 곧바로 알 수 있게 확인을 하나 걸어뒀습니다. 이런 종류의 어쩔 수 없는 중복에는 감시를 붙여두는 것이 실무의 답입니다.
💡 한 줄 정리
React는 화면이 한 번 그려진 뒤에 뜨므로, 저장된 테마를 React 안에서 적용하면 늦습니다. <head>의 인라인 스크립트로 옮기면 첫 그림부터 제 색으로 나오고, 대신 생기는 중복은 감시로 막습니다.
🙋 학생 질문 — "튜터님, 그 몇 줄을 그냥 main.tsx 맨 위에 넣으면 안 되나요?"
좋은 질문이에요. 훨씬 나아지긴 합니다. React가 뜨기 전에는 실행되니까요.
그런데 완전히 없어지지는 않습니다. main.tsx는 <script type="module">로 불러오는데, 모듈 스크립트는 미루기가 기본이거든요. 브라우저가 HTML을 다 읽고 나서 실행합니다. 게다가 그 파일은 따로 받아와야 하니 도착할 때까지 기다려야 하죠.
정리하면 이렇습니다.
index.html 의 인라인 <script> HTML 을 읽다가 그 자리에서 바로 실행
main.tsx (모듈) HTML 을 다 읽고 + 파일이 도착한 뒤에 실행
느린 인터넷에서 차이가 크게 벌어집니다. 그래서 이 기법을 쓰는 곳은 거의 다 index.html 안에 직접 넣어요.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 남의 규칙을 들이고, 겹치는 이름을 정리하고, 다크모드의 스위치를 우리 손으로 가져왔습니다.
지난 시간까지는 우리 것만 있었죠. 오늘은 처음으로 다른 사람이 만든 규칙이 우리 프로젝트 안으로 들어왔습니다.
오늘 배운 핵심 세 가지
💡 하나 — 설치는 명령 한 줄로 끝나지 않습니다. 들어온 쪽도 이름을 갖고 있어서 우리 이름과 부딪힙니다. 겹친 것마다 양보할지, 합칠지, 받아들일지를 정하는 것까지가 설치예요.
💡 둘 — 자동 확인이 통과했다고 화면이 멀쩡한 게 아닙니다. 우리가 걸어둔 확인들은 클래스 이름을 봅니다. 이름이 그대로인데 그 이름이 가리키는 값이 바뀌면 아무것도 못 잡아요. 스타일을 건드린 뒤에는 눈으로 봐야 합니다.
💡 셋 — 값과 조건을 갈라 두면 조건만 통째로 갈아끼울 수 있습니다. 운영체제를 보던 것을 표시를 보는 것으로 바꿨는데, 지난 시간에 고른 색은 한 개도 안 버렸어요.
시작할 때 남아 있던 것
오프닝에서 지난 시간의 약속을 이야기했죠. 어떻게 닫혔는지 봅시다.
| 약속했던 것 | 닫힌 곳 |
|---|---|
@custom-variant를 실제로 쓴다 |
Step 4 — 다만 우리에게 필요했던 것은 @media 블록의 조건이었습니다 |
| 표시를 붙였다 떼는 버튼을 만든다 | Step 5 — 둘이 아니라 셋으로 |
| 첫 화면 깜빡임 | Step 6 — 그리기 전으로 옮겨서 |
다음 시간 예고
오늘 우리는 라이브러리를 들여놓기만 했습니다. components/ui/ 폴더는 아직 비어 있어요. 컴포넌트를 하나도 안 썼는데 화면이 깨졌다가 고쳐진 것이 오늘의 전부였습니다.
다음 시간에 드디어 가져다 씁니다. 명령 하나로 파일이 복사돼 오는 걸 보고, 그 파일을 열어서 읽어봅니다. 남이 짠 코드지만 이제 내 파일이니까요.
읽다 보면 처음 보는 것들이 나옵니다. 하나의 컴포넌트가 large와 small을 어떻게 구분하는지, 클래스 이름이 충돌할 때 무엇이 정리해주는지, 그리고 data-slot이라는 표시가 왜 붙어 있는지요.
그리고 오늘 첫머리에서 이야기한 그 모달을 만듭니다. 포커스를 가두고, Esc로 닫고, 낭독기에 알리는 것을 우리가 한 줄도 안 짜고 얻게 돼요. Step 1의 목록을 다시 꺼내서 하나씩 맞춰볼 겁니다.
그때 오늘 놀고 있던 @custom-variant 줄도 드디어 일을 시작합니다.
과제
[구현] 프리셋이 데려온 글꼴에 값을 매기기
프리셋을 고르자 Geist라는 글꼴이 따라 들어왔습니다. 우리가 부탁한 적은 없죠. 이걸 계속 둘지 직접 판단해 보세요.
- 빌드한 뒤 산출물 폴더에서 글꼴 파일들을 찾아 개수와 크기 합계를 적어 주세요.
- 그중 브라우저가 실제로 내려받는 것이 몇 개인지 개발자 도구의 네트워크 탭에서 확인해 주세요. 파일 개수와 다르다면 왜 그런지 CSS에서 근거를 찾아보세요.
- 화면에서 로마자(
jaehoon같은 사용자 이름)와 한글이 각각 어떤 글꼴로 그려지는지 확인해 주세요. 눈으로 말고 글자 폭을 재서 견주면 확실합니다. globals.css에서 Geist를 부르는 줄과 글꼴 목록에서 Geist를 빼고 화면을 견줘 보세요. 무엇이 달라지나요?- 남길지 뺄지 정하고, 그 이유를 한 문단으로 적어 주세요. 우리 화면에서 로마자가 차지하는 비중을 근거로 삼으면 좋습니다.
- 어느 쪽으로 정하든 한글이 우리가 고른 글꼴로 그려지는지 마지막에 다시 확인해 주세요.
[구현] 고른 것을 다른 탭에도 알려주기
지금은 탭을 두 개 열어두고 한쪽에서 어둡게 고르면 다른 탭은 그대로입니다. 새로고침해야 따라와요.
- 브라우저가
localStorage변화를 알려주는 방법을 찾아보세요.window에 붙는 이벤트가 하나 있습니다. useTheme에 그 이벤트를 구독하는 부분을 더해서, 다른 탭에서 고른 값이 이 탭에도 반영되게 만드세요.- 정리 함수를 빠뜨리지 마세요. B-4에서 배운 그대로입니다.
- 탭 두 개를 열고 한쪽에서 눌렀을 때 다른 쪽이 따라오는지 확인해 주세요.
- 자기 탭에서 고른 것 때문에 불필요하게 다시 그려지지는 않는지도 확인해 보세요.
[탐구] 겹친 이름 다뤄보기
각 실험 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
globals.css의 우리@theme블록을 shadcn이 붙인@theme inline블록 뒤로 통째로 옮기고, 지웠던--color-muted도 되살려 놓으세요. 그리고text-muted를 아무 요소에나 하나 붙인 다음 빌드해 보세요. 오늘 Step 3에서 그 이름을 전부text-faint로 바꿨기 때문에, 쓰는 데를 하나 만들어 주지 않으면 빌드된 CSS에 그 이름으로 아무것도 안 생깁니다 — 왜 그런지는 E-1에서 배웠죠. 이제 우리가 이기나요?- 그렇게 해서 이겼다면, 그 상태에서
bg-muted라는 클래스를 아무 요소에나 붙여 보세요. 어떤 색이 나오나요? 이 방법을 안 고른 이유를 여기서 설명할 수 있나요? @theme inline에서inline이라는 글자만 지우고 빌드해 보세요. 빌드된 CSS에서--color-muted를 찾아보면 있나요, 없나요?index.html의 인라인 스크립트에type="module"을 붙이고 새로고침해 보세요. 깜빡임이 돌아오나요? 그리고 빌드된dist/index.html을 열어보면 그 코드가 아직 거기 있나요?
생각해볼 주제
1. 남의 이름과 내 이름이 부딪혔을 때 누가 양보해야 할까요
오늘 우리는 muted라는 이름을 shadcn에게 넘기고 우리 것을 faint로 옮겼습니다. 앞으로 들어올 컴포넌트들이 그 이름을 쓰고 있으니 우리가 옮기는 편이 쌌기 때문이에요.
그런데 반대 상황도 있습니다. 우리 디자인 시스템이 이미 완성돼 있고 문서까지 있고 다른 팀도 그 이름을 쓰고 있다면요. 그때는 우리가 옮기는 값이 훨씬 비쌉니다.
이 판단을 무엇으로 할까요. 그리고 양보하지 않기로 했다면, 남의 라이브러리가 우리 이름을 안 건드리게 만드는 방법에는 어떤 것들이 있을지 생각해 보세요.
2. 자동 확인이 못 잡는 것을 어떻게 잡을까요
오늘 가장 무서웠던 순간은 화면이 깨졌는데 자동 확인은 전부 통과한 순간이었습니다. 클래스 이름은 그대로였고, 그 이름이 가리키는 색만 바뀌었으니까요.
색이나 간격이나 글꼴이 바뀐 것을 자동으로 잡으려면 무엇을 확인해야 할까요. 화면을 그림으로 찍어서 예전 그림과 견주는 방법도 있고, 대비를 계산해서 기준 아래면 알려주는 방법도 있습니다.
각각 무엇을 잡을 수 있고 무엇을 놓칠지, 그리고 그 확인을 유지하는 데 드는 비용은 어떨지 생각해 보세요. 모든 화면을 그림으로 찍어두면 버튼 하나만 옮겨도 전부 다시 찍어야 하니까요.
3. 어쩔 수 없는 중복을 어떻게 다룰까요
Step 6에서 우리는 같은 규칙을 두 곳에 적었습니다. 없앨 수 있었다면 없앴겠지만, 없애면 늦어져서 넣은 뜻이 사라지는 상황이었죠.
개발을 하다 보면 이런 상황을 종종 만납니다. 원칙대로 하면 느려지고, 빠르게 하면 원칙이 깨지는 곳이요.
이럴 때 무엇을 남겨두면 나중에 온 사람이 사고를 안 낼까요. 주석, 확인, 이름 짓기, 파일 위치 중에 어떤 것이 실제로 효과가 있을지 생각해 보세요. 그리고 여러분이 지금까지 본 코드에서 "이건 왜 두 번 적혀 있지" 싶었던 곳이 있었다면, 거기에는 무엇이 있었으면 좋았을지도요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 프리셋이 데려온 글꼴에 값을 매기기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 파일 개수와 크기를 실제로 쟀는가 | 20 | 짐작이 아니라 산출물 폴더에서 확인 |
| 받는 파일이 있는 파일보다 적다는 것을 알아냈는가 | 30 | 네트워크 탭에서 1개만 확인 + unicode-range 근거 |
| 로마자와 한글을 나눠서 확인했는가 | 25 | 둘의 결과가 다르다는 것을 짚음 |
| 결정에 근거가 붙었는가 | 15 | "느낌상" 이 아니라 잰 값으로 |
| 되돌린 뒤 한글을 다시 확인했는가 | 10 | 마지막 확인 습관 |
풀이 예시
하나, 파일 개수와 크기
빌드하면 글꼴 파일이 다섯 개 나옵니다.
geist-cyrillic-ext 7,420 B
geist-vietnamese 8,004 B
geist-cyrillic 15,084 B
geist-latin-ext 16,512 B
geist-latin 29,400 B
─────────────────────────────
합계 76,420 B (약 74.6 KB)
둘, 실제로 받는 것은 한 개입니다
네트워크 탭을 보면 geist-latin 하나만 내려오고, 전송된 크기는 29,700 B였는데, 나머지 넷은 요청조차 안 갑니다.
빌드된 CSS를 열어보면 이유가 나오는데, 각 @font-face마다 unicode-range가 붙어 있어요.
geist-cyrillic unicode-range: U+301, U+400-45F, ... 키릴 문자
geist-vietnamese unicode-range: U+102-103, U+110-111, ... 베트남어
geist-latin unicode-range: U+??, U+131, U+152-153, ... 기본 로마자
브라우저는 화면에 그 범위의 글자가 실제로 나올 때만 해당 파일을 받으므로, 우리 화면에 키릴 문자가 없으면 그 파일은 안 받아요.
그러니 "74.6 KB를 낭비한다"는 틀린 계산이고, 실제 비용은 29.7 KB예요.
셋, 로마자와 한글은 결과가 다릅니다
눈으로는 구분이 어려우니 글자 폭을 재서 견줍니다. 캔버스로 같은 문자열을 다른 글꼴 목록에 넣어 재보면 돼요.
| 잰 것 (16px) | Geist 넣기 전 | 지금 |
|---|---|---|
로마자 jaehoon likes this post |
160.58 | 163.22 |
한글 인스타그램좋아요누른게시물 |
179.92 | 179.92 |
로마자만 바뀌었을 뿐, 한글은 그대로예요.
한글 폭 179.92는 Apple SD Gothic Neo로 직접 재본 값과 정확히 같습니다. 즉 한글은 목록을 흘러 내려가 그 글꼴에 도착하고 있어요. Geist를 넣기 전에도 목록 첫머리가 system-ui였고 거기에도 한글이 없어서, 어차피 같은 곳으로 흘러가고 있었습니다.
넷, 결정
남기기로 했습니다. 근거는 이렇습니다.
- 실제 비용이 29.7 KB로 생각보다 작아서, 우리 자바스크립트 묶음이 300 KB가 넘는 것에 비하면 10%도 안 돼요.
- 우리 화면에서 로마자가 차지하는 비중이 작지 않습니다. 사용자 이름이 전부 로마자예요.
- 앞으로 가져올 컴포넌트들이 이 프리셋의 생김새를 전제로 만들어져 있습니다.
빼기로 했다면 근거는 이쪽이 됩니다. 우리 서비스가 한국어 사용자만 쓰고 로마자가 사용자 이름 정도뿐이라면 29.7 KB는 아까운데, 특히 느린 망을 쓰는 사용자가 많다면 더 그렇습니다. 어느 쪽이든 잰 값을 근거로 삼았다면 정답입니다.
다섯, 마지막 확인
남기기로 했으니 글꼴 목록에 한글 글꼴 이름이 살아 있는지 다시 봅니다.
--font-sans: 'Geist Variable', system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;
자주 나오는 실수
"파일이 다섯 개니까 76 KB를 받는다"로 계산하기. 가장 흔한 실수인데, 산출물 폴더의 크기와 실제 전송량은 다릅니다. unicode-range로 쪼개는 것은 글꼴 전달의 기본 기법이라, 파일 개수를 보고 겁먹으면 잘못된 결정을 하게 됩니다.
한글이 안 보이는 것을 근거로 "한글이 깨졌다" 고 적기. 화면은 멀쩡하고 눈으로 보면 아무 차이가 없어서, 폭을 재라고 한 겁니다.
💡 튜터의 한마디
이 과제의 진짜 목표는 글꼴이 아니라 "딸려온 것에 값을 매기는 습관"입니다.
라이브러리를 들이면 우리가 고르지 않은 것들이 함께 들어오는데, 글꼴이나 아이콘이나 애니메이션 유틸리티 같은 것들이 그렇습니다. 그걸 전부 그대로 두면 프로젝트가 조용히 무거워지고, 전부 걷어내면 라이브러리를 쓰는 뜻이 없어집니다.
가운데를 찾으려면 재야 해요. 그리고 재봤더니 76 KB가 아니라 29.7 KB였던 것처럼, 재보면 짐작과 다른 경우가 많습니다.
🎯 [과제 2 예시답안] 고른 것을 다른 탭에도 알려주기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
storage 이벤트를 찾아냈는가 |
20 | window에 붙는 이벤트 |
| 우리 키만 걸러냈는가 | 25 | 다른 키의 변화에 반응하면 감점 |
| 저장소에서 온 값을 좁혔는가 | 25 | 다른 탭에서 온 값도 손댈 수 있다 |
| 정리 함수를 넣었는가 | 20 | 안 떼면 계속 쌓인다 |
| 자기 탭에는 안 온다는 것을 확인했는가 | 10 | 확인만 하면 됨 |
풀이 예시
storage 이벤트는 같은 사이트의 다른 탭에서 저장소가 바뀌었을 때 옵니다.
// apps/web-spa/src/hooks/useTheme.ts
// 다른 탭에서 고르면 이 탭에도 알려준다. 자기 탭에서 고른 것은 안 온다.
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== THEME_STORAGE_KEY) {
return;
}
setChoice(isThemeChoice(event.newValue) ? event.newValue : 'system');
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
세 가지를 짚을게요.
키를 거르는 것이 먼저입니다. storage 이벤트는 그 사이트의 모든 저장소 변화에 오는데, 장바구니를 저장하든 무엇을 저장하든 다 와요. event.key를 확인하지 않으면 엉뚱한 변화에 화면이 바뀝니다.
저장소에서 온 값은 여기서도 좁히는데, Step 5에서 readStoredChoice에 타입 가드를 붙였던 것과 같은 이유예요. 다른 탭이라고 해서 더 믿을 만한 게 아닙니다. 같은 사람이 개발자 도구로 고칠 수 있는 건 마찬가지니까요.
isThemeChoice를 재사용하면 규칙이 한 곳에만 있게 됩니다.
newValue가 null 일 수 있는데, 저장소를 통째로 비우면 그렇게 와요. 위 코드는 그때 'system'으로 돌아갑니다. isThemeChoice(null)이 false 이니 자연스럽게 처리돼요.
정리 함수를 빠뜨리면 컴포넌트가 사라진 뒤에도 이벤트가 계속 오고, 없는 컴포넌트의 상태를 바꾸려 들게 됩니다. B-4에서 다룬 그대로입니다.
확인
탭 두 개를 열고 한쪽에서 "어둡게"를 누르면 다른 탭도 어두워집니다.
자기 탭에서는 이벤트가 안 오는데, 이건 브라우저가 그렇게 정해둔 동작이에요. 자기가 바꾼 것을 자기가 다시 듣는 것은 의미가 없으니, 덕분에 우리가 따로 막을 필요가 없습니다. 만약 자기 탭에도 온다면 select로 이미 바꾼 상태를 한 번 더 바꾸게 돼서 불필요하게 다시 그렸을 거예요.
자주 나오는 실수
키를 안 거르기. 지금 우리 앱은 저장소에 테마 하나만 넣으니 겉보기엔 잘 돌아갑니다. 나중에 저장하는 것이 늘어나면 그때부터 이상해져요. 지금 안 걸러도 안 티가 나는 종류의 실수라 더 위험합니다.
event.newValue를 그대로 setChoice에 넣기. 타입스크립트가 막아줍니다. newValue는 string | null이라 ThemeChoice 쪽에 못 들어가요. 그런데 as ThemeChoice로 억지로 넣는 답안이 종종 나옵니다. A-3에서 이야기한 그대로, as는 검사를 끄는 것이지 통과하는 게 아닙니다.
💡 튜터의 한마디
이 과제는 작아 보이지만 실무에서 자주 만나는 문제입니다. 한 브라우저 안에 우리 앱이 여러 개 떠 있을 수 있다는 것을 처음 의식하게 되는 대목이거든요.
로그아웃이 특히 그렇습니다. 한 탭에서 로그아웃했는데 다른 탭이 여전히 로그인 상태로 남아 있으면 곤란하죠. 그때도 같은 방법을 씁니다.
🎯 [과제 3 예시답안] 겹친 이름 다뤄보기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 우리를 뒤에 두면 이긴다는 것을 확인했는가 | 25 | 빌드 CSS로 확인 |
| 이겼을 때 무엇이 망가지는지 짚었는가 | 30 | 이 실험의 핵심 |
inline 유무의 차이를 정확히 설명했는가 |
25 | 두 조건을 나눠서 확인 |
type="module" 결과를 확인했는가 |
20 | 깜빡임 + 코드 위치 |
풀이 예시
실험 1 — 우리를 뒤에 두면 이깁니다
우리 @theme 블록을 shadcn 것 뒤로 옮기고 --color-muted를 되살린 뒤 빌드하면 이렇게 나옵니다.
--color-muted: oklch(64.7% 0 none) 우리 값이 살아났다
.text-muted { color: var(--color-muted) } 우리 것을 가리킨다
이깁니다. 같은 층에서 같은 이름을 쓰면 나중 것이 이긴다는 규칙 그대로예요.
실험 2 — 그런데 이기면 다른 게 망가집니다
같은 상태에서 bg-muted를 아무 요소에나 붙여 보면 이렇게 나옵니다.
.bg-muted { background-color: var(--color-muted) }
우리 --color-muted는 중간 회색인 보조 글자색 #8e8e8e인데, 그게 이제 배경색으로 쓰입니다.
shadcn 컴포넌트들은 bg-muted를 "아주 옅은 배경면"으로 쓰려고 만들어졌어요. 원래 값은 흰색에 가까운 회색이었죠. 거기에 중간 회색이 들어가면 드롭다운 배경이나 비활성 영역이 칙칙하게 나옵니다.
이게 우리가 이 방법을 안 고른 이유입니다. 싸움에서 이기는 것은 쉬워요. 이긴 다음에 상대편 컴포넌트가 전부 이상해지는 게 문제죠. 그리고 그 이상함은 아직 그 컴포넌트를 안 썼기 때문에 지금은 안 보입니다. 다음 시간에 컴포넌트를 가져오고 나서야 드러나요.
실험 3 — inline을 지우면
이건 두 조건을 나눠서 봐야 정확합니다.
| 상태 | --color-muted 선언 |
.bg-muted가 가리키는 것 |
|---|---|---|
inline 있음 + bg-muted 안 씀 |
없음 | (규칙 자체가 안 생김) |
inline 없음 + bg-muted 안 씀 |
없음 | (규칙 자체가 안 생김) |
inline 있음 + bg-muted 씀 |
없음 | var(--muted) |
inline 없음 + bg-muted 씀 |
var(--muted) |
var(--color-muted) |
inline만 지우고 확인하면 여전히 없어서 "차이가 없네" 하고 결론 내리기 쉬운데, 그건 다른 이유입니다. 안 쓰는 토큰은 애초에 안 나옵니다. E-1에서 "안 쓴 유틸리티는 애초에 안 생긴다"를 봤죠. 토큰도 같습니다.
실제로 써봐야 차이가 드러납니다. inline이 있으면 변수를 안 만들고 곧바로 var(--muted)를 써 넣고, 없으면 --color-muted라는 변수를 하나 더 만들어서 두 번 거칩니다.
실험 4 — type="module"을 붙이면
깜빡임이 돌아옵니다. 모듈 스크립트는 HTML을 다 읽은 뒤에 실행되도록 정해져 있으니까요.
그리고 dist/index.html을 열어보면 그 코드가 없습니다. Vite가 모듈 스크립트를 별도 파일로 빼내서 묶었기 때문이에요. 그러니 이제 그 파일을 받아오는 시간까지 더해져서, 붙이지 말아야 할 이유가 하나 더 생긴 셈이죠.
💡 튜터의 한마디
실험 2가 오늘 과제의 핵심입니다.
이름 싸움에서 이길 수 있는 것과 이겨야 하는 것은 다릅니다. 우리가 나중에 쓰기만 하면 언제든 이길 수 있지만, 이겨서 얻는 것은 이름 하나고 잃는 것은 앞으로 가져올 모든 컴포넌트의 정상 동작입니다.
실험 3이 알려주는 습관도 하나 있습니다. "차이가 없다"는 결론은 조건을 바꿔가며 확인한 뒤에 내리세요. 안 쓰고 있어서 안 보이는 것과 원래 없는 것은 다릅니다.
🤔 [생각해볼 주제 1] 남의 이름과 내 이름이 부딪혔을 때 누가 양보해야 할까요
문제 상황 요약
우리는 muted를 넘기고 우리 것을 faint로 옮겼습니다. 앞으로 들어올 컴포넌트들이 그 이름을 이미 쓰고 있어서였죠. 그런데 우리 디자인 시스템이 이미 문서화돼 있고 다른 팀도 그 이름을 쓰고 있다면 이야기가 달라집니다.
튜터의 가이드 및 해설
옮기는 비용을 세어보는 것부터 시작합니다.
이름 하나를 바꾸면 무엇이 따라오는지 실제로 세어 보세요. 우리는 일곱 곳이었고, 그 정도면 십 분이면 끝나죠. 다른 팀 세 곳이 그 이름을 쓰고 있고 디자인 문서와 피그마에도 적혀 있고 이미 배포된 서비스가 있다면, 완전히 다른 계산이 됩니다.
기준을 하나 드리면 이렇습니다. 이름을 바꿀 때 함께 고쳐야 하는 곳이 우리 저장소 밖으로 나가는 순간 비용이 몇 배로 뜁니다. 저장소 안에서 끝나면 찾아 바꾸기로 해결되지만, 밖으로 나가면 다른 사람의 일정과 협의가 끼어들거든요.
양보하지 않기로 했다면 방법은 세 가지입니다.
첫째, 우리 이름에 표시를 붙입니다. --color-ig-muted처럼요. 확실하지만 모든 사용처를 고쳐야 하니 지금 옮기는 것과 비용이 비슷한데, 한 번 하면 다음 라이브러리와는 안 부딪힙니다. 라이브러리를 여러 개 들일 계획이면 이쪽이 낫습니다.
둘째, 들여오는 쪽에 표시를 붙입니다. 도구에 따라 클래스 이름 앞에 붙일 글자를 정할 수 있어요. tw-bg-muted처럼 되게 하는 겁니다. 우리 것은 한 글자도 안 건드려도 되지만, 가져온 컴포넌트 안의 클래스 이름이 전부 달라져서 문서나 예제와 어긋납니다.
셋째, 겹치는 이름만 골라서 들여오지 않습니다. 우리가 오늘 한 화해의 반대 방향인데, 그 컴포넌트가 그 이름에 기대고 있으면 결국 깨집니다. 권하지 않아요.
판단의 축은 결국 "누가 더 많이, 더 멀리 퍼져 있는가"입니다.
라이브러리 쪽이 더 퍼져 있으면 우리가 옮기고, 우리 쪽이 더 퍼져 있으면 표시를 붙입니다. 그리고 이 판단은 들이기 전에 해야 해요. 설치하고 나서 화면이 깨진 다음에 하면 이미 늦습니다. 오늘 우리가 겪은 게 그거였죠.
🎯 면접관을 홀리는 핵심 멘트
"이름 충돌은 기술 문제가 아니라 범위 문제라고 봅니다. 저는 라이브러리를 들이기 전에 그쪽이 쓰는 토큰 이름을 먼저 확인하고, 우리 이름과 겹치는 목록부터 뽑습니다. 그다음 각 이름이 우리 저장소 안에서 끝나는지 밖으로 나가는지를 봅니다. 저장소 안이면 우리가 옮기는 게 싸고, 다른 팀이나 배포된 문서까지 걸쳐 있으면 접두사를 붙여 우리 이름을 지킵니다. 실제로 최근 프로젝트에서 아홉 개 중 하나가 겹쳤는데, 사용처가 일곱 곳뿐이라 우리를 옮겼습니다. 그 판단을 설치 전에 했기 때문에 화면이 깨지는 일은 없었습니다."
🤔 [생각해볼 주제 2] 자동 확인이 못 잡는 것을 어떻게 잡을까요
문제 상황 요약
오늘 화면이 깨졌는데 우리가 걸어둔 확인은 전부 통과했습니다. 클래스 이름은 그대로였고 그 이름이 가리키는 색만 바뀌었으니까요.
튜터의 가이드 및 해설
먼저 왜 못 잡았는지를 정확히 짚어야 합니다.
우리 확인은 "컴포넌트가 text-muted라는 이름을 붙였는가"를 봅니다. 이건 컴포넌트가 자기 할 일을 했는가를 보는 확인이라 그건 잘 통과했습니다. 컴포넌트는 잘못한 게 없거든요.
깨진 것은 그 이름과 색을 잇는 연결이었습니다. 그 연결은 CSS 안에 있는데 우리 확인은 CSS를 안 보니, 못 잡는 게 당연해요. 확인이 부실했던 게 아니라 보는 대상이 달랐습니다.
이걸 구분하는 게 중요합니다. 안 그러면 "확인을 더 많이 쓰자"는 엉뚱한 결론으로 갑니다.
화면을 그림으로 찍어 견주는 방법
가장 직접적입니다. 오늘 같은 사고는 확실히 잡아요. 색이 바뀌면 그림이 달라지니까요.
대신 비용이 큰데, 버튼 하나를 일부러 옮겨도 그 화면이 전부 다시 찍혀야 하고 사람이 하나하나 "이건 의도한 변화"라고 승인해야 해요. 승인이 귀찮아지면 사람들이 안 보고 눌러버립니다. 그러면 확인이 있으나 마나가 되죠.
글꼴 렌더링도 기계마다 미세하게 달라서, 내 컴퓨터에서는 통과하고 다른 곳에서는 실패하는 일이 자주 납니다.
대비를 계산해서 기준 아래면 알려주는 방법
이쪽이 오늘 사고에 더 잘 맞습니다. 글자색과 뒷배경을 읽어서 대비를 계산하고, 기준 아래면 실패시키는 거예요. 오늘 1.09는 곧바로 걸렸을 겁니다.
장점은 의도한 변화와 사고를 구분한다는 것입니다. 디자인을 바꿔서 색이 달라져도 대비만 충분하면 통과해요. 그림 비교는 그걸 구분 못 하죠.
한계도 뚜렷합니다. 대비만 보니까 글꼴이 바뀌거나 간격이 틀어진 것은 못 잡아요. 오늘 우리가 겪은 세 가지 중 하나만 잡는 셈입니다.
결국 실무에서는 층을 나눕니다.
전부를 자동으로 잡으려 하면 비용이 감당이 안 됩니다. 대신 이렇게 나눠요.
- 꼭 지켜야 하는 것은 계산으로 확인합니다. 대비처럼 숫자로 판정되는 것들이요.
- 자주 바뀌는 화면은 그림 비교를 안 겁니다. 대신 몇 개의 대표 화면에만 걸어요.
- 나머지는 사람이 봅니다. 대신 "스타일을 건드린 변경에는 화면 사진을 붙인다" 같은 규칙으로 보게 만듭니다.
마지막이 제일 중요합니다. 자동화가 안 되는 영역이 반드시 남는데, 그걸 인정하고 사람이 보는 절차를 만드는 편이 억지로 자동화하다 실패하는 것보다 낫습니다.
🎯 면접관을 홀리는 핵심 멘트
"테스트가 통과했다는 것은 '테스트가 보는 것'이 정상이라는 뜻이지 화면이 정상이라는 뜻은 아니라고 생각합니다. 실제로 디자인 토큰 이름이 라이브러리와 충돌해서 글자 대비가 3.28에서 1.09로 떨어진 적이 있는데, 클래스 이름을 검사하는 테스트는 전부 통과했습니다. 컴포넌트는 잘못한 게 없었으니까요. 그 뒤로 저는 확인을 층으로 나눕니다. 숫자로 판정되는 접근성 기준은 자동으로 걸고, 전체 화면 비교는 대표 화면에만 걸고, 나머지는 스타일 변경 시 사진을 첨부하게 해서 사람이 보게 만듭니다. 자동화가 안 닿는 영역을 인정하는 게 시작이라고 봅니다."
🤔 [생각해볼 주제 3] 어쩔 수 없는 중복을 어떻게 다룰까요
문제 상황 요약
첫 화면 깜빡임을 없애려고 같은 규칙을 두 곳에 적었습니다. 없애면 늦어져서 넣은 뜻이 사라지는 상황이었죠.
튜터의 가이드 및 해설
먼저 이 중복이 어떤 종류인지 구분해야 합니다.
중복에는 두 가지가 있습니다. 게을러서 생긴 중복과 제약 때문에 생긴 중복이요. 앞의 것은 그냥 합치면 되지만, 뒤의 것은 합치려 들면 다른 게 망가져요.
우리 경우는 뒤쪽입니다. 합치려면 import를 써야 하고, 쓰는 순간 기다리게 되고, 기다리면 깜빡임이 돌아옵니다. 없앨 수 없다는 것을 먼저 인정하는 게 출발점입니다.
여기서 흔한 실수가 억지로 합치는 겁니다. 빌드할 때 그 값을 HTML에 끼워 넣는 도구를 만들 수도 있어요. 그러면 중복은 사라지지만 빌드 설정이 복잡해지고, 나중에 온 사람은 "이 값이 어디서 오는 거지" 하고 더 헤매게 됩니다. 중복을 없앤 대가로 이해하기 어려움을 얻는 거죠. 좋은 거래가 아닐 때가 많습니다.
남길 것 네 가지를 견줘보면 효과가 다릅니다.
주석은 가장 싸고 가장 약합니다. 읽어야 효과가 있는데, 고치는 사람이 그 파일을 안 열 수도 있어요. 우리 경우는 lib/theme.ts만 고치고 index.html은 안 여는 상황이 그렇습니다. 그래도 이유를 남기는 값은 있는데, "왜 두 번 적혀 있지"라는 의문에 답해주니까요.
확인은 가장 확실합니다. 두 곳이 갈라지면 곧바로 실패하니 안 읽어도 걸려요. 우리가 이걸 건 이유입니다.
다만 확인에도 함정이 있습니다. 저는 처음에 이름이 들어 있는지만 봤는데, 그러면 ig-theme을 ig-theme-어쩌고로 바꿔도 통과합니다. 앞부분이 같으니까요. 가드를 만들었으면 일부러 깨서 진짜 잡히는지 확인해야 합니다. 통과하는 확인은 그 자체로는 아무것도 증명하지 않아요.
이름 짓기는 은근히 강합니다. 두 곳에 같은 이름을 쓰고, 그 이름이 검색해서 다 나오게 만드는 거예요. 고치는 사람이 이름으로 한 번 훑으면 다른 쪽이 나옵니다.
파일 위치는 효과가 제한적입니다. 나란히 두면 좋지만, 우리처럼 하나는 HTML이고 하나는 타입스크립트면 나란히 둘 수가 없어요.
정리하면 확인이 일차 방어선이고, 주석은 왜 그런지를 설명하는 보조입니다. 둘 다 있어야 해요. 확인만 있으면 "왜 실패하지" 하고 확인을 지워버릴 수 있고, 주석만 있으면 안 읽으면 그만이거든요.
🎯 면접관을 홀리는 핵심 멘트
"저는 중복을 없앨 수 있는 것과 없앨 수 없는 것으로 나눕니다. 없앨 수 없는 쪽은 억지로 합치면 대개 더 이해하기 어려운 코드가 나오더라고요. 대신 갈라지는 순간 실패하는 테스트를 겁니다. 최근에 첫 페인트 전에 테마를 적용하느라 저장소 키를 HTML 인라인 스크립트와 모듈 양쪽에 두게 됐는데, 두 값이 어긋나면 실패하는 테스트를 붙였습니다. 그런데 처음 만든 테스트가 부분 문자열만 봐서 키를 바꿔도 통과하더군요. 그 뒤로는 가드를 만들면 반드시 일부러 깨뜨려서 실제로 잡히는지 확인합니다. 통과하는 테스트는 아직 아무것도 증명하지 않았다고 생각해서요."