본문 바로가기

예외 처리와 필터

빌트인 HTTP 예외와 사용자 정의 예외를 발생시키고 예외 필터로 오류 응답 형식과 처리 범위를 일관되게 만듭니다.

웹 애플리케이션을 개발하다 보면 다양한 예외(Exceptions) 상황을 만나게 됩니다.

예를 들어 존재하지 않는 리소스 접근, 데이터베이스 오류, 사용자 입력 유효성 검사 실패가 대표적입니다.

이런 예외를 적절히 처리하지 않으면 사용자에게 불친절한 오류 메시지가 노출되거나, 애플리케이션이 비정상 종료될 수 있습니다.

NestJS는 강력하고 유연한 예외 처리(Exception Handling) 메커니즘을 제공하여, 애플리케이션의 안정성과 사용자 경험을 향상시킬 수 있도록 돕습니다.

핵심적으로 예외 필터(Exception Filters)를 통해 이러한 예외들을 중앙 집중식으로 관리할 수 있습니다.

Nest 예외 응답 · 기본 예제와 확장

HttpException 응답 예제에서 공개 계약과 비공개 진단을 확장할 수 있습니다

본문의 @Catch(HttpException)은 statusCode·timestamp·path·message를 응답합니다. 아래 catch-all·도메인 오류 분류·요청 ID·비공개 로그는 추가로 설계할 때의 제안이며 본문에 구현되어 있지는 않습니다.

확장 설계: 공개 응답과 비공개 진단을 분리한다 원문의 HttpException 전용 필터를 확장하여 HTTP 예외·독립 도메인 오류·unknown을 분류하고 공개 응답과 비공개 진단을 나누는 제안이다. HTTP EXCEPTION 상태 · errorCode 보존 DOMAIN ERROR 운송 계층과 독립 UNKNOWN 예상하지 못한 실패 @Catch() · ArgumentsHost 한 번만 분류하고 매핑 원래 상태 · 도메인 매핑 · 500 PUBLIC CONTRACT status · code message · requestId stack · SQL · 비밀 값 제외 PRIVATE DIAGNOSTICS 원인 · 스택 · 요청 문맥 requestId로 공개 응답과 연결 CLIENT 응답에 따른 후속 행동 OPERATOR 원인 진단 · 경보 · 복구
오류 분류 뒤 공개 응답과 비공개 진단을 분리한다 확장 설계에서 HTTP 예외, 독립 도메인 오류, 알 수 없는 오류를 catch-all 필터가 분류한다. 공개 응답은 안전한 상태·코드·메시지·요청 ID로 클라이언트의 후속 판단을 돕고, 비공개 로그는 같은 요청 ID로 운영자의 원인 진단에 연결한다. 본문 HttpException 전용 필터만으로 구현된 기능은 아니다. HTTP 예외원래 상태 · 오류코드 도메인 오류전송 계층과 독립 알 수 없는 오류예상하지 못한실패 catch-all ·확장 설계한 번 분류 · 상태매핑HTTP 상태 /도메인 / 500 공개 응답status ·codemessage·requestId비밀 값 ·스택 제외 비공개 진단원인 ·스택 ·문맥같은requestId구조화된로그 클라이언트응답별후속 행동재시도는별도 정책 운영자진단 ·경보 ·복구공개응답과추적
  1. 실패 발생

    HTTP 예외, 운송 계층과 독립적인 도메인 오류, 예상하지 못한 오류를 구분합니다.

  2. catch-all 필터

    ArgumentsHost에서 HTTP 요청을 꺼내 상태와 안정적인 오류 코드를 한 번만 매핑합니다.

  3. 공개 응답

    상태·오류 코드·안전한 메시지·요청 ID만 보내 클라이언트가 다음 행동을 고르게 합니다.

  4. 비공개 진단

    원인과 스택은 구조화 로그에 남기고 같은 요청 ID로 운영자가 추적합니다.

필터는 route → controller → global 순으로 가장 가까운 범위부터 탐색한다. DI가 필요한 전역 필터는 module provider의 APP_FILTER로 등록합니다. @Catch(HttpException)만 붙은 본문 필터는 일반 Error를 잡는 catch-all이 아닙니다. 응답 이후의 재시도·복구는 별도 정책입니다.


