NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기

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 제약 위반 메시지

분명 잘 되던 건데, 🔍 증상: 500 에러와 FK 제약 위반 메시지에서 갑자기 안 될 때
분명 잘 되던 건데, 🔍 증상: 500 에러와 FK 제약 위반 메시지에서 갑자기 안 될 때

콘텐츠에 여러 Level을 연결하는 다대다 관계가 있었다. 클라이언트에서 levelIds: [1, 2, 99]를 보냈는데, Level 99는 DB에 없는 ID였다.

Prisma는 createManyconnect 과정에서 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의 에러 핸들링이었다. PrismaClientKnownRequestErrorcode를 보면 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 })),
      },
    },
  });
}

핵심은 세 단계다.

  1. 존재하는 ID 조회: findMany로 실제 DB에 있는 ID만 가져온다
  2. 차집합 계산: 요청 ID 중 DB에 없는 것을 골라낸다
  3. 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의 불친절함이 된다. 서비스 레이어에서 먼저 검증하고, 의미 있는 에러 메시지를 돌려주자.