← 모든 글

monorepo vs polyrepo — 작은 팀의 결론

결제 API·정산 엔진·자동화 웹훅을 동시에 운영하는 3인 팀이 polyrepo 사고를 겪고 monorepo로 전환한 실제 이유, 수치 비교, 그리고 즉시 적용 가능한 판단 기준을 공유한다.

결제·정산 현장에서 이 선택이 유독 민감한 이유

monorepo냐 polyrepo냐는 많은 팀에게 "취향과 규모의 문제"로 다뤄진다. 그런데 결제·정산·자동화를 직접 운영하는 팀에게는 이 선택이 단순한 코드 관리 스타일이 아니라 사고 위험과 직결되는 아키텍처 결정이다. 결제 API, 정산 배치 엔진, 자동화 웹훅 디스패처는 각각 독립적으로 배포되지만 PaymentStatus, SettlementResult, WebhookPayload 같은 핵심 도메인 타입을 공유한다. 이 타입 하나가 한 서비스에서만 바뀌면 나머지 서비스의 계약이 조용히 깨진다. 일반적인 웹 서비스라면 렌더링이 이상하게 나오고 끝이지만, 결제·정산에서는 금액 오차, 원장 불일치, 규제 이슈로 번진다.

HEDVION은 결제 수집 API, 정산 배치 엔진, 자동화 웹훅 디스패처를 3명이 운영한다. 세 서비스는 배포 주기가 다르지만 도메인 모델을 강하게 공유한다. 이 구조에서 저장소 분리가 어떤 사고를 만들었는지, 통합 후 무엇이 달라졌는지를 구체적으로 공유한다. 정답이 아니라 우리의 조건에서 나온 답이다.

polyrepo 시절: 타입 불일치가 실제 정산 사고로 이어졌다

초기 구조는 payment-api, settlement-engine, infra-scripts를 각각 별도 저장소로 분리했다. 각 서비스가 독립적으로 버전 관리되고 배포된다는 게 깔끔하게 느껴졌다. 문제는 이 독립성이 공통 타입이 '공통이 아니게 되는 속도'를 감출 수 없다는 것이었다.

구체적인 사고 사례를 공유한다. 결제 API에서 amount 필드의 타입을 string("10000", 원화 문자열)에서 number(10000, 정수)로 바꾸는 PR이 머지됐다. 이유는 타당했다 — 정산 배치가 매번 parseInt를 중복 호출하는 게 비효율적이었고, 내부 API이니 타입을 통일하는 게 맞았다. 문제는 이 변경이 payment-api 저장소의 PR로만 완결됐다는 점이다. settlement-engine은 별도 저장소였기 때문에 PR 리뷰어 누구도 settlement 코드를 동시에 볼 수 없었다. 다음 정산 배치가 돌았을 때, JS의 타입 강제 변환으로 "10000" + 5000 = "100005000"이 되는 버그가 발생했다. 해당 배치 전체를 롤백하고 재계산하는 데 약 2시간이 걸렸으며, 그날 정산 마감이 1시간 지연됐다. 이것이 전환을 결정한 직접적인 계기였다.

monorepo 전환 후 달라진 것 — 수치로 비교한다

전환 직후 가장 먼저 체감한 변화는 "변경의 가시성"이다. monorepo에서는 PaymentStatus 열거형을 수정하는 PR이 TypeScript 컴파일러를 통해 연관된 모든 서비스의 깨진 지점을 즉시 드러낸다. 사람이 체크리스트를 지키는 게 아니라 빌드가 실패하면 배포가 막힌다. 이 구조 하나로 타입 불일치 관련 정산 사고는 전환 이후 0건이다.

수치로 비교하면: polyrepo 시절 공통 타입 변경 하나를 처리하는 평균 소요 시간은 약 45분이었다(공통 패키지 수정 → npm 배포 → 각 저장소 버전업 PR → 리뷰 → 머지). monorepo 전환 후 동일 작업은 단일 PR 하나, 평균 12분으로 줄었다. CI 시간은 늘었다 — polyrepo에서 각 서비스 CI가 평균 3분이었다면, monorepo는 전체 파이프라인이 8~11분이다. 그러나 Turborepo의 --filter 플래그로 변경된 패키지와 그 downstream만 선택적으로 빌드하기 때문에, 서비스 3개를 각각 돌리던 총 9분과 사실상 동일하거나 낫다. 그리고 "공통 변경 → 각 저장소 PR 왕복" 비용이 사라졌으므로 개발자 순수 투입 시간 기준으로는 명확히 효율적이다.

우리 팀의 실제 구조: shared-types가 계약을 정의한다

현재 HEDVION의 monorepo 구조는 다음과 같다:

/apps
  /payment-api          # Express + TypeScript, 결제 수집 엔드포인트
  /settlement-engine    # Node.js 배치, 정산 계산 및 원장 기록
  /webhook-dispatcher   # 자동화 웹훅 발송 서비스
