NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
NestJS + Prisma에서 콘텐츠 등록 시 Foreign key constraint violated 500 에러가 터졌습니다. 존재하지 않는 Level ID가 원인이었고, 저장 전 검증 패턴으로 해결한 과정을 정리합니다.
콘텐츠 등록 API를 호출했다. 요청 바디에는 문제가 없어 보였다. 그런데 500이 터진다.
PrismaClientKnownRequestError:
Foreign key constraint failed on the field: `ContentLevel_levelId_fkey`
클라이언트에서 보낸 Level ID 중 하나가 DB에 존재하지 않았다. FK 제약 조건이 이걸 잡아준 건 다행이지만, 사용자에게는 500 Internal Server Error가 그대로 노출됐다.
🔍 증상: 500 에러와 FK 제약 위반 메시지

콘텐츠에 여러 Level을 연결하는 다대다 관계가 있었다. 클라이언트에서 levelIds: [1, 2, 99]를 보냈는데, Level 99는 DB에 없는 ID였다.
Prisma는 createMany나 connect 과정에서 FK 제약 조건을 확인하고, 위반 시 PrismaClientKnownRequestError를 던진다. NestJS의 기본 예외 필터는 이걸 500으로 변환한다.
문제는 에러 메시지가 불친절하다는 것이다.
{
"statusCode": 500,
"message": "Internal server error"
}
어떤 ID가 잘못됐는지, 왜 실패했는지 알 수 없다. 프론트엔드 개발자는 “서버 에러”라고만 보고, 백엔드 개발자는 로그를 뒤져야 한다.
🔎 탐색: 처음엔 DTO 검증 문제인 줄 알았다
처음 의심한 건 class-validator였다. “DTO에서 배열 검증이 빠졌나?” 싶어서 확인했다.
// DTO — 타입 검증은 정상
@IsArray()
@IsInt({ each: true })
levelIds: number[];
배열 여부, 정수 여부는 잘 잡고 있었다. 하지만 class-validator는 “이 숫자가 DB에 실제로 존재하는지”는 검증하지 않는다. 이건 DTO의 영역이 아니다.
그 다음 의심한 건 Prisma의 에러 핸들링이었다. PrismaClientKnownRequestError의 code를 보면 P2003(Foreign key constraint failed)인데, NestJS 기본 예외 필터가 이걸 잡지 못하고 500으로 넘기고 있었다.
두 가지 해결 경로가 있었다:
- A안: Prisma 에러를 잡아서 400으로 변환하는 글로벌 필터
- B안: 저장 전에 먼저 검증하고, 잘못된 ID를 구체적으로 알려주기
A안은 에러 메시지가 여전히 불친절하다. P2003만으로는 “어떤 ID가 잘못됐는지”를 알 수 없다. B안이 UX와 디버깅 모두에서 낫다.
✅ 해결: 저장 전 FK 대상 존재 여부 검증
// ✅ 저장 전 FK 대상 존재 여부 검증
// findMany로 실제 존재하는 ID만 조회 → 차집합이 곧 잘못된 ID
async createContent(dto: CreateContentDto) {
if (dto.levelIds?.length) {
const existingLevels = await this.prisma.level.findMany({
where: { id: { in: dto.levelIds } },
select: { id: true }, // id만 가져와서 쿼리 비용 최소화
});
const existingIds = new Set(existingLevels.map(l => l.id));
const invalidIds = dto.levelIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
// 어떤 ID가 잘못됐는지 명시 → 프론트엔드에서 사용자 안내 가능
throw new BadRequestException(
`유효하지 않은 Level ID: ${invalidIds.join(', ')}`
);
}
}
return this.prisma.content.create({
data: {
title: dto.title,
levels: {
create: dto.levelIds.map(id => ({ levelId: id })),
},
},
});
}
핵심은 세 단계다.
- 존재하는 ID 조회:
findMany로 실제 DB에 있는 ID만 가져온다 - 차집합 계산: 요청 ID 중 DB에 없는 것을 골라낸다
- 400 에러 반환: 어떤 ID가 잘못됐는지 명확하게 알려준다
검증: 수정 전 vs 후
수정 전:
{
"statusCode": 500,
"message": "Internal server error"
}
수정 후:
{
"statusCode": 400,
"message": "유효하지 않은 Level ID: 99"
}
500이 400으로 바뀌었다. 에러 메시지에 구체적인 ID가 포함된다. 프론트엔드에서 사용자에게 안내할 수 있다.
🛡️ 예방: FK 검증 유틸리티 패턴

이 패턴은 Level뿐 아니라 모든 FK 관계에 적용할 수 있다. 유틸리티로 추출하면 반복을 줄일 수 있다.
// utils/validate-fk.ts
// 모든 FK 관계에 재사용 가능한 범용 검증 유틸리티
// Prisma 모델의 findMany 시그니처를 활용하여 타입 안전하게 구현
import { BadRequestException } from '@nestjs/common';
type ModelDelegate = {
findMany: (args: any) => Promise<{ id: number }[]>;
};
export async function validateForeignKeys(
model: ModelDelegate,
ids: number[],
label: string, // 에러 메시지에 표시할 엔티티명
) {
if (!ids?.length) return;
const existing = await model.findMany({
where: { id: { in: ids } },
select: { id: true },
});
const existingSet = new Set(existing.map(r => r.id));
const invalid = ids.filter(id => !existingSet.has(id));
if (invalid.length > 0) {
throw new BadRequestException(
`유효하지 않은 ${label} ID: ${invalid.join(', ')}`
);
}
}
사용할 때는 한 줄이면 된다.
// 서비스에서 FK 검증 — 엔티티가 늘어나도 패턴 동일
await validateForeignKeys(this.prisma.level, dto.levelIds, 'Level');
await validateForeignKeys(this.prisma.category, dto.categoryIds, 'Category');
📌 정리
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| FK 대상 ID 검증 | DB에 맡기고 500 노출 | 서비스에서 선검증 → 400 + 구체 메시지 |
| 에러 메시지 | ”Internal server error" | "유효하지 않은 Level ID: 99” |
| 검증 재사용 | 서비스마다 복붙 | validateForeignKeys 유틸리티 추출 |
| DTO vs 서비스 | DTO에서 DB 검증 시도 | DTO=타입, 서비스=비즈니스 로직 분리 |
FK 제약 위반은 DB가 마지막으로 지켜주는 안전장치다. 하지만 그 에러가 500으로 사용자에게 노출되면, DB의 친절함이 UX의 불친절함이 된다. 서비스 레이어에서 먼저 검증하고, 의미 있는 에러 메시지를 돌려주자.
📚 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 누락 — 빌드는 되는데 런타임 에러가 나는 이유