문서 읽는 데 86분 · E2

E-2: Tailwind CSS-first 설정 — 이번엔 값에 이름을 준다

목차 60
전체 59강 중 33강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다. 지난 시간에 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에 같은 값이 얼마나 반복되는지 정확히 세어 봅시다. 느낌으로 "많다" 고 하면 해결됐는지도 알 수 없거든요.

색을 세어 봅시다

터미널에서 세어 볼게요.

Bash
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 맨 위에 넣어 봅시다.

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가 어떻게 나오는지 봅시다.

Bash
npm run build --workspace apps/web-spa

빌드된 CSS가 몇 바이트인지 재 볼게요. dist/assets/에 생긴 파일 크기를 세는 명령입니다.

Bash
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를 만들어라"가 아니라 "이런 이름을 쓸 수 있게 준비해 둬라"입니다.

써 보면 생깁니다

게시물 카드의 테두리 색을 이 이름으로 바꿔 봅시다. 먼저 카드에 유틸리티를 붙입니다.

tsx
// 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에서는 색만 걷어내고, 굵기와 선 종류는 아직 남겨둡니다.

CSS
.post-card {
  background: #ffffff;
  border: 1px solid;
  border-radius: 8px;
  margin-bottom: 24px;
  overflow: hidden;
}

다시 빌드하고 크기를 재면 9278로 57 바이트가 늘었는데, 늘어난 게 뭔지 CSS에서 직접 찾아봅시다.

Bash
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인 이유예요. 나중에 회색을 조금 바꾸더라도 "테두리에 쓰는 색"이라는 뜻은 그대로니까요.

CSS
/* 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 기본 색을 하나도 안 쓰고 있으니 팔레트가 통째로 없어요.

잠깐 쓰게 만들어야 하니, 아무 컴포넌트에나 한 줄 넣고 빌드해 보세요.

tsx
// 확인용입니다. 보고 나서 지웁니다.
<div className="bg-neutral-50 bg-neutral-300 bg-neutral-500 bg-neutral-800 bg-blue-500" />
Bash
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로 바꿔 봅시다.

CSS
/* 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-30087%로 다름

셋은 글자까지 똑같은데, 화면에 칠해서 재봐도 같은 색이라 text-neutral-500이라고 써도 text-subtle과 똑같이 나옵니다.

지난 시간에 제가 지목했던 #0095f6·#ed4956·#dbdbdb는 Tailwind에 정말 없는 색이 맞고, line은 아깝게 빗나갔는데, 오늘 아홉 개를 등록하면서 새로 들어온 회색 셋은 사정이 달랐던 겁니다.

그런데도 이름을 주는 이유가 있습니다. neutral-500은 값을 부르는 이름이고 subtle은 용도를 부르는 이름이거든요.

text-neutral-500이라고 적힌 곳을 보면 "회색 500 번"인 건 알겠는데 왜 회색인지는 모릅니다. text-subtle은 "덜 중요해서 흐리게 두는 글자"라고 말해줘요. 나중에 그 글자를 조금 더 진하게 바꾸기로 하면, 용도로 이름을 지은 쪽만 한 줄 고쳐서 끝납니다.

다크모드에서 이 차이가 결정적으로 드러납니다. 어두운 화면에서는 canvassurface의 밝기가 통째로 반대편으로 넘어가거든요.

💡 한 줄 정리

Tailwind의 색 눈금은 oklch로 되어 있습니다(흰색과 검정만 hex예요). 색상각이 달라져도 보이는 밝기가 거의 안 변해서(편차 67.2 대 3.4) 색을 조절하기 좋기 때문이에요. 우리 색도 같은 표기로 옮겨 적되, 변환은 도구에 맡기고 옮긴 뒤 눈으로 확인합니다.

🙋 학생 질문 — "그냥 hex로 두면 안 되나요?"

됩니다. @theme은 표기를 가리지 않아서, #0095f6이라고 적어도 유틸리티는 똑같이 생기고 화면도 같아요.

실제로 디자인 시안은 hex로 오는 경우가 많은데, 그걸 굳이 변환하다가 값이 틀어지면 손해라서 그대로 두는 것도 충분히 합리적인 선택입니다.

우리는 왜 옮겼냐면 두 가지 때문인데, 하나는 Tailwind 기본 팔레트와 표기가 통일돼서 나란히 놓고 견주기 편하다는 것이고, 다른 하나는 나중에 다크모드용 색을 만들 때 명도 숫자만 만지면 된다는 것입니다.

한 가지만 조심하세요. 팀에서 표기를 섞어 쓰면 "이 둘이 같은 색인가?"를 매번 눈으로 확인해야 합니다. 어느 쪽을 고르든 한 파일 안에서는 통일하는 게 낫습니다.


Step 3: "@theme:root는 무엇이 다른가"

Step 1에서 --color-line을 찾았을 때는 값만 잘라서 보고, 그 변수가 어디에 담겨 있는지는 안 봤습니다. 셀렉터만 뽑아 봅시다.

Bash
grep -o ":root[^{]*{" apps/web-spa/dist/assets/*.css
텍스트
:root,:host{

우리가 등록한 토큰은 여기 담겨 나옵니다. @theme이라고 적었는데 결과는 :root인 거예요.

:root는 선수 과목에서 이미 배운 건데, CSS 변수를 선언하는 곳이었죠. 그럼 이런 생각이 듭니다. 어차피 :root로 나올 거면 그냥 :root에 쓰면 되지 않나?

직접 해봅시다.

대조군을 만들어 봅시다

globals.css:root를 하나 두고, 눈에 확 띄는 색을 넣습니다.

CSS
/* apps/web-spa/src/styles/globals.css */
:root {
  --color-probe: #ff00ff;
}