NestJS의 빌트인 예외 처리

NestJS는 기본적으로 표준 HTTP 예외를 처리하기 위한 빌트인(built-in) 예외 계층을 제공합니다.

이는 @nestjs/common 패키지의 HttpException 클래스와 그 하위 클래스들을 통해 구현됩니다.

예를 들어, NotFoundException, BadRequestException, UnauthorizedException 등이 있습니다.

예시: 빌트인 예외 사용하기

컨트롤러나 서비스에서 특정 조건이 만족되지 않을 때, 이들 예외 클래스의 인스턴스를 throw하면 NestJS가 이를 자동으로 감지하여 적절한 HTTP 응답(상태 코드, 메시지 등)을 클라이언트에 반환합니다.

src/items/items.controller.ts
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { ItemsService } from './items.service';

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    const item = this.itemsService.findItemById(id); // 예를 들어, 서비스에서 아이템을 찾는다고 가정
    if (!item) {
      // 아이템이 없는 경우 NotFoundException을 발생시킵니다.
      throw new NotFoundException(`Item with ID "${id}" not found.`);
    }
    return item;
  }
}

위 코드에서 findOne 메서드는 특정 id를 가진 아이템을 찾고, 만약 아이템이 존재하지 않으면 NotFoundException을 throw합니다.

NestJS는 이 예외를 가로채서 클라이언트에게 404 Not Found 상태 코드와 함께 지정된 메시지를 반환합니다.

이는 개발자가 일일이 res.status(404).json(...)과 같은 코드를 작성할 필요 없이, 선언적으로 예외를 처리할 수 있게 해줍니다.

이 예제를 따라 실행하려면 생략된 ItemsService.findItemById()가 동기적으로 항목 또는 undefined를 반환해야 하며, 컨트롤러와 서비스도 같은 모듈에서 사용할 수 있게 등록되어 있어야 합니다. 예를 들어 ID '1'에서는 { id: '1', name: 'Notebook' }, 다른 ID에서는 undefined를 반환하도록 준비하면 정상 조회와 404를 비교할 수 있습니다. DB 조회처럼 Promise를 반환하는 서비스로 바꿀 때는 핸들러도 async로 바꾸고 결과를 await한 뒤 검사해야 합니다. 아직 완료되지 않은 Promise를 if (!item)으로 검사하면 누락된 항목을 판별할 수 없습니다.


예외 필터: 커스텀 예외 처리

빌트인 예외 처리만으로 충분하지 않은 경우, 즉 특정 예외에 대해 사용자 정의 응답 형식을 제공하거나, 추가적인 로깅을 수행하는 등 더 세밀한 제어가 필요할 때는 예외 필터를 사용할 수 있습니다.

이 절의 HTTP 예외 필터는 요청 처리 경로에서 처리되지 않은 예외에 응답 로직을 적용합니다. 컨트롤러·서비스에서 반환하거나 await한 Promise의 실패도 이 경로에 포함됩니다. 요청과 분리해서 시작한 비동기 작업이나 타이머의 오류까지 모두 잡아 주는 프로세스 전체 오류 처리기는 아닙니다.

주요 특징 및 사용 사례
  • Catch() 데코레이터: 어떤 종류의 예외를 처리할지 @Catch() 데코레이터에 지정합니다. 특정 예외 클래스를 지정하거나 여러 예외 클래스를 쉼표로 구분한 인수로 전달할 수 있으며, 인자가 없으면 모든 종류의 예외를 처리합니다.
  • ExceptionFilter 인터페이스: ExceptionFilter 인터페이스를 구현하고 catch() 메서드를 오버라이드합니다. catch() 메서드는 발생한 예외 객체와 ArgumentsHost 객체를 인자로 받습니다.
  • 전역, 컨트롤러, 메서드 레벨 적용: @UseFilters() 데코레이터를 사용하여 특정 컨트롤러나 메서드에 적용하거나, main.ts에서 전역으로 적용할 수 있습니다.
예시: 커스텀 예외 필터 작성

클라이언트에게 좀 더 상세하고 일관된 에러 응답 형식을 제공하는 예외 필터를 만들어보겠습니다.

