던플 개발기 (5): 디자인 시스템을 실제로 깐 과정 — wave, 토큰 대비 게이트, 폰트 subset
프론트엔드 편 (3)에서 디자인 시스템을 "왜" 그렇게 깔았는지를 다뤘습니다. 이번 편은 같은 주제의 "어떻게"입니다. 토큰을 OKLCH로 관리하기로 했다는 결정과, 그 토큰이 실제로 대비를 만족하는지 매번 검증하는 게이트를 만드는 일은 다른 작업이니까요.
던플마켓 디자인 시스템 foundation은 한 번에 만든 게 아니라 작은 단위로 쌓아 올렸습니다. 이 글은 그 과정에서 나온 구체적인 결정들 — wave로 나눈 빌드 순서, 토큰 이름이 아니라 대비를 계산하는 테스트, 2MB 폰트를 subset으로 줄인 교정 — 을 기록한 구현 회고입니다.
한눈에 보면
- 디자인 시스템은 한 번에 만들지 않고 **작은 task(0101~0117)**로 쌓았습니다. 빈 폴더를 미리 만들지 않고 필요한 것부터 채웠습니다.
- shadcn/ui를 설치형 패키지가 아니라 프로젝트 코드로 소유합니다.
market-web과shared-ui에components.json을 따로 두되 같은 style·base color를 씁니다. - 토큰 테스트는 이름 존재만 보는 parity test와 **실제 대비를 계산하는
contrast.spec.ts**를 분리했습니다. - 폰트는 단일 2MB variable에서 KS X 1001 per-weight subset으로 바꿔 route당 약 48% 줄였습니다.
- 스타일은 Tailwind +
cva/cn만, 시스템 CSS는 다섯 가지 예외로만 제한했습니다. - 결과는 component 58개 / CSF story 58개, light·dark parity·대비·keyboard 테스트, production build 통과입니다.
한 번에 만들지 않았다: wave로 쌓기
가장 먼저 정한 건 "한꺼번에 다 만들지 않는다"였습니다. 컴포넌트 폴더를 미리 빈 껍데기로 만들어 두면 쓰지도 않는 추상화가 쌓입니다. 그래서 실제 화면이 필요로 하는 것부터, 작은 task 단위로 쌓았습니다.
| 단계 | 한 일 |
|---|---|
| 0101 | Tailwind·reset·shadcn monorepo alias·Storybook tooling |
| 0102 | token CSS·dark mode·대비 테스트 |
| 0103 | Pretendard self-host |
| 0104 | Storybook Autodocs·MDX·viewport·a11y 패널 |
| 0105–0111 | 핵심 component wave(General→Feedback→Form→Navigation→Data Display→고급 입력) |
| 0112–0113 | 반응형 app shell·페이지 placeholder |
| 0114 | 품질 게이트 |
| 0115 | dependency 소유권·command alias |
| 0116 | 테마 점검·rarity 토큰 |
| 0117 | motion 시스템 |
컴포넌트 범위는 Ant Design을 capability 기준으로 참고했습니다. Ant Design 패키지를 설치하거나 API를 복제하는 게 아니라, "Ant이 다루는 기능을 우리 UI만으로 조립할 수 있는가"를 wave A~E로 나눠 채웠습니다. 당장 필요 없는 무거운 기능은 defer로 표시해 두되 카탈로그에서 누락하지는 않았습니다.
shadcn을 '설치'가 아니라 '소유'한다
shadcn/ui는 npm 의존성처럼 버전을 올리는 패키지가 아니라, 컴포넌트 코드를 내 프로젝트로 가져와 직접 고치는 방식입니다. 던플마켓도 shadcn 컴포넌트를 libs/shared/ui로 가져온 뒤 던플 토큰과 접근성 기준에 맞게 수정했습니다.
모노레포라서 한 가지 정합을 신경 써야 했습니다. apps/market-web과 libs/shared/ui 각각에 components.json을 두는데, 둘이 같은 style·icon library·base color를 가리키게 맞췄습니다. 그래야 어느 쪽에서 컴포넌트를 추가해도 같은 결로 들어옵니다. 앱에서는 컴포넌트 내부 파일 경로를 직접 import하지 않고 패키지 export(@dunplay/shared-ui/components/*)로만 가져오게 해서, 내부 구조를 바꿔도 사용처가 깨지지 않게 했습니다.
토큰: 이름이 아니라 대비를 계산한다
토큰에서 가장 공들인 부분은 "검증"이었습니다. 토큰을 OKLCH로 선언하고 light·dark에서 같은 이름을 다른 값으로 해석하기로 한 것까지는 (3)에서 말한 대로입니다. 문제는 "그래서 그 색이 실제로 읽히는가"를 매번 사람이 눈으로 확인할 수 없다는 점이었습니다.
그래서 테스트를 두 종류로 나눴습니다.
- parity test: light와 dark가 같은 semantic 토큰 이름을 제공하는지 확인합니다. 이름이 빠지지 않았는지만 봅니다.
contrast.spec.ts:tokens.css를 직접 읽어 실제 대비 비율(contrast ratio)을 계산합니다. 이름이 아니라 값을 검증합니다.
대비 임계는 용도별로 다르게 뒀습니다.
| 대상 | 기준 |
|---|---|
| 본문·badge·muted·rarity 텍스트 | 4.5:1 이상 |
| gradient 채움/foreground 양 끝 색 | 4.5:1 이상 |
| status·freshness 비텍스트 accent | 3.0:1 이상 |
이 게이트 덕분에 새 토큰 조합을 추가할 때 "예뻐 보이는데 안 읽히는" 색을 PR 단계에서 걸러 냅니다. 특히 던전앤파이터 rarity는 까다로웠습니다. 공식 가이드의 등급 색(예: common #FFFFFF)을 reference로 기록은 하되, 그대로 쓰면 light mode에서 안 보이므로 semantic 토큰은 대비를 보정한 값으로 따로 뒀습니다. mythic·primordial 같은 gradient 등급은 양 끝 색 모두에 AA 대비를 만족하는 전용 foreground 토큰을 만들어, gradient 위 글자에 text-white/text-black을 직접 쓰지 않게 했습니다.
폰트: 2MB를 267KB로
폰트는 처음부터 잘 깐 게 아니라 고쳤습니다. 0103에서는 공식 pretendard@1.3.9 배포본의 단일 variable woff2(약 2,057,688 bytes)를 vendor해서 모든 route가 그걸 적재했습니다. 그러다 최종 품질 점검에서, UI가 실제로 쓰는 weight만 정적 subset으로 교체했습니다.
| 구분 | 크기 |
|---|---|
| 단일 variable woff2 | 약 2.06MB |
| KS X 1001 subset (weight당) | 약 267KB |
| route당 합계(4 weight) | 약 1.07MB |
400/500/600/700 네 weight만 next/font/local로 self-host하니 단일 blob 대비 약 48% 줄었고, 브라우저 캐시 이후 route 이동에서는 같은 asset을 재사용합니다. 어디서 가져왔는지(pretendard@1.3.9 tarball, integrity, 원본 경로)는 SOURCE-Pretendard.md에 적어 두고, SIL Open Font License 파일도 함께 vendor했습니다. 앱 runtime 의존성으로 Pretendard 패키지 전체를 설치하지는 않습니다.
이 과정에서 남은 교훈은 "처음 결정을 끝까지 고집하지 않는다"였습니다. variable 폰트가 편해 보였지만 측정해 보니 무거웠고, subset 전환은 측정값이 있어서 자신 있게 바꿀 수 있었습니다.
스타일은 다섯 가지 CSS만 허용한다
스타일링 방법이 여러 개면 같은 버튼을 세 가지 방식으로 만들게 됩니다. 그래서 규칙을 좁게 고정했습니다. 컴포넌트는 Tailwind utility와 cva/cn만 쓰고, 시스템 레벨 CSS는 아래 다섯 가지만 허용합니다.
Tailwind entry
reset.css (호환성 보정만)
tokens.css (semantic 토큰)
storybook-fonts.css
공용 motion utility · rarity recipeindex.css가 이 조각들을 명시적인 순서로 조립합니다.
flowchart LR
A[Tailwind theme] --> B[Tailwind Preflight]
B --> C[reset.css 보정]
C --> D[tokens.css]
D --> E[Tailwind utilities]reset.css에는 .button·.card 같은 페이지 전용 selector를 넣지 않습니다. body min-height, form 요소 font 상속, focus-visible, reduced-motion fallback 같은 호환성 보정만 둡니다. 생성기가 깔아 둔 예제 CSS와 shared-ui.module.css는 제거했습니다. motion도 토큰으로 통일해서(--motion-fast 120ms / --motion-base 180ms / --motion-slow 240ms), prefers-reduced-motion에서는 transition을 0.01ms로 줄입니다.
Storybook과 완료 상태
컴포넌트는 앱을 띄우지 않고 Storybook에서 light·dark·viewport·접근성을 확인합니다. CSF로 상태 예시, Autodocs로 API 문서, MDX로 사용 가이드를 나눠 씁니다. 여기에 Jest로 light/dark parity, 위에서 말한 대비, 핵심 keyboard 동작을 자동 검증합니다.
2026-06-02 기준 foundation은 이렇게 마무리됐습니다.
component 58 / CSF story 58
light/dark 토큰 parity test
WCAG AA 대비 test
핵심 keyboard interaction test
Storybook static build
market-web production build솔직히 못 한 것도 명시해 뒀습니다. axe 결과를 CI에서 실패시키는 test-storybook 게이트는 test-runner가 이 워크스페이스의 Jest 30과 호환되지 않아 미뤄 둔 상태입니다. 억지로 의존성을 올리는 대신 그때까지는 axe 패널과 수동·자동 keyboard 점검을 함께 합니다. 이후 화면은 이 컴포넌트와 recipe를 우선 쓰고, 일상 검증은 pnpm check:ui / pnpm build:web으로 합니다.
정리하면
디자인 시스템을 "실제로 깐" 과정의 핵심은 결정이 아니라 검증과 교정이었습니다.
- 한 번에 만들지 않고 작은 task(wave)로 쌓아, 쓰지 않는 추상화를 미리 만들지 않았다.
- shadcn을 코드로 소유하고, 두
components.json을 같은 기준으로 맞췄다. - 토큰은 이름 parity와 실제 대비(
contrast.spec.ts)를 분리해 검증했다. - 폰트는 측정값을 근거로 2MB variable에서 subset으로 갈아끼웠다.
- 스타일 방법을 다섯 가지 CSS로 좁혀 일관성을 강제했다.
다음 편부터는 이 디자인 시스템 위에 첫 기능을 올린 이야기 — 아이템 검색을 .proto부터 화면까지 한 줄기로 구현한 vertical slice — 로 넘어가겠습니다.
