저희 회사는 KOKKOK이라는 플랫폼 안에서 여러 서비스를 운영하고 있었습니다. 그 서비스들의 어드민은 모두 같은 피그마 디자인을 기반으로 만들어졌는데, 정작 코드는 서비스마다 별도 레포로 나뉘어 있었습니다. 디자인이 하나 바뀌면 그 디자인을 쓰는 레포 수만큼 같은 작업을 반복해야 했고, 이 멀티 레포 구조가 업무 비효율을 계속 만들어내고 있었습니다.
업무 효율을 위해 저희 팀이 내린 결정은 공용 코드를 흩어놓지 말고 하나의 저장소에서 함께 관리하자는 것이었습니다.
무엇을 비교했고 왜 모노레포였나
공용 코드를 관리할 방법으로 네 가지를 검토했습니다.
- 멀티 레포 유지: 지금처럼 서비스마다 레포를 따로 두는 방식입니다. 디자인이나 로직이 바뀔 때마다 레포 수만큼 같은 작업을 반복해야 했습니다.
- 마이크로 프론트엔드(MFE): 여러 팀이 각자 독립된 프로젝트를 소유할 때 힘을 발휘하는 구조입니다. 저희는 한 팀이 모든 웹 프로젝트를 관리하고 있어, 도입해도 오버엔지니어링이었습니다.
- npm에 배포: 공용 코드를 패키지로 만들어 npm에 퍼블리시하고, 각 앱이 이를 설치해 쓰는 방식입니다. 코드를 바꿀 때마다 버전을 올리고, 배포하고, 재설치하는 과정이 필요했고, 이 흐름 자체를 갖추는 것도 어려웠습니다.
- GitHub Packages: 비공개 레지스트리인 GitHub Packages로 배포하는 방식입니다. npm 배포와 마찬가지로 버전 관리·재설치 과정이 필요했고, 이 역시 어려웠습니다.
- 모노레포: 공용 코드와 앱을 한 저장소에 두고 워크스페이스로 바로 참조하는 방식입니다.
이 중 모노레포가 가장 간단했습니다. 배포 방식들은 버전을 올리고 배포하고 재설치하는 과정이 매번 필요했고, MFE는 한 팀이 모든 프로젝트를 관리하는 저희 조직 구조와 맞지 않았습니다.
모노레포로 정한 뒤에도 구현 방법이 하나 더 남아 있었습니다. pnpm workspace만으로 갈지, 아니면 Lerna·Nx·Turborepo 같은 도구를 얹을지였습니다. pnpm workspace만으로는 패키지 간 참조는 가능해도 빌드 순서나 캐싱까지 관리해주진 않았습니다. 그래서 Turborepo를 얹기로 했습니다. Vercel이 만든 도구인 데다, 저희가 쓰고 있던 Next.js도 Vercel 소속이라 호환성이 좋았기 때문입니다.
npm install turbo --global로 최소한의 PoC부터 시작해, 기존 node_modules와 package-lock.json을 지우고 pnpm으로 재설치했습니다. 검증이 끝난 뒤 6개 프로젝트를 한 번에 옮기는 본작업으로 넘어갔습니다.
1차 문제: 캐시가 못 믿을 캐시였다
어느 날 공용 컴포넌트 하나를 고쳐서 배포했는데, 실제 화면엔 고치기 전 모습이 그대로 떠 있었습니다. 원인을 찾아보니 Turborepo가 예전 빌드 캐시를 그대로 재사용하고 있었습니다.
Turborepo는 turbo.json에 적어둔 파일들이 바뀌었는지 확인해서, 안 바뀌었으면 이전 빌드 결과를 그대로 재사용합니다. 그런데 저희는 이 "확인 대상"에 packages 폴더나 .env 파일을 제대로 적어놓지 않았습니다. 그래서 이 파일들이 바뀌어도 Turborepo는 "안 바뀌었다"고 착각하고 예전 빌드 결과를 그대로 써버린 것이었습니다. 캐시가 최신 코드인지 확신할 수 없다면, 캐시는 있으나 마나 한 정도가 아니라 오히려 없는 것보다 위험합니다. 확인해야 할 파일(inputs)과 작업 순서(dependsOn)를 turbo.json에 다시 명확하게 적어 이 문제를 없앴습니다.
여기서 더 파고들어보니, 각 패키지의 package.json에 정작 어떤 라이브러리를 쓰는지 제대로 안 적혀 있다는 것도 발견했습니다. 그 라이브러리가 루트 package.json에 이미 설치돼 있었던 덕분에 "어쩌다 보니" 잘 동작하고 있었던 겁니다. 이걸 그대로 두면 안 되는 이유는 세 가지였습니다.
- 어떤 패키지가 뭘 쓰는지 알 수 없어집니다.
package.json만 봐서는 실제 의존성을 파악할 수 없어서, 나중에 다른 사람이 코드를 유지보수하기 어려워집니다. - pnpm 환경에서는 언제든 문제가 터질 수 있습니다. pnpm은 각 패키지가 직접 선언한 의존성만 연결해주기 때문에, 지금은 우연히 되고 있을 뿐 CI나 다른 개발자 PC, 혹은 버전이 바뀌는 순간 갑자기 안 될 수 있습니다.
- 캐시가 다시 부정확해집니다. Turborepo는 패키지 간 의존 관계를 보고 캐시를 갱신할지 말지 정하는데, 의존성이 제대로 안 적혀 있으면 이 판단도 같이 틀어집니다. 앞서 고친 캐시 문제로 다시 돌아가는 셈입니다.
전 패키지의 dependencies, devDependencies를 실제 사용 현황에 맞춰 일괄 재작성했습니다.
2차 문제: 공용 코드를 고치기가 무서웠다
모노레포로 합친 뒤에도 정작 공용 코드(컴포넌트, 커스텀 훅, 유틸 등)는 하나같이 손대기 무서운 존재였습니다. 고치고 나면 어떤 사이드 이펙트가 생길지 예상이 안 됐기 때문입니다. 기존 동작이 여전히 잘 되는지 확인하려 해도 수동으로 하나하나 확인하려니 시간이 너무 많이 들었습니다. 결국 공용 코드는 웬만하면 건드리지 않으려는 분위기가 생겼고, 이건 모노레포로 전환한 이유(한 곳을 고치면 전체에 반영되는 것) 자체를 무색하게 만드는 문제였습니다.
이 문제를 풀려면 테스트 코드 도입이 필요했습니다. 테스트 코드가 있으면 코드를 고쳐도 기존 기능이 제대로 동작하는지 자동으로 확인할 수 있고, 그만큼 더 확신을 갖고 코드를 고칠 수 있기 때문입니다. 테스트를 자동화하기로 하고 Jest와 Vitest를 두고 고민했습니다. 저희 빌드 환경이 Vite 기반이었기 때문에, 별도 설정 없이 잘 맞물리는 Vitest를 선택했습니다.
hooks 46개 중 44개, utils 40개 중 37개, components 164개 중 72개에 테스트를 적용해, 공용 코드 라인 커버리지 52%를 확보했습니다. 그중 components는 지금도 계속 커버리지를 넓혀가는 중입니다.
공용 코드를 고치거나 새로 추가할 때는 테스트 코드를 함께 작성하는 걸 컨벤션으로 정했습니다. 덕분에 공용 코드를 훨씬 더 안정적으로 고칠 수 있게 됐습니다.
종합 결과
- 10개 앱이 동일한 공용 패키지를 사용 — 한 곳을 고치면 즉시 전체에 반영
- 관리해야 할 저장소 수가 10개에서 1개로 줄어 레포 간 동기화 작업 자체가 사라짐
hooks46개 중 44개,utils40개 중 37개,components164개 중 72개에 Vitest 테스트 적용 — 공용 코드 라인 커버리지 52%- 공용 코드 수정·추가 시 테스트 작성을 컨벤션으로 정착시켜, 안정적으로 고칠 수 있는 구조 확보
배운 점
모노레포 전환 여정을 통해 저희가 얻은 교훈은 이랬습니다. 비슷한 전환을 앞두고 계신 분들께 도움이 되었으면 합니다.
- 옮기는 순간보다 옮긴 다음을 더 길게 잡읍시다. 캐시 설정, 의존성 명시화, 테스트 자동화까지, 마이그레이션 이후에 발견되는 문제가 더 많았습니다.
- 캐시는 정확하지 않으면 부채라고 생각합시다.
inputs/dependsOn을 대충 적어두면, 캐시가 있다는 사실 자체가 "최신 코드가 아닐 수도 있다"는 의심으로 바뀝니다. - 테스트 없는 공용 코드는 아무도 못 건드리는 코드라고 생각합시다. 모노레포로 합쳐도 안전하게 고칠 수 없으면 "한 곳만 고치면 된다"는 장점을 실제로는 못 씁니다. Vitest로 안전망을 깔고 나서야 공용 코드 수정이 진짜 쉬워졌습니다.
- "우연히 동작한다"는 상태부터 없앱시다. 루트에 얹혀서 되던 의존성은 언젠가 반드시 문제를 일으킵니다. 발견했을 때 바로 명시적으로 고쳐야, 나중에 원인 모를 CI 실패를 디버깅하지 않게 됩니다.
여러분의 팀도 여러 레포에 흩어진 공용 코드 때문에 같은 버그를 몇 번씩 고치고 계신가요? 그렇다면 도구 선택보다 먼저, "왜 한 곳에서 관리해야 하는가"에 대한 팀의 합의부터 만들어두시길 권합니다.