모노레포 이해해보기

@choi2021 · October 12, 2025 · 9 min read

모노레포란?

모노레포(Monorepo)는 여러 프로젝트를 하나의 저장소에서 관리하는 개발 전략이다. 현재 내가 일하고 있는 토스에서도 다양한 글과 발표를 했을 정도로, 여러 프로젝트를 동시에 운영해야할 때 문제점을 해소하기 위해 채택하고 있다.

그러면 멀티 레포 방식의 어떤 문제를 해결하기 위해 모노레포를 선택하는 것일까?

멀티 레포의 문제점

공통 로직을 shared 패키지로 분리하고, 이를 여러 서비스(a, b)에서 사용하는 상황을 가정해보자. 패키지 매니저는 npm인 상황이다.

프로젝트 구조
├── shared/          (공통 로직)
├── service-a/       (서비스 A)
└── service-b/       (서비스 B)

1. 패키지 배포 및 버전 관리의 복잡성

shared를 import하려면 별도 패키지로 npm에 배포하고, 각 서비스에서 해당 버전을 명시해야 한다.

// service-a/package.json
{
  "dependencies": {
    "shared": "^1.0.0" // npm에 배포된 버전
  }
}

이때 Atomic 변경이 불가능하다는 치명적인 문제가 있다:

  • shared 수정 → npm 배포 → service-a, service-b의 버전 업데이트
  • shared에서 에러 발생 시, 모든 의존 서비스를 롤백해야 함

shared를 의존하는 서비스가 늘어날 수록 총 N+1개의 PR, N+1개의 리뷰, N+1번의 배포가 필요하다보니 개발 비용이 크게 늘어나는 상황이 된다.

2. 중복된 node_modules로 인한 리소스 낭비

모노레포와는 조금 다른 npm 패키지 매니저의 문제이지만 여러 서비스를 npm으로 운영했을 때의 문제점으로, 각 서비스가 독립된 node_modules를 가지면서 동일한 패키지가 중복 저장된다.

monorepo/
├── shared/
│   └── node_modules/              # 약 50MB
│       ├── lodash@4.17.21/        (500KB)
│       ├── axios@1.6.0/           (1.2MB)
│       └── typescript@5.3.0/      (30MB)
│
├── service-a/
│   └── node_modules/              # 약 250MB
│       ├── lodash@4.17.21/        (500KB) ← 중복!
│       ├── axios@1.6.0/           (1.2MB) ← 중복!
│       └── typescript@5.3.0/      (30MB) ← 중복!
│
└── service-b/
    └── node_modules/              # 약 280MB
        ├── lodash@4.17.21/        (500KB) ← 또 중복!
        ├── axios@1.6.0/           (1.2MB) ← 또 중복!
        └── typescript@5.3.0/      (30MB) ← 또 중복!

총 디스크 사용량: 580MB
실제 고유 패키지: 약 250MB
중복: 330MB (57% 낭비!)

3. 코드 검색의 어려움

저장소 경계로 인해 전체 코드베이스를 대상으로 검색하거나 리팩토링하기 어렵다. 특정 함수가 어디서 사용되는지 추적하려면 여러 저장소를 오가며 확인해야 한다.

4. 팀 협업 비용 증가

  • 3개 저장소의 컨텍스트를 모두 이해해야 함
  • 의존성 변경 시 여러 저장소에 걸친 코드 리뷰 필요
  • 로컬 개발 환경 설정 복잡성 증가

모노레포로 해결하기

모노레포는 위 문제들을 근본적으로 해결한다:

단일 커밋으로 Atomic 변경: shared와 서비스를 동시에 수정하고 하나의 PR로 배포
패키지 중복 제거: 공유 캐시를 통해 디스크 사용량 최소화
통합 검색 및 리팩토링: 전체 코드베이스를 대상으로 작업 가능
간소화된 협업: 하나의 저장소에서 모든 것을 관리

여러가지 모노레포 구성 방식이 있지만, 간단히 토스에서 현재 사용하고 있는 Yarn PnP에 workspace를 더한 방식으로 구현해보려 한다.

Yarn PnP + Workspace

Yarn Berry의 Plug'n'Play(PnP)Workspace 기능을 조합하면 강력한 모노레포를 구축할 수 있다.

Yarn PnP의 장점

1. Minimal install footprint

node_modules 폴더를 생성하는 대신 .pnp.cjs 파일 하나만 생성한다.

# 기존 방식
yarn install
→ node_modules/ (수백 MB ~ 수 GB)

# PnP 방식
yarn install
→ .pnp.cjs (수백 KB)

2. Shared installs across disks

