Advanced (업데이트: 2026. 6. 1.)

Claude Code로 디자인 시스템 구축하기: Design Tokens, Storybook, CI 실전 가이드

Claude Code로 Design Tokens, React/TypeScript, Storybook, 접근성, visual 테스트와 CI를 연결하는 실전 가이드입니다.

Claude Code로 디자인 시스템 구축하기: Design Tokens, Storybook, CI 실전 가이드

디자인 시스템은 컴포넌트 모음이 아니라 운영 체계다

디자인 시스템을 만들 때 버튼, 카드, 입력창부터 만들고 싶어집니다. 하지만 실제로 오래 버티는 시스템은 색상, 간격, 타이포그래피, 상태, 리뷰, 테스트를 어떻게 계속 변경할지까지 포함합니다.

Claude Code는 이 작업과 잘 맞습니다. 기존 코드를 읽고, 여러 파일을 수정하고, Storybook과 테스트를 실행하고, 변경 내용을 정리할 수 있기 때문입니다. 다만 브랜드 판단, Figma의 의도, 최종 접근성 평가는 사람이 책임져야 합니다.

이 글에서는 Design Tokens, React/TypeScript 컴포넌트, Storybook, 접근성, CI의 visual/a11y 검사, Figma 연동의 현실적인 경계를 하나의 흐름으로 정리합니다. 함께 보면 좋은 글은 Design Tokens 관리, Storybook 개발, 접근성 대응입니다.

목표 구조

코드 쪽의 기준은tokens.json으로 둡니다. Figma는 중요한 설계 입력이지만, 운영 코드는 CI에서 검증 가능한 계약이 필요합니다.

flowchart LR
  Figma["Figma Variables"]
  Tokens["tokens.json"]
  Build["token build script"]
  CSS["CSS variables"]
  TS["TypeScript token map"]
  Components["React components"]
  Storybook["Storybook stories"]
  CI["Visual and a11y CI"]

  Figma -->|review input| Tokens
  Tokens --> Build
  Build --> CSS
  Build --> TS
  CSS --> Components
  TS --> Components
  Components --> Storybook
  Storybook --> CI

Design Tokens는 색상, 여백, radius, 폰트, 상태 같은 디자인 결정을 이름이 있는 데이터로 저장하는 방식입니다. 컴포넌트는#2563eb같은 원시값보다action.background.primary처럼 의미가 있는 토큰을 참조해야 변경에 강합니다.

최신 기준은 Design Tokens Community Group, Claude Code 문서, Claude Code security, Storybook accessibility testing, Storybook visual tests, Playwright accessibility testing, Figma REST API를 확인하세요.

Claude Code에 맡길 작업 단위

“디자인 시스템을 만들어줘”는 너무 큽니다. “Button만 기존 API를 유지하며 마이그레이션하고, Storybook 상태와 a11y 테스트를 추가해줘”처럼 범위를 좁히면 리뷰가 쉬워집니다.

영역Claude Code에 맡기기 좋은 일사람이 결정할 일
TokensCSS에서 반복 색상과 여백 추출브랜드 의미와 이름
Components타입이 있는ButtonInput 구현공개 API와 제품 의미
Storybookvariant와 상태 story 추가실제 업무에 필요한 상태
Accessibilitylabel, focus, axe 오류 탐지최종 UX와 스크린리더 판단
CIvisual/a11y 검사 연결실패 기준과 예외 승인

작업 전에는 다음 규칙을 프로젝트 지침에 넣는 것이 좋습니다.

Design system task rules:
- Edit only src/components, src/styles, .storybook, tests, scripts, and tokens.json.
- Do not change brand colors without listing old and new token names.
- Every new component needs TypeScript props, keyboard behavior, Storybook stories, and a11y notes.
- Run npm run tokens:build, npm run test:storybook, npm run test:a11y, and npm run test:visual before reporting done.
- If focus behavior changes, include manual review steps.

권한과 보안도 구체화해야 합니다. Figma token, npm token, CI secret, 고객 화면 캡처를 프롬프트에 그대로 넣지 마세요. 명령 실행 전에는 승인 범위를 확인하고, 대량 snapshot 갱신은 사람이 승인하도록 합니다.

최소 설치

React와 TypeScript 프로젝트를 기준으로 합니다.

npm install class-variance-authority clsx tailwind-merge
npm install -D @storybook/react-vite @storybook/addon-a11y @storybook/test-runner @playwright/test @axe-core/playwright concurrently http-server wait-on
npx storybook init
npx playwright install chromium

package.json에는 로컬과 CI에서 같은 명령을 쓰도록 스크립트를 둡니다.

{
  "scripts": {
    "tokens:build": "node scripts/build-tokens.mjs",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test:storybook": "test-storybook --url http://127.0.0.1:6006",
    "test:a11y": "playwright test tests/a11y.spec.ts",
    "test:visual": "playwright test tests/button.visual.spec.ts"
  }
}

