안녕하세요, 로건입니다 👋
1편에서 어댑터 세 개를 그렸다면, 2편은 그걸 들고 실제로 옮긴 이야기예요.
기존 프로젝트에서 25개 commit, OSS repo에서 20개 commit이 나왔어요. 숫자만 보면 평범한 리팩토링 같은데, 사실 이 분리 작업에서 가장 중요했던 결정은 순서였어요. 한 줄로 적자면 — OSS 본체 코드를 만들기 전에, 기존 프로젝트 안에서 먼저 어댑터 모양으로 갈아엎었다.
처음엔 그럴 생각이 없었어요. OSS repo부터 파고, 거기서 본체를 짠 다음, 기존 프로젝트를 그쪽에 갈아끼는 방향이 자연스러워 보였거든요. 그런데 한 번 생각해보니까 그 순서엔 문제가 있더라고요.
순서를 뒤집은 이유
새로 만든 OSS 코드를 기존 프로젝트에 끼우는 순간을 상상해봤어요. 만약 거기서 화면이 한 칸이라도 다르게 보이면 — 원인이 어디일까요? OSS에 버그가 있나, 기존 프로젝트 어댑터가 잘못 묶었나, 둘 다인가? 회귀가 뜨는 순간 디버깅 표면이 기존 프로젝트 + OSS 양쪽으로 동시에 열려요. 그러면 거꾸로 OSS의 어떤 API 모양을 의심해야 할지도 흐릿해지고요.
그래서 순서를 뒤집었어요. OSS 본체는 아직 없는 상태에서, 기존 프로젝트 안에서만 어댑터 인터페이스로 먼저 분리해봅니다. 기존 프로젝트 코드가 자기 자리에서 자기 토큰을 알고 자기 컴포넌트를 알고 있는 채로, 단지 어댑터 모양으로 묶여 있을 뿐인 상태. 이 상태로 Chromatic이 회귀 0건을 내주면, 그건 어댑터 인터페이스가 기존 프로젝트 자산에 자연스럽게 맞는다는 증명이에요.
거기까지 통과하고 나면, 검증된 코드를 OSS 본체로 옮기는 작업은 거의 cp에 가까워져요. OSS는 어댑터를 받기만 하는 입장이라 새로 짜는 로직이 거의 없거든요. 회귀 위험이 기존 프로젝트 내부에 갇혀 있는 동안 가장 위험한 부분을 다 통과시키고, 그 뒤에 OSS로 옮기는 순서. 이게 분리 작업 전체에서 가장 마음 편했던 결정이었어요.
그래서 어떤 순서로 옮겼냐면
기존 프로젝트와 OSS 양쪽이 시간상 겹쳐 돌아갔지만, 기존 프로젝트 쪽 작업을 영역별로 끊어서 보면 흐름이 분명해져요.
가장 먼저 기존 프로젝트 안에 어댑터 폴더를 만들었어요(registries/). 거기에 ComponentRegistry, TokenRegistry, StorageAdapter 타입을 정의해두고, 기존 프로젝트 컴포넌트들을 appComponents.tsx로, 기존 프로젝트 토큰을 appTokens.ts로, 기존 프로젝트 S3 클라이언트를 appS3.ts로 묶었어요. 이때 핵심은 기존 export 표면을 안 건드린다는 점. 기존 프로젝트 caller 입장에서는 같은 경로, 같은 export, 같은 동작. 다만 그 뒤편이 어댑터 모양으로 다시 묶여 있을 뿐이에요. 이 한 commit으로는 Chromatic이 아무 변화도 못 봐야 정상이에요. (그게 통과 조건이었고요.)
ddc6518b feat(visualBuilder): OSS 분리 대비 어댑터 인터페이스 도입
다음으로 Storage를 분리했어요. 1편에서 말한 대로 s3Client.ts가 가장 깨끗했거든요. 환경변수 4개랑 버킷 키 규약 한 줄만 알면 끝나는 영역. OSS의 createS3Storage()를 그 자리에 끼우고, 기존 프로젝트 s3Client.ts 파일은 제거.
ca2ef476 refactor: appS3Storage를 OSS createS3Storage 인스턴스로 교체
45b4da7b refactor: VisualBuilder/FromS3 caller를 appS3Storage 객체 호출로 마이그레이션
a6a42628 refactor: 기존 프로젝트 s3Client.ts 제거 — storage 영역 OSS 완전 위임
기존 프로젝트는 OSS의 VISUAL_BUILDER_* 환경변수 자동 fallback에 의존하지 않고, 기존 STORYBOOK_S3_*를 명시적으로 매핑해서 넘겼어요. 기존 프로젝트의 기존 운영 환경을 흔들지 않으려는 결정이었어요.
export const appS3Storage: StorageAdapter = createS3Storage({
bucket: process.env.STORYBOOK_S3_BUCKET ?? "",
region: process.env.STORYBOOK_S3_REGION ?? "ap-northeast-2",
identityPoolId: process.env.STORYBOOK_COGNITO_POOL_ID ?? "",
publicBaseUrl: process.env.STORYBOOK_S3_PUBLIC_BASE_URL ?? "",
nameRegex: S3_NAME_REGEX,
userRegex: S3_USER_REGEX,
});Storage가 깨끗하게 통과하니까 다른 영역에도 같은 패턴을 그대로 가져갈 수 있다는 자신감이 생겼어요.
그 다음이 Token. tokenOptions.ts를 기존 프로젝트 caller가 여기저기서 직접 가져다 쓰고 있었는데, 그걸 appTokenRegistry라는 단일 진입점으로 모았어요. 본체 토큰 파일은 안 건드리고, OSS가 요구하는 TokenRegistry 모양으로 묶기만 한 거죠.
42f240f8 refactor: tokenOptions 외부 표면 축소 — appTokenRegistry 단일 진입점
이어서 codeGenerator. 기존 프로젝트에 있던 IMPORT_MAP을 직접 참조하던 친구를, registry 주입형으로 바꿔서 OSS로 보내고 기존 프로젝트의 wrapper마저 제거했어요.
17c7b015 refactor: codeGenerator 영역 OSS 완전 위임 + 기존 프로젝트 wrapper 제거
가장 복잡했던 자리는 renderNode였어요. 기존 프로젝트 컴포넌트(AppView, Spacer)를 hard-import하고 있었고, 위에서 묶어둔 매핑들도 직접 참조하고 있었거든요.
이걸 해결하기 위해 두 단계로 진행했어요. 먼저 컴포넌트 특화 분기(AppImage backgroundColor hoist, controlled input noop 핸들러)를 1편에서 말한 transformResolvedProps escape hatch로 옮기고, 그 다음에 Spacer 3종 hard-import를 어댑터의 resolve로 위임. 마지막에 기존 프로젝트의 renderNode.tsx를 통째로 제거하고 OSS의 renderNodeWith(...)로 갈아끼웠어요.
8a57ca2d refactor: renderNode의 컴포넌트 특화 분기를 transformResolvedProps로 이전
c3ecb403 refactor: renderNode의 Spacer 3종 분기 제거 — 어댑터 resolve로 위임
6e455380 refactor: renderNode 영역 OSS 완전 위임 + renderNode.tsx 제거
여기까지 통과하니까 기존 프로젝트는 더 이상 빌더의 렌더 로직을 들고 있지 않은 상태가 됐어요.
그 다음은 대량 이전이었어요. 1편에서 "결합 0인 generic"이라고 분류해뒀던 친구들 — drag, reorder, palette, spacing handle, resize, history — 이걸 한꺼번에 OSS로 넘겼습니다.
068cad51 refactor: hooks/overlays 11개 파일 제거 — react 영역 OSS 완전 위임
9b6ace3e refactor: 기존 프로젝트 알고리즘 6개 + 단위 테스트 50개 제거 — OSS 53 tests로 위임
이 한 개의 commit에서 기존 프로젝트 단위 테스트 50개가 통째로 사라져요. 같은 회귀 신호를 OSS의 단위 테스트(이 시점에 53개, 이후 패널 작성 끝나면 101개로 자람)가 대체해주거든요.
OSS 본체는 그 옆에서 동시에 자랐어요
기존 프로젝트 쪽 작업이 진행되는 동안 OSS repo에서도 코드가 떨어졌어요. 시간상으로는 병행이고, 흐름상으로는 "기존 프로젝트에서 어댑터로 검증된 코드를 OSS로 옮긴다"는 한 줄이에요.
OSS 쪽 commit을 시간순으로 보면 이렇게 생겼어요.
3485a31 chore: initial scaffold (v0.0.0, MIT, sub-entry layout)
ccda746 feat(core): port domain types and adapter interfaces
7f78588 feat(core): port algorithms, geometry, and code generator
a414750 feat(storage): implement memory and S3 adapters
6ba8f33 feat(react): port useNodesHistory and usePanelResizer
de453b1 feat(react): port drag, resize, spacing hooks and overlays
f5a482e feat(react): port renderNodeWith renderer
scaffold만 처음에 빈 골격으로 잡고, 그 뒤로는 기존 프로젝트에서 다듬어진 코드를 옮기는 작업의 연속이에요. tsup으로 ESM/CJS dual build를 굽고, sub-entry를 네 개로 나눴어요(core, react, storage-memory, storage-s3).
작은 결정 하나가 여기서 꽤 중요했는데, 루트 엔트리(@bdmakers/visual-builder)에서 storage-s3만 일부러 빼놓은 거였어요. S3를 안 쓰는 사용자는 AWS SDK가 번들에 안 묶이도록 일부러 갈라둔 거죠. 이 한 칸이 3편에 나올 사고에서 피해 범위를 줄여주는 안전핀이 돼요.
1,700줄짜리 단일 컴포넌트 분해
기존 프로젝트의 VisualBuilder.stories.tsx는 한 파일이 1,700줄이었어요. 빌더의 모든 UI — 팔레트, 캔버스, Properties, Layers, CodePanel — 가 한 곳에 다 들어 있던 거죠. 이걸 OSS의 5개 패널 컴포넌트로 나눠 빼는 게 마지막 산이었어요.
순서를 신중하게 짰어요. 한 번에 하나씩 교체. 그래야 Chromatic이 회귀를 잡았을 때 어디서 깨졌는지가 분명해지거든요. CodePanel → Palette → Layers → Properties → Canvas 순으로, 각 패널이 교체될 때마다 시각 비교를 했어요.
OSS:
e633569 feat(react): add VisualBuilderProvider and useVisualBuilder
e72486b feat(react): add Palette and PaletteDragGhost panel components
1405748 feat(react): add Canvas panel component
42c981c feat(react): add PropertiesPanel panel component
016dd61 feat(react): add Layers panel component
f855390 feat(react): add CodePanel — completes the panel component set
ae5de7b examples: add minimal-react working demo
00f735f test(react): add Provider/history/renderer smoke tests
기존 프로젝트:
d89115a4 refactor: VisualBuilderProvider 도입 — Step 8 패널 분해 사전 작업
a0894f55 refactor: 코드 미리보기 영역을 OSS <CodePanel />로 교체
89d0365a refactor: 팔레트 영역을 OSS <Palette /> + <PaletteDragGhost />로 교체
fc3e59f0 refactor: Layers 영역을 OSS <Layers />로 교체
ed6d872f refactor: Properties 영역을 OSS <PropertiesPanel />로 교체
f0a593de refactor: Canvas 영역을 OSS <Canvas />로 교체 — Step 8 panel 마이그레이션 완료
65fe3061 refactor: 기존 프로젝트 types.ts를 OSS core type re-export로 단순화
마지막 65fe3061이 의외로 의미가 컸어요. 기존 프로젝트 types.ts가 빌더 도메인 모델을 자기 것으로 들고 있었는데, 이걸 OSS core 타입의 re-export로 단순화. 도메인 모델까지 OSS 소유가 된 거예요. 기존 프로젝트는 OSS 타입을 import해서 자기 caller에게 재공급하는 역할만 남았어요.
마지막 정리와 publish
여기까지 통과하니까 publish + 기존 프로젝트 cleanup이 남았어요.
OSS:
75f7cdf release: v0.1.0 — first public preview
e09e4b1 release: v0.1.1 — storage-s3 cognito-only credential import
6ab8e85 examples: add with-s3-storage runnable demo
f8613a6 chore: rename package to @bdmakers/visual-builder
c13ebbc npm publish
기존 프로젝트:
4ad1b0d7 refactor: SSO/INI noop alias 제거 — OSS v0.1.1 fan-out fix로 불요
11e93395 chore: aws-sdk credential-providers umbrella → cognito-identity 단일 패키지
9c4ed150 chore: visual-builder dep을 @bdmakers scope으로 rename
e44c4962 chore: @bdmakers/visual-builder file: dep → registry ^0.1.1
위 commit 메시지 중에 부자연스러운 게 두 개 있어요. 4ad1b0d7의 "SSO/INI noop alias 제거", 9c4ed150의 "rename to @bdmakers scope". 둘 다 publish 직전에 발에 걸린 함정이고, 3편에서 따로 풀게요.
기존 프로젝트는 결국 어떻게 남았느냐
분리 전과 후의 기존 프로젝트 상태를 한 번 같이 놓고 보면 — Playground/visualBuilder/ 라인 수가 8,400에서 3,600으로 줄었어요. 이 줄어든 4,800줄이 라이브러리로 옮겨갔다고 보면 돼요. 알고리즘 파일 6개, 훅·오버레이 11개, 단위 테스트 50개가 기존 프로젝트에서 사라지고 OSS로 옮겨갔어요. 기존 프로젝트에 남은 빌더 자산은 어댑터 wrapper랑 기존 프로젝트 디자인 시스템 카탈로그뿐.
웹팩 alias 8개도 정리됐고(임시로 박았다가 OSS v0.1.1로 풀려서 제거), aws-sdk umbrella dep도 cognito-only 단일 패키지로 교체됐어요. visual-builder 패키지에 대한 의존이 ^0.1.1 한 줄로 깔끔해졌고요.
Chromatic 회귀 0건이 무슨 뜻이었는가
기존 프로젝트 25개 commit이 다 머지된 시점에 Chromatic CI가 모든 합격 스토리에 대해 visual diff를 돌렸어요. 회귀 0건. 처음엔 약간 못 미더웠어요. 이렇게 큰 변경에서 회귀가 진짜 한 건도 없을까. 그런데 굵직한 panel 교체, 알고리즘 위임, storage 위임이 누적된 PR 시퀀스를 처음부터 다시 따라가봐도 — 어디서도 시각 차이가 안 나왔어요.
이 0이 두 가지를 동시에 증명해준 신호였어요. 어댑터 인터페이스가 기존 프로젝트 자산을 누수 없이 표현할 만큼 충분하다는 것. 그리고 OSS 패널들이 픽셀 단위로 기존 프로젝트 inline 구현과 일치한다는 것. 단위 테스트가 잡지 못하는 픽셀 단위 동일성은 시각 회귀 도구만이 잡을 수 있는데, 그 신호가 9단계 내내 살아 있었기 때문에 한 영역씩 과감하게 분리할 수 있었어요.
만약 이 안전망이 없었다면 같은 분리 작업이 두 배는 더 오래 걸렸을 거예요. 마지막 한 주를 풀 회귀에 쓰는 모양으로 끝났을 가능성이 높고요.
그런데 publish 직전에…
이렇게 깔끔하게 통과시켜놓고도, npm publish 직전에 발이 걸렸어요.
AWS SDK 번들이 폭발한 사건. @aws-sdk/credential-providers라는 umbrella 패키지가 미사용 provider까지 다 끌고 들어와서, 기존 프로젝트 webpack에서 node:fs.promises.writeFile을 destructure하려다 런타임이 깨졌어요. 기존 프로젝트에 alias 8개를 임시로 박았다가, 결국 OSS v0.1.1에서 cognito-only 단일 패키지로 import를 바꿔서 기존 프로젝트 alias를 다 걷어냈어요.
3편에서 이 함정의 원인이랑 수습 과정을 풀고, 마지막에 라이브러리를 외부에서 어떻게 쓰는지 — React 웹, RN-Web, RN 본체 — 도 같이 정리할게요.