📚 NestJS 실전 트러블슈팅 #3

NestJS Prisma 마이그레이션 실수 방지 — 운영 DB 컬럼 누락 트러블슈팅

NestJS + Prisma 마이그레이션에서 테이블 하나를 빠뜨려 운영 DB에 컬럼 없음 에러가 터졌습니다. schema.prisma 변경 시 모든 모델을 확인하는 체크리스트와 migrate diff 예방법을 정리했어요.

📚 NestJS 실전 트러블슈팅 시리즈 (3편)

NestJS Prisma 마이그레이션 실수 방지 — 운영 DB 컬럼 누락 트러블슈팅

NestJS + Prisma 마이그레이션을 작성했고, 로컬에서 잘 돌았고, 스테이징에 올렸다. 근데 특정 API에서만 500이 터졌다.

column "curriculumCurrentTargetIdx" does not exist

스키마에 분명히 추가했는데? prisma generate도 했는데? 문제는 같은 필드를 쓰는 다른 테이블을 빠뜨린 거였다.

Prisma migrate는 설계도(schema.prisma)와 시공 계획서(migration SQL)가 분리돼 있다. 설계도를 완벽하게 그려도, 시공 계획서에 누락이 있으면 운영에서 사고 난다. 이 글은 그 사고를 겪고 나서 만든 체크리스트다.


🔍 증상: 특정 API에서만 컬럼 없음 에러

디버깅 중 모니터를 노려보는 표정

커리큘럼 진도 추적 기능을 구현하고 있었다. 두 개 모델에 동일한 세 필드를 추가해야 했다.

  • curriculumCurrentTargetIdx — 현재 진도 인덱스
  • curriculumAdvancedAt — 마지막 승급 일시
  • curriculumCompletedAt — 커리큘럼 완료 일시

schema.prisma에서 StudentClassGroup 두 모델에 필드를 추가했다.

model Student {
  // ...기존 필드...
  curriculumCurrentTargetIdx Int       @default(0)
  curriculumAdvancedAt       DateTime?
  curriculumCompletedAt      DateTime?
}

model ClassGroup {
  // ...기존 필드...
  curriculumCurrentTargetIdx Int       @default(0)
  curriculumAdvancedAt       DateTime?
  curriculumCompletedAt      DateTime?
}

마이그레이션 SQL은 수동으로 작성했다. Student 테이블에 ALTER TABLE 세 줄 추가. 깔끔하게 끝났다고 생각했다.

-- migration.sql
ALTER TABLE "students"
  ADD COLUMN "curriculum_current_target_idx" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "students"
  ADD COLUMN "curriculum_advanced_at" TIMESTAMP(3);
ALTER TABLE "students"
  ADD COLUMN "curriculum_completed_at" TIMESTAMP(3);

로컬 테스트 통과. 스테이징 배포. 그리고…

💥 에러 발생

학생 개인 API는 멀쩡히 동작했다. 근데 반(ClassGroup) 관련 API를 호출하니 바로 500.

PrismaClientKnownRequestError:
Invalid `prisma.classGroup.findFirst()` invocation:

column "curriculum_current_target_idx"
of relation "class_groups" does not exist

class_groups 테이블에는 ALTER TABLE을 안 썼다 😱

에러 메시지가 명확해서 원인 파악은 빨랐다. 문제는 이게 스테이징이 아니라 운영이었다면 어쩔 뻔 했나 하는 생각이었다.

이전 글에서 다룬 CORS 삽질처럼, NestJS + Prisma 조합에서는 “되는 것 같은데 안 되는” 함정이 자주 나온다.

주의: 이 에러는 특정 테이블을 조회하는 API에서만 발생한다. 전수 테스트를 하지 않으면 배포 직후에 바로 잡기 어렵다.

⚠️ 왜 발견이 늦어지는가

이런 누락이 특히 위험한 이유가 있다. Student 관련 API는 정상 동작하기 때문에, 전체 배포가 성공한 것처럼 보인다.

모니터링에 HTTP 5xx 알람을 걸어놔도, 알람이 울리는 시점은 이미 유저가 에러를 마주한 이후다.

QA 단계에서 모든 API 엔드포인트를 커버하지 않으면, 이런 부분적 누락은 운영에서 터진다.


🔬 원인: 같은 필드, 다른 테이블 — 하나만 처리

원인을 찾은 순간 무릎을 탁 치는 표정

schema.prisma에는 두 모델에 동일한 필드를 추가했다. 근데 마이그레이션 SQL에서는 하나만 처리했다.

스키마 파일은 “설계도”고, 마이그레이션 SQL은 “시공 계획서”다. 설계도에 2층 3층 다 그려놨는데, 시공 계획서에 2층만 적은 상황이었다.

왜 로컬에서는 됐나?

로컬 개발 환경에서는 prisma migrate dev를 썼기 때문이다.

