외부 Claude 디자인 스킬을 프로덕션 코드에 격리하는 빌드 파이프라인
TuBrief 편집팀
2026년 9월 11일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
디자이너 없이 혼자 풀스택 서비스를 만드는 개발자에게 외부 디자인 스킬 문서는 매력적입니다. 트위터나 깃허브에서 주운 5,000줄짜리 디자인 시스템 프롬프트를 복사해 Claude 시스템 지침에 넣으면 화면이 깔끔해질 것 같습니다.
실제로는 정반대 결과가 나옵니다. 첫 메시지를 보내자마자 토큰 한도 경고가 뜨고, API 응답 대기 시간은 하염없이 늘어납니다. 모델이 뱉어낸 코드는 컴포넌트에 정체불명의 인라인 스타일을 박아 넣거나 프로젝트에 설치된 적도 없는 Framer Motion 모듈을 불러옵니다. 결국 깨진 UI를 새벽까지 수동으로 고치다가 기존 shadcn/ui 기본 템플릿으로 되돌아갑니다.
문제는 모델의 미적 감각이 아닙니다. 비구조화된 줄글 마크다운을 시스템 프롬프트에 그대로 쏟아붓는 방식 자체가 원인입니다. 자연어로 된 스타일 서술을 기계가 읽을 수 있는 토큰 규격으로 바꾸고, 모델이 생성한 코드가 프로덕션 파일에 닿기 전에 격리 테스트하는 파이프라인을 구축해야 합니다.
디자인 시스템 마크다운을 프롬프트에 통째로 붙여넣으면 토큰 낭비가 심각해집니다. "사용자에게 신뢰감을 주는 깊은 네이비" 같은 미학적 수식어는 레이아웃 생성에 아무런 쓸모가 없습니다. 이런 문장은 언어 모델 내부에서 정작 지켜야 할 TypeScript 인터페이스나 검증 로직으로 가야 할 연산 자원을 갉아먹습니다.
자연어 서술을 쳐내고 W3C Design Tokens Community Group(DTCG) 규격의 JSON 객체로 시스템 프롬프트를 재구성합니다. 색상, 간격, 모서리 곡률만 남깁니다.
{
"color": {
"background": {
"surface": { "$value": "oklch(0.98 0.005 250)", "$type": "color" },
"canvas": { "$value": "oklch(1.0 0 0)", "$type": "color" }
},
"action": {
"primary": { "$value": "oklch(0.55 0.20 250)", "$type": "color" },
"primary-hover": { "$value": "oklch(0.48 0.22 250)", "$type": "color" }
}
},
"spacing": {
"compact": { "$value": "0.5rem", "$type": "dimension" },
"comfortable": { "$value": "1rem", "$type": "dimension" }
},
"radius": {
"md": { "$value": "0.5rem", "$type": "dimension" }
}
}
정형화한 토큰 명세를 Anthropic 프롬프트 캐싱(Prompt Caching)과 결합합니다. 기본 토큰과 시스템 역할을 고정 블록으로 두고 cache_control: { type: "ephemeral" }을 겁니다.
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
export async function requestComponentGeneration(componentBrief: string) {
return await anthropic.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 4096,
system: [
{
type: "text",
text: `Next.js 및 Tailwind CSS 컴포넌트 생성기.
반드시 아래 DTCG 토큰 명세에 정의된 클래스만 사용하고 인라인 스타일(style 속성)은 출력하지 마십시오.
[DTCG_TOKENS_JSON_DATA]`,
cache_control: { type: "ephemeral" },
},
],
messages: [
{
role: "user",
content: componentBrief,
},
],
});
}
줄글 문서를 쳐내면 프롬프트 크기가 10,000토큰 안팎에서 1,200토큰 수준으로 줄어듭니다. Anthropic 문서 기준 프롬프트 캐시 읽기는 기본 입력 토큰 요금의 10%만 청구되므로 반복 호출 시 API 비용이 70% 넘게 줄어듭니다.
외부 스킬을 주입하면 Claude는 종종 style={{ marginTop: '13px' }} 같은 임의 값을 몰래 심어놓습니다. 대괄호 임의 클래스(w-[342px])도 마구잡이로 찍어냅니다. 이런 코드는 전역 스타일 시스템을 조금씩 무너뜨립니다.
eslint.config.mjs의 Flat Config에 eslint-plugin-react와 eslint-plugin-tailwindcss를 걸어 빌드 단계에서 걸러냅니다.
import reactPlugin from "eslint-plugin-react";
import tailwindPlugin from "eslint-plugin-tailwindcss";
export default [
{
files: ["**/*.{ts,tsx}"],
plugins: {
react: reactPlugin,
tailwindcss: tailwindPlugin,
},
settings: {
tailwindcss: {
callees: ["cn", "cva"],
config: "./tailwind.config.ts",
},
},
rules: {
"react/forbid-dom-props": [
"error",
{
forbid: [
{
propName: "style",
message: "인라인 스타일은 허용되지 않습니다. 지정된 Tailwind 유틸리티를 사용하세요.",
},
],
},
],
"tailwindcss/no-arbitrary-value": "error",
"tailwindcss/no-custom-classname": [
"error",
{
whitelist: ["animate-.*"],
},
],
},
},
];
추출한 토큰은 tailwind.config.ts의 theme.extend에 매핑합니다. 기본 팔레트를 덮어쓰면 shadcn/ui 내부 프리미티브가 참조하는 색상이 망가집니다.
import type { Config } from "tailwindcss";
import themeTokens from "./build/tailwind/theme.json";
const config: Config = {
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
surface: themeTokens.color.background.surface,
canvas: themeTokens.color.background.canvas,
action: {
primary: themeTokens.color.action.primary,
"primary-hover": themeTokens.color.action["primary-hover"],
},
},
borderRadius: {
token: themeTokens.radius.md,
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
인터랙션은 Class Variance Authority(CVA) 변형(Variant)으로 격리합니다. Radix UI 프리미티브를 직접 건드리면 접근성(ARIA) 트리 속성이 지워집니다.
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-token text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-action-primary disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-action-primary text-white hover:bg-action-hover active:scale-[0.98]",
secondary: "bg-surface text-foreground hover:bg-surface/80",
ghost: "hover:bg-surface text-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-8 text-base",
},
motion: {
subtle: "transition-all duration-150 ease-out",
expressive: "transition-all duration-300 cubic-bezier(0.16, 1, 0.3, 1)",
},
},
defaultVariants: {
variant: "default",
size: "default",
motion: "subtle",
},
}
);
export interface ActionButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
({ className, variant, size, motion, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, motion, className }))}
ref={ref}
{...props}
/>
);
}
);
ActionButton.displayName = "ActionButton";
Claude는 화려한 모션을 요구받으면 습관적으로 Framer Motion을 불러옵니다. 번들포비아(Bundlephobia) 측정 기준 Framer Motion은 Gzip 압축 후에도 약 60KB를 차지합니다. 버튼 몇 개 움직이자고 60KB를 얹는 순간 초기 로딩 지표인 LCP가 흔들립니다.
시스템 프롬프트에 서드파티 라이브러리 임포트를 금지하고 브라우저 합성기 스레드(Compositor Thread) 전용 속성만 쓰도록 못박아 둡니다.
// 메인 스레드에서 레이아웃 리플로우를 일으키는 비권장 형태
// <div className="transition-all duration-200 hover:w-64 hover:top-[-4px]" />
// GPU 합성기 스레드에서 처리하는 권장 형태
<div className="transform-gpu transition-transform duration-200 ease-out hover:-translate-y-1 hover:scale-105" />
top, left, width, height 같은 기하 속성은 프레임마다 레이아웃 트리를 다시 계산합니다. 반면 transform-gpu 유틸리티와 opacity는 메인 스레드를 거치지 않고 GPU 독립 레이어에서 처리되므로 60 FPS를 부드럽게 유지합니다.
배경 흐림 효과(backdrop-filter: blur())도 스크롤 영역에서는 뺍니다. 저사양 모바일 브라우저에서 스크롤 뷰포트 내 블러 필터는 매 프레임 텍스처를 다시 계산해 화면 주사율을 20 FPS 밑으로 떨어뜨립니다. 블러 대신 단색 반투명 알파 채널(bg-background/80과 1px 보더)을 쓰면 비디오 메모리 점유 문제를 막을 수 있습니다.
Claude가 생성한 코드를 Next.js app/ 디렉터리에 바로 밀어 넣으면 레이아웃이 깨지고 하이드레이션 오류가 납니다. Storybook 독립 환경에서 먼저 렌더링하고 테스트를 통과한 컴포넌트만 프로덕션에 병합합니다.
.storybook/test-runner.js에 axe-playwright를 붙여 WCAG 2.1 AA 기준의 접근성을 확인합니다.
const { injectAxe, checkA11y } = require("axe-playwright");
module.exports = {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page) {
await page.waitForSelector("#storybook-root", { state: "attached" });
await checkA11y(page, "#storybook-root", {
detailedReport: true,
axeOptions: {
runOnly: {
type: "tag",
values: ["wcag2a", "wcag2aa"],
},
},
});
},
};
테스트가 깨지면 에러 로그를 그대로 Claude에 다시 던집니다.
[AI 피드백 루프: 컴포넌트 정밀 교정 프롬프트]
생성된 코드가 axe-core 접근성 테스트에 실패했습니다.
로그를 분석해 기존 구조를 유지한 채 결함만 수정한 코드를 출력하십시오.
[실패 상세]
위반 항목: color-contrast
실패 노드: <button class="bg-surface text-gray-400">
사유: 명도 대비가 2.8:1로 WCAG AA 기준인 4.5:1에 미달함.
해결 지침: 텍스트 유틸리티를 text-foreground 또는 text-action-primary-hover로 교체하십시오.
Playwright로 시각적 회귀(Visual Regression) 스냅샷을 찍고, 모든 검사를 통과했을 때만 브랜치를 따는 셸 스크립트를 구성합니다.
import { test, expect } from "@playwright/test";
import storybookManifest from "../../storybook-static/index.json";
const stories = Object.values(storybookManifest.entries).filter(
(entry) => entry.type === "story"
);
for (const story of stories) {
test(`스냅샷 검증: ${story.title} - ${story.name}`, async ({ page }) => {
await page.goto(`/iframe.html?id=${story.id}&viewMode=story`);
await page.waitForSelector("#storybook-root");
await page.waitForLoadState("networkidle");
await expect(page).toHaveScreenshot(`${story.id}.png`, {
animations: "disabled",
maxDiffPixelRatio: 0.01,
threshold: 0.2,
});
});
}
#!/usr/bin/env bash
set -e
COMPONENT_NAME=$1
if [ -z "$COMPONENT_NAME" ]; then
echo "오류: 검증할 컴포넌트 이름을 입력하세요."
exit 1
fi
echo "1. 린트 가드레일 검사"
pnpm eslint "src/components/ui/${COMPONENT_NAME}.tsx" --max-warnings=0
echo "2. Storybook 정적 빌드"
pnpm build-storybook --quiet
echo "3. Storybook 접근성 CLI 테스트"
pnpm test-storybook
echo "4. Playwright 시각 회귀 스냅샷 비교"
pnpm playwright test tests/visual/component-regression.spec.ts
echo "5. 통과: 브랜치 생성 및 PR 발송"
git checkout -b "feature/ui-${COMPONENT_NAME}"
git add "src/components/ui/${COMPONENT_NAME}.tsx"
git commit -m "feat(ui): ${COMPONENT_NAME} 격리 검증 완료"
git push origin "feature/ui-${COMPONENT_NAME}"
gh pr create --title "feat(ui): ${COMPONENT_NAME} 추가" --body "Storybook A11y 및 Playwright 회귀 검사를 통과한 컴포넌트입니다."
화면이 어그러질 때마다 브라우저 개발자 도구를 켜서 여백을 몇 픽셀씩 만지작거릴 필요가 없습니다. 검증 규칙을 터미널 스크립트에 넘겨두면 혼자서도 안전하게 UI를 찍어낼 수 있습니다.