모노레포 도입한다고요? 그럼 이때가 기회다 — 디자인 시스템 리뉴얼 (왜 npm이 아니라 서브모듈인가)
디자인 시스템 시리즈 세 번째 글. 어느 날 “프론트엔드 모노레포를 도입한다”는 얘기가 나왔다. 만든 건 팀 동료지, 내가 아니다. 근데 그 소식을 듣자마자 든 생각은 하나였다 — “이때다. 늘 채택률 0%로 방치되던 디자인 시스템, 이참에 제대로 리뉴얼하자.” 그렇게 동료의 모노레포를 그릇 삼아 디자인 시스템을 붙이면서 결정을 하나 했다: npm 패키지로 배포하지 않고 git 서브모듈로 가져오기. 이 글은 그 리뉴얼과 판단의 기록이다.
늘 채택률 0%였던 디자인 시스템
배경부터. 우리 프론트엔드는 오래 파편화돼 있었다. 같은 상담신청 화면이 웹·차량상세·커뮤니티·결제에 각각 따로 구현돼 있어서, 버튼 색 하나 바꾸려면 레포 4~6개를 고쳐야 했다. 스타일링은 한 레포에서 tailwind(EOL된 2.x)+emotion+styled-components+MUI5+material-ui4+antd+sass를 7종 동시로 쓰는 지경이었고, “우리 브랜드 회색”의 정답이 코드에 없었다.
정작 design-system 레포는 이제 막 씨앗만 심긴 상태였다. 예전 컴포넌트 라이브러리 레포는 무겁고 메모리도 새서 걷어냈고, 내가 새로 판 이 레포엔 Figma(Tokens Studio)에서 뽑은 tokens.json만 덩그러니 넣어둔 참이었다. 빌드 스크립트도 config도 없어서 산출물로 변환되지도, 배포되지도, 어느 레포도 import하지 않았다. 방향은 맞는데 채택률 0%.
가장 상징적인 사고가 이거였다. 토큰 최신본이 레포가 아니라 한 사람 노트북에만 있었다.
신규 웹 의 design-tokens.json syncedAt 2026-08-03
design-system 레포의 tokens.json 마지막 변경 2026-07-09
→ 값 5개가 서로 다르다
동기화 스크립트의 기본 경로가 개인 로컬 경로로 박혀 있어서, Figma → 로컬 클론 → 신규 웹 로 흐르고 정작 공용 레포를 거치지 않았다. 사본이 낡는 건 시간 문제였다.
동료가 만든 그릇 — 서브모듈 모노레포
이 상황에서 동료가 myapp-frontend 모노레포를 세웠다. 핵심은 레포들을 합치지 않고 git 서브모듈로 붙였다는 점이다.
myapp-frontend/ ← 동료가 세운 그릇
├─ apps/
│ ├─ web/ (이 레포가 소유) Next.js
│ ├─ mobile/ (이 레포가 소유) Flutter
│ ├─ 레거시 웹/ ┐
│ ├─ car-detail/ │ 전부 git 서브모듈
│ ├─ community/ │ (각자 원래 레포 그대로)
│ ├─ payment/ │
│ └─ request/ ┘
└─ packages/
├─ design-system/ ← 서브모듈 (myorg/design-system)
├─ boot/ ← 서브모듈
└─ bridge/ (이 레포가 소유, 웹뷰 브리지 계약)
.gitmodules를 보면 apps/car-detail·community·payment·request와 packages/design-system·boot가 전부 서브모듈이다. 앱 레포는 그대로 살아 있고 배포도 각자 레포에서 나간다. 이 그릇이 하는 일은 앱을 빌드/배포하는 게 아니라 웹·앱의 브리지 계약이 어긋났는지 교차 검사하고, 디자인 토큰을 한 지점에 연결하는 것이다.
나한테 이건 기회였다. 흩어져서 불가능하던 “디자인 시스템을 한 곳에서 모두가 소비” 를 드디어 걸 수 있는 자리가 생긴 거다.
내가 한 일 — 토큰만 있던 레포에 파이프라인을 붙이다
design-system 서브모듈에 codegen 파이프라인을 붙였다. 덤프였던 tokens.json을 실제 플랫폼 산출물로 굽는다.
tokens.json → codegen.mjs → web/src/generated/tokens.generated.json (중첩, createGlobalTheme용)
web/src/generated/tokens.generated.ts (평평)
mobile/lib/generated/tokens.g.dart (평평, Color / double)
루트에서 한 방에 돈다.
// myapp-frontend/package.json (발췌)
{
"workspaces": ["packages/bridge", "packages/boot", "packages/design-system/web", "apps/web"],
"scripts": {
"sub:init": "git submodule update --init --recursive",
"codegen": "yarn workspaces foreach -A --topological run codegen && yarn tokens:codegen",
"tokens:codegen": "node packages/design-system/codegen.mjs"
}
}
리뷰(PR 3개)에서 실제 결함도 두 건 잡았다. 하나는 알파 색 사고다. CSS는 #RRGGBBAA인데 Dart Color는 0xAARRGGBB로 알파 위치가 반대다. 8자리를 그대로 넘기니 알파가 R 자리에 들어가 색이 바뀌었다.
colorSemanticStateOverlayModal #181818b3
전 Color(0x181818B3) 불투명도 9% 파란색 ← 틀림
후 Color(0xB3181818) 불투명도 70% 검정 ← 맞음
다른 하나는 참조가 깨져도 codegen이 exit 0이던 것(색 155개→154개가 되어도 통과). 생성물을 쓰기 전에 중단하도록 고쳤다.
결정 — 왜 npm 패키지가 아니라 서브모듈인가
여기가 이 글의 본론이다. 디자인 시스템을 각 앱에 나눠주는 방법은 크게 둘이었다.
A. npm/pub에 게시한다. @myorg/design-system을 레지스트리에 올리고, 앱들은 버전을 명시해 설치한다. 라이브러리의 정석이다.
B. git 서브모듈로 붙이고, codegen 산출물을 각 앱 레포에 커밋한다.
나는 B를 골랐다. 이유는 세 겹이다.
① 게시(publish)는 이 팀에 손해였다
npm/pub 게시를 안 한 이유는 명확했다.
- 코드가 비공개다. 게다가 브리지 패키지를 레지스트리에 올리면 내부 명령 이름 전체(브리지 계약)가 그대로 노출된다. 사설 레지스트리를 세워 막을 수도 있지만, 그 인프라를 운영할 여력이 없었다.
- 게시 사이클의 오버헤드. 토큰은 Figma가 바뀔 때마다 흔들린다. 그때마다 build → version bump → publish → 앱마다
yarn add로 올리는 왕복은, 그 자체가 “사본이 낡는” 지금 문제의 느린 버전일 뿐이다.
그래서 web/package.json은 private: true, mobile/pubspec.yaml은 publish_to: none이다. 대신 codegen 산출물을 각 앱 레포에 커밋한다. 신규 웹가 이미 src/shared/styles/design-tokens.json을 Auto-generated … do not edit by hand 헤더와 함께 이 방식으로 쓰고 있었고, 그 파일과 경로 428개·값이 전부 일치했다. 바꾼 건 “출처” 하나였다 — 개인 노트북이 아니라 서브모듈이 진실의 원천이 된다.
② 서브모듈은 위험이 0이고 되돌릴 수 있다
그럼 왜 아예 앱까지 모노레포로 편입(ingestion) 하지 않고 서브모듈에 머물렀나. 지금 당장 필요한 건 하나였다 — “웹과 앱의 브리지 계약이 어긋났는지 확인”. 그건 서브모듈로도 된다. 소스를 SHA로 핀 채 --recurse-submodules로 체크아웃해서 파일을 직접 읽으면, drift 검사도 codegen도 그대로 돈다.
반대로 앱들을 yarn 워크스페이스로 묶으면 연쇄가 시작된다.
앱을 워크스페이스로 묶는다
→ 앱별 yarn.lock·.yarnrc.yml 이 루트로 인식돼 지워야 한다
→ lockfile 을 지우니 의존성이 최신으로 올라가 6개 중 3개 빌드 실패
→ 직접 의존성 372개를 고정해야 한다
→ 서브모듈 7개에 파일 40개가 영구 dirty → 커밋에 섞인다
→ skip-worktree → pull 이 막힌다 → 동기화 스크립트가 필요하다
실측으로 비교해도 답은 명확했다.
apps/*까지 묶음 | packages/*만 (택함) | |
|---|---|---|
| 서브모듈 상태 | 파일 40개 dirty | 7개 전부 clean |
node_modules | 3.4GB | 203MB |
| 필요한 스크립트 | bootstrap + sync | 없음 |
| drift 검사 / codegen | ✓ | ✓ 그대로 |
그래서 루트 workspaces에는 apps/web(이 레포가 소유)과 packages/*만 넣고, 서브모듈 앱들은 워크스페이스에 넣지 않았다. 게다가 그 앱들이 @myorg/*를 import하는 파일은 지금 0개다. 묶어서 당장 얻는 게 없었다.
③ 서브모듈로 “안 되는 것”도 정직하게 인정한다
서브모듈이 공짜는 아니다. “PR 하나로 웹과 앱을 같이 고친다”는 안 된다. 서브모듈에서는 앱마다 따로 PR이고, 거기에 부모 레포에서 포인터를 올리는 PR이 하나 더 붙는다. 원자적 변경은 완전 편입해야 얻는다.
하지만 지금 필요한 건 원자적 동시 수정이 아니라 교차 검사였다. 필요를 초과하는 통합은 비용만 앞당길 뿐이다. 그래서 “서브모듈로 시작하고, 편입은 필요해지면” 으로 정했다.
결정 요약: 게시(npm/pub) ✗ → 서브모듈 + 생성물 커밋 ✓. 지금 필요한 건 원천 단일화와 교차 검사지, 버전드 아티팩트 배포가 아니다. 위험 0, 되돌리기 자유, 그리고 언제든 편입으로 승격 가능.
앱은 이렇게 소비한다
앱 코드는 이제 사본도, 각자 구현한 버튼도 없이 한 곳에서 가져온다.
// 디자인 토큰을 bind한 전역 테마 (vanilla-extract, zero-runtime)
import { createGlobalTheme } from '@vanilla-extract/css';
import { designTokens } from './designTokens'; // tokens.generated.json 기반
export const vars = createGlobalTheme(':root', {
color: { primary: designTokens.semantic.action.primary /* ... */ },
});
// 버튼은 한 곳에서 온다. frame·shape 조합만 고르면 된다.
import DCButton from '@myorg/design-system/components/buttons/button';
<DCButton frame="solid" onClick={submit}>상담 신청</DCButton>;
소비 지점은 apps/web의 shared/DesignSystem으로 모아, 상위 어느 화면에서 갖다 써도 “이 버튼 어디서 왔지”가 항상 한 곳으로 내려가게 했다.
지금 어디까지 왔고, 다음은
- 개인 노트북에만 있던 토큰 원천 → 서브모듈이 단일 진실. 사본 drift가 구조적으로 막혔다.
- 채택률 0%였던 디자인 시스템을
apps/web이 실제로 소비. 씨앗만 있던 레포가 그릇의 중심이 됐다. - codegen으로
tokens.json→ TS·Dart 산출물이 결정적으로 생성되고, 각 앱 레포에 커밋되는 방식으로 배포 오버헤드 없이 전파된다.
아직 끝은 아니다. 사본이 6벌로 갈라져 있어서 계약을 넓히고 → 트래픽 작은 앱부터(car-detail → request → payment → community) → 레거시 웹·web은 마지막에, 순으로 점진 통합 중이다. 완전 편입(원자적 웹+앱 동시 수정)은 그 값을 치를 이유가 생기면 그때 승격시킬 계획이다. 지금은 서브모듈이 정확히 필요한 만큼만 준다.
관련 작업
- 모노레포 자체는 팀 동료가 설계·구축(git 서브모듈 기반). 이 글은 그 위에 디자인 시스템을 편입한 작업 기록이다.
- 모노레포 스펙 문서(내부) 「01. 현재 구조」 — 서브모듈 채택 근거,
apps/*비-워크스페이스 결정, node_modules 3.4GB↔203MB 실측, 게시 대신 생성물 커밋. design-systemcodegen 파이프라인 PR 3종 — 코드 생성 파이프라인 · 워크스페이스 멤버 · 잘못 커밋된.iml제거. 리뷰(리뷰어)에서 알파 색(#RRGGBBAAvs0xAARRGGBB)·참조 깨짐exit 0버그 2건 지적.- figma token 주입 세팅 · vanilla-extract CSS 변수 식별성 개선 · 토큰 마이그레이션.