안녕하세요, 로건입니다 👋
여러분, 한번 상상해보세요.
디자이너가 Figma에서 그린 화면을 코드로 옮기는데, 토큰이 어긋나고, 컴포넌트 prop은 누락되고, 분명히 디자인 시스템 안에서만 합성하라고 했는데 어디선가 자유 layout이 새어 들어옵니다. 이 어긋남이 매번 PR 리뷰의 절반을 잡아먹는다면 — 그리고 그걸 풀려고 Plasmic 같은 SaaS를 알아봤더니 RN을 안 받는다면 — 어떻게 하시겠어요?
저는 만들기로 했어요. Storybook 안에. 디자이너가 production 컴포넌트로 직접 화면을 합성하는 시각 도구를. 카탈로그 안에서만 움직이고, 결과는 JSON으로만 떨어지고, production 코드는 한 줄도 건드리지 않는 빌더를요. 이 글은 그 빌더를 어떻게 만들었는지, 한 번 갈아탔던 길과 결국 정착한 모양, 그리고 막 도입을 시작한 지금 시점에서 어떻게 쓰일 거라고 기대하는지에 대한 창작 기록입니다.
Storybook으로도 못 풀던 한 칸
Storybook은 컴포넌트가 "올바르게 동작하는지"를 보여주는 무대로는 거의 완벽해요. controls로 props도 만지고, play 함수로 인터랙션도 검증하죠. 그런데 무대 위에서 디자이너가 직접 "여러 컴포넌트를 조립해서 화면 한 장을 그리는" 일은 못 합니다. controls는 컴포넌트 1개에 묶여 있으니까요.
그래서 디자이너는 다시 Figma로 돌아갑니다. 거기서 그린 화면은 production 컴포넌트와 1:1로 어긋나고, 개발자가 그 격차를 매번 손으로 메꿔요. 저는 이 한 칸이 늘 걸렸어요. Storybook을 작업 도구로 한 단계 끌어올리고 싶었습니다. 컴포넌트 카탈로그 안에서만 합성하게 만들면, 디자인-코드 어긋남이라는 문제 자체가 사라지니까요.
| 도구 | 다루는 단위 | 디자이너 자력 사용 | 영구 저장 | 다중 컴포넌트 합성 | RN 지원 |
|---|---|---|---|---|---|
| Storybook controls | 컴포넌트 1개 props | ✅ | ❌ | ❌ | ✅ |
| Plasmic / Builder.io 등 SaaS | 페이지 트리 | ✅ | ✅ | ✅ | ❌ |
| 사내 Visual Builder | 페이지 트리 | ✅ | ✅ | ✅ (사내 카탈로그) | ✅ |

