[NestJS] 에러 처리 - HttpException과 표준 응답

세하·2025년 12월 7일

NestJS

목록 보기
8/8
post-thumbnail

API 서버를 개발할 때, 에러 처리는 기능 개발만큼이나 중요하다. 클라이언트에게 무엇이, 왜 잘못되었는지 명확하게 알려주는 것은 좋은 API의 기본 요건이다.

NestJS는 이러한 에러 처리를 표준화된 방식으로 해결한다. NestJS에서 HttpException을 사용하거나 이를 상속받는 내장 예외들을 사용하면, 클라이언트는 항상 { statusCode, message, error } 구조의 일관된 JSON 응답을 받게 된다.

이 구조가
1. 어떻게 동작하는지,
2. 이를 활용해 어떻게 커스텀 예외를 만들 수 있는지
에 대해서 알아보자.

1. NestJS의 HttpException과 예외 필터

NestJS는 애플리케이션 전역에서 발생하는 예외를 처리하기 위해 전역 예외 필터(Global Exception Filter)를 내장하고 있다.

이 필터는 컨트롤러나 서비스에서 처리되지 않고 던져진(throw) 예외를 감지(catch)한다. 만약 감지된 예외가 HttpException 클래스의 인스턴스(instance)이거나 이를 상속받은 클래스라면, NestJS는 이 예외 객체에서 정보를 추출하여 표준화된 JSON 응답을 생성한다.

따라서 개발자가 별도 설정을 하지 않아도 HttpException을 던지는 것만으로도 깔끔한 JSON 에러 응답을 전송할 수 있게 된다.

2. 표준 응답 구조: statusCode, message, error

HttpException을 기반으로 한 예외가 발생했을 때, NestJS가 생성하는 JSON 응답은 기본적으로 다음 세 가지 프로퍼티를 가진다.

  • statusCode: HTTP 상태 코드 (e.g., 404, 403, 500)
  • message: 예외에 대한 구체적인 설명. 우리가 생성자에 전달하는 메시지이다.
  • error: HTTP 상태 코드에 대한 기본 문자열 설명 (e.g., 'Not Found', 'Forbidden', 'Internal Server Error')

3. 기본 HttpException 사용

가장 기본이 되는 HttpException을 직접 사용해 보자.
이 클래스의 생성자는 messagestatusCode를 인자로 받는다.

// some.service.ts
import { HttpException, HttpStatus } from '@nestjs/common';

// ...
public async findSomething(id: number) {
    const item = await this.itemRepository.findOne(id);
    if (!item) {
        // HttpException을 직접 던진다.
        throw new HttpException(
            '해당 아이템을 찾을 수 없습니다.', // message
            HttpStatus.NOT_FOUND,             // statusCode
        );
    }
    return item;
}

클라이언트 응답

이 코드는 클라이언트에게 다음과 같은 JSON을 전송한다.

{
  "statusCode": 404,
  "message": "해당 아이템을 찾을 수 없습니다.",
  "error": "Not Found" // 404에 해당하는 기본 "error" 문자열
}

error 필드는 HttpStatus.NOT_FOUND(404)에 매핑되는 기본 문자열인 'Not Found'가 자동으로 채워졌다.

4. 내장(Built-in) 예외 사용

NestJS는 자주 사용되는 HTTP 예외들을 미리 만들어 두었다. (NotFoundException, ForbiddenException, BadRequestException 등)

이 내장 예외들은 모두 HttpException을 상속받으며, 각자에 맞는 statusCode와 기본 error 값이 이미 지정되어 있다. 따라서 우리는 message만 신경 쓰면 된다.

// some.service.ts
import { NotFoundException } from '@nestjs/common';

// ...
public async findSomething(id: number) {
    const item = await this.itemRepository.findOne(id);
    if (!item) {
        // message만 전달
        throw new NotFoundException('해당 아이템을 찾을 수 없습니다.');
    }
    return item;
}

클라이언트 응답

결과는 HttpException을 직접 사용했을 때와 동일하다.

{
  "statusCode": 404,
  "message": "해당 아이템을 찾을 수 없습니다.",
  "error": "Not Found"
}

5. 커스텀 예외

