실행 컨텍스트 (Execution Context) — 여러 애플리케이션 컨텍스트에서 공용 컴포넌트 만들기
실행 컨텍스트 (Execution Context) — 여러 애플리케이션 컨텍스트에서 공용 컴포넌트 만들기
Nest는 여러 애플리케이션 컨텍스트(예: Nest HTTP 서버 기반, 마이크로서비스, WebSockets 애플리케이션 컨텍스트)에서 동작하는 애플리케이션을 쉽게 작성할 수 있게 해주는 여러 유틸리티 클래스를 제공해요. 이 유틸리티들은 현재 실행 컨텍스트에 대한 정보를 제공해서, 다양한 컨트롤러·메서드·실행 컨텍스트에서 동작할 수 있는 범용 가드, 필터, 인터셉터를 만드는 데 쓸 수 있어요. 이번 챕터에서는 그중 ArgumentsHost와 ExecutionContext 두 클래스를 다룰게요. 이 두 클래스를 이해하면 핸들러의 인자에 접근하고, 지금 어떤 종류의 요청이 들어오는지 파악하며, 핸들러에 붙은 메타데이터를 읽을 수 있어요. 🧭
본문
ArgumentsHost 클래스
ArgumentsHost 클래스는 핸들러에 전달되는 인자를 검색하는 메서드를 제공해요. 적절한 컨텍스트(예: HTTP, RPC(마이크로서비스), 또는 WebSockets)를 골라 그곳에서 인자를 가져올 수 있게 해주죠. 프레임워크는 접근하고 싶은 곳에서 ArgumentsHost 인스턴스, 보통 host 파라미터로 참조되는 인스턴스를 제공해요. 예를 들어 예외 필터의 catch() 메서드는 ArgumentsHost 인스턴스와 함께 호출돼요.
ArgumentsHost는 단순히 핸들러 인자에 대한 추상화 역할을 해요. 예를 들어 HTTP 서버 애플리케이션(@nestjs/platform-express 사용 시)에서 host 객체는 Express의 [request, response, next] 배열을 캡슐화해요. 여기서 request는 요청 객체, response는 응답 객체, next는 애플리케이션의 요청-응답 주기를 제어하는 함수예요. 반면 GraphQL 애플리케이션에서 host 객체는 [root, args, context, info] 배열을 담아요.
현재 애플리케이션 컨텍스트(Current application context)
여러 애플리케이션 컨텍스트에서 실행되도록 만들어진 범용 가드, 필터, 인터셉터를 만들 때는 우리 메서드가 현재 어떤 유형의 애플리케이션에서 실행 중인지 결정할 방법이 필요해요. ArgumentsHost의 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
}
💡
GqlContextType은@nestjs/graphql패키지에서 import 해요.
애플리케이션 유형을 알 수 있게 되면 아래처럼 더 범용적인 컴포넌트를 작성할 수 있어요.
호스트 핸들러 인자(Host handler arguments)
핸들러에 전달되는 인자 배열을 검색하려면 host 객체의 getArgs() 메서드를 사용하는 방법이 있어요.
const [req, res, next] = host.getArgs();
getArgByIndex() 메서드로 인덱스별로 특정 인자를 뽑아낼 수 있어요.
const request = host.getArgByIndex(0);
const response = host.getArgByIndex(1);
이 예시들에서는 인덱스로 요청·응답 객체를 검색했는데, 이는 일반적으로 권장되지 않아요. 애플리케이션을 특정 실행 컨텍스트에 결합시키기 때문이에요. 대신 host 객체의 유틸리티 메서드 중 하나를 사용해 애플리케이션에 적합한 애플리케이션 컨텍스트로 전환하면 코드를 더 견고하고 재사용 가능하게 만들 수 있어요. 컨텍스트 전환 유틸리티 메서드는 아래와 같아요.
/**
* Switch context to RPC.
*/
switchToRpc(): RpcArgumentsHost;
/**
* Switch context to HTTP.
*/
switchToHttp(): HttpArgumentsHost;
/**
* Switch context to WebSockets.
*/
switchToWs(): WsArgumentsHost;
이전 예시를 switchToHttp() 메서드를 사용해 다시 작성해 볼게요. host.switchToHttp() 헬퍼 호출은 HTTP 애플리케이션 컨텍스트에 적합한 HttpArgumentsHost 객체를 반환해요. HttpArgumentsHost 객체에는 원하는 객체를 추출하는 데 사용할 수 있는 유용한 메서드 두 개가 있어요. 이 경우 네이티브 Express 타입 객체를 반환하기 위해 Express 타입 단언(type assertion)도 사용해요.
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
마찬가지로 WsArgumentsHost와 RpcArgumentsHost도 마이크로서비스와 WebSockets 컨텍스트에서 적절한 객체를 반환하는 메서드를 가져요. WsArgumentsHost의 메서드는 다음과 같아요.
export interface WsArgumentsHost {
/**
* Returns the data object.
*/
getData<T>(): T;
/**
* Returns the client object.
*/
getClient<T>(): T;
}
RpcArgumentsHost의 메서드는 다음과 같아요.
export interface RpcArgumentsHost {
/**
* Returns the data object.
*/
getData<T>(): T;
/**
* Returns the context object.
*/
getContext<T>(): T;
}
ExecutionContext 클래스
ExecutionContext는 ArgumentsHost를 확장해 현재 실행 과정에 대한 추가 정보를 제공해요. ArgumentsHost처럼 Nest는 필요할 수 있는 곳 — 예를 들어 가드의 canActivate() 메서드나 인터셉터의 intercept() 메서드 — 에 ExecutionContext 인스턴스를 제공해요. 다음 메서드를 제공해요.
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() 메서드는 이 특정 핸들러가 속한 Controller 클래스의 타입을 반환해요. 예를 들어 HTTP 컨텍스트에서 현재 처리 중인 요청이 CatsController의 create() 메서드에 바인딩된 POST 요청이라면, getHandler()는 create() 메서드에 대한 참조를 반환하고 getClass()는 CatsController 클래스(인스턴스가 아니라)를 반환해요.
const methodKey = ctx.getHandler().name; // "create"
const className = ctx.getClass().name; // "CatsController"
현재 클래스와 핸들러 메서드 모두에 대한 참조에 접근할 수 있는 능력은 큰 유연성을 제공해요. 가장 중요한 것은 Reflector#createDecorator로 만든 데코레이터나 내장 @SetMetadata() 데코레이터를 통해 설정된 메타데이터를 가드나 인터셉터 안에서 접근할 기회를 준다는 거예요. 이 사용 사례를 아래에서 다룰게요.
리플렉션과 메타데이터(Reflection and metadata)
Nest는 Reflector#createDecorator 메서드로 만든 데코레이터와 내장 @SetMetadata() 데코레이터를 통해 라우트 핸들러에 커스텀 메타데이터를 붙일 수 있는 기능을 제공해요. 이 섹션에서는 두 접근법을 비교하고 가드나 인터셉터 안에서 메타데이터에 접근하는 방법을 볼게요.
Reflector#createDecorator로 강타입 데코레이터를 만들려면 타입 인자를 지정해야 해요. 예를 들어 문자열 배열을 인자로 받는 Roles 데코레이터를 만들어 볼게요.
@@filename(roles.decorator)
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
여기 Roles 데코레이터는 string[] 타입의 단일 인자를 받는 함수예요. 이 데코레이터를 사용하려면 그냥 핸들러에 붙이면 돼요.
@@filename(cats.controller)
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
@@switch
@Post()
@Roles(['admin'])
@Bind(Body())
async create(createCatDto) {
this.catsService.create(createCatDto);
}
여기서는 create() 메서드에 Roles 데코레이터 메타데이터가 붙었어요. admin 역할을 가진 사용자만 이 라우트에 접근할 수 있다는 의미예요.
라우트의 역할(커스텀 메타데이터)에 접근하려면 Reflector 헬퍼 클래스를 다시 사용해요. Reflector는 일반적인 방식으로 클래스에 주입할 수 있어요.
@@filename(roles.guard)
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
@@switch
@Injectable()
@Dependencies(Reflector)
export class CatsService {
constructor(reflector) {
this.reflector = reflector;
}
}
💡
Reflector클래스는@nestjs/core패키지에서 import 해요.
이제 핸들러 메타데이터를 읽으려면 get() 메서드를 사용해요.
const roles = this.reflector.get(Roles, context.getHandler());
Reflector#get 메서드는 두 인자를 넘겨 메타데이터에 쉽게 접근하게 해줘요: 데코레이터 참조와 메타데이터를 가져올 컨텍스트(데코레이터 대상)예요. 이 예시에서 지정된 데코레이터는 Roles예요(위의 roles.decorator.ts 파일 참고). 컨텍스트는 context.getHandler() 호출로 제공되는데, 그 결과 현재 처리 중인 라우트 핸들러에 대한 메타데이터를 추출해요. 기억하세요, getHandler()는 라우트 핸들러 함수에 대한 참조를 줘요.
또는 컨트롤러 레벨에서 메타데이터를 적용해, 컨트롤러 클래스의 모든 라우트에 적용되도록 구성할 수도 있어요.
@@filename(cats.controller)
@Roles(['admin'])
@Controller('cats')
export class CatsController {}
@@switch
@Roles(['admin'])
@Controller('cats')
export class CatsController {}
이 경우 컨트롤러 메타데이터를 추출하려면 context.getHandler() 대신 두 번째 인자로 context.getClass()를 넘겨요(컨트롤러 클래스를 메타데이터 추출 컨텍스트로 제공).
@@filename(roles.guard)
const roles = this.reflector.get(Roles, context.getClass());
여러 레벨에서 메타데이터를 제공할 수 있다는 점을 고려하면, 여러 컨텍스트에서 메타데이터를 추출하고 병합해야 할 수도 있어요. Reflector 클래스는 이를 돕는 두 가지 유틸리티 메서드를 제공해요. 이 메서드들은 컨트롤러와 메서드 메타데이터 양쪽을 한 번에 추출하고, 서로 다른 방식으로 결합해요.
두 레벨 모두에서 Roles 메타데이터를 제공한 다음 시나리오를 고려해 볼게요.
@@filename(cats.controller)
@Roles(['user'])
@Controller('cats')
export class CatsController {
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
}
@@switch
@Roles(['user'])
@Controller('cats')
export class CatsController {}
@Post()
@Roles(['admin'])
@Bind(Body())
async create(createCatDto) {
this.catsService.create(createCatDto);
}
}
의도가 'user'를 기본 역할로 지정하고 특정 메서드에 대해 선택적으로 오버라이드하는 것이라면 getAllAndOverride() 메서드를 사용하면 돼요.
const roles = this.reflector.getAllAndOverride(Roles, [context.getHandler(), context.getClass()]);
이 코드를 가진 가드가 위 메타데이터로 create() 메서드 컨텍스트에서 실행되면 roles에는 ['admin']이 담겨요.
둘 다 가져와 병합(이 메서드는 배열과 객체를 모두 병합)하려면 getAllAndMerge() 메서드를 사용해요.
const roles = this.reflector.getAllAndMerge(Roles, [context.getHandler(), context.getClass()]);
이렇게 하면 roles에는 ['user', 'admin']이 담겨요.
이 두 병합 메서드 모두 메타데이터 키를 첫 번째 인자로, 메타데이터 대상 컨텍스트 배열(즉 getHandler() 및/또는 getClass() 메서드 호출)을 두 번째 인자로 넘겨요.
저수준 접근법(Low-level approach)
앞서 언급했듯이 Reflector#createDecorator 대신 내장 @SetMetadata() 데코레이터를 사용해 핸들러에 메타데이터를 붙일 수도 있어요.
@@filename(cats.controller)
@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
@@switch
@Post()
@SetMetadata('roles', ['admin'])
@Bind(Body())
async create(createCatDto) {
this.catsService.create(createCatDto);
}
💡
@SetMetadata()데코레이터는@nestjs/common패키지에서 import 해요.
위 구성으로 roles 메타데이터(roles는 메타데이터 키, ['admin']은 연결된 값)를 create() 메서드에 붙였어요. 이렇게 해도 동작하지만, @SetMetadata()를 라우트에서 직접 사용하는 것은 좋은 관행이 아니에요. 대신 아래처럼 여러분만의 데코레이터를 만들 수 있어요.
@@filename(roles.decorator)
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@@switch
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles) => SetMetadata('roles', roles);
이 접근법이 훨씬 더 깔끔하고 읽기 쉬우며, Reflector#createDecorator 접근법과 다소 비슷해요. 차이점은 @SetMetadata를 사용하면 메타데이터 키와 값을 더 많이 제어할 수 있고, 하나 이상의 인자를 받는 데코레이터를 만들 수 있다는 거예요.
이제 커스텀 @Roles() 데코레이터가 있으니 이걸로 create() 메서드를 꾸밀 수 있어요.
@@filename(cats.controller)
@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
@@switch
@Post()
@Roles('admin')
@Bind(Body())
async create(createCatDto) {
this.catsService.create(createCatDto);
}
라우트의 역할(커스텀 메타데이터)에 접근하려면 Reflector 헬퍼 클래스를 다시 사용해요.
@@filename(roles.guard)
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
@@switch
@Injectable()
@Dependencies(Reflector)
export class CatsService {
constructor(reflector) {
this.reflector = reflector;
}
}
💡
Reflector클래스는@nestjs/core패키지에서 import 해요.
이제 핸들러 메타데이터를 읽으려면 get() 메서드를 사용해요.
const roles = this.reflector.get<string[]>('roles', context.getHandler());
여기서는 데코레이터 참조 대신 첫 번째 인자로 메타데이터 키(이 경우 'roles')를 넘겨요. 나머지는 Reflector#createDecorator 예시와 동일해요.
더 알아보기
- NestJS 공식 문서 - Execution context
- 가드(Guards) — 실행 컨텍스트를 활용하는 권한 검사
- 인터셉터(Interceptors) — 실행 컨텍스트를 사용하는 또 다른 공용 컴포넌트
- 예외 필터(Exception filters) —
ArgumentsHost를 다루는 예시