본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

Storybook에서 여러 패키지 버전 비교하기 — 스냅샷 자동화와 적용 조건

이전 버전 컴포넌트를 Storybook에서 바로 비교하고 싶을 때, 가장 단순한 방법은 버전마다 Storybook을 따로 빌드·배포하는 겁니다. 즉, 버전 개수만큼 Storybook도 늘어나는 1:1 방식이죠. 하지만… 배포물과 링크가 버전 수에 따라 늘어나는 관리 비용이 생깁니다.

그래서 우리는 한 개의 Storybook 안에 버전들을 섹션처럼 쌓아두는 방식을 썼습니다. 핵심은 스냅샷(snapshot) 스크립트 하나로 “귀찮은 3번 단계(마이그레이션)”를 자동화하는 겁니다.

접근 방식

버전 상관없이 stories/head에서 마음껏 개발한다. (여기가 “현재 작업 중” 스토리 모음)

패키지를 배포한다. (@hk/weave-ui@0.2.0 처럼)

현재 Storybook을 ‘해당 패키지 버전을 쓰는 스냅샷’으로 자동 변환한다. → stories/v0.2.0/**로 복제되고, 내부 import가 배포된 패키지(별칭) 를 바라보도록 치환됨.

이렇게 하면 한 개의 Storybook 안에서 head/, v0.1.0/, v0.2.0/ … 을 동시에 살릴 수 있습니다.

문제는 3번이 너무 귀찮다는 것. 그래서 만든 게 바로 snapshot 스크립트입니다.

snapshot 스크립트가 하는 일 (요약)

버전 폴더 생성 & 복제

package.json의 version(예: 0.2.0)으로 stories/v0.2.0 폴더를 만들고 stories/head 내용을 그대로 복제합니다.

스토리 메타와 import에 버전 명시

title: "Button" → title: "v0.2.0/Button" 으로 자동 접두.

import "@hk/weave-ui/..."; → import "@hk/weave-ui_v0_2_0/..."; 로 npm alias 적용.

CSS도 동일: @import "@hk/weave-ui/globals.css" → @import "@hk/weave-ui_v0_2_0/globals.css".

배포된 패키지(alias) 설치

@hk/weave-ui_v0_2_0@npm:@hk/weave-ui@0.2.0 형태로 alias 패키지를 devDependencies에 설치. → 스냅샷 스토리는 정확히 그 시점의 배포물을 사용하게 됨.

이 방식은 해당 버전의 패키지를 참조하게 합니다. React·Storybook·브라우저·전역 CSS까지 당시와 동일한 환경으로 고정하는 방식은 아닙니다.

실제 스크립트 (요약 설명)

복제: stories/head → stories/v{version}

치환:

JS/TS/MDX: @hk/weave-ui → @hk/weave-ui_vM_m_p (예: v0_2_0)

CSS: @import 경로도 같은 규칙으로 치환

Storybook title에 vX.Y.Z/ 접두

설치: alias spec으로 해당 버전의 패키지를 설치

전제: 패키지에 exports["./globals.css"]가 열려 있어야 CSS 서브패스 임포트가 동작하고, sideEffects에 CSS가 포함되어 있어야 트리셰이크에서 안 날아갑니다.

// @hk/weave-ui/package.json (발췌)
"exports": {
  ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
  "./globals.css": "./dist/globals.css"
},
"sideEffects": ["./dist/globals.css", "**/*.css"]

Storybook 설정 팁 (prod에선 버전 폴더만)

운영(배포) Storybook에서는 버전 스냅샷(v)만 노출*하고, 로컬 개발에선 head까지 모두 보이게 하면 깔끔합니다.

// .storybook/main.mjs
const isProd = process.env.SB_MODE === "prod" || process.env.NODE_ENV === "production";
const EXT = "{mdx,js,jsx,mjs,ts,tsx}";

export default {
  stories: isProd
    ? [`../stories/v*/**/*.${EXT}`]   // 배포: v로 시작하는 폴더만
    : [`../stories/**/*.${EXT}`],     // 개발: head 포함 전체
  framework: { name: "@storybook/react-vite", options: {} },
  addons: ["@storybook/addon-docs", "@storybook/addon-links"],
};

팀 워크플로우 (실전)

기능 개발 시작

git checkout -b feature/add-calendar-dialog
npm run sb:dev  # head 환경에서 개발

head에서 Storybook 완성 UI 확인 후 릴리스 준비.

버전 릴리스 & 퍼블리시

npm version 0.2.0  # 기본 설정에서 커밋과 태그도 생성
git push origin v0.2.0

CI가 태그와 package.json.version을 비교하여 npm publish 수행.

스냅샷 생성

npm run snapshot
git add stories package.json package-lock.json
git commit -m "Add Storybook snapshot for v0.2.0"

