junyeokk
Blog
Testing·2025. 08. 11

Design Tokens in Storybook

디자인 시스템을 운영하다 보면 색상, 타이포그래피, 간격 같은 값들이 코드 곳곳에 흩어져 있다는 걸 깨닫게 된다. 디자이너가 "Primary Blue를 #2196f3에서 #1976d2로 바꿔주세요"라고 하면, 그 색상이 사용된 곳을 일일이 찾아서 바꿔야 한다. 더 큰 문제는 시간이 지나면서 비슷하지만 미묘하게 다른 색상들이 난립한다는 것이다. #4a5565#4b5563이 둘 다 "회색 텍스트"로 사용되고 있지만 누가 어떤 의도로 넣었는지 아무도 모른다.

Design Token은 이 문제를 해결하기 위한 개념이다. 색상, 폰트 크기, 간격, 그림자 같은 디자인 값에 의미 있는 이름을 붙여서 하나의 중앙 소스로 관리하는 것이다. "이 버튼의 배경색은 #2196f3이다"가 아니라 "이 버튼의 배경색은 color-primary이다"로 표현하면, 나중에 primary 색상이 바뀌어도 토큰 정의만 수정하면 된다.

Storybook은 컴포넌트를 독립적으로 개발하고 문서화하는 도구인데, Design Token을 시각적으로 문서화하는 데도 활용할 수 있다. 코드에 정의된 토큰 값을 Storybook Story로 렌더링하면, 개발자와 디자이너가 현재 시스템에서 사용 가능한 색상/타이포그래피/간격을 한눈에 확인할 수 있다.

Design Token이란

Design Token은 디자인 시스템의 가장 작은 단위다. 색상 하나, 폰트 크기 하나, 간격 하나가 각각 하나의 토큰이 된다. 토큰은 보통 세 가지 레벨로 구분한다.

Primitive Token (기본 토큰)

가장 원시적인 값이다. 이름 자체가 값을 설명한다.

gray-100: #f3f4f6 gray-200: #e5e7eb gray-700: #374151 blue-500: #3b82f6

이 단계에서는 "이 색이 어디에 쓰이는지"에 대한 정보가 없다. 단순히 색상 팔레트를 정의할 뿐이다.

Semantic Token (의미 토큰)

Primitive 토큰을 참조하면서 용도를 명시한다.

color-text-primary: gray-700 color-text-secondary: gray-500 color-background-hover: gray-100 color-error: red-500 color-success: green-500

이제 "이 색이 어디에 쓰이는지"가 이름에 드러난다. 코드에서 color-text-primary를 사용하면, 나중에 이 색이 바뀌어도 의미가 유지된다.

Component Token (컴포넌트 토큰)

특정 컴포넌트에 바인딩된 토큰이다.

button-bg-primary: color-primary button-bg-hover: color-primary-dark input-border: color-border input-border-focus: color-primary

이 레벨까지 가면 컴포넌트와 토큰이 1:1로 매핑된다. 대규모 디자인 시스템에서는 이 단계까지 정의하지만, 중소규모에서는 Semantic 레벨까지만 해도 충분하다.

왜 Storybook에서 문서화하는가

디자인 토큰을 코드로 정의하는 것만으로는 부족하다. CSS 변수나 Tailwind config에 정의된 값을 보려면 코드를 직접 열어봐야 하는데, 디자이너는 코드를 안 보고, 신규 개발자는 어떤 토큰이 있는지 모른다.

Storybook에 토큰을 문서화하면 세 가지 이점이 있다.

첫째, 시각적 확인이 가능하다. #4caf50이라는 hex 값만 보고는 어떤 색인지 바로 떠올리기 어렵다. 실제 색상 스와치를 렌더링하면 직관적으로 파악할 수 있다.

둘째, Single Source of Truth가 된다. Figma의 색상 가이드와 실제 코드의 색상이 다른 경우가 생각보다 많다. Storybook에서 코드 기반으로 렌더링하면 "실제로 적용되는 값"을 보여주기 때문에 불일치가 줄어든다.

셋째, 변경 감지가 가능하다. Chromatic 같은 비주얼 리그레션 도구와 결합하면, 토큰 값이 변경됐을 때 시각적 차이를 자동으로 감지할 수 있다.

색상 토큰 Story 만들기

색상 토큰을 Storybook Story로 만드는 기본 패턴을 살펴보자. 핵심은 색상 값을 시각적으로 렌더링하는 컴포넌트를 만들고, 이를 Story로 등록하는 것이다.

