디자인 시스템 시리즈 세 번째 글. 어느 날 “프론트엔드 모노레포를 도입한다”는 얘기가 나왔다. 만든 건 팀 동료지, 내가 아니다. 근데 그 소식을 듣자마자 든 생각은 하나였다 — “이때다. 늘 채택률 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·requestpackages/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 Color0xAARRGGBB알파 위치가 반대다. 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.jsonprivate: true, mobile/pubspec.yamlpublish_to: none이다. 대신 codegen 산출물을 각 앱 레포에 커밋한다. 신규 웹가 이미 src/shared/styles/design-tokens.jsonAuto-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개 dirty7개 전부 clean
node_modules3.4GB203MB
필요한 스크립트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/webshared/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-system codegen 파이프라인 PR 3종 — 코드 생성 파이프라인 · 워크스페이스 멤버 · 잘못 커밋된 .iml 제거. 리뷰(리뷰어)에서 알파 색(#RRGGBBAA vs 0xAARRGGBB)·참조 깨짐 exit 0 버그 2건 지적.
  • figma token 주입 세팅 · vanilla-extract CSS 변수 식별성 개선 · 토큰 마이그레이션.