프로젝트를 진행하다 보면 '파일 시스템 접근 거부', '사용자 포인트 부족' 처럼 도메인에 특화된 예외가 필요하다. 이때 내장 예외를 상속받아 커스텀 예외 클래스를 만들면 유용하다.

가상 파일 시스템의 루트 디렉토리를 벗어나는 접근을 막는 예외를 만든다고 가정해 보자. 이 행위는 금지된(Forbidden) 행위이므로 ForbiddenException을 상속받는 것이 적절하다.

filesystem-access-denied.exception.ts

import { ForbiddenException } from '@nestjs/common';

/**
 * 가상 루트 디렉토리 외부 접근 시도 시 던지는 예외
 */
export class FileSystemAccessDeniedException extends ForbiddenException {
  constructor() {
    // 부모 클래스(ForbiddenException)의 생성자를 호출한다.
    super('접근 불가. 가상 루트 디렉토리를 벗어났습니다.'); // message 전달
  }
}

FileSystemService에서 이 예외를 사용해 보자.

// file-system.service.ts
// ...
private resolveVirtualPath(virtualPath: string): string {
    const fullPath = path.resolve(this.VIRTUAL_ROOT, /* ... */);

    // 사용자가 VIRTUAL_ROOT를 벗어나려는지 확인
    if (!fullPath.startsWith(this.VIRTUAL_ROOT)) {
        // 커스텀 예외를 던진다
        throw new FileSystemAccessDeniedException();
    }
    return fullPath;
}

클라이언트 응답

FileSystemAccessDeniedExceptionForbiddenException을 상속받았기 때문에, NestJS의 예외 필터는 이 예외를 HttpException의 일종으로 인식한다.

  1. statusCode: ForbiddenException의 기본값인 403이 사용된다.
  2. message: super()를 통해 우리가 전달한 커스텀 메시지가 사용된다.
  3. error: ForbiddenException의 기본값인 'Forbidden'이 사용된다.
{
  "statusCode": 403,
  "message": "접근 불가. 가상 루트 디렉토리를 벗어났습니다.",
  "error": "Forbidden"
}

이처럼, message만 작성했음에도 statusCodeerror가 자동으로 포함되어 전송된다.

6. 응답 커스터마이징

만약 { statusCode, message, error } 구조에서 error 필드까지 바꾸고 싶거나, 아예 다른 필드를 추가하고 싶을 수도 있다.

1) error 필드 변경하기

대부분의 내장 예외 생성자는 두 번째 인자로 error 문자열을 받는다. super를 호출할 때 이 값을 넘겨주면 된다.

// filesystem-access-denied.exception.ts
import { ForbiddenException } from '@nestjs/common';

export class FileSystemAccessDeniedException extends ForbiddenException {
  constructor() {
    super(
      '접근 불가. 가상 루트 디렉토리를 벗어났습니다.', // message
      'ACCESS_DENIED' // error (기본값 'Forbidden' 대신 사용됨)
    );
  }
}

클라이언트 응답

{
  "statusCode": 403,
  "message": "접근 불가. 가상 루트 디렉토리를 벗어났습니다.",
  "error": "ACCESS_DENIED" // 'Forbidden' 대신 내가 넘겨준 값
}

2) 응답 구조 자체를 변경하기

HttpException 생성자의 첫 번째 인자로 문자열 대신 객체를 전달하면, messageerror 필드를 덮어쓰고 원하는 필드를 추가할 수도 있다.

// some.service.ts
import { HttpException, HttpStatus } from '@nestjs/common';

// ...
throw new HttpException(
  {
    // 응답 본문을 객체로 전달
    message: '포인트가 부족하여 아이템을 구매할 수 없습니다.',
    error: 'INSUFFICIENT_POINTS', // error 필드 변경
    requiredPoints: 100, // 커스텀 필드 추가
    userPoints: 30,      // 커스텀 필드 추가
  },
  HttpStatus.PAYMENT_REQUIRED, // statusCode는 두 번째 인자로 전달
);

클라이언트 응답

statusCode는 두 번째 인자를 따르지만, 응답 본문은 우리가 전달한 객체가 그대로 사용된다.

{
  "statusCode": 402,
  "message": "포인트가 부족하여 아이템을 구매할 수 없습니다.",
  "error": "INSUFFICIENT_POINTS",
  "requiredPoints": 100,
  "userPoints": 30
}

