4.Node.js/Prisma(ORM)

[Prisma7] 9편. Node.js + Prisma 에러 종류 및 처리 방법

쿼드큐브 2026. 2. 2. 20:39
반응형
반응형

 

9편. Node.js + Prisma 에러 종류 및 처리 방법

 

📚 목차
1. Prisma 에러 분류 체계
2. 실무에서 가장 많이 발생하는 Prisma 에러 코드 이해하기
3. Prisma 에러를 HTTP 에러로 변환하는 예시
4. Fastify 레이어별 에러 처리 예시

 

Prisma 에러 처리 삽화 이미지
Prisma 에러 처리 삽화 이미지

1. Prisma 에러 분류 체계

Prisma는 단순한 ORM이 아니라 DB 접근을 추상화한 클라이언트 레이어입니다.

따라서 Prisma에서 발생하는 에러는 대부분 DB 레벨의 문제를 개발자에게 전달한 것에 가깝습니다.
즉, Prisma 에러는 그대로 사용자에게 전달할 대상이 아니라, 애플리케이션 레벨의 에러로 변환되어야 하는 신호(signal)라고 이해하는 것이 중요합니다.

 

Prisma에서 발생하는 에러는 크게 네 가지로 분류됩니다

분류 클래스 설명
Client Error PrismaClientKnownRequestError 에러 코드(Pxxxx) 제공, 실무 핵심
Client Error PrismaClientUnknownRequestError DB 엔진 내부 오류
Client Error PrismaClientValidationError 스키마/쿼리 구조 오류
Runtime Error PrismaClientInitializationError DB 연결·환경 문제

이 중 실무에서 가장 중요하게 다뤄야 할 대상은 PrismaClientKnownRequestError입니다.

 

다음과 같은 형태로 식별할 수 있습니다.

▸ message는 절대 신뢰하지 않습니다
▸ code (Pxxxx)만을 기준으로 분기 처리합니다
▸ Prisma 에러는 **사용자에게 보여줄 에러가 아니라 “변환 대상”**입니다

import { Prisma } from '@prisma/client'

try {
  await prisma.user.create({
    data: { email: 'test@example.com' }
  })
} catch (e) {
  if (e instanceof Prisma.PrismaClientKnownRequestError) {
    console.log(e.code) // 'P2002'
    console.log(e.meta)
  }
}

 

2. 실무에서 가장 많이 발생하는 Prisma 에러 코드 이해하기

아래 예시는 Prisma 에러의 의미를 이해하기 위한 예시이며,
실무에서는 이 변환 로직을 전역 Error Handler로 이동시키는 것이 일반적이다.

 

1. P2002 - Unique Constraint Violation

▸ 회원가입 시 이메일 중복
▸ username / slug / code 등 UNIQUE INDEX 컬럼 중복
▸ 복합 unique key (@@unique) 충돌

Unique constraint failed on the fields: (`email`)

 

이 에러는 DB 관점에서는 정상 동작입니다. 하지만 사용자 관점에서는 명확한 비즈니스 에러입니다.

따라서 서버 에러(500)가 아니라, 중복 리소스에 대한 응답으로 변환해야 합니다.

try {
  await prisma.user.create({ data })
} catch (e: any) {
  if (e.code === 'P2002') {
    throw new ConflictError('이미 존재하는 사용자입니다')
  }
  throw e
}

 

HTTP 관점에서의 의미

항목
HTTP Status 409 Conflict
Error Code DUPLICATE_RESOURCE
Message 이미 존재하는 리소스입니다

 

2. P2003 - Foreign Key Constraint Failed

▸ 존재하지 않는 외래 키(FK) 참조
▸ 부모 레코드가 없는 상태에서 자식 레코드 생성
▸ soft delete 된 부모를 참조

Foreign key constraint failed on the field: `userId`

 

이 에러 역시 DB 관점에서는 정상 동작입니다. 존재하지 않는 데이터를 참조하지 못하도록 차단한 것입니다.


하지만 API 관점에서는 클라이언트 입력이 잘못된 경우에 해당합니다.
즉, 서버의 문제가 아니라 요청 데이터의 정합성 문제입니다.

try {
  await prisma.post.create({
    data: { userId, title }
  })
} catch (e: any) {
  if (e.code === 'P2003') {
    throw new BadRequestError('유효하지 않은 참조 값입니다')
  }
  throw e
}

 

HTTP 관점에서의 의미

항목
HTTP Status 400 Bad Request
Error Code INVALID_REFERENCE
Message 참조 대상이 존재하지 않습니다.

 

3. P2025 - Required Record Not Found

Prisma v6부터는 NotFoundError 타입이 제거되고,

