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

버그 발견 밈
버그 발견 밈

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

Prisma는 createManyconnect 과정에서 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 })),
      },
    },
  });
}

핵심은 세 단계다.

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