화면을 한 컷으로 보면 위 모양입니다. 좌측에 사용 가능한 컴포넌트가 카탈로그로 정렬되어 있고, 가운데가 캔버스(Preview) 또는 생성된 코드(Code) 탭으로 토글되며, 우측에 선택된 노드의 properties — layout, padding, margin, style — 가 펼쳐집니다. 모든 입력은 토큰 시스템 enum과 정합하도록 묶여 있어요.
첫 시도 — 보조 서버, 그리고 폐기
첫 발상은 단순했어요. dev 모드에서 Storybook 옆에 작은 Express 보조 서버를 같이 띄우고, 빌더가 POST /save로 컴포넌트 소스를 던지면 그걸 Playground/Generated/<Name>/ 폴더에 .tsx로 떨어뜨리는 방식. HMR이 바로 잡아채니까 사이드바에 즉시 새 스토리가 등장하죠. 데모로는 멋졌습니다.
그런데 세 개의 벽에 부딪혔어요.
| 항목 | 보조 서버 방식 | 결과 |
|---|---|---|
| dev 모드 | OK (HMR 즉시 반영) | — |
| 정적 빌드 / Chromatic | 동작 ✗ | 치명 |
| 디자이너 PC 요구사항 | Node + yarn + repo clone | 도입 장벽 ↑ |
| production 코드 영역 침범 | 파일 직접 작성 | 격리 위배 (결정타) |
세 번째 — 그리고 가장 중요했던 — 항목이 결정타였습니다. 빌더가 production 코드 영역에 파일을 떨어뜨립니다. 격리 원칙 위배. 디자이너가 의도치 않게 production 디렉토리를 건드릴 가능성이 0이 아닌 구조였어요. 5편의 "인프라를 고치지 마라, blast radius를 좁혀라" 원칙을, 빌더 자체가 위반하고 있었던 거죠. 이건 못 갑니다.
두 번째 시도 — S3 + JSON 정적 런타임
방향을 바꿨습니다. 코드를 떨어뜨리지 말자. JSON만 떨어뜨리자.
빌더가 만드는 결과물은 컴포넌트 트리를 그대로 직렬화한 CanvasNode[] JSON 한 종류뿐. production 디렉토리는 손도 안 댑니다. 저장처는 사내 S3 버킷 하나. 인증은 Identity Pool의 Unauth로 임시 토큰만 받아 브라우저에서 직접 PutObject / GetObject. 영구 키 0개. 그리고 결정적으로 — 정적 빌드에서도 동작합니다. Chromatic publish된 storybook URL에서도 똑같이 fetch해서 렌더할 수 있어요. 인프라가 서버 0개예요.
두 시도를 한 표에 놓으면 차이가 분명해져요.
| 차원 | 보조 서버 | S3 정적 런타임 (현재) |
|---|---|---|
| 정적 빌드 동작 | ✗ | ✅ |
| Chromatic 동작 | ✗ | ✅ |
| 서버 구성 | Express + concurrently | 0 (Identity Pool + bucket) |
| 디자이너 PC 요구사항 | Node + repo clone | 브라우저만 |
| production 코드 격리 | 깨짐 | 보장 |
| 변경 이력 | 별도 git push | S3 versioning 자동 |
| 영구 키 | 운영자 PAT 필요 | 임시 토큰만 |
보조 서버 방식이 해결하려던 모든 걸 해결하면서, 격리 원칙은 그대로 지킵니다. 피벗이 성공적이었다는 평가는 사실 사후 평가예요. 그때는 1주일 동안 "이게 정말 답인가?" 자문하면서 갈아탔습니다.
S3에 저장된 design은 From S3 스토리에서 dropdown으로 즉시 골라 렌더할 수 있어요. 다른 디자이너가 만든 화면을 코드 한 줄 안 만지고 그대로 미리보기 할 수 있습니다.

박아둔 미시 규칙들
빌더의 코드 생성기와 데이터 모델에는 작아 보이지만 빠지면 곧장 깨지는 규칙이 잔뜩 박혀 있어요. 몇 개만 풀어볼게요.
| 케이스 | 빌더 동작 | 이유 |
|---|---|---|
| 트리에 루트 노드가 2개 이상 | 코드 생성 시 자동으로 컨테이너로 wrap | JSX는 단일 루트 강제 |
<Image> 같은 void element에 children 추가 시도 | 차단 + onPress도 leaf에서 가드 | RN/React 안전성 |
| 핸들러 prop 비움 | 기본 noop placeholder 자동 주입 | 컴파일 깨짐 방지 |
| 라벨 ≠ 실제 컴포넌트 식별자 | 강제로 동일 | 코드의 import 재현 가능 |
| 절대 좌표 free-form 배치 | 비활성 (롤백) | 캔버스↔코드 1:1 깨짐 |
마지막 줄이 특히 아팠어요. 절대 좌표 배치를 한 번 시도했다가 롤백했거든요. 캔버스에서 디자이너가 자유롭게 픽셀 단위로 끄집어다 놓는 UX를 만들었는데, 결과로 떨어지는 코드(flex 기반)와 캔버스의 모양이 1:1로 일치하지 않더라고요. "production-fidelity"가 깨지는 순간 빌더의 신뢰가 무너진다는 걸 경험으로 배웠어요. 데이터 필드(x, y)와 일부 hook 코드는 backward-compat을 위해 보존만 해뒀습니다.
데이터 모델의 핵심은 이게 전부예요.
interface CanvasNode {
id: string;
type: string; // "View" | "Text" | "Button" 등
props: Record<string, unknown>;
children: CanvasNode[];
textContent?: string;
hidden?: boolean; // 캔버스/코드 모두 skip
locked?: boolean; // 드래그 차단
}디자이너가 직접 쓰는 도구의 디테일
도구를 디자이너가 직접 쓴다는 건 단순히 "GUI를 얹는다"가 아니에요. 마우스가 닿는 모든 지점이 토큰 시스템과 정합해야 합니다.
그래서 캔버스에 색깔로 구분된 시각 핸들을 깔았어요.
| 색 | 대상 | 동작 | snap |
|---|---|---|---|
| 🟦 파랑 | padding 4면 + gap | 안쪽 드래그 = 증가 | 가장 가까운 spacing 토큰 |
| 🟪 보라 | margin 4면 | 안쪽 드래그 = 증가 | 가장 가까운 spacing 토큰 |
| 🟧 오렌지 | width / height (corner) | 자유 드래그 | 8~1000px clamp |
핵심은 snap이에요. 1px 단위로 자유 드래그하다가 마우스를 떼는 순간, 가장 가까운 spacing 토큰으로 자동으로 흡수됩니다. 18px에서 손을 떼면 spacing.s16이 박혀요. 디자이너가 토큰 enum을 외울 필요가 없습니다. 토큰 시스템이 손가락 끝에서 자동으로 작동하는 거죠.

