개발
[Checky] 공용 컴포넌트는 있었지만, 디자인은 흩어져 있었다. Checky에 디자인 시스템을 들인 이야기
| 서론
안녕파세요, 팡일입니다.
Checky에도 공용 컴포넌트는 있었습니다. Button이 있었고 Modal이 있었고 Text도 있었습니다. 그런데 어느 날 다크 모드를 붙여 보려고 코드를 열었을 때, 저는 이 프로젝트의 회색이 몇 종류인지 대답할 수 없었습니다. text-gray-400이 있었고 text-[#8E8E93]이 있었습니다. 둘은 다른 색이지만 화면에서는 똑같이 "연한 회색 보조 텍스트"였습니다.

Tailwind 팔레트에서 고른 순간도 있고, 디자인 시안에서 값을 그대로 옮겨 적은 순간도 있어서, 어느 쪽이 틀렸다고 말하기도 애매했습니다. 공용 컴포넌트를 만들어 뒀는데 왜 이런 일이 생겼을까요
| 공용 컴포넌트는 "재사용"을 해결하지, "일관성"을 해결하지 않는다
공용 컴포넌트는 덩어리를 공유합니다. 버튼이라는 덩어리, 모달이라는 덩어리를 한곳에 두고 여러 화면이 가져다 씁니다. 이건 잘 작동합니다. 버튼을 고치면 모든 버튼이 고쳐지니까요. 문제는 그 덩어리 바깥입니다.
화면을 만들다 보면 컴포넌트로 묶이지 않는 코드가 훨씬 많습니다. 이 화면에만 있는 카드, 한 번만 쓰는 목록 행, 여기서만 필요한 여백. 이런 자리는 공용 컴포넌트가 닿지 않습니다.
그래서 개발자는 매번 Tailwind 클래스를 직접 씁니다. px-4 py-3 rounded-xl bg-white text-gray-800처럼요.
이 한 줄을 쓸 때마다 사실은 결정이 다섯 번 일어납니다. 좌우 여백을 얼마로 할지, 위아래는 얼마로 할지, 모서리를 얼마나 둥글릴지, 배경을 무슨 색으로 할지, 글자를 무슨 색으로 할지. 그리고 그 결정은 어디에도 기록되지 않습니다. 다음 사람은 옆 파일을 열어 보고 비슷하게 따라 적거나, 자기 판단으로 새로 고릅니다.
Checky에서 벌어진 일이 정확히 이것이었습니다. 주요 컴포넌트는 잘 관리되고 있었지만, 화면 코드에 흩어진 Tailwind는 아무도 관리하고 있지 않았습니다. 회색이 두 종류가 된 건 누가 실수해서가 아니라, 고를 수 있는 값이 무한했기 때문입니다. 공용 컴포넌트를 더 만든다고 해결되지 않습니다. 컴포넌트로 감쌀 수 없는 자리는 계속 생기니까요. 필요한 건 덩어리가 아니라, 그 자리에서 쓸 값과 이름이었습니다.
| 디자인 시스템은 어휘를 공유하는 일이다
디자인 시스템이라고 하면 보통 컴포넌트 라이브러리를 떠올립니다. 저도 그랬습니다. 그런데 실제로 손을 대 보니 첫 작업은 컴포넌트를 만드는 게 아니었습니다. 이름을 정하는 일이었습니다.
Checky가 한 첫 번째 결정은 이겁니다.
색을 생김새가 아니라 역할로 부른다.
black, gray-400, red-500은 생김새입니다. 눈에 보이는 대로 부른 이름이죠. content, content-muted, danger는 역할입니다. 그 색이 화면에서 무슨 일을 하는지로 부른 이름입니다.
Checky의 토큰은 이렇게 정리됐습니다.
surface / -raised / -sunken 바탕, 떠 있는 면, 눌린 면
surface-hover / -selected 마우스를 올린 상태, 고른 상태
content / -muted / -subtle 본문, 보조 설명, 더 옅은 것
line / line-strong 구분선, 강조 테두리
primary / accent / danger / warning / success 의미색
on-primary / on-accent / ... 위 색을 배경으로 깔았을 때의 글자색이름을 바꿨을 뿐인데, 세 가지가 따라왔습니다.
첫째, 코드에 의도가 남습니다.
: bg-white라고 쓰면 "흰색"이라는 사실만 남지만, bg-surface-raised라고 쓰면 "이 면은 떠 있는 면이다"라는 판단이 남습니다. 나중에 이 카드의 색을 바꿔야 할 때, 어떤 면들이 같이 바뀌어야 하는지가 코드에 이미 적혀 있는 셈입니다.
둘째, 앞서 말한 회색 두 갈래 문제가 자동으로 풀립니다.
: gray-400과 #8E8E93 중 뭘 쓸지 고민할 일이 없어집니다. 보조 텍스트는 content-muted 하나뿐이니까요. 고를 수 있는 값이 무한해서 생긴 문제였으니, 유한하게 만드는 것이 답이었습니다.
셋째, 다크 테마가 값 교체로 끝납니다.
: 이게 제일 컸습니다. 애초에 다크 모드를 붙이려다 시작한 일이었으니까요.
@theme {
--color-surface: #ffffff;
--color-content: #000000;
--color-content-muted: #74747a;
}
.dark {
--color-surface: #000000;
--color-content: #ffffff;
--color-content-muted: #9a9aa2;
}화면 코드는 bg-surface 하나만 씁니다. dark:bg-black 같은 걸 뿌릴 필요가 없습니다. 이름이 역할이면, 테마 전환은 그 역할에 어떤 값을 넣을지만 바꾸는 일이 되기 때문입니다.
실제 마이그레이션은 이런 모양이었습니다. 404 페이지의 diff입니다.
- <div className="flex min-h-screen ... bg-gray-50 px-4 text-center">
+ <div className="flex min-h-screen ... bg-surface-sunken px-4 text-center">
- <h1 className="text-7xl font-extrabold text-gray-300">404</h1>
+ <h1 className="text-7xl font-extrabold text-content-subtle">404</h1>
- <p className="mt-4 text-xl font-semibold text-gray-800">
+ <p className="mt-4 text-xl font-semibold text-content">
페이지를 찾을 수 없어요
</p>레이아웃 클래스(flex, mt-4, px-4)는 그대로 두고 색만 토큰으로 옮겼습니다. Tailwind를 걷어내는 작업이 아니라, Tailwind가 표현하던 것 중 결정에 해당하는 부분만 이름으로 끌어올리는 작업이었습니다.
| 프리미티브 — 컴포넌트를 만드는 기준
토큰을 정하고 나니 컴포넌트 차례였습니다. 그런데 여기서 한 가지 기준을 세웠습니다.
정할 결정이 있는 자리에만 컴포넌트를 만든다.
Checky에는 원래 Text1부터 Text8까지 있었습니다. 크기 번호로 부르는 컴포넌트였죠. 이게 왜 문제냐면, Text3이라는 이름은 아무것도 정해 주지 않습니다. 어떤 화면은 제목에 Text2를 쓰고 어떤 화면은 Text3을 썼습니다. 둘 다 "제목"이지만 크기가 달랐습니다. 역할로 고르는 하나의 Text로 바꿨습니다.
- <Text3 text="색상" />
+ <Text variant="body">색상</Text>
- <Text2 text="[종료]" className="text-gray-400 font-bold" />
+ <Text variant="bodySm" tone="muted" className="font-bold">[종료]</Text>variant는 display / heading / title / body / bodySm / caption 여섯 개, tone은 default / muted / subtle / danger / warning / accent / success 일곱 개입니다. 크기를 자유롭게 고르는 대신 역할 중에서 고르게 만든 겁니다.
버튼도 마찬가지였습니다. 원래는 NormalBlackButton, NormalBlackUnFillButton 같은 이름이었습니다. 색과 채움 방식이 이름에 박혀 있으니, 빨간 버튼이 필요하면 컴포넌트를 하나 더 만들어야 했습니다.
지금은 variant(채움 방식) × tone(의미) × size(크기) 세 축의 조합입니다.


| 만들었다가 지웠다가, 다시 살린 것
Surface와 Stack은 한 번 지웠습니다. Surface level="raised"는 결국 bg-surface-raised와 같은 말이고, Stack gap={4}는 flex flex-col gap-4와 같은 말이니, 유틸리티를 한 번 더 감싸기만 하는 것 아니냐는 판단이었습니다. 실제로 만들어 놓고 갤러리 말고는 쓰이지도 않았고요.
그런데 얼마 뒤 되살렸습니다. 판단이 틀렸던 지점은 이거였습니다. Stack은 유틸리티를 옮겨 적는 게 아니라 gap으로 고를 수 있는 값을 스케일로 좁히는 컴포넌트였습니다. Text가 글자 크기에 하는 일과 성격이 똑같습니다. gap-[13px]이 나올 자리를 없애는 것이죠. 쓰지 않아서 부채였던 것이지, 필요 없던 게 아니었습니다.
되살린 뒤에는 실제로 채택했습니다. Stack 66곳, Surface 3곳을 옮겼습니다. 추상의 가치는 코드 모양이 아니라 채택 여부가 결정한다는 걸 이때 배웠습니다.
| /dev/ui — 디자인 시스템을 눈으로 보는 화면
개인적으로 이번 작업에서 가장 만족스러웠던 건 여기입니다. 토큰과 프리미티브를 전부 한 페이지에 늘어놓은 갤러리를 만들고 /dev/ui에 붙였습니다. 배경 토큰이 색 칩으로 늘어서 있고, 글자 역할이 표로 있고, 버튼 조합이 격자로 있고, 그림자 세 종류가 카드로 있습니다. 맨 위에는 라이트/다크/시스템 토글이 있습니다.


만들고 나서야 알게 된 효용이 세 가지 있었습니다.
첫째, 고를 수 있는 것의 목록이 화면 하나에 있습니다.
: 새 화면을 만들 때 "보조 텍스트 색이 뭐였더라" 하고 코드를 뒤지지 않습니다. /dev/ui를 열고 눈으로 고릅니다. 문서로 적어 둔 것과 이건 체감이 꽤 다릅니다. 문서는 읽어야 하지만 갤러리는 보면 됩니다.
둘째, 다크 테마 검수 비용이 확 줄었습니다.
: 전에는 색을 하나 고칠 때마다 홈, 카테고리, 루틴, 마이페이지를 차례로 들어가서 다크로 바꿔 보고 확인해야 했습니다. 지금은 갤러리에서 토글 한 번 누르면 모든 토큰이 양쪽 테마에서 어떻게 보이는지 한눈에 들어옵니다. 실제로 이 화면 덕분에 다크에서 hover가 안 보이는 문제를 잡았습니다. surface-hover와 surface-sunken을 같은 값으로 쓰고 있었는데, 다크에서는 "눌린 면"이 바탕보다 어두워지기 때문에 마우스를 올려도 아무 변화가 없었습니다. 화면 안에서는 눈치채기 어려웠지만 칩을 나란히 놓으니 바로 보였습니다.
셋째, 새 토큰을 여기 먼저 놓아 봅니다.
: 실제 화면에 적용하기 전에 갤러리에 칩 하나를 추가해 봅니다. 어색하면 거기서 걸러집니다. 디자인 시스템에 뭘 추가하는 일이 가벼워집니다.
| 개발 빌드에만 넣기 — 라우트만 빼면 될 줄 알았다
이 화면은 사용자에게 보일 이유가 없으니 개발 빌드에만 넣기로 했습니다. 처음엔 라우트 등록만 조건부로 하면 되는 줄 알았습니다.
// 이렇게 하면 안 된다
const UiGalleryPage = lazy(() => import("@/pages/dev/UiGalleryPage"));
...
{import.meta.env.DEV && <Route path="/dev/ui" element={<UiGalleryPage />} />}이러면 라우트는 안 걸리지만 청크는 그대로 만들어져 배포 폴더에 남습니다. 번들러 입장에서는 import()가 코드에 적혀 있으니 만들어 둘 수밖에 없죠. 그래서 조건을 import 자체에 걸어야 합니다.
const UiGalleryPage = import.meta.env.DEV
? lazy(() => import("@/pages/dev/UiGalleryPage"))
: null;
...
{UiGalleryPage && <Route path="/dev/ui" element={<UiGalleryPage />} />}이러면 프로덕션 빌드에서는 import() 자체가 사라지고 청크도 생기지 않습니다. 참고로 이 화면은 정확히는 "localhost 전용"이 아니라 vite dev로 띄운 개발 서버 전용입니다. 빌드 결과를 vite preview로 로컬에서 열어도 접근되지 않습니다.
| 되돌아가지 않게 만들기
여기까지 하고 나서 든 걱정은 하나였습니다. 다시 흩어지면 어쩌지. 토큰을 정해 두어도 다음 화면을 만들 때 무심코 text-gray-400을 쓰면 그만입니다. 사람의 주의력에 기대는 규칙은 오래가지 않습니다. 그래서 린트 규칙을 하나 넣었습니다. Tailwind 원시 팔레트 색(gray-400, red-500 같은 것)을 쓰면 에러가 납니다.

이건 생각보다 큰 차이였습니다. 규칙이 문서에만 있으면 "지켜야 하는 것"이지만, 린트에 있으면 선택지에서 사라집니다. 디자인 시스템이 무너지는 이유는 대개 누가 어겨서가 아니라, 어기는 게 더 쉬워서니까요.
| 지금 어떤가
가장 크게 달라진 건 새 화면을 만들 때 색을 "고르지" 않게 됐다는 점입니다. 전에는 카드를 하나 만들 때 배경을 흰색으로 할지 아주 연한 회색으로 할지 매번 판단했습니다. 지금은 "이건 떠 있는 면인가, 눌린 면인가"만 판단하고 surface-raised나 surface-sunken을 씁니다. 판단의 층위가 색에서 의미로 올라간 셈입니다. 그리고 이쪽이 훨씬 답하기 쉬운 질문입니다.

부수적으로 얻은 것도 있습니다. 다크 테마를 붙이는 게 별도의 작업이 아니게 됐고, 명암비를 숫자로 관리할 수 있게 됐습니다. (iOS 시스템 색을 그대로 쓰던 자리들이 흰 배경에서 3.5:1밖에 안 나온다는 걸 이때 발견했습니다. 본문 크기는 4.5:1을 넘겨야 하는데요. 색상은 유지한 채 명도만 낮춰서 기준을 맞췄습니다.)
| 결론
디자인 시스템을 도입한다고 하면 큰일처럼 들리지만, Checky가 실제로 한 일은 이 순서였습니다.
색에 역할 이름을 붙였다 — 값이 아니라 이름으로 부르기 시작한 것이 전부의 출발점이었습니다.
정할 결정이 있는 자리에만 컴포넌트를 만들었다 — 감싸기 위해서가 아니라, 고를 수 있는 값을 좁히기 위해서.
전부 한 화면에 늘어놓았다 —
/dev/ui가 없었으면 검수도 채택도 훨씬 느렸을 겁니다.어기는 것보다 지키는 게 쉽게 만들었다 — 린트 규칙 하나.
이미 공용 컴포넌트를 쓰고 계신 분이라면, 아마 Checky와 같은 지점에 서 계실 겁니다. 컴포넌트는 잘 관리되는데 화면 코드는 관리가 안 되는 상태요. 그렇다면 다음 단계는 컴포넌트를 더 만드는 게 아니라, 색 이름 열 개쯤 정하는 것부터일지도 모릅니다. 생각보다 작게 시작할 수 있고, 그 열 개가 꽤 멀리까지 데려다줍니다.
Checky가 궁금하시다면? 보러가기