Logan.

2026-05-20

스토리북에 박혀있던 Visual Builder를 npm으로 분리하기로

기존 프로젝트 안에 8,400 라인이 쌓여 있던 Visual Builder를 외부 라이브러리로 분리하기로 한 이유. 그리고 분리 설계의 한가운데에 있었던 어댑터 인터페이스 세 개 이야기.

by Logan·16분 읽기·part 1 of Visual Builder OSS(Open Source Software) 분리기·#oss #visual-builder #library-extraction #adapter-pattern #react #design-system

안녕하세요, 로건입니다 👋

사내 storybook에서 굴러가던 Visual Builder를 npm으로 분리하기로 했어요. 결과만 먼저 말하면 @bdmakers/visual-builder@0.1.1이라는 회사 OSS로 떨어졌고, 기존 프로젝트에 남은 빌더 관련 코드는 300줄 남짓이 됐어요. 분리하기 전에 기존 프로젝트에 박혀 있던 양이 8,400 줄이었으니까, 8,100 줄 정도가 라이브러리 쪽으로 넘어간 셈이죠.

그 사이에 어댑터 인터페이스 설계 한 번 갈아엎고, AWS SDK 번들이 한 번 폭발하고, npm publish 직전에 패키지 이름이 잘못된 걸 발견해서 부랴부랴 일괄 치환하고… 일이 많았어요. 한 번에 다 풀기엔 길어서 3편으로 나눠 써볼게요. 이번 1편은 왜 분리하기로 했는지, 그리고 분리할 때 가장 오래 그렸던 어댑터 세 개에 대한 이야기예요.

도구가 어쩌다 8,400 줄까지 자랐는가

Visual Builder 자체에 대한 이야기는 예전 글에서 자세히 풀어놨어요. 짧게만 다시 적자면, 기존 프로젝트(RN 앱) storybook 안에서 디자이너·기획자·개발자가 같이 화면을 합성하고, 그 결과를 *.stories.tsx로 떨궈주는 사내 도구였어요. 처음엔 작게 시작했죠. 팔레트에서 컴포넌트 끌어다 캔버스에 놓는 거 정도.

그러다 — 다들 아시잖아요, 이런 도구가 잘 굴러가기 시작하면 — 기능이 자꾸 붙어요. Layers 트리, undo/redo, padding/margin 시각 핸들, resize 핸들, 토큰 기반 props 편집, 그리고 결정적으로 서버 없이 S3에 직접 저장 + 버전 히스토리까지. 어느 날 라인 수를 세보니 Playground/visualBuilder/ 아래가 6,300줄, 그 위에 storybook UI를 묶는 파일이 1,700줄. 단위 테스트 50개 정도가 같이.

이 정도 크기가 되면 도구가 기존 프로젝트의 일부처럼 느껴지기 시작해요. 그게 이상한 거였어요. 도구는 기존 프로젝트의 일부가 아니어야 하거든요.

기존 프로젝트와 슬며시 엉켜 있던 두 불편

엉킨 자리는 사실 처음부터 보였어요. 다만 사이즈가 작을 때는 별로 거슬리지 않았던 거고요. 두 가지였어요.

하나는 도구 코드가 기존 프로젝트의 componentDefs.ts, tokenOptions.ts, s3Client.ts를 직접 들고 있다는 점. 빌더 안에서 토큰 옵션 하나 정리하려고 PR을 올리면, production 파일에 자국이 남아요. 도구 변경이 production 의존 트리에 끼어드는 모양이라서, 리뷰가 매번 한 칸씩 더 무거워졌어요.

다른 하나는 우리만 쓸 수 있는 도구라는 점. 기존 프로젝트 컴포넌트랑 기존 프로젝트 토큰이 도구 코드에 박혀 있었거든요. 곧 시작할 다음에 시작할 신규 프로젝트이 다른 디자인 시스템을 들고 갈 거라는 게 이미 확정된 상태였는데, 그 시점에 빌더를 데려가려면 fork + rebrand가 필요한 모양이었어요. 이건 시간이 지나면 분명히 부딪힐 일이었죠.

두 문제는 사실 같은 문제예요. 빌더가 기존 프로젝트를 알고 있다. 이걸 뒤집어서 기존 프로젝트가 빌더에게 자기 자산을 알려주는 모양으로 바꾸면 둘이 동시에 풀려요. 결합 해체는 결국 의존 방향을 한 번 뒤집는 거더라고요.

어디가 박혀 있는지부터 그려봤어요

뒤집기로 결정하기 전에, 일단 어디가 얼마나 박혀 있는지부터 측정해봤어요. 결합점이 흐릿하면 분리 작업 도중에 끊임없이 "이건 어떻게 해야 하지?"가 튀어나오거든요.