글로벌 캐시 디렉토리(~/.yarn/berry/cache/)에 패키지를 저장하고, 모든 프로젝트가 이를 공유한다. pnpm의 경우 hard link로 같은 패키지더라도 개별로 관리해 메모리 사용량이 큰 반면, yarn pnp는 globalCache를 이용할 수 있어 같은 패키지면 같이 사용한다.

# enableGlobalCache: false (기본)
project-1/.yarn/cache/  (800MB)  ← 독립 캐시
project-2/.yarn/cache/  (600MB)  ← 독립 캐시
project-3/.yarn/cache/  (500MB)  ← 독립 캐시
총 사용량: 1.9GB

# enableGlobalCache: true
~/.yarn/berry/cache/    (900MB)  ← 공유 캐시
총 사용량: 900MB (52% 절약!)

3. Ghost dependencies protection

package.json에 명시하지 않은 의존성에 접근하면 에러를 발생시킨다. 호이스팅으로 인한 암묵적 의존성 문제를 방지한다.

// package.json에 lodash가 없는데 사용하면
import _ from "lodash"
// ❌ Error: lodash is not listed in your dependencies

4. Semantic erroring

import 에러 발생 시 더 상세한 정보를 제공한다.

❌ 기존: Module not found
✅ PnP: Package "react" is not listed in dependencies.
       Did you mean "@types/react"?

실제 구현 예시

프로젝트 구조

monorepo/
├── package.json           # 루트 설정
├── .yarnrc.yml            # Yarn 설정
├── .pnp.cjs               # PnP 매니페스트
├── shared/
│   └── package.json
├── service-a/
│   └── package.json
└── service-b/
    └── package.json

1. 루트 package.json

{
  "name": "monorepo",
  "version": "1.0.0",
  "workspaces": ["shared", "service-a", "service-b"],
  "scripts": {
    "start:a": "yarn workspace service-a start",
    "start:b": "yarn workspace service-b start"
  },
  "packageManager": "yarn@4.1.0"
}

위 루트 요소에서 주요 속성에 대해 정리해보자.

주요 속성:

  • private: true: npm 배포 방지
  • workspaces: 각 패키지 경로 지정
  • packageManager: Corepack이 사용할 Yarn 버전

여기서 Corepack은 Node.js에 내장된 패키지 매니저로 corepack을 사용하면 팀원 모두가 동일한 Yarn 버전을 사용할 수 있게 된다.

// package.json
{
  "packageManager": "yarn@4.1.0"
}
# 1. corepack 활성화
corepack enable

# 2. yarn 명령어 실행
yarn install
# → corepack이 yarn@4.1.0 자동 다운로드 및 실행

2. .yarnrc.yml

nodeLinker: pnp

enableGlobalCache: true

npmRegistryServer: "https://registry.yarnpkg.com"

주요 설정:

  • nodeLinker: pnp: PnP 모드 활성화
  • enableGlobalCache: true: 글로벌 캐시 사용
  • npmRegistryServer: npm 레지스트리 URL

3. 워크스페이스 패키지 설정

// shared/package.json
{
  "name": "shared",
  "version": "1.0.0",
  "main": "index.js"
}

// service-a/package.json
{
  "name": "service-a",
  "version": "1.0.0",
  "dependencies": {
    "shared": "workspace:*"
  }
}

workspace:* 프로토콜:

  • 로컬 워크스페이스의 최신 코드를 항상 참조
  • 버전 관리 불필요
  • 변경 사항 즉시 반영

4. 워크스페이스 확인 및 실행

# 워크스페이스 목록 확인
yarn workspaces list
# shared
# service-a
# service-b

# 워크스페이스 정보 조회
yarn workspace service-a info
# ├─ service-a@workspace:service-a
# │  └─ Dependencies
# │     └─ shared@workspace:* → workspace:shared

마치며

내게 모노레포는 이미 세팅이 되어있다보니, 블랙박스와 같은 부분이었다. 인컴 서비스 규모가 커짐에 따라 서비스가 늘어나는 것이 예상되는 상황에서 어떻게 프로젝트들의 의존관계를 관리하면 좋을까 고민이되었고, 이번에 정리할 수 있었다.

이번 글에는 단순한 예제였지만, 여기에 배포 파이프라인과 인프라가 더해지면 더욱 복잡해지는데, 다음은 실제 Next 서비스를 여러개 있고, 배포하는 상황을 가정해서 배포 파이프라인을 구성하는 법을 고민해보고 정리해봐야겠다.

참고 자료

@choi2021
매일의 시행착오를 기록하는 개발일지입니다.