색상 카드 컴포넌트

먼저 개별 색상을 표시하는 카드 컴포넌트를 만든다.

tsx
gray-100: #f3f4f6
gray-200: #e5e7eb
gray-700: #374151
blue-500: #3b82f6

상단에 실제 색상을 배경으로 보여주고, 하단에 이름과 hex 값을 표시한다. description으로 용도 설명을 추가할 수도 있다.

Scale Colors Story

기본 색상 팔레트를 그리드로 나열한다.

tsx
color-text-primary: gray-700
color-text-secondary: gray-500
color-background-hover: gray-100
color-error: red-500
color-success: green-500

meta.titleDesign Tokens/Colors로 설정하면 Storybook 사이드바에서 "Design Tokens" 폴더 하위에 "Colors"가 나타난다. 이렇게 토큰 카테고리별로 폴더를 구성할 수 있다.

Semantic Colors Story

시맨틱 색상은 Success, Error, Warning, Info 같은 카테고리로 묶어서 표시한다.

tsx
button-bg-primary: color-primary
button-bg-hover: color-primary-dark
input-border: color-border
input-border-focus: color-primary

각 시맨틱 카테고리를 Light → Base → Deep → Deeper 순서로 나열하면 색상의 명도 단계를 한눈에 볼 수 있다. 이 패턴은 디자이너와 소통할 때 특히 유용하다. "Error Deep 색상 쓰세요"라고 하면 양쪽이 같은 색을 가리키게 된다.

다크 모드 비교 Story

라이트/다크 테마를 지원하는 시스템이라면 두 모드의 색상을 나란히 비교하는 Story를 추가하면 유용하다.

tsx
const ColorCard = ({
  name,
  value,
  description,
}: {
  name: string;
  value: string;
  description?: string;
}) => (
  <div className="overflow-hidden rounded-lg border">
    <div
      className="h-16 w-full border-b"
      style={{ backgroundColor: value }}
    />
    <div className="p-3">
      <h4 className="text-sm font-semibold">{name}</h4>
      <p className="font-mono text-xs text-gray-500">{value}</p>
      {description && (
        <p className="mt-1 text-xs text-gray-500">{description}</p>
      )}
    </div>
  </div>
);

실제 배경색 위에 텍스트를 렌더링하면 명도 대비가 충분한지, 가독성이 괜찮은지를 직접 확인할 수 있다. WCAG 접근성 가이드라인에서는 일반 텍스트의 명도 대비가 4.5:1 이상이어야 한다고 권고하는데, 이런 Story를 통해 시각적으로 검증할 수 있다.

타이포그래피 토큰 Story 만들기

타이포그래피도 색상과 비슷한 패턴으로 문서화한다. 다만 타이포그래피는 보여줘야 하는 속성이 더 많다. 폰트 크기, 굵기, 행간을 모두 표시해야 한다.

타이포그래피 카드 컴포넌트

tsx
import type { Meta, StoryObj } from "@storybook/react";

const meta = {
  title: "Design Tokens/Colors",
  parameters: {
    layout: "padded",
  },
  tags: ["autodocs"],
} satisfies Meta;

export default meta;
type Story = StoryObj<typeof meta>;

export const ScaleColors: Story = {
  render: () => (
    <div className="space-y-6">
      <h2 className="mb-4 text-2xl font-bold">Scale Colors</h2>
      <p className="mb-6 text-gray-500">
        Primary colors used for backgrounds, texts, and UI elements
      </p>
      <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
        <ColorCard
          name="White"
          value="#ffffff"
          description="Primary white background"
        />
        <ColorCard
          name="Background"
          value="#f9fafb"
          description="Main background"
        />
        <ColorCard
          name="Hover"
          value="#f3f4f6"
          description="Hover states"
        />
        <ColorCard
          name="Clicked"
          value="#e5e7eb"
          description="Active/pressed"
        />
        <ColorCard
          name="Primary Text"
          value="#030712"
          description="Main text"
        />
        <ColorCard
          name="Secondary Text"
          value="#4a5565"
          description="Secondary text"
        />
        <ColorCard
          name="Tertiary Text"
          value="#6a7282"
          description="Tertiary text"
        />
        <ColorCard
          name="Disabled Text"
          value="#99a1af"
          description="Disabled"
        />
      </div>
    </div>
  ),
};

