prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유

NestJS + Prisma 프로젝트에서 schema.prisma에 새 필드를 추가한 뒤 prisma generate를 빠뜨리면, TypeScript 빌드는 as any 캐스팅 덕에 통과하지만 런타임에서 Prisma Client가 새 필드를 모른다. pnpm build와 prisma generate가 별개 명령인 구조적 함정, prebuild 훅으로 자동화하는 해결법, Docker와 CI에서 놓치지 않는 예방 전략을 실전 코드와 함께 정리한다.


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

  • pnpm build(= nest build)는 TypeScript 컴파일만 수행한다 — Prisma Client 재생성은 포함되지 않는다
  • schema.prisma에 새 필드를 추가해도 prisma generate를 실행하지 않으면 Prisma Client에 반영되지 않는다
  • as any 캐스팅으로 타입 에러를 우회하면 빌드는 통과하지만 런타임에서 undefined가 된다
  • package.jsonprebuild 훅에 prisma generate를 넣어 빌드할 때 자동으로 실행되게 하는 것이 정답이다
  • Docker와 CI 파이프라인에서도 별도로 prisma generate 스텝을 명시해야 안전하다

🔍 증상 — 빌드 성공, 하지만 런타임에서 undefined

이건 또 뭐지… 🔍 증상 — 빌드 성공, 하지만 런타임에서 undefined에서 터진 에러
이건 또 뭐지… 🔍 증상 — 빌드 성공, 하지만 런타임에서 undefined에서 터진 에러

PM이 schema.prismasnapshotData Json? 필드를 추가하고 머지했다. BE 개발자(내 역할)가 해당 필드를 사용하는 서비스 코드를 작성한 뒤 pnpm build를 돌렸다.

$ pnpm build
> @alp/[email protected] build /app/apps/api
> nest build

✔ Build succeeded

빌드 통과. 문제없어 보인다.

하지만 실제로 서버를 실행하면?

// student-report.application.service.ts
const report = await this.prisma.studentReport.create({
  data: {
    token,
    studentId,
    type: dto.type || 'CURRENT',
    periodDays,
    expiresAt,
    createdByUserId,
    snapshotData: fullSnapshot as any, // ← 여기
  },
});

이 코드에서 snapshotData에 데이터를 넣었는데, 조회 시 undefined가 나왔다. 더 정확히는 — Prisma Client가 snapshotData라는 필드 자체를 모르는 상태였다.

왜?

prisma generate를 실행하지 않았기 때문이다.


🎯 원인 — pnpm build ≠ prisma generate

🎯 원인 — pnpm build ≠ prisma generate 원인을 추적하는 중
🎯 원인 — pnpm build ≠ prisma generate 원인을 추적하는 중

이 문제의 근본 원인은 pnpm buildprisma generate가 완전히 별개의 명령이라는 점이다.

실제 빌드 스크립트 구조

프로젝트의 package.json을 보자.

// apps/api/package.json
{
  "scripts": {
    "build": "nest build",       // ← TypeScript → JavaScript 컴파일만
    "start:dev": "dotenv -e .env -- nest start --watch"
  }
}

루트의 package.json은 Turborepo를 사용한다.

// package.json (root)
{
  "scripts": {
    "build": "turbo run build"   // ← 각 워크스페이스의 build를 병렬 실행
  }
}

turbo run build는 각 패키지의 build 스크립트를 실행할 뿐이다. nest build는 TypeScript를 JavaScript로 컴파일만 한다. Prisma Client를 재생성하는 건 아무도 하지 않는다.

전체 흐름 — pnpm build vs prisma generate

직접 정리한 pnpm build와 prisma generate 실행 경로 비교 흐름도
직접 정리한 pnpm build와 prisma generate 실행 경로 비교 흐름도

왼쪽 경로(pnpm build)는 TypeScript 컴파일만 수행하고, 오른쪽 경로(prisma generate)는 Prisma Client를 재생성한다. 두 경로를 모두 거쳐야 “타입 안전 + 런타임 정상”을 달성할 수 있다.