Design Tokens를 계약으로 만들기

primitive, semantic, component 세 층으로 나누면 운영이 쉬워집니다.

{
  "primitive": {
    "color": {
      "blue": {
        "50": { "$type": "color", "$value": "#eff6ff" },
        "600": { "$type": "color", "$value": "#2563eb" },
        "700": { "$type": "color", "$value": "#1d4ed8" }
      },
      "gray": {
        "50": { "$type": "color", "$value": "#f9fafb" },
        "200": { "$type": "color", "$value": "#e5e7eb" },
        "900": { "$type": "color", "$value": "#111827" }
      },
      "red": {
        "600": { "$type": "color", "$value": "#dc2626" },
        "700": { "$type": "color", "$value": "#b91c1c" }
      },
      "white": { "$type": "color", "$value": "#ffffff" }
    },
    "space": {
      "2": { "$type": "dimension", "$value": "0.5rem" },
      "3": { "$type": "dimension", "$value": "0.75rem" },
      "4": { "$type": "dimension", "$value": "1rem" },
      "6": { "$type": "dimension", "$value": "1.5rem" }
    },
    "radius": {
      "md": { "$type": "dimension", "$value": "0.375rem" },
      "lg": { "$type": "dimension", "$value": "0.5rem" }
    }
  },
  "semantic": {
    "color": {
      "surface": { "$type": "color", "$value": "{primitive.color.white}" },
      "text": { "$type": "color", "$value": "{primitive.color.gray.900}" },
      "border": { "$type": "color", "$value": "{primitive.color.gray.200}" },
      "focus": { "$type": "color", "$value": "{primitive.color.blue.600}" }
    }
  },
  "component": {
    "button": {
      "primary": {
        "background": { "$type": "color", "$value": "{primitive.color.blue.600}" },
        "backgroundHover": { "$type": "color", "$value": "{primitive.color.blue.700}" },
        "text": { "$type": "color", "$value": "{primitive.color.white}" }
      },
      "danger": {
        "background": { "$type": "color", "$value": "{primitive.color.red.600}" },
        "backgroundHover": { "$type": "color", "$value": "{primitive.color.red.700}" },
        "text": { "$type": "color", "$value": "{primitive.color.white}" }
      }
    }
  }
}

CSS 변수와 TypeScript 상수를 생성합니다.

import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

const source = JSON.parse(readFileSync("tokens.json", "utf8"));

function getToken(path) {
  const node = path.split(".").reduce((current, key) => current?.[key], source);
  if (!node || typeof node.$value === "undefined") {
    throw new Error(`Unknown token reference: ${path}`);
  }
  return node.$value;
}

function resolveValue(value) {
  if (typeof value === "string" && value.startsWith("{") && value.endsWith("}")) {
    return resolveValue(getToken(value.slice(1, -1)));
  }
  return value;
}

function walk(node, pathParts = [], result = {}) {
  if (node && typeof node === "object" && typeof node.$value !== "undefined") {
    result[pathParts.join("-")] = resolveValue(node.$value);
    return result;
  }
  for (const [key, value] of Object.entries(node)) {
    walk(value, [...pathParts, key], result);
  }
  return result;
}

const flat = walk(source);
const css = [
  ":root {",
  ...Object.entries(flat).map(([name, value]) => `  --${name}: ${value};`),
  "}",
  ""
].join("\n");

mkdirSync(dirname("src/styles/tokens.css"), { recursive: true });
mkdirSync(dirname("src/tokens.ts"), { recursive: true });
writeFileSync("src/styles/tokens.css", css);
writeFileSync("src/tokens.ts", `export const tokens = ${JSON.stringify(flat, null, 2)} as const;\n`);
console.log(`Generated ${Object.keys(flat).length} tokens.`);

React/TypeScript Button

아래 컴포넌트는 variant, size, loading, disabled, focus ring을 포함합니다.

import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

const buttonVariants = cva(
  [
    "inline-flex items-center justify-center gap-2 rounded-md font-medium",
    "transition-colors focus-visible:outline-none focus-visible:ring-2",
    "focus-visible:ring-[var(--semantic-color-focus)] focus-visible:ring-offset-2",
    "disabled:pointer-events-none disabled:opacity-50"
  ],
  {
    variants: {
      variant: {
        primary: [
          "bg-[var(--component-button-primary-background)]",
          "text-[var(--component-button-primary-text)]",
          "hover:bg-[var(--component-button-primary-backgroundHover)]"
        ],
        secondary: "border border-[var(--semantic-color-border)] bg-[var(--semantic-color-surface)] text-[var(--semantic-color-text)] hover:bg-gray-50",
        danger: [
          "bg-[var(--component-button-danger-background)]",
          "text-[var(--component-button-danger-text)]",
          "hover:bg-[var(--component-button-danger-backgroundHover)]"
        ]
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "h-10 px-4 text-sm",
        lg: "h-12 px-6 text-base"
      }
    },
    defaultVariants: { variant: "primary", size: "md" }
  }
);

