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

개발과 기술

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

이전 버전의 컴포넌트와 지금 버전을 Storybook에서 바로 비교하고 싶었습니다. 버전마다 따로 빌드하고 배포하면 되지만, 버전이 늘 때마다 찾아가야 할 링크와 관리할 배포물도 하나씩 늘어납니다.

그래서 우리는 한 개의 Storybook 안에 버전별 스토리를 모으는 방식을 썼습니다. 작업 중인 스토리는 그대로 두고, 릴리스할 때 복사본이 해당 버전의 배포 패키지를 바라보게 만드는 겁니다. 이 복제와 경로 변경을 스냅샷(snapshot) 스크립트로 묶었습니다.

접근 방식

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

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

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

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

문제는 3번이 너무 귀찮다는 겁니다. 폴더를 복사하고, import를 바꾸고, 스토리 제목에 버전을 붙이는 일을 릴리스마다 반복해야 합니다. 그래서 만든 게 스냅샷 스크립트입니다. 귀찮은 일을 성실하게 하는 대신, 다음에는 하지 않아도 되게 만들었습니다.

스냅샷을 만드는 세 단계

버전 폴더 생성 & 복제

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까지 당시와 동일한 환경으로 고정하는 방식은 아닙니다.

치환 규칙과 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에서 찾아볼 수 있습니다. QA나 디자인 검토에서도 어느 버전을 말하는지 스토리 경로로 가리킬 수 있습니다.

스냅샷은 그 버전의 배포 패키지를 참조합니다. 다만 전역 CSS와 peer dependency를 공유하면 버전 간 간섭이 생길 수 있으므로, 패키지 버전을 고정했다는 사실만으로 당시 화면이 완전히 재현된다고 보지는 않습니다.

호스팅과 URL은 하나로 유지하고, 새 버전의 복제와 치환은 npm run snapshot으로 처리합니다. 줄어드는 것은 버전마다 반복하던 배포물 관리와 스냅샷 생성 작업입니다.

적용 범위

아래 스크립트는 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개만 포함하는 글롭을 사용

전체 스크립트

앞에서 설명한 복제, 치환, alias 설치를 순서대로 수행합니다.

// 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 명령도 흐름을 설명하는 예시이므로 실제 저장소의 브랜치 보호와 리뷰 절차를 따라야 합니다.

버전이 쌓인 뒤에도

스냅샷을 만드는 수고는 줄었지만, 버전이 쌓일수록 빌드 크기와 의존성 관리 비용은 늘어납니다. 오래된 버전을 어디까지 남길지는 계속 정해야 합니다. 당시 환경까지 재현해야 한다면 버전별 정적 빌드를 보관하는 편이 맞을 수도 있습니다.

우리가 원한 것은 이전 UI를 확인할 때마다 다른 배포물을 찾아다니지 않는 것이었습니다. “그때 그 버튼이 어떻게 생겼더라”를 말로 설명하는 대신, 같은 Storybook에서 버전을 열어 보여줄 수 있게 됐습니다.

코드만 버전 관리하는 데서 한 걸음 더 가서, UI를 확인하는 자리도 버전별로 남겨둡니다. 릴리스할 때마다 손으로 복사하던 귀찮음은 스크립트에 넘겼고요.

Assisted by AI

© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내

무단 복제·재게시를 허용하지 않습니다. 콘텐츠 이용 안내

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

모든 글 둘러보기 →