Prisma Client 생성이란?

prisma generateschema.prisma 파일을 읽어서 타입이 포함된 Prisma Client 코드node_modules/.prisma/client/에 생성한다.

schema.prisma (선언)
    ↓ prisma generate
node_modules/.prisma/client/ (실행 코드 + 타입)
    ↓ import
서비스 코드에서 사용

이 생성 과정을 거치지 않으면:

  • TypeScript 타입 정의에 새 필드가 없다
  • 런타임 Prisma Client도 새 필드를 인식하지 못한다

as any가 감춘 진짜 문제

정상적이라면 TypeScript 컴파일러가 에러를 잡아준다.

// ❌ prisma generate 안 했으면 → 컴파일 에러
snapshotData: fullSnapshot,
// → Property 'snapshotData' does not exist on type 'StudentReportCreateInput'

하지만 as any 캐스팅이 이 안전장치를 무력화했다.

// ✅ 빌드는 통과하지만... 런타임에서 문제
snapshotData: fullSnapshot as any,

TypeScript는 as any를 보고 타입 검사를 포기한다. 컴파일러 입장에서는 “개발자가 알아서 하겠지”인 셈이다.

결과적으로:

  1. schema.prismasnapshotData Json? 추가됨
  2. prisma generate 미실행 → Prisma Client에 필드 미반영
  3. 서비스 코드에서 as any로 타입 에러 우회
  4. pnpm build = nest build → TypeScript 빌드 성공
  5. 런타임에서 Prisma Client가 snapshotData무시 → DB에 저장은 되지만 조회 시 select에서 빠짐

🔧 해결 — prebuild 훅으로 자동화

즉시 해결: 수동 실행

# 스키마 변경이 포함된 머지 직후 필수 순서
git merge dev
cd apps/api && npx prisma generate   # ← 이 한 줄이 빠져서 사고 남
pnpm build

하지만 사람이 기억해서 매번 실행하는 건 언젠가 또 빠뜨린다.

근본 해결: prebuild 훅

npm/pnpm의 lifecycle scripts를 활용한다. prebuild라는 이름의 스크립트는 build 실행 직전에 자동으로 실행된다.

// ❌ Before: prisma generate가 빌드에 포함되지 않음
{
  "scripts": {
    "build": "nest build"
  }
}
// ✅ After: prebuild 훅으로 자동 실행
{
  "scripts": {
    "prebuild": "npx prisma generate",
    "build": "nest build"
  }
}

이제 pnpm build를 실행하면:

  1. prebuildnpx prisma generate 자동 실행
  2. buildnest build 실행

스키마가 변경되지 않았어도 prisma generate는 **멱등(idempotent)**하다. 이미 최신 상태면 빠르게 끝나므로 성능 걱정도 없다.

$ pnpm build

> @alp/[email protected] prebuild
> npx prisma generate

✔ Generated Prisma Client (v5.x.x) to ./node_modules/@prisma/client in 312ms

> @alp/[email protected] build
> nest build

✔ Build succeeded

Docker에서도 별도 명시

Dockerfile에서는 lifecycle script가 동작하지 않을 수 있다. 명시적으로 prisma generate 스텝을 추가해야 안전하다.

# ❌ Before: prisma generate 없이 바로 빌드
COPY apps/api ./apps/api
RUN pnpm run build

# ✅ After: generate를 먼저 실행
COPY apps/api ./apps/api

# Generate Prisma Client (prebuild 훅과 별개로 명시)
WORKDIR /app/apps/api
RUN npx prisma generate

# Build the application
RUN pnpm run build

실제 프로젝트의 Dockerfile도 이 패턴을 따른다.

# Stage 1: Builder
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

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/

RUN pnpm install --frozen-lockfile

COPY apps/api ./apps/api

# ✅ Prisma Client 생성을 명시적으로 실행
WORKDIR /app/apps/api
RUN npx prisma generate

# 빌드
RUN pnpm run build

