E-2: Tailwind CSS-first 설정 — 이번엔 값에 이름을 준다
목차 60
안녕하세요, 홍순구입니다. 지난 시간에 Avatar를 유틸리티로 옮기면서 클래스 이름을 하나도 안 짓고 스타일을 붙였죠.
마무리에서 제가 하나 보여드렸는데요, globals.css를 열면 #dbdbdb라는 테두리 색이 열한 번 적혀 있었습니다. 테두리 색을 한 번 바꾸려면 열한 곳을 고쳐야 한다는 뜻이었어요.
#0095f6 파란색과 #ed4956 빨간색도 마찬가지인데, 이 색들은 Tailwind가 몰라서 bg-brand 같은 걸 쓸 수가 없습니다.
오늘은 그걸 해결하는데, 방법이 조금 뜻밖이에요. 지난 시간에 우리는 이름 짓기를 그만뒀는데, 오늘은 클래스가 아니라 값에 다시 이름을 줍니다.
오늘의 여정
Step 1 같은 색이 열한 번 적혀 있다
│
Step 2 색을 무슨 표기로 적을까
│
Step 3 @theme 과 :root 는 무엇이 다른가
│
Step 4 어떤 값에 이름을 줄까
│
Step 5 피드를 토큰 유틸리티로
│
Step 6 댓글과 폼까지
▼
우리 색이 이름을 얻고, 화면 전체가 그 이름으로 그려진다
💡 오늘 수업의 핵심 — "값에 이름을 주면, 그 이름의 유틸리티가 저절로 생긴다"
지난 시간에는 Tailwind가 미리 정해둔 값만 골라 썼는데, 오늘은 우리 값을 Tailwind에게 알려줍니다. 알려주고 나면 border-line 같은 유틸리티를 우리가 만들지 않았는데도 쓸 수 있게 돼요.
🎯 학습 목표
- 여러 곳에 반복되는 값을
@theme으로 등록하고, 그 이름의 유틸리티가 생기는 것을 빌드된 CSS에서 확인할 수 있다 @theme에 쓰는 것과:root에 쓰는 것이 무엇이 다른지 두 가지로 설명할 수 있다- OKLCH 표기를 읽고, 우리 색을 그 표기로 옮겨 적어도 화면이 같은지 직접 확인할 수 있다
- 어떤 값에 이름을 줄지 기준을 세우고, 기준에 못 미치는 값은 임의 값으로 넘길 수 있다
Step 1: "같은 색이 열한 번 적혀 있다"
먼저 지금 globals.css에 같은 값이 얼마나 반복되는지 정확히 세어 봅시다. 느낌으로 "많다" 고 하면 해결됐는지도 알 수 없거든요.
색을 세어 봅시다
터미널에서 세어 볼게요.
grep -oiE '#[0-9a-f]{6}' apps/web-spa/src/styles/globals.css | sort | uniq -c | sort -rn
결과는 이렇습니다.
| 색 | 횟수 | 어디에 쓰이나 |
|---|---|---|
#dbdbdb |
11 | 카드·버튼·입력창 테두리 |
#ffffff |
9 | 카드와 패널 배경, 토스트 글자 |
#8e8e8e |
6 | 흐린 글자 (댓글 수, 구역 제목) |
#ed4956 |
4 | 좋아요 누른 상태, 오류 메시지 |
#737373 |
4 | 라벨과 설명 글자 |
#fafafa |
3 | 페이지 배경, 작은 버튼 배경 |
#262626 |
3 | 본문 글자 |
#0095f6 |
2 | 제출 버튼 |
#efefef |
1 | 댓글 입력줄 위쪽 경계선 |
#b2dffc |
1 | 잠긴 제출 버튼 글자 |
화면은 회원가입 폼 하나와 게시물 카드 두 장인데, 색 열 종류가 마흔네 번 적혀 있습니다.
토스트 배경에 쓴 rgba(0, 0, 0, 0.82)는 위 명령에 안 걸립니다. #으로 시작하는 표기만 찾았거든요. 색을 적는 방법이 하나가 아니라서 생기는 일인데, 이 이야기는 다음 Step에서 이어집니다.
일괄 치환으로는 안 됩니다
"그냥 편집기에서 #dbdbdb를 전부 바꾸면 되지 않나요?" 싶으실 거예요. 실제로 많이들 그렇게 합니다.
문제는 같은 값이 다른 뜻으로 쓰일 때 생기는데, #fafafa를 보면 페이지 배경에도 쓰이고 작은 버튼 배경에도 쓰입니다. 페이지를 조금 더 밝게 하고 싶어서 일괄 치환하면 버튼 색까지 함께 바뀌어 버려요.
값은 같지만 하는 일이 다른 건데, CSS 파일에는 그 하는 일이 안 적혀 있어요. #fafafa 라고만 적혀 있죠.
더 큰 문제도 있는데, 지난 시간에 확인했듯 이 색들로는 유틸리티를 못 씁니다. bg-white는 되는데 bg-[우리 파랑]은 이름이 없으니까요.
@theme으로 알려주기
Tailwind에게 우리 값을 알려주는 곳이 @theme입니다. globals.css 맨 위에 넣어 봅시다.
/* apps/web-spa/src/styles/globals.css */
@import "tailwindcss";
@source not "../../scratch";
@source not "../**/*.test.ts*";
@theme {
--color-line: #dbdbdb;
}
--color-line이라고 이름을 지었는데, 테두리에 쓰는 색이라는 뜻이에요. #dbdbdb라는 값 대신 line이라는 용도로 부르기 시작한 겁니다.
빌드해 보면 — 아무 일도 안 일어납니다
이제 빌드해서 CSS가 어떻게 나오는지 봅시다.
npm run build --workspace apps/web-spa
빌드된 CSS가 몇 바이트인지 재 볼게요. dist/assets/에 생긴 파일 크기를 세는 명령입니다.
python3 -c "import glob,os; print(len(open(sorted(glob.glob('apps/web-spa/dist/assets/*.css'), key=os.path.getmtime)[-1]).read()))"
9221이 나옵니다.
지난 시간 마지막에 잰 것과 똑같은 숫자라 한 글자도 안 늘었는데, 빌드된 CSS에서 --color-line을 찾아봐도 안 나오고 .border-line을 찾아봐도 안 나옵니다.
당황스러우실 텐데, 사실 이건 지난 시간에 배운 것과 같은 이야기예요. 안 쓴 유틸리티는 애초에 안 생긴다 고 했죠. 토큰도 똑같아서, 등록만 해두고 아무 데서도 안 쓰면 CSS에 나오지 않습니다.
즉, @theme에 값을 적는 것은 "이 CSS를 만들어라"가 아니라 "이런 이름을 쓸 수 있게 준비해 둬라"입니다.
써 보면 생깁니다
게시물 카드의 테두리 색을 이 이름으로 바꿔 봅시다. 먼저 카드에 유틸리티를 붙입니다.
// apps/web-spa/src/components/PostCard.tsx
<Card
className="post-card border-line"
header={<PostHeader username={username} profileImageUrl={profileImageUrl} />}
footer={<CommentForm onSubmit={(text) => dispatch({ type: 'add', content: text })} />}
>
globals.css의 .post-card에서는 색만 걷어내고, 굵기와 선 종류는 아직 남겨둡니다.
.post-card {
background: #ffffff;
border: 1px solid;
border-radius: 8px;
margin-bottom: 24px;
overflow: hidden;
}
다시 빌드하고 크기를 재면 9278로 57 바이트가 늘었는데, 늘어난 게 뭔지 CSS에서 직접 찾아봅시다.
grep -o "\-\-color-line:[^;}]*" apps/web-spa/dist/assets/*.css
grep -o "\.border-line{[^}]*}" apps/web-spa/dist/assets/*.css
--color-line:#dbdbdb
.border-line{border-color:var(--color-line)}
두 가지가 함께 생겼습니다. 값을 담은 CSS 변수 하나와, 그 변수를 쓰는 유틸리티 클래스 하나요.
⚠️ 빌드된 CSS는 공백을 다 없앤 한 줄이라서 grep -c로 세면 안 됩니다. grep -c는 매치된 줄 수를 세는데 전체가 한 줄이라 언제나 1이 나오거든요. 개수를 보려면 위처럼 grep -o로 뽑아서 세야 합니다.
우리는 .border-line이라는 클래스를 만든 적이 없고 --color-line이라는 이름을 알려줬을 뿐인데, Tailwind가 그 이름으로 유틸리티를 만들어 줬습니다.
이름 앞머리가 유틸리티 종류를 정합니다
여기서 --color-라는 앞머리가 중요한데, 이게 "이건 색이다"라고 알려주는 표시거든요. 색이라고 알려주면 색을 쓰는 유틸리티가 전부 생깁니다.
| 앞머리 | 생기는 유틸리티 |
|---|---|
--color- |
bg- · text- · border- · ring- · outline- · divide- |
--text- |
text- (글자 크기) |
--radius- |
rounded- |
--font- |
font- |
--color-line 하나를 등록하면 bg-line·text-line·border-line·ring-line·outline-line·divide-line이 한꺼번에 준비되어, 색을 쓸 수 있는 모든 곳에 쓸 수 있게 됩니다. 실제로 쓴 것만 CSS에 나오는 건 이번에도 마찬가지고요.
앞머리를 바꾸면 종류가 바뀌는데, --color-line 대신 --text-line이라고 적으면 Tailwind는 글자 크기로 알아듣습니다. 그때는 border-line은 안 생기고 text-line이 글자 크기 유틸리티가 되므로, 이름을 지을 때 앞머리를 먼저 정해야 해요.
나머지 색도 등록합시다
이름은 값이 아니라 용도로 짓습니다. --color-gray-300이 아니라 --color-line인 이유예요. 나중에 회색을 조금 바꾸더라도 "테두리에 쓰는 색"이라는 뜻은 그대로니까요.
/* apps/web-spa/src/styles/globals.css */
@theme {
--color-brand: #0095f6;
--color-danger: #ed4956;
--color-line: #dbdbdb;
--color-line-soft: #efefef;
--color-muted: #8e8e8e;
--color-subtle: #737373;
--color-ink: #262626;
--color-canvas: #fafafa;
--color-surface: #ffffff;
}
canvas는 페이지 바닥, surface는 그 위에 얹히는 카드 배경입니다. 둘 다 지금은 거의 흰색이라 굳이 나눌 이유가 없어 보이실 텐데, 어두운 화면을 다루는 시간이 오면 이 둘이 갈라집니다.
아홉 개를 다 적어도 빌드된 CSS는 여전히 9278입니다. 실제로 쓴 건 border-line 하나뿐이니까요.
💡 한 줄 정리
@theme은 값에 이름을 붙여 Tailwind에게 알려주는 곳이고, 알려준 이름으로 유틸리티가 저절로 생깁니다. 등록만 해서는 CSS가 한 글자도 안 느는데, 지난 시간의 규칙 그대로 실제로 쓴 것만 나오기 때문이에요.
🙋 학생 질문 — "border-[#dbdbdb]로 그냥 쓰면 안 되나요?"
됩니다. 대괄호 안에 값을 직접 넣으면 Tailwind가 그 값으로 만들어 주고요, 지난 시간 과제에서 이미 써보셨을 수도 있어요.
이렇게 하면 #dbdbdb가 여전히 열한 번 적히게 되는데, globals.css에 열한 번 적혀 있던 게 컴포넌트 파일 열한 곳으로 옮겨간 것뿐이에요. 고칠 때 열한 곳을 찾아다녀야 하는 건 똑같습니다.
이름을 주면 값이 한곳에만 남습니다. --color-line을 한 줄 고치면 그 이름을 쓰는 곳이 전부 따라 바뀌어요.
모든 값에 이름을 줘야 하냐면 그건 아닌데, 한 번만 쓰는 값에 이름을 붙이면 이름만 늘어나고 얻는 게 없거든요. 그럼 어떤 값에 이름을 줄지 기준은 Step 4에서 정합니다.
Step 2: "색을 무슨 표기로 적을까"
Step 1 끝에서 걸리는 게 하나 있었는데, 토스트 배경에 쓴 rgba(0, 0, 0, 0.82)는 우리가 돌린 명령에 안 걸렸습니다.
색을 적는 방법이 하나가 아니라서 그렇습니다. #dbdbdb처럼 적을 수도 있고, rgb(219 219 219) 나 hsl(0 0% 86%)로 적어도 전부 같은 색입니다.
그럼 우리는 무슨 표기로 적어야 할까요. 답을 정하기 전에 Tailwind가 자기 색을 무슨 표기로 적는지부터 봅시다.
Tailwind의 색을 직접 꺼내 봅시다
Tailwind에는 blue-500, neutral-300 같은 색이 이미 잔뜩 있어서, 그 값이 궁금하면 빌드된 CSS에서 찾아보면 되겠죠.
그냥 찾으면 안 나오는데, Step 1에서 본 규칙 때문이에요. 안 쓰는 값은 CSS에 나오지 않습니다. 우리는 Tailwind 기본 색을 하나도 안 쓰고 있으니 팔레트가 통째로 없어요.
잠깐 쓰게 만들어야 하니, 아무 컴포넌트에나 한 줄 넣고 빌드해 보세요.
// 확인용입니다. 보고 나서 지웁니다.
<div className="bg-neutral-50 bg-neutral-300 bg-neutral-500 bg-neutral-800 bg-blue-500" />
npm run build --workspace apps/web-spa
grep -o "\-\-color-neutral-[0-9]*:[^;}]*" apps/web-spa/dist/assets/*.css
grep -o "\-\-color-blue-500:[^;}]*" apps/web-spa/dist/assets/*.css
--color-neutral-50:oklch(98.5% 0 none)
--color-neutral-300:oklch(87% 0 none)
--color-neutral-500:oklch(55.6% 0 none)
--color-neutral-800:oklch(26.9% 0 none)
--color-blue-500:oklch(62.3% .214 259.815)
다섯 개 전부 oklch인데, Tailwind v4의 색 눈금이 이 표기로 되어 있어요.
딱 두 개는 예외인데, 흰색과 검정은 --color-white: #fff · --color-black: #000로 hex예요. 눈금의 양 끝이라 굳이 색 공간을 따질 게 없거든요. 이 둘은 나중에 text-white를 쓸 때 실제로 보게 됩니다.
확인이 끝났으면 방금 넣은 줄은 지우세요. 안 지우면 안 쓰는 색 다섯 개가 CSS에 계속 남습니다.
숫자 세 개를 읽는 법
oklch는 색을 숫자 세 개로 적습니다.
oklch(65.46% 0.177 248.2)
│ │ │
│ │ └── H 색상각 0~360
│ └───────── C 채도, 0 이면 회색
└───────────────── L 명도 0~100%
- L은 밝기입니다. 0% 면 검정, 100% 면 흰색이에요.
- C는 색의 진하기입니다. 0이면 채도가 없어서 회색이 됩니다.
- H는 색상환의 각도예요. 어떤 색 계열인지를 정합니다.
회색을 보면 oklch(55.6% 0 none)처럼 마지막이 none입니다. 채도가 0이면 각도를 정해봐야 의미가 없어서 "없음"이라고 적는 거예요.
왜 이 표기를 쓸까
익숙한 hsl도 밝기를 숫자로 갖고 있는데, 그 숫자가 눈에 보이는 밝기와 안 맞아요. 직접 재보면 바로 드러납니다.
색상각만 다른 다섯 색을 놓고, 한 번은 hsl의 밝기를 50% 로 통일하고 한 번은 oklch의 명도를 60% 로 통일한 뒤, 각각이 눈에 얼마나 밝게 보이는지를 상대 휘도로 쟀습니다.
| 색상각 | hsl(H 100% 50%) |
oklch(60% 0.15 H) |
|---|---|---|
| 90 (노랑) | 76.1 | 21.7 |
| 140 (초록) | 72.2 | 23.2 |
| 230 (남색) | 8.9 | 23.5 |
| 264 (보라) | 10.0 | 21.2 |
| 300 (자홍) | 28.5 | 20.1 |
| 최대 − 최소 | 67.2 | 3.4 |
hsl에서 밝기를 똑같이 50% 로 맞췄는데 실제로 보이는 밝기는 8.9에서 76.1까지 벌어집니다. 즉 노란색은 눈부시고 남색은 캄캄해요.
oklch는 같은 실험에서 편차가 3.4입니다. 색상각이 달라져도 보이는 밝기가 거의 안 변합니다.
두 열의 숫자 크기 자체를 견주면 안 됩니다. 기준으로 삼은 밝기(50% 와 60%)가 다르니까요. 여기서 볼 것은 한 열 안에서 값이 얼마나 흔들리는가입니다.
이게 왜 중요하냐면, 색을 조금 밝게 하거나 어둡게 할 때 숫자 하나만 만지면 되기 때문인데, hsl에서는 밝기를 10% 올렸을 때 색마다 결과가 제각각이라 눈으로 다시 맞춰야 하거든요.
나중에 다크모드를 할 때 이 차이를 체감하게 됩니다. 어두운 화면용 색을 만들 때 명도만 조절하면 되거든요.
우리 색을 옮겨 적기
이제 Step 1에서 등록한 아홉 개를 oklch로 바꿔 봅시다.
/* apps/web-spa/src/styles/globals.css */
@theme {
--color-brand: oklch(65.46% 0.177 248.2);
--color-danger: oklch(64.1% 0.2 20.5);
--color-line: oklch(89.1% 0 none);
--color-line-soft: oklch(95.2% 0 none);
--color-muted: oklch(64.7% 0 none);
--color-subtle: oklch(55.6% 0 none);
--color-ink: oklch(26.9% 0 none);
--color-canvas: oklch(98.5% 0 none);
--color-surface: oklch(100% 0 none);
}
빌드하고 화면을 보세요. 표기만 바꿨을 뿐 같은 색이라 아무것도 안 바뀌어야 합니다.
⚠️ 변환은 눈대중으로 하면 안 됩니다
이 값들은 계산해서 나온 것이라, "파란색이니까 대충 이 정도"로 적으면 안 됩니다.
제가 실제로 눈대중으로 적어봤다가 확인한 결과입니다.
| 색 | 원래 값 | 눈대중으로 적은 결과 |
|---|---|---|
brand |
0, 149, 246 | 0, 132, 236 |
danger |
237, 73, 86 | 233, 61, 82 |
line |
219, 219, 219 | 216, 216, 216 |
초록 성분이 17이나 차이 나서, 파란색이 눈에 띄게 탁해졌습니다.
변환은 도구에 맡기세요. 색을 다루는 편집기 확장이나 온라인 변환기가 많거든요. 중요한 건 변환한 뒤에 확인하는 것이에요. 개발자 도구에서 옮기기 전 색과 옮긴 뒤 색을 나란히 놓고 같은지 보세요.
⚠️ 우리 회색 셋은 이미 Tailwind에 있었습니다
값을 옮겨 적고 나니 눈에 걸리는 게 있습니다. 위에서 꺼내 본 Tailwind 팔레트와 견줘 보세요.
| 우리 것 | 값 | Tailwind |
|---|---|---|
--color-subtle |
oklch(55.6% 0 none) |
neutral-500과 같음 |
--color-ink |
oklch(26.9% 0 none) |
neutral-800과 같음 |
--color-canvas |
oklch(98.5% 0 none) |
neutral-50과 같음 |
--color-line |
oklch(89.1% 0 none) |
neutral-300은 87%로 다름 |
셋은 글자까지 똑같은데, 화면에 칠해서 재봐도 같은 색이라 text-neutral-500이라고 써도 text-subtle과 똑같이 나옵니다.
지난 시간에 제가 지목했던 #0095f6·#ed4956·#dbdbdb는 Tailwind에 정말 없는 색이 맞고, line은 아깝게 빗나갔는데, 오늘 아홉 개를 등록하면서 새로 들어온 회색 셋은 사정이 달랐던 겁니다.
그런데도 이름을 주는 이유가 있습니다. neutral-500은 값을 부르는 이름이고 subtle은 용도를 부르는 이름이거든요.
text-neutral-500이라고 적힌 곳을 보면 "회색 500 번"인 건 알겠는데 왜 회색인지는 모릅니다. text-subtle은 "덜 중요해서 흐리게 두는 글자"라고 말해줘요. 나중에 그 글자를 조금 더 진하게 바꾸기로 하면, 용도로 이름을 지은 쪽만 한 줄 고쳐서 끝납니다.
다크모드에서 이 차이가 결정적으로 드러납니다. 어두운 화면에서는 canvas와 surface의 밝기가 통째로 반대편으로 넘어가거든요.
💡 한 줄 정리
Tailwind의 색 눈금은 oklch로 되어 있습니다(흰색과 검정만 hex예요). 색상각이 달라져도 보이는 밝기가 거의 안 변해서(편차 67.2 대 3.4) 색을 조절하기 좋기 때문이에요. 우리 색도 같은 표기로 옮겨 적되, 변환은 도구에 맡기고 옮긴 뒤 눈으로 확인합니다.
🙋 학생 질문 — "그냥 hex로 두면 안 되나요?"
됩니다. @theme은 표기를 가리지 않아서, #0095f6이라고 적어도 유틸리티는 똑같이 생기고 화면도 같아요.
실제로 디자인 시안은 hex로 오는 경우가 많은데, 그걸 굳이 변환하다가 값이 틀어지면 손해라서 그대로 두는 것도 충분히 합리적인 선택입니다.
우리는 왜 옮겼냐면 두 가지 때문인데, 하나는 Tailwind 기본 팔레트와 표기가 통일돼서 나란히 놓고 견주기 편하다는 것이고, 다른 하나는 나중에 다크모드용 색을 만들 때 명도 숫자만 만지면 된다는 것입니다.
한 가지만 조심하세요. 팀에서 표기를 섞어 쓰면 "이 둘이 같은 색인가?"를 매번 눈으로 확인해야 합니다. 어느 쪽을 고르든 한 파일 안에서는 통일하는 게 낫습니다.
Step 3: "@theme과 :root는 무엇이 다른가"
Step 1에서 --color-line을 찾았을 때는 값만 잘라서 보고, 그 변수가 어디에 담겨 있는지는 안 봤습니다. 셀렉터만 뽑아 봅시다.
grep -o ":root[^{]*{" apps/web-spa/dist/assets/*.css
:root,:host{
우리가 등록한 토큰은 여기 담겨 나옵니다. @theme이라고 적었는데 결과는 :root인 거예요.
:root는 선수 과목에서 이미 배운 건데, CSS 변수를 선언하는 곳이었죠. 그럼 이런 생각이 듭니다. 어차피 :root로 나올 거면 그냥 :root에 쓰면 되지 않나?
직접 해봅시다.
대조군을 만들어 봅시다
globals.css에 :root를 하나 두고, 눈에 확 띄는 색을 넣습니다.
/* apps/web-spa/src/styles/globals.css */
:root {
--color-probe: #ff00ff;
}
카드에 bg-probe를 붙여 봅니다. @theme에 등록했을 때 border-line이 생겼으니, 같은 방식이면 bg-probe도 생겨야겠죠.
// apps/web-spa/src/components/PostCard.tsx
<Card className="post-card border-line bg-probe" ... >
빌드하고 화면을 보세요. 카드 배경이 안 바뀝니다. 자홍색이 나와야 하는데 그대로 흰색이에요.
CSS에서 찾아봅시다.
grep -o "\-\-color-probe:[^;}]*" apps/web-spa/dist/assets/*.css
grep -o "\.bg-probe{[^}]*}" apps/web-spa/dist/assets/*.css
--color-probe:#f0f
변수는 나왔습니다. #ff00ff가 #f0f로 짧아졌을 뿐 분명히 있는데, .bg-probe는 아무것도 안 나옵니다. 두 번째 명령은 결과가 비어 있어요.
클래스는 HTML에 붙었는데 그 클래스에 해당하는 CSS가 없는 겁니다. 지난 시간 마지막에 본 조용한 실패와 같은 모습이에요.
차이 하나 — 유틸리티를 만드는가
:root는 CSS 변수를 만드는 곳이고, 그게 전부입니다. 브라우저에게 "이런 변수가 있다" 고 알려줄 뿐이에요.
그런데 @theme은 Tailwind 에게 알려주는 곳입니다. Tailwind가 그 이름을 읽고 유틸리티를 만들어 주는데, 그 결과로 CSS 변수도 함께 내놓는 거예요.
| CSS 변수가 생기나 | 유틸리티가 생기나 | |
|---|---|---|
:root { --color-probe } |
생김 | 안 생김 |
@theme { --color-line } |
생김 | 생김 |
:root에 쓴 값은 CSS 안에서 var(--color-probe)로 꺼내 쓸 수는 있지만, bg-probe 같은 클래스는 못 씁니다.
차이 둘 — 언제 나오는가
하나 더 있습니다. 방금 붙인 bg-probe를 도로 떼고 빌드해 보세요.
크기를 재면 9315로 붙였을 때와 똑같습니다. bg-probe를 쓰든 안 쓰든 CSS가 1 바이트도 안 달라져요. 애초에 만들어진 게 없으니 당연합니다.
그런데 --color-probe는 어떨까요. 아무도 안 쓰는데도 여전히 CSS에 들어 있습니다.
grep -o "\-\-color-probe:[^;}]*" apps/web-spa/dist/assets/*.css
--color-probe:#f0f
반면 @theme 쪽은 정반대인데, 우리가 아홉 개를 등록했지만 지금 실제로 쓰는 건 border-line 하나뿐이죠. 나머지 여덟 개를 찾아보세요.
grep -o "\-\-color-brand:[^;}]*" apps/web-spa/dist/assets/*.css
grep -o "\-\-color-muted:[^;}]*" apps/web-spa/dist/assets/*.css
둘 다 안 나옵니다. brand·danger·muted·subtle·ink·canvas·surface 전부 없어요.
| 안 쓰면 | |
|---|---|
:root |
그래도 나옴 |
@theme |
안 나옴 |
:root는 우리가 직접 쓴 CSS라 Tailwind가 손대지 않습니다. 우리가 적었으니 그대로 내보내는 거지만, @theme은 Tailwind가 관리하니까 안 쓰는 건 걸러냅니다.
확인이 끝났으면 :root 블록과 bg-probe는 지우세요.
그럼 :root는 언제 쓰나
@theme이 더 좋아 보이니 항상 그걸 쓰면 될 것 같지만, 그렇진 않습니다.
유틸리티로 쓸 일이 없고 CSS 안에서만 필요한 값이라면 :root가 맞습니다. 예를 들어 애니메이션 지속 시간이나 특정 레이아웃 계산에만 쓰는 값 같은 것들이요. @theme에 넣으면 안 쓸 유틸리티까지 준비되니 이름만 늘어납니다.
기준은 간단합니다. 클래스로 쓸 값이면 @theme, CSS 안에서만 쓸 값이면 :root.
💡 한 줄 정리
:root는 CSS 변수를 만들고, @theme은 Tailwind에게 값을 알려줍니다. :root로는 유틸리티가 안 생기는 대신 아무도 안 써도 그대로 출력되고, @theme은 유틸리티를 만들어 주는 대신 안 쓰는 건 걸러내요.
🙋 학생 질문 — "@theme 안에서 var()로 다른 토큰을 참조해도 되나요?"
권장하지 않습니다. @theme은 Tailwind가 빌드 시점에 읽어서 유틸리티를 만드는 곳인데, 값이 다른 변수를 가리키고 있으면 그 시점에 실제 값을 모르거든요.
색을 조합해서 파생시키고 싶다면 값을 그대로 두 번 적는 게 안전합니다. 중복이 마음에 걸리시겠지만, 토큰은 몇 개 안 되고 한곳에 모여 있어서 관리가 어렵지 않아요.
한 가지 예외처럼 보이는 게 있습니다. 나중에 다크모드를 하면 같은 이름의 값을 조건에 따라 바꾸게 되는데, 그건 참조가 아니라 덮어쓰기라 사정이 다릅니다.
Step 4: "어떤 값에 이름을 줄까"
색은 정리됐으니, 이제 나머지 값들을 봅시다. 간격, 글자 크기, 모서리 둥글기요.
색과 똑같이 하면 될 것 같지만, 세어보면 사정이 좀 다릅니다.
먼저 세어 봅시다
grep -oE 'font-size: *[0-9]+px' apps/web-spa/src/styles/globals.css | grep -oE '[0-9]+' | sort -n | uniq -c
| 글자 크기 | 횟수 |
|---|---|
| 12px | 3 |
| 13px | 6 |
| 14px | 16 |
| 18px | 1 |
| 24px | 1 |
간격으로 쓴 값은 4·6·8·10·12·16·18·24·32px 이고, 모서리는 6·8·20px 입니다.
이걸 전부 @theme에 등록해야 할까요? 등록하기 전에 확인할 게 있습니다. Tailwind 가 이미 갖고 있는 값인지부터 봐야 해요.
간격은 이미 전부 있었습니다
Tailwind의 간격은 --spacing 하나에서 나오는데, 기본값이 0.25rem, 즉 4px 이에요. p-3은 그 값의 3 배입니다.
.p-3{padding:calc(var(--spacing) * 3)}
곱하는 수라서 정수가 아니어도 됩니다. p-2.5는 2.5 배니까 10px, p-4.5는 4.5 배니까 18px 이에요.
우리가 쓴 간격을 4로 나눠 보세요. 4·8·12·16·24·32는 딱 떨어지고, 6·10·18은 1.5·2.5·4.5가 됩니다. 전부 눈금 안에 있어서, 간격 토큰은 하나도 필요 없어요.
글자 크기는 하나만 벗어납니다
기본 눈금을 꺼내 봅시다. Step 2에서 쓴 방법 그대로예요.
| 이름 | 값 | px |
|---|---|---|
text-xs |
0.75rem |
12 |
text-sm |
0.875rem |
14 |
text-base |
1rem |
16 |
text-lg |
1.125rem |
18 |
text-2xl |
1.5rem |
24 |
12·14·18·24가 전부 있습니다. 13px 만 없어요.
⚠️ 지난 시간 과제에서 .post-more의 font-size: 18px을 옮기면서 대괄호를 썼다면, 이제 text-lg로 바꿀 수 있습니다. 눈금에 있는 값이었어요. text-lg는 줄 높이도 28px 로 함께 정하니, leading-none은 그대로 필요합니다.
또한 모서리도 마찬가지입니다. rounded-md가 6px, rounded-lg가 8px 이라 우리 값 두 개는 이미 있고, 20px 만 없습니다.
기준을 세웁시다
그럼 13px 과 20px 은 어떻게 할까요. 둘 다 눈금 밖인데, 처리를 다르게 합니다.
기준은 두 가지를 함께 봅니다.
눈금에 있나?
│
├── 있다 ──────────────> 그대로 쓴다 (text-sm, p-3, rounded-lg)
│
└── 없다 ──> 몇 번 쓰나?
│
├── 여러 번 ──> 이름을 준다 (@theme)
│
└── 한 번 ──> 대괄호로 그 자리에서 넘긴다
13px 은 여섯 번 쓰여서 이름을 줄 만한데, 20px 모서리와 470px 폭은 각각 한 번뿐이라 이름을 주면 이름만 늘어납니다.
/* apps/web-spa/src/styles/globals.css */
@theme {
--text-note: 0.8125rem;
}
0.8125rem이 13px 인데, 16으로 나눈 값이에요. Tailwind 기본 눈금이 rem을 쓰니 맞춰줬습니다.
한 번만 쓰는 값은 이렇게 넘깁니다.
rounded-[20px] max-w-[470px]
지난 시간에 대괄호를 "어쩔 수 없을 때 쓰는 것"처럼 이야기했는데, 정확히는 한 번만 쓰는 값을 위한 표기입니다. 여기저기 많아지면 눈금에서 벗어난 값이 늘고 있다는 신호고요.
⚠️ 글자 크기 유틸리티는 줄 높이를 함께 정합니다
주의할 게 하나 있습니다. text-sm은 글자 크기만 정하지 않아요.
.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}
줄 높이가 함께 들어 있는데, 우리 손 CSS는 줄 높이를 한 번도 안 정했습니다. 안 정하면 브라우저 기본값을 쓰는데, 그 값이 Tailwind가 정한 값과 다릅니다.
| 값 | 손 CSS (줄 높이 미지정) | 유틸리티 |
|---|---|---|
| 12px | 12 / 18 | text-xs → 12 / 16 |
| 13px | 13 / 19.5 | text-note → 13 / 19.5 |
| 14px | 14 / 21 | text-sm → 14 / 20 |
| 24px | 24 / 36 | text-2xl → 24 / 32 |
12·14·24px 자리는 줄 높이가 바뀝니다. 지난 시간에 리셋을 보면서 "기본값에 기대던 곳만 드러난다" 고 했던 것과 같은 일이에요.
이 강의에서는 Tailwind 가 정한 줄 높이를 따르기로 합니다. 줄 간격이 화면 전체에서 일정해지는 게 낫거든요. 원래 값을 지키고 싶다면 leading- 유틸리티를 함께 붙이면 됩니다.
재미있는 건 text-note입니다. 우리가 만든 이름인데 줄 높이가 안 바뀌었죠. --text-note만 등록하고 --text-note--line-height는 안 만들었기 때문이에요. 짝이 없으면 글자 크기만 정합니다.
글꼴도 알려줍시다
이제 body에 적어둔 글꼴을 보세요.
font-family: system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;
Apple SD Gothic Neo는 한글을 위해 넣은 겁니다. 이걸 그냥 두면 곧 문제가 생기는데, 다음 Step에서 body 규칙을 걷어내면 Tailwind 기본 글꼴이 적용되면서 이 한글 글꼴이 빠지거든요.
이제 @theme의 다른 쓰임이 나옵니다. Tailwind 가 이미 갖고 있는 토큰을 우리 값으로 덮어쓸 수 있어요.
/* apps/web-spa/src/styles/globals.css */
@theme {
--font-sans: system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;
}
--font-sans는 Tailwind가 원래 갖고 있는 이름입니다. 우리가 같은 이름으로 다시 적으면 그 값이 우리 것으로 바뀌는데, 새 이름을 만드는 것뿐 아니라 있는 이름을 갈아끼우는 것도 되는 거예요.
이러면 body 규칙을 지워도 한글 글꼴이 유지됩니다. Tailwind가 기본 글꼴로 우리 값을 쓰게 되니까요.
💡 한 줄 정리
값에 이름을 줄지는 두 가지로 판단합니다. 눈금에 이미 있으면 그대로 쓰고, 없으면 반복 횟수를 봅니다. 여러 번이면 이름을, 한 번이면 대괄호를 씁니다. @theme은 새 이름을 만드는 것뿐 아니라 Tailwind가 가진 이름을 덮어쓰는 데도 씁니다.
🙋 학생 질문 — "13px 이 여섯 번이면 그냥 14px 로 통일하면 안 되나요?"
훨씬 좋은 생각인데, 실무에서 자주 하는 정리예요.
우리 화면의 13px 은 파일 선택 버튼, 고른 파일 이름, 작은 설명 글자처럼 "덜 중요한 잔글씨" 자리에 쓰였는데, 14px 로 올려도 화면이 크게 안 달라집니다. 그때는 눈금 밖 값이 사라지고 토큰도 필요 없어져요.
이 강의에서는 화면을 안 바꾸는 것을 규칙으로 삼아서 13px 을 그대로 뒀습니다. 스타일 방식을 옮기는 작업과 디자인을 바꾸는 작업을 섞으면, 나중에 뭔가 달라 보일 때 어느 쪽 탓인지 못 가리거든요.
실무에서도 순서를 이렇게 잡는 게 안전합니다. 먼저 값을 그대로 옮기고, 다 옮긴 뒤에 눈금 밖 값을 정리할지 따로 이야기하는 거예요.
Step 5: "피드를 토큰 유틸리티로"
준비가 끝났으니, 이제 손으로 쓴 규칙을 실제로 걷어냅니다.
순서는 지난 시간에 Avatar를 옮길 때와 같습니다. 값을 짝지어 보고, 옮기고, 원래 규칙을 지우고, 확인합니다.
먼저 안 옮겨도 되는 것을 가려냅시다
손 CSS를 한 줄씩 유틸리티로 바꾸다 보면 필요 없는 것까지 적게 됩니다. 지난 시간에 들어온 리셋이 이미 해주는 게 있거든요.
새 요소를 하나 만들어 계산값을 재보면 이렇습니다.
| 태그 | 리셋이 이미 해주는 것 |
|---|---|
ul |
margin: 0 · padding: 0 · list-style: none |
p · h1~h6 |
margin: 0 |
button |
margin: 0 · padding: 0 · 배경 투명 · 테두리 굵기 0 · 글자색과 글꼴 상속 |
img |
display: block · max-width: 100% |
input |
테두리 굵기 0 · 배경 투명 · outline: none |
.feed-list를 보세요.
.feed-list {
list-style: none;
margin: 0;
padding: 0;
}
세 줄 전부 리셋이 이미 해줍니다. 유틸리티를 하나도 안 붙이고 규칙만 지우면 됩니다. Feed 컴포넌트에서 className을 통째로 빼세요.
// apps/web-spa/src/components/Feed.tsx
<List
items={posts}
aria-label="피드 목록"
renderItem={(post) => <PostCard {...post} onToggleLike={onToggleLike} />}
/>
버튼들의 border: none·background: none·color·padding: 0도 마찬가지고, .post-image의 display: block도 안 옮겨도 됩니다.
값을 짝지어 봅시다
피드 구역의 규칙들을 옮기면 이렇게 됩니다.
| 원래 규칙 | 유틸리티 |
|---|---|
.feed |
mx-auto max-w-[470px] p-4 |
.feed-header |
mb-4 flex items-baseline justify-between |
.feed-title |
mb-4 text-2xl font-bold |
.feed-liked-count |
text-sm text-muted |
.feed-list |
(없음 — 리셋이 처리) |
.post-card |
mb-6 overflow-hidden rounded-lg border border-line bg-surface |
.post-header |
flex items-center justify-between |
.post-more |
cursor-pointer p-3 text-lg leading-none |
.post-image |
w-full cursor-pointer |
.post-content |
px-3 py-1 text-sm |
.post-likes |
px-3 pt-3 pb-1 text-sm font-semibold |
.post-comments |
px-3 pt-1 pb-3 text-sm text-muted |
.caption-toggle |
cursor-pointer pl-1 text-sm text-muted |
.like-area |
px-3 pt-2 |
.like-button |
cursor-pointer rounded-md border border-line bg-surface px-3 py-1.5 text-sm |
.section-title |
mb-3 text-sm font-semibold text-muted |
470px은 한 번만 쓰이니 Step 4의 기준대로 대괄호로 넘겼습니다.
상태에 따라 달라지는 클래스
.like-button.liked는 조금 다른데, 좋아요를 누르면 테두리와 글자가 빨개지고 굵어졌죠.
B-2에서 배운 대로 조건에 따라 문자열을 고르면 됩니다. 공통 부분은 앞에 두고, 갈리는 부분만 조건으로요.
// apps/web-spa/src/components/LikeButton.tsx
<Button
className={`cursor-pointer rounded-md border bg-surface px-3 py-1.5 text-sm ${
liked ? 'border-danger font-semibold text-danger' : 'border-line'
}`}
onClick={onToggle}
>
{liked ? '♥ 좋아요 취소' : '♡ 좋아요'}
</Button>
border는 굵기만 정하고 색은 조건 쪽에서 정합니다. 눌렀으면 border-danger, 아니면 border-line이에요.
body는 어디로 갈까
body 규칙도 지워야 하는데, body는 React 컴포넌트가 아니라서 클래스를 붙일 곳이 없죠.
index.html로 갑니다.
<!-- apps/web-spa/index.html -->
<body class="bg-canvas text-ink">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
글꼴은 안 적어도 됩니다. Step 4에서 --font-sans를 덮어썼으니 Tailwind가 기본 글꼴로 우리 값을 씁니다. 개발자 도구에서 body의 font-family를 확인해 보세요. Apple SD Gothic Neo가 그대로 있습니다.
⚠️ 규칙을 지웠더니 엉뚱한 곳이 무너집니다
그런데 여기서 예상 못 한 일이 생깁니다. .post-card 규칙을 지우고 화면을 보면, 우리가 안 건드린 데모 화면들의 테두리가 사라져 있어요.
찾아보면 이렇습니다.
grep -rn "post-card\|post-content\|post-likes\|like-button\|hide-button" apps/web-spa/src/components/*.tsx | grep -v test
MiniPostCard·StaticPostCard·KeyDemo·ClickCounter가 같은 클래스 이름을 쓰고 있었습니다. B-1과 B-2에서 만든 시연용 컴포넌트들인데, 카드처럼 보이게 하려고 post-card를 그대로 갖다 쓴 거예요.
이게 지난 시간 Step 1에서 이야기한 문제가 실제로 일어난 겁니다. 이름을 공유하면 한 곳을 고칠 때 다른 곳이 함께 흔들린다고 했죠. .post-card를 지운 건 게시물 카드 때문이었는데, 그 이름에 얹혀 있던 네 컴포넌트가 같이 무너졌습니다.
지우기 전에는 이걸 알 수 없었다는 게 핵심인데, 검색해 보기 전까지는 .post-card가 어디서 쓰이는지 알 방법이 없었어요.
넷 다 같은 유틸리티로 옮기면 해결됩니다. 위 표에 있는 것을 그대로 쓰면 되고, KeyDemo의 숨기기 버튼만 표에 없는 규칙이라 따로 짝지어 둘게요.
| 원래 규칙 | 유틸리티 |
|---|---|
.hide-button |
cursor-pointer px-3 py-1 text-note text-muted |
text-note는 Step 4에서 13px 에 붙인 이름인데, 여기가 그 이름을 실제로 처음 쓰는 곳입니다.
그러고 나면 이제는 한 컴포넌트를 고쳐도 다른 컴포넌트가 안 흔들려요.
이름은 있는데 규칙이 없던 것
옮기다 보면 이상한 것도 나옵니다. Section 컴포넌트를 보세요.
<section className="section" aria-label={title}>
globals.css에서 .section을 찾아보면 없습니다. 이름만 붙어 있고 대응하는 규칙이 없어요. 언젠가 지웠는데 클래스는 안 뗀 겁니다.
지우면 되는데, 이런 게 남아 있어도 아무도 모른다는 게 지난 시간 Step 1의 셋째 문제였죠.
얼마나 줄었나
여기까지 하면 이렇습니다.
| 모듈 시작 | Step 5 뒤 | |
|---|---|---|
globals.css |
373 줄 | 264 줄 |
| 손으로 지은 규칙 | 53 개 | 34 개 |
| 빌드된 CSS | 9,221 B | 9,713 B |
마지막 줄이 이상하죠. 규칙을 열아홉 개나 지웠는데 CSS 가 오히려 492 B 늘었습니다.
당연합니다. 지금은 손 CSS와 유틸리티가 둘 다 들어 있거든요. 아직 안 옮긴 규칙이 34 개 남아 있고, 거기에 새로 만든 유틸리티가 더해진 상태예요.
"Tailwind를 쓰면 CSS가 작아진다"는 말을 여기서 판단하면 안 되니, 마지막에 다시 재볼게요.
화면은 그대로인가
개발자 도구로 옮기기 전후를 견줘 보세요. 색·간격·굵기·모서리는 전부 같습니다.
딱 하나 달라지는 게 있습니다. 줄 높이예요. Step 4에서 예고한 대로 text-xs·text-sm·text-2xl이 줄 높이를 함께 정하거든요. 제목의 줄 높이가 36px 에서 32px 로, 본문이 21px 에서 20px 로 바뀝니다.
의도한 변화입니다. 눈에 잘 안 띄지만 "완전히 똑같다" 고 하면 거짓이에요.
💡 한 줄 정리
옮기기 전에 리셋이 이미 해주는 것을 가려내면 적을 유틸리티가 확 줄어듭니다. 규칙을 지우는 순간 그 이름에 얹혀 있던 다른 컴포넌트가 함께 무너지는데, 지난 시간 Step 1에서 말한 문제를 직접 겪는 셈이에요.
🙋 학생 질문 — "긴 문자열이 네 컴포넌트에 똑같이 반복되는데 괜찮나요?"
mb-6 overflow-hidden rounded-lg border border-line bg-surface가 PostCard·MiniPostCard·StaticPostCard에 똑같이 들어갔는데, 불편하게 느껴지는 게 맞습니다.
답은 지난 시간에 이미 나왔습니다. 같은 모양이 반복되면 클래스를 공유하는 게 아니라 컴포넌트를 공유합니다. B-3에서 Card를 만들었던 것처럼요.
지금 이 셋은 사실 카드 하나로 합칠 수 있는데, 이 컴포넌트들은 B-1·B-2에서 "props 없이 만들면 이렇게 된다", "구조분해를 안 하면 이렇게 된다"를 보여주려고 일부러 따로 둔 것이라 합치지 않았습니다.
실무에서 같은 문자열이 세 번쯤 반복되면 컴포넌트로 묶을 때가 된 겁니다. 그 기준을 각자 세워보는 건 오늘 생각해볼 주제로 남겨둘게요.
Step 6: "댓글과 폼까지"
남은 건 댓글 구역과 회원가입 폼, 그리고 토스트입니다.
짝지어 옮기기
| 원래 규칙 | 유틸리티 |
|---|---|
.comment-form |
flex gap-2 border-t border-line-soft p-3 |
.comment-input |
flex-1 text-sm |
.comment-list |
px-3 pb-3 text-sm |
.signup-form |
flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 |
.signup-field |
flex flex-col gap-1 |
.signup-label |
text-xs text-subtle |
.signup-input |
rounded-md border border-line p-2 text-sm |
.signup-error |
text-xs text-danger |
.signup-submit |
cursor-pointer rounded-lg bg-brand p-2 text-sm font-semibold text-white |
.comment-input이 짧은 이유는 앞에서 본 리셋 덕분인데, border: none과 outline: none은 이미 처리돼 있어요.
또 이 Step에서 brand·line-soft·subtle 세 토큰이 처음 쓰입니다. 아홉 개를 등록해 두고 여섯 개만 나오던 게, 이제 전부 CSS에 나와요.
토스트 — 표기가 달라도 화면은 같습니다
토스트는 손 CSS와 유틸리티의 표기가 눈에 띄게 다릅니다.
| 항목 | 손 CSS | 유틸리티 |
|---|---|---|
| 배경 | rgba(0, 0, 0, 0.82) |
bg-black/82 |
| 가운데 정렬 | transform: translateX(-50%) |
-translate-x-1/2 |
| 모서리 | border-radius: 20px |
rounded-[20px] |
| 안쪽 여백 | padding: 10px 18px |
py-2.5 px-4.5 |
// apps/web-spa/src/components/Toast.tsx
<div
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"
role="status"
>
{message}
</div>
bg-black/82는 검정을 82% 불투명도로 쓴다는 뜻인데, 슬래시 뒤 숫자가 불투명도예요.
⚠️ 여기서 계산값을 견주면 헷갈립니다. 개발자 도구로 배경색을 보면 손 CSS는 rgba(0, 0, 0, 0.82)인데 유틸리티는 oklab(0 0 0 / 0.82)로 나와요. 정렬도 손 CSS는 transform에 값이 들어가는데 유틸리티는 transform: none이고 대신 translate 속성에 -50%가 들어갑니다.
글자가 다르다고 화면이 다른 건 아닙니다. 흰 배경 위에 겹쳐 칠한 픽셀을 재보면 둘 다 같은 값이고, 요소의 실제 위치와 폭도 같습니다. 색과 정렬은 계산값 문자열이 아니라 눈에 보이는 결과로 견줘야 해요.
⚠️ 두 곳만 못 옮깁니다
거의 다 옮겼는데 두 규칙이 남습니다.
.comment-submit:disabled {
color: #b2dffc;
cursor: default;
}
.comment-remove:hover {
color: #262626;
}
빈 입력일 때 잠기는 제출 버튼과, 마우스를 올리면 진해지는 댓글 삭제 버튼입니다.
우리가 지금까지 쓴 유틸리티는 전부 조건 없이 적용되는 것들이었어요. text-brand는 언제나 파란 글자입니다. "잠겼을 때만"이나 "마우스를 올렸을 때만"을 말할 방법이 아직 없습니다.
그래서 이 두 규칙은 손 CSS로 남겨두는데, 규칙이 붙으려면 클래스 이름이 있어야 하니 그 두 요소만 손으로 지은 이름을 그대로 달고 있게 됩니다.
// apps/web-spa/src/components/CommentForm.tsx
<Button
className="comment-submit cursor-pointer text-sm font-semibold text-brand"
type="submit"
disabled={isEmpty}
>
게시
</Button>
comment-submit이라는 이름이 그대로 있죠. 이름을 떼면 :disabled 규칙이 붙을 데가 없어집니다.
잠긴 상태를 확인해 보세요. 댓글 입력창을 비워두면 버튼 글자가 연한 하늘색입니다. text-brand 유틸리티가 붙어 있는데도 남겨둔 규칙이 이기는데, 우리 손 CSS는 Tailwind가 관리하는 층 바깥에 있어서 더 강하게 적용되거든요.
다음 시간에 조건을 붙이는 방법을 배우면 이 두 곳도 정리됩니다.
지금까지 얼마나 줄었나
| 모듈 시작 | Step 5 뒤 | Step 6 뒤 | |
|---|---|---|---|
globals.css |
373 줄 | 264 줄 | 167 줄 |
| 손으로 지은 규칙 | 53 개 | 34 개 | 22 개 |
| 빌드된 CSS | 9,221 B | 9,713 B | 10,212 B |
남은 22 개는 방금 본 두 규칙과, 아직 안 옮긴 교습용 데모 20 개입니다. 데모는 과제로 남겨둘게요.
CSS는 옮기는 동안 계속 커졌는데, 손 CSS와 유틸리티가 함께 있는 상태라 그렇습니다.
내내 오르기만 한 건 아닌데, Step 4에서 --font-sans를 덮어썼을 때는 오히려 내려갔어요. Tailwind 기본 글꼴 목록이 길었는데 우리 것이 짧아서 그만큼 줄었거든요. 그 시점에 재면 9,168 B로 시작보다도 작습니다.
정리하면 크기는 무엇을 건드렸느냐에 따라 오르내립니다. 토큰을 덮어쓰면 줄고, 규칙을 옮기는 동안에는 늡니다. 한 시점만 보고 "Tailwind는 CSS를 키운다/줄인다"로 판단하면 안 되는 이유예요. 손 CSS를 다 걷어낸 뒤 마지막으로 한 번 더 재봅시다. 그건 과제에서 확인합니다.
💡 한 줄 정리
리셋 덕분에 옮길 선언이 생각보다 적었고, 표기가 달라 보이는 곳은 계산값이 아니라 화면으로 견줘야 했습니다. 조건이 붙은 두 규칙은 아직 유틸리티로 옮길 방법이 없어서, 그 두 요소만 손으로 지은 이름을 달고 남았습니다.
🙋 학생 질문 — "남은 두 규칙을 @theme 토큰으로 적으면 더 깔끔하지 않나요?"
.comment-remove:hover의 #262626은 우리가 --color-ink로 등록해 둔 값이라, color: var(--color-ink)라고 적을 수 있고 실제로 됩니다. @theme이 만든 변수는 보통 CSS 변수라서 어디서든 var()로 꺼내 쓸 수 있거든요.
이번에는 일부러 손대지 않았는데, 이 두 규칙은 다음 시간에 통째로 사라질 거예요. 사라질 코드를 다듬는 데 시간을 쓰는 것보다, "아직 못 옮긴 두 곳"이라는 게 눈에 띄게 남겨두는 편이 낫다고 봤습니다.
실무에서도 비슷한 판단을 자주 합니다. 곧 걷어낼 코드는 예쁘게 만들기보다 눈에 띄게 두는 게 나을 때가 있어요.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 값에 이름을 줬습니다.
지난 시간에는 클래스 이름 짓기를 그만뒀는데, 오늘은 반대로 이름을 지었죠. 헷갈릴 수 있는데 대상이 다릅니다. 클래스 이름은 없앨수록 좋고, 값의 이름은 있을수록 좋습니다.
오늘 배운 핵심 세 가지
💡 하나 — @theme은 CSS를 만드는 곳이 아니라 이름을 알려주는 곳입니다. 아홉 개를 등록해도 CSS는 한 글자도 안 늘었는데, 알려준 이름 중 실제로 쓴 것만 나옵니다. 지난 시간의 규칙이 토큰에도 그대로 적용돼요.
💡 둘 — 같은 :root로 나와도 출발지가 다르면 결과가 다릅니다. :root에 직접 쓴 변수는 유틸리티를 못 만들고, 대신 아무도 안 써도 그대로 남습니다. 어느 쪽이 나은 게 아니라 쓰임이 다릅니다.
💡 셋 — 이름을 줄지는 반복 횟수로 판단합니다. 눈금에 이미 있으면 그대로 쓰고, 없으면 몇 번 쓰는지 셉니다. 13px 은 여섯 번이라 이름을 얻었고, 20px 모서리는 한 번이라 대괄호로 넘겼어요.
시작할 때 내걸었던 것들
Step 1에서 문제를 셋 꺼냈죠. 이제 어디서 닫혔는지 봅시다.
| 내걸었던 것 | 닫힌 곳 |
|---|---|
| 색 열 종류가 마흔네 번 흩어져 있다 | Step 5·6 — 값은 @theme 한곳에, 화면은 이름으로 |
| 일괄 치환하면 다른 뜻으로 쓰인 곳까지 바뀐다 | Step 5·6 — 같은 #ffffff가 배경(bg-surface)과 글자색(text-white)으로 갈렸다 |
| 우리 색으로는 유틸리티를 못 쓴다 | Step 1에서 border-line 하나로 시작해 Step 6에서 아홉 개를 전부 쓴다 |
다음 시간 예고
오늘 못 끝낸 게 정확히 두 줄 남아 있습니다.
.comment-submit:disabled { ... }
.comment-remove:hover { ... }
"잠겼을 때만", "마우스를 올렸을 때만"을 유틸리티로 말할 방법이 없어서 남겨뒀죠. 다음 시간에 이걸 배우는데, hover:·disabled:처럼 유틸리티 앞에 조건을 붙이는 방법이에요.
조건은 상태만이 아닙니다. 화면 크기도 조건이 됩니다. 지난 시간 과제에서 변수 이름을 container로 지었더니 .container가 6 개 나왔던 것 기억하시나요. 왜 하나가 아니라 여섯이었는지, 다음 시간에 답이 나옵니다.
B-4 과제에서 창 너비를 재는 훅을 만들며 "CSS로 되는 일을 굳이 자바스크립트로 하지 말라" 고 했던 것도 그때 회수합니다.
마지막으로 오늘 이름만 지어두고 안 갈라놓은 게 있습니다. canvas와 surface요. 둘 다 거의 흰색이라 나눌 이유가 없어 보였는데, 어두운 화면에서는 이 둘이 완전히 갈라집니다. 다크모드는 조건 붙이는 법을 먼저 익힌 다음, 그 뒤에 이어서 합니다.
과제
[구현] 남은 손 CSS를 마저 걷어내기
globals.css에 남은 22 개 중 교습용 데모 20 개를 옮기세요. 조건이 붙은 두 규칙(:disabled·:hover)은 다음 시간 몫이니 그대로 둡니다.
대상은 이 컴포넌트들입니다.
KeyDemo—.key-demo·.key-demo-list·.key-demo-rowEffectTimingDemo—.effect-demo·.effect-demo-values·.effect-demo-buttonInputValueDemo—.input-demo-pair·.input-demo·.input-demo h3·.input-demo p·.input-demo-fieldProfileImagePicker—.profile-picker·.profile-picker-input·.profile-picker-button·.profile-picker-nameResponseCheckDemo—.response-demo·.response-panel·.response-title·.response-verdict·.response-issues
몇 가지 힌트를 드릴게요.
.key-demo-list는.feed-list와 같은 경우입니다. 유틸리티가 몇 개 필요한지 먼저 따져보세요..input-demo h3처럼 자식을 가리키는 규칙은 유틸리티로 그대로 옮길 수 없습니다. 그 자식 요소에 직접 붙여야 해요..profile-picker-input의display: none에 맞는 유틸리티를 찾아보세요..input-demo-field의box-sizing: border-box는 옮길 필요가 있는지 확인해 보세요.RefVsStateDemo의className="ref-demo"도 한번 찾아보세요. 뭔가 이상한 게 있습니다.
다 옮기면 globals.css가 몇 줄이 됐는지, 빌드된 CSS가 몇 바이트인지 적어 주세요. 크기가 어느 쪽으로 갔는지가 이 과제에서 제일 중요한 관찰입니다.
마치면 npm run typecheck -w web-spa와 npm run lint -w web-spa를 돌려 둘 다 통과하는지 확인해 주세요.
[탐구] 토큰의 경계를 건드려 보기
각 실험 결과를 한두 줄씩 적어 주세요. 확인이 끝나면 전부 되돌립니다.
@theme의--color-line값을 눈에 띄는 색(예: 빨강)으로 바꾸고 빌드해 보세요. 화면에서 어디가 바뀌고 어디가 안 바뀌나요? 안 바뀐 곳이 있다면 왜 그런지globals.css에서 확인해 보세요.--text-note--line-height: 2를@theme에 추가하고 빌드한 뒤,text-note를 쓴 요소의 줄 높이를 개발자 도구로 재보세요. Step 4에서 이야기한 것과 맞나요?--color-brand를@theme에서 빼서:root로 옮기고 빌드해 보세요. 회원가입 버튼이 어떻게 되나요? 개발자 도구에서 그 버튼의background-color를 확인하고, CSS에서.bg-brand를 찾아보세요.@theme에 아무 데서도 안 쓸 토큰을 하나 새로 추가하고(예:--color-never-used: #123456) 빌드해 보세요. CSS 크기가 달라지나요? 달라지지 않는다면 그 이유를 Step 1과 이어서 설명해 보세요. 참고로 지금@theme에 있는 토큰은 전부 어딘가에서 쓰이고 있어서, 지우는 방향으로는 이 실험을 할 수 없습니다.
생각해볼 주제
1. 용도로 부르는 이름과 값으로 부르는 이름
Step 2에서 확인했듯 우리 회색 셋은 Tailwind의 neutral-500·neutral-800·neutral-50과 완전히 같은 색이었는데, text-neutral-500이라고 써도 결과가 같았어요.
그런데도 --color-subtle처럼 이름을 따로 줬는데, 값이 아니라 용도로 부르기 위해서였죠.
이 선택에는 대가가 있습니다. 새로 합류한 팀원은 subtle이 무슨 색인지 모릅니다. neutral-500은 이름만 봐도 회색 계열이라는 걸 알 수 있는데요. 팔레트를 한 번 더 열어봐야 하는 셈입니다.
실무에서는 두 층을 다 두는 팀도 많습니다. 값을 부르는 이름을 아래에 깔고, 용도를 부르는 이름이 그걸 가리키게 하는 식으로요. 대신 이름이 두 배가 됩니다.
여러분이라면 어느 쪽을 고르시겠어요. 그 판단이 팀 크기나 디자이너의 참여 정도에 따라 달라질지도 생각해 보세요.
2. 눈금에서 벗어난 값을 만났을 때
13px 이 여섯 번 쓰이는 걸 보고 우리는 --text-note라는 이름을 줬는데, 눈금을 우리 쪽으로 늘린 거예요.
반대 선택도 있었는데, 13px 을 전부 14px 로 바꿔서 눈금에 맞추는 것이요. 그렇게 하면 토큰도 필요 없고 눈금 밖 값도 사라집니다.
첫 번째는 디자인을 지키고 도구를 맞추는 쪽이고, 두 번째는 도구를 지키고 디자인을 맞추는 쪽입니다.
개발자가 "이 13px 을 14px 로 바꿔도 될까요"라고 물어보는 게 맞을까요, 아니면 디자인 값은 그대로 두고 도구를 늘리는 게 맞을까요. 여러분의 기준을 세워 보세요. 그 기준이 값의 종류(색·간격·글자 크기)에 따라 달라질지도요.
3. 절반만 옮긴 상태를 어떻게 다룰까
오늘 수업이 끝난 시점에 globals.css에는 손 CSS 22 개가 남아 있습니다. 빌드된 CSS는 시작보다 오히려 커졌고요. 손 CSS와 유틸리티가 한 프로젝트에 섞여 있는 상태입니다.
이건 실무에서 아주 흔한 모습입니다. 스타일 방식을 바꾸는 작업은 하루에 안 끝나거든요. 기능 개발과 병행하면 몇 달씩 이 상태로 갑니다.
문제는 그동안 팀원들이 헷갈린다는 겁니다. 새 컴포넌트를 만들 때 어느 방식으로 써야 할까요. 기존 컴포넌트를 고칠 때 옆에 있는 손 CSS도 같이 옮겨야 할까요.
Step 6에서 본 것처럼, 남겨둔 손 CSS가 유틸리티를 이기기도 합니다. 섞여 있는 동안은 "이 스타일이 왜 안 먹지"가 생길 수 있어요.
이런 중간 상태를 팀이 견디려면 무엇을 정해두어야 할까요. 절반만 옮긴 채로 멈춰버리지 않으려면 어떻게 해야 할지도 생각해 보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 남은 손 CSS를 마저 걷어내기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 리셋 확인 | .key-demo-list를 유틸리티 없이 클래스만 지웠는가 |
| 자식 선택자 | .input-demo h3 · .input-demo p를 그 자식 요소에 직접 붙였는가 |
display: none |
hidden을 찾아냈는가 |
box-sizing |
옮길 필요가 없다는 것을 확인했는가 |
| 죽은 이름 | RefVsStateDemo의 ref-demo에 규칙이 없다는 것을 발견하고 지웠는가 |
| 토큰 사용 | 색과 13px 을 직접 적지 않고 border-line·bg-canvas·text-note로 썼는가 |
| 비슷한 규칙 구분 | .effect-demo-button의 margin-right를 놓치지 않고 mr-2를 붙였는가 (버튼 두 개 모두) |
| 남긴 것 | :disabled·:hover 두 규칙을 그대로 뒀는가 |
| 크기 보고 | globals.css 줄 수와 빌드된 CSS 크기를 적었는가 |
| 크기 방향 | 크기가 줄어든 것을 확인하고 왜인지 설명했는가 |
| 마무리 확인 | typecheck와 lint가 통과하는가 |
풀이 예시
먼저 반복되는 조합부터 눈에 들어옵니다. 데모 화면들은 대부분 "흰 카드에 테두리" 모양이라 같은 문자열이 여러 번 나와요.
| 쓰임 | 유틸리티 |
|---|---|
| 아래 여백이 있는 카드 | mb-4 rounded-lg border border-line bg-surface p-3 |
| 나란히 놓이는 패널 | flex-1 rounded-lg border border-line bg-surface p-3 |
| 작은 회색 버튼 | cursor-pointer rounded-md border border-line bg-canvas px-3 py-1.5 text-note |
| 잔글씨 | text-note text-subtle |
⚠️ 세 번째 조합은 조심해야 합니다. .effect-demo-button과 .profile-picker-button이 거의 같아 보이지만, .effect-demo-button에만 margin-right: 8px이 있어요. 그 버튼에는 mr-2를 함께 붙여야 합니다. 비슷하다고 문자열을 그대로 복사하면 선언 하나를 조용히 잃습니다.
KeyDemo입니다. .key-demo-list는 list-style: none·margin: 0·padding: 0 세 줄뿐이라 리셋이 전부 해줍니다. 유틸리티 없이 클래스만 빼면 돼요.
// apps/web-spa/src/components/KeyDemo.tsx
<li className="flex items-center gap-3 py-1">
<section className="mb-4 rounded-lg border border-line bg-surface p-3">
<h3>key = 순서 번호</h3>
<ul>
EffectTimingDemo는 버튼이 13px 을 씁니다. Step 5의 숨기기 버튼에 이어 text-note를 다시 쓰는 곳이에요.
// apps/web-spa/src/components/EffectTimingDemo.tsx
<div className="mb-4 rounded-lg border border-line bg-surface p-3">
<p className="mb-2 text-sm">
count: {count} / other: {other}
</p>
<button
className="mr-2 cursor-pointer rounded-md border border-line bg-canvas px-3 py-1.5 text-note"
onClick={() => setCount(count + 1)}
>
count +1
</button>
<button
className="mr-2 cursor-pointer rounded-md border border-line bg-canvas px-3 py-1.5 text-note"
onClick={() => setOther(other + 1)}
>
InputValueDemo가 제일 까다롭습니다. .input-demo h3와 .input-demo p는 자식을 가리키는 규칙이라 유틸리티로 그대로 못 옮겨요. 자식 요소에 직접 붙여야 합니다.
// apps/web-spa/src/components/InputValueDemo.tsx
<section className="flex-1 rounded-lg border border-line bg-surface p-3" aria-label="상태가 값을 든 입력창">
<h3 className="mb-2 text-sm">상태가 값을 든다</h3>
<input
className="w-full rounded-md border border-line px-2 py-1.5 text-sm"
aria-label="controlled 닉네임"
value={nickname}
onChange={(event) => setNickname(event.target.value)}
/>
<p className="mt-2 text-note text-subtle">지금 값: {nickname}</p>
</section>
.input-demo-field의 box-sizing: border-box는 안 옮겨도 됩니다. 리셋이 모든 요소에 이미 주고 있어요. 개발자 도구에서 아무 요소나 잡고 box-sizing을 보면 전부 border-box입니다.
ProfileImagePicker의 숨은 입력창은 hidden입니다.
// 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"
onClick={() => fileInputRef.current?.click()}
>
프로필 사진 고르기
</Button>
<span className="text-note text-subtle">{pickedName || '아직 고르지 않았어요'}</span>
</div>
ResponseCheckDemo의 목록은 padding-left: 18px인데, 18은 4의 4.5 배라 pl-4.5가 됩니다.
// apps/web-spa/src/components/ResponseCheckDemo.tsx
<section className="flex-1 rounded-lg border border-line bg-surface p-3" aria-label={title}>
<h4 className="mb-2 text-sm">{title}</h4>
<p className="mt-1 text-note text-subtle">{isPost(payload) ? 'isPost: 통과' : 'isPost: 막힘'}</p>
<p className="mt-1 text-note text-subtle">
{found.length === 0 ? '스키마: 통과' : '스키마: 막힘'}
</p>
<ul className="mt-2 pl-4.5 text-xs text-danger">
마지막으로 RefVsStateDemo입니다. className="ref-demo"를 globals.css에서 찾아보면 규칙이 없습니다. Section의 section과 같은 경우예요. 지우면 끝입니다.
// apps/web-spa/src/components/RefVsStateDemo.tsx
<section aria-label="ref 와 state 비교">
결과
globals.css 167 줄 -> 35 줄 · 손으로 지은 규칙 22 개 -> 2 개
빌드된 CSS 10,212 B -> 8,958 B
globals.css에 남는 건 이게 전부입니다.
/* apps/web-spa/src/styles/globals.css */
@import "tailwindcss";
@source not "../../scratch";
@source not "../**/*.test.ts*";
@theme {
/* 색 아홉 개 · --text-note · --font-sans */
}
.comment-submit:disabled { ... }
.comment-remove:hover { ... }
크기가 줄어든 것이 중요합니다
수업 중에 CSS가 늘었는데, Step 5에서 9,713으로, Step 6에서 10,212 로요. 과제를 마치면 8,958로 시작보다 작아집니다.
다섯 시점을 나란히 놓으면 이렇습니다.
| 시점 | CSS |
|---|---|
| 모듈 시작 (지난 시간 끝) | 9,221 B |
Step 4 뒤 (--font-sans 덮어쓴 직후) |
9,168 B |
| Step 5 뒤 | 9,713 B |
| Step 6 뒤 | 10,212 B |
| 과제까지 마친 뒤 | 8,958 B |
한 번 내려갔다가, 두 번 올랐다가, 마지막에 다시 내려옵니다. Step 4에서 내려간 건 토큰을 덮어써서고, Step 5·6에서 오른 건 손 CSS와 유틸리티가 둘 다 들어 있어서예요. 손 CSS가 다 빠지고 나서야 줄어듭니다.
수업 중간에 크기만 보고 "Tailwind를 쓰면 CSS가 커지는구나"라고 결론 내렸다면 틀렸을 겁니다. 반대로 시작과 끝만 보고 "항상 작아진다" 고 해도 정확하지 않고요. 옮기는 도중에는 커지고, 다 옮기면 작아진다가 맞습니다.
자주 나오는 실수
.key-demo-list에 list-none m-0 p-0을 붙이는 경우가 많습니다. 틀린 건 아니지만 셋 다 리셋이 이미 해주는 일이라 없어도 화면이 같아요. 붙이기 전에 지워보고 확인하는 습관을 들이면 좋습니다.
.input-demo h3를 부모에 붙이려다 막히는 경우도 있습니다. 유틸리티는 자기 자신에게만 적용돼서 "내 안의 h3" 같은 걸 말할 수 없어요. 자식에게 직접 가는 게 맞습니다.
13px 을 text-[13px]로 적은 경우도 틀리지 않았고 화면도 같은데, 다만 우리는 Step 4에서 여섯 번 반복된다는 이유로 --text-note라는 이름을 줬으니, 그 이름을 쓰는 게 일관됩니다.
💡 튜터의 한마디
이 과제에서 제일 중요한 건 옮기는 손놀림이 아니라 크기가 오르내리는 걸 직접 본 것입니다.
실무에서 스타일 방식을 바꾸자고 제안하면 "그래서 얼마나 좋아지는데요"라는 질문을 받습니다. 그때 중간 수치를 들고 가면 오히려 역효과예요. 아직 옮기는 중이라 커진 상태거든요.
작업을 절반쯤 하고 멈춘 뒤 "이거 해봤는데 별로네요"라고 결론 내리는 팀을 실제로 많이 봅니다. 도중에 재면 나쁘게 나오는 게 당연한데도요. 시작할 때 "다 옮기기 전까지는 지표가 나빠진다"를 미리 합의해 두는 게 그래서 중요합니다.
🎯 [과제 2 예시답안] 토큰의 경계를 건드려 보기
채점 포인트
| 항목 | 확인할 것 |
|---|---|
| 실험 ① | 안 바뀐 곳을 찾아내고, 그 이유가 두 종류라는 것까지 갈랐는가 |
| 실험 ② | 짝을 만들면 줄 높이도 정해진다는 것을 확인했는가 |
| 실험 ③ | 변수는 남는데 유틸리티만 사라지는 것을 두 곳(CSS·화면)에서 확인했는가 |
| 실험 ④ | 크기가 안 변하는 이유를 Step 1과 이어 설명했는가 |
| 되돌리기 | 네 실험을 전부 원래대로 되돌렸는가 |
실험 ① — --color-line을 빨강으로
--color-line: red;
화면에서 바뀌는 곳은 게시물 카드 두 장, 좋아요 버튼(안 누른 것), 회원가입 폼 테두리, 입력칸 네 개입니다. border-line을 쓰는 곳들이죠.
안 바뀌는 곳이 셋 있는데, 이유가 서로 다릅니다.
| 안 바뀐 곳 | 실제 색 | 이유 |
|---|---|---|
| "프로필 사진 고르기" 버튼 | rgb(219, 219, 219) |
아직 손 CSS라서 |
| 댓글 입력줄 위 경계선 | oklch(0.952 0 none) |
다른 토큰(--color-line-soft) |
| 좋아요 누른 버튼 | oklch(0.641 0.2 20.5) |
다른 토큰(--color-danger) |
첫 번째가 오늘 수업의 핵심을 보여줍니다. .profile-picker-button은 아직 안 옮긴 규칙이라 #dbdbdb를 직접 들고 있어서, 토큰을 바꿔도 꿈쩍 안 합니다.
grep -n "#dbdbdb" apps/web-spa/src/styles/globals.css
일곱 곳이 나옵니다. "값이 한곳에 모였다"는 아직 절반만 참인 거예요. 과제 1을 마치면 그제야 완전히 참이 됩니다.
나머지 둘은 안 바뀌는 게 정상입니다. 애초에 다른 이름을 쓰고 있으니까요. "안 바뀌었다"를 전부 같은 이유로 묶으면 안 됩니다.
실험 ② — 줄 높이 짝 만들기
@theme {
--text-note: 0.8125rem;
--text-note--line-height: 2;
}
빌드하고 .text-note를 찾아보면 이렇게 바뀝니다.
.text-note{font-size:var(--text-note);line-height:var(--tw-leading,var(--text-note--line-height))}
line-height가 생겼습니다. 짝을 만들기 전에는 font-size 한 줄뿐이었어요.
Step 4에서 말한 그대로입니다. text-sm 같은 기본 눈금이 줄 높이를 함께 정하는 건 --text-sm--line-height라는 짝이 있어서고, 우리 --text-note는 짝을 안 만들어서 글자 크기만 정했던 겁니다.
이름 뒤에 --line-height를 붙이는 규칙만 알면 우리 눈금도 기본 눈금과 똑같이 동작하게 만들 수 있어요.
실험 ③ — --color-brand를 :root로
:root {
--color-brand: oklch(65.46% 0.177 248.2);
}
@theme {
/* --color-brand 를 여기서 뺀 상태 */
}
화면부터 보세요. 가입하기 버튼의 파란 배경이 사라집니다. 댓글 제출 버튼의 파란 글자도 없어지고요.
CSS에서 확인하면 이렇습니다.
grep -o "\-\-color-brand:[^;}]*" apps/web-spa/dist/assets/*.css
grep -o "\.bg-brand{[^}]*}" apps/web-spa/dist/assets/*.css
--color-brand:oklch(65.46% .177 248.2)
변수는 멀쩡히 있는데, .bg-brand는 안 나와요. .text-brand도 마찬가지고요.
Step 3의 자홍색 실험과 완전히 같은 결과인데, 이번엔 실제로 쓰이는 컴포넌트에서 일어났다는 게 다릅니다. 클래스는 HTML에 그대로 붙어 있는데 그 클래스에 해당하는 CSS만 없는 상태예요. 오류도 안 나고 경고도 없습니다.
실험 ④ — 안 쓰는 토큰 지우기
먼저 확인할 게 있습니다. 지금 @theme에 있는 토큰은 전부 어딘가에서 쓰이고 있습니다. Step 6에서 brand·line-soft·subtle까지 쓰이기 시작해서, "안 쓰는 토큰을 지운다"는 방향으로는 실험이 안 됩니다.
반대로 하면 됩니다. 안 쓸 토큰을 하나 새로 넣어보는 거예요.
--color-never-used: #123456;
넣기 전과 넣은 뒤의 크기가 똑같습니다.
Step 1에서 본 것과 같은 이유입니다. @theme에 적는 건 "이런 이름을 쓸 수 있게 준비해 둬라"이고, 준비만 하고 안 쓰면 CSS에 나오지 않아요. 없는 것을 지웠으니 크기가 변할 리가 없습니다.
💡 튜터의 한마디
네 실험 중 ①과 ③이 실무에서 실제로 사람을 잡는 것들입니다.
①은 "토큰으로 바꿨는데 왜 저기는 안 바뀌지"입니다. 마이그레이션 중간에는 늘 이런 곳이 남아요. 안 바뀐 곳을 발견했을 때 "아직 안 옮긴 곳"인지 "다른 토큰을 쓰는 곳"인지 갈라서 보는 습관이 중요합니다. 둘은 대응이 완전히 다르거든요.
③은 더 고약합니다. 오류도 경고도 없이 스타일만 조용히 사라지니까요. 지난 시간에 클래스 이름을 조립하면 안 된다고 했던 것과 같은 종류의 실패입니다. 도구가 "이렇게 써야 알아본다"는 전제를 깔고 있고, 그 전제를 어기면 말없이 동작을 안 해요.
이런 걸 팀이 놓치지 않으려면 결국 화면을 보는 테스트가 필요합니다. 그 이야기는 F 카테고리에서 본격적으로 합니다.
🤔 [생각해볼 주제 1] 용도로 부르는 이름과 값으로 부르는 이름
문제 상황 요약
우리 회색 셋(subtle·ink·canvas)은 Tailwind의 neutral-500·neutral-800·neutral-50과 완전히 같은 색이었습니다. 그런데도 --color-subtle처럼 용도로 이름을 다시 지었죠.
값을 부르는 이름과 용도를 부르는 이름 중 무엇을 쓸지, 아니면 둘 다 둘지를 정해야 합니다.
튜터의 가이드 및 해설
먼저 각각이 무엇을 잘하는지 갈라 봅시다.
값을 부르는 이름(neutral-500)은 읽는 사람이 색을 짐작할 수 있습니다. 숫자가 밝기 순서라서 neutral-300이 neutral-500보다 밝다는 것도 이름만 보고 압니다. 디자이너와 개발자가 같은 언어로 이야기하기도 좋아요.
용도를 부르는 이름(subtle)은 왜 그 색인지를 말해줍니다. 그리고 결정적으로, 값을 바꿀 때 한곳만 고치면 됩니다. 라벨과 설명 글자를 조금 진하게 바꾸기로 하면 --color-subtle 한 줄이면 끝나요.
값으로 이름을 지었다면 text-neutral-500이 적힌 곳을 전부 찾아 바꿔야 합니다. 게다가 그중 어떤 게 "덜 중요한 글자" 고 어떤 게 다른 이유로 회색인지 구분해야 해요.
Step 1에서 본 #fafafa 문제와 똑같은 모양입니다. 같은 값이 다른 뜻으로 쓰이면 일괄 치환이 안 됐죠.
그래서 규모가 커지면 대부분 두 층을 다 둡니다. 값을 부르는 이름을 아래에 깔고, 용도를 부르는 이름이 그것을 가리키게 하는 식이에요. 디자인 도구와 코드가 같은 팔레트를 공유하면서도, 화면에서는 용도로 부를 수 있습니다.
대신 대가가 있습니다. 이름이 두 배가 되고, 새 팀원은 두 층을 다 익혀야 해요. subtle이 뭔지 보려면 팔레트를 한 번 더 열어야 하고요. 화면이 몇 개 안 되는 프로젝트에서는 이 비용이 얻는 것보다 큽니다.
판단 기준을 하나만 고른다면 "이 값이 앞으로 바뀔 것 같은가"입니다. 바뀔 값이면 용도로 부르는 게 낫고, 안 바뀔 값이면 굳이 층을 더할 이유가 없어요.
우리 프로젝트에서 canvas와 surface를 굳이 나눈 게 그 예입니다. 지금은 둘 다 거의 흰색이라 나눌 이유가 없어 보이지만, 다크모드를 하면 완전히 갈립니다. 바뀔 것을 알고 있으니 미리 용도로 부른 거예요.
🎯 면접관을 홀리는 핵심 멘트
"토큰 이름은 값이 아니라 용도로 짓습니다. 값으로 이름을 지으면 나중에 값을 바꿀 때 그 이름이 쓰인 곳을 전부 찾아야 하고, 그중 어떤 게 정말 그 용도였는지 구분할 수 없거든요. 다만 이름 층이 늘어나는 비용이 있어서, 저는 '이 값이 앞으로 바뀔 것 같은가'를 기준으로 나눕니다. 다크모드처럼 값이 뒤집히는 게 예정돼 있으면 미리 용도로 부르고, 안 바뀔 값이면 기본 팔레트를 그대로 씁니다."
🤔 [생각해볼 주제 2] 눈금에서 벗어난 값을 만났을 때
문제 상황 요약
13px 이 여섯 번 쓰이는 걸 보고 --text-note라는 이름을 줬습니다. 눈금을 우리 쪽으로 늘린 셈이죠.
반대로 13px 을 전부 14px 로 바꿔 눈금에 맞추면, 토큰도 필요 없고 눈금 밖 값도 사라집니다.
튜터의 가이드 및 해설
먼저 순서 이야기부터 하겠습니다. 어느 쪽을 고르든, 옮기는 작업과 동시에 하면 안 됩니다.
스타일 방식을 바꾸는 작업은 "화면이 그대로인지"로 검증하는데, 옮기면서 값도 함께 바꾸면 나중에 뭔가 달라 보일 때 옮기다 실수한 건지 의도한 변경인지 못 가려요. 그래서 이 강의에서도 13px 을 그대로 뒀습니다. 먼저 값을 그대로 옮기고, 다 옮긴 뒤에 정리를 이야기하는 게 안전합니다.
그 다음이 진짜 질문입니다. 개발자가 디자인 값을 바꾸자고 제안해도 되는가.
값의 종류에 따라 다르다고 봅니다.
간격과 글자 크기는 제안해도 되는 쪽입니다. 13px 과 14px 은 나란히 놓고 봐야 겨우 구분됩니다. 대개 그 값이 특별해서가 아니라 그때 그 자리에서 적당해 보여서 정해진 경우가 많아요. 이런 건 눈금에 맞추자고 이야기할 만합니다.
색은 다릅니다. 브랜드 파란색을 "눈금에 가까우니까" 라며 옮기면 안 돼요. 그 값 자체가 의미를 갖고 있고, 로고나 인쇄물처럼 코드 밖에도 같은 값이 쓰이고 있을 수 있습니다.
몇 번 쓰이는지도 함께 봐야 하는데, 13px 은 여섯 번이라 통일 효과가 있지만, 20px 모서리처럼 한 번뿐인 값은 바꾸자고 할 이유가 약해요. 우리가 20px 을 대괄호로 넘긴 게 그래서입니다.
제안할 때는 "눈금에 없어서"라고 말하지 말고 "이 값이 눈금 밖이라 앞으로도 계속 예외로 남습니다"라고 이유를 붙이는 게 낫습니다. 디자이너 입장에서는 도구 사정으로 디자인을 바꾸자는 말로 들리기 쉽거든요. 실제로는 일관성 이야기라는 걸 전달해야 합니다.
거꾸로, 눈금 밖 값이 자꾸 늘어난다면 그건 디자인 문제가 아니라 눈금이 그 프로젝트에 안 맞는다는 신호 일 수 있습니다. 그때는 값 하나하나를 고치는 게 아니라 눈금 자체를 우리 것으로 다시 잡아야 해요. @theme이 그걸 할 수 있게 열어둔 겁니다.
🎯 면접관을 홀리는 핵심 멘트
"옮기는 작업과 값을 바꾸는 작업은 반드시 분리합니다. 섞으면 화면이 달라졌을 때 어느 쪽 탓인지 못 가리거든요. 먼저 값을 그대로 옮겨서 화면이 같은 걸 확인하고, 그 다음에 눈금 밖 값을 정리할지 이야기합니다. 정리할 때도 간격이나 글자 크기는 제안하지만 브랜드 색은 손대지 않습니다. 그 값은 코드 밖에도 쓰이고 있을 수 있으니까요. 그리고 눈금 밖 값이 계속 늘어난다면 디자인이 아니라 눈금을 다시 잡아야 한다는 신호로 봅니다."
🤔 [생각해볼 주제 3] 절반만 옮긴 상태를 어떻게 다룰까
문제 상황 요약
수업이 끝난 시점에 globals.css에는 손 CSS 22 개가 남아 있고, 빌드된 CSS는 시작보다 커졌습니다. 손 CSS와 유틸리티가 한 프로젝트에 섞여 있는 상태예요.
실무에서 스타일 방식을 바꾸는 작업은 하루에 안 끝납니다. 기능 개발과 병행하면 몇 달씩 이 상태로 갑니다.
튜터의 가이드 및 해설
이 중간 상태에서 실제로 생기는 문제는 세 가지입니다.
하나는 판단이 안 서는 겁니다. 새 컴포넌트를 만들 때 어느 방식으로 쓸지, 기존 컴포넌트를 고칠 때 옆에 있는 손 CSS도 같이 옮겨야 할지 매번 고민하게 돼요. 사람마다 다르게 판단하면 코드가 더 섞입니다.
여기는 규칙 두 줄이면 대부분 해결됩니다. 새로 만드는 건 무조건 새 방식으로, 기존 것은 그 파일을 고칠 일이 생겼을 때만 함께 옮깁니다. 뒤쪽이 중요한데, "시간 날 때 옮기자"는 영원히 안 오거든요. 어차피 열어본 파일만 옮기면 자연스럽게 자주 고치는 곳부터 정리됩니다.
둘은 우선순위가 헷갈리는 겁니다. Step 6에서 봤듯 남겨둔 손 CSS가 유틸리티를 이깁니다. text-brand를 붙였는데 잠긴 상태에서는 손 CSS 색이 나왔죠. 섞여 있는 동안은 "이 스타일이 왜 안 먹지"가 반드시 생깁니다.
이건 규칙으로 못 막고 알고 있는 수밖에 없습니다. 다만 어느 쪽이 이기는지 방향은 일정하다는 걸 알면 디버깅이 빨라져요. 우리 손 CSS가 Tailwind 층 바깥에 있으니 손 CSS가 이깁니다. 예상 못 한 스타일이 보이면 손 CSS부터 찾으면 됩니다.
셋은 지표가 나빠지는 겁니다. 과제에서 확인했듯 도중에는 CSS가 커집니다. 이때 누가 "효과가 없네요"라고 하면 반박하기 어려워요. 아직 안 끝났다는 게 사실이지만, 그건 변명처럼 들리거든요.
그래서 시작할 때 "다 옮기기 전까지 지표가 나빠진다"를 미리 말해두는 게 중요합니다. 나중에 설명하면 변명이지만 미리 말하면 예측이에요.
마지막으로 멈추지 않으려면 끝을 눈에 보이게 만들어야 합니다. 우리 경우엔 globals.css의 손 규칙 개수가 그 역할을 합니다. 53 → 34 → 22 → 2로 줄어드는 게 보이니까요. 남은 게 몇 개인지 아무도 모르면 작업은 조용히 멈춥니다.
🎯 면접관을 홀리는 핵심 멘트
"큰 마이그레이션은 중간 상태가 몇 달 갑니다. 저는 세 가지를 미리 정합니다. 첫째, 새로 만드는 건 새 방식으로 하되 기존 것은 그 파일을 고칠 일이 생겼을 때만 옮깁니다. '시간 날 때'는 안 오니까요. 둘째, 옛 코드와 새 코드 중 어느 쪽이 우선하는지를 팀이 알고 있게 합니다.
셋째, 도중에는 번들 크기 같은 지표가 오히려 나빠진다는 걸 시작할 때 미리 말해둡니다. 나중에 말하면 변명이지만 미리 말하면 예측이거든요. 그리고 남은 개수를 항상 셀 수 있게 해둡니다. 끝이 안 보이면 작업은 조용히 멈춥니다."