카드에 bg-probe를 붙여 봅니다. @theme에 등록했을 때 border-line이 생겼으니, 같은 방식이면 bg-probe도 생겨야겠죠.

tsx
// apps/web-spa/src/components/PostCard.tsx
<Card className="post-card border-line bg-probe" ... >

빌드하고 화면을 보세요. 카드 배경이 안 바뀝니다. 자홍색이 나와야 하는데 그대로 흰색이에요.

CSS에서 찾아봅시다.

Bash
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가 없는 겁니다. 지난 시간 마지막에 본 조용한 실패와 같은 모습이에요.

차이 하나 — 유틸리티를 만드는가

:rootCSS 변수를 만드는 곳이고, 그게 전부입니다. 브라우저에게 "이런 변수가 있다" 고 알려줄 뿐이에요.

그런데 @themeTailwind 에게 알려주는 곳입니다. 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에 들어 있습니다.

Bash
grep -o "\-\-color-probe:[^;}]*" apps/web-spa/dist/assets/*.css
텍스트
--color-probe:#f0f

반면 @theme 쪽은 정반대인데, 우리가 아홉 개를 등록했지만 지금 실제로 쓰는 건 border-line 하나뿐이죠. 나머지 여덟 개를 찾아보세요.

Bash
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: "어떤 값에 이름을 줄까"

색은 정리됐으니, 이제 나머지 값들을 봅시다. 간격, 글자 크기, 모서리 둥글기요.

색과 똑같이 하면 될 것 같지만, 세어보면 사정이 좀 다릅니다.

먼저 세어 봅시다

Bash
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-morefont-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 폭은 각각 한 번뿐이라 이름을 주면 이름만 늘어납니다.

CSS
/* 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에 적어둔 글꼴을 보세요.

CSS
font-family: system-ui, -apple-system, 'Apple SD Gothic Neo', sans-serif;

Apple SD Gothic Neo는 한글을 위해 넣은 겁니다. 이걸 그냥 두면 곧 문제가 생기는데, 다음 Step에서 body 규칙을 걷어내면 Tailwind 기본 글꼴이 적용되면서 이 한글 글꼴이 빠지거든요.

이제 @theme의 다른 쓰임이 나옵니다. Tailwind 가 이미 갖고 있는 토큰을 우리 값으로 덮어쓸 수 있어요.

CSS
/* 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를 보세요.

CSS
.feed-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

세 줄 전부 리셋이 이미 해줍니다. 유틸리티를 하나도 안 붙이고 규칙만 지우면 됩니다. Feed 컴포넌트에서 className을 통째로 빼세요.

tsx
// 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-imagedisplay: 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에서 배운 대로 조건에 따라 문자열을 고르면 됩니다. 공통 부분은 앞에 두고, 갈리는 부분만 조건으로요.

tsx
// 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로 갑니다.

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가 기본 글꼴로 우리 값을 씁니다. 개발자 도구에서 bodyfont-family를 확인해 보세요. Apple SD Gothic Neo가 그대로 있습니다.

⚠️ 규칙을 지웠더니 엉뚱한 곳이 무너집니다

그런데 여기서 예상 못 한 일이 생깁니다. .post-card 규칙을 지우고 화면을 보면, 우리가 안 건드린 데모 화면들의 테두리가 사라져 있어요.

찾아보면 이렇습니다.

Bash
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 컴포넌트를 보세요.

tsx
<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-surfacePostCard·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: noneoutline: 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
tsx
// 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%가 들어갑니다.

글자가 다르다고 화면이 다른 건 아닙니다. 흰 배경 위에 겹쳐 칠한 픽셀을 재보면 둘 다 같은 값이고, 요소의 실제 위치와 폭도 같습니다. 색과 정렬은 계산값 문자열이 아니라 눈에 보이는 결과로 견줘야 해요.

⚠️ 두 곳만 못 옮깁니다

거의 다 옮겼는데 두 규칙이 남습니다.

CSS
.comment-submit:disabled {
  color: #b2dffc;
  cursor: default;
}

.comment-remove:hover {
  color: #262626;
}

빈 입력일 때 잠기는 제출 버튼과, 마우스를 올리면 진해지는 댓글 삭제 버튼입니다.

우리가 지금까지 쓴 유틸리티는 전부 조건 없이 적용되는 것들이었어요. text-brand는 언제나 파란 글자입니다. "잠겼을 때만"이나 "마우스를 올렸을 때만"을 말할 방법이 아직 없습니다.

그래서 이 두 규칙은 손 CSS로 남겨두는데, 규칙이 붙으려면 클래스 이름이 있어야 하니 그 두 요소만 손으로 지은 이름을 그대로 달고 있게 됩니다.

tsx
// 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에서 아홉 개를 전부 쓴다

다음 시간 예고

오늘 못 끝낸 게 정확히 두 줄 남아 있습니다.

CSS
.comment-submit:disabled { ... }
.comment-remove:hover { ... }

"잠겼을 때만", "마우스를 올렸을 때만"을 유틸리티로 말할 방법이 없어서 남겨뒀죠. 다음 시간에 이걸 배우는데, hover:·disabled:처럼 유틸리티 앞에 조건을 붙이는 방법이에요.

조건은 상태만이 아닙니다. 화면 크기도 조건이 됩니다. 지난 시간 과제에서 변수 이름을 container로 지었더니 .container가 6 개 나왔던 것 기억하시나요. 왜 하나가 아니라 여섯이었는지, 다음 시간에 답이 나옵니다.

B-4 과제에서 창 너비를 재는 훅을 만들며 "CSS로 되는 일을 굳이 자바스크립트로 하지 말라" 고 했던 것도 그때 회수합니다.

마지막으로 오늘 이름만 지어두고 안 갈라놓은 게 있습니다. canvassurface요. 둘 다 거의 흰색이라 나눌 이유가 없어 보였는데, 어두운 화면에서는 이 둘이 완전히 갈라집니다. 다크모드는 조건 붙이는 법을 먼저 익힌 다음, 그 뒤에 이어서 합니다.


과제

[구현] 남은 손 CSS를 마저 걷어내기

globals.css에 남은 22 개 중 교습용 데모 20 개를 옮기세요. 조건이 붙은 두 규칙(:disabled·:hover)은 다음 시간 몫이니 그대로 둡니다.

대상은 이 컴포넌트들입니다.

  • KeyDemo.key-demo · .key-demo-list · .key-demo-row
  • EffectTimingDemo.effect-demo · .effect-demo-values · .effect-demo-button
  • InputValueDemo.input-demo-pair · .input-demo · .input-demo h3 · .input-demo p · .input-demo-field
  • ProfileImagePicker.profile-picker · .profile-picker-input · .profile-picker-button · .profile-picker-name
  • ResponseCheckDemo.response-demo · .response-panel · .response-title · .response-verdict · .response-issues

몇 가지 힌트를 드릴게요.

  • .key-demo-list.feed-list와 같은 경우입니다. 유틸리티가 몇 개 필요한지 먼저 따져보세요.
  • .input-demo h3처럼 자식을 가리키는 규칙은 유틸리티로 그대로 옮길 수 없습니다. 그 자식 요소에 직접 붙여야 해요.
  • .profile-picker-inputdisplay: none에 맞는 유틸리티를 찾아보세요.
  • .input-demo-fieldbox-sizing: border-box는 옮길 필요가 있는지 확인해 보세요.
  • RefVsStateDemoclassName="ref-demo"도 한번 찾아보세요. 뭔가 이상한 게 있습니다.

다 옮기면 globals.css가 몇 줄이 됐는지, 빌드된 CSS가 몇 바이트인지 적어 주세요. 크기가 어느 쪽으로 갔는지가 이 과제에서 제일 중요한 관찰입니다.

마치면 npm run typecheck -w web-spanpm 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 옮길 필요가 없다는 것을 확인했는가
죽은 이름 RefVsStateDemoref-demo에 규칙이 없다는 것을 발견하고 지웠는가
토큰 사용 색과 13px 을 직접 적지 않고 border-line·bg-canvas·text-note로 썼는가
비슷한 규칙 구분 .effect-demo-buttonmargin-right를 놓치지 않고 mr-2를 붙였는가 (버튼 두 개 모두)
남긴 것 :disabled·:hover 두 규칙을 그대로 뒀는가
크기 보고 globals.css 줄 수와 빌드된 CSS 크기를 적었는가
크기 방향 크기가 줄어든 것을 확인하고 왜인지 설명했는가
마무리 확인 typechecklint가 통과하는가

풀이 예시

먼저 반복되는 조합부터 눈에 들어옵니다. 데모 화면들은 대부분 "흰 카드에 테두리" 모양이라 같은 문자열이 여러 번 나와요.

쓰임 유틸리티
아래 여백이 있는 카드 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-listlist-style: none·margin: 0·padding: 0 세 줄뿐이라 리셋이 전부 해줍니다. 유틸리티 없이 클래스만 빼면 돼요.

tsx
// 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를 다시 쓰는 곳이에요.

tsx
// 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는 자식을 가리키는 규칙이라 유틸리티로 그대로 못 옮겨요. 자식 요소에 직접 붙여야 합니다.

tsx
// 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-fieldbox-sizing: border-box는 안 옮겨도 됩니다. 리셋이 모든 요소에 이미 주고 있어요. 개발자 도구에서 아무 요소나 잡고 box-sizing을 보면 전부 border-box입니다.

ProfileImagePicker의 숨은 입력창은 hidden입니다.

tsx
// 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가 됩니다.

tsx
// 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에서 찾아보면 규칙이 없습니다. Sectionsection과 같은 경우예요. 지우면 끝입니다.

tsx
// 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에 남는 건 이게 전부입니다.

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-listlist-none m-0 p-0을 붙이는 경우가 많습니다. 틀린 건 아니지만 셋 다 리셋이 이미 해주는 일이라 없어도 화면이 같아요. 붙이기 전에 지워보고 확인하는 습관을 들이면 좋습니다.

.input-demo h3를 부모에 붙이려다 막히는 경우도 있습니다. 유틸리티는 자기 자신에게만 적용돼서 "내 안의 h3" 같은 걸 말할 수 없어요. 자식에게 직접 가는 게 맞습니다.

13px 을 text-[13px]로 적은 경우도 틀리지 않았고 화면도 같은데, 다만 우리는 Step 4에서 여섯 번 반복된다는 이유로 --text-note라는 이름을 줬으니, 그 이름을 쓰는 게 일관됩니다.

💡 튜터의 한마디

이 과제에서 제일 중요한 건 옮기는 손놀림이 아니라 크기가 오르내리는 걸 직접 본 것입니다.

실무에서 스타일 방식을 바꾸자고 제안하면 "그래서 얼마나 좋아지는데요"라는 질문을 받습니다. 그때 중간 수치를 들고 가면 오히려 역효과예요. 아직 옮기는 중이라 커진 상태거든요.

작업을 절반쯤 하고 멈춘 뒤 "이거 해봤는데 별로네요"라고 결론 내리는 팀을 실제로 많이 봅니다. 도중에 재면 나쁘게 나오는 게 당연한데도요. 시작할 때 "다 옮기기 전까지는 지표가 나빠진다"를 미리 합의해 두는 게 그래서 중요합니다.

🎯 [과제 2 예시답안] 토큰의 경계를 건드려 보기

채점 포인트

항목 확인할 것
실험 ① 안 바뀐 곳을 찾아내고, 그 이유가 두 종류라는 것까지 갈랐는가
실험 ② 짝을 만들면 줄 높이도 정해진다는 것을 확인했는가
실험 ③ 변수는 남는데 유틸리티만 사라지는 것을 두 곳(CSS·화면)에서 확인했는가
실험 ④ 크기가 안 변하는 이유를 Step 1과 이어 설명했는가
되돌리기 네 실험을 전부 원래대로 되돌렸는가

실험 ① — --color-line을 빨강으로

CSS
--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를 직접 들고 있어서, 토큰을 바꿔도 꿈쩍 안 합니다.

Bash
grep -n "#dbdbdb" apps/web-spa/src/styles/globals.css

일곱 곳이 나옵니다. "값이 한곳에 모였다"는 아직 절반만 참인 거예요. 과제 1을 마치면 그제야 완전히 참이 됩니다.

나머지 둘은 안 바뀌는 게 정상입니다. 애초에 다른 이름을 쓰고 있으니까요. "안 바뀌었다"를 전부 같은 이유로 묶으면 안 됩니다.

실험 ② — 줄 높이 짝 만들기

CSS
@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

CSS
:root {
  --color-brand: oklch(65.46% 0.177 248.2);
}

@theme {
  /* --color-brand 를 여기서 뺀 상태 */
}