Docker 멀티스테이지 빌드에서 주의할 점이 하나 더 있다. pnpm deploy --prod로 프로덕션 의존성만 추출하면 .prisma/client가 빠질 수 있다.

# 프로덕션 의존성 추출
RUN pnpm --filter @alp/api deploy --prod --legacy /app/deploy

# ✅ 빌드 스테이지에서 생성된 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/

이 복사 스텝이 없으면 프로덕션 이미지에서 PrismaClientInitializationError가 발생한다.


🛡️ 예방 — 다시는 빠뜨리지 않으려면

🛡️ 예방 — 다시는 빠뜨리지 않으려면 재발 방지를 위한 안전장치
🛡️ 예방 — 다시는 빠뜨리지 않으려면 재발 방지를 위한 안전장치

1. 머지 후 체크리스트

# 스키마 변경 포함 여부 확인
git diff dev --name-only | grep schema.prisma

# 변경 있으면 → generate 필수
cd apps/api && npx prisma generate

이 확인을 자동화할 수도 있다.

#!/bin/bash
# scripts/post-merge.sh
if git diff HEAD@{1} --name-only | grep -q "schema.prisma"; then
  echo "⚠️  schema.prisma 변경 감지 → prisma generate 실행"
  cd apps/api && npx prisma generate
fi

Git의 post-merge 훅으로 등록하면 머지할 때마다 자동 실행된다.

# .git/hooks/post-merge에 위 스크립트 연결
cp scripts/post-merge.sh .git/hooks/post-merge
chmod +x .git/hooks/post-merge

2. as any 최소화 원칙

as any는 TypeScript의 타입 안전성을 완전히 무력화한다.

// ❌ 안전장치 해제
snapshotData: fullSnapshot as any,

// ✅ 타입을 명시하면 generate 누락 시 컴파일 에러로 잡힌다
snapshotData: fullSnapshot satisfies Prisma.InputJsonValue,

as any 대신 satisfies나 명시적 타입 단언을 사용하면, prisma generate를 빠뜨렸을 때 컴파일 에러가 나서 즉시 알 수 있다.

프로젝트 전체에서 as any 사용 현황을 확인해보면:

$ grep -rn "as any" apps/api/src/ | wc -l
15

15곳이나 있었다. 각각이 잠재적 타입 안전성 구멍이다.

3. CI 파이프라인에 검증 스텝 추가

# .github/workflows/ci.yml
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - run: pnpm install --frozen-lockfile

      # ✅ Prisma Client 생성
      - name: Generate Prisma Client
        run: cd apps/api && npx prisma generate

      # ✅ 타입 체크 (as any 없이)
      - name: Type Check
        run: pnpm tsc --noEmit

      - name: Build
        run: pnpm build

4. Turborepo에서 generate를 dependsOn으로 관리

모노레포라면 Turborepo의 태스크 의존성으로 관리할 수도 있다.

// turbo.json
{
  "tasks": {
    "db:generate": {
      "cache": false
    },
    "build": {
      "dependsOn": ["db:generate", "^build"],
      "outputs": ["dist/**"]
    }
  }
}
// apps/api/package.json
{
  "scripts": {
    "db:generate": "prisma generate",
    "build": "nest build"
  }
}

이렇게 하면 turbo run build 실행 시 db:generate자동으로 먼저 실행된다.


📌 정리

항목내용
증상pnpm build 성공하지만 런타임에서 새 필드가 undefined
원인nest build는 TypeScript 컴파일만 수행, prisma generate는 별도
숨은 범인as any 캐스팅이 타입 에러를 은폐
해결prebuild 훅에 npx prisma generate 추가
Docker명시적 RUN npx prisma generate + .prisma/client 수동 복사
예방post-merge 훅, as any 최소화, CI에 generate 스텝, Turborepo dependsOn

핵심 교훈

pnpm build 통과 ≠ Prisma Client 최신. 스키마를 바꿨으면 prisma generate별도로 실행해야 한다. as any는 이런 문제를 감추기만 할 뿐, 해결하지 않는다.