카드 상단에 토큰 이름과 수치 스펙을 표시하고, 하단에 해당 스타일이 적용된 샘플 텍스트를 렌더링한다. 수치 스펙을 font-mono로 표시하면 개발자가 바로 코드에 적용할 수 있다.

PC / Mobile 분리

반응형 디자인 시스템에서는 PC와 모바일의 타이포그래피 스케일이 다른 경우가 많다. 같은 "Title" 토큰이라도 PC에서는 24px, 모바일에서는 20px일 수 있다. 이를 별도 Story로 분리하면 각 브레이크포인트에서의 스케일을 명확히 확인할 수 있다.

tsx
export const SemanticColors: Story = {
  render: () => (
    <div className="space-y-6">
      <h2 className="mb-4 text-2xl font-bold">Semantic Colors</h2>

      <div className="space-y-8">
        <div>
          <h3 className="mb-3 text-lg font-semibold">Success</h3>
          <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
            <ColorCard name="Success Light" value="#d6fae3" />
            <ColorCard name="Success Base" value="#4caf50" />
            <ColorCard name="Success Deep" value="#2e7d32" />
            <ColorCard name="Success Deeper" value="#1b5e20" />
          </div>
        </div>

        <div>
          <h3 className="mb-3 text-lg font-semibold">Error</h3>
          <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
            <ColorCard name="Error Light" value="#feebea" />
            <ColorCard name="Error Base" value="#e57373" />
            <ColorCard name="Error Deep" value="#d32f2f" />
            <ColorCard name="Error Deeper" value="#c62828" />
          </div>
        </div>
      </div>
    </div>
  ),
};

PC와 모바일을 비교하면 대부분 2단계씩 내려가는 패턴을 볼 수 있다. H1이 40px → 32px, Display가 32px → 24px, Title이 24px → 20px. 이런 일관된 스케일링 규칙이 있으면 새로운 토큰을 추가할 때도 기준이 생긴다.

폰트 굵기 Story

사용 가능한 폰트 굵기를 나열하는 Story도 만들어두면 좋다. 특히 가변 폰트(Variable Font)를 사용할 때는 어떤 weight 값이 지원되는지 명시하는 게 중요하다.

