관리 메뉴

bright jazz music

NestJS의 예외처리: 예외 필터 사용(Exception filter) 본문

Framework/NestJS

NestJS의 예외처리: 예외 필터 사용(Exception filter)

bright jazz music 2024. 11. 6. 23:01

nestJS에서 제공하는 내장 예외 필터는 기본적으로 자동으로 예외를 처리한다. 사용자가 일일이 예외를 다룰 필요는 없어지지만, 예외처리에 관한 완전한 제어가 불가능한 부분도 있다. ExceptionFilter를 사용하면 이러한 필터를 커스텀하여 사용할 수 있다. 필터에 로깅을 추가하거나 임의의 JSON 스키마를 적용하는 등의 구성이 가능하다는 것이다. 

 

Exception filter를 사용함으로써,

제어 흐름과 클라이언트에게 전송되는 응답을 통제한다.

 

1. 필터 작성

아래 코드는 ExceptionFilter 인터페이스를 구현(implements)하는 필터 클래스를 작성한 것이다.

이 필터는 HttpException 클래스의 인스턴스를 캐치하며,  클라이언트에게 반환되는 응답을 구성한다.

이를 위해 익스프레스의 Request, Response 객체를 사용해야 한다. Request객체에서 url을 뽑아내고 이 정보를 Response에 담아내기 위해서이다.

 

참고로 아래 코드에는 return 문이 존재하지 않는다. 이는 익스프레스의 Response 객체에 존재하는 .json() 메서드가 자동으로 객체를 반환하는 로직이 있기 때문이다.

 

response.json():

  • 자동으로 Content-Type을 'application/json'으로 설정
  • JSON.stringify()를 통해 객체를 문자열로 변환
  • 응답을 클라이언트로 전송
  • 자동으로 응답을 종료 (내부적으로 response.end() 호출)
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

/**
* HTTP 예외를 처리하는 필터
* NestJS에서 발생하는 HttpException을 잡아서 일관된 형식의 응답으로 변환
*/
@Catch(HttpException) // HttpException 타입의 예외만 캐치
export class HttpExceptionFilter implements ExceptionFilter {
 /**
  * 예외를 처리하는 메소드
  * @param exception 발생한 HTTP 예외 객체
  * @param host ArgumentsHost 객체 (실행 컨텍스트를 포함)
  */
 catch(exception: HttpException, host: ArgumentsHost) {
   // HTTP 컨텍스트로 전환 (Express의 Request/Response 객체에 접근하기 위해)
   const ctx = host.switchToHttp();
   // Response 객체 가져오기
   const response = ctx.getResponse<Response>();
   // Request 객체 가져오기
   const request = ctx.getRequest<Request>();
   // 예외의 HTTP 상태 코드 가져오기
   const status = exception.getStatus();

   // 클라이언트에게 에러 응답 전송
   response
     .status(status)  // HTTP 상태 코드 설정
     .json({
       statusCode: status,         // 상태 코드
       timestamp: new Date().toISOString(),  // 발생 시간
       path: request.url,          // 요청 URL
     });
 }
}

 

클라이언트는 아래와 같은 응답을 받아볼 수 있다.

{
  "statusCode": 404,
  "timestamp": "2024-11-06T12:34:56.789Z",
  "path": "/cats/123"
}

 

 

모든 예외 필터는 ExceptionFilter<T> 인터페이스를 구현해야 한다. 따라서, ExceptionFilter 인터페이스를 구현하여 작성되는 클래스는 해당 인터페이스에 선언된 catch() 메서드 역시 구현해야 한다. T에는 예외 타입을 의미한다.

catch( exception : T, host : Argument ) { }

 

응답 반환 역시 이 메서드 내에서 이루어진다.

*익스프레스가 아니라 fasify기반이라면 response.json() 대신 response.send()를 사용해야 한다.

 

 

2. 필터 적용

2.1. 개별 핸들러에 적용하는 경우

// 커스텀 필터 임포트
import { HttpExceptionFilter } from 'src/\bfilters/http-exception.filter';

@Controller('cats')
export class CatsController {
  // private final CatsService catsService 과 유사. 캣서비스 인스턴스를 주입받아 사용
  constructor(private catsService: CatsService) {}



