Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
Docker에서 pnpm 모노레포 NestJS 프로젝트를 빌드하면 TypeScript 데코레이터 에러가 3132개 터진다. TypeScript 버전 충돌부터 tsconfig 누락, pnpm deploy --legacy, Prisma Client 수동 복사까지 — 4가지 함정과 해결법을 실전 코드로 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Docker 빌드 시 TypeScript 데코레이터 에러 3132개가 터지면, 모노레포 내 TypeScript 버전 충돌이 원인일 가능성이 높음
pnpm.overrides로 TypeScript 버전을 워크스페이스 전체에 고정하는 게 핵심tsconfig.base.json을 COPY하지 않으면 extends 참조 실패로 컴파일 설정이 깨짐pnpm deploy --legacy를 써야 node_modules 플랫 구조로 NestJS 런타임 호환 확보- Prisma Client는
pnpm deploy에 포함되지 않으므로 수동 복사 로직 필수
로컬에서는 빌드가 깨끗하게 통과한다. CI/CD 파이프라인도 문제 없다.
근데 Docker 빌드만 돌리면 에러가 3132개 터진다. 전부 TypeScript 데코레이터 관련이다.
pnpm 모노레포를 Docker로 빌드하는 건, 로컬 빌드와 전혀 다른 세계라는 걸 이때 처음 알았다. 이 글은 그 삽질의 기록이다.
🔥 증상 — 데코레이터 에러 3132개

Docker 멀티스테이지 빌드를 구성하고 docker build를 실행했다.
$ docker build -t my-api .
빌드 로그가 올라가다가 컴파일 단계에서 멈춘다.
error TS1241: Unable to resolve signature of method decorator when called as an expression.
error TS1240: Unable to resolve signature of property decorator when called as an expression.
error TS1238: Unable to resolve signature of class decorator when called as an expression.
...
Found 3132 error(s).
3132개. 숫자를 보고 잠시 멍했다.
에러의 특징
에러가 전부 데코레이터에 집중되어 있었다.
@Injectable()— NestJS DI 데코레이터@Controller()— 라우트 데코레이터@Column()— Prisma/TypeORM 스타일 데코레이터@ApiProperty()— Swagger 데코레이터
로컬에서는 한 번도 본 적 없는 에러들이다.
pnpm build는 깨끗하게 통과하는데, Docker 안에서만 터진다.
🤔 핵심 의문: 같은 코드, 같은
pnpm-lock.yaml인데 왜 Docker에서만 데코레이터가 깨지는가?
🔍 탐색 — 4가지 함정을 하나씩 찾아내다

가설 1: Node.js 버전 차이?
처음에는 Node.js 버전이 다른 줄 알았다.
# 로컬
$ node -v
v20.11.0
# Docker (node:20-slim)
$ node -v
v20.11.1
패치 버전 차이만 있다. 이게 3132개 에러의 원인은 아니다.
가설 2: TypeScript 버전 충돌
두 번째 가설. 모노레포에서 TypeScript 버전이 패키지마다 다르면 어떻게 될까?
# Docker 컨테이너 안에서 확인
$ find node_modules -name "typescript" -path "*/node_modules/typescript/package.json" \
-exec grep '"version"' {} \;
"version": "5.7.3"
"version": "5.3.3"
"version": "5.6.2"
범인을 찾았다.
pnpm의 strict 의존성 해석 방식 때문에 각 패키지가 자기 package.json에 명시한 TypeScript 버전을 사용한다.
로컬에서는 hoisting 덕분에 루트의 TypeScript 하나로 통일되지만, Docker의 clean install에서는 각자 다른 버전이 설치된다.
NestJS의 데코레이터는 TypeScript 5.0+ 이상의 experimentalDecorators 구현에 의존한다.
버전이 섞이면 데코레이터 메타데이터 emit 방식이 충돌한다.
📌 핵심: pnpm 모노레포에서 TypeScript 버전이 패키지마다 달라지면, 데코레이터 컴파일 결과가 불일치한다. Docker clean install은 이 차이를 적나라하게 드러낸다.
가설 3: tsconfig를 못 찾는 건 아닐까?
TypeScript 버전을 고정해도 에러가 줄어들긴 했지만 완전히 사라지지 않았다. 빌드 로그를 다시 보니 다른 에러가 섞여 있었다.
error TS5083: Cannot read file '/app/tsconfig.base.json': ENOENT
모노레포에서는 보통 루트에 tsconfig.base.json을 두고 각 앱에서 extends로 참조한다.
// apps/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Dockerfile에서 tsconfig.base.json을 COPY하지 않았다.
로컬에서는 파일이 당연히 있으니 문제가 없었지만, Docker는 명시적으로 복사하지 않으면 존재하지 않는다.
extends 참조가 실패하면 experimentalDecorators와 emitDecoratorMetadata 설정이 적용되지 않는다.
그 결과 모든 데코레이터가 에러를 뱉는다.
가설 4: @nestjs/common을 못 찾는다?
데코레이터 에러를 잡고 나니 또 다른 에러가 나타났다.
Error: Cannot find module '@nestjs/common'
pnpm deploy로 프로덕션 의존성만 추출했는데, NestJS 패키지가 빠져 있다.
pnpm v8+에서 pnpm deploy는 기본적으로 symlink 기반으로 node_modules를 구성한다.
Docker의 COPY 단계에서 symlink가 깨지면 모듈을 못 찾는다.
--legacy 플래그를 붙이면 **플랫 구조(flat node_modules)**로 배포한다.
NestJS처럼 런타임에 require()로 모듈을 탐색하는 프레임워크에서는 이게 필수다.
🛠️ 해결 — Dockerfile 4가지 수정

