pnpm 워크스페이스와 Turborepo로 모노레포 구성하기

패키지 경계부터 원격 캐시까지, 순서대로 구성하는 모노레포

pnpm Turborepo 모노레포 썸네일

핵심 요약

  1. pnpm 워크스페이스는 apps/packages 디렉터리 선언과 workspace: 프로토콜로 로컬 패키지를 연결한다.
  2. 설정 패키지는 의존 그래프의 최하단에 두어 순환 참조를 막는다.
  3. Turborepo는 dependsOn으로 태스크 순서를, inputs/outputs로 캐시 경계를 정의한다.
  4. 원격 캐시는 편리하지만 inputs·env 누락 시 오래된 결과를 재사용해 간헐적 실패를 만든다.

모노레포를 도입하면 코드 공유는 쉬워지지만, 그 대가로 "어디까지가 한 패키지인가"라는 질문이 계속 따라온다. 경계를 대충 정하면 앱 하나를 고쳤는데 전혀 상관없는 앱의 빌드가 깨지고, 반대로 너무 잘게 쪼개면 관리 부담만 늘어난다. 이 글은 pnpm 워크스페이스로 패키지를 묶고 Turborepo로 태스크와 캐시를 관리하는 구성을 순서대로 정리한다.

모노레포가 이득인 경우와 아닌 경우

모노레포는 여러 프로젝트가 같은 코드와 설정을 공유하고, 변경이 여러 패키지에 걸쳐 함께 배포될 때 이득이다. 반대로 각 서비스가 독립적으로 릴리스되고 팀 경계가 뚜렷하다면 저장소를 나누는 편이 단순할 수 있다. 다음 조건이 겹치면 도입을 다시 생각한다.

  • 패키지 간 릴리스 주기가 완전히 달라 한 저장소에서 묶는 이점이 거의 없다.
  • 권한 분리가 강하게 필요해 저장소 단위 접근 제어가 필수다.
  • CI 자원이 부족해 전체 저장소 빌드 비용을 감당하기 어렵다.

워크스페이스 구조와 패키지 경계 설계

pnpm 워크스페이스는 루트의 설정 파일에 어떤 디렉터리를 패키지로 볼지 선언하는 방식으로 시작한다.

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

이후 각 패키지는 자체 package.json을 갖고, 루트에는 공통 개발 도구와 스크립트만 둔다. 의존성을 설치할 때는 워크스페이스 내부 패키지를 workspace: 프로토콜로 참조해 로컬 버전이 연결되도록 한다.

앱·공용 패키지·설정 패키지 나누기

경계를 나누는 실용적인 기준은 "무엇이 무엇에 의존하는가"다. 보통 다음 세 층으로 나눈다.

층예시의존 방향
앱웹 앱, 관리자 앱공용 패키지에 의존
공용 패키지UI, 유틸, API 클라이언트설정 패키지에 의존
설정 패키지tsconfig, eslint, tailwind 프리셋아무것도 의존하지 않음

설정 패키지가 다른 패키지에 의존하기 시작하면 순환 참조가 생기므로, 설정은 항상 가장 아래 층에 둔다. 새 패키지를 추가할 때 발생하는 설정 중복이나 구조 파편화는 AI 기반 프로젝트 보일러플레이트 자동화를 통해 템플릿과 생성 범위를 규격화해 두면 효과적으로 방지할 수 있다.

의존성 관리와 버전 정책

여러 패키지가 같은 라이브러리를 각자 다른 버전으로 요구하면 중복 설치가 생긴다. pnpm은 카탈로그 기능으로 버전을 한곳에서 선언하고 각 패키지가 참조하게 해 이 문제를 줄인다.

# pnpm-workspace.yaml
catalog:
  react: ^19.0.0
  typescript: ^5.6.0

# 각 패키지 package.json
"dependencies": { "react": "catalog:" }

호이스팅에 기대어 선언하지 않은 의존성을 몰래 쓰는 코드는 모노레포에서 특히 위험하다. pnpm의 엄격한 node_modules 구조는 이런 실수를 조기에 드러내 주지만, CI에서 의존성 검사를 한 번 돌려 두는 편이 좋다.

Turborepo 태스크와 캐시 설정

Turborepo는 각 패키지의 스크립트를 태스크 그래프로 묶고, 입력이 같으면 이전 결과를 재사용한다. 루트 turbo.json에 태스크와 입출력을 정의한다.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**"]
    },
    "test": { "dependsOn": ["build"], "outputs": [] }
  }
}

dependsOn의 ^build는 "내가 의존하는 패키지의 build를 먼저 실행"이라는 뜻이다. 태스크 간 의존을 명시하지 않으면 병렬 실행 중 잘못된 순서로 빌드되어 간헐적 실패가 생긴다.

CI에서 캐시 활용하기

로컬 캐시만으로도 반복 실행이 빨라지지만, CI에서는 원격 캐시를 붙여야 클론마다 처음부터 다시 빌드하는 비용을 줄일 수 있다. 캐시 키는 입력 파일과 환경 변수에 의해 결정되므로, 빌드에 영향을 주는 값을 inputs 또는 env에 빠짐없이 넣는다. 빠뜨리면 오래된 결과를 재사용해 "로컬에서는 되는데 CI에서는 안 되는" 문제가 생긴다. 파이프라인 구성은 GitHub Actions 워크플로 정리 글과 함께 보면 캐시 단계 배치를 잡기 쉽다.

로컬 개발 경험 맞추기

모노레포에서는 "내 패키지만 빠르게 돌리기"가 중요하다. 전체 빌드와 테스트를 매번 돌리면 개발 루프가 길어진다. 필터로 특정 패키지와 그 의존성만 실행하고, 변경된 패키지 기준으로 태스크를 선택한다.

pnpm --filter web dev
pnpm --filter web... build
npx turbo run test --filter=...[origin/main]

또한 루트에서 제공하는 공통 스크립트 이름을 통일해 어느 패키지에서든 같은 명령으로 실행되게 한다. 개발자가 패키지마다 다른 명령을 외워야 하면 온보딩이 느려지고 실수가 늘어난다. 루트 스크립트는 "무엇을 하는가"를 기준으로 이름을 정하고, 세부 옵션은 각 패키지에 둔다.

도입 후 흔한 문제와 주의점

  • 순환 참조: 패키지 A가 B를, B가 A를 참조하면 빌드 그래프가 성립하지 않는다. 층 구조를 강제한다.
  • 설정 드리프트: 앱마다 tsconfig·lint 규칙이 조금씩 달라진다. 공유 설정 패키지를 단일 출처로 둔다.
  • 캐시 오염: 환경 변수를 inputs에 넣지 않아 다른 환경의 결과를 재사용한다.
  • 과도한 분할: 너무 잘게 나누면 각 패키지의 설정·테스트 부담이 커진다.

모노레포는 도구보다 규칙이 중요하다. 층 구조와 버전 정책, 캐시 입력 규칙을 문서로 정해 두고 CI가 이를 강제하도록 만드는 것이 핵심이다.

초보자가 자주 실수하는 포인트

  • 태스크 의존(dependsOn)을 지정하지 않아 병렬 실행 중 빌드 순서가 뒤바뀐다.
  • hoisting에 기대어 package.json에 선언하지 않은 패키지를 import한다.
  • 빌드에 영향을 주는 환경 변수를 캐시 inputs에서 빠뜨린다.

체크리스트

  • 워크스페이스 디렉터리와 패키지 경계를 층 구조로 정했다.
  • 내부 의존은 workspace: 프로토콜로 참조하고 카탈로그로 버전을 통일했다.
  • turbo.json에 태스크 의존과 inputs/outputs를 정의했다.
  • CI에 원격 캐시를 연결하고 캐시 키 입력을 점검했다.
  • 순환 참조·설정 드리프트를 막는 규칙을 문서화했다.

자주 묻는 질문

pnpm 대신 npm이나 yarn을 써도 되나요?

가능하지만 워크스페이스 프로토콜과 엄격한 의존성 구조는 pnpm이 모노레포에서 특히 편하다. 도구를 혼용하면 잠금 파일이 갈라지므로 하나로 통일한다.

Turborepo 없이 pnpm만으로 충분한가요?

패키지가 적고 빌드가 짧다면 pnpm만으로도 충분하다. 태스크 수가 늘고 반복 빌드 비용이 커질 때 Turborepo 캐시가 효과를 낸다.

원격 캐시는 반드시 필요한가요?

로컬 반복 실행만 빠르게 하려면 없어도 된다. 다만 CI나 여러 개발자 환경에서 캐시를 공유하려면 원격 캐시가 실질적인 효과를 낸다.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.