"인덱서를 두 개 유지하는 데는 관심이 없습니다."
Storybook의 CSF 인덱싱을 OXC로 빠르게 만들자는 제안에 돌아온 답변은 예상과 달랐다. 나는 기존 Babel 구현을 남겨 두는 편이 안전하다고 생각했다. 메인테이너는 오히려 그 선택이 앞으로의 유지보수 비용을 늘린다고 봤다.
2026년 9월 말에 시작한 작업은 같은 스토리 파일을 두 번 파싱하는 문제를 고치는 것이었다. 중복 파싱을 없애는 PR #36468을 올렸고, 이어 OXC를 이용한 빠른 인덱싱 경로를 Discussion #36467에 제안했다. 하지만 논의 끝에 목표는 인덱싱 최적화가 아니라 CSF 처리 계층에서 Babel과 Recast를 제거하는 것으로 바뀌었다.
범위가 커진 만큼 예상하지 못한 문제도 많았다. 기존 테스트를 통과한 코드가 템플릿 리터럴의 값이나 배열 설정의 의미를 바꾸기도 했다. PR #36613을 진행하며 어떤 선택을 했고, 리뷰에서 무엇을 놓쳤는지 정리했다.
| 날짜 | 작업 |
|---|---|
| 9월 27일 | Discussion #36467 제안, PR #36468 제출 |
| 10월 6일 | PR #36613 제출 |
| 10월 13일 | 중복 파싱 제거 PR 병합 |
| 10월 15일~20일 | 차등 리뷰와 회귀 수정, 메인테이너와 공동 작업 |
| 10월 20일~21일 | OXC 전환 PR과 분리된 PR #36861 병합 |
1. 시작은 중복 파싱이었다
Storybook에서 스토리는 CSF(Component Story Format) 파일로 작성한다. 예를 들면 이런 코드다.
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta = {
component: Button,
title: 'Example/Button',
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
label: 'Button',
},
};
Storybook은 이 파일을 파싱해 스토리의 ID, 이름, 메타데이터를 추출하고 인덱스를 만든다. Docs에서 원본 코드를 보여주거나 Vitest 테스트로 변환할 때도 같은 CSF 처리 로직(csf-tools)을 쓴다. 당시 이 모든 과정은 Babel 위에서 동작했다.
변환 시간의 대부분은 파싱이었다
먼저 본 곳은 addon-docs의 CSF 플러그인이었다. 저장소의 스토리 파일 706개를 대상으로 플러그인 변환 시간을 재 보니, 전체 시간의 약 92%가 Babel 파싱이었다. 변환 로직 자체는 가벼웠고, 시간은 거의 전부 "코드를 AST로 만드는 일"에 쓰이고 있었다.
코드를 읽어 보니 이유가 있었다. Vite에서 이 플러그인은 pre 단계에서 실행되기 때문에 대부분 원본 스토리 파일을 그대로 받는다. 그런데 플러그인은 같은 소스를 Babel로 두 번 파싱하고 있었다.
- 한 번은 Docs 정보를 덧붙일 대상으로
- 한 번은 JSDoc 설명과 원본 소스(
originalSource)를 읽어 올 "깨끗한 원본"으로
같은 입력으로 AST를 두 번 만들 필요가 없다면, 파싱 비용도 그만큼 줄일 수 있었다.
두 번째 파싱을 없애기 전에 확인한 것
다만 두 AST를 무조건 합칠 수는 없었다. 기본 enrichment는 파일 끝에 문장을 덧붙일 뿐 기존 스토리 노드를 바꾸지 않으므로, 디스크의 파일과 코드가 같다면 파싱한 결과를 원본으로도 쓸 수 있다.
반면 사용자가 등록하는 experimental_enrichCsf 훅은 변환 중에 원본을 읽을 수 있다. 실제로 React의 code-examples enricher가 그렇게 동작했다. 그래서 다음 조건에서만 재사용하도록 했다.
- 전달된 코드가 디스크의 원본 파일과 같을 것
experimental_enrichCsf훅이 등록되어 있지 않을 것
Webpack은 로더가 babel/swc 이후에 실행되어 코드가 디스크 파일과 같을 일이 없으므로 기존 경로를 그대로 뒀다.
실행 시간은 절반으로 줄었다
PR #36468에서는 예상한 만큼 시간이 줄었다.
- 플러그인 변환 시간: 약 8.4초 -> 4.2초 (5회 평균)
- 706개 파일 모두 생성 코드와 소스맵이 기존과 동일
메인테이너 리뷰에서도 705개 스토리 파일에 대해 여러 변환 모드의 코드와 소스맵이 바이트 단위로 같다는 것을 따로 확인했고, PR은 10월 13일 병합됐다.
이후 OXC 전환 작업에서도 성능 수치와 함께 기존 출력과의 비교 결과를 계속 제출했다.
2. RFC: "Babel은 대체하지 않는다"
중복 파싱을 없앤 뒤에는 파서 자체를 바꿔 보고 싶었다.
후보는 OXC였다. Rust로 작성된 JavaScript/TypeScript 툴체인으로, Storybook은 이미 변경 감지용 모듈 그래프를 만들 때 oxc-parser를 쓰고 있었다(#34625). 새 의존성을 추가할 필요가 없다는 점이 컸다.
처음 올린 RFC #36467에서는 변경 범위를 인덱싱으로 제한했다.
- 정적으로 분석 가능한 스토리 파일만 OXC로 인덱싱하는 fast path
- 지원하지 않는 형태는
null을 반환하고 기존 BabelCsfFile로 fallback - Non-goal: Babel/
CsfFile대체. 변환과 쓰기에는 계속 Babel을 쓴다 - 대안으로 검토한 "CsfFile의 Babel 완전 교체"는 훨씬 크고 위험하다는 이유로 기각
PoC 결과는 이랬다.
| 항목 | 결과 |
|---|---|
| fast path 처리 비율 | 706개 중 680개 (96.3%) |
| Babel 출력과의 불일치 | 0건 |
내부 UI index.json (1,768 엔트리) | 바이트 단위 동일 |
| 격리된 CSF 인덱싱 벤치마크 | 약 4.5배 |
storybook index end-to-end | 7.97초 -> 5.95초 (약 25% 감소) |
fallback되는 26개 파일도 실행할 때마다 같은 파일이 같은 이유로 빠지도록 결정적으로 만들었다. 리스크 항목에는 "두 경로 사이의 drift"와 "읽기 전용 코드 경로를 하나 더 유지하는 비용"을 적었다.
두 개의 인덱서를 유지하지 말자는 답변
첫 답변은 예상과 달랐다. 메인테이너 Sidnioulz가 이렇게 답했다.
인덱서를 하나가 아니라 두 개 소유하는 것에는 관심이 거의 없을 것이다. … 개인적으로는 "CsfFile의 Babel 완전 교체"가 이 RFC의 좋은 목표라고 생각한다. 훨씬 큰 작업이지만 순이익도 크고, Babel을 계속 유지할 필요가 없어진다.
내가 리스크로 적어 둔 "두 경로의 drift"를, 메인테이너는 리스크가 아니라 채택할 수 없는 이유로 봤다. CSF 4가 Storybook 11의 기본이 되면 인덱스용 정적 추출 코드에 PR이 몰릴 텐데, 그때마다 두 구현을 맞춰야 하기 때문이다.
여기서 바로 범위를 키우지는 않았다. 먼저 이미 CsfFile의 Babel 교체를 진행 중인 사람이 있는지 물었다. 큰 작업일수록 다른 사람의 작업과 겹치지 않는지 확인하는 게 먼저라고 생각했다.
그러자 Storybook을 만든 Michael Shilman이 제품 관점에서 찬성한다며 이렇게 덧붙였다. "OXC가 압도적으로 이길 거라고 가정하면, Babel을 아예 제거할 수 있나? 막는 게 뭔가? 장기적으로 둘 다 지원하는 상황은 원하지 않는다."
Sidnioulz는 PoC를 진행해 달라고 하면서, 처음에는 보이지 않는 요구 사항이 있을 수 있으니 팀이 생각나는 대로 이 스레드에 남기겠다고 했다.
처음에는 기존 코드를 최대한 건드리지 않는 쪽이 안전하다고 생각했다. 실제로 PoC에서는 706개 중 680개 파일만 OXC로 처리하고 나머지는 Babel에 맡겼다. 이 방식이면 당장 다뤄야 할 예외가 줄고, 기존 동작을 망가뜨릴 위험도 작아 보였다.
하지만 인덱싱 규칙이 추가될 때마다 두 구현을 함께 수정해야 한다. OXC에서 처리하지 못하는 파일을 Babel로 넘기는 조건도 계속 관리해야 한다. 당장의 PR은 작아지지만 두 코드 경로가 서로 달라지는 문제는 사라지지 않는다.
결국 판단 기준을 바꿨다. 이번 변경을 얼마나 작게 만들 수 있느냐보다, 병합 이후에도 두 구현을 유지해야 하느냐가 중요했다. Babel을 완전히 교체하는 쪽은 초기 작업과 검증 부담이 컸지만, 성공하면 CSF 처리 규칙을 한곳에서 관리할 수 있었다. 단, 사용자 코드 컴파일까지 Babel을 없애겠다는 뜻은 아니었다. 범위는 csf-tools를 중심으로 한 CSF 처리 경로로 한정했다.
그래서 구현에 들어가기 전에 범위와 측정 계획을 다시 정리해 Discussion에 올렸다. 팀이 문제를 일찍 짚을 수 있게 하려는 목적이었다.
- 무엇을 교체하는가: 읽기 경로, 쓰기 경로, 변환, 그리고 API 검증을 위한 외부 소비자 하나. 가장 위험한 쓰기 경로를 가장 먼저 한다.
- 어떻게 측정하는가: 기존 테스트 스위트를 그대로 통과하고, 스냅샷이 바뀌면 모두 명시하고,
index.json이 바이트 단위로 같은지 확인한다. - 알려진 한계: 삽입 코드의 포맷, Flow 미지원, AST 노출로 인한 breaking change.
- 범위 밖: 사용자 코드 컴파일러로서의 Babel(
babel-loader등).
PoC 결과와 PR 링크도 같은 스레드에 공유했고, Michael Shilman이 "These are amazing numbers"라고 답했다.
3. Babel이 맡고 있던 일을 옮기기
Babel을 걷어낸다는 건 파서만 바꾸는 일이 아니었다. csf-tools가 Babel에서 실제로 쓰던 건 세 가지였다.
| Babel이 해 주던 일 | 교체 후 |
|---|---|
@babel/parser - 파싱 | oxc-parser (ESTree) |
@babel/traverse - NodePath, scope.getBinding() | 직접 작성한 스코프 분석기 (estree/scope.ts) |
recast - AST 수정 후 재출력 | span 기반 텍스트 편집기 (estree/editor.ts, MagicString) |
파싱 API를 바꾸는 것보다 스코프 분석과 코드 편집을 옮기는 데 시간이 훨씬 많이 들었다.
스코프 분석을 직접 구현하다
OXC의 JavaScript API는 스코프 정보를 노출하지 않는다. 그런데 Storybook 곳곳에서 Babel의 스코프 분석을 당연하게 쓰고 있었다. TanStack 프레임워크의 서버 코드 제거 플러그인이 좋은 예다. createServerFn().handler(fn)에서 fn이 한 번만 쓰이는 식별자라면, 그 선언을 지운다.
Babel에서는 세 줄이었다.
// Before: Babel NodePath
if (t.isIdentifier(handlerArg)) {
const binding = path.scope.getBinding(handlerArg.name);
if (binding && binding.referencePaths.length === 1) {
binding.path.remove();
}
}
binding.path.remove() 한 줄이 감추고 있던 것이 많다. 선언이 function인지 const인지, const a = 1, b = 2처럼 여러 선언자 중 하나인지, export 문 안에 있는지에 따라 지워야 하는 범위가 다르다. Babel은 이걸 AST 수준에서 알아서 처리해 줬다.
OXC로 옮기면서 선언을 삭제하는 조건을 직접 처리해야 했다. 참조가 한 번뿐인지 확인한 다음, 함수나 클래스라면 선언 전체를 지운다. 변수 선언은 조금 더 복잡하다. const a = 1, b = 2에서 a만 지울 때는 쉼표까지 함께 처리해야 하고, export로 감싼 선언은 또 다른 경계를 고려해야 한다. Babel의 binding.path.remove() 한 줄 뒤에 있던 동작을 코드로 풀어 쓴 셈이다.
scope.ts는 Babel의 getBinding / constant / referencePaths 의미를 재현하는 850줄짜리 분석기가 됐다. 검증은 차등 비교로 했다. 저장소의 3,717개 파일에서 나오는 99,828개 바인딩을 Babel과 비교했고 불일치는 0건이었다. Babel 고유의 규칙도 scope.test.ts에 고정했다. 예를 들어 타입 참조도 참조로 센다, export도 참조다, 루프 본문 안의 함수는 constant가 아니다 같은 것들이다.
그런데도 리뷰에서 빈틈이 나왔다. 이 이야기는 5장에서 다시 하겠다.
Recast 대신 원본 텍스트를 편집하기
Recast는 AST를 수정한 뒤 파일 전체를 다시 출력한다. 대체로 원본 포맷을 보존하지만 완벽하지는 않다. 실제로 리뷰 중에 기존 next 브랜치의 버그도 하나 발견됐다. Recast가 재출력하면서 건드리지도 않은 형제 노드의 \ 줄 연속과 템플릿 리터럴을 바꿔 버리는 문제였다.
대안은 AST를 수정한 뒤 다시 출력하는 대신, 원본 문자열에서 필요한 범위만 고치는 것이었다. SourceEditor가 원본 텍스트를 보관하고 MagicString으로 특정 span의 삽입, 삭제, 교체를 기록한다. 편집하지 않은 부분을 다시 출력하지 않으므로 원래 공백과 줄바꿈을 그대로 보존할 수 있다.
이 선택에는 분명한 대가가 있었다. Recast에서는 노드를 지우거나 옮기면 출력 과정이 주변 문법을 어느 정도 처리해 줬다. span 편집에서는 삭제할 노드 앞의 주석, 배열 원소 옆의 쉼표, 괄호의 바깥 경계를 직접 판단해야 한다. 원본 포맷 보존에는 유리하지만, 문법을 고려하지 않은 문자열 조작은 오히려 더 위험하다. MagicString을 선택한 이유는 편집이 쉬워서가 아니라, 변경하지 않은 코드를 건드리지 않기 위해서였다.
대신 Recast가 해 주던 일을 직접 해야 한다. 삽입한 코드의 들여쓰기를 주변에 맞추고, 따옴표 스타일을 따르고, 쉼표와 괄호와 주석의 경계를 정확히 잡는 일이다. span을 바꾸는 것과 안전한 코드 편집기를 만드는 것은 완전히 다른 문제였고, 그 차이는 리뷰에서 그대로 드러났다.
4. 테스트가 통과한 첫 PR
PR #36613은 10월 6일에 올렸다. 처음 측정에서는 587개 스토리 파일 기준으로 인덱싱 5.1배, Docs enrichCsf 4.8배, vitestTransform 3.2배였고, 세 작업 모두 Babel 출력과의 차이가 0건이었다.
fork에서는 리뷰어 지정도 라벨 추가도 할 수 없어서, PR 코멘트로 메인테이너들에게 리뷰를 요청하고 필요한 라벨을 부탁했다. 커밋은 영역별로 나눠 순서대로 읽을 수 있게 했고, 승인되면 세 개의 stacked PR로 나눠 병합할 수 있다는 계획도 본문에 적었다.
리뷰 초기에 Kasper Peulen이 물었다.
Recast를 어떻게 없앤 거죠? 들여쓰기는 어떻게 유지하고요? magic-string으로 가능하다고 듣긴 했는데.
나는 이렇게 답했다. 삽입 코드는 들어갈 줄의 들여쓰기를 복사하고(reindent), 파일의 따옴표 스타일을 따르며, save-story와 codemod 스냅샷 테스트가 그대로 통과하는 것이 포맷이 유지된다는 가장 좋은 증거라고.
그때는 기존 스냅샷이 통과한다는 사실을 꽤 강한 근거로 봤다. 그러나 스냅샷에 없던 입력은 아무것도 보장하지 못했다. 특히 문자열 안의 공백이나 괄호처럼, 코드가 정상적으로 파싱되면서도 의미가 달라지는 경우를 놓쳤다. 다음 리뷰에서 바로 그 문제가 드러났다.
5. 리뷰에서 발견한 세 가지 회귀
다음 날 Kasper의 리뷰가 올라왔다. 방향은 맞고 읽기 쪽은 인상적이라는 말로 시작했지만, 결론은 Changes requested였다.
Kasper는 같은 입력을 next(Babel)와 내 브랜치(OXC)의 컴파일된 core/dist에 각각 넣고 결과를 비교했다.
비교한 입력도 적지 않았다.
- 약 1,000개 스토리 파일 × 8개 스토리 변환, save-story 5,397건
- 자동 마이그레이션 약 15,000회,
ConfigFile조작 약 21,600건 - 76개 파일짜리 적대적(adversarial) 코퍼스
읽기 쪽은 거의 다 통과했다. 인덱스 엔트리는 동일했고, Vite csf-plugin 소스맵은 오히려 next보다 좋아졌다(잘못 매핑된 토큰 0개, next는 14만 7천 개). 문제는 쓰기 쪽이었다. 일부 변경은 오류 없이 사용자 코드의 의미를 바꿀 수 있었다.
이때부터는 테스트가 통과했는지를 확인하는 데서 그치지 않고, 결과가 왜 같아야 하는지부터 따졌다. 파싱 오류라면 쉽게 발견할 수 있지만, 문자열 값이나 설정 객체가 조용히 바뀌면 실제 사용자가 문제를 겪기 전까지 알아차리기 어렵다. 아래 세 사례는 그 차이를 잘 보여준다.
사례 1: 템플릿 리터럴의 값이 바뀐다
reindent는 삽입할 코드의 모든 줄에 들여쓰기를 더했다. 템플릿 리터럴 안의 줄도 예외가 아니었다.
// set(['code'], ...)으로 객체 안에 삽입한 값
{ code: `line1
line2` }
// 다시 읽었을 때
// Babel: "line1\nline2"
// OXC: "line1\n line2" // 들여쓰기가 문자열에 들어감
템플릿 리터럴 내부의 공백은 문자열 값의 일부다. 줄마다 들여쓰기를 추가하는 방식으로는 이 차이를 구분할 수 없었다.
수정할 때는 삽입할 코드를 먼저 파싱해 템플릿 리터럴과 문자열처럼 줄바꿈이 값에 영향을 주는 영역을 찾았다. reindent는 그 영역 안에서는 공백을 건드리지 않도록 바꿨다. 텍스트의 줄 수만 보고 들여쓰기를 조정하던 방식에서, 코드의 문법을 확인하는 방식으로 바뀐 것이다.
같은 문제가 줄 단위로 들여쓰기를 바꾸던 다른 헬퍼 세 곳(Vitest 설정 래핑, React 소스 출력 등)에도 있었다. 결국 모두 이 reindent 하나를 쓰도록 모았다.
사례 2: 괄호 하나가 배열을 바꾼다
OXC는 기본 설정(preserveParens: false)에서 노드의 span에 바깥 괄호를 포함하지 않는다. 그런데 편집기는 노드 span을 경계로 썼다.
// 원본
export default { addons: [(cond ? 'a' : 'c')] };
// appendValueToArray(['addons'], 'b') 실행 후
// Babel: [(cond ? 'a' : 'c'), 'b']
// OXC: [(cond ? 'a' : 'c', 'b')]
// 쉼표 연산자 때문에 결과적으로 ['b']가 됨
문법 오류도 아니고 에러도 나지 않는다. 설정 파일이 조용히 다른 의미가 된다. 리스트를 편집할 때는 노드의 바깥 span(괄호 포함)을 쓰도록 바꿨고(outerSpan), 이 입력은 그대로 editor.test.ts의 회귀 테스트가 됐다.
사례 3: 주석은 누구의 것인가
csf-2-to-3 codemod는 CSF 2의 Template.bind({}) 패턴을 CSF 3 객체로 바꾸면서 Template 선언을 지운다. 문제는 그 위의 주석이었다.
/** Shared template */
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = { label: 'A' };
// 잘못된 변환 결과: Template의 주석이 Primary에 남음
/** Shared template */
export const Primary = {
args: { label: 'A' },
};
Template의 JSDoc이 남아서 Primary에 붙었다. Docs의 Primary 설명이 "Shared template"이 된다. // @ts-expect-error나 // eslint-disable-next-line이었다면 다음 export에 적용된다. 실제로 저장소의 Zoom.stories.tsx는 마이그레이션 후 TS2578 에러가 다섯 개 생겼다.
AST에서 주석은 노드가 아니라 노드 사이의 텍스트다. Babel/Recast는 주석을 노드에 붙여서 관리했기 때문에 노드를 지우면 주석도 같이 사라졌다. span 편집에서는 "이 주석이 어느 문장의 것인가"를 직접 결정해야 한다. 문장을 지울 때 앞에 붙은 주석도 함께 지우고, 첫 문장을 지울 때는 파일 헤더(라이선스 등)를 남기도록 바꿨다.
스코프 분석의 빈틈
3장에서 99,828개 바인딩이 일치했다고 썼지만 리뷰에서 빈틈이 나왔다. 저장소 코드에 없는 패턴은 차등 비교로도 잡을 수 없다.
let token;
export function setSession(s) {
token = s.token;
}
export const getData = createServerFn().handler(async () => token);
// Babel: let token 선언 유지
// 초기 OXC 구현: 선언 삭제 -> setSession()에서 ReferenceError
이 밖에도 매개변수 기본값이 함수 본문의 선언으로 해석되는 문제, 매개변수 데코레이터를 방문하지 않는 문제 등이 있었다.
더 위험한 부분은 알 수 없는 TypeScript 노드를 처리하는 방식이었다. 기존 scope.ts는 모르는 TS 노드를 타입 전용으로 취급했다. OXC 업그레이드로 노드 이름이 바뀌거나 새 노드가 생기면 참조를 조용히 적게 세게 되고, 데드 코드 제거에서는 그게 가장 위험한 방향이다. 살아 있는 import를 지울 수 있기 때문이다. 그래서 fail-closed로 바꿨다.
알 수 없는 노드를 만나면 예외를 던지도록 바꿨다. 잘못된 판단으로 실제 사용 중인 선언을 삭제하는 것보다는 처리를 중단하는 편이 안전했다.
추가로 수정한 문제
리뷰에는 이 외에도 많은 항목이 있었다.
- Webpack/Rspack 로더가 들어오는 소스맵을 버림 -> 모든 webpack 프로젝트의 스토리 파일이 엉뚱한 줄로 매핑
- 파일명 없이 파싱하면 TSX로 가정 ->
.ts의<T>(x: T) => x제네릭 화살표 함수가 파싱 실패, Vite automock이 아무것도 mock하지 않음 - OXC는 기본적으로 의미 오류를 보고하지 않음 ->
export const A가 두 번 선언돼도 인덱싱됨 (showSemanticErrors: true로 해결) - CRLF 파일에 삽입한 코드는 항상
\n-> 줄바꿈 혼재 - Flow 지원 중단은 명시적인 breaking change여야 함 ->
MIGRATION.md와// @flow파일 전용 에러 메시지
6. 리뷰 이후의 수정과 검증
먼저 인정했다. 리뷰에 단 첫 답변은 이렇게 시작했다.
들여쓰기에 대한 제 이전 답변은 지나치게 자신 있었습니다. 지적하신 대로
reindent는 템플릿 리터럴 안의 줄을 옮기고 있었습니다.
수정 순서는 파서, 편집기, 소스맵, 패키지별 회귀, 스코프 분석, 마이그레이션 문서 순으로 잡아 공유했다. 입력을 잘못 파싱한 상태에서 편집기부터 고쳐 봐야 결과를 신뢰하기 어렵기 때문이다.
수정 커밋은 가능한 한 리뷰 지적별로 나눴고, 모든 인라인 스레드에 해당 커밋 해시를 달았다. 첫 라운드에서 원래 커밋 17개 위에 수정 커밋 23개가 쌓였다. 인라인 스레드가 없던 항목은 PR 코멘트에 항목별로 커밋을 정리했다. 리뷰어가 어떤 지적을 어느 커밋에서 고쳤는지 바로 확인할 수 있도록 하려는 목적이었다.
Babel 없이 동등성을 검사하는 테스트
리뷰 과정에서 테스트 방식도 바꿨다. 처음에는 "Babel 구현을 테스트용으로 남겨 두고 계속 비교하면 되지 않을까"라고 생각했다. Kasper의 제안은 달랐다.
저장소에서 가져온 입력 27개를 고정하고, 마지막 Babel next의 출력을 golden.json으로 저장했다. 인덱스 엔트리, _stories와 _meta, 테스트 이름과 ID, 스코프 바인딩처럼 실제 호출자가 사용하는 값을 기록했다. 비교에는 업데이트가 쉬운 스냅샷 대신 toEqual을 썼다. 실수로 스냅샷을 갱신해 기준까지 바뀌는 일을 막기 위해서다.
csf-tools/__corpus__/에는 이 입력과 기대값을 함께 넣었다. 26개는 Babel과 정확히 일치하고, 나머지 하나(.ts 파일의 제네릭 화살표 함수)는 Babel에서 파싱 에러였던 것이 이제 인덱싱되므로 별도의 기대값을 갖는다.
이제 Babel 구현을 삭제한 뒤에도 OXC를 업그레이드할 때 같은 입력과 기대값으로 비교할 수 있다. 의도한 차이는 golden 파일에 따로 기록하고, 그 밖의 차이는 회귀로 다룬다.
같은 브랜치에서 함께 작업하다
세 번째 라운드가 끝날 무렵 Kasper가 물었다. 남은 후속 작업은 리뷰 스레드로 주고받는 것보다 커밋으로 올리는 게 빠르니, 브랜치에 직접 push해도 되겠냐고. 대신 force-push는 하지 않을 테니 나도 리베이스하지 말아 달라고 했다.
바로 수락했다. 그리고 약속대로 이후에는 리베이스 대신 merge commit으로만 next를 따라갔고, 그 사실도 따로 알렸다. 같은 브랜치를 두 사람이 만지는 상황에서는 코드만큼 히스토리를 다루는 약속도 중요했다. 그 뒤로는 리뷰 지적 사항 마무리, 편집기 구조 정리, 성능 최적화가 같은 브랜치 위에서 이어졌다. 추가로 지원 환경(64비트 Node 22 이상, Windows 제외)에서는 OXC의 raw transfer를 사용해 AST 직렬화 비용을 줄였다. 변경 전후 출력도 비교했다.
10월 20일, 승인 리뷰가 달렸다.
Amazing work, let's go!!
7. 외부 패키지까지 이어진 변경
변경은 csf-tools 안에서 끝나지 않았다. Docs, Vitest, CLI 자동 마이그레이션과 codemod, React, Vue, Angular, Web Components의 docgen, TanStack의 서버 코드 제거, 두 빌더의 mock 추출, 샌드박스 스크립트까지 Babel을 쓰던 모든 곳을 옮겼다. Babel은 사용자 코드를 컴파일하는 곳(@storybook/nextjs, ember)에만 남았다.
공개 API도 바뀌었다. CsfFile, CsfObject, CsfEnricher가 Babel 노드 대신 ESTree 노드를 노출하고, storybook/internal/babel이 사라졌다. 사용자가 체감하는 변화도 있다.
docs.source.originalSource가 Babel이 재생성한 코드가 아니라 작성한 원본 텍스트 그대로가 된다- 자동 마이그레이션 결과에서 건드리지 않은 코드는 포맷이 유지된다
- 에러 메시지가 Babel 코드 프레임 대신
message (line:column)형식이 된다 - Flow 스토리 파일은 더 이상 지원하지 않는다
호환 계층을 두지 않기로 한 만큼, 영향을 받는 쪽에 직접 찾아갔다. storybook/internal/babel이나 CsfFile._ast를 읽던 커뮤니티 패키지 10곳(storybook-rsbuild, msw-storybook-addon, solidjs storybook, react-spectrum 등)에 공개 csf-tools API로 옮기는 PR을 열었다. 이 패키지들의 릴리스 전까지는 해당 샌드박스를 CI에서 잠시 제외하고, sandbox-templates.ts에 PR 링크와 재활성화 조건을 주석으로 남겼다.
내부 API였지만 실제로 이를 사용하는 외부 패키지가 있었다. 코어 변경만 병합한다고 작업이 끝나는 상황은 아니었다.
8. 한 줄짜리 수정은 별도 PR로
작업 중에 OXC와 무관한 버그도 하나 고쳤다. Open Service 런타임은 동기화 요청을 보낸 뒤 응답을 기다리는 타이머를 만드는데, Node.js에서는 이 타이머가 이벤트 루프를 붙잡아 일을 마친 일회성 명령이 타이머가 끝날 때까지 종료되지 않았다.
silenceTimer.unref?.();
unref()를 하면 다른 작업이 프로세스를 유지하는 동안에는 타이머가 그대로 동작하고, 타이머만 남았을 때는 종료를 막지 않는다. 저장소의 file-lock.ts가 heartbeat에 쓰던 패턴과 같다.
처음에는 메인 PR에 들어 있었지만, Kasper의 리뷰에서 "무관한 커밋이니 따로 올려도 된다"는 지적을 받고 PR #36861로 분리했다. 100개가 넘는 커밋 속에 섞여 있던 한 줄짜리 수정은, 따로 떼어 내자 테스트 하나와 함께 하루 만에 병합됐다.
9. 최종 측정 결과
측정 조건: 저장소의 스토리 파일 740개(1.45MB)와 설정 파일 80개, M1 Pro, 각 작업 45회 반복의 중앙값.
| 작업 | Babel | OXC | 개선 |
|---|---|---|---|
| CSF 인덱싱 | 1,086ms | 128ms | 8.5배 |
Docs 소스 처리 (enrichCsf + 소스맵) | 1,183ms | 172ms | 6.9배 |
| Vitest 변환 | 1,192ms | 288ms | 4.1배 |
| 설정 편집 10회 | 138ms | 90ms | 1.5배 |
프로젝트 단위 수치는 다음과 같다(내부 Storybook UI 기준).
| 지표 | 기존 | 변경 후 |
|---|---|---|
storybook index | 1.65초 | 0.84초 |
| 개발 서버 smoke test (중앙값) | 4.76초 | 3.87초 |
| 전체 빌드 | 약 38.9초 | 약 36.0초 |
| npm 비압축 패키지 | 17.75MiB | 15.50MiB (-13%) |
| npm 압축 패키지 | 5.29MiB | 4.89MiB (-8%) |
마이크로벤치마크의 8.5배가 전체 빌드를 8.5배 줄이지는 않는다. 빌드에는 CSF 파싱 말고도 많은 작업이 있다. 그래도 인덱싱 시간은 절반으로 줄었다. 그중 일부는 파서 속도가 아니라 Babel을 아예 로드하지 않게 된 덕분이다. Babel은 프로세스나 워커마다 로드에 약 64ms가 들고, oxc-parser는 약 5ms가 든다.
정합성 측면에서는 이렇다.
- 인덱싱,
enrichCsf,vitestTransform세 작업 모두 Babel 출력과 차이 0건 - 내부 UI
index.json1,766개 엔트리 바이트 단위 동일 - 스코프 분석 99,828개 바인딩 일치
- 48개 프로젝트 컴파일과
check통과, 단위 테스트 14,643개 통과 (로컬 실패 4건은 변경하지 않은 파일의 환경 의존 문제)
10. 작업을 마치고
처음에는 같은 파일을 두 번 파싱하지 않게 만들면 충분하다고 생각했다. 그다음에는 OXC로 빠른 인덱싱 경로를 하나 더 만들면 될 것 같았다. 실제로 병합된 것은 두 번째 인덱서가 아니라, Babel과 Recast에 의존하던 CSF 처리 계층을 교체하는 변경이었다.
가장 오래 붙잡고 있었던 문제도 파서 속도가 아니었다. 삭제한 선언의 주석을 어디까지 지울지, 괄호를 span에 포함해야 하는지, 템플릿 리터럴의 공백을 어떻게 보존할지 같은 것들이었다. 기존 구현에서는 별도 설명 없이 처리되던 동작이었다. 새 구현으로 옮기고 나서야 그것들이 사실상 호환성 요구 사항이라는 걸 알게 됐다.
처음 작성한 편집기는 기존 테스트를 통과했지만 충분히 안전하지 않았다. Kasper의 차등 리뷰가 없었다면 일부 회귀는 그대로 남았을 것이다. 그 뒤로는 같은 입력을 두 구현에 넣어 비교하고, 발견한 문제를 회귀 테스트로 남겼다. Babel을 제거한 뒤에도 비교할 수 있도록 마지막 Babel 출력은 고정된 golden 파일로 보관했다.
Sidnioulz와 Michael Shilman은 구현을 시작하기 전에 목표부터 다시 생각하게 했고, Kasper는 구현의 빈틈을 찾아낸 데서 그치지 않고 마지막에는 같은 브랜치에서 직접 수정에 참여했다. 내가 혼자 준비한 코드가 그대로 병합된 작업은 아니었다.
최종적으로 CSF 인덱싱은 8.5배 빨라졌고 실제 인덱싱 시간은 약 절반으로 줄었다. 하지만 이 작업을 다시 한다면 성능 측정보다 먼저 준비할 것은 기존 동작을 어떤 입력으로, 어디까지 비교할 것인가에 대한 계획일 것 같다. 처음의 작은 최적화가 큰 교체 작업으로 이어지면서 가장 많이 시간을 쓴 곳이 바로 그 부분이었다.