/packages
  /shared-types         # PaymentStatus, SettlementResult, WebhookPayload 등 도메인 타입
  /shared-utils         # 금액 계산, 날짜 변환, 페이지네이션 헬퍼
  /eslint-config        # 공통 린트 규칙 (금융 데이터 처리 커스텀 룰 포함)

핵심은 packages/shared-types가 각 서비스의 계약을 정의한다는 점이다. PaymentStatusPARTIALLY_REFUNDED를 추가하면, 해당 PR에서 settlement-engine과 webhook-dispatcher의 switch문도 함께 수정된다. TypeScript의 exhaustive check(switch의 default: never 패턴)가 빠진 케이스를 컴파일 에러로 잡아주기 때문에, "어디어디를 다 고쳐야 하지?"라고 슬랙에 묻는 일이 사라졌다. PR 리뷰어는 한 화면에서 세 서비스의 변경을 동시에 검토하기 때문에, "결제 API는 맞는데 정산 쪽은 이 케이스를 안 다루네"라는 피드백이 배포 전 리뷰 단계에서 나온다. 이전에는 배포 후 배치 오류 로그에서 나왔다.

monorepo가 불리해지는 조건 — 우리가 정한 임계점

솔직히 말하면 monorepo가 항상 좋지는 않다. 우리가 경험하거나 예상하는 불리한 지점은 세 가지다.

첫째, 외부 팀과의 저장소 공유가 필요해질 때다. 외부 PG사나 파트너 개발자에게 특정 서비스 코드를 공개해야 한다면, monorepo 전체를 공유할 수 없다. git sparse-checkout이나 별도 분리를 검토해야 하는 복잡도가 생긴다. 둘째, 서비스별 긴급 배포 시 CI 병목이다. 결제 API는 핫픽스가 10분 안에 배포돼야 하는 상황이 생기는데, monorepo의 영향 분석 파이프라인이 이를 느리게 만들 수 있다. 현재는 path-based CI와 --filter 선택적 빌드로 완화하고 있지만, 파이프라인 설정 복잡도는 polyrepo보다 분명히 높다. 셋째, 팀이 10명 이상으로 커져 서비스별 오너십이 명확히 분리될 때다. 여러 팀이 같은 저장소에 PR을 올리면 CODEOWNERS 설정과 리뷰 대상 경계가 복잡해진다.

우리가 내부적으로 정한 분리 기준은 명확하다: "이 서비스가 shared-types를 쓰지 않고, 다른 서비스와 동일 PR에서 변경될 일이 없다면 polyrepo." 현재 내부 어드민 대시보드(Next.js)가 그 경계에 걸쳐 있다. shared-types 일부를 쓰지만 배포 주기가 완전히 독립적이라, 팀이 커지면 분리 1순위 후보다.

지금 바로 써먹을 판단 기준과 첫 단계

결제·정산·자동화 서비스를 운영하는 소규모 팀이라면 아래 체크리스트로 오늘 판단할 수 있다.

monorepo로 가야 하는 신호 (하나라도 해당되면):

  • 두 개 이상의 서비스가 같은 도메인 타입(결제 상태, 금액 구조, 웹훅 스펙)을 공유한다
  • "공통 타입 바꿨는데 A 서비스에 반영 안 됐다"는 사고가 한 번이라도 있었다
  • 공통 ESLint·테스트 설정을 여러 저장소에 복사-붙여넣기로 관리하고 있다
  • 팀 10명 미만이고 서비스 간 PR 연동이 주 2회 이상 필요하다

전환 첫 단계 (2주 안에 가능):

  1. pnpm workspaces 또는 Turborepo로 기존 저장소들을 apps/ 하위로 병합한다. 코드는 건드리지 않고 폴더 구조만 이동한다.
  2. 가장 자주 복사됐던 타입 파일을 packages/shared-types로 추출하고, 각 서비스의 import 경로만 수정한다.
  3. GitHub Actions에 paths 필터를 추가해 변경된 패키지와 downstream만 CI가 돌도록 설정한다. 전체 CI 시간이 polyrepo 대비 크게 늘지 않도록 하는 핵심 설정이다.
  4. 첫 2주 동안 "이 PR을 monorepo에서 했으면 얼마나 편했을까"를 팀 노션에 기록한다. 3건 이상 나오면 전환 확신이 생긴다.

polyrepo로 돌아가야 하는 시점도 미리 정해두자: 외부 팀과 저장소를 공유해야 하거나, CI 병목으로 긴급 배포가 실제로 지연되는 사례가 발생하면 그때 해당 서비스만 분리한다. "나중에 커지면 분리하자"가 아니라, "이 서비스가 이 조건에 해당하면 분리한다"는 기준을 지금 팀과 합의해두는 것이 핵심이다. 합의 없는 monorepo는 결국 "왜 다 한 저장소에 있어?"가 되고, 합의 없는 polyrepo는 "이거 왜 아직도 안 맞아?"가 된다.

— by slecs, HEDVION

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.