가장 깊게 박힌 곳은 셋이었어요. componentDefs.ts — 기존 프로젝트 컴포넌트 70여 개를 hard-import해서 메타데이터까지 인라인으로 박아둔 친구. 1,700줄짜리. 그 다음이 tokenOptions.ts — spacing, color, typography, ButtonSize, ButtonTone… 기존 프로젝트 토큰을 직접 import해서 옵션 배열로 만들어내는 곳. 마지막이 renderNode.tsxAppViewSpacer 같은 기존 프로젝트 컴포넌트를 직접 import하고, 위의 매핑들도 직접 참조하고 있었어요.

S3 관련 코드(s3Client.ts)는 의외로 깨끗했어요. 환경변수 4개랑 버킷 키 규약 한 줄만 알면 끝. 캡슐화가 잘 돼 있어서 외부화하기 가장 쉬운 영역이었어요.

반대로 결합이 거의 0인 영역도 분명했죠. canvasReorder, treeReorder, paletteDrag, freeFormDrag, spacingHandles, resizeHandles 같은 알고리즘 디렉토리들. 그리고 history/useNodesHistory(undo/redo), usePanelResizer(패널 너비 저장). 이 친구들은 기존 프로젝트를 모르고 자기 일만 하는 generic 코드라, 그냥 옮기기만 하면 됐어요.

표를 그려놓고 보면 결국 외부화의 작업량은 결합점 서너 군데를 인터페이스로 추출하는 일로 좁혀지더라고요. 8,400 줄 중에 진짜 발라내야 하는 부분은 손바닥만 한 영역이었던 거죠. 이 그림이 보이고 나니 분리하기로 결심하는 게 어렵지 않았어요.

어댑터 세 개를 그리는 데 시간을 가장 많이 썼어요

분리 자체는 큰 결정이긴 했는데, 사실 진짜 어려운 건 그 다음이었어요. 빌더 본체가 기존 프로젝트를 모르게 만들려면, 기존 프로젝트가 빌더에게 무엇을 알려줘야 하는지를 결정해야 해요. 그 약속의 모양 — API 계약 — 을 어떻게 그릴 거냐. 한 번 굳히면 깨기 힘드니까 여기서 가장 오래 멈춰 있었어요.

결국 셋으로 모였어요.

컴포넌트 카탈로그(ComponentRegistry). 빌더가 가장 모르는 부분이에요. 기존 프로젝트가 어떤 컴포넌트를 갖고 있고, 각 컴포넌트가 어떤 props를 받고, 코드 export할 때 어디서 import해야 하는지 — 이걸 한 묶음으로 받습니다.

interface ComponentRegistry {
 defs: ComponentDef[]; // 팔레트가 보는 카탈로그
 resolve: (type: string) => ComponentType; // 캔버스 렌더용 실제 컴포넌트
 imports: Record<string, ImportEntry>; // 코드 export 시 import 라인
 noOnPressTypes?: Set<string>;
 transformResolvedProps?: (...); // 디자인 시스템 특화 hook
}

마지막 transformResolvedProps는 escape hatch예요. 일반 흐름으로 처리할 수 없는, 디자인 시스템마다 다른 특수한 props 변환이 필요할 때만 쓰는 자리. 이걸 따로 빼야 했던 이유는 잠시 뒤에.

디자인 토큰(TokenRegistry). spacing.s4 같은 문자열을 실제 값(4 또는 "4px")으로 바꾸는 함수랑, Properties 패널에서 select 옵션으로 보여줄 그룹들을 받습니다. 기존 프로젝트는 spacing/color/typography에 더해 ButtonSize, ButtonTone 같은 디자인 시스템 고유 그룹이 더 있었는데, 그건 optionGroups.custom이라는 자유로운 record로 받게 만들었어요. 토큰이 아예 없는 사용자는 이걸 안 넘기면 raw value가 그냥 통과해요.

저장소(StorageAdapter). listDesigns, loadDesign, saveDesign, 그리고 버전 히스토리. 빌더는 어디에 저장하는지 몰라요. 기존 프로젝트는 S3 + Cognito 어댑터를 만들어 넘기고, 데모용은 메모리 어댑터를 넘기는 식.

이렇게 세 개를 그려놓고 나니, 빌더 본체에 박혀 있던 기존 프로젝트 import들이 어디로 빠져나갈지가 한 번에 보였어요. componentDefs.ts가 들고 있던 책임은 ComponentRegistry 하나로 옮겨가고, tokenOptions.tsTokenRegistry로, s3Client.tsStorageAdapter로. 그 세 자리만 사용자가 채워주면 빌더는 어디서든 동일하게 돕니다.

escape hatch를 따로 둔 이유

어댑터 설계에서 가장 오래 멈춘 자리가 transformResolvedProps 한 칸이었어요. 기존 프로젝트에 일반 흐름으로 안 풀리는 케이스가 두 종류 있었거든요.