src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException) // HttpException 클래스에 해당하는 예외를 잡습니다.
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus(); // 예외의 HTTP 상태 코드 가져오기

    // HttpException에 담긴 공개 응답 메시지를 가져옵니다.
    const errorResponse = exception.getResponse();
    const errorMessage = typeof errorResponse === 'string'
      ? errorResponse
      : (errorResponse as any).message || 'An unexpected error occurred.';

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message: errorMessage, // 클라이언트에게 보여줄 커스텀 메시지
      // errorCode: 'CUSTOM_ERROR_CODE', // 필요에 따라 커스텀 에러 코드 추가
    });
  }
}

위 HttpExceptionFilter는 적용 범위의 HttpException에 timestamp와 path를 더합니다. 일반 Error를 처리하는 분기는 없으며, requestId·별도 비공개 로그도 아직 구현하지 않았습니다.

응답 형식을 바꿀 때 보존되는 정보 확인하기

getResponse()는 문자열이나 객체를 돌려줄 수 있습니다. 객체의 message가 배열이면 이 코드도 배열을 그대로 응답에 넣습니다. 반면 객체에 들어 있던 errorCode나 다른 속성은 새 JSON에 복사하지 않으므로 사라집니다. 클라이언트가 message를 항상 문자열로 가정하거나 원래 오류 객체가 전부 보존된다고 가정해서는 안 됩니다. 공개 오류 코드를 유지하려면 필터의 응답 계약에 그 필드를 명시적으로 포함해야 합니다.

앞의 도식에 나온 일반 오류 분류, 비공개 진단 로그, requestId는 확장 설계입니다. 현재 필터는 그 기능을 구현하지 않습니다. 특히 DB 오류 메시지나 stack을 그대로 공개 응답으로 복사하는 대신, 클라이언트용 메시지와 운영자용 진단 정보를 구분해야 합니다. path: request.url에는 쿼리 문자열도 포함될 수 있으므로 공개 경로에 어떤 정보를 담을지도 정해야 합니다. Nest 11의 HttpException API에서 문자열·객체 응답을 구분할 수 있습니다.

예시: 같은 실패를 필터 적용 전후로 비교하기

위 서비스 준비에서 존재하지 않는 ID '9'를 조회합니다. 전역 접두사를 유지했다면 GET /api/items/9입니다. 메서드에 @UseFilters(HttpExceptionFilter)를 적용한 뒤에는 상태 404를 유지하면서 응답 본문에 statusCode: 404, 실제 요청의 path, 동적으로 생성한 timestamp, Item with ID "9" not found.라는 message가 포함됩니다. 필터는 실패를 성공으로 바꾸지 않고 오류 응답 계약을 정합니다. 처리 시각은 요청마다 달라지므로 고정 문자열로 비교하지 않습니다.

예외 필터 적용 방법

메서드 스코프: 특정 메서드에만 적용

src/items/items.controller.ts
import { Controller, Get, Param, NotFoundException, UseFilters } from '@nestjs/common';
import { ItemsService } from './items.service';
import { HttpExceptionFilter } from '../common/filters/http-exception.filter';

@Controller('items')
export class ItemsController {
  constructor(private readonly itemsService: ItemsService) {}

  @UseFilters(HttpExceptionFilter) // 이 메서드에서 발생하는 HttpException에만 적용
  @Get(':id')
  findOne(@Param('id') id: string) {
    const item = this.itemsService.findItemById(id);
    if (!item) {
      throw new NotFoundException(`Item with ID "${id}" not found.`);
    }
    return item;
  }
}

컨트롤러 스코프: 특정 컨트롤러의 모든 라우트에 적용

src/items/items.controller.ts
import { Controller, Get, Param, NotFoundException, UseFilters } from '@nestjs/common';
import { ItemsService } from './items.service';
import { HttpExceptionFilter } from '../common/filters/http-exception.filter';

@UseFilters(HttpExceptionFilter) // 이 컨트롤러의 모든 라우트에 적용
@Controller('items')
export class ItemsController {
  // ...
}

전역 스코프: 애플리케이션 전체에 적용

