본문 바로가기

node js/NestJs

[Nest Js] Nest Js 공식 문서 파헤치기 - Exception Filter(예외 필터)

트리스티가 JavaScript를 공부하며 남긴 기록입니다. 틀린 내용은 언제든지 말씀해주세요 ~!

 

지난 시간에는 Middleware에 대해 알아보았습니다. 이번 시간에는 Nest Js의 예외 처리에 대해서 알아보도록 하겠습니다.

 

 

 

 

📣 Nest Js의 예외처리 

 

예외 처리란 프로그램이 처리되는 동안 어떤 문제가 발생했을 경우, 처리를 중지하고 해당 예외를 처리하는 것을 말합니다. 훌륭한 개발자라면 멋지게 예외를 처리해서 나중에 프로그램에서 문제가 생겨도 금방 처리할 수 있게 끔 만들어 놔야 합니다. 하지만 예외를 처리하기 위해 예외가 발생할 것 같은 모든 곳에 예외 처리 코드를 작성하는 것은 너무 불합리합니다. 그래서 Nest Js에서는 쉽게 예외를 처리할 수 있도록 Exception Filters 기능을 지원하고 있습니다.

 

Nest Js에는 예외 레이어가 내장되어 있어서 작동하고 있는 애플리케이션 전체에서 처리되지 않은 예외를 처리하는 역할을 합니다. 만약 예외를 처리하지 않았다면 예외 레이어가 처리하지 않은 예외를 잡아내고 적절한 응답을 사용자에게 전달합니다. 밑의 그림은 좀 이상하게 그려져 있긴 하지만 pipe를 거쳐서 Route Handler까지 간 요청이 있을 때 예외가 발생했을 경우, Client Side에게 Filter의 응답 값을 전송하는 그림입니다. 

 

출처: https://docs.nestjs.kr/exception-filters

 

 

 

 

예외 레이어가 에어를 처리하지 않으면 적절한 응답을 내보낸다고 했는데, 어떤 형식으로 내보낼까요? 예외 레이어에는 내장된 전역 예외 필터가 존재하는데 기본적으로 HttpException 유형의 예외를 잡아서 처리하게 됩니다. 만약 HttpException도 아니고 HttpException에서 상속되는 예외도 아닌 경우, 그러니까 인식되지 않은 예외의 경우에는 내장된 전역 예외 필터가 기본 JSON 응답을 생성해서 보내게 됩니다.

 

간단하게 예를 들어보겠습니다. 컨트롤러에 다음과 같은 엔드 포인트가 있다고 가정해보겠습니다. 애플리케이션을 실행하면 정상적으로 "Hello World"를 출력하게 되죠.

 

// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello(): string {
		return this.appService.getHello();
	}
}

 

 

 

 

 

하지만 다음과 같은 코드라면 어떤 일이 발생할까요?

 

// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello(exam): string {
		return exam.hello();
	}
}

 

 

 

 

 

보시는 바와 같이 내장된 예외 필터가 자동으로 JSON 응답을 생성해게 됩니다. Http Status Code 500은 Internal Server Error로서 서버가 처리 방법을 모르는 상황이 발생했음을 의미합니다. 전역 예외 필터는 기본적으로 JSON응답을 만들어서 보낼 때 statusCode와 message 속성을 포함해서 전송하도록 합니다.

 

JSON 형식으로 넘어간다.

 

 

 

 

 

📣 Throwing standard exceptions 

 

하지만 내장된 예외 필터가 자동으로 만드는 JSON응답을 보내는건 사용자 한 테도, 개발자한테도 친절한 응답이 아닌 것 같습니다. 그래서 이번에는 직접 JSON응답을 만들어서 보내보도록 하겠습니다. Nest Js는 HttpException 클래스를 제공하기 때문에 다음과 같이 개발자가 하드 코딩해서 응답을 만들 수도 있습니다. 한번 403 Forbidden 에러를 발생시켜보도록 하겠습니다.

 

 

// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello() {
		throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
	}
}

 

 

 

 

그러면 이런 JSON 응답이 만들어지게 됩니다.

 

 

 

Nest Js의 HttpException 생성자는 반드시 2개의 인자를 받아야만 사용할 수 있습니다. 앞쪽의 response 인수는 JSON 응답 본문을 정의하고, 뒤쪽의 status 인수는 HTTP 상태코드를 정의합니다. 이때 response 인수는 string이 올 수도 있고, object가 올 수도 있습니다. 그리고 기본적으로 생성되는 JSON 응답은 statusCode와 message를 담습니다. 

 

만약 JSON 응답의 message 부분을 재정의 하려면 HttpException의 response 인수에 string 형태의 문자열을 제공하면 되고, JSON 응답 전체를 재정의 하려면 HttpException의 response 인수에 object 형태의 객체를 제공하면 됩니다. HTTP 상태 코드를 재정의 하고 싶다면 HttpException의 status 인수를 바꾸면 됩니다. 가장 좋은 방법은 @nestjs/common에 존재하는 HttpStatus 열겨형을 사용하는 것입니다.

 