해당 기능(레코드 없음 관련 에러)은 오직 PrismaClientKnownRequestError + 코드 P2025 하나로 통일되었습니다

 

▸ findUniqueOrThrow/ findFirstOrThrow사용
▸ 필수 relation에서 연결 대상 레코드가 존재하지 않음
▸ nested write 과정에서 필수 레코드가 누락됨

An operation failed because it depends on one or more records that were required but not found.

 

이 에러는 Prisma가 “이 레코드는 반드시 있어야 한다”고 판단했지만 실제로는 존재하지 않을 때 발생합니다.
API 관점에서는 전형적인 Not Found 상황입니다.

try {
  await prisma.user.findUniqueOrThrow({
    where: { id },
  })
} catch (e: any) {
  if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
     throw new NotFoundError('사용자를 찾을 수 없습니다')
  }
  throw e
}


HTTP 관점에서의 의미

항목
HTTP Status 404 Not Found
Error Code RESOURCE_NOT_FOUND
Message 리소스를 찾을 수 없습니다

 

4. P2014 – Relation Violation

▸ 필수 관계(required relation) 위반
▸ 자식 레코드가 존재하는 상태에서 부모 삭제
▸ cascade 정책이 없는 관계

The change you are trying to make would violate the required relation.

 

이 에러는 데이터 무결성은 지켜지지만, 비즈니스 정책을 위반한 요청인 경우가 많습니다.

try {
  await prisma.user.delete({ where: { id } })
} catch (e: any) {
  if (e.code === 'P2014') {
    throw new ConflictError('연관된 데이터가 있어 삭제할 수 없습니다')
  }
  throw e
}

 

HTTP 관점에서의 의미

항목
HTTP Status 409 Conflict
Error Code RELATION_CONFLICT
Message 연관된 리소스로 인해 작업을 수행할 수 없습니다

 

5. Validation / Initialization 계열 에러

1) PrismaClientValidationError

▸ 필드 타입 불일치
▸ 필수 필드 누락
▸ 잘못된 where / include / select 구조
▸ Prisma schema와 코드 불일치

if (error instanceof Prisma.PrismaClientValidationError) {
  request.log.error(
    { err: error },
    'Prisma validation error'
  )

  return reply.status(500).send({
    error: {
      code: 'INTERNAL_SERVER_ERROR',
      message: '서버 오류가 발생했습니다'
    }
  })
}


2) PrismaClientInitializationError

▸ DB 서버 연결 실패
▸ DATABASE_URL 환경 변수 누락
▸ 잘못된 인증 정보
▸ 커넥션 풀 고갈

if (error instanceof Prisma.PrismaClientInitializationError) {
  request.log.fatal(
    { err: error },
    'Prisma initialization error'
  )

  return reply.status(503).send({
    error: {
      code: 'SERVICE_UNAVAILABLE',
      message: '일시적으로 서비스를 사용할 수 없습니다'
    }
  })
}

 

3) PrismaClientUnknownRequestError

▸ DB 엔진 내부 오류
▸ 네트워크 단절
▸ 예상하지 못한 쿼리 실패

if (error instanceof Prisma.PrismaClientUnknownRequestError) {
  request.log.error(
    { err: error },
    'Unknown Prisma error'
  )

  return reply.status(500).send({
    error: {
      code: 'DATABASE_ERROR',
      message: '서버 오류가 발생했습니다'
    }
  })
}
반응형

 

3. Prisma 에러를 HTTP 에러로 변환하는 예시

 

1. Prisma → HTTP 매핑 예시

Prisma Code 상황 Http Status
P2002 Unique 제약 충돌 409 Conflict
P2003 Foreign Key 위반 400 Bad Request
P2025 대상 레코드 없음 404 Not Found
P2001 where 조건 대상 없음 404 Not Found
P2014 필수 관계(Relation) 충돌 409 Conflict
PrismaClientValidationError 서버 코드 오류 500 Internal Server Error
PrismaClientInitializationError DB 연결/환경 문제 503 Service Unavailable
PrismaClientUnknownRequestError DB 내부/네트워크 오류 500 Internal Server Error

 

2. Prisma 에러 → 에러 변환

Prisma 등 인프라 레벨 에러는 전역 Error Handler 에서 도메인 에러로 일관 변환하는 방식이 더 가독성 관점에서 유리합니다.

이 단계에서 HTTP 개념은 전혀 알 필요가 없습니다.

