Filter를 구현하는 과정에서, http-exceptions Filter를 만들었으나 AxiosError는 HttpException으로 떨어지지 않는다는 점을 확인했습니다.
문제를 해결하기 위해, convertAxiosErrorToHttpException()라는 공용 변환 함수를 개발하게 되었는데요, 결국 에러를 변환하기 위해 서비스 레이어에서 반복적으로 try-catch 문을 작성해야 하는 문제는 해결되지 않았습니다.
관련해서 Interceptor를 적용할 수 있는 문제인지 궁금해졌고, 오늘은 Interceptor에 관해 깊게 다루어 보고자 합니다. 이 글은, 이전 글의 흐름과 이어집니다. 이전 글을 참고하실 분께서는 다음 링크를 참고해 주세요.
이전 글: https://velog.io/@minkwan/TILNest-20250430

Interceptor는 그 이름에 걸맞게, 클라이언트와 컨트롤러 내 라우트 핸들러의 중간 지점에서, 요청 또는 응답을 가로채는 역할을 합니다.
공식 문서에서는 다음과 같은 Interceptor의 활용 방안을 제시합니다.
함수에서 발생한 예외를 변환 함수에서 발생한 예외를 변환하는 목적으로 Interceptor를 활용한다고 하니, 조금 더 살펴봐도 괜찮을 것 같다는 생각이 듭니다.
개별 Interceptor는 두 개의 argument를 받습니다. ExecutionContext와 Call Handler가 바로 그 두 개의 argument에 해당합니다.
ExecutionContext는 ArgumentsHost를 상속받습니다. 우선 첫 번째 인자인 ExecutionContext에 관해 깊게 학습해 보겠습니다.
NestJS는 여러 애플리케이션 컨텍스트(HTTP / WebSocket 등)를 활용하기 위한, 다양한 유틸리티 클래스를 제공합니다. 해당 유틸리티를 통해 실행 컨텍스트 전반에서 작동하는 가드, 필터, 인터셉터 등을 개발할 수 있게 됩니다.
특별히 오늘은, 이러한 유틸리티 클래스 중 ArgumentHost와 ExecutionContext에 관해 다루고자 합니다.
ArgumentHost Class는, 핸들러에 전달되는 인자들을 가져오는 메서드를 제공합니다. 일반적으로 host라는 매개변수로 참조되곤 합니다.
현재 개발 중인 HTTP 서버 애플리케이션의 경우, host 객체는 Express의 [request, response, next] 배열을 캡슐화합니다. 각각은 요청 객체 / 응답 객체 / 요청-응답 사이클 제어 함수입니다.
만약 개발 중인 애플리케이션이 GraphQL 기반이라면 host 객체는 [root, args, context, info] 배열을 캡슐화하게 되는데요, 현재로서는 우리의 관심사라고 보기는 어렵습니다.
애플리케이션 컨텍스트에서 작동하도록 가드 / 필터 / 인터셉터 등을 설계할 때, 현재 메서드가 어떤 종류의 애플리케이션에서 실행 중인지 확인할 수 있는 방법이 필요합니다. 이를 위해 ArgumentHost의 getType() 메서드를 활용하게 됩니다.
if (host.getType() === 'http') {
// do something that is only important in the context of regular HTTP requests (REST)
} else if (host.getType() === 'rpc') {
// do something that is only important in the context of Microservice requests
} else if (host.getType<GqlContextType>() === 'graphql') {
// do something that is only important in the context of GraphQL requests
}
host 객체의 getArgs() 메서드를 사용하면, 핸들러에 전달되는 인자 배열을 가져올 수 있습니다.
const [req, res, next] = host.getArgs();
getArgByIndex() 메서드를 통해, 특정 인덱스의 인자를 추출하는 방법도 있습니다.
const request = host.getArgByIndex(0);
const response = host.getArgByIndex(1);
요청 또는 응답 객체를 위와 같이 인덱싱하는 방식은 일반적으로 추천되지 않습니다. 인덱스를 사용하면 애플리케이션이 특정 실행 컨텍스트에 결합되기 때문입니다.
가령, HTTP 요청과 응답 객체는 Express나 Fastify와 같은 프레임워크에서만 사용되고, GraphQL이나 WebSocket 환경에서는 다른 방식으로 요청과 응답이 처리됩니다. 인덱스를 사용하면 코드가 특정 프레임워크나 실행 환경에 결부되어, 향후 재사용성이나 확장성 측면에서 제약이 발생할 가능성이 높습니다.
따라서, host 객체의 컨텍스트 전환 유틸리티 메서드를 사용하는 편이 좋겠습니다.
/**
* Switch context to RPC.
*/
switchToRpc(): RpcArgumentsHost;
/**
* Switch context to HTTP.
*/
switchToHttp(): HttpArgumentsHost;
/**
* Switch context to WebSockets.
*/
switchToWs(): WsArgumentsHost;
switchToHttp() 헬퍼를 호출하면, HTTP 애플리케이션 컨텍스트에 적합한 HttpArgumentsHost 객체를 반환받을 수 있습니다.
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
ExecutionContext는 위에서 살펴본 ArgumentsHost로부터 상속받아 만든 클래스입니다. 현재 실행 프로세스에 대한 추가 정보를 제공하게 되는데요, 예를 들어 가드의 canActivate() 메서드나 인터셉터의 intercept() 메서드에서 활용할 수 있습니다.
export interface ExecutionContext extends ArgumentsHost {
/**
* Returns the type of the controller class which the current handler belongs to.
*/
getClass<T>(): Type<T>;
/**
* Returns a reference to the handler (method) that will be invoked next in the
* request pipeline.
*/
getHandler(): Function;
}
getHandler() 메서드는 호출된 핸들러에 대한 참조를 반환합니다. getClass() 메서드는 이 특정 핸들러가 속한 컨트롤러 클래스의 타입을 반환합니다.
만약, HTTP 컨텍스트에서 현재 처리 중인 요청이 POST 요청이고, CatsController의 create() 메서드에 바인딩 되어 있다면, getHandler() 메서드는 create() 메서드에 대한 참조를 반환하고, getClass() 메서드는 CatsController 클래스를 반환하게 됩니다.
const methodKey = ctx.getHandler().name; // "create"
const className = ctx.getClass().name; // "CatsController"
현재 클래스와 핸들러 메서드에 액세스할 수 있는 기능은 큰 유연성을 제공합니다. Reflector나 Metadata를 활용하여 가드나 인터셉터 내에서 이러한 access가 이루어집니다. 이에 관해서 더 깊게 살펴볼 필요가 있겠습니다.
Reflector.createDecorator() 메서드를 통해 문자열 배열을 인수로 받는 Roles 데코레이터를 만들어 보겠습니다.
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
방금 만든 Roles 데코레이터를 사용하기 위해, 핸들러에 문자열 배열을 전달하겠습니다.
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
Roles 데코레이터 메타데이터를 create() 메서드에 첨부하는 과정을 통해, admin 역할을 가진 사용자만 해당 경로에 접근할 수 있도록 지정했습니다.
경로의 역할(=admin)에 접근하려면, 다시 Reflector를 사용하면 됩니다.
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
핸들러 메타데이터를 읽기 위해 get() 메서드를 사용합니다.
const roles = this.reflector.get(Roles, context.getHandler());
Reflector의 get() 메서드는 두 개의 인자를 받아 메타데이터에 접근할 수 있도록 해줍니다. 한마디로, 현재 처리 중인 라우트 핸들러의 메타데이터를 추출하기 위해 get() 메서드를 사용하는 것입니다.
Controller level에서 메타데이터를 적용하여, 해당 컨트롤러 클래스의 모든 라우터에 메타데이터가 적용되도록 구성할 수도 있습니다.
@Roles(['admin'])
@Controller('cats')
export class CatsController {}
위와 같은 상황에서는 컨트롤러 메타데이터를 추출하기 위해, getClass() 메서드를 두 번째 인자로 전달하여, 컨텍스트로서 컨트롤러 클래스를 전달해야 합니다.
const roles = this.reflector.get(Roles, context.getClass());
controller level과 method level에서 메타데이터를 동시에 추출하고 서로 다른 방식으로 결합하는 케이스도 있을 수 있습니다.
@Roles(['user'])
@Controller('cats')
export class CatsController {
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
}
기본 역할은 user이고, 특정 메서드에서 선택적으로 override 하기 위한 의도라면, 다음과 같이 getAllAndOverride() 메서드를 사용하는 것이 적절합니다.
const roles = this.reflector.getAllAndOverride(Roles, [context.getHandler(), context.getClass()]);
위 코드에서 roles에는 ['admin']이 들어가게 됩니다. 핸들러에 Roles가 존재한다면, 우선순위가 더 높다고 판단하여, 우선순위가 높은 하나만 반환하게 됩니다.
컨트롤러와 메서드의 메타데이터를 모두 가져와 병합하기 위해서는 getAllAndMerge() 메서드를 사용하면 됩니다.
const roles = this.reflector.getAllAndMerge(Roles, [context.getHandler(), context.getClass()]);
이 경우에는 ['admin', 'user']가 모두 roles에 들어가게 됩니다. 핸들러와 클래스에 있는 모든 역할을 병합하여 반환하게 되는 것이지요.
@SetMetadata() 데코레이터를 사용하여 메타데이터를 핸들러에 추가하는 방식도 있습니다.
@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
위 코드에서 roles는 메타데이터에 대한 key이고, ['admin']은 value에 해당합니다. 동작은 합니다만, 라우터에 @SetMetadata()를 직접 사용하는 것은 좋은 방식이라고 보긴 어렵습니다. 대신 아래와 같이 커스텀 데코레이터를 만들어 사용하는 것이 좋습니다.
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
Reflector.createDecorator() 방식과 유사하지만, 메타데이터 key와 value에 대해 더 많은 제어를 할 수 있고, 여러 인자를 받는 데코레이터를 생성할 수 있다는 부분에서 이점이 더 큽니다.
@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
Reflector 헬퍼 클래스를 통해 메타데이터를 가져올 수 있게 되었습니다.
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
이제 메타데이터를 읽기 위해 get() 메서드를 사용합니다.
const roles = this.reflector.get<string[]>('roles', context.getHandler());
다시 Interceptor로 돌아옵니다. 지금까지는, 첫 번째 인자인 Execution Context를 명확하게 이해하기 위해 ArgumentHost를 중심으로 개념을 살펴봤습니다.
Interceptor는 두 번째 인자로 Call Handler를 받게 됩니다. Call Handler는 인터셉터에서 라우트 핸들러 메서드를 호출할 때 사용합니다. 만약 intercept() 메서드에서 handle() 메서드를 호출하지 않으면, 라우트 핸들러 메서드는 실행되지 않습니다. 사실상 intercept() 메서드가 request / response stream을 wrapping 한다는 것을 의미합니다.
handle() 메서드는 Observable을 반환합니다. 예를 들어 /cats 경로로 POST 요청이 들어온다면, 해당 요청은 CatsController 내부의 create() 핸들러로 전달됩니다. 만약 handle() 메서드를 호출하지 않은 인터셉터가 경로 어딘가에서 호출된다면, create() 메서드는 실행되지 않을 것입니다. handle()이 호출되고 그 결과로 Observable이 반환되어야 create() 핸들러가 실행됩니다.
인터셉터를 통해 로깅을 구현한 코드를 살펴보겠습니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('Before...');
const now = Date.now();
return next
.handle()
.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
요청이 들어오면 Before...라는 메세지를 콘솔에 출력한 뒤 시간을 측정하고, next.handle()을 통해 실제로 요청을 처리하게 됩니다. 요청 처리가 완료된 후에는 After...와 함께 처리에 걸린 시간을 콘솔에 출력하게 되죠.
RxJS(Reactive Extensions for JavaScript)는 비동기 처리를 위한 라이브러리입니다. Observable이라는 개념을 중심으로 데이터 스트림을 표현합니다. pipe() 메서드를 통해, tap 연산자를 적용하기 위해 체이닝을 진행했습니다. 마지막으로 tap()을 통해 요청 처리가 완료된 후 소요 시간을 로그로 출력하는 side-effect를 추가하게 됩니다.
인터셉터를 바인딩 하기 위해서 @UseInterceptors() 데코레이터를 활용합니다.
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
컨트롤러 레벨에서 인터셉터를 적용했습니다. 만약 /cats 경로로 GET 요청이 들어오면, 다음과 같은 출력 결과를 확인할 수 있게 됩니다.
Before...
After... 1ms
아래와 같이 인스턴스를 직접 생성하여 전달하는 방식도 있습니다. 다만, 아래의 방식에서는 의존성 주입이 자동으로 되지는 않습니다. NestJS의 Dependency Injection 시스템을 활용하기 위해서는 클래스 자체를 직접 전달하는 위의 방식이 더 적합하겠습니다.
@UseInterceptors(new LoggingInterceptor())
export class CatsController {}
전역으로 인터셉터를 적용할 수도 있습니다.
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
위 방식도 인스턴스를 직접 생성하여 전달하는 방식을 따르기에, 의존성 주입 시스템을 활용할 수 없게 됩니다. 아래와 같은 방식을 적용하는 것이 NestJS의 기본 철학에 더 가깝다는 생각이 듭니다.
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule {}
handle()이 Observable을 반환한다고 했습니다. RxJS의 map() 연산자를 사용하여 응답값을 매핑할 수 있습니다.
TransformInterceptor를 생성하겠습니다. 이 인터셉터는 RxJS의 map() 연산자를 사용하여, 응답 객체를 새로 생성된 객체의 data 속성에 할당하고, 새로운 객체를 클라이언트에 반환하는 기능을 수행합니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
data: T;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(map(data => ({ data })));
}
}
/cats 경로로 GET 요청을 진행하면, 다음과 같은 응답값을 반환받게 됩니다.
{
"data": []
}
만약에 null 값을 문자열 ''로 변환해야 한다고 가정해 봅시다. 한 줄의 코드로 처리하고 인터셉터를 전역적으로 바인딩 하여, 등록된 각 핸들러에서 자동으로 사용할 수 있도록 설정이 가능합니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(map(value => value === null ? '' : value ));
}
}
RxJS의 catchError() 연산자를 활용하여 발생한 예외를 오버라이드 하여 예외를 처리할 수도 있습니다. 사실 제가 가장 궁금해했던 주제이기도 하죠.
import {
Injectable,
NestInterceptor,
ExecutionContext,
BadGatewayException,
CallHandler,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
catchError(err => throwError(() => new BadGatewayException())),
);
}
}
다만 오늘 글에서 프로젝트에 대한 구체적인 적용을 논하진 않겠습니다.
핸들러 호출을 완전히 방지하고 다른 값을 반환해야 하는 경우도 있습니다. 응답 시간을 개선하기 위한 캐싱 관련 이슈가 있을 수 있습니다.
캐시에서 응답을 반환하는 간단한 캐시 인터셉터를 살펴보고자 합니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const isCached = true;
if (isCached) {
return of([]);
}
return next.handle();
}
}
RxJS의 of() 연산자를 사용하여 새로운 스트림을 반환함으로써, 라우트 핸들러가 전혀 호출되지 않는다는 점이 핵심입니다.
캐싱(Cacheing)이란, 자주 사용하는 데이터나 결과를 빠르게 접근할 수 있도록 임시 저장소에 보관하는 기능입니다. 만약 캐싱된 데이터가 있으면 실제 컨트롤러에 작성된 핸들러를 실행하지 않고, 즉시 캐싱된 데이터를 반환해야겠죠.
핸들러 호출을 완전히 방지하고 다른 값을 반환해야 하는 경우에도 인터셉터를 사용할 수 있다는 점만 인지하고 넘어가도록 하겠습니다.
라우트 요청에 대해 타임아웃을 처리하고 싶을 수도 있습니다. RxJS 연산자를 사용하여 스트림을 조작할 수 있는 다양한 가능성에 관해 말하고 싶은 것입니다.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(5000),
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException());
}
return throwError(() => err);
}),
);
};
};
여기까지, Interceptor를 이해하기 위한 핵심 개념 중 ExecutionContext와 Reflector, 그리고 NestJS의 실행 컨텍스트 처리 방식에 대해 정리해 보았습니다. 이를 통해 Interceptor 내부에서 발생한 예외를 식별하고, 원하는 방식으로 변환하거나 로깅하는 기반을 마련할 수 있습니다.
다음 글에서는 convertAxiosErrorToHttpException()을 Interceptor 내부에 통합하여, 서비스 레이어에서 반복적인 try-catch 없이도 AxiosError를 처리할 수 있는 실용적인 코드를 구현해 보고자 합니다.
코드를 작성할수록, 자연적으로 지적 요구사항이 계속 늘어납니다. 그런 내용들을 매일 이 정도 분량으로 정리하는 것이 여전히 쉽진 않습니다만, 확실히 요즘은 정도(正道)를 걷고 있다는 느낌을 받습니다. 이대로 계속하면 될 것 같습니다. 5월도 화이팅입니다.
Reference: https://docs.nestjs.com/interceptors
Reference: https://docs.nestjs.com/fundamentals/execution-context