애플리케이션 전체에 공통 오류 응답 계약이 필요할 때 사용할 수 있습니다. 특정 경로만 다른 계약이 필요하면 메서드나 컨트롤러 범위를 선택합니다.

main.ts 파일에서 NestJS 애플리케이션이 부트스트랩될 때 전역으로 등록합니다.

같은 예외에 여러 범위의 필터가 후보라면 메서드 → 컨트롤러 → 전역 순서로 가장 가까운 일치 필터를 찾습니다. 이미 메서드 필터가 처리한 예외가 컨트롤러와 전역 필터로 차례차례 전달되지는 않습니다. 또한 핸들러에서 try/catch로 잡고 정상 값을 반환했다면 예외 필터가 실행되지 않습니다. 공통 로깅을 전역 필터에만 넣을 경우, 지역 필터가 처리한 오류를 놓치지 않는지도 고려해야 합니다. 공식 요청 생명주기가 이 처리 범위를 설명합니다.

아래처럼 new HttpExceptionFilter()를 넘기는 방식은 의존성 없는 현재 필터에 사용할 수 있습니다. 필터 생성자에 Nest 프로바이더를 주입하려면 모듈에서 APP_FILTER 프로바이더로 등록하는 등의 DI 가능한 등록 방식을 선택해야 합니다.

아래 코드는 전역 예외 필터를 등록하는 위치를 보여줍니다. 앞 절의 main.ts를 이어서 사용한다면 ValidationPipe, api 전역 접두사와 기존 PORT 설정을 유지한 채 필터 import와 app.useGlobalFilters(...)를 추가합니다. 아래 코드로 파일 전체를 덮어쓰지 않습니다.

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; // 필터 임포트

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 전역 예외 필터 등록
  // 주의: new 키워드를 사용하면 해당 필터는 의존성 주입을 받을 수 없습니다.
  // 의존성 주입이 필요한 경우, APP_FILTER 토큰을 사용하여 모듈에서 프로바이더로 등록해야 합니다.
  app.useGlobalFilters(new HttpExceptionFilter());

  await app.listen(3000);
}
bootstrap();

참고: app.useGlobalFilters(new HttpExceptionFilter()) 방식은 필터 내부에서 다른 서비스를 주입받아야 할 경우(예: 로깅 서비스) 문제가 될 수 있습니다.

이럴 때는 APP_FILTER 토큰으로 모듈에 등록하면 DI를 사용하는 전역 필터가 됩니다. 같은 전역 적용을 위해 @UseFilters()를 추가할 필요는 없습니다.

src/app.module.ts (전역 필터로 등록 시 의존성 주입이 필요한 경우)
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
    // ... 다른 프로바이더들
  ],
  // ...
})
export class AppModule {}

사용자 정의 예외

NestJS의 HttpException을 상속받아 우리 애플리케이션의 특정 비즈니스 로직에 맞는 사용자 정의 예외를 생성할 수도 있습니다.

이는 코드의 가독성을 높이고, 특정 예외 상황을 명확하게 표현하는 데 도움을 줍니다.

예시: 사용자 정의 예외 클래스
src/common/exceptions/user-already-exists.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';

export class UserAlreadyExistsException extends HttpException {
  constructor(username: string) {
    super(`User with username "${username}" already exists.`, HttpStatus.CONFLICT); // 409 Conflict
  }
}

이제 이 사용자 정의 예외를 컨트롤러나 서비스에서 throw할 수 있으며, HttpExceptionFilter는 이를 자동으로 처리할 것입니다.

src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { UserAlreadyExistsException } from '../common/exceptions/user-already-exists.exception';

@Injectable()
export class UsersService {
  private users: string[] = ['testuser'];

  createUser(username: string): string {
    if (this.users.includes(username)) {
      throw new UserAlreadyExistsException(username);
    }
    this.users.push(username);
    return `User ${username} created successfully.`;
  }
}

사용자 정의 예외를 쓰면 서비스 코드에는 도메인 의미가 남고, 필터는 이를 클라이언트 응답 규격으로 변환합니다.

예외 필터를 설계할 때는 예외를 잡는 것에서 끝내지 말고, 클라이언트가 재시도·로그인 이동·입력 수정 같은 복구 경로를 선택할 수 있도록 응답 형식을 정규화해야 합니다.