간단하게 재정의를 해보도록 하겠습니다. exceptionObj라는 객체를 만들어서 HttpException의 response 인자에 전달해주고, HTTP 상태코드는 Forbidden으로 해보겠습니다. 물론 status Code는 통일시켜주는 것이 정상이지만, 아래의 예제에서는 어떻게 변화하는지 확인하기 위해 다르게 작성하였습니다.

 

// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello() {
		const exceptionObj = {
			statusCode: HttpStatus.BAD_REQUEST,
			message: '커스텀 에러를 만들었습니다.'
		};

		throw new HttpException(exceptionObj, HttpStatus.FORBIDDEN);
	}
}

 

 

 

 

 

그러면 다음과 같이 exceptionObj 안에 담겨있던 statusCode와 message가 출력되고, 우측 하단의 Http Status Code는 403 Forbidden으로 출력되는 것을 확인하실 수 있습니다.  

 

 

 

 

 

 

📣 Custom exceptions 

 

기본적으로 제공되는 HttpException만으로도 대부분의 예외를 설명할 수는 있겠지만, 제공되는 것만으로는 내가 만든 애플리케이션에서 발생하는 모든 예외를 설명할 수는 없을 것입니다. Nest Js에서는 HttpException 클래스를 상속받도록 해서 나만의 예외를 만들 수 있습니다. 이렇게 만든다면 Nest Js가 예외를 인식하고 응답을 자동으로 처리하게 할 수 있습니다. 아래의 예제는 exceptionObj 객체를 받는 ExampleCustomException을 만드는 예제입니다.

 

 

// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello() {
		const exceptionObj = {
			statusCode: HttpStatus.BAD_REQUEST,
			message: '커스텀 에러를 만들었습니다.'
		};

		throw new ExampleCustomException(exceptionObj);
	}
}



// ExampleCustomException.ts

export class ExampleCustomException extends HttpException {
	constructor(exceptionObj) {
		super(exceptionObj, HttpStatus.FORBIDDEN);
	}
}

 

 

 

📣 Exception filters 

 

위의 예제에서 처럼 엔드 포인트에서 예외를 처리해도 되지만, 저렇게 작성하면 기존 로직이랑 섞여서 코드가 깔끔해보이지는 않을 것 같습니다. 그래서 Nest Js에서는 Exception filter를 사용해서 예외를 한 곳에서 처리할 수 있게 해 줍니다. 한번 HttpException 클래스의 예외를 포착하고 커스텀한 응답을 내보낼 수 있도록 하는 예외 필터를 만들어 보겠습니다. 

 

우선은 @Catch() 데코레이터에 감지할 예외를 설정해줍니다. 여기서는 HttpException 타입의 예외만 찾을 것 이기 때문에 HttpException을 작성하였습니다. 물론 단일 매개변수를 넘겨줘도 되고, 여러 예외를 감지하게 하기위해 쉼표를 활용해서 구분된 목록을 넘겨줘도 됩니다.

 

그 다음에는 ExceptionFilter의 catch(exception: HttpException, host: ArgumentsHost) 메소드를 구현해야 합니다. catch메소드 안의 HttpException은 그냥 예외 타입이기 때문에 다른 타입을 작성하셔도 됩니다. 그다음에는 Request와 Response 객체를 사용해주면 됩니다. Request 객체를 사용하면 url을 추출할 수 있고, Response 객체를 사용하면 나만의 커스텀 응답을 만들 수 있습니다.

 

마지막으로 내가 적용하고 싶은 Controller나 엔드포인트에 @UseFilters() 데코레이터를 사용하셔서 Exception Filter를 구현한 클래스를 주입해주시면 됩니다. 예제에서는 ExceptionHandler 클래스의 인스턴스를 전달해주는 형식인 new ExceptionHandler()라고 작성하였지만 new를 붙이지 않고 그냥 클래스를 전달하는 형식으로 ExceptionHandler라고 작성해주셔도 됩니다.

 

또한 @Catch() 데코레이터에서 그랬던것 처럼 여기에도 쉼표를 사용해서 여러 개의 Exception Filter를 설정할 수 있습니다. 만약 어떤 Controller의 전체 엔드 포인트에 내가 만든 Exception Filter를 적용하고 싶으면 아래의 예제와 같이 작성해주시면 되고, 그게 아니라 엔드 포인트 개별마다 다른 Exception Filter를 적용하고 싶으시면 @Get(), @Post() 같은 데코레이터에다가 @UseFilters() 데코레이터를 붙여주시면 됩니다. 

 

// ExceptionHandler.ts

@Catch(HttpException)
export class ExceptionHandler 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();

		response.status(status).json({
			statusCode: status,
			timestamp: new Date().toISOString(),
			path: request.url
		});
	}
}


// app.controller.ts
// AppController의 모든 엔드포인트에 적용하고 싶은 경우

@Controller()
@UseFilters(new ExceptionHandler())
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello(): string {
		throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
	}
}


// app.controller.ts
// 하나의 엔드포인트에만 적용하고 싶은 경우

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	@UseFilters(new ExceptionHandler())
	getHello(): string {
		throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
	}
}

 

 

 

 

 

 