prisma migrate devschema.prisma 전체를 읽고, 현재 DB 상태와 비교해 diff를 자동 계산한다. 내부적으로 shadow database(섀도 DB — 임시 DB를 복제해 변경 사항을 시뮬레이션하는 방식)를 활용한다. 누락 없이 모든 테이블에 컬럼을 추가해준다.

문제는 운영/스테이징에서 수동 SQL을 migrate deploy로 실행할 때다.

# 로컬 개발 시
prisma migrate dev schema.prisma diff 자동 계산 (안전)

# 운영/스테이징 배포 시
prisma migrate deploy migrations/ 폴더의 SQL을 있는 그대로 실행 (위험)

핵심: prisma migrate deploy는 SQL을 검증하지 않는다. schema.prisma와 비교하지 않고, 작성된 SQL을 그냥 실행한다. 누락이 있어도 에러 없이 통과한다.

migrate dev가 자동으로 해주던 것들을 migrate deploy에서는 직접 챙겨야 한다. 이걸 모르면 로컬과 운영 사이에서 정확히 이 사고가 난다.

수동 SQL을 쓰는 이유

“그냥 migrate dev로 만들면 되지 않나요?” 하는 생각이 들 수 있다.

실제로는 운영 DB에서 migrate dev를 직접 실행하지 않는다. 운영 DB는 shadow database를 만들 권한이 없거나, 보안 정책상 제한되는 경우가 많다.

그래서 로컬이나 CI에서 SQL을 생성한 뒤, 운영에서는 migrate deploy로 해당 SQL만 실행하는 패턴을 쓴다. Prisma 공식 문서에서도 이 방식을 프로덕션 권장 플로우로 안내한다.

이 패턴 자체가 문제는 아니다. SQL을 수동으로 건드릴 때 사람이 실수하는 게 문제다.


✅ 해결: 누락된 테이블에 ALTER 추가

수정은 간단했다. class_groups 테이블에도 동일한 ALTER TABLE을 추가하면 끝이다.

-- class_groups 누락분 추가
ALTER TABLE "class_groups"
  ADD COLUMN "curriculum_current_target_idx" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "class_groups"
  ADD COLUMN "curriculum_advanced_at" TIMESTAMP(3);
ALTER TABLE "class_groups"
  ADD COLUMN "curriculum_completed_at" TIMESTAMP(3);

팁: 이미 migrate deploy로 실행된 마이그레이션은 수정할 수 없다. 새 마이그레이션 파일을 만들어서 누락분을 추가하는 방식으로 처리해야 한다.

고쳐놓고 안도하는 표정

✅ 핫픽스 절차

긴급 수정이라도 절차는 지키는 게 맞다.

  1. 신규 마이그레이션 파일 생성 — YYYYMMDD_hotfix_add_class_groups_curriculum
  2. 누락된 ALTER TABLE 작성
  3. 스테이징에 먼저 적용 + 모든 관련 API 검증
  4. 운영 배포

배포 후에는 해당 API 호출을 직접 해보면서 에러가 사라졌는지 확인했다. 이번엔 ClassGroup 관련 API를 모두 리스트업해서 하나씩 체크했다.

❌ 하면 안 되는 것

이미 배포된 마이그레이션 파일을 직접 수정하면 안 된다.

prisma/migrations/
└── 20260116_add_curriculum/
    └── migration.sql  ← 이 파일을 직접 고치면 안 된다

Prisma는 마이그레이션 파일의 해시를 DB에 기록한다. 파일을 변경하면 해시가 달라져서, 이후 migrate deploy 실행 시 오류가 발생한다.


🛡️ 예방: 마이그레이션 전 전수 조사 체크리스트

예방 체크리스트를 만들고 뿌듯한 표정

진짜 교훈은 “어떻게 하면 안 빠뜨리나”다. 이후로는 마이그레이션 SQL을 작성하기 전에 아래 순서를 밟는다.

1단계: 변경된 모든 모델 나열

schema.prisma에서 방금 수정한 내용이 어떤 모델에 걸려 있는지 확인한다. 커밋 전에 diff를 보는 습관이 중요하다.

# 스키마 변경 diff 확인 — 어떤 모델이 수정됐나
git diff prisma/schema.prisma | grep -E "^\+.*model |^\+.*curriculum"

2단계: 동일 필드를 쓰는 모델 grep

같은 필드명이 여러 모델에 있을 수 있다. 전부 찾아야 한다.

# schema.prisma에서 해당 필드가 쓰이는 모든 위치 확인
grep -n "curriculumCurrentTargetIdx" prisma/schema.prisma

출력 예시:

45:  curriculumCurrentTargetIdx Int       @default(0)   ← Student
82:  curriculumCurrentTargetIdx Int       @default(0)   ← ClassGroup

핵심: grep 결과 줄 수 = 마이그레이션 SQL의 ALTER 대상 테이블 수. 두 숫자가 다르면 누락이 있다.

두 줄 나오면 마이그레이션 SQL에도 두 테이블에 대한 ALTER가 있어야 한다. 이게 전부다. 단순하지만, 이 한 줄 grep이 운영 사고를 막는다.