필터의 범위를 스스로 확인하기

앞의 HttpExceptionFilter를 전역으로 등록했습니다. 서비스가 UserAlreadyExistsException('testuser')를 던진 경우와, 일반 Error('database unavailable')를 던진 경우에 이 필터가 각각 실행되는지 설명해 보세요. 사용자 정의 오류 응답을 만든다는 이유만으로 모든 오류가 같은 경로를 쓰는지도 판단합니다.

예외 타입과 처리 경계

첫 예외는 HttpException의 하위 타입이므로 이 필터가 처리하고 원래 409 상태를 유지합니다. 일반 Error는 @Catch(HttpException)의 대상이 아니므로 이 필터가 처리하지 않습니다. 다른 일치 필터가 없다면 기본 예외 처리에서 500을 응답합니다. 일반 오류까지 분류하려면 별도의 catch-all 정책을 설계해야 합니다. 공개 응답에 내부 DB 메시지를 그대로 넣는 일도 그때 별도로 판단해야 합니다.


예외 처리와 예외 필터는 NestJS 애플리케이션의 오류 응답 형식과 복구 전략을 정하는 요소입니다.

도메인 오류, 인증 오류, 시스템 오류를 구분해 일관된 응답 구조로 처리해야 합니다.

연습: 어떤 오류가 어디에서 처리되는가

  1. findItemById()를 Promise<Item | undefined>로 바꿨고 결과는 undefined입니다. 기존 동기 핸들러를 그대로 두면 if (!item)이 누락된 항목을 찾을까요? 올바른 검사 순서를 적어 보세요.
  2. 현재 HttpExceptionFilter가 { message: ['name is required'], errorCode: 'INVALID_NAME' }를 응답 객체로 가진 HttpException을 받습니다. 상태는 400, 요청 URL은 /users입니다. 유지되는 message, 상태와 경로, 사라지는 필드를 구분하세요.
  3. 같은 HttpException을 처리하는 메서드·컨트롤러·전역 필터를 모두 등록했습니다. 메서드 필터가 응답을 완료한 다음 나머지 두 필터도 실행될까요? 핸들러가 직접 오류를 잡고 'fallback'을 반환하는 경우도 비교하세요.
  4. 앞의 메모리 UsersService는 처음에 'testuser'만 보관합니다. 순서대로 'alice', 'alice', 'Alice'를 생성하면 어떤 결과인가요? 입력은 모두 문자열이며, 다른 필터는 없고 문자열 대소문자 규칙과 중복 검사 코드는 그대로입니다. 이 검사만으로 여러 서버가 공유하는 DB의 중복을 막을 수 있을까요?
비동기 결과·공개 계약·처리 범위 풀이
  1. Promise 객체 자체는 참으로 평가되므로 if (!item)이 실행되지 않습니다. 핸들러를 async로 바꾸고 const item = await this.itemsService.findItemById(id)로 결과를 받은 다음 검사해야 합니다. 결과가 undefined이면 NotFoundException을 던집니다. Promise가 실패하면 그 실패 역시 반환·대기한 요청 처리 경로에서 처리됩니다.
  2. message는 ['name is required'] 배열을 유지합니다. JSON의 statusCode는 400, path는 /users, timestamp는 처리 시각입니다. 원래의 errorCode는 이 필터가 복사하지 않으므로 응답에 없습니다.
  3. 메서드 필터가 처리하면 그 예외를 나머지 필터가 이어서 처리하지 않습니다. 오류를 직접 잡아 정상 값을 반환한 경우에는 처리되지 않은 예외가 없으므로 어떤 예외 필터도 실행되지 않습니다.
  4. 첫 'alice'는 배열에 추가되어 성공하고 두 번째는 UserAlreadyExistsException으로 기본 409가 됩니다. 'Alice'는 다른 문자열이므로 현재 코드에서는 성공합니다. 이는 한 서비스 인스턴스의 메모리 상태와 대소문자 구분 규칙에 따른 결과입니다. DB를 공유하는 여러 서버의 중복 생성은 DB의 고유 제약과 충돌 처리 등 별도 저장소 경계에서 보장해야 합니다.