head → v0.2.0 복제/치환/alias 설치 자동화.

main 병합 & Storybook 배포

git checkout main
git merge feature/add-calendar-dialog
git push origin main

main push 시 Storybook 정적 빌드/배포 실행.

중복 파이프라인을 줄일 때도 리뷰에 필요한 검사까지 꺼지지 않는지 확인해야 합니다. CI 트리거는 저장소의 필수 검사 정책에 맞춰 정합니다.

왜 이 방식이 좋은가

한 개의 Storybook = UI 타임라인 과거/현재 버전을 한 화면에서 즉시 비교.

정확한 재현성 스냅샷은 그 버전의 배포 패키지를 참조합니다. 전역 CSS와 peer dependency가 공유되면 버전 간 간섭이 생길 수 있습니다.

운영비용 최소화 Storybook 호스팅은 1개. URL도 1개. 버전 추가는 npm run snapshot 1회로 끝.

QA/디자인 커뮤니케이션 단순화 “그때 그 버전”을 말로 설명하지 않아도 됩니다. 바로 보여주면 끝.

적용 범위

아래 스크립트는 x.y.z 릴리스 버전과 정해진 import/title 형식을 전제로 한 예시입니다. 정규식 치환은 모든 JS/TS/MDX 문법을 파싱하지 못하므로 생성된 diff를 검토해야 합니다. 프리릴리스 버전이나 복잡한 스토리는 별도 지원이 필요합니다.

전역 스타일이 버전 사이에서 충돌할 수 있고, 여러 버전을 같은 페이지에 배치하면 이 문제가 더 분명해집니다. 버전별 패키지 선택과 스타일 격리는 따로 다뤄야 합니다.

npm version은 기본 설정에서 커밋과 태그를 생성하므로 같은 태그를 다시 만들지 않습니다. npm version 문서

자주 겪는 이슈 & 체크리스트

CSS가 적용 안 됨

exports["./globals.css"] 공개되어 있는지

sideEffects에 CSS 포함되어 있는지

Storybook 프리뷰 iframe에 CSS가 실제로 로드되는지(필요 시 .storybook/preview-head.html에 <link> 추가)

prod에서 head가 보임/안 보임

.storybook/main.mjs의 stories 패턴을 다시 확인(위 예시처럼 분기)

버전 폴더가 많아져 사이드바 길어짐

오래된 버전 폴더를 주기적으로 정리하거나, prod에선 최신 N개만 포함하는 글롭을 사용

맺음말

Storybook을 “UI 미리보기”로만 쓰지 말고, 릴리스 히스토리를 눈으로 확인하는 타임머신으로 만드세요. 우리는 코드만 버전 관리하지 않습니다. UI 자체를 버전 관리합니다.

“버전마다 Storybook을 따로 배포”는 쉬우나 낭비가 큽니다. 스냅샷 생성의 반복 작업은 줄지만, 버전이 쌓일수록 빌드 크기와 의존성 관리 비용은 늘어납니다. 당시 환경까지 재현해야 한다면 버전별 정적 빌드를 보관하는 선택도 필요합니다.

// scripts/snapshot-head.mjs
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const storiesRoot = path.join(repoRoot, "stories");

const log = (m) => process.stderr.write(`[snapshot] ${m}\n`);

async function readJson(p) {
  return JSON.parse(await fsp.readFile(p, "utf8"));
}

async function ensureDir(p) {
  await fsp.mkdir(p, { recursive: true });
}

async function copyDir(src, dst) {
  await ensureDir(dst);
  const ents = await fsp.readdir(src, { withFileTypes: true });
  await Promise.all(
    ents.map(async (e) => {
      const s = path.join(src, e.name);
      const d = path.join(dst, e.name);
      if (e.isDirectory()) {
        await copyDir(s, d);
      } else if (e.isFile()) {
        await ensureDir(path.dirname(d));
        await fsp.copyFile(s, d);
      }
    })
  );
}

function toAliasName(version) {
  // 0.2.0 -> v0_2_0
  const [ma = "0", mi = "0", pa = "0"] = String(version).split(".");
  return `v${ma}_${mi}_${pa}`;
}