3단계: SQL과 스키마 교차 검증

마이그레이션 SQL의 ALTER 대상 테이블 수와, grep 결과의 모델 수가 같은지 직접 비교한다.

# 마이그레이션 SQL에서 ALTER 대상 테이블 목록 확인
grep "ALTER TABLE" \
  prisma/migrations/20260116_add_curriculum/migration.sql

출력 결과:

ALTER TABLE "students" ADD COLUMN ...

한 줄만 나왔다면 뭔가 빠진 거다. schema.prisma에서 grep 결과와 줄 수를 맞춰보면 된다.

4단계: 실제 DB와 스키마 비교

의심스러우면 현재 DB 상태를 스키마와 직접 비교한다. 이 명령은 실제 DB에서 스키마를 끌어와서 Prisma 포맷으로 출력한다.

# DB 실제 상태를 Prisma 스키마로 출력
npx prisma db pull --print | grep -A5 "curriculumCurrentTargetIdx"

배포 전 스테이징 DB에서 이 명령을 실행하면, 예상한 컬럼이 실제로 존재하는지 눈으로 확인할 수 있다.

⚠️ prisma migrate diff 활용

Prisma 3.9 이상에서는 migrate diff 명령으로 스키마와 DB 사이의 diff를 SQL로 뽑아준다.

# 현재 스키마와 DB 상태의 차이를 SQL로 출력
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-url "$DATABASE_URL" \
  --script

수동으로 작성한 마이그레이션 SQL과 이 출력을 비교하면, 누락된 ALTER 문이 있는지 한눈에 알 수 있다.

처음부터 이 명령을 쓰고 수동 SQL을 작성하면 가장 안전하다.


📝 보너스: 코드에 없는데 DB에 있는 경우

마이그레이션을 다루다 보면 반대 상황도 만난다. 에러 로그에 특정 컬럼이 나오는데, 코드를 아무리 검색해도 안 나오는 경우다.

이건 과거 마이그레이션에서 추가됐다가, 코드에서만 삭제된 케이스다. Prisma는 코드와 DB를 자동으로 동기화해주지 않는다. 코드에서 필드를 지워도, 마이그레이션으로 DROP 하지 않으면 DB 컬럼은 그대로 남는다.

분석 순서는 이렇다.

# 1. 코드 검색 — 없음
grep -r "someField" src/   # 결과 없음

# 2. 마이그레이션 히스토리 검색 — 발견
grep -r "some_field" prisma/migrations/
# → 이전 마이그레이션에서 생성됐음을 확인

# 3. 실제 DB 확인
npx prisma db pull --print | grep "someField"
# → DB에 컬럼이 실제로 존재함

올바른 분석 순서: 코드 → 마이그레이션 히스토리 → 실제 DB. 세 곳 다 확인해야 정확한 원인을 찾는다.

팁: prisma db pull --print는 실제 DB 상태를 코드로 확인하는 가장 빠른 방법이다. 의심스러운 컬럼이 있을 때 가장 먼저 실행하는 명령이 됐다.

코드와 DB가 불일치하는 상태가 오래 유지되면, 나중에 누군가 코드를 보고 “이 필드 왜 없지?” 하면서 다시 추가하는 사고가 생긴다. 발견하는 즉시 DROP 마이그레이션을 만들어서 정리하는 게 낫다.

N+1 쿼리 문제처럼, Prisma에서는 “동작하는 코드”와 “올바른 코드” 사이에 간극이 있다. 마이그레이션도 마찬가지다.


정리

상황안티패턴권장 패턴
여러 모델에 동일 필드 추가첫 번째 모델만 SQL 작성grep -n "필드명" schema.prisma로 전수 확인
마이그레이션 SQL 검증눈으로만 확인prisma migrate diff로 자동 비교
배포 후 검증일부 API만 테스트해당 필드를 쓰는 모든 모델의 API 체크
기존 마이그레이션 수정파일 직접 편집신규 마이그레이션 파일로 누락분 추가

마이그레이션 체크리스트 요약

□ schema.prisma에서 변경된 모든 모델 나열
□ grep으로 동일 필드 사용 모델 전수 확인
□ 각 모델에 대한 ALTER 문 작성 확인
□ prisma migrate diff로 SQL 자동 검증
□ 스테이징에서 모든 관련 API 테스트
□ 운영 배포 후 직접 API 호출 검증

prisma migrate dev가 만들어주는 SQL을 기반으로 시작하거나, migrate diff로 검증하는 습관을 들이면 이런 사고는 막을 수 있다.

수동 SQL은 자유도가 높은 만큼 사람 실수가 끼어들 틈도 많다. 스키마에 쓴 만큼 SQL도 써야 한다. 설계도와 시공 계획서의 줄 수가 다르면 사고가 난다 🏗️

📚 NestJS 실전 트러블슈팅 시리즈 (3편)

  1. 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
  2. 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
  3. 3. NestJS Prisma 마이그레이션 실수 방지 — 운영 DB 컬럼 누락 트러블슈팅