NestJS ExecutionContext 심화

ExecutionContext란?

NestJS의 ExecutionContext는 Guard, Interceptor, Filter 등 크로스커팅 관심사에서 현재 요청의 모든 메타데이터에 접근하는 핵심 객체다. 어떤 Controller의 어떤 메서드가 호출되는지, HTTP인지 WebSocket인지 gRPC인지, 커스텀 메타데이터(Reflector)가 무엇인지 — 이 모든 것을 ExecutionContext가 제공한다.

이 글에서는 ExecutionContext의 내부 구조, ArgumentsHost와의 관계, Reflector 메타데이터 활용, 프로토콜별 분기 처리, 실전 패턴까지 심화 수준으로 다룬다.

ArgumentsHost: 기반 클래스

ExecutionContextArgumentsHost를 상속한다. ArgumentsHost는 현재 요청의 프로토콜별 인자에 접근하는 저수준 API다.

// ArgumentsHost 인터페이스
interface ArgumentsHost {
  getArgs(): any[];              // 원시 인자 배열
  getArgByIndex(index: number): any;
  getType(): string;              // 'http' | 'ws' | 'rpc'

  // 프로토콜별 헬퍼
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
  switchToRpc(): RpcArgumentsHost;
}

// ExecutionContext 인터페이스 (ArgumentsHost 확장)
interface ExecutionContext extends ArgumentsHost {
  getClass(): Type;               // Controller 클래스
  getHandler(): Function;         // 핸들러 메서드
}

프로토콜별 컨텍스트 접근

// HTTP 요청
@Injectable()
export class HttpContextInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    if (context.getType() === 'http') {
      const httpCtx = context.switchToHttp();
      const request = httpCtx.getRequest<Request>();
      const response = httpCtx.getResponse<Response>();

      console.log(`${request.method} ${request.url}`);
      console.log('Headers:', request.headers);
      console.log('Body:', request.body);
      console.log('Params:', request.params);
      console.log('Query:', request.query);
    }
    return next.handle();
  }
}

// WebSocket
if (context.getType() === 'ws') {
  const wsCtx = context.switchToWs();
  const client = wsCtx.getClient<Socket>();
  const data = wsCtx.getData();
  console.log('WS client:', client.id);
  console.log('WS data:', data);
}

// gRPC / Microservice
if (context.getType() === 'rpc') {
  const rpcCtx = context.switchToRpc();
  const data = rpcCtx.getData();
  const metadata = rpcCtx.getContext(); // gRPC Metadata
}

getClass()와 getHandler(): Controller 메타데이터

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    // 어떤 Controller의 어떤 메서드인지 알 수 있다
    const controllerClass = context.getClass();
    const handlerMethod = context.getHandler();

    const controllerName = controllerClass.name;   // 'OrderController'
    const methodName = handlerMethod.name;          // 'createOrder'

    console.log(`[Audit] ${controllerName}.${methodName}`);

    return next.handle();
  }
}

Reflector: 커스텀 메타데이터의 핵심

ReflectorSetMetadata로 설정한 커스텀 메타데이터를 ExecutionContext에서 읽는 유틸리티다. Guard, Interceptor에서 어노테이션 기반 제어를 구현하는 핵심이다.

// 1. 커스텀 데코레이터 정의
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

export const THROTTLE_KEY = 'throttle';
export const Throttle = (limit: number, ttl: number) =>
  SetMetadata(THROTTLE_KEY, { limit, ttl });

// 2. Controller에서 사용
@Controller('orders')
export class OrderController {

  @Post()
  @Roles('ADMIN', 'MANAGER')
  @Throttle(10, 60)
  createOrder(@Body() dto: CreateOrderDto) { ... }

  @Get('public-catalog')
  @Public()
  getCatalog() { ... }
}

// 3. Guard에서 Reflector로 읽기
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // getAllAndOverride: 메서드 메타데이터 우선, 없으면 클래스 메타데이터
    const requiredRoles = this.reflector.getAllAndOverride<string[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()],
    );

    if (!requiredRoles) {
      return true; // 역할 제한 없음
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}

Reflector의 3가지 조회 메서드