tsx
export const DarkModeComparison: Story = {
  render: () => (
    <div className="grid gap-6 md:grid-cols-2">
      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Light Theme</h3>
        <div className="rounded-lg border bg-[#f9fafb] p-6">
          <div className="space-y-3">
            <div className="rounded border bg-white p-3 text-[#030712]">
              Primary Text (#030712)
            </div>
            <div className="rounded bg-[#f3f4f6] p-3 text-[#4a5565]">
              Secondary Text (#4a5565)
            </div>
          </div>
        </div>
      </div>

      <div className="space-y-4">
        <h3 className="text-lg font-semibold">Dark Theme</h3>
        <div className="rounded-lg border bg-[#09090b] p-6">
          <div className="space-y-3">
            <div className="rounded border border-gray-700 bg-[#151515] p-3 text-[#fafafa]">
              Primary Text (#fafafa)
            </div>
            <div className="rounded bg-[#18181b] p-3 text-[#9f9fa9]">
              Secondary Text (#9f9fa9)
            </div>
          </div>
        </div>
      </div>
    </div>
  ),
};

디자이너가 "여기 좀 두꺼운 폰트 써주세요"라고 할 때 SemiBold(600)을 쓸지 Bold(700)를 쓸지 애매한 경우가 많다. 이 Story를 보면서 "이 정도?"라고 합의할 수 있다.

Story 구조 설계

토큰 문서화 Story는 meta.title의 슬래시(/)를 활용해서 Storybook 사이드바에 계층 구조를 만드는 게 핵심이다.

stories/ design-tokens/ colors.stories.tsx → title: "Design Tokens/Colors" typography.stories.tsx → title: "Design Tokens/Typography" spacing.stories.tsx → title: "Design Tokens/Spacing" shadows.stories.tsx → title: "Design Tokens/Shadows"

이렇게 하면 Storybook 사이드바에서 "Design Tokens" 폴더 아래에 Colors, Typography, Spacing, Shadows가 나타난다. 컴포넌트 Story와 분리되어 있어서 디자인 시스템의 기초 요소를 독립적으로 탐색할 수 있다.

autodocs 태그

tags: ['autodocs']를 추가하면 Storybook이 자동으로 문서 페이지를 생성한다. 여러 개의 Story가 하나의 문서 페이지에 모여서 "Colors 개요" 같은 역할을 한다.

tsx
const TypographyCard = ({
  name,
  size,
  weight,
  lineHeight,
  children = "The quick brown fox jumps over the lazy dog",
}: {
  name: string;
  size: string;
  weight: string;
  lineHeight: string;
  children?: string;
}) => (
  <div className="space-y-3 rounded-lg border p-6">
    <div className="flex items-center justify-between">
      <h3 className="text-sm font-semibold">{name}</h3>
      <div className="space-x-2 font-mono text-xs text-gray-500">
        <span>Size: {size}</span>
        <span>Weight: {weight}</span>
        <span>Line: {lineHeight}</span>
      </div>
    </div>
    <div
      style={{
        fontSize: size,
        fontWeight: weight,
        lineHeight: lineHeight,
      }}
    >
      {children}
    </div>
  </div>
);

layout: "padded"는 Story 주변에 패딩을 추가한다. 토큰 문서는 보통 여백이 있는 게 보기 좋기 때문에 이 설정을 권장한다.

CSS 변수와 연동하기

하드코딩된 hex 값 대신 CSS 변수(Custom Properties)를 사용하면 토큰의 단일 소스를 유지하면서 Story에서도 같은 값을 참조할 수 있다.

css
export const PCTypography: Story = {
  render: () => (
    <div className="space-y-4">
      <h2 className="mb-4 text-3xl font-bold">PC Typography Scale</h2>
      <TypographyCard
        name="H1 Landing"
        size="40px"
        weight="700"
        lineHeight="1.45"
      >
        Main Landing Headline
      </TypographyCard>
      <TypographyCard
        name="Display"
        size="32px"
        weight="700"
        lineHeight="1.45"
      >
        Display Text
      </TypographyCard>
      <TypographyCard
        name="Title"
        size="24px"
        weight="600"
        lineHeight="1.45"
      >
        Section Title
      </TypographyCard>
      <TypographyCard
        name="Body"
        size="16px"
        weight="400"
        lineHeight="1.45"
      >
        Body text for paragraphs and general content
      </TypographyCard>
      <TypographyCard
        name="Caption"
        size="12px"
        weight="400"
        lineHeight="1.45"
      >
        Caption text for labels
      </TypographyCard>
    </div>
  ),
};

export const MobileTypography: Story = {
  render: () => (
    <div className="space-y-4">
      <h2 className="mb-4 text-3xl font-bold">Mobile Typography Scale</h2>
      <TypographyCard
        name="H1 Landing"
        size="32px"
        weight="700"
        lineHeight="1.45"
      >
        Mobile Landing Headline
      </TypographyCard>
      <TypographyCard
        name="Display"
        size="24px"
        weight="700"
        lineHeight="1.45"
      >
        Mobile Display Text
      </TypographyCard>
      <TypographyCard
        name="Title"
        size="20px"
        weight="600"
        lineHeight="1.45"
      >
        Mobile Section Title
      </TypographyCard>
      <TypographyCard
        name="Body"
        size="14px"
        weight="400"
        lineHeight="1.45"
      >
        Mobile body text for general content
      </TypographyCard>
      <TypographyCard
        name="Caption"
        size="12px"
        weight="400"
        lineHeight="1.45"
      >
        Mobile caption text
      </TypographyCard>
    </div>
  ),
};

Story에서 CSS 변수를 직접 참조하면, CSS를 수정했을 때 Story도 자동으로 업데이트된다.

tsx
export const FontWeights: Story = {
  render: () => (
    <div className="space-y-4">
      <h2 className="mb-4 text-3xl font-bold">Font Weights</h2>
      {[
        { label: "Regular", weight: 400 },
        { label: "Medium", weight: 500 },
        { label: "SemiBold", weight: 600 },
        { label: "Bold", weight: 700 },
      ].map(({ label, weight }) => (
        <div key={weight} className="text-2xl" style={{ fontWeight: weight }}>
          {label} ({weight}) - The quick brown fox jumps
        </div>
      ))}
    </div>
  ),
};

다만 이 방식은 hex 값을 텍스트로 표시하기 어렵다는 단점이 있다. getComputedStyle로 런타임에 값을 읽어오는 방법도 있지만, 정적 문서화 목적이라면 값을 별도 상수 객체로 정의하고 CSS와 Story에서 동시에 참조하는 패턴이 더 실용적이다.

tsx
stories/
  design-tokens/
    colors.stories.tsx        → title: "Design Tokens/Colors"
    typography.stories.tsx    → title: "Design Tokens/Typography"
    spacing.stories.tsx       → title: "Design Tokens/Spacing"
    shadows.stories.tsx       → title: "Design Tokens/Shadows"
tsx
const meta = {
  title: "Design Tokens/Colors",
  parameters: {
    layout: "padded",
  },
  tags: ["autodocs"],
} satisfies Meta;

토큰 객체를 순회해서 카드를 렌더링하면 새 토큰을 추가할 때 tokens.ts에만 추가하면 Story에 자동으로 반영된다. 수동으로 Story를 수정할 필요가 없다.

Tailwind CSS와의 관계

Tailwind CSS를 사용하는 프로젝트에서는 tailwind.config.tstheme.extend에 정의된 값이 사실상 Design Token 역할을 한다.

ts
/* globals.css */
:root {
  --color-primary: #2196f3;
  --color-error: #d32f2f;
  --color-success: #4caf50;
  --color-text-primary: #030712;
  --color-text-secondary: #4a5565;
}

.dark {
  --color-text-primary: #fafafa;
  --color-text-secondary: #9f9fa9;
}

Tailwind config에 정의된 값을 Story에서 직접 참조하면 중복 정의를 피할 수 있다. resolveConfig 유틸리티를 사용하면 런타임에 Tailwind 설정값을 읽어올 수 있다.

tsx
<div
  className="h-16 w-full"
  style={{ backgroundColor: "var(--color-primary)" }}
/>

하지만 이 방법은 번들 크기를 키울 수 있으므로, 빌드 타임에 토큰 목록을 추출하는 스크립트를 별도로 만드는 것도 방법이다.

Chromatic과의 시너지

Design Token Story를 Chromatic에 배포하면 토큰 변경에 대한 비주얼 리그레션 테스트를 자동화할 수 있다. 누군가 색상 값을 실수로 변경하거나, 다크 모드 색상을 빠뜨리면 Chromatic이 시각적 차이를 감지해서 PR에 리뷰를 요청한다.

이 방식의 장점은 "토큰 변경의 영향 범위"를 시각적으로 보여준다는 것이다. Primary Blue를 변경하면 해당 색상이 사용된 모든 컴포넌트 Story에서 차이가 발생하기 때문에, 의도하지 않은 영향을 사전에 발견할 수 있다.

실전 팁

토큰 네이밍 규칙

토큰 이름은 용도 → 속성 → 변형 순서로 짓는 게 일반적이다.

color-text-primary color-text-secondary color-background-hover color-border-focus

이 규칙을 따르면 자동완성에서 color-text-를 치면 관련 토큰이 모두 나타나서 검색이 쉬워진다.

토큰 개수 관리

토큰이 너무 많으면 오히려 관리가 힘들어진다. "비슷한 토큰이 3개 이상이면 통합을 고려하라"는 경험 규칙이 있다. 예를 들어 Gray 계열 토큰이 10개가 넘어가면 정말 10개가 다 필요한지 의심해봐야 한다.

Story 업데이트 자동화

토큰을 JSON이나 TypeScript 객체로 관리하고, Story에서 이를 순회하면 새 토큰 추가 시 Story를 수동으로 업데이트하지 않아도 된다. 앞서 tokens.ts 예시처럼 Object.entries()로 순회하는 패턴이 가장 실용적이다.

관련 개념

  • Style Dictionary: Salesforce가 만든 Design Token 빌드 도구. JSON으로 토큰을 정의하면 CSS, iOS, Android 등 플랫폼별 코드로 변환해준다.
  • Figma Tokens Plugin: Figma에서 토큰을 정의하고 JSON으로 내보내는 플러그인. 디자이너가 직접 토큰을 관리할 수 있다.
  • Design Token Community Group (W3C): Design Token의 표준 포맷을 정의하려는 W3C 커뮤니티 그룹. .tokens.json 포맷을 제안하고 있다.

정리

  • Design Token은 Primitive → Semantic → Component 3단계로 구성하며, 중소규모에서는 Semantic 레벨까지만으로도 충분하다.
  • Storybook Story로 토큰을 시각화하면 코드 기반 Single Source of Truth가 되어 Figma와의 불일치를 줄일 수 있다.
  • tokens.ts 객체를 순회하는 패턴으로 Story 자동 반영을 구현하고, Chromatic 연동으로 토큰 변경의 비주얼 리그레션을 감지할 수 있다.

관련 문서