NestJS Interceptor 활용 가이드

NestJS Interceptor란?

NestJS Interceptor@Injectable() 클래스로, NestInterceptor 인터페이스를 구현합니다. AOP(Aspect-Oriented Programming) 패턴에서 영감을 받아 요청/응답 흐름의 전·후 처리를 깔끔하게 분리할 수 있습니다.

Guard가 “접근 허용 여부”를 결정한다면, Interceptor는 “요청이 컨트롤러에 도달하기 전과 응답이 클라이언트에 전달되기 전에 무엇을 할 것인가”를 담당합니다. 로깅, 캐싱, 응답 변환, 타임아웃 등 횡단 관심사(Cross-Cutting Concern)를 처리하는 핵심 메커니즘입니다.

Interceptor 실행 흐름

NestJS의 요청 처리 파이프라인에서 Interceptor의 위치를 이해하는 것이 중요합니다:

Client Request
  → Middleware
    → Guard
      → Interceptor (before)
        → Pipe
          → Controller Method
        → Interceptor (after)
      → Exception Filter (에러 시)
    → Response

intercept() 메서드는 ExecutionContextCallHandler를 받습니다. CallHandler.handle()이 반환하는 Observable을 통해 응답 스트림을 조작할 수 있습니다.

기본 구조: LoggingInterceptor

가장 흔한 패턴인 로깅 인터셉터부터 살펴봅시다:

import {
  Injectable, NestInterceptor,
  ExecutionContext, CallHandler, Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const { method, url } = req;
    const now = Date.now();

    return next.handle().pipe(
      tap(() =>
        this.logger.log(`${method} ${url} — ${Date.now() - now}ms`),
      ),
    );
  }
}

tap 연산자는 응답 데이터를 변경하지 않고 부수 효과만 실행합니다. 요청 시작 시점(next.handle() 호출 전)과 응답 완료 시점(pipe 내부)을 모두 활용하는 것이 핵심입니다.

응답 변환: TransformInterceptor

API 응답을 일관된 형식으로 감싸는 패턴은 실무에서 필수입니다:

import { map } from 'rxjs/operators';

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, ApiResponse<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map((data) => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

map 연산자로 컨트롤러가 반환한 데이터를 { success, data, timestamp } 구조로 감쌉니다. 모든 엔드포인트에 적용하면 프론트엔드가 일관된 응답 구조를 기대할 수 있습니다.

타임아웃 처리: TimeoutInterceptor

느린 외부 API 호출이나 DB 쿼리로 인한 무한 대기를 방지하는 패턴입니다:

import {
  RequestTimeoutException,
} from '@nestjs/common';
import {
  timeout, catchError,
} from 'rxjs/operators';
import { throwError, TimeoutError } from 'rxjs';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly ms = 5000) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      timeout(this.ms),
      catchError((err) =>
        err instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException(
              `요청이 ${this.ms}ms를 초과했습니다`,
            ))
          : throwError(() => err),
      ),
    );
  }
}

RxJS의 timeout 연산자를 활용해 지정된 시간 내에 응답이 오지 않으면 RequestTimeoutException을 던집니다. 생성자를 통해 타임아웃 값을 주입받아 유연하게 사용할 수 있습니다.

캐싱 Interceptor: Redis 연동

GET 요청의 응답을 캐싱하는 고급 패턴입니다. NestJS 내장 CacheInterceptor 대신 직접 구현하면 세밀한 제어가 가능합니다:

import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject } from '@nestjs/common';
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Cache } from 'cache-manager';

@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
  constructor(
    @Inject(CACHE_MANAGER) private cache: Cache,
  ) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const req = context.switchToHttp().getRequest();
    if (req.method !== 'GET') return next.handle();

    const key = `cache:${req.url}`;
    const cached = await this.cache.get(key);
    if (cached) return of(cached);

    return next.handle().pipe(
      tap((response) => this.cache.set(key, response, 60_000)),
    );
  }
}

GET이 아닌 요청은 캐싱을 건너뛰고, 캐시 히트 시 of()로 즉시 반환합니다. TTL은 60초로 설정했지만, 커스텀 데코레이터로 엔드포인트별 TTL을 지정할 수도 있습니다.

커스텀 데코레이터와 조합

Interceptor의 진가는 Reflector와 커스텀 데코레이터를 조합할 때 발휘됩니다:

// cache-ttl.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CacheTTL = (seconds: number) =>
  SetMetadata('cache-ttl', seconds);

// controller에서 사용
@Get('products')
@CacheTTL(300) // 5분 캐시
findAll() { ... }