/** JS/TS/MDX: import '@hk/weave-ui[/sub]' → '@hk/weave-ui_vM_m_p[/sub]' */
function replaceWeaveImports(code, aliasModule) {
  // 캡처: 앞따옴표, 서브패스(예: /globals.css) 포함
  const re = /(['"])@hk\/weave-ui(\/[^"'`]*)?\1/g;
  return code.replace(re, (m, q, sub = "") => {
    return `${q}@hk/weave-ui_${aliasModule}${sub}${q}`;
  });
}

/** CSS/SCSS: @import "@hk/weave-ui[/sub]"; 또는 @import url("@hk/weave-ui[/sub]"); */
function replaceWeaveCssAtImport(code, aliasModule) {
  const re = /@import\s+(?:url\()?(['"])@hk\/weave-ui(\/[^'")\s]*)?\1\)?\s*;/g;
  return code.replace(re, (m, q, sub = "") => {
    return `@import ${q}@hk/weave-ui_${aliasModule}${sub}${q};`;
  });
}

/**
 * title: "something" → title: "vX.Y.Z/something"
 * - 이미 같은 버전 접두가 있으면 유지하고 head/ 접두는 제거
 * - 작은따옴표/큰따옴표/백틱 모두 처리
 */
function prefixStoryTitle(code, folderName) {
  const re = /(\btitle\s*:\s*)(["'`])([^"'`]+)\2/g;
  return code.replace(re, (full, pre, quote, inner) => {
    const t = inner.trim();
    if (t.startsWith(`${folderName}/`)) {
      return full; // 이미 접두가 있으면 그대로
    }
    const normalized = t.replace(/^head\//i, "");
    return `${pre}${quote}${folderName}/${normalized}${quote}`;
  });
}

async function rewriteImportsRecursively(folder, aliasModule, folderName) {
  // ✅ CSS/SCSS까지 포함해서 처리
  const exts = /\.(mdx|[cm]?[jt]sx?|css|scss)$/i;
  const ents = await fsp.readdir(folder, { withFileTypes: true });
  for (const e of ents) {
    const p = path.join(folder, e.name);
    if (e.isDirectory()) {
      await rewriteImportsRecursively(p, aliasModule, folderName);
    } else if (e.isFile() && exts.test(e.name)) {
      const raw = await fsp.readFile(p, "utf8");
      let next = raw;

      if (/\.(css|scss)$/i.test(e.name)) {
        // CSS/SCSS: @import 치환만 수행
        next = replaceWeaveCssAtImport(next, aliasModule);
      } else {
        // JS/TS/MDX: import 치환 + title 보정
        next = replaceWeaveImports(next, aliasModule);
        next = prefixStoryTitle(next, folderName);
      }

      if (next !== raw) {
        await fsp.writeFile(p, next, "utf8");
        log(`rewrite: ${path.relative(repoRoot, p)}`);
      }
    }
  }
}

function run(cmd, args, opts = {}) {
  return new Promise((resolve, reject) => {
    log(`$ ${cmd} ${args.join(" ")}`);
    const cp = spawn(cmd, args, {
      stdio: "inherit",
      shell: process.platform === "win32",
      ...opts,
    });
    cp.on("error", reject);
    cp.on("exit", (code) => {
      if (code === 0) resolve();
      else reject(new Error(`${cmd} exited with code ${code}`));
    });
  });
}

async function main() {
  const pkg = await readJson(path.join(repoRoot, "package.json"));
  const version = pkg.version;
  if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) {
    throw new Error("This example supports x.y.z release versions only");
  }

  const folderName = `v${version}`; // e.g. v0.2.0
  const aliasModule = toAliasName(version); // e.g. v0_2_0
  const aliasPkg = `@hk/weave-ui_${aliasModule}`; // e.g. @hk/weave-ui_v0_2_0

  const srcHead = path.join(storiesRoot, "head");
  const dstVer = path.join(storiesRoot, folderName);

  // 1) stories/head 복제 → stories/v{version}
  if (!fs.existsSync(srcHead)) {
    throw new Error(`Not found: ${path.relative(repoRoot, srcHead)}`);
  }
  if (fs.existsSync(dstVer)) {
    throw new Error(`Snapshot already exists: ${dstVer}`);
  }
  await copyDir(srcHead, dstVer);
  log(`copied: stories/head -> stories/${folderName}`);

  // 2) stories/v{version} 내부 import & title & CSS @import 치환
  await rewriteImportsRecursively(dstVer, aliasModule, folderName);

  // 3) alias 패키지 설치
  //    alias 문법: <alias>@npm:<real>@<version>
  const realPkg = "@hk/weave-ui";
  const aliasSpec = `${aliasPkg}@npm:${realPkg}@${version}`;

  // devDependencies에 넣고 싶으면 --save-dev, prod로 넣고 싶으면 --save
  await run("npm", ["i", "--save-dev", aliasSpec]);

  log(`done: stories/${folderName}, installed ${aliasSpec}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

이 스크립트의 import·title 변경은 정규식으로 처리한다. 동적 import 경로, 계산된 title, 주석과 일반 문자열을 문법적으로 구분하지 못하므로 생성 diff를 검토해야 한다. 복제 후 설치가 실패하면 중간 폴더가 남을 수 있어 자동 롤백까지 보장하지 않는다. 위 git 명령은 흐름을 설명하는 예시이며 실제 저장소의 브랜치 보호와 리뷰 절차를 따른다.

Assisted by AI

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →