📚 모노레포 아키텍처 결정기 #2

pnpm workspace 의존성 관리 삽질기 — 모노레포에서 겪은 5가지 함정

pnpm workspace + Turborepo 모노레포에서 의존성 관리 중 겪은 실전 트러블슈팅. TypeScript 버전 충돌, Docker 빌드 실패, Prisma Client 미생성, DI 에러까지 — 증상별 원인과 해결법을 정리했다.

pnpm workspace 의존성 관리 삽질기 — 모노레포에서 겪은 5가지 함정

💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • pnpm workspace 모노레포는 로컬은 되는데 Docker에서 터지는 패턴이 반복된다
  • TypeScript 버전 충돌은 pnpm.overrides로 루트에서 전역 고정하면 해결된다
  • Dockerfile에서 tsconfig extends 체인 전체를 COPY하지 않으면 빌드가 깨진다
  • pnpm deploy --legacy는 Prisma Client 같은 generated 파일을 자동 복사하지 않는다
  • workspace:* 프로토콜은 모노레포 전체를 install하는 컨텍스트에서만 동작한다

🔥 증상: “로컬에선 되는데 Docker에서 안 돼요”

로컬에서 되는데 CI에서 안 될 때의 표정
로컬에서 되는데 CI에서 안 될 때의 표정

pnpm workspace로 모노레포를 구성하면 로컬 개발은 쾌적하다. pnpm install 한 번이면 모든 앱이 의존성을 공유하고, workspace:* 프로토콜로 패키지 간 참조도 깔끔하다.

문제는 빌드 환경과 배포 환경에서 터진다. (모노레포를 왜 선택했는지는 이전 글에서 다뤘다.)

NestJS API + React 어드민/포털 + 공유 타입 패키지로 구성된 모노레포에서 최소 5번 의존성 지옥에 빠졌다. 전부 “로컬에선 되는데…”로 시작하는 이슈였다. 💀

핵심: pnpm workspace의 hoisting 동작은 환경마다 다르게 작동할 수 있다. 로컬 성공이 Docker 성공을 보장하지 않는다.

아래 5가지 함정은 겪은 순서대로 정리했다. 증상 → 원인 → 해결 → 예방 흐름으로 따라오면 된다.


🔍 원인 1: TypeScript 버전 충돌 — 데코레이터 오류 3,132개

workspace 프로토콜의 함정을 추적하는 중
workspace 프로토콜의 함정을 추적하는 중

❌ 증상

Docker 빌드에서 TypeScript 데코레이터 관련 에러가 3,132개 쏟아졌다.

error TS1240: Unable to resolve signature of property decorator...
error TS1241: Unable to resolve signature of method decorator...

로컬에서는 멀쩡하다. 빌드 서버에서만 재현됐다.

🔎 원인 분석

pnpm의 hoisting 동작 때문이다. 루트와 apps/api가 각각 다른 TypeScript 버전을 참조할 수 있다.

로컬에서는 hoisted된 버전이 우연히 맞지만, Docker의 깨끗한 환경에서는 충돌한다.

NestJS 데코레이터는 TypeScript 5.0+ 실험적 데코레이터를 사용하는데, 마이너 버전 차이만으로도 시그니처 해석이 달라진다.

pnpm why typescript를 실행해보면 같은 프로젝트 안에 버전이 두세 개 섞여 있는 걸 발견할 수 있다 😅

참고로 pnpm 공식 문서의 overrides 설명에서 이 기능의 정확한 동작을 확인할 수 있다.

주의: 데코레이터 에러가 갑자기 3천 개 넘게 쏟아진다면, 타입 자체 문제가 아닌 TypeScript 버전 불일치를 먼저 의심해야 한다.

✅ 해결

package.json 루트에 pnpm.overrides로 TypeScript 버전을 전역 고정했다.

{
  "pnpm": {
    "overrides": {

...

      "typescript": "5.7.3" // 워크스페이스 전체 버전 고정
    }
  }

...

}

이 한 줄로 워크스페이스 전체의 TypeScript 버전이 통일된다. pnpm installpnpm why typescript로 버전이 하나로 고정됐는지 확인한다.

🛡️ 예방

  • 모노레포에서 TypeScript는 반드시 루트에서 버전 고정
  • 신규 패키지 추가 시마다 pnpm why typescript로 버전 오염 여부 확인
  • devDependencies에 명시된 TS 버전과 pnpm.overrides 버전 일치 여부 체크

🔍 원인 2: Docker 빌드에서 tsconfig 못 찾음

결국 tsconfig 경로 설정이 범인이었다
결국 tsconfig 경로 설정이 범인이었다

❌ 증상

error TS5057: Cannot find a tsconfig.json file at the specified location...

로컬 빌드는 정상이지만 Docker에서만 실패한다. 처음에는 경로 문제인 줄 알고 한참 헤맸다.

🔎 원인 분석

모노레포 구조에서 apps/api/tsconfig.json은 루트의 tsconfig.base.jsonextends로 참조한다. Docker COPY 단계에서 루트 설정 파일을 복사하지 않으면 빌드가 깨진다.

로컬에서는 당연히 루트 파일이 존재하니 문제가 없지만, Docker 이미지 안에는 명시적으로 복사한 파일만 존재한다.

extends 체인을 따라가 보면 루트에 tsconfig.base.json 하나인 경우가 대부분이다.

팁: Dockerfile을 작성할 때 extends 체인을 끝까지 추적하는 것이 핵심이다. 앱 폴더의 tsconfig만 복사하고 루트 base를 빠뜨리는 실수가 가장 흔하다.

✅ 해결

Dockerfile에서 루트 설정 파일을 명시적으로 COPY한다.

# 루트 base config 먼저 복사
COPY tsconfig.base.json ./

# 앱별 tsconfig 복사
COPY apps/api/tsconfig.json ./apps/api/
COPY apps/api/tsconfig.build.json ./apps/api/

🛡️ 예방

  • Dockerfile 작성 시 extends 체인을 따라가며 모든 참조 파일 COPY
  • pnpm deploy --legacy를 사용해도 설정 파일은 여전히 수동 복사가 필요하다
  • Docker 빌드를 로컬에서도 주기적으로 돌려 환경 차이를 조기 발견

🔍 원인 3: pnpm deploy --legacy와 Prisma Client

패키지 버전 충돌의 근본 원인을 찾은 순간
패키지 버전 충돌의 근본 원인을 찾은 순간

❌ 증상

Docker 빌드 성공, 컨테이너 실행하는 순간:

Error: @prisma/client did not initialize yet. Please run "prisma generate"

빌드는 됐는데 실행이 안 된다. prisma generate를 로컬에서는 이미 실행했는데 왜 이러지 싶었다.

🔎 원인 분석

pnpm deploy --legacy는 production 의존성만 격리 복사한다. Prisma Client는 prisma generate로 생성되는 코드로, node_modules/.prisma/client에 위치한 generated 파일이다. (Prisma 마이그레이션 관련 삽질은 별도 글에서 자세히 다뤘다.)

pnpm 공식 문서에 따르면, pnpm deploynode_modules/.pnpm 경로의 심볼릭 링크 구조를 따라 복사하는데, .prisma/client 경로가 이 과정에서 누락되는 케이스가 있다.

결과적으로 배포된 node_modules에는 @prisma/client 패키지 자체는 있지만, 실제 DB 스키마 기반 코드가 없는 상태가 된다.

주의: Prisma뿐만 아니라 generate 단계가 별도로 필요한 패키지라면 동일한 문제를 겪을 수 있다. ORM, gRPC 코드젠, GraphQL codegen 등이 해당된다.

✅ 해결

Dockerfile에서 Prisma Client를 수동으로 복사하는 단계를 추가했다.

# production 의존성 격리
RUN pnpm deploy --filter=api --legacy /app/deployed

# Prisma Client는 자동 복사 안 됨 — 수동으로
COPY --from=build /app/node_modules/.prisma /app/deployed/node_modules/.prisma

🛡️ 예방

  • ORM을 사용하는 모노레포에서 Docker 빌드 시 generated 파일 복사 확인 필수
  • prisma generate를 빌드 스크립트에 포함시켜 실수 방지:
{
  "scripts": {
    "prebuild": "prisma generate"

...

  }
}

🔍 원인 4: pnpm build 통과했는데 런타임 에러

또 다른 의존성 지옥 입장
또 다른 의존성 지옥 입장

❌ 증상

스키마 변경 후 pnpm build 성공. 서버를 실행하면:

The column 'snapshotData' does not exist in the current database

또는 NestJS DI 에러:

Nest can't resolve dependencies of the SomeService (?).
Please make sure that the argument at index [0] is available in the AppModule context.

빌드는 통과했는데 서버가 안 뜬다.

🔎 원인 분석

두 가지 함정이 섞여 있다.

함정 1: pnpm buildprisma generate

pnpm build는 TypeScript 컴파일만 한다. Prisma 스키마를 수정해도 prisma generate를 따로 실행하지 않으면 Prisma Client에 새 필드가 반영되지 않는다. TypeScript는 이전 타입으로 컴파일하므로 빌드는 통과하지만, 런타임에 터진다.

함정 2: pnpm build ≠ DI 검증

NestJS의 DI 컨테이너는 런타임에 구성된다. 서비스에 새 의존성을 추가하고 모듈의 providers에 등록하지 않으면, TypeScript 컴파일은 통과하지만 서버가 기동되지 않는다.

핵심: 빌드 성공 ≠ 동작 보장. TypeScript 컴파일 → prisma generate → 서버 기동 테스트, 세 단계를 모두 확인해야 진짜 검증이다.

✅ 해결

빌드 스크립트에 prebuild와 서버 기동 확인 단계를 추가했다.

{
  "scripts": {
    // 빌드 전 Prisma Client 재생성

...

    "prebuild": "prisma generate",
    // 빌드 후 기동 확인 (실패해도 CI 블로킹 안 함)
    "postbuild": "PORT=3001 node dist/main.js --dry-run || true"

...

  }
}

🛡️ 예방

  • 스키마 변경 시 순서: prisma generatepnpm build → 서버 기동 확인
  • 서비스 의존성 추가 시: 모듈 providers 등록 확인 → 서버 기동 확인
  • CI 파이프라인에 서버 기동 smoke test 단계 추가

🔍 원인 5: workspace 프로토콜과 배포 환경

hoisting 설정 하나로 모든 게 해결된 순간
hoisting 설정 하나로 모든 게 해결된 순간

❌ 증상

로컬에서 workspace:*로 참조하는 공유 패키지가 CI/CD 환경이나 Docker에서 해석되지 않는다.

ERR_PNPM_NO_MATCHING_VERSION
No matching version found for @repo/shared-types@*

🔎 원인 분석

workspace:* 프로토콜은 pnpm workspace 컨텍스트에서만 동작한다. CI 환경에서 개별 앱만 설치하거나, npm publish 시에는 이 프로토콜을 해석할 수 없다.

로컬에서는 pnpm-workspace.yamlpnpm-lock.yaml이 함께 있어서 자동으로 해석되지만, 격리된 환경에서는 “없는 버전”으로 취급된다.

문제가 되는 시나리오는 크게 세 가지다:

  • Docker에서 앱 디렉토리만 COPY하고 install 시도
  • CI에서 루트가 아닌 앱 경로에서 pnpm install 실행
  • 내부 패키지를 npm 레지스트리에 publish할 때

팁: pnpm pack을 사용하면 workspace:*가 자동으로 실제 버전으로 치환된다. npm 레지스트리에 publish하는 경우라면 이걸 활용하면 된다.

✅ 해결

환경별로 접근 방식이 다르다.

  • Docker: 전체 모노레포를 COPY한 뒤 pnpm install --frozen-lockfile 실행
  • CI: 루트에서 pnpm install 후 특정 앱 빌드
  • publish: pnpm pack이 자동으로 workspace:*를 실제 버전으로 치환

🛡️ 예방

  • Docker 멀티스테이지 빌드에서 모노레포 전체를 복사하는 단계 유지
  • 개별 패키지 디렉토리에서 단독 pnpm install 시도하지 않기
  • pnpm deploy --filter=<app>으로 production 의존성 격리하는 방식 사용
  • Turborepo 공식 Docker 가이드도 참고하면 좋다

✅ 정리: pnpm workspace 의존성 체크리스트

5개 함정을 전부 해결하고 나니 뿌듯하다
5개 함정을 전부 해결하고 나니 뿌듯하다

함정핵심 원인해결 키워드
TS 데코레이터 오류 3천+ 개버전 충돌pnpm.overrides
tsconfig 못 찾음extends 체인 미복사Dockerfile COPY
Prisma Client 없음generated 파일 미복사수동 COPY
빌드 OK, 런타임 NGgenerate/DI 미검증기동 테스트
workspace:* 해석 불가컨텍스트 의존전체 모노레포 install

모노레포는 “설정 한 번이면 끝”이 아니다. 로컬 / Docker / CI, 세 환경에서 의존성이 동일하게 해석되는지 반드시 확인해야 한다.

pnpm workspace 삽질은 알면 30분, 모르면 며칠이다 🚀

다음 편에서는 스펙 변경에 강한 API 설계 패턴을 다룬다.


📚 모노레포 아키텍처 결정기 시리즈 (2편)

  1. 1. NestJS + React 모노레포 구성법 — 1인 개발자의 pnpm workspace + Turborepo 실전기
  2. 2. pnpm workspace 의존성 관리 삽질기 — 모노레포에서 겪은 5가지 함정