export interface ButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { className, variant, size, loading = false, disabled, children, ...props },
  ref
) {
  return (
    <button
      ref={ref}
      className={cn(buttonVariants({ variant, size }), className)}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? (
        <span
          aria-hidden="true"
          className="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
        />
      ) : null}
      <span>{children}</span>
    </button>
  );
});

Storybook과 테스트

중요한 상태는 모두 Storybook에 있어야 합니다.

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

const meta = {
  title: "Design System/Button",
  component: Button,
  parameters: { layout: "centered", a11y: { test: "error" } },
  argTypes: {
    variant: { control: "select", options: ["primary", "secondary", "danger"] },
    size: { control: "select", options: ["sm", "md", "lg"] },
    loading: { control: "boolean" },
    disabled: { control: "boolean" }
  }
} satisfies Meta<typeof Button>;

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

export const Primary: Story = { args: { children: "저장", variant: "primary" } };
export const Danger: Story = { args: { children: "삭제", variant: "danger" } };
export const Loading: Story = { args: { children: "저장 중", loading: true } };

export const AllStates: Story = {
  render: () => (
    <div className="flex flex-wrap items-center gap-3">
      <Button variant="primary" size="sm">Small</Button>
      <Button variant="primary" size="md">Medium</Button>
      <Button variant="primary" size="lg">Large</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="danger">Danger</Button>
      <Button disabled>Disabled</Button>
      <Button loading>Loading</Button>
    </div>
  )
};

CI에서는 접근성과 visual snapshot을 같이 확인합니다.

import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

const storyPaths = [
  "/iframe.html?id=design-system-button--primary",
  "/iframe.html?id=design-system-button--danger",
  "/iframe.html?id=design-system-button--loading",
  "/iframe.html?id=design-system-button--all-states"
];

for (const storyPath of storyPaths) {
  test(`a11y ${storyPath}`, async ({ page }) => {
    await page.goto(`http://127.0.0.1:6006${storyPath}`);
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
      .analyze();
    expect(results.violations).toEqual([]);
  });
}
import { expect, test } from "@playwright/test";

test("button all states visual snapshot", async ({ page }) => {
  await page.goto("http://127.0.0.1:6006/iframe.html?id=design-system-button--all-states");
  await expect(page).toHaveScreenshot("button-all-states.png", {
    fullPage: true,
    animations: "disabled"
  });
});
name: design-system-quality

on:
  pull_request:
    paths:
      - "tokens.json"
      - "scripts/build-tokens.mjs"
      - "src/components/**"
      - "src/styles/**"
      - ".storybook/**"
      - "tests/**"
      - "package.json"
      - "package-lock.json"

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run tokens:build
      - run: npm run build-storybook
      - run: npx playwright install --with-deps chromium
      - run: >
          npx concurrently -k -s first -n server,tests
          "npx http-server storybook-static -p 6006"
          "npx wait-on http://127.0.0.1:6006 && npm run test:storybook && npm run test:a11y && npm run test:visual"

Figma 연동의 경계와 실전 사례

Figma Variables는 좋은 입력이지만, 자동 양방향 동기화부터 시작하면 위험합니다. 먼저 차이 보고서를 만들게 하세요.

Read figma-tokens-export.json and tokens.json.
Create a markdown report with:
1. tokens that exist in Figma but not in code
2. tokens that exist in code but not in Figma
3. value differences for matching semantic tokens
Do not edit tokens.json. Do not rename tokens. Mark risky differences around focus, danger, and text color.

대표 사례는 세 가지입니다. SaaS 관리자 화면은 버튼과 폼 상태가 많아 컴포넌트 단위 이전이 좋습니다. 화이트라벨 제품은 브랜드별 primitive token과 공통 semantic token을 나누면 안정적입니다. 레거시 CSS 정리는 반복 색상과 여백을 추출해 migration table을 만든 뒤 작은 PR로 진행합니다.

실패 사례도 분명합니다. primitive token만 늘리면 의미가 사라집니다. Storybook이 CI에 없으면 회귀를 막지 못합니다. visual test를 너무 많이 만들면 애니메이션과 폰트 때문에 노이즈가 늘어납니다. axe 통과가 곧 접근성 완료는 아닙니다. Claude Code에 너무 큰 마이그레이션을 맡기면 리뷰가 불가능해집니다.

실제로 적용할 때는tokens.json이 CSS와 TypeScript를 생성하는지, Storybook에서 모든 버튼 상태가 보이는지, CI에서 build와 a11y와 visual test가 안정적으로 도는지 먼저 확인하세요. 디자인 시스템 구축, Storybook 도입, 접근성 리뷰, Claude Code 팀 교육이 필요하면 영문 교육 및 상담 페이지에서 문의할 수 있습니다.

#Claude Code #디자인 시스템 #Design Tokens #Storybook #접근성
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.