기존 프로젝트의 AppImagebackgroundColor를 root prop으로 안 받아요. 받으려면 style.backgroundColor로 넘겨야 하는데, 빌더 Properties 패널은 backgroundColor를 평범한 props 자리에 노출하고 있단 말이에요. 그래서 렌더 직전에 prop을 hoist해줘야 해요.

또 기존 프로젝트 14종의 controlled-input 컴포넌트는 핸들러가 undefined면 안에서 throw해요. 빌더 환경에서는 디자이너가 핸들러를 등록할 방법이 없으니까, 렌더 직전에 누락된 핸들러에 noop을 넣어줘야 throw가 안 나요.

이걸 OSS 본체에 박을지를 한참 고민했어요. 박으면 코드는 깔끔해지는데, 그 순간 OSS가 우리 회사 디자인 시스템 모양에 가까워져요. 다른 사용자가 받아서 깔면 모르는 분기가 켜져 있는 상태가 되는 거예요. 그게 OSS답지 않더라고요.

그래서 그냥 한 칸을 열어두기로 했어요. 빌더 일반 흐름이 다 끝난 다음, 사용자가 원하면 호출되는 hook. 안 넘기면 그냥 통과. 기존 프로젝트 어댑터에서는 그 hook을 채워서 위 두 케이스를 처리해요.

function transformResolvedProps(type, resolvedProps) {
 if (type === "AppImage" && resolvedProps.backgroundColor != null) {
 return {
 ...resolvedProps,
 style: {
 ...(resolvedProps.style as object),
 backgroundColor: resolvedProps.backgroundColor,
 },
 backgroundColor: undefined,
 };
 }
 if (CONTROLLED_INPUT_TYPES.has(type)) {
 return injectNoopHandlers(type, resolvedProps);
 }
 return resolvedProps;
}

OSS 본체는 일반 흐름만 알아요. 특이한 분기는 사용자 자리에서만 켜져요. 디자인 시스템마다 비슷한 escape hatch가 한두 개씩 필요할 텐데, 그게 OSS 코드를 점점 뚱뚱하게 만들지 않도록 입구를 분명히 정해둔 거예요.

회사 OSS냐, 개인 OSS냐

마지막 결정이 하나 남아 있었어요. 분리하는 건 결정했는데, 어디로 분리할 거냐. 개인 npm scope으로 갈지, 회사 OSS로 갈지.

이게 단순한 결정이 아니에요. MIT 라이선스로 풀면 코드 자체는 누구나 fork할 수 있긴 한데, 패키지 이름은 그렇지 않거든요. 회사 scope으로 publish하면 그 이름은 영구히 회사 자산이에요. 회사를 떠나면 publish 권한을 잃고, 그 이름으로 새 버전을 못 올려요. 코드는 들고 갈 수 있어도 이름은 못 들고 가요.

이번 케이스에선 회사 쪽이 자연스러웠어요. 회사가 이미 @bdmakers/* scope으로 다른 OSS를 운영 중이었고(이미 같은 scope에 다른 회사 OSS 패키지 몇 개가 publish 돼 있었거든요), OSS화 자체에 명시적으로 동의가 된 상태였거든요. 사내 OSS 카탈로그에 묶이면 발견성도 좋고, namespace도 통일되고.

라이선스는 MIT, GitHub repo는 bd-makers/visual-builder로 새로 파기로 했어요. (참고로 GitHub은 bd-makers(하이픈 있음)이고 npm은 bdmakers(하이픈 없음)인데, 이 작은 차이가 publish 직전에 발을 한 번 헛디디게 만들어요. 그건 3편에서.)

결국 1편의 핵심은

여기까지가 1편이에요. 코드는 거의 안 다뤘죠. 사실 분리 작업에서 진짜 중요했던 건 코드보다 이 두 가지였어요.

기존 프로젝트에 어디가 얼마나 박혀 있는지를 한 번 그려본 것. 그리고 그걸 풀어낼 어댑터 세 개를 — 특히 transformResolvedProps 같은 escape hatch를 어디에 둘지 — 신중하게 결정한 것. 한 번 굳히면 깨기 어려운 부분이라, 코드 작업보다 여기에 시간을 더 썼어요.

다음 편은 실제로 어떻게 옮겼는지에 대한 이야기예요. 처음엔 OSS 본체부터 만들려고 했는데, 결국 기존 프로젝트 안에서 어댑터 모양으로 먼저 갈아엎고 그 다음에 OSS로 옮기는 순서를 택했어요. 이 순서가 회귀 위험을 기존 프로젝트 안에 가둬두는 안전망이 됐고, Chromatic 시각 회귀 0건이라는 신호로 이어졌어요. 기존 프로젝트 25개 PR이랑 OSS 20개 commit을 한 줄로 풀어볼게요.

related — in series · Visual Builder OSS(Open Source Software) 분리기