개요
회사와 사이드 프로젝트에서 디자인 시스템을 구축하게 됐다. React 기반에 Radix UI를 사용하고, Tailwind CSS로 스타일링하고, Storybook으로 문서화하는 전형적인 구성이었다. 하지만 여기서 한 가지 독특한 시도를 했다.
컴포넌트의 스크린샷을 자동으로 캡처해서 JSDoc에 주입하는 파이프라인을 만든 것이다. ▼

'Storybook이 있는데 도대체 왜?' 라는 의문이 들 수 있을 것이다. 지금부터 왜 이런 걸 만들었고, 어떻게 구현했고, 실제로 얼마나 유용한지 정리해보려 한다.
문제 상황: 컴포넌트를 확인하려면 어디로 가야 하나?
일반적인 디자인 시스템 사용 경험
디자인 시스템을 만들어 본 사람이라면 알겠지만, 컴포넌트를 쓸 때 항상 이런 과정을 거친다. ▼
- "이 버튼 컴포넌트 어떻게 생겼더라?"
- Storybook 사이트 열기 (보통 별도 URL)
- 컴포넌트 찾기
- 여러 variant 확인
- 다시 코드로 돌아와서 작성
이게 한 두 번이면 괜찮은데, 이게 조금씩 반복되면서 쌓이면 꽤나 피곤하다. 특히 "이 variant가 어떻게 생겼더라?"할 때마다 Storybook을 왔다 갔다 해야 한다.
"아니, 여러 번 보면 기억할 만도 한데 그걸 기억 못해서 이런다고?"
일반적으로는 여러 번 보면 기억에 남고, 그 기억에 의존해서 개발을 하게 된다. 하지만 그건 프로젝트가 하나일 때의 이야기고, 다수의, 여러 도메인의 프로젝트를 다루는 경우에는 디자인 시스템도 여러 개라 일일히 다 기억할 수 없다. 기억한다고 해도 기억이 섞여서 휴먼 에러가 발생할 가능성이 높다.
더 답답한 상황
이 문제는 신입 개발자가 합류했을 때 더 크다. 신입 개발자가 합류했다고 해보자. ▼
// 신입: "이 Button 컴포넌트 어떻게 생긴 건가요?"
import { Button } from '@company/design-system';
function MyPage() {
return <Button variant="primary">클릭</Button>;
}
설명하는 입장에서는
"Storybook 여기 있어요"
"이 링크 북마크해두세요"
"variant는 여기서 확인하세요"
매번 이렇게 안내하는 것도, 듣는 것도 번거롭다. 코드를 보면서 바로 확인할 수 있으면 안 될까?
해결책: IDE에서 바로 보여주자
아이디어의 시작
VS Code에서 함수나 컴포넌트 위에 마우스를 올리면 JSDoc이 뜬다. 여기에 스크린샷을 넣으면 어떨까? ▼
/**
* Primary UI component for user interaction
*
* ## 참고사진
* 
*/
export function Button({ variant, children, ...props }: ButtonProps) {
// ...
}
이렇게 하면 개발자가 Button에 마우스를 올렸을 때 IDE에서 바로 이미지를 볼 수 있다. Storybook을 열 필요가 없다. 하지만 수동으로 스크린샷 찍어서 JSDoc에 넣는 건 단순 노가다이며 유지보수에 있어서는 악몽 그 자체다. 컴포넌트가 바뀔 때마다 일일이 스크린샷을 다시 찍어야 한다. 그래서 이를 위해서는 자동화가 필요했다.
구현 과정의 고민들
자동화를 결정하고 나서도 '스크린샷을 어디에 저장할 것인가?'라는 문제가 남았다. 이게 생각보다 까다로웠다.
첫 번째 시도: 레포에 그냥 넣기
처음에는 간단하게 생각했다. 스크린샷을 `__screenshots__` 폴더에 넣고 커밋하면 되지 않나? ▼
my-design-system/
├── src/
├── __screenshots__/
│ ├── button-default.png
│ ├── button-primary.png
│ └── ...
└── package.json
하지만 이러면 패키지 용량이 커진다. npm에 배포할 때 수백 개의 PNG 파일이 함께 올라간다. 사용자 입장에서는 컴포넌트만 필요한건데 스크린샷까지 다운받아야 한다.
"빌드할 때 스크린샷만 빼면 되지 않나?"라고 생각할 수 있다. 실제로 가능하다. ▼
// package.json
{
"files": [
"dist",
"!__screenshots__"
]
}
이렇게 하면 배포용 패키지에는 스크린샷이 포함되지 않는다. 첫 번째 문제는 해결됐다.
두 번째 시도: 충돌의 지옥
패키지 용량 문제는 해결했지만, 다른 문제가 생겼다. 여러 명이 동시에 작업하면 스크린샷 충돌이 끊임없이 발생한다. ▼
- 개발자 A가 Button 컴포넌트 수정 → 스크린샷 변경 → 커밋
- 개발자 B가 Input 컴포넌트 수정 → 스크린샷 변경 → 커밋
- PR 머지 시 __screenshots__ 폴더 전체가 충돌
더 답답한 건 의미 없는 충돌이라는 점이다. `Button`과 `Input`은 서로 관련이 없는데, 같은 폴더에 스크린샷이 있다는 이유만으로 충돌이 난다.
게다가 스크린샷은 바이너리 파일이다. Git이 자동으로 머지해줄 수 없다. 매번 '내 거 쓸까? 네 거 쓸까?' 수동으로 결정해야 한다. 처음에는 '그냥 충돌 나면 해결하면 되지'라고 생각했다. 하지만 아무리 생각해도 이 충돌로 인해 생산성이 더 떨어질 것 같았다.
해결 시도: GitHub Actions로 나중에 커밋
그래서 다른 방법을 시도했다. 개발자는 스크린샷을 커밋하지 않고, GitHub Actions가 마지막에 자동으로 커밋하게 하는 것이다. ▼
# .github/workflows/screenshots.yml
name: Update Screenshots
on:
pull_request:
types: [closed]
branches: [main]
jobs:
capture:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Capture screenshots
run: pnpm run screenshot:ci
- name: Commit screenshots
run: |
git config user.name "github-actions[bot]"
git add __screenshots__
git commit -m "chore: update screenshots"
git push
이렇게 하면
- 개발자는 코드만 커밋 (스크린샷 무시)
- PR 머지되면 Actions가 스크린샷 생성 & 커밋
- 충돌 발생 안 함
충돌 문제는 해결됐다. 개발자들은 PR 올리기 전에 메인 브랜치를 한 번 pull 받아서 최신 스크린샷을 받아오기만 하면 된다.
세 번째 문제: 끝없는 재빌드
하지만 새로운 문제가 생겼다. Actions가 스크린샷을 커밋하면, 그 커밋이 또 다른 Actions를 트리거한다. ▼
- PR 머지 → Actions 실행 (빌드, 린트, 테스트)
- Actions가 스크린샷 커밋
- 새 커밋 → Actions 다시 실행 (빌드, 린트, 테스트)
빌드나 테스트는 [skip ci] 같은 걸로 막을 수 있다. 하지만 내가 직접 설정한 Actions만 있는 게 아니었다. Vercel 자동 배포와 Coderabbit AI 리뷰가 문제였다. ▼
1. PR 머지
→ Vercel 배포 시작
→ Coderabbit 분석 시작
2. 스크린샷 커밋
→ Vercel 또 배포 시작 (내 의지와 무관)
→ Coderabbit 또 분석 시작 (내 의지와 무관)
Vercel이나 Coderabbit은 내가 설정한 게 아니라 레포지토리 설정에 통합된 서비스다. 이걸 내가 '스크린샷 커밋일 때는 실행하지 마' 라고 제어할 수 없었다. 그렇다고 스크린샷 커밋 문제 때문에 Vercel이랑 Coderabbit 설정을 고치는 것은 배보다 배꼽이 큰 상황이다.
결과적으로 아래의 문제들이 발생했다.
- Actions 대기열이 계속 밀림
- 배포가 두 번 일어남 (리소스 낭비)
- Coderabbit 크레딧 소모 (비용 증가)
이건 근본적인 해결이 필요했다.
최종 해결: 별도 저장소
결국 내린 결론은 "메인 저장소에는 스크린샷을 아예 넣지 말자"였다. 스크린샷 전용 저장소를 만들어서 분리하는 것이다. ▼
메인 저장소 (my-design-system)
├── src/
├── .github/workflows/
└── package.json
스크린샷 저장소 (my-design-system-screenshots)
├── button-default.png
├── button-primary.png
└── ...
새로운 워크플로우 ▼
# .github/workflows/screenshots.yml
name: Update Screenshots
on:
push:
branches: [main]
jobs:
capture:
runs-on: ubuntu-latest
steps:
- name: Checkout main repo
uses: actions/checkout@v3
- name: Capture screenshots
run: pnpm run screenshot:ci
- name: Push to screenshots repo
run: |
cd __screenshots__
git init
git remote add origin <https://github.com/company/ds-screenshots.git>
git add .
git commit -m "Update screenshots [skip ci]"
git push -f origin main
이렇게 하니 아래의 이점이 생겼다.
- 메인 저장소는 깨끗함 (코드만 존재)
- 스크린샷 커밋이 메인 저장소를 트리거하지 않음
- Vercel, Coderabbit 재실행 안 됨
- 개발자들은 스크린샷 충돌 걱정 없음
구현: 3단계 파이프라인
1단계: 스크린샷 자동 캡처 (Storycap)
Storybook의 모든 스토리를 자동으로 순회하면서 스크린샷을 찍는 도구가 있다. Storycap이다. ▼
pnpm run screenshot:ci
이 명령어 하나로:
- Storybook 서버 실행
- 모든 스토리 순회
- 데스크톱/태블릿/모바일 뷰포트별로 캡처
- __screenshots__ 폴더에 저장
결과물은 이런 식이다. ▼
__screenshots__/
button-default-desktop.png
button-default-tablet.png
button-default-mobile.png
button-primary-desktop.png
button-secondary-desktop.png
...
이 때, 스크린 샷 이름들을 통일시키기 위해 Storybook에 `ForJsdoc`이라는 스토리를 하나씩 만들어줬다. 전체를 모두 볼 수 있는 스토리로 `Default`라고 이름을 짓고 공용으로 사용해도 되지만, 직관성을 위해 위의 이름을 붙였다. 스토리는 Default 스토리 변수를 복제해서 사용하기만 하면 되기에 중복 코드를 작성할 일은 없었다.
2단계: 외부 저장소에 업로드 (Github Actions)
앞서 설명한 고민 끝에 결정한 방식이다. 이미지를 별도 저장소에 푸시한다. ▼
- name: Push to screenshots repo
env:
GITHUB_TOKEN: ${{ secrets.SCREENSHOTS_REPO_TOKEN }}
run: |
cd __screenshots__
git init
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git remote add origin <https://x-access-token:${GITHUB_TOKEN}@github.com/company/ds-screenshots.git>
git add .
git commit -m "Update screenshots from ${{ github.sha }}"
git push -f origin main
이렇게 하면:
- 메인 저장소는 가볍게 유지
- 이미지는 별도 관리
- CDN처럼 raw.githubusercontent.com으로 접근 가능
- 메인 저장소의 CI/CD와 완전히 분리
3단계: JSDoc에 자동 주입 (Script)
캡처된 이미지를 JSDoc에 넣는 스크립트다. ▼
// scripts/add-screenshots-to-jsdoc.ts
import fs from 'fs';
import path from 'path';
function addScreenshotsToJSDoc() {
const screenshotsDir = '__screenshots__';
const componentsDir = 'src/components';
// 스크린샷 파일 목록 읽기
const screenshots = fs.readdirSync(screenshotsDir);
// 컴포넌트별로 그룹화
const screenshotsByComponent = groupScreenshotsByComponent(screenshots);
// 각 컴포넌트 파일 처리
Object.entries(screenshotsByComponent).forEach(([componentName, images]) => {
const componentPath = findComponentFile(componentsDir, componentName);
if (!componentPath) return;
const content = fs.readFileSync(componentPath, 'utf-8');
const updatedContent = injectScreenshots(content, images);
fs.writeFileSync(componentPath, updatedContent);
});
}
function injectScreenshots(content: string, images: string[]): string {
// JSDoc 주석 찾기
const jsdocRegex = /\\/\\*\\*([\\s\\S]*?)\\*\\//;
const match = content.match(jsdocRegex);
if (!match) return content;
// 기존 참고사진 섹션 제거
let jsdoc = match[1].replace(/## 참고사진[\\s\\S]*?(?=\\*\\/|$)/, '');
// 새 참고사진 섹션 추가
const imageSection = '\\n * ## 참고사진\\n' +
images.map(img => {
// 별도 저장소의 URL 사용
const url = `https://raw.githubusercontent.com/company/ds-screenshots/main/${img}`;
return ` * `;
}).join('\\n');
jsdoc += imageSection;
return content.replace(jsdocRegex, `/**${jsdoc}\\n */`);
}
이 스크립트는:
- __screenshots__ 폴더를 스캔
- 컴포넌트 이름으로 매칭
- 각 컴포넌트 파일의 JSDoc에 이미지 주입
- 기존 이미지는 자동으로 갱신
- 별도 저장소의 URL을 사용
최종 결과 ▼
/**
* Primary UI component for user interaction
*
* @param variant - Button style variant
* @param children - Button content
*
* ## 참고사진
* 
* 
*/
export function Button({ variant = 'default', children, ...props }: ButtonProps) {
// ...
}
실제 사용 경험
IDE에서의 경험
이제 개발자가 Button에 마우스를 올리면 ▼

VSCode는 Markdown 이미지를 렌더링해서 보여준다. 즉, Storybook을 열 필요 없이 코드 에디터에서 바로 확인할 수 있다.
그럼 Storybook은 왜 있냐? 라고 할 수 있는데, Storybook은 직접 컴포넌트를 다뤄볼 수 있는 공간의 측면이 더 강하고 jsdoc은 개발 중간에 급하게 볼 수 있는 느낌이 더 강하다. 개발에 들어가기 전 숙지할 수 있는 Playground의 용도라고 생각하면 좋다.
유지보수는?
완전 자동이다. ▼
- 컴포넌트 스타일 수정
- 커밋 & 푸시
- GitHub Actions 실행
- 스크린샷 자동 갱신 (별도 저장소에)
- JSDoc 자동 업데이트
개발자는 아무것도 신경 쓸 필요가 없다. 컴포넌트만 수정하면 스크린샷은 알아서 따라온다. 그리고 메인 저장소의 CI/CD는 한 번만 실행된다.
+) 추가로 고려한 점들
Tailwind Preflight 제거
디자인 시스템은 기존 프로젝트에 추가될 가능성이 높다. 레거시 스타일과 충돌하지 않도록 Tailwind의 Preflight를 제거했다. ▼
/* src/styles/globals.css */
/* 일반적인 방식 (Preflight 포함) */
/* @import "tailwindcss"; */
/* 우리 방식 (Preflight 제외) */
@import "tailwindcss/theme";
@import "tailwindcss/utilities";
이렇게 하면 h1, p, button 같은 기본 태그 스타일을 건드리지 않는다. 기존 사이트에 안전하게 디자인 시스템을 도입할 수 있다.
E2E 테스트와 통합
Playwright로 각 컴포넌트의 기능을 테스트한다. ▼
// e2e/button.spec.ts
import { test, expect } from '@playwright/test';
test('Button renders correctly', async ({ page }) => {
await page.goto('<http://localhost:6006/?path=/story/button--default>');
const button = page.locator('button');
await expect(button).toBeVisible();
await expect(button).toHaveText('Button');
});
test('Button is clickable', async ({ page }) => {
await page.goto('<http://localhost:6006/?path=/story/button--default>');
const button = page.locator('button');
await button.click();
// 클릭 이벤트 검증
});
스크린샷으로 "어떻게 생겼는지"를 보여주고, E2E 테스트로 "제대로 동작하는지"를 검증한다. 시각적 문서화와 기능 검증을 동시에 달성한다.
성능 고려사항
이미지가 많아지면 별도 저장소도 커질 텐데? 몇 가지 최적화를 했다. ▼
- 중복 제거: 똑같은 이미지는 한 번만 저장 (해시 비교)
- 포맷 최적화: PNG 대신 WebP 사용 (파일 크기 30% 감소)
- 리사이즈: 너무 큰 이미지는 자동으로 리사이즈
- 캐싱: GitHub CDN이 알아서 캐싱해줌
결과적으로 수백 개의 스크린샷이 쌓여도 저장소 크기는 50MB 이하로 유지된다.
마무리
처음 아이디어는 간단했다. "IDE에서 컴포넌트 미리보기를 보여주자." 하지만 실제 구현 과정은 생각보다 복잡했다. 패키지 용량 문제, 충돌 문제, CI/CD 재실행 문제를 하나씩 만나면서 해결책을 찾아갔다. 결국 별도 저장소 분리라는 방법에 도달했다.
디자인 시스템을 만들 예정이라면, 문서화 자동화를 진지하게 고려해볼 만하다. 처음엔 과한 것 같지만, 팀이 커지고 컴포넌트가 많아질수록 그 가치가 커진다. 그리고 구현 과정에서 만나는 문제들도 하나씩 해결하다 보면 더 나은 아키텍처에 도달하게 될 것이라 믿는다.
'Develop > Web' 카테고리의 다른 글
| [Web] JSDoc과 Storybook으로 컴포넌트 문서화 (0) | 2025.12.25 |
|---|---|
| [Web] 시맨틱 태그 적용 작업 (3) | 2025.12.12 |
| [Web][Issue] MUI의 INP 성능 저하 문제 (0) | 2025.12.12 |
| [JS] Named export와 Default export, 둘 중에 뭘 써야할까? (0) | 2025.12.09 |
| [React][Issue] 간단한 알고리즘과 함께 말줄임 디테일 챙기기 (0) | 2025.12.01 |