device frame 안에 실제 화면이 그대로 렌더되고, 선택된 노드 주변으로 색깔로 구분된 핸들이 떠요. 캔버스에서 핸들을 잡고 끌면 1px 단위로 자유롭게 움직이다가, 마우스를 떼는 순간 가장 가까운 spacing 토큰으로 흡수됩니다.
캔버스에서 컴포넌트를 그대로 잡고 끌면 ghost가 마우스를 따라오며 flex 트리 안에서 위치가 재배치되고, Layers 패널에서도 동일한 reorder가 일어납니다. 단축키도 당연히 다 있어요.
| 단축키 | 동작 |
|---|---|
Cmd/Ctrl + Z | Undo (depth 50, 300ms burst 묶음) |
Cmd/Ctrl + Shift + Z | Redo |
Esc | 드래그 baseline 복원 |
그리고 새로고침해도 죽지 않도록 localStorage에 draft가 자동 저장됩니다. 디자이너가 점심 먹고 와도 작업이 살아 있어요. 변경 메시지 한 줄을 적어 저장하면 그게 그대로 버전 이력에 박혀, "이 버전은 왜 만들었지" 가 사라지지 않습니다.
서버 없는 영구 보존 — 스토리지 설계
저장 백엔드의 핵심 결정 두 개를 말씀드릴게요.
첫째, 인증은 Identity Pool Unauth. 빌드 산출물에 영구 키를 박지 않습니다. 디자이너 브라우저가 임시 토큰을 받아 designs/* prefix 한정으로 S3에 직접 PUT/GET해요. IAM에는 s3:PutObjectAcl과 s3:DeleteObjectVersion을 Deny로 박아 — bucket policy 우회나 이력 영구 삭제를 코드가 아닌 권한 레벨에서 막아둡니다.
{
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::<bucket>/designs/*"
},
{
"Effect": "Allow",
"Action": ["s3:ListBucketVersions", "s3:GetObjectVersion"],
"Resource": ["arn:aws:s3:::<bucket>", "arn:aws:s3:::<bucket>/designs/*"]
},
{
"Effect": "Deny",
"Action": ["s3:PutObjectAcl", "s3:DeleteObjectVersion"],
"Resource": "*"
}
]
}둘째, 버전 관리는 S3 자체 versioning에 위임. 같은 키에 PUT할 때마다 새 VersionId가 자동 부여되고 이전 객체는 영구 보존됩니다. 빌더의 "이력" 버튼은 ListObjectVersions + 병렬 GetObjectVersion으로 최근 20개 버전의 변경 메시지를 lazy load해서 모달에 띄워요. AWS 콘솔 권한이 없는 디자이너도 빌더 안에서 직접 미리보고 복원할 수 있어요. git의 90% 가치를 git 없이 얻은 셈입니다.
키 규약은 단순해요.
<bucket>/
└── designs/
├── index.json
└── <user>/<Name>.json (+ 무한 VersionId)
└─ user matches ^[a-z][a-z0-9_-]{0,31}$
└─ Name matches ^[A-Z][A-Za-z0-9]+$ (path-traversal 차단)
변경 이력은 빌더 안에서 바로 펼쳐서 미리보고 복원할 수 있어요. AWS 콘솔 권한이 없는 디자이너도 git의 90% 가치를 빌더 UI 안에서 그대로 얻을 수 있습니다.

소비 측인 From S3 스토리는 이렇게 동작합니다.
function FromS3Story() {
const [design, setDesign] = useState<DesignPayload | null>(null);
const [key, setKey] = useState("logan/LoginPage");
useEffect(() => {
let cancelled = false;
loadDesign(key).then((d) => {
if (!cancelled) setDesign(d);
});
return () => {
cancelled = true;
};
}, [key]);
if (!design) return <Loading />;
return (
<DeviceFrame data-testid="design-loaded">
{design.nodes.map(renderNode)}
</DeviceFrame>
);
}지금부터 — 도입 직후의 그림
빌더는 막 검증을 끝내고 도입을 시작하는 단계예요. 회고가 아니라 출발선의 풍경입니다. 지금부터 기대하는 그림 몇 가지를 적어둘게요.
디자이너 베타 합류. 두세 명에게 user slug를 발급하고, 같은 컴포넌트 카탈로그 위에서 페이지를 합성하기 시작합니다. PR 리뷰에서 "이건 토큰이 다른데요" 가 사라지는지 측정할 거예요. 이게 가장 빠르게 확인하고 싶은 가설입니다.
다음 분기 신규 프로젝트의 사전 검증 도구화. 기획-디자인-개발이 같은 무대에서 화면을 미리 합성해보고, 그 결과 JSON이 곧 명세가 됩니다. "문서가 곧 산출물" 이라는 그림이 자연스럽게 만들어질 수 있을 거예요.
백엔드 추상화. 지금은 S3 하나지만, DesignBackend 인터페이스 + 어댑터 N개 구조로 빠질 수 있게 설계해뒀어요. 사내 정책이 GitLab 쪽을 요구하는 순간 어댑터만 갈아끼우면 됩니다. 도구가 인프라에 묶이지 않도록 처음부터 한 칸 비워둔 셈이에요.
이력 UX 고도화. 두 버전 간 JSON tree diff를 색칠해서, 디자인 변경의 흐름 자체를 시간축으로 읽을 수 있게요. 디자이너가 "이 화면의 두 달 전 모습" 을 한 클릭으로 비교할 수 있게 되면, 디자인 의사결정의 경로가 보이기 시작할 거예요.
카탈로그 → 사내 위키 자동 동기화. design 메타데이터(태그·설명·썸네일)를 붙여 사내 문서와 양방향으로 묶는 그림. 빌더가 디자인 시스템 카탈로그의 진짜 입구가 될 수 있다고 보고 있어요.
흔하지 않은 길이라는 걸 압니다
외부 SaaS를 안 쓰고, page builder를 사내에서 만들고, 그걸 Storybook 안에 박는 회사는 많지 않아요. 그런데 RN 앱 + 사내 토큰 시스템 + 외부 SaaS 의존 회피라는 조건에서는 이 길이 정확한 fit이었어요. 도구를 만든다는 결정은 대안이 없을 때가 아니라, 대안이 우리에게 안 맞을 때 내리는 결정이라는 걸 이번에 다시 배웠습니다.
합성 도구 한 칸이 채워졌으니, 이제 그 위에서 디자인이 만들어지는 모습을 한번 지켜보려고 합니다. 이게 어떤 무대로 자라는지, 혹은 어떤 한계에 부딪힐지는, 몇 달 뒤에 다시 적어볼 글의 재료가 되겠죠.
다음 편이 시리즈의 마지막입니다. 여기까지 도구를 정착시키면서 손에 남은 도입 과정의 그림과, 앞으로 활용에 대한 기대를 정직하게 정리해볼게요.