이렇게 설정해준 엔드 포인트로 접근해보면, 아까 ExceptionHandler 클래스에서 작성한 statusCode, timestamp, path 값이 담긴 JSON 객체를 전달해주는 것을 확인하실 수 있습니다.

 

 

짜잔!

 

 

 

 

 

 

@UseFilters() 데코레이터를 사용해서 Controller 하나마다 설정하기 귀찮다고요? 그렇다면 전역으로 설정하는 방법도 있습니다. main.ts 파일에 useGlobalFilters() 메소드에 Exception Filter를 구현한 클래스의 인스턴스를 넘겨주시면 됩니다. 여기서는 반드시 new를 붙여서 인스턴스를 전달해주셔야 됩니다. 왜냐하면 예외 필터는 예외가 발생한 모듈 외부에서 수행되기 때문에

 

이렇게 설정하면 Controller 부분에서 @UseFilters() 데코레이터가 없어도 정상적으로 Exception Filter를 구현한 클래스를 거쳐서 예외처리가 되는 것을 확인하실 수 있답니다. 

 

 

// main.ts

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



// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello(): string {
		throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
	}
}

 

 

 

 

예외 필터는 예외가 발생한 모듈 외부에서 수행되기 때문에 Exception Filter를 구현한 클래스에는 의존성을 주입할 수 없다는 단점이 있었습니다. 만약 의존성을 주입받아서 다른 기능을 만들어보고 싶은 경우에는 어떻게 해야 할까요? 이럴 때는 custom providers 기능을 사용해서 등록하면 사용할 수 있습니다. 이 방법을 사용하면 main.ts에 useGlobalFilters() 메소드 없이도 전역으로 사용됩니다.

 

하지만 여기서 custom providers를 다루기는 것은 완전히 다른 내용이기 때문에 추후에 Custom providers 항목에서 자세히 다뤄보도록 하겠습니다.

 

 

// app.module.ts

@Module({
	imports: [StudyModule, SubModule],
	controllers: [AppController],
	providers: [
		AppService,
		{
			provide: APP_FILTER,
			useClass: ExceptionHandler
		}
	]
})
export class AppModule {}

 

 

 

 

 

📣 Catch everything 

 

 

만약 @Catch() 데코레이터를 빈칸으로 두면 어떻게 될까요? 이때는 처리되지 않은 모든 예외를 Exception Filter 구현 클래스에서 잡게 됩니다. 다만, 이 경우에는 자칫하면 모든 예외를 똑같은 JSON 객체로만 반환할 가능성이 있기 때문에 instance of를 사용해서 각 예외마다 다른 JSON 객체를 반환시키게 하는 것이 좋습니다.

 

 

// ExceptionHandler.ts

@Catch()
export class ExceptionHandler 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();

		if (exception instanceof ExampleCustomException) {
			response.status(status).json({
				statusCode: status,
				message: exception.getResponse(),
				path: request.url
			});
		} else {
			response.status(status).json({
				statusCode: status,
				timestamp: new Date().toISOString(),
				path: request.url
			});
		}
	}
}


// app.controller.ts

@Controller()
export class AppController {
	constructor(private readonly appService: AppService) {}

	@Get()
	getHello(): string {
		const exceptionObj = {
			message: 'Catch를 테스트 해보자'
		};

		throw new ExampleCustomException(exceptionObj);
	}
}

 

 

 

 

 

애플리케이션을 실행시켜서 확인해보면 다음과 같은 JSON 객체가 반환되는 것을 확인할 수 있습니다.

 

JSON 객체 반환 확인!

 

 

 

 

 

 

📣 Inheritance 

 

위에서 했던 것처럼 완전히 새로운 커스텀 예외 필터를 만들 수도 있지만, 기본으로 제공되는 전역 예외 필터를 개발자가 원하는 대로 확장하거나 동작을 재정의할 수도 있습니다. 다만, 이 경우에는 ExceptionFilter를 구현하지 않고 BaseExceptionFilter상속받아서 사용해야 합니다. 예외 처리를 기존에 했던 것처럼 기본 필터 방식에 넘기려면 catch 메소드를 호출해주면 됩니다. 

 

// InheritanceExceptionHandler.ts

@Catch()
export class InheritanceExceptionHandler extends BaseExceptionFilter {
	catch(exception: unknown, host: ArgumentsHost) {
		super.catch(exception, host);
	}
}



// main.ts

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

	const { httpAdapter } = app.get(HttpAdapterHost);
	app.useGlobalFilters(new InheritanceExceptionHandler(httpAdapter));

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

 

 

 

 

 

 

아니면 Custom providers 방식을 써서 main.ts에 작성하지 않고 사용할 수도 있습니다.

 

// app.module.ts

@Module({
	imports: [StudyModule, SubModule],
	controllers: [AppController],
	providers: [
		AppService,
		{
			provide: APP_FILTER,
			useClass: InheritanceExceptionHandler
		}
	]
})
export class AppModule {}