❌ Before — 원래의 Dockerfile
FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# ❌ tsconfig.base.json 누락
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/api/package.json ./apps/api/
COPY packages/ ./packages/
# ❌ TypeScript 버전 충돌 상태로 install
RUN pnpm install --frozen-lockfile
COPY apps/api ./apps/api
# ❌ Prisma generate 빠짐
RUN pnpm run build
# ❌ --legacy 없이 deploy → symlink 깨짐
RUN pnpm --filter @my-org/api deploy --prod /app/deploy
✅ After — 수정된 Dockerfile
FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# ✅ 1. tsconfig.base.json 반드시 포함
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY turbo.json tsconfig.base.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/ ./packages/
# ✅ 2. pnpm.overrides로 TypeScript 5.7.3 고정된 상태로 install
RUN pnpm install --frozen-lockfile
COPY apps/api ./apps/api
# ✅ 3. Prisma Client 생성 → 빌드 순서
WORKDIR /app/apps/api
RUN npx prisma generate
RUN pnpm run build
# ✅ 4. --legacy로 플랫 node_modules 배포
WORKDIR /app
RUN pnpm --filter @my-org/api deploy --prod --legacy /app/deploy
# ✅ 5. Prisma Client 수동 복사
RUN mkdir -p /app/deploy/node_modules/.prisma && \
cp -r /app/node_modules/.pnpm/@prisma+client@*/node_modules/.prisma/client \
/app/deploy/node_modules/.prisma/
4가지 수정 포인트를 정리하면 이렇다.
| # | 문제 | 수정 |
|---|---|---|
| 1 | tsconfig.base.json 미복사 | COPY turbo.json tsconfig.base.json ./ 추가 |
| 2 | TypeScript 버전 충돌 | pnpm.overrides로 typescript: "5.7.3" 고정 |
| 3 | @nestjs/common 못 찾음 | pnpm deploy --legacy 플래그 추가 |
| 4 | Prisma Client 미포함 | .prisma/client 수동 복사 로직 추가 |
pnpm.overrides는 어디에?
루트 package.json에 추가한다.
{
"pnpm": {
"overrides": {
"typescript": "5.7.3"
}
}
}
이렇게 하면 워크스페이스 전체에서 TypeScript가 5.7.3 하나로 통일된다.
어떤 패키지가 "typescript": "^5.3.0"을 요구하든, 5.7.3이 강제 적용된다.
⚠️ 주의:
pnpm.overrides를 추가한 후 반드시pnpm install을 다시 실행하고,pnpm-lock.yaml을 커밋해야 한다. lockfile이 갱신되지 않으면 Docker에서 여전히 구버전이 설치된다.
Prisma Client 수동 복사가 필요한 이유
pnpm deploy는 package.json의 dependencies에 명시된 패키지만 추출한다.
그런데 prisma generate로 생성되는 Prisma Client는 node_modules/.prisma/client/에 위치한다.
이 경로는 dependencies에 없다.
@prisma/client는 포함되지만, 실제 생성된 런타임 코드(.prisma/client/)는 빠진다.
# pnpm deploy 결과물 확인
$ ls /app/deploy/node_modules/@prisma/client/
# ✅ 있음 — npm 패키지
$ ls /app/deploy/node_modules/.prisma/client/
# ❌ 없음 — generate로 생성된 런타임 코드
그래서 수동으로 복사해야 한다.
# ✅ builder 스테이지에서 생성된 Prisma Client를 deploy 폴더로 복사
RUN mkdir -p /app/deploy/node_modules/.prisma && \
cp -r /app/node_modules/.pnpm/@prisma+client@*/node_modules/.prisma/client \
/app/deploy/node_modules/.prisma/
📌 핵심: Prisma Client는 “설치”가 아니라 “생성”되는 코드다.
pnpm deploy의 의존성 추출 범위 밖이므로 수동 복사가 필수다.
프로덕션 스테이지
FROM node:20-slim AS runner
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs nestjs
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Asia/Seoul
# deploy 결과물 복사 (symlink 없는 플랫 구조)
COPY --from=builder /app/deploy/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/prisma ./prisma
COPY --from=builder /app/deploy/package.json ./
RUN chown -R nestjs:nodejs /app
USER nestjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/v1/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" || exit 1
CMD ["node", "dist/main"]
💡 팁:
node:20-slim이미지에는 OpenSSL이 없다. Prisma는 런타임에 OpenSSL을 요구하므로, runner 스테이지에서도apt-get install -y openssl을 반드시 추가해야 한다.
✅ 검증 — 빌드 성공 확인
수정 후 빌드를 다시 실행한다.
$ docker build -t my-api .
빌드 로그 (수정 전)
Step 8/15 : RUN pnpm run build
---> Running in abc123...
error TS1241: Unable to resolve signature of method decorator...
Found 3132 error(s).
ERROR: process "/bin/sh -c pnpm run build" did not complete successfully
빌드 로그 (수정 후)
Step 10/18 : RUN pnpm run build
---> Running in def456...
Successfully compiled 247 files.
Step 14/18 : RUN pnpm --filter @my-org/api deploy --prod --legacy /app/deploy
---> Packages deployed successfully
Step 18/18 : CMD ["node", "dist/main"]
---> Successfully built 7a8b9c0d
에러 0개. 이미지 크기도 확인해보자.
$ docker images my-api
REPOSITORY TAG SIZE
my-api latest 287MB
287MB. node:20-slim 기반 멀티스테이지 빌드의 장점이다.
builder 스테이지의 모든 빌드 도구가 최종 이미지에 포함되지 않는다.
$ docker run --rm -p 3000:3000 my-api
[Nest] LOG [NestFactory] Starting Nest application...
[Nest] LOG [NestApplication] Nest application successfully started
서버 기동까지 정상이다.
🛡️ 예방 — Docker + pnpm 모노레포 체크리스트