// interceptor에서 메타데이터 읽기
@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
  constructor(
    private reflector: Reflector,
    @Inject(CACHE_MANAGER) private cache: Cache,
  ) {}

  async intercept(context: ExecutionContext, next: CallHandler) {
    const ttl = this.reflector.get<number>(
      'cache-ttl',
      context.getHandler(),
    );
    if (!ttl) return next.handle();

    const key = `cache:${context.switchToHttp().getRequest().url}`;
    const cached = await this.cache.get(key);
    if (cached) return of(cached);

    return next.handle().pipe(
      tap((res) => this.cache.set(key, res, ttl * 1000)),
    );
  }
}

@CacheTTL() 데코레이터가 없는 엔드포인트는 캐싱을 건너뛰고, 있는 엔드포인트만 지정된 TTL로 캐싱합니다. 이 패턴은 NestJS Guard 접근 제어에서 다룬 Reflector 활용법과 동일합니다.

에러 매핑 Interceptor

도메인 에러를 HTTP 에러로 변환하는 패턴입니다. NestJS Exception Filter와 함께 사용하면 더욱 강력합니다:

import { catchError } from 'rxjs/operators';
import {
  NotFoundException,
  ConflictException,
} from '@nestjs/common';

@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(
      catchError((error) => {
        if (error.code === 'ENTITY_NOT_FOUND') {
          throw new NotFoundException(error.message);
        }
        if (error.code === 'DUPLICATE_ENTRY') {
          throw new ConflictException(error.message);
        }
        throw error; // 알 수 없는 에러는 그대로 전파
      }),
    );
  }
}

Interceptor 적용 범위

Interceptor는 세 가지 범위로 적용할 수 있습니다:

범위 적용 방법 영향 범위
메서드 @UseInterceptors(LoggingInterceptor) 해당 핸들러만
컨트롤러 클래스 레벨 데코레이터 컨트롤러 전체
글로벌 app.useGlobalInterceptors() 또는 APP_INTERCEPTOR 전체 애플리케이션

글로벌 적용 시 DI를 활용하려면 APP_INTERCEPTOR 토큰을 사용해야 합니다:

// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor,
    },
  ],
})
export class AppModule {}

여러 Interceptor를 등록하면 등록 순서대로 before 로직이 실행되고, 역순으로 after 로직이 실행됩니다 (스택 구조).

실전 팁: Interceptor vs Middleware vs Guard

각각의 역할을 명확히 구분해야 코드가 깔끔해집니다:

  • Middleware: 요청 전처리 (CORS, 바디 파싱, 로그). ExecutionContext 접근 불가
  • Guard: 인증·인가 판단. true/false만 반환
  • Interceptor: 요청 전·후 변환, 캐싱, 로깅, 타임아웃. RxJS Observable로 응답 스트림 제어
  • Pipe: 입력값 검증·변환. 파라미터 단위

Interceptor는 RxJS Observable을 다루므로 비동기 스트림 조작이 자유롭습니다. 이것이 단순 Middleware와의 가장 큰 차별점입니다.

성능 측정 Interceptor 패턴

프로덕션에서 유용한 성능 측정 + 느린 쿼리 알림 패턴입니다:

@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
  private readonly logger = new Logger('Perf');
  private readonly threshold: number;

  constructor(@Inject('SLOW_THRESHOLD') threshold = 3000) {
    this.threshold = threshold;
  }

  intercept(context: ExecutionContext, next: CallHandler) {
    const cls = context.getClass().name;
    const handler = context.getHandler().name;
    const start = process.hrtime.bigint();

    return next.handle().pipe(
      tap(() => {
        const ms = Number(process.hrtime.bigint() - start) / 1e6;
        if (ms > this.threshold) {
          this.logger.warn(
            `🐢 SLOW ${cls}.${handler} — ${ms.toFixed(1)}ms`,
          );
        }
      }),
    );
  }
}

3초를 초과하는 핸들러를 자동으로 감지해 경고 로그를 남깁니다. 모니터링 시스템과 연동하면 병목 지점을 빠르게 파악할 수 있습니다.

마무리

NestJS Interceptor는 횡단 관심사를 깔끔하게 분리하는 핵심 도구입니다. 로깅, 응답 변환, 캐싱, 타임아웃, 에러 매핑, 성능 측정 등 다양한 패턴을 RxJS Observable 기반으로 구현할 수 있습니다. Guard, Pipe, Filter와 역할을 명확히 나누고, Reflector와 커스텀 데코레이터를 조합하면 선언적이고 재사용 가능한 아키텍처를 만들 수 있습니다.

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