1. 작업 내용
이번 주는 데모데이 매칭 서비스 레포의 Storybook 인수인계를 했습니다. Storybook 실행 환경을 붙이고, 공통 UI 스토리 11개와 매칭·리크루팅·프로젝트 화면 스토리 10개를 쓰고, 인수인계 문서를 남겼습니다. 커밋 4개입니다.
스토리를 쓰다 보니 공통 UI가 src/shared/ui에 컴포넌트 75개로 꽤 모여 있고, 디자인 토큰도 src/styles 아래 CSS 변수 5파일로 정리돼 있었습니다. 그래서 자연스럽게 질문이 생겼습니다.
매칭과 리크루팅은 서로 다른 기능이고 하나의 디자인시스템을 같이 쓴다. 그러면 디자인시스템 레포, 매칭 레포, 리크루팅 레포 3개로 쪼갤 수 있나?
AI에게 물어보니 "가능하다, 디자인시스템을 private npm 패키지로 배포하고 두 앱이 의존하면 된다"는 답이 바로 나왔습니다. 그런데 뒤이어 "가능한가보다 좋은가를 먼저 봐야 한다"고 하더군요. 그 기준을 우리 레포에 실제로 대조해 봤습니다.
이 이슈는 결론이 없습니다. 축이 두 개인데 둘 다 스터디에서 같이 따져 보고 싶습니다.
- 매칭 / 리크루팅을 멀티 레포로 쪼갤 것인가, 모노레포로 갈 것인가, 지금처럼 둘 것인가
- 디자인시스템을 private npm 패키지(GitHub Packages)로 배포할 것인가
스터디 뒤에 실제로 전환할지 정할 예정이라, 이 이슈는 판단 자료까지입니다.
2. Deep Dive 해본 것
2.0 개념 정리
본론 전에 두 축의 용어를 먼저 맞춥니다.
멀티 레포 vs 모노레포
멀티 레포는 배포 단위마다 git 저장소를 따로 두는 방식입니다. 디자인시스템, 매칭, 리크루팅이 각각 저장소를 갖고, 서로는 npm 패키지 같은 배포된 산출물로만 만납니다.
org/design-system → build → @org/design-system@1.3.0 (registry)
org/matching → dependencies: @org/design-system ^1.3.0
org/recruiting → dependencies: @org/design-system ^1.2.0
각 저장소가 자기 CI, 자기 릴리즈 주기, 자기 버전을 갖습니다. 디자인시스템이 2.0으로 올라가도 리크루팅은 1.2에 머물 수 있습니다. 대신 디자인시스템을 고치고 앱에서 확인하려면 publish와 install을 거쳐야 합니다.
모노레포는 여러 패키지를 저장소 하나에 두는 방식입니다. 저장소가 하나라는 것과 앱이 하나라는 것은 다릅니다. 앱은 여전히 여러 개고 배포도 따로 할 수 있습니다.
frontend/
├── apps/
│ ├── matching/ → 독립 빌드·배포
│ └── recruiting/ → 독립 빌드·배포
├── packages/
│ └── design-system/
├── pnpm-workspace.yaml
└── turbo.json
pnpm workspace가 packages/design-system을 node_modules에 심볼릭 링크로 걸어 주기 때문에 앱은 "@org/design-system": "workspace:*"로 소스를 직접 참조합니다. publish 없이 수정이 바로 반영됩니다. Turborepo나 Nx는 그 위에서 "무엇이 바뀌었으니 어떤 앱만 다시 빌드하면 되는가"를 계산해 주는 도구입니다.
둘의 차이를 한 줄로 줄이면, 경계를 저장소로 긋느냐 폴더로 긋느냐입니다. 저장소로 그으면 강제력이 세고 팀 독립성이 높은 대신 공유 코드 변경이 무겁습니다. 폴더로 그으면 변경이 가볍지만 경계를 지키는 건 린트 규칙 같은 별도 장치의 몫입니다.
그리고 셋째 선택지가 지금 우리 상태입니다. 단일 앱 + 폴더 분리. 저장소도 하나, 앱도 하나, 배포도 하나이고 features/matching, features/recruiting처럼 폴더와 eslint-plugin-boundaries로만 경계를 긋습니다. 모노레포와의 차이는 앱을 따로 빌드·배포할 수 있느냐입니다.
장단점 정리
| 관점 |
멀티 레포 |
모노레포 |
| 공유 코드 변경 |
단점. publish → bump → install을 거쳐야 앱에서 확인 가능 |
장점. 수정 즉시 모든 앱에 반영, 한 PR에서 DS와 앱을 같이 고침 |
| 경계 강제력 |
장점. 저장소가 다르면 물리적으로 import 불가 |
단점. 폴더는 넘나들 수 있어 린트·boundaries 규칙으로 막아야 함 |
| 버전 독립성 |
장점. 앱마다 DS 버전을 고를 수 있고 breaking change를 미룰 수 있음 |
단점. 보통 전부 최신을 따르므로 DS의 breaking change가 모든 앱에 즉시 도달 |
| 팀 독립성 |
장점. 권한·리뷰어·릴리즈 주기를 저장소 단위로 분리 |
중간. CODEOWNERS로 폴더 단위 리뷰어 지정은 되지만 저장소 권한은 하나 |
| 원자적 변경 |
단점. DS API 변경과 앱 수정이 PR 두 개, 배포 두 번으로 갈라져 중간 상태가 생김 |
장점. 커밋 하나로 DS와 소비자를 동시에 바꿈 |
| 코드 탐색·리팩토링 |
단점. 사용처를 찾으려면 저장소를 오가야 하고 IDE 전역 리네임이 안 됨 |
장점. 전체 코드베이스에서 grep, rename, 타입 체크가 한 번에 됨 |
| 의존성 관리 |
단점. 저장소마다 React·TS·ESLint 버전이 따로 흘러 drift 발생 |
장점. 루트에서 한 번에 고정, 공통 config 패키지 공유 |
| CI 시간 |
장점. 저장소 크기만큼만 돌아 짧음 |
단점. 기본은 전체가 돌아 길어짐. Turborepo 캐시·affected 계산으로 상쇄해야 함 |
| 초기 구축 |
장점. 저장소 만들고 시작하면 됨 |
단점. workspace, 빌드 파이프라인, 캐시 설정이 필요 |
| 히스토리·clone 크기 |
장점. 작고 가벼움 |
단점. 시간이 지날수록 커지고 clone·checkout이 무거워짐 |
| 온보딩 |
단점. 어떤 저장소가 있는지, 어떤 순서로 세팅하는지 문서가 필요 |
장점. clone 하나, install 하나 |
| 장애 격리 |
장점. 한 저장소의 잘못된 머지가 다른 앱 빌드를 깨지 않음 |
단점. 공유 패키지 하나가 깨지면 의존하는 앱 전부가 빌드 실패 |
표를 보면 한쪽이 장점인 항목이 반대쪽에서는 거의 그대로 단점입니다. 같은 성질의 양면이기 때문입니다. 저장소로 긋는 경계는 강제력과 격리를 주는 대신 변경 비용을 받아 가고, 폴더로 긋는 경계는 변경 비용을 깎아 주는 대신 강제력과 격리를 도구에 맡깁니다.
그래서 "어느 쪽이 좋은가"보다 "지금 우리가 어느 비용을 더 자주 지불하는가"가 질문입니다. 공유 코드를 자주 고치고 앱이 같이 움직이면 멀티 레포의 변경 비용이 매일 쌓이고, 팀과 배포가 갈라져 있으면 모노레포의 강제력 부재가 매일 쌓입니다.
디자인시스템을 npm 패키지로 배포한다는 것
지금 src/shared/ui는 앱 소스의 일부입니다. import { Button } from '@/shared/ui'는 같은 저장소 안의 상대 경로일 뿐입니다.
npm 패키지로 배포한다는 건 이 폴더를 독립 라이브러리로 빌드해서 레지스트리에 올리고, 앱이 다른 서드파티 라이브러리와 똑같이 설치해 쓰게 만드는 겁니다.
src/components, src/tokens
↓ vite build (library mode)
dist/index.js, dist/index.d.ts, dist/style.css
↓ pnpm publish
@org/design-system@1.3.0
↓ pnpm add @org/design-system
import { Button } from '@org/design-system'
세 가지가 따라옵니다.
- 버전: 패키지에는 semver가 붙습니다. Button prop 이름을 바꾸면 major, DatePicker를 추가하면 minor입니다. 앱은 원하는 버전에 고정할 수 있고, 올릴 때는 명시적으로 bump합니다.
- peerDependencies: React 컴포넌트 라이브러리는 React를
dependencies가 아니라 peerDependencies에 둡니다. "나는 React를 쓰지만 React 자체는 앱이 제공해라"는 선언입니다. dependencies에 두면 앱과 라이브러리가 React를 각각 들고 와서 hooks가 깨질 수 있습니다.
- private registry: 공개 npm에 올릴 게 아니면 접근 제어가 있는 레지스트리가 필요합니다. GitHub Packages는
@org/ scope와 GitHub 권한 체계를 그대로 쓰고, CI에서는 GITHUB_TOKEN으로 읽습니다. 로컬 개발자는 개인 토큰을 ~/.npmrc에 넣어야 설치가 됩니다.
멀티 레포에서는 이 패키지화가 필수입니다. 저장소가 다르면 배포된 산출물로만 만날 수 있기 때문입니다. 모노레포에서는 선택입니다. workspace:*로 소스를 직접 참조해도 되고, 나중에 외부 소비자가 생기면 그때 publish를 붙여도 됩니다. 이 차이가 2.5의 개발 루프 차이로 이어집니다.
private npm 패키지가 최선인가, 대안은 무엇인가
"디자인시스템을 공유한다"는 문제에 private npm 패키지는 답 중 하나일 뿐입니다. 공유 방식은 크게 넷으로 갈립니다. 빌드 산출물을 배포하느냐, 소스를 링크하느냐, 소스를 복사하느냐, 런타임에 로드하느냐.
| 방식 |
어떻게 |
장점 |
단점 |
맞는 상황 |
| private registry (GitHub Packages, npm Pro, Verdaccio) |
빌드해서 레지스트리에 publish, 앱은 install |
정석. semver, lockfile, 캐시가 다 붙음. 소비자가 늘어도 같은 방식 |
publish 루프, 인증 온보딩, 레지스트리 운영. 소비자가 앱 하나면 비용만 남음 |
저장소가 분리돼 있고 소비자가 둘 이상 |
git 의존성 (pnpm add github:org/ds#v1.3.0) |
레지스트리 없이 git 태그를 직접 설치 |
레지스트리와 별도 인증이 필요 없음. 저장소 접근 권한이 곧 설치 권한 |
설치 시 빌드가 돌거나 dist를 커밋해야 함. lockfile이 커밋 해시를 물어 재현성이 약함. 캐시 없음 |
소비자 1~2개인 소규모 팀의 임시 단계 |
| git submodule / subtree |
앱 저장소 안에 DS 저장소를 경로로 마운트 |
소스 그대로 참조, 빌드 불필요 |
submodule 포인터 갱신을 다들 잊음. subtree는 머지 충돌이 잦음. 대부분의 팀이 후회함 |
권장 안 함 |
pnpm workspace workspace:* |
모노레포 안에서 심볼릭 링크 |
publish 없이 즉시 반영, 한 PR에서 DS와 앱 동시 수정 |
모노레포 전제. 외부 저장소는 못 씀 |
저장소를 합치기로 한 경우 |
| 소스 복사 (shadcn 방식 registry) |
컴포넌트를 패키지가 아니라 앱 코드로 복사해 넣음. CLI가 registry에서 가져옴 |
앱이 소유권을 가져 자유롭게 수정. 버전 충돌 없음 |
공통 수정이 앱마다 drift. "디자인시스템"이라기보다 "출발 템플릿" |
앱마다 커스터마이즈가 많고 일관성보다 속도가 중요할 때 |
| Module Federation / import maps |
빌드가 아니라 브라우저 런타임에 DS 번들을 로드 |
DS 배포만으로 모든 앱에 즉시 반영, 앱 재배포 불필요 |
런타임 버전 불일치, 타입은 별도 배포, 디버깅 난이도 급상승 |
마이크로 프론트엔드가 이미 있을 때만 |
| 공개 npm (scoped, public) |
그냥 npm에 올림 |
인증 없음, 가장 단순 |
소스가 공개됨. 사내 도메인이 섞이면 불가 |
오픈소스로 내놓을 의도가 있을 때 |
우리 조건에 대조하면 이렇게 좁혀집니다.
- submodule, Module Federation, 소스 복사는 제외. 각각 운영 피로, 과한 복잡도, 일관성 포기라서 문제와 안 맞습니다.
- 공개 npm은 제외.
RecruitStatusChip처럼 도메인이 섞여 있고 사내 서비스입니다.
- 남는 건 셋입니다. 저장소를 나누면 private registry, 합치면 workspace:*, 나누되 소비자가 하나뿐인 과도기라면 git 의존성.
그래서 "private npm이 최선인가"의 답은 저장소 결정에 종속됩니다. 저장소를 나누기로 했다면 private registry가 맞고, 그 안에서 GitHub Packages는 우리가 이미 GitHub org를 쓰니 추가 비용이 가장 적은 선택입니다. 저장소를 안 나누면 private npm은 필요 없고 workspace 링크가 답입니다.
한 가지 더. 지금처럼 앱이 하나뿐인 상태에서 DS만 패키지로 떼는 것은 위 표 어디에도 유리한 칸이 없습니다. 소비자가 하나면 semver도, 버전 독립성도 쓸 데가 없고 publish 루프 비용만 남습니다. DS를 패키지로 떼는 시점은 둘째 소비자가 생기는 시점과 맞추는 게 맞아 보입니다.
국내 조직들은 어떻게 했나
이론만으로는 감이 안 와서 국내 서비스 조직의 공개 사례를 찾아봤습니다. 기술 블로그와 공개 저장소에 적힌 내용만 옮겼고, 제가 추정한 부분은 표시했습니다.
| 조직 |
레포 구조 |
디자인시스템 / 공통 코드 공유 방식 |
눈에 띄는 점 |
| 토스 |
계열사별 단일 모노레포. 토스 코어는 200개 넘는 서비스가 저장소 하나 |
공통 라이브러리는 frontend-libraries라는 별도 저장소에서 관리 |
기여자 50~60명, 하루 PR 60개 이상, 저장소 40GB 초과. CI를 동적 파이프라인으로 병렬화해 30분 → 6분, 체크아웃 36분 → 22초 |
| 우아한형제들 (커머스 어드민) |
멀티 레포로 시작했다가 모노레포로 전환. pnpm workspace + Turborepo |
앱들이 쓰는 공통 코드를 packages로 분리, 워크스페이스 참조 |
멀티 레포 시절 겪은 문제가 UI 컴포넌트 중복, 저장소 간 버전 호환, 개발 경험 불일치. 어드민 3개가 병렬로 굴러가면서 터짐 |
| 우아한형제들 (셀프서비스) |
다른 팀의 앱 안에 들어갈 페이지 하나를 npm 패키지로 배포 |
사내 npm 레지스트리, 버전 중앙 관리·롤백 |
Module Federation과 비교해 npm을 택함. 이유는 호스트 앱 설정 변경이 필요 없어서. 환경(dev/beta/prod)은 props로 주입 |
| 카카오 FE플랫폼 |
전사 어드민 UI 컴포넌트를 모노레포로 개발 |
어드민 서비스들이 공통 컴포넌트를 패키지로 소비 |
2020년 글. 소비자가 전사 어드민 여러 개라는 점이 우리와 다름 |
| 카카오스타일 (ZDS) |
디자인시스템 자체를 모노레포로 구성 |
core / components / icons / tokens / themes 패키지로 나누고 최상위 zds 패키지가 묶음 |
단일 패키지에 전부 넣었을 때의 문제를 관심사 분리로 풀되, 과도한 세분화는 리소스 문제로 일부러 피함 (components는 하나로 유지) |
| 당근 (SEED) |
디자인시스템이 소비 앱과 분리된 공개 모노레포 (daangn/seed-design) |
Changesets로 버전 관리, @seed-design/* 패키지 공개 배포. react, react-headless, css, tokens, Tailwind/Vite 플러그인까지 패키지 |
React·iOS·Android·Lynx가 같은 토큰을 쓰는 멀티 플랫폼이라 DS를 앱에서 떼어낼 이유가 분명함 |
| 한컴 |
프로젝트 A는 Turborepo 모노레포, 프로젝트 B는 모노레포+멀티레포 하이브리드 |
B는 앱마다 저장소를 두고 루트에서 git submodule로 묶음 |
A는 빌드 36초 → 7초. B는 Nx 모노레포로 갔다가 권한 관리 문제로 되돌려 하이브리드로 |
| 컬리 |
Nx 모노레포, 패키지 매니저는 pnpm → Bun |
워크스페이스 참조 |
Nx가 Bun을 공식 지원하기 전에 도입해서 겪은 마이그레이션 문제를 기록 |
| 무신사 (커뮤니티) |
스냅·패션톡·체험단 여러 제품을 모노레포로 |
워크스페이스 참조 (추정) |
도입 후 새 프로젝트 생성부터 첫 런칭까지 1주일 |
읽고 나서 정리한 세 가지입니다.
모노레포가 다수고, 멀티 레포에서 모노레포로 간 사례는 있는데 반대는 못 찾았습니다. 우아한형제들 커머스 어드민이 대표적입니다. 멀티 레포로 빠르게 출발했다가 UI 중복과 버전 호환 문제로 합쳤습니다. 우리가 지금 3레포로 나누면 이 팀이 출발했던 지점으로 가는 셈입니다. 한컴 B 프로젝트가 모노레포에서 물러난 유일한 사례인데, 이유가 코드가 아니라 권한이었습니다.
디자인시스템을 별도 저장소에 두는 곳은 소비자가 여럿이거나 플랫폼이 여럿입니다. 토스는 서비스 200개, 당근은 React·iOS·Android·Lynx, 카카오는 전사 어드민. 소비자가 하나인 상태에서 DS를 뗀 사례는 못 찾았습니다. 카카오스타일은 DS를 모노레포로 쪼개면서도 "지금 리소스에 세분화는 과하다"고 components를 하나로 뒀는데, 분리 단위를 조직 규모에 맞춘 판단이라 우리에게 가장 가까운 사례로 보입니다.
npm 패키지 배포는 "저장소가 다른 팀 사이"에서 쓰였습니다. 우아한형제들 셀프서비스는 남의 앱에 페이지를 넣어야 해서 npm을 택했고, 토스는 라이브러리 저장소가 서비스 저장소와 따로라서 배포가 필요합니다. 같은 팀이 같은 저장소 안에서 npm publish를 거치는 사례는 없었습니다.
참고한 글은 다음과 같습니다.
- 토스, 200여개 서비스 모노레포의 파이프라인 최적화, 토스 프론트엔드 챕터를 소개합니다
- 우아한형제들, 웹프론트개발팀에서 배민 커머스 어드민을 개발하는 방법, 웹 애플리케이션 페이지를 패키지로 개발해 본 경험
- 카카오, 카카오 어드민 UI 컴포넌트를 모노레포로 개발하여 얻은 것들
- 카카오스타일, 프론트엔드팀의 디자인 시스템 재구축기
- 당근, daangn/seed-design
- 한컴, 모노레포 소개 및 도입기
- 컬리, Nx에서 Bun 더 잘 사용하기
- 무신사, 모노레포 이렇게 좋은데 왜 안써요?
2.1 "독립적인 기능"과 "독립적인 애플리케이션"은 다르다
AI가 제시한 레포 경계 기준은 코드 도메인이 아니라 배포 주기, 팀 ownership, 릴리즈 주기, 장애 영향 범위였습니다. 넷 중 하나라도 독립적이면 레포를 나눌 근거가 생기고, 넷 다 같이 움직이면 나눌 이유가 없다는 얘기입니다.
우리 레포는 넷 다 하나입니다.
| 기준 |
현재 상태 |
근거 |
| 배포 |
단일 SPA 한 번 배포 |
amplify.yml 하나, dist 하나, /<*> → index.html 리라이트 |
| 진입점 |
하나 |
src/app/main.tsx, QueryClient 1개 |
| 라우터 |
하나 |
routeTree.gen.ts 109 라우트, /matching/* 14개 + /recruiting/* 16개 |
| CI |
워크플로 1개 |
ci.yml (Lint / boundaries / Build), 배포 워크플로 없음 |
| 팀 |
한 팀 |
넷이 같은 레포에서 작업 |
즉 매칭과 리크루팅은 "독립적인 기능"이긴 하지만 "독립적인 애플리케이션"은 아직 아닙니다. 라우트 prefix로만 나뉜 하나의 앱입니다. 3레포 분리는 이 넷을 먼저 독립시키는 결정을 동반합니다.
2.2 공통 영역이 디자인시스템만이 아니다
AI 답변에서 가장 와닿은 대목은 이겁니다. 공유하는 게 디자인시스템뿐이면 3레포가 깔끔하지만, auth·api client·라우터·레이아웃까지 공유하면 @team/auth, @team/api-client, @team/types 같은 패키지가 계속 늘어나서 결국 모노레포가 낫다는 것.
우리 레포에서 매칭과 리크루팅이 디자인시스템 외에 같이 쓰는 걸 세어 봤습니다.
| 공통 의존 |
위치 |
규모 |
| 인증 스토어 + axios 인터셉터 + authBridge |
entities/member/store/authStore.ts, shared/lib/ |
참조 파일 24개 / 38개 |
| 생성된 API 타입 |
src/types/api.d.ts |
24,923줄 단일 파일, 전 도메인 스키마 포함 |
| 헤더 / 사이드바 |
widgets/navigation/ 35파일 |
recruiting 98회, matching 14회 하드코딩 |
| 내비 설정 |
shared/config/navigation.ts, recruitingNavigation.ts |
shared 레이어가 두 도메인 라우트를 알고 있음 |
| 도메인 모델 |
shared/model/recruitingStatus.ts |
shared에 리크루팅 상태 머신 |
| 전역 상태 |
기수 선택, 뷰 모드, 토스트 |
zustand 스토어 4개 |
AI가 "이렇게 되면 모노레포"라고 한 시나리오가 우리 레포에서는 이미 현실이었습니다. 특히 api.d.ts는 openapi.yaml에서 한 번에 생성하는 파일이라, 레포를 나누면 이걸 어떻게 쪼개서 나눠 가질지부터 답이 없습니다.
2.3 matching은 슬라이스가 아니다
두 도메인이 서로를 직접 import하는지 grep했습니다. 런타임 참조는 0건이고, 주석 처리된 한 줄만 남아 있었습니다. 여기까지는 좋았습니다.
그런데 /matching/* 라우트가 실제로 어디를 import하는지 세어 보니 features/matching(22파일)이 아니라 다른 곳이었습니다.
| import 대상 |
횟수 |
entities/member |
29 |
entities/application |
17 |
features/notice |
12 |
entities/organization |
12 |
features/auth |
11 |
features/project |
9 |
features/application |
8 |
매칭은 project, application, notice, entities에 걸쳐 있어서 "매칭 레포"로 떼어낼 경계 자체가 성립하지 않습니다. 반대로 리크루팅은 features/recruiting 203파일에 뭉쳐 있고 자기 슬라이스 참조가 55회입니다.
분리 난이도가 비대칭입니다. 리크루팅은 떼어낼 수 있는 모양이고, 매칭은 그냥 "리크루팅을 뺀 나머지"입니다. 3레포 그림이 실제로는 "recruiting + 나머지 앱 + design-system"에 가깝습니다.
2.4 디자인시스템 분리는 가능성이 높다
앱 분리와 달리 디자인시스템 쪽은 물리적으로 이미 모여 있습니다.
src/shared/ui 컴포넌트 75개 (button, input, modal, table, toast, chip, calendar, dropdown 등 카테고리 22개)
src/styles 토큰 5파일 (color 149줄, typography 492줄, shadow, font, custom)
- Tailwind v4 + shadcn,
app.css의 @theme inline으로 토큰을 Tailwind에 매핑
- Storybook 하네스와 인수인계 문서까지 이번 주에 갖춰짐
걸림돌은 세 가지입니다.
shared/ui에 도메인이 섞인 컴포넌트가 5~6개 있습니다. ProjectThumbnail, ProjectTitleCard, RecruitStatusChip, GraphTimestampLabel 같은 것들. 순수 DS로 떼려면 선별이 필요합니다.
shared/ui/toast가 zustand 전역 스토어에 결합돼 있습니다. 패키지가 앱 상태를 소유하는 모양이 됩니다.
- Tailwind v4 토큰이 CSS 변수 +
@theme inline 형태라, 패키지 소비자가 Tailwind 없이도 쓸 수 있게 할지, Tailwind 프리셋으로 내보낼지 정해야 합니다.
2.5 private npm 패키지의 운영 비용
GitHub Packages로 가정하고 흐름을 따라가 봤습니다.
design-system repo
↓ PR merge → version bump → GitHub Release
↓ Actions: pnpm build → pnpm publish
GitHub Packages @org/design-system@1.x
↓ pnpm add
matching / recruiting
주의할 지점은 이미 알려진 것들이었습니다.
- React는
peerDependencies로 둔다. dependencies에 넣으면 앱과 React가 중복될 수 있다.
.npmrc에는 @org:registry=https://npm.pkg.github.com만 두고 토큰은 커밋하지 않는다. 개인 토큰은 ~/.npmrc, CI는 GITHUB_TOKEN에 packages: read.
- 팀원은 최초 한 번 GitHub Packages 인증을 해야
pnpm install이 통과한다. 온보딩 단계가 하나 늘어난다.
- npm 패키지가 private이라고 Storybook이 private이 되진 않는다. Storybook 정적 배포에는 별도 접근 제어가 필요하다.
그리고 개발 루프 차이가 큽니다.
|
멀티 레포 (npm) |
모노레포 (workspace) |
| Button 수정 후 앱에서 확인 |
PR merge → publish → package.json bump → install |
바로 확인 |
| 앱별 DS 버전 선택 |
가능 |
가능하지만 보통 안 함 |
| DS 변경이 앱을 깨는 시점 |
앱이 bump할 때 |
DS 수정 즉시 |
| 초기 구축 |
단순 |
pnpm workspace + Turborepo 설정 |
우리 레포에는 지금 pnpm-workspace.yaml이 없습니다. 모노레포로 가도 새로 구축입니다.
3. 알게된 것
레포 경계는 코드 도메인이 아니라 배포·소유·장애 범위로 긋는다. "매칭과 리크루팅은 다른 기능이니까"는 레포를 나눌 근거가 아니었습니다. 우리는 넷 다 하나라서, 나누려면 먼저 "별도 배포로 간다"는 결정이 있어야 합니다.
쪼개기 전에 풀어야 할 결합이 네 개 있습니다. 단일 api.d.ts, 공유 인증, navigation과 shared/config의 도메인 하드코딩, 단일 routeTree. 이건 레포 구조와 무관하게 지금도 lateral 위반 12건 baseline과 같은 종류의 문제입니다. 레포를 나누는 결정과 별개로 먼저 정리할 가치가 있습니다.
디자인시스템 패키지화와 앱 레포 분리는 별개 결정이고 순서가 있습니다. DS를 먼저 패키지로 떼도 앱은 하나여도 됩니다. 반대로 앱을 나누려면 DS 패키지화가 선행돼야 합니다. 그래서 첫 단계 후보는 "DS만 떼기"인데, 소비자가 앱 하나뿐인 상태에서 publish 루프 비용을 감수할 이유가 있는지는 확신이 없습니다.
AI의 첫 답은 "가능하다"였고, 두 번째 답이 "좋은가를 봐라"였습니다. 첫 답만 받고 움직였으면 3레포를 만들고 나서 api.d.ts를 어떻게 나눌지 고민하고 있었을 겁니다.
스터디에서 묻고 싶은 것
- 지금 조건(같은 팀, 같은 배포, 같은 백엔드)에서 멀티 레포로 갈 만한 이유가 있나요. 아니면 "나중에 별도 배포가 되면 그때 나눈다"가 맞나요.
- 소비자가 앱 하나뿐일 때 DS를 npm 패키지로 먼저 떼는 게 의미가 있나요. pnpm workspace 모노레포로
workspace:* 참조만 만들어 두는 게 나은가요.
- GitHub Packages 인증을 팀 온보딩에 얹어 본 경험이 있다면, 실제로 얼마나 걸렸고 뭐가 막혔나요.
1. 작업 내용
이번 주는 데모데이 매칭 서비스 레포의 Storybook 인수인계를 했습니다. Storybook 실행 환경을 붙이고, 공통 UI 스토리 11개와 매칭·리크루팅·프로젝트 화면 스토리 10개를 쓰고, 인수인계 문서를 남겼습니다. 커밋 4개입니다.
스토리를 쓰다 보니 공통 UI가
src/shared/ui에 컴포넌트 75개로 꽤 모여 있고, 디자인 토큰도src/styles아래 CSS 변수 5파일로 정리돼 있었습니다. 그래서 자연스럽게 질문이 생겼습니다.AI에게 물어보니 "가능하다, 디자인시스템을 private npm 패키지로 배포하고 두 앱이 의존하면 된다"는 답이 바로 나왔습니다. 그런데 뒤이어 "가능한가보다 좋은가를 먼저 봐야 한다"고 하더군요. 그 기준을 우리 레포에 실제로 대조해 봤습니다.
이 이슈는 결론이 없습니다. 축이 두 개인데 둘 다 스터디에서 같이 따져 보고 싶습니다.
스터디 뒤에 실제로 전환할지 정할 예정이라, 이 이슈는 판단 자료까지입니다.
2. Deep Dive 해본 것
2.0 개념 정리
본론 전에 두 축의 용어를 먼저 맞춥니다.
멀티 레포 vs 모노레포
멀티 레포는 배포 단위마다 git 저장소를 따로 두는 방식입니다. 디자인시스템, 매칭, 리크루팅이 각각 저장소를 갖고, 서로는 npm 패키지 같은 배포된 산출물로만 만납니다.
각 저장소가 자기 CI, 자기 릴리즈 주기, 자기 버전을 갖습니다. 디자인시스템이 2.0으로 올라가도 리크루팅은 1.2에 머물 수 있습니다. 대신 디자인시스템을 고치고 앱에서 확인하려면 publish와 install을 거쳐야 합니다.
모노레포는 여러 패키지를 저장소 하나에 두는 방식입니다. 저장소가 하나라는 것과 앱이 하나라는 것은 다릅니다. 앱은 여전히 여러 개고 배포도 따로 할 수 있습니다.
pnpm workspace가
packages/design-system을node_modules에 심볼릭 링크로 걸어 주기 때문에 앱은"@org/design-system": "workspace:*"로 소스를 직접 참조합니다. publish 없이 수정이 바로 반영됩니다. Turborepo나 Nx는 그 위에서 "무엇이 바뀌었으니 어떤 앱만 다시 빌드하면 되는가"를 계산해 주는 도구입니다.둘의 차이를 한 줄로 줄이면, 경계를 저장소로 긋느냐 폴더로 긋느냐입니다. 저장소로 그으면 강제력이 세고 팀 독립성이 높은 대신 공유 코드 변경이 무겁습니다. 폴더로 그으면 변경이 가볍지만 경계를 지키는 건 린트 규칙 같은 별도 장치의 몫입니다.
그리고 셋째 선택지가 지금 우리 상태입니다. 단일 앱 + 폴더 분리. 저장소도 하나, 앱도 하나, 배포도 하나이고
features/matching,features/recruiting처럼 폴더와eslint-plugin-boundaries로만 경계를 긋습니다. 모노레포와의 차이는 앱을 따로 빌드·배포할 수 있느냐입니다.장단점 정리
표를 보면 한쪽이 장점인 항목이 반대쪽에서는 거의 그대로 단점입니다. 같은 성질의 양면이기 때문입니다. 저장소로 긋는 경계는 강제력과 격리를 주는 대신 변경 비용을 받아 가고, 폴더로 긋는 경계는 변경 비용을 깎아 주는 대신 강제력과 격리를 도구에 맡깁니다.
그래서 "어느 쪽이 좋은가"보다 "지금 우리가 어느 비용을 더 자주 지불하는가"가 질문입니다. 공유 코드를 자주 고치고 앱이 같이 움직이면 멀티 레포의 변경 비용이 매일 쌓이고, 팀과 배포가 갈라져 있으면 모노레포의 강제력 부재가 매일 쌓입니다.
디자인시스템을 npm 패키지로 배포한다는 것
지금
src/shared/ui는 앱 소스의 일부입니다.import { Button } from '@/shared/ui'는 같은 저장소 안의 상대 경로일 뿐입니다.npm 패키지로 배포한다는 건 이 폴더를 독립 라이브러리로 빌드해서 레지스트리에 올리고, 앱이 다른 서드파티 라이브러리와 똑같이 설치해 쓰게 만드는 겁니다.
세 가지가 따라옵니다.
dependencies가 아니라peerDependencies에 둡니다. "나는 React를 쓰지만 React 자체는 앱이 제공해라"는 선언입니다.dependencies에 두면 앱과 라이브러리가 React를 각각 들고 와서 hooks가 깨질 수 있습니다.@org/scope와 GitHub 권한 체계를 그대로 쓰고, CI에서는GITHUB_TOKEN으로 읽습니다. 로컬 개발자는 개인 토큰을~/.npmrc에 넣어야 설치가 됩니다.멀티 레포에서는 이 패키지화가 필수입니다. 저장소가 다르면 배포된 산출물로만 만날 수 있기 때문입니다. 모노레포에서는 선택입니다.
workspace:*로 소스를 직접 참조해도 되고, 나중에 외부 소비자가 생기면 그때 publish를 붙여도 됩니다. 이 차이가 2.5의 개발 루프 차이로 이어집니다.private npm 패키지가 최선인가, 대안은 무엇인가
"디자인시스템을 공유한다"는 문제에 private npm 패키지는 답 중 하나일 뿐입니다. 공유 방식은 크게 넷으로 갈립니다. 빌드 산출물을 배포하느냐, 소스를 링크하느냐, 소스를 복사하느냐, 런타임에 로드하느냐.
pnpm add github:org/ds#v1.3.0)workspace:*우리 조건에 대조하면 이렇게 좁혀집니다.
RecruitStatusChip처럼 도메인이 섞여 있고 사내 서비스입니다.그래서 "private npm이 최선인가"의 답은 저장소 결정에 종속됩니다. 저장소를 나누기로 했다면 private registry가 맞고, 그 안에서 GitHub Packages는 우리가 이미 GitHub org를 쓰니 추가 비용이 가장 적은 선택입니다. 저장소를 안 나누면 private npm은 필요 없고 workspace 링크가 답입니다.
한 가지 더. 지금처럼 앱이 하나뿐인 상태에서 DS만 패키지로 떼는 것은 위 표 어디에도 유리한 칸이 없습니다. 소비자가 하나면 semver도, 버전 독립성도 쓸 데가 없고 publish 루프 비용만 남습니다. DS를 패키지로 떼는 시점은 둘째 소비자가 생기는 시점과 맞추는 게 맞아 보입니다.
국내 조직들은 어떻게 했나
이론만으로는 감이 안 와서 국내 서비스 조직의 공개 사례를 찾아봤습니다. 기술 블로그와 공개 저장소에 적힌 내용만 옮겼고, 제가 추정한 부분은 표시했습니다.
frontend-libraries라는 별도 저장소에서 관리daangn/seed-design)@seed-design/*패키지 공개 배포. react, react-headless, css, tokens, Tailwind/Vite 플러그인까지 패키지읽고 나서 정리한 세 가지입니다.
모노레포가 다수고, 멀티 레포에서 모노레포로 간 사례는 있는데 반대는 못 찾았습니다. 우아한형제들 커머스 어드민이 대표적입니다. 멀티 레포로 빠르게 출발했다가 UI 중복과 버전 호환 문제로 합쳤습니다. 우리가 지금 3레포로 나누면 이 팀이 출발했던 지점으로 가는 셈입니다. 한컴 B 프로젝트가 모노레포에서 물러난 유일한 사례인데, 이유가 코드가 아니라 권한이었습니다.
디자인시스템을 별도 저장소에 두는 곳은 소비자가 여럿이거나 플랫폼이 여럿입니다. 토스는 서비스 200개, 당근은 React·iOS·Android·Lynx, 카카오는 전사 어드민. 소비자가 하나인 상태에서 DS를 뗀 사례는 못 찾았습니다. 카카오스타일은 DS를 모노레포로 쪼개면서도 "지금 리소스에 세분화는 과하다"고 components를 하나로 뒀는데, 분리 단위를 조직 규모에 맞춘 판단이라 우리에게 가장 가까운 사례로 보입니다.
npm 패키지 배포는 "저장소가 다른 팀 사이"에서 쓰였습니다. 우아한형제들 셀프서비스는 남의 앱에 페이지를 넣어야 해서 npm을 택했고, 토스는 라이브러리 저장소가 서비스 저장소와 따로라서 배포가 필요합니다. 같은 팀이 같은 저장소 안에서 npm publish를 거치는 사례는 없었습니다.
참고한 글은 다음과 같습니다.
2.1 "독립적인 기능"과 "독립적인 애플리케이션"은 다르다
AI가 제시한 레포 경계 기준은 코드 도메인이 아니라 배포 주기, 팀 ownership, 릴리즈 주기, 장애 영향 범위였습니다. 넷 중 하나라도 독립적이면 레포를 나눌 근거가 생기고, 넷 다 같이 움직이면 나눌 이유가 없다는 얘기입니다.
우리 레포는 넷 다 하나입니다.
amplify.yml하나,dist하나,/<*>→index.html리라이트src/app/main.tsx, QueryClient 1개routeTree.gen.ts109 라우트,/matching/*14개 +/recruiting/*16개ci.yml(Lint / boundaries / Build), 배포 워크플로 없음즉 매칭과 리크루팅은 "독립적인 기능"이긴 하지만 "독립적인 애플리케이션"은 아직 아닙니다. 라우트 prefix로만 나뉜 하나의 앱입니다. 3레포 분리는 이 넷을 먼저 독립시키는 결정을 동반합니다.
2.2 공통 영역이 디자인시스템만이 아니다
AI 답변에서 가장 와닿은 대목은 이겁니다. 공유하는 게 디자인시스템뿐이면 3레포가 깔끔하지만, auth·api client·라우터·레이아웃까지 공유하면
@team/auth,@team/api-client,@team/types같은 패키지가 계속 늘어나서 결국 모노레포가 낫다는 것.우리 레포에서 매칭과 리크루팅이 디자인시스템 외에 같이 쓰는 걸 세어 봤습니다.
entities/member/store/authStore.ts,shared/lib/src/types/api.d.tswidgets/navigation/35파일shared/config/navigation.ts,recruitingNavigation.tsshared/model/recruitingStatus.tsAI가 "이렇게 되면 모노레포"라고 한 시나리오가 우리 레포에서는 이미 현실이었습니다. 특히
api.d.ts는 openapi.yaml에서 한 번에 생성하는 파일이라, 레포를 나누면 이걸 어떻게 쪼개서 나눠 가질지부터 답이 없습니다.2.3 matching은 슬라이스가 아니다
두 도메인이 서로를 직접 import하는지 grep했습니다. 런타임 참조는 0건이고, 주석 처리된 한 줄만 남아 있었습니다. 여기까지는 좋았습니다.
그런데
/matching/*라우트가 실제로 어디를 import하는지 세어 보니features/matching(22파일)이 아니라 다른 곳이었습니다.entities/memberentities/applicationfeatures/noticeentities/organizationfeatures/authfeatures/projectfeatures/application매칭은 project, application, notice, entities에 걸쳐 있어서 "매칭 레포"로 떼어낼 경계 자체가 성립하지 않습니다. 반대로 리크루팅은
features/recruiting203파일에 뭉쳐 있고 자기 슬라이스 참조가 55회입니다.분리 난이도가 비대칭입니다. 리크루팅은 떼어낼 수 있는 모양이고, 매칭은 그냥 "리크루팅을 뺀 나머지"입니다. 3레포 그림이 실제로는 "recruiting + 나머지 앱 + design-system"에 가깝습니다.
2.4 디자인시스템 분리는 가능성이 높다
앱 분리와 달리 디자인시스템 쪽은 물리적으로 이미 모여 있습니다.
src/shared/ui컴포넌트 75개 (button, input, modal, table, toast, chip, calendar, dropdown 등 카테고리 22개)src/styles토큰 5파일 (color 149줄, typography 492줄, shadow, font, custom)app.css의@theme inline으로 토큰을 Tailwind에 매핑걸림돌은 세 가지입니다.
shared/ui에 도메인이 섞인 컴포넌트가 5~6개 있습니다.ProjectThumbnail,ProjectTitleCard,RecruitStatusChip,GraphTimestampLabel같은 것들. 순수 DS로 떼려면 선별이 필요합니다.shared/ui/toast가 zustand 전역 스토어에 결합돼 있습니다. 패키지가 앱 상태를 소유하는 모양이 됩니다.@theme inline형태라, 패키지 소비자가 Tailwind 없이도 쓸 수 있게 할지, Tailwind 프리셋으로 내보낼지 정해야 합니다.2.5 private npm 패키지의 운영 비용
GitHub Packages로 가정하고 흐름을 따라가 봤습니다.
주의할 지점은 이미 알려진 것들이었습니다.
peerDependencies로 둔다.dependencies에 넣으면 앱과 React가 중복될 수 있다..npmrc에는@org:registry=https://npm.pkg.github.com만 두고 토큰은 커밋하지 않는다. 개인 토큰은~/.npmrc, CI는GITHUB_TOKEN에packages: read.pnpm install이 통과한다. 온보딩 단계가 하나 늘어난다.그리고 개발 루프 차이가 큽니다.
우리 레포에는 지금
pnpm-workspace.yaml이 없습니다. 모노레포로 가도 새로 구축입니다.3. 알게된 것
레포 경계는 코드 도메인이 아니라 배포·소유·장애 범위로 긋는다. "매칭과 리크루팅은 다른 기능이니까"는 레포를 나눌 근거가 아니었습니다. 우리는 넷 다 하나라서, 나누려면 먼저 "별도 배포로 간다"는 결정이 있어야 합니다.
쪼개기 전에 풀어야 할 결합이 네 개 있습니다. 단일
api.d.ts, 공유 인증, navigation과 shared/config의 도메인 하드코딩, 단일 routeTree. 이건 레포 구조와 무관하게 지금도 lateral 위반 12건 baseline과 같은 종류의 문제입니다. 레포를 나누는 결정과 별개로 먼저 정리할 가치가 있습니다.디자인시스템 패키지화와 앱 레포 분리는 별개 결정이고 순서가 있습니다. DS를 먼저 패키지로 떼도 앱은 하나여도 됩니다. 반대로 앱을 나누려면 DS 패키지화가 선행돼야 합니다. 그래서 첫 단계 후보는 "DS만 떼기"인데, 소비자가 앱 하나뿐인 상태에서 publish 루프 비용을 감수할 이유가 있는지는 확신이 없습니다.
AI의 첫 답은 "가능하다"였고, 두 번째 답이 "좋은가를 봐라"였습니다. 첫 답만 받고 움직였으면 3레포를 만들고 나서
api.d.ts를 어떻게 나눌지 고민하고 있었을 겁니다.스터디에서 묻고 싶은 것
workspace:*참조만 만들어 두는 게 나은가요.