던플 개발기 (5): 디자인 시스템을 실제로 깐 과정 — wave, 토큰 대비 게이트, 폰트 subset

Dunplay

프론트엔드 편 (3)에서 디자인 시스템을 "왜" 그렇게 깔았는지를 다뤘습니다. 이번 편은 같은 주제의 "어떻게"입니다. 토큰을 OKLCH로 관리하기로 했다는 결정과, 그 토큰이 실제로 대비를 만족하는지 매번 검증하는 게이트를 만드는 일은 다른 작업이니까요.

던플마켓 디자인 시스템 foundation은 한 번에 만든 게 아니라 작은 단위로 쌓아 올렸습니다. 이 글은 그 과정에서 나온 구체적인 결정들 — wave로 나눈 빌드 순서, 토큰 이름이 아니라 대비를 계산하는 테스트, 2MB 폰트를 subset으로 줄인 교정 — 을 기록한 구현 회고입니다.

한눈에 보면

  • 디자인 시스템은 한 번에 만들지 않고 **작은 task(0101~0117)**로 쌓았습니다. 빈 폴더를 미리 만들지 않고 필요한 것부터 채웠습니다.
  • shadcn/ui를 설치형 패키지가 아니라 프로젝트 코드로 소유합니다. market-webshared-uicomponents.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-weblibs/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 recipe

index.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 — 로 넘어가겠습니다.

같이 보면 좋은 글