NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
📚 NestJS 실전 트러블슈팅 시리즈 (5편)
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가 잘못됐는지, 왜 실패했는지 알 수 없다. 프론트엔드 개발자는 “서버 에러”라고만 보고, 백엔드 개발자는 로그를 뒤져야 한다.
🔎 원인: 저장 전 FK 대상 존재 여부 미검증

원래 코드는 이랬다.
// ❌ 검증 없이 바로 저장
async createContent(dto: CreateContentDto) {
return this.prisma.content.create({
data: {
title: dto.title,
levels: {
create: dto.levelIds.map(id => ({ levelId: id })),
},
},
});
}
DTO 검증(class-validator)은 levelIds가 배열인지, 숫자인지만 확인한다. 실제로 DB에 존재하는 ID인지는 검증하지 않는다.
이건 DTO 레벨이 아니라 서비스 레벨에서 해야 할 검증이다.
✅ 해결: 저장 전 FK 대상 존재 여부 검증

// ✅ 저장 전 FK 대상 존재 여부 검증
async createContent(dto: CreateContentDto) {
if (dto.levelIds?.length) {
const existingLevels = await this.prisma.level.findMany({
where: { id: { in: dto.levelIds } },
select: { id: true },
});
const existingIds = new Set(existingLevels.map(l => l.id));
const invalidIds = dto.levelIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
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가 잘못됐는지 명확하게 알려준다
이제 응답이 달라진다.
{
"statusCode": 400,
"message": "유효하지 않은 Level ID: 99"
}
500이 400으로 바뀌었다. 에러 메시지에 구체적인 ID가 포함된다. 프론트엔드에서 사용자에게 안내할 수 있다.
🛡️ 예방: FK 검증 유틸리티 패턴

이 패턴은 Level뿐 아니라 모든 FK 관계에 적용할 수 있다. 유틸리티로 추출하면 반복을 줄일 수 있다.
// utils/validate-fk.ts
import { BadRequestException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
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(', ')}`
);
}
}
사용할 때는 한 줄이면 된다.
await validateForeignKeys(this.prisma.level, dto.levelIds, 'Level');
await validateForeignKeys(this.prisma.category, dto.categoryIds, 'Category');
📌 정리

| 항목 | 내용 |
|---|---|
| 증상 | 콘텐츠 등록 시 500 에러, Foreign key constraint violated |
| 원인 | 존재하지 않는 FK 대상 ID를 검증 없이 저장 시도 |
| 해결 | 저장 전 findMany로 존재 여부 확인, 없으면 400 반환 |
| 예방 | FK 검증 유틸리티를 만들어 모든 관계에 재사용 |
| 교훈 | DTO 검증은 타입만, FK 존재 검증은 서비스 레이어에서 |
FK 제약 위반은 DB가 마지막으로 지켜주는 안전장치다. 하지만 그 에러가 500으로 사용자에게 노출되면, DB의 친절함이 UX의 불친절함이 된다. 서비스 레이어에서 먼저 검증하고, 의미 있는 에러 메시지를 돌려주자.
📚 NestJS 실전 트러블슈팅 시리즈 (5편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. NestJS Prisma 마이그레이션 실수 방지 — 운영 DB 컬럼 누락 트러블슈팅
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기