화면부터 보세요. 가입하기 버튼의 파란 배경이 사라집니다. 댓글 제출 버튼의 파란 글자도 없어지고요.

CSS에서 확인하면 이렇습니다.

Bash
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까지 쓰이기 시작해서, "안 쓰는 토큰을 지운다"는 방향으로는 실험이 안 됩니다.

반대로 하면 됩니다. 안 쓸 토큰을 하나 새로 넣어보는 거예요.

CSS
--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-300neutral-500보다 밝다는 것도 이름만 보고 압니다. 디자이너와 개발자가 같은 언어로 이야기하기도 좋아요.

용도를 부르는 이름(subtle)은 왜 그 색인지를 말해줍니다. 그리고 결정적으로, 값을 바꿀 때 한곳만 고치면 됩니다. 라벨과 설명 글자를 조금 진하게 바꾸기로 하면 --color-subtle 한 줄이면 끝나요.

값으로 이름을 지었다면 text-neutral-500이 적힌 곳을 전부 찾아 바꿔야 합니다. 게다가 그중 어떤 게 "덜 중요한 글자" 고 어떤 게 다른 이유로 회색인지 구분해야 해요.

Step 1에서 본 #fafafa 문제와 똑같은 모양입니다. 같은 값이 다른 뜻으로 쓰이면 일괄 치환이 안 됐죠.