메서드 동작 사용 시점
get(key, target) 단일 타겟에서 조회 메서드 또는 클래스만 확인
getAllAndOverride(key, targets[]) 첫 번째 non-undefined 값 반환 메서드 우선, 클래스 폴백
getAllAndMerge(key, targets[]) 모든 타겟 값을 병합(배열) 클래스 + 메서드 역할 합산
// getAllAndOverride 예시
@Roles('USER')  // 클래스 레벨
@Controller('orders')
export class OrderController {
  @Roles('ADMIN')  // 메서드 레벨 — 이것이 우선
  @Delete(':id')
  deleteOrder() { ... }
}
// getAllAndOverride → ['ADMIN'] (메서드가 덮어씀)
// getAllAndMerge   → ['ADMIN', 'USER'] (합산)

실전 패턴: 프로토콜 불문 범용 Guard

@Injectable()
export class UniversalAuthGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authService: AuthService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // Public 엔드포인트 체크
    const isPublic = this.reflector.getAllAndOverride<boolean>(
      IS_PUBLIC_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (isPublic) return true;

    // 프로토콜별 토큰 추출
    let token: string;

    switch (context.getType()) {
      case 'http': {
        const req = context.switchToHttp().getRequest();
        token = req.headers.authorization?.replace('Bearer ', '');
        break;
      }
      case 'ws': {
        const client = context.switchToWs().getClient();
        token = client.handshake?.auth?.token;
        break;
      }
      case 'rpc': {
        const metadata = context.switchToRpc().getContext();
        token = metadata.get('authorization')?.[0];
        break;
      }
    }

    if (!token) return false;

    try {
      const user = await this.authService.validateToken(token);
      // 프로토콜별로 user 정보 주입
      this.attachUser(context, user);
      return true;
    } catch {
      return false;
    }
  }

  private attachUser(context: ExecutionContext, user: any) {
    switch (context.getType()) {
      case 'http':
        context.switchToHttp().getRequest().user = user;
        break;
      case 'ws':
        context.switchToWs().getClient().user = user;
        break;
    }
  }
}

실전 패턴: 조건부 캐시 Interceptor

export const CACHE_TTL_KEY = 'cacheTtl';
export const CacheTTL = (seconds: number) =>
  SetMetadata(CACHE_TTL_KEY, seconds);

@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
  constructor(
    private reflector: Reflector,
    private cacheManager: Cache,
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler) {
    const ttl = this.reflector.get<number>(
      CACHE_TTL_KEY,
      context.getHandler(),
    );

    // @CacheTTL 없으면 캐시 안 함
    if (!ttl) return next.handle();

    const request = context.switchToHttp().getRequest();
    const cacheKey = `${context.getClass().name}:${context.getHandler().name}:${request.url}`;

    const cached = await this.cacheManager.get(cacheKey);
    if (cached) return of(cached);

    return next.handle().pipe(
      tap(async (data) => {
        await this.cacheManager.set(cacheKey, data, ttl * 1000);
      }),
    );
  }
}

주의점과 안티패턴

안티패턴 문제점 해결책
getType() 체크 없이 switchToHttp() WebSocket/gRPC 요청에서 런타임 에러 항상 getType() 분기 처리
getAllAndOverride vs getAllAndMerge 혼동 의도와 다른 메타데이터 조회 덮어쓰기=Override, 합산=Merge
Guard에서 request 직접 수정 테스트 어려움, 부수 효과 최소한의 user 주입만, 로직은 서비스로
Reflector 없이 Reflect.getMetadata 직접 사용 NestJS 내부 API에 의존 반드시 Reflector 사용

마무리

ExecutionContext와 Reflector는 NestJS의 크로스커팅 관심사 메커니즘의 근간이다. Guard, Interceptor, Filter가 모두 ExecutionContext를 통해 요청 정보와 커스텀 메타데이터에 접근한다. 프로토콜 불문 범용 Guard, 어노테이션 기반 캐시/권한 제어 등 고급 패턴의 기반이 된다. NestJS Guard에서의 활용과 Interceptor RxJS 패턴을 함께 이해하면 NestJS의 요청 파이프라인을 완전히 제어할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux