NestJS ExecutionContext와 ArgumentsHost
들어가며
Docker로 Elasticsearch, Logstash, Kibana를 이것저것 설정하면서 테스트하던 중, 실제로 로그를 쌓아줄 서버가 필요해져서 간단한 NestJS 프로젝트를 생성했다.
ELK 설정을 변경해보고, 로그 구조를 어떻게 가져가면 좋을지 고민하면서 관련 내용을 찾아보던 중 Stack Overflow에서 흥미로운 질문을 보게 되었다.
NestJS의 Filter에서 host(ArgumentsHost)를 통해 핸들러 정보를 가져오는 방법에 대해 알려주세요.
이 질문을 보자마자 질문자가 어떤 상황을 겪고 있는지 어느 정도 짐작할 수 있었다. 실제로 나 역시 비슷한 상황을 겪은 적이 있었기 때문이다. Interceptor나 Guard에서는 ExecutionContext를 통해 현재 요청을 처리할 Controller와 Handler 정보를 가져올 수 있는데, Exception Filter에서는 같은 방식으로 접근했을 때 원하는 값이 나오지 않는 경우가 있다.
처음에는 단순히 '왜 Filter에서는 getClass()나 getHandler()가 안 되지?' 정도의 의문으로 시작했지만, 다시 살펴보니 NestJS의 Request Lifecycle과 ExecutionContext, ArgumentsHost의 목적을 함께 이해해야 하는 문제였다. 이번 글에서는 이 상황을 기준으로 NestJS에서 ExecutionContext와 ArgumentsHost가 어떤 차이를 가지는지 정리해보려고 한다.
1. 개요
먼저 로그에 담고 싶은 데이터를 간단히 정리해보았다. HTTP 요청이 성공하거나 실패했을 때, 어떤 요청에서 어떤 예외가 발생했는지 추적하기 위해 다음과 같은 형태의 로그 객체를 구성한다고 가정했다.
export class HttpLogVo {
readonly context: string;
readonly message = '';
readonly status: number;
readonly method: string;
readonly url: string;
readonly request: {
ip: string;
xforwardedfor: string;
params: Record<string, unknown>;
query: Record<string, unknown>;
body: unknown;
};
readonly exception: {
name: string;
message: string;
cause: unknown;
};
}여기서 context는 로그가 어느 Controller 또는 Handler에서 발생했는지를 나타내기 위한 값이다. 예를 들어 AppController.getOkResponse와 같은 형태로 남길 수 있다면, 이후 로그를 확인할 때 어떤 API에서 문제가 발생했는지 훨씬 쉽게 추적할 수 있다. NestJS에서 Guard나 Interceptor에서는 ExecutionContext를 통해 현재 요청이 어느 Controller와 Handler에 바인딩되어 있는지 확인할 수 있다.
const contextName = [context.getClass()?.name, context.getHandler()?.name].join('.');이렇게 하면 현재 요청을 처리할 Controller 클래스 이름과 Handler 메서드 이름을 조합해서 로그 컨텍스트로 사용할 수 있다. 즉, 이 값의 목적은 '어떤 클래스의 어떤 메서드에서 로그가 발생했는가?'를 기록하는 것이다. 이를 테스트하기 위해 AppController에 몇 가지 엔드포인트를 구성했다.
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('ok')
getOkResponse(): void {
return;
}
@Get('fail')
throwException(): never {
throw new BadRequestException();
}
@Get('error')
throwError(): never {
throw new Error('test error');
}
@Get('guard')
@UseGuards(AuthGuard)
throwExceptionByGuard(): void {
return;
}
@Get('pipe')
@UsePipes(AlwaysFailedPipe)
throwValidationPipe(@Param() params: never): void {
return;
}
}각 엔드포인트는 다음과 같은 역할을 한다.
/ok: 요청이 정상적으로 처리되는 경우/fail: Handler 내부에서HttpException이 발생하는 경우/error: Handler 내부에서 예상하지 못한Error가 발생하는 경우/guard: Guard 단계에서 예외 또는 접근 거부가 발생하는 경우/pipe: Pipe 단계에서 예외가 발생하는 경우
로그는 성공 요청과 실패 요청을 나누어 처리하도록 구성했다. 성공한 요청은 LoggingInterceptor에서 남기고, 예외가 발생한 요청은 AllExceptionFilter에서 남긴다.
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly logger: Logger) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
tap(() => {
this.logger.verbose(new HttpLogVo(context));
}),
);
}
}위 LoggingInterceptor는 bootstrap 단계에서 인스턴스를 직접 생성하여 global interceptor로 등록하는 방식이라면 @Injectable() 데코레이터를 붙이지 않아도 동작할 수 있다. 다만 Nest의 DI 컨테이너를 통해 의존성을 주입받고 싶다면 @Injectable()을 붙이고 provider로 등록하는 방식이 더 일반적이다. 예외 처리는 다음과 같이 AllExceptionFilter에서 수행한다.
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(err: HttpException | Error, host: ArgumentsHost) {
let exception: HttpException;
let type: keyof Pick<Logger, 'warn' | 'error'>;
if (err instanceof HttpException) {
exception = err;
type = 'warn';
} else {
exception = new InternalServerErrorException({
name: err.name,
message: err.message,
});
type = 'error';
}
this.logger[type](new HttpLogVo(host, exception));
host.switchToHttp().getResponse<Response>().status(exception.getStatus()).send({
name: exception.name,
message: exception.message,
statusCode: exception.getStatus(),
});
}
}이제 각 엔드포인트를 호출해보면 Handler 내부에서 발생한 예외는 어느 정도 예상대로 처리되지만, Guard나 Pipe에서 예외가 발생한 경우에는 로그에 기대했던 context 값이 누락되는 상황을 확인할 수 있다. 즉, ExecutionContext에서는 가져올 수 있었던 Controller와 Handler 정보가, Exception Filter의 ArgumentsHost에서는 기대한 방식으로 나오지 않는 것이다.
그렇다면 왜 Guard나 Pipe에서 발생한 예외를 Filter에서 처리할 때는 Handler 정보가 누락될까? 이 문제를 이해하려면 먼저 NestJS의 Request Lifecycle을 살펴볼 필요가 있다.
2. NestJS Request Lifecycle
NestJS에서 요청이 들어오면 Controller의 Handler가 바로 실행되는 것이 아니다. 요청은 여러 단계를 거쳐 처리된다. Middleware, Guard, Interceptor, Pipe, Handler, Exception Filter 등이 각각의 역할을 수행하며 요청 처리 흐름에 참여한다.
간단히 흐름을 정리하면 다음과 같다.
graph TD
IncomingRequest(Incoming Request) --> Middlewares
subgraph Middlewares
GlobalMiddlewares(Global Middlewares) --> ModuleMiddlewares(Module Middlewares)
end
ModuleMiddlewares --> Guards
subgraph Guards
GlobalGuards(Global Guards) --> ControllerGuards(Controller Guards) --> RouteGuards(Route Guards)
end
RouteGuards --> PreInterceptors
subgraph PreInterceptors["Interceptors - before handler"]
GlobalPreInterceptors(Global Interceptors) --> ControllerPreInterceptors(Controller Interceptors) --> RoutePreInterceptors(Route Interceptors)
end
RoutePreInterceptors --> Pipes
subgraph Pipes
GlobalPipes(Global Pipes) --> ControllerPipes(Controller Pipes) --> RoutePipes(Route Pipes) --> RouteParamPipes(Route Parameter Pipes)
end
RouteParamPipes --> Handler
subgraph HandlerArea["Handler"]
Handler(Controller Handler) --> Service(Service if exists)
end
Service --> PostInterceptors
subgraph PostInterceptors["Interceptors - after handler"]
GlobalPostInterceptors(Global Interceptors) --> ControllerPostInterceptors(Controller Interceptors) --> RoutePostInterceptors(Route Interceptors)
end
PostInterceptors --> Response(Server Response)
Guards -. exception .-> Filters
PreInterceptors -. exception .-> Filters
Pipes -. exception .-> Filters
Handler -. exception .-> Filters
PostInterceptors -. exception .-> Filters
subgraph Filters
RouteFilters(Route Filters) --> ControllerFilters(Controller Filters) --> GlobalFilters(Global Filters)
end
Filters --> Response이 흐름에서 중요한 점은 Guard와 Pipe가 Handler 실행 이전에 동작한다는 것이다. Guard는 해당 요청이 Handler까지 도달해도 되는지 판단하고, Pipe는 Handler의 인자로 전달될 값을 변환하거나 검증한다. 따라서 Guard나 Pipe에서 예외가 발생하면 Controller Handler는 실제로 호출되지 않을 수 있다.
반면 Interceptor는 Handler 실행 전후를 감싸는 구조로 동작한다. Interceptor에서는 ExecutionContext를 통해 현재 요청이 어떤 Controller와 Handler에 매핑되어 있는지 알 수 있다. Guard 역시 Handler 실행 전에 동작하지만, “앞으로 호출될 Handler”에 대한 정보는 알고 있기 때문에 ExecutionContext에서 getClass()와 getHandler()를 사용할 수 있다.
예를 들어 Guard 안에서 다음 코드를 실행하면 Controller와 Handler 정보를 확인할 수 있다.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
console.log(context.getClass()?.name);
console.log(context.getHandler()?.name);
return false;
}
}이 경우 AppController와 throwExceptionByGuard 같은 값이 정상적으로 출력될 수 있다. Guard는 아직 Handler를 실행하지는 않았지만, 현재 요청이 어떤 Handler로 향하고 있는지는 알고 있기 때문이다. 하지만 Exception Filter에서 전달받는 인자는 ExecutionContext가 아니라 ArgumentsHost이다. 따라서 Filter에서 다음처럼 타입만 ExecutionContext로 바꿔서 사용하는 것은 안전한 방식이 아니다.
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(err: HttpException | Error, host: ExecutionContext) {
console.log(host.getClass()?.name);
console.log(host.getHandler()?.name);
}
}TypeScript에서는 타입을 바꿔 적을 수 있지만, 런타임에서 실제로 전달되는 객체가 항상 Handler 정보를 가지고 있다는 보장은 없다. 특히 Exception Filter의 목적은 예외가 발생한 이후 HTTP 응답 객체에 접근하여 예외를 처리하는 것이지, 다음에 호출될 Handler를 추적하는 것이 아니다.
3. ExecutionContext와 ArgumentsHost
NestJS에서 ExecutionContext와 ArgumentsHost는 비슷해 보이지만 목적이 다르다. 두 객체 모두 현재 요청과 관련된 인자에 접근할 수 있다는 공통점이 있지만, 제공하는 정보의 범위와 사용되는 위치가 다르다.
먼저 ArgumentsHost는 현재 실행 환경의 인자 목록에 접근하기 위한 추상화 객체이다. HTTP 애플리케이션이라면 request, response, next와 같은 객체에 접근할 수 있고, RPC나 WebSocket 환경이라면 그에 맞는 인자에 접근할 수 있다. Exception Filter에서 ArgumentsHost를 사용하는 이유도 여기에 있다. 예외가 발생했을 때 현재 요청의 response 객체를 가져와 적절한 상태 코드와 응답을 내려주기 위함이다.
const http = host.switchToHttp();
const request = http.getRequest<Request>();
const response = http.getResponse<Response>();반면 ExecutionContext는 ArgumentsHost를 확장한 객체이다. 여기에 현재 실행될 Controller 클래스와 Handler 메서드에 대한 정보가 추가된다. 그래서 Guard, Interceptor, Pipe 등에서는 getClass()와 getHandler()를 통해 현재 요청이 어떤 Handler에 매핑되었는지 알 수 있다.
const classRef = context.getClass();
const handler = context.getHandler();즉, 단순히 정리하면 ArgumentsHost는 요청 처리에 필요한 인자에 접근하기 위한 객체이고, ExecutionContext는 여기에 “현재 실행 컨텍스트가 어떤 Controller와 Handler에 연결되어 있는가”라는 정보까지 포함한 객체라고 볼 수 있다. 이 차이를 모르고 Exception Filter의 ArgumentsHost를 ExecutionContext처럼 사용하면 문제가 생긴다. 실제로 런타임에서 객체 내부를 확인해보면, Filter에서 받은 host에는 constructorRef나 handler가 비어 있는 경우가 있다.
예를 들어 Filter에서 받은 host는 다음과 같은 형태일 수 있다.
console.log(Object.getOwnPropertyDescriptors(host));{
args: {
value: [ [IncomingMessage], [ServerResponse], [Function: next] ],
writable: true,
enumerable: true,
configurable: true
},
constructorRef: {
value: null,
writable: true,
enumerable: true,
configurable: true
},
handler: {
value: null,
writable: true,
enumerable: true,
configurable: true
},
contextType: {
value: 'http',
writable: true,
enumerable: true,
configurable: true
}
}반면 Guard나 Interceptor에서 전달받은 ExecutionContext는 다음처럼 Controller와 Handler 정보를 가지고 있다.
console.log(Object.getOwnPropertyDescriptors(context));{
args: {
value: [ [IncomingMessage], [ServerResponse], [Function: next] ],
writable: true,
enumerable: true,
configurable: true
},
constructorRef: {
value: [class AppController],
writable: true,
enumerable: true,
configurable: true
},
handler: {
value: [Function: throwExceptionByGuard],
writable: true,
enumerable: true,
configurable: true
},
contextType: {
value: 'http',
writable: true,
enumerable: true,
configurable: true
}
}NestJS 공식 문서에서도 getHandler()는 호출될 Handler에 대한 참조를 반환하고, getClass()는 해당 Handler가 속한 Controller 클래스를 반환한다고 설명한다. 중요한 표현은 “호출될 Handler”이다. Guard나 Interceptor는 Handler 호출 전후의 실행 흐름에 있기 때문에 이 정보를 사용할 수 있다. 반면 Exception Filter는 예외가 발생한 이후 예외를 처리하는 단계이므로, 전달받은 ArgumentsHost에서 항상 Handler 정보를 기대하는 것은 적절하지 않다.
The getHandler() method returns a reference to the handler about to be invoked. The getClass() method returns the type of the Controller class which this particular handler belongs to. For example, in an HTTP context, if the currently processed request is a POST request, bound to the create() method on the CatsController, getHandler() returns a reference to the create() method and getClass() returns the CatsControllerclass (not instance).
따라서 Filter에서 getClass()나 getHandler()를 직접 사용하려고 하기보다는, 예외가 발생하기 전 단계에서 필요한 정보를 미리 저장해두는 방식으로 접근하는 것이 더 안전하다.
4. Filter에서 Handler 정보를 참조하는 방법
그렇다면 Exception Filter에서 '어떤 Handler에서 예외가 발생했는가?'를 로그로 남기려면 어떻게 해야 할까? 여러 방법이 있을 수 있지만, 가장 단순한 방법은 request 객체에 필요한 컨텍스트 정보를 미리 저장해두는 것이다.
Guard와 Interceptor는 ExecutionContext를 통해 Controller와 Handler 정보를 알 수 있으므로, 이 단계에서 request 객체에 context 정보를 추가해둘 수 있다. 이후 Exception Filter에서는 ArgumentsHost를 통해 request 객체를 꺼내고, 거기에 저장된 context 값을 읽으면 된다.
먼저 Interceptor에서 context 정보를 request에 저장할 수 있다.
export class LoggingInterceptor implements NestInterceptor {
constructor(private readonly logger: Logger) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
request.context = [context.getClass()?.name, context.getHandler()?.name].join('.');
return next.handle().pipe(
tap(() => {
this.logger.verbose(new HttpLogVo(context));
}),
);
}
}이렇게 하면 Handler까지 정상적으로 도달한 요청의 경우, request 객체에 Controller와 Handler 정보가 저장된다. Guard 단계에서 예외나 접근 거부가 발생할 수 있다면, Guard에서도 비슷한 방식으로 context 정보를 저장할 수 있다.
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
request.context = [context.getClass()?.name, context.getHandler()?.name, AuthGuard.name].join('.');
return false;
}
}이제 Exception Filter에서는 ArgumentsHost에서 request 객체를 꺼내 context 값을 확인할 수 있다.
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(err: HttpException | Error, host: ArgumentsHost) {
const request = host.switchToHttp().getRequest();
console.log(request.context);
}
}이 방식은 단순하지만 꽤 실용적이다. Filter 자체에서 Handler 정보를 직접 찾으려고 하기보다, Handler 정보를 알 수 있는 단계에서 미리 request에 저장해두고, Filter는 예외 처리에 필요한 정보만 꺼내 쓰는 구조이기 때문이다.
다만 이 방법을 사용할 때는 request 객체에 임의 속성을 추가하는 것이므로, 타입을 명확하게 확장해두는 것이 좋다. 예를 들어 Express Request 타입을 확장하거나 별도의 인터페이스를 정의해서 사용하는 방식이다.
interface RequestWithContext extends Request {
context?: string;
}그리고 Filter에서는 다음처럼 사용할 수 있다.
const request = host.switchToHttp().getRequest<RequestWithContext>();이렇게 해두면 TypeScript에서도 임의 속성 접근에 대한 오류를 줄일 수 있고, 코드의 의도도 더 명확해진다. 또한 이 방식은 Global Guard, Route Guard, Interceptor 등 어느 단계에서 context를 저장하느냐에 따라 로그에 남는 정보가 달라질 수 있다.
따라서 프로젝트에서 어떤 단계의 정보를 로그로 남길지 기준을 정해두는 것이 좋다. 예를 들어 Controller.Handler만 남길 것인지, Controller.Handler.GuardName처럼 예외 발생 지점까지 함께 남길 것인지에 따라 구현 방식이 달라질 수 있다.
마치며
나도 처음에는 Exception Filter에서 ArgumentsHost를 ExecutionContext처럼 사용하면 Handler 정보를 가져올 수 있을 것이라고 생각했다. 실제로 내부 객체를 보면 비슷한 속성을 가지고 있는 것처럼 보이기 때문에, 타입만 바꾸면 동작할 것처럼 느껴질 수도 있다. 하지만 NestJS의 실행 흐름을 다시 살펴보면, 두 객체는 목적이 다르다는 것을 알 수 있다.
ExecutionContext는 현재 요청이 어떤 Controller와 Handler에 연결되어 있는지까지 포함하는 실행 컨텍스트이고, ArgumentsHost는 예외 처리나 플랫폼별 인자 접근을 위한 객체이다. 특히 Exception Filter는 예외가 발생한 이후 응답을 처리하는 역할에 집중하기 때문에, 여기서 항상 Handler 정보를 기대하는 것은 안전하지 않다.
이번 내용을 정리하면서 NestJS에서 로그를 남길 때 단순히 request와 response만 보는 것이 아니라, 요청이 어떤 단계에서 실패했는지도 함께 고려해야 한다는 점을 다시 느꼈다. Guard에서 막힌 요청인지, Pipe 검증에서 실패한 요청인지, Handler 내부에서 예외가 발생한 것인지에 따라 남겨야 할 정보가 조금씩 달라질 수 있기 때문이다.
결국 중요한 것은 NestJS의 Request Lifecycle을 이해하고, 각 단계에서 어떤 정보를 얻을 수 있는지를 명확히 구분하는 것이라고 생각한다. ExecutionContext와 ArgumentsHost의 차이를 알고 나면, Filter에서 Handler 정보를 직접 찾으려 하기보다 필요한 시점에 미리 저장해두는 방식이 훨씬 자연스럽고 안전한 해결책이라는 것을 이해할 수 있다.