  @Post()
  @UseFilters(new HttpExceptionFilter())  // UseFilters()데코레이터를 통해 커스텀 익셉션 필터를 핸들러에 적용
  async create(@Body() createCatDto: CreateCatDto) {
    //this.catsService.create(createCatDto);
    throw new ForbiddenException();

  }

 

2.2. 컨트롤러 전체에 적용하는 경우

import { CustomForbiddenException } from 'src/exceptions/forbidden.exception';
import { HttpExceptionFilter } from 'src/\bfilters/http-exception.filter';

@Controller('cats')
@UseFilters(new HttpExceptionFilter())  // 커스텀 익셉션 필터를 컨트롤러 전체에 적용
export class CatsController {
  // private final CatsService catsService 과 유사. 캣서비스 인스턴스를 주입받아 사용
  constructor(private catsService: CatsService) {}

//...

 

 

2.3. 앱 전체에 전역으로 사용하는 경우

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './common/middlewares/logger.middleware';
import { HttpExceptionFilter } from './\bfilters/http-exception.filter';
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 미들웨어를 전역으로 적용. 그러나 이렇게 사용하는 경우DI에 접근할 수 없다.
  // 따라서 의존성 주입이 없는 함수형 미들웨어를 사용하거나,
  // 의존성이 필요한 경우 클래스 미들웨어를 만들어 앱 모듈 등의 모듈에 등록하고 .forRoutes('*') 메소드를 사용해야 한다.
  // 의존성이 없는 클래스 미들웨어의 경우 다음과 같이 사용도 가능하다.
  // app.use(new LoggerMiddleware()); <- 인스턴스를 직접 생성해서 사용
  app.use(logger);
  app.useGlobalFilters(new HttpExceptionFilter)	//전역으로 필터 적용
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

 

그런데 이 방식으로 전역 필터를 설정해 주는 경우, 그 어떤 모듈의 컨텍스트에도 속하지 않은 상태로 등록되다 보니 의존성을 주입할 수 없다. 따라서 이를 해결하려면 아래와 같은 구조의 아무 모듈에나 직접 등록해 주면 된다.

 

provider: [] 배열에 추가해 주었다. 이런 방식으로 여러 개의 커스텀 필터를 프로바이더 배열에 추가해주는 것도 가능하다.

import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
// import { LoggerMiddleware } from './common/middlewares/logger.middleware';
import { logger } from './common/middlewares/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './\bfilters/http-exception.filter';
// import * as cors from 'cors';
// import * as helmet from 'helmet';
@Module({
    // CatsModule을 구성하고 가져옴으로 인해서 CatsController와 CatsService를 직접 등록해줄 필요가 없다.
  imports: [CatsModule],

  providers: [
  
    { // 이 방식으로 전역 필터를 등록해 준다.
      provide: APP_FILTER,
      useClass: HttpExceptionFilter // 커텀 필터 등록
    }
  ]
})
export class AppModule implements NestModule {
  // configure()는 async/await를 적용해서도 사용 가능
  // consumer는 헬퍼 클래스로 미들웨어를 적용하는 데 사용한다. 미들웨어를 관리하는 메소드들을 가지고 있다.
  // 그러한 메소드들은 유동적으로 연결돼 있으며, forRoutes() 메소드의 경우 다양한 아규먼트를 받을 수 있다.
  // 그렇지만 이 메소드에는 주로 컴마로 구분된 컨트롤러 모듈 리스트를 넣는 경우가 많다.
  // apply() 메소드 역시 단일 또는 복수의 미들웨어를 받을 수 있다.
  configure(consumer: MiddlewareConsumer) {
    consumer
      // .apply(LoggerMiddleware)
      // .apply(cors(), helmet(), logger)
      .apply(logger)
      .exclude(
        { path: 'cats', method: RequestMethod.GET }, //RouteInfo 객체 형태
        { path: 'cats', method: RequestMethod.POST },
        'cats/(.*)'
      )
      .forRoutes(CatsController)
    // .forRoutes({
    //   path: 'cats',
    //   method: RequestMethod.GET,
    // });
  }
}

 

 

 

3. 추가

3.1. 모든 예외를 캐치하는 플랫폼(express, fastify)로부터 독립적인 필터 작성

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

// 모든 예외를 캐치하는 글로벌 필터: @Catch()	데코레이션 내부가 비어있다.
@Catch()
export class CatchEverythingFilter implements ExceptionFilter {
  // HttpAdapterHost 주입받아 플랫폼 독립적 구현 가능
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    // HttpAdapter를 생성자가 아닌 여기서 가져오는 이유는
    // 특정 상황에서 생성자에서 사용 불가능할 수 있기 때문
    const { httpAdapter } = this.httpAdapterHost;
    
    // HTTP 컨텍스트로 전환
    const ctx = host.switchToHttp();

    // 예외가 HttpException인 경우 해당 상태 코드 사용
    // 그 외의 경우 500 Internal Server Error 사용
    const httpStatus =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    // 표준화된 응답 본문 구성
    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
    };

    // 플랫폼에 독립적인 방식으로 응답 전송
    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}

 

아래와 같이 등록

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const { httpAdapter } = app.get(HttpAdapterHost);
  app.useGlobalFilters(new CatchEverythingFilter(httpAdapter));
  await app.listen(3000);
}

 

 

 

3.2. 내장 필터 상속

완전히 따로 작성된 필터가 아니라 내장 필터를 상속하여 사용하고 싶은 경우, 아래와 같이 BaseExceptionFilter를 상속하여 사용한다.

 

- 기본동작을 유지하면서 기능만 추가하는 경우

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    console.log('에러 발생:', exception); // 추가 로깅
    super.catch(exception, host);  // 기본 동작 유지
  }
}

 

- 특정 조건에서만 다르게 처리하고 싶은 경우

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    if (exception instanceof BusinessException) {
      // 비즈니스 예외는 커스텀 처리
      // super.catch() 호출하지 않음
    } else {
      // 나머지는 기본 동작 사용
      super.catch(exception, host);
    }
  }
}

 

- 완전히 개별적인 처리를 하고싶은 경우(super를 사용해서 처리를 위임하지 않음)

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    // super.catch() 호출 없이 완전히 커스텀 처리
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    response.status(500).json({
      customError: true,
      message: 'Custom error handling'
    });
  }
}

 

그러나 이렇게 BaseExceptionFilter를 상속해 사용하는 경우, 컨트롤러나 핸들러 단위로 필터를 지정할 때 new를 사용해 새로운 인스턴스를 만들면 안 된다. 프레임워크에서 자동으로 인스턴스를 만들어서 의존성을 주입하지 않기 때문이다. 전역 필터로 사용하는 것은 가능하다.

 

// 전역 범위에서는 new 사용 가능
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new AllExceptionsFilter(app.get(HttpAdapter)));

// 컨트롤러 범위에서는 new 사용하지 않음
@Controller('cats')
@UseFilters(AllExceptionsFilter)  // new 사용하지 않음
export class CatsController {}
Comments