function mapPrismaError(e: any) {
  if (e.code === 'P2002') {
    return new ConflictError('이미 존재하는 리소스입니다')
  }

  if (e.code === 'P2001' || e.code === 'P2025') {
    return new NotFoundError('리소스를 찾을 수 없습니다')
  }

  if (e.code === 'P2003') {
    return new BadRequestError('유효하지 않은 참조 값입니다')
  }

  if (e.code === 'P2014') {
    return new ConflictError('연관된 데이터로 인해 작업을 수행할 수 없습니다')
  }

  // 그 외 Prisma / 시스템 에러
  return new InternalServerError()
}

 

3. 에러 응답 표준 포맷

모든 에러 응답은 하나의 고정된 구조를 유지하는 것이 중요합니다.
그래야 프론트엔드에서 에러 처리가 단순해집니다.

{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "게시글을 찾을 수 없습니다"
  }
}

 

4. Fastify 레이어별 에러 처리 예시

1. Repository - “절대 처리하지 않는다”

▸ try-catch ❌
▸ 로그 ❌
▸ 에러 변환 ❌

Repository의 역할은 DB 접근 단 하나입니다.

// user.repository.ts
export class UserRepository {
  constructor(private readonly prisma: PrismaClient) {}

  findByEmail(email: string) {
    return this.prisma.user.findUnique({ where: { email } })
  }

  create(data: Prisma.UserCreateInput) {
    return this.prisma.user.create({ data })
  }
}

 

2. Service - “비즈니스 의미로만 표현한다”

▸ HTTP 개념 ❌
▸ 의도된 비즈니스 에러→ 도메인 에러로 변환
▸ 의미 없는 에러는 그대로 전파

Service는 의도된 비즈니스 에러만 throw 하고
Prisma 등 인프라 레벨 에러는 전역 Error Handler에서 도메인 에러로 일괄 변환하는 방식이 실무에서 더 선호된다.

 

// user.service.ts
export class UserService {
  constructor(private readonly repo: UserRepository) {}

  async register(email: string, password: string) {
    const exists = await this.repo.findByEmail(email)
    if (exists) {
      throw new ConflictError('이미 가입된 이메일입니다')
    }
    // Prisma 등 인프라 레벨 에러는 
    // 전역 Error Handler에서 도메인 에러로 
    // 일괄 변환하는 방식이 실무에서 더 선호된다.
    return await this.repo.create(...)
  }
}

 

3. Controller - “에러를 잡지 않는다”

Controller는 입력과 출력만 책임집니다.

Controller에서 try-catch를 사용하기 시작하면 에러 처리가 분산되고 중복됩니다.

// user.controller.ts
export async function registerHandler(
  request: FastifyRequest<{ Body: { email: string; password: string } }>,
  reply: FastifyReply
) {
  const user = await ......

  reply.code(201).send({ id: user.id, email: user.email })
}

 

4. 전역 에러 핸들러와 로그 기록 예시

reply.send() 전에 발생한 모든 에러는 Fastify가 자동으로 가로채서 전역 에러 핸들러로 보내고, 그 결과를 HTTP 응답으로 반환합니다.

app.setErrorHandler((error, request, reply) => {
  /**
   * 1️⃣ 예상 가능한 비즈니스/도메인 에러
   * - Service 계층에서 의도적으로 throw 한 에러
   * - 사용자에게 메시지를 전달해도 되는 에러
   */
  if (error instanceof AppError) {
    // 운영 중 정상적으로 발생 가능한 에러이므로 info 레벨
    request.log.info(
      { err: error },
      'Operational error'
    )

    // 에러 객체가 정의한 statusCode / errorCode 그대로 응답
    return reply.status(error.statusCode).send({
      error: {
        code: error.errorCode,
        message: error.message
      }
    })
  }

  /**
   * 2️⃣ Prisma 에러가 그대로 올라온 경우
   * - Service에서 변환되지 않은 DB/Infra 레벨 에러
   * - 내부 구현 노출 방지를 위해 메시지는 공통화
   */
  if (error.code?.startsWith('P')) {
    // 운영 중 문제 추적이 필요하므로 error 레벨
    request.log.error(
      { err: error },
      'Unhandled Prisma error'
    )

    return reply.status(500).send({
      error: {
        code: 'DATABASE_ERROR',
        message: '서버 오류가 발생했습니다'
      }
    })
  }

  /**
   * 3️⃣ 그 외 모든 에러
   * - 버그, 예외 상황, 또는 처리되지 않은 오류
   * - 즉시 확인이 필요한 심각한 문제
   */
  request.log.fatal(
    { err: error },
    'Unexpected error'
  )

  return reply.status(500).send({
    error: {
      code: 'INTERNAL_SERVER_ERROR',
      message: '서버 오류가 발생했습니다'
    }
  })
})

 


※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형