추가 - 필터와 인터셉터를 따로 등록하지 않아도 되는가?

이 기능을 사용하기 위해 개발자가 전역 예외 필터인터셉터를 직접 만들고 등록해야할까? 그렇지 않다!
NestJS는 프레임워크 자체에 기본 처리 메커니즘을 내장하고 있어 별도의 설정이 필요 없다.

1. 전역 예외 필터는 이미 존재함

NestJS의 편리한 에러 처리가 가능한 이유는 바로 내장된 전역 예외 필터(Built-in Global Exception Filter) 덕분이다.

  • NestJS 애플리케이션을 구동할 때, 개발자가 명시적으로 필터를 등록하지 않아도 BaseExceptionFilter라는 기본 필터가 전역으로 등록되어 실행된다.
  • 이 기본 필터는 애플리케이션의 어떤 레이어(Controller, Service 등)에서 처리되지 않고 던져진 예외를 최종적으로 가로채는 역할을 한다. 특히 HttpException 클래스 또는 이를 상속받는 모든 예외를 감지하도록 설계되어 있다.
  • 예외를 감지하면, 이 필터는 예외 객체에 포함된 상태 코드와 메시지 등을 추출하여 표준 JSON 응답으로 변환하여 클라이언트에게 전송한다.

따라서 throw new BadRequestException('...') 처럼 코드를 작성하는 것만으로도, 복잡한 응답 처리 로직을 구현할 필요가 없는 것이다.

2. Interceptor VS Exception Filter

에러 처리 시 인터셉터(Interceptor) 의 필요성에 대해 혼동하는 경우가 많은데, 이 둘은 역할이 명확히 구분되어 있다.

Exception Filter (예외 필터)

  • 예외가 발생했을 때 작동하며, 에러를 잡아서 응답 포맷을 변환하거나 부가적인 로직(로깅 등)을 실행하는 곳이다.
  • 기본적인 HttpException 처리를 위해서는 기본 필터가 이미 탑재되어 있어 필수는 아니다.

Interceptor (인터셉터)

  • 요청(Request)이 컨트롤러로 가기 이나 응답(Response)이 클라이언트로 가기 에 데이터를 가로채서 조작하거나 로깅하는 역할을 한다.
  • 인터셉터는 catchError 연산자를 이용해 에러를 처리할 수도 있지만, 이는 스트림 기반의 RxJS 로직을 다루는 영역이며, NestJS의 기본 HttpException JSON 응답 기능을 대체하기 위해 필요한 요소는 아니다.

따라서 기본적인 에러 응답 포맷팅에 인터셉터는 불필요하다.

3. 커스텀 예외 필터는 언제 필요할까?

아래 두 가지 경우에 한해서 개발자가 직접 커스텀 예외 필터를 만들고 등록하면 된다.

- 응답 포맷을 변경하고 싶을 때

기본 { statusCode, message, error } 구조에 추가 필드를 넣거나 기존 필드를 제거하고 싶을 때 사용한다. 예를 들어, 모든 에러 응답에 timestamp나 서버의 추적 ID(Trace ID)를 필수로 포함하고 싶다면 커스텀 필터가 필요하다.

- 모든 예외에 대한 공통 로직이 필요할 때

예외 발생 시 데이터베이스에 에러 로그를 저장하거나, 슬랙(Slack) 같은 외부 모니터링 서비스로 알림을 보내는 등의 공통 로직을 구현해야 할 때 커스텀 필터를 사용하면 효율적이다.

이러한 특수한 요구 사항이 없다면, NestJS가 제공하는 내장 기능을 그대로 사용하는 것이 가장 효율적인 방법이다.


NestJS의 HttpException과 전역 예외 필터는 기본적으로 제공되는 효율적인 기능이라고 생각한다.

  1. HttpException을 상속받는 것만으로 일관된 { statusCode, message, error } 응답이 보장된다.
  2. NotFoundException 같은 내장 예외를 사용하면 코드가 간결해진다.
  3. 내장 예외를 extends하여 커스텀 예외를 만들면, 도메인 로직을 명확하게 표현하고 코드 재사용성을 높일 수 있다.

0개의 댓글