Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체

Docker에서 pnpm 모노레포 NestJS 프로젝트를 빌드하면 TypeScript 데코레이터 에러가 3132개 터진다. TypeScript 버전 충돌부터 tsconfig 누락, pnpm deploy --legacy, Prisma Client 수동 복사까지 — 4가지 함정과 해결법을 실전 코드로 정리한다.


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

  • Docker 빌드 시 TypeScript 데코레이터 에러 3132개가 터지면, 모노레포 내 TypeScript 버전 충돌이 원인일 가능성이 높음
  • pnpm.overridesTypeScript 버전을 워크스페이스 전체에 고정하는 게 핵심
  • tsconfig.base.json을 COPY하지 않으면 extends 참조 실패로 컴파일 설정이 깨짐
  • pnpm deploy --legacy를 써야 node_modules 플랫 구조로 NestJS 런타임 호환 확보
  • Prisma Client는 pnpm deploy에 포함되지 않으므로 수동 복사 로직 필수

로컬에서는 빌드가 깨끗하게 통과한다. CI/CD 파이프라인도 문제 없다.

근데 Docker 빌드만 돌리면 에러가 3132개 터진다. 전부 TypeScript 데코레이터 관련이다.

pnpm 모노레포를 Docker로 빌드하는 건, 로컬 빌드와 전혀 다른 세계라는 걸 이때 처음 알았다. 이 글은 그 삽질의 기록이다.

🔥 증상 — 데코레이터 에러 3132개

이건 또 뭐지… 🔥 증상 — 데코레이터 에러 3132개에서 터진 에러
이건 또 뭐지… 🔥 증상 — 데코레이터 에러 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가지 함정을 하나씩 찾아내다

🔍 탐색 — 4가지 함정을 하나씩 찾아내다 디버깅에 지쳐가는 중
🔍 탐색 — 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 참조가 실패하면 experimentalDecoratorsemitDecoratorMetadata 설정이 적용되지 않는다. 그 결과 모든 데코레이터가 에러를 뱉는다.

가설 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가지 수정

드디어 🛠️ 해결 — Dockerfile 4가지 수정 문제를 잡았다
드디어 🛠️ 해결 — 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가지 수정 포인트를 정리하면 이렇다.

#문제수정
1tsconfig.base.json 미복사COPY turbo.json tsconfig.base.json ./ 추가
2TypeScript 버전 충돌pnpm.overridestypescript: "5.7.3" 고정
3@nestjs/common 못 찾음pnpm deploy --legacy 플래그 추가
4Prisma 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 deploypackage.jsondependencies에 명시된 패키지만 추출한다. 그런데 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 모노레포 체크리스트

🛡️ 예방 — Docker + pnpm 모노레포 체크리스트 재발 방지를 위한 안전장치
🛡️ 예방 — Docker + pnpm 모노레포 체크리스트 재발 방지를 위한 안전장치

Dockerfile 작성 시 반드시 확인할 것

  1. tsconfig.base.json COPY 여부extends로 참조하는 파일은 전부 복사
  2. pnpm.overrides 설정 여부 — TypeScript, ESLint 등 버전 충돌 가능한 도구는 루트에서 고정
  3. pnpm deploy --legacy 플래그 — NestJS 등 런타임 모듈 탐색이 필요한 프레임워크에서 필수
  4. Prisma Client 수동 복사prisma generate 결과물은 deploy 범위 밖
  5. OpenSSL 설치node:20-slim에서 Prisma 런타임 필수
  6. 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 차이 요약

pnpm 모노레포 환경에서 로컬 개발과 Docker 빌드의 5가지 핵심 차이점 비교 — node_modules 구조, TypeScript 버전, tsconfig, Prisma Client, symlink

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

📋 정리 — 핵심 요약

몇 시간 삽질 끝에 📋 정리 — 핵심 요약 해결 완료
몇 시간 삽질 끝에 📋 정리 — 핵심 요약 해결 완료

상황안티패턴권장 패턴
TypeScript 데코레이터 에러 다수패키지별 TypeScript 버전 방치pnpm.overrides로 단일 버전 고정
Cannot read tsconfig.base.jsonDockerfile에서 루트 설정 파일 미복사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에서 기동해봐야 진짜다 🐳