그래서 규모가 커지면 대부분 두 층을 다 둡니다. 값을 부르는 이름을 아래에 깔고, 용도를 부르는 이름이 그것을 가리키게 하는 식이에요. 디자인 도구와 코드가 같은 팔레트를 공유하면서도, 화면에서는 용도로 부를 수 있습니다.

대신 대가가 있습니다. 이름이 두 배가 되고, 새 팀원은 두 층을 다 익혀야 해요. subtle이 뭔지 보려면 팔레트를 한 번 더 열어야 하고요. 화면이 몇 개 안 되는 프로젝트에서는 이 비용이 얻는 것보다 큽니다.

판단 기준을 하나만 고른다면 "이 값이 앞으로 바뀔 것 같은가"입니다. 바뀔 값이면 용도로 부르는 게 낫고, 안 바뀔 값이면 굳이 층을 더할 이유가 없어요.

우리 프로젝트에서 canvassurface를 굳이 나눈 게 그 예입니다. 지금은 둘 다 거의 흰색이라 나눌 이유가 없어 보이지만, 다크모드를 하면 완전히 갈립니다. 바뀔 것을 알고 있으니 미리 용도로 부른 거예요.

🎯 면접관을 홀리는 핵심 멘트

"토큰 이름은 값이 아니라 용도로 짓습니다. 값으로 이름을 지으면 나중에 값을 바꿀 때 그 이름이 쓰인 곳을 전부 찾아야 하고, 그중 어떤 게 정말 그 용도였는지 구분할 수 없거든요. 다만 이름 층이 늘어나는 비용이 있어서, 저는 '이 값이 앞으로 바뀔 것 같은가'를 기준으로 나눕니다. 다크모드처럼 값이 뒤집히는 게 예정돼 있으면 미리 용도로 부르고, 안 바뀔 값이면 기본 팔레트를 그대로 씁니다."

🤔 [생각해볼 주제 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로 줄어드는 게 보이니까요. 남은 게 몇 개인지 아무도 모르면 작업은 조용히 멈춥니다.

🎯 면접관을 홀리는 핵심 멘트

"큰 마이그레이션은 중간 상태가 몇 달 갑니다. 저는 세 가지를 미리 정합니다. 첫째, 새로 만드는 건 새 방식으로 하되 기존 것은 그 파일을 고칠 일이 생겼을 때만 옮깁니다. '시간 날 때'는 안 오니까요. 둘째, 옛 코드와 새 코드 중 어느 쪽이 우선하는지를 팀이 알고 있게 합니다.

셋째, 도중에는 번들 크기 같은 지표가 오히려 나빠진다는 걸 시작할 때 미리 말해둡니다. 나중에 말하면 변명이지만 미리 말하면 예측이거든요. 그리고 남은 개수를 항상 셀 수 있게 해둡니다. 끝이 안 보이면 작업은 조용히 멈춥니다."

전체 목록 리액트