Dockerfile 작성 시 반드시 확인할 것
tsconfig.base.jsonCOPY 여부 —extends로 참조하는 파일은 전부 복사pnpm.overrides설정 여부 — TypeScript, ESLint 등 버전 충돌 가능한 도구는 루트에서 고정pnpm deploy --legacy플래그 — NestJS 등 런타임 모듈 탐색이 필요한 프레임워크에서 필수- Prisma Client 수동 복사 —
prisma generate결과물은deploy범위 밖 - OpenSSL 설치 —
node:20-slim에서 Prisma 런타임 필수 - non-root user — 프로덕션에서는 반드시 비루트 사용자로 실행
CI/CD에서 Docker 빌드 검증
# .github/workflows/docker-build.yml
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t my-api ./apps/api
- name: Smoke test
run: |
docker run -d --name test-api -p 3000:3000 my-api
sleep 5
curl -f http://localhost:3000/api/v1/health || exit 1
docker stop test-api
💡 팁: CI에서 Docker 빌드 + 헬스체크를 같이 돌리면, 런타임 에러(Prisma Client 누락, DI 에러 등)까지 잡을 수 있다. 빌드 성공만 확인하면 불충분하다.
로컬 vs Docker 차이 요약

| 항목 | 로컬 | Docker |
|---|---|---|
| node_modules | hoisting으로 루트에 통합 | 패키지별 독립 설치 |
| TypeScript | 루트 하나로 통일 | 각 패키지가 자체 버전 사용 가능 |
| tsconfig | 파일시스템에 항상 존재 | COPY하지 않으면 없음 |
| Prisma Client | generate 후 자동 인식 | pnpm deploy 범위 밖 |
| symlink | 정상 동작 | COPY 시 깨질 수 있음 |
📋 정리 — 핵심 요약

| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| TypeScript 데코레이터 에러 다수 | 패키지별 TypeScript 버전 방치 | pnpm.overrides로 단일 버전 고정 |
Cannot read tsconfig.base.json | Dockerfile에서 루트 설정 파일 미복사 | COPY tsconfig.base.json ./ 명시 |
Cannot find module '@nestjs/common' | pnpm deploy 기본 모드(symlink) 사용 | --legacy 플래그로 플랫 구조 배포 |
| Prisma 런타임 에러 | pnpm deploy가 자동으로 포함할 거라 기대 | .prisma/client/ 수동 복사 |
| Docker에서만 실패 | 로컬 빌드만 확인 | CI에 Docker 빌드 + smoke test 추가 |
Docker 빌드에서 pnpm 모노레포 삽질은, 결국 “로컬과 컨테이너의 환경 차이”를 이해하는 과정이다. 로컬에서 되는 건 아무 의미 없다. Docker에서 빌드하고, Docker에서 기동해봐야 진짜다 🐳
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유