NestJS Interceptor: Logging

들어가며: Interceptor가 NestJS 미들웨어와 다른 점

NestJS에서 요청/응답을 가로채는 방법은 Middleware, Guard, Interceptor, Pipe, Exception Filter 다섯 가지다. 이 중 Interceptor는 유일하게 요청 전(before)과 응답 후(after) 양쪽 모두에 로직을 넣을 수 있다. NestJS 공식 문서(Interceptors)에 따르면 Interceptor는 NestInterceptor 인터페이스를 구현하며, RxJS Observable을 반환하는 intercept() 메서드를 통해 요청-응답 스트림을 제어한다.

이 글에서는 NestJS 공식 문서를 근거로, Interceptor의 실행 순서·바인딩 범위를 정리하고, 실무에서 가장 많이 쓰이는 4가지 패턴 — Logging, Response Mapping, Timeout, Caching — 을 코드와 함께 다룬다.

1. Interceptor 실행 순서와 바인딩 범위

1-1. NestJS 요청 라이프사이클

NestJS 공식 문서(Request lifecycle)에 명시된 실행 순서:

  1. Middleware (Express/Fastify 레벨)
  2. Guard (인증/인가 판단)
  3. Interceptor — before (next.handle() 호출 전)
  4. Pipe (유효성 검증/변환)
  5. Controller 핸들러
  6. Interceptor — after (next.handle()의 Observable 구독 후)
  7. Exception Filter (예외 발생 시)

핵심: Interceptor의 next.handle()실제 라우트 핸들러의 실행을 트리거하며, 그 반환값은 RxJS Observable로 감싸져 있다. pipe() 오퍼레이터로 응답을 변환·가로채기·타임아웃 처리할 수 있다.

1-2. 바인딩 범위 3단계

범위 적용 방법 사용 사례
메서드 @UseInterceptors(LoggingInterceptor) 특정 엔드포인트만 로깅
컨트롤러 클래스에 @UseInterceptors() 특정 모듈의 모든 엔드포인트
글로벌 app.useGlobalInterceptors() 또는 APP_INTERCEPTOR 프로바이더 전체 애플리케이션 공통 로직

주의: app.useGlobalInterceptors()로 등록하면 DI 컨테이너 밖에서 인스턴스가 생성되므로, 의존성 주입이 필요한 경우 APP_INTERCEPTOR 토큰으로 모듈에 등록해야 한다.

// DI가 필요한 글로벌 Interceptor 등록
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
})
export class AppModule {}

2. 패턴 1 — Logging Interceptor: 요청·응답 시간 측정

가장 기본적이면서 실무에서 가장 유용한 패턴이다.

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({
        next: () =>
          this.logger.log(`${method} ${url} ${Date.now() - now}ms`),
        error: (err) =>
          this.logger.error(`${method} ${url} ${Date.now() - now}ms - ${err.message}`),
      }),
    );
  }
}

설계 포인트:

  • tap()은 스트림을 변경하지 않으므로 응답 데이터에 영향을 주지 않는다.
  • error 콜백을 함께 등록하면 예외 발생 시에도 응답 시간을 기록할 수 있다.
  • 프로덕션에서는 요청 ID(correlation ID)를 함께 로깅하면 분산 추적에 도움된다.

3. 패턴 2 — Response Mapping: 일관된 응답 포맷 강제

API 응답을 { data, statusCode, timestamp } 같은 표준 포맷으로 감싸는 패턴이다.

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

export interface ResponseEnvelope<T> {
  data: T;
  statusCode: number;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T>
  implements NestInterceptor<T, ResponseEnvelope<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ResponseEnvelope<T>> {
    const statusCode = context.switchToHttp().getResponse().statusCode;

    return next.handle().pipe(
      map((data) => ({
        data,
        statusCode,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

실무 팁:

  • Swagger(@nestjs/swagger)와 함께 사용할 때, @ApiExtraModels()@ApiOkResponse()로 envelope 스키마를 명시해야 문서가 정확해진다.
  • 파일 다운로드·SSE·WebSocket 응답에는 이 Interceptor를 적용하면 안 된다. @SkipTransform() 같은 커스텀 데코레이터 + Reflector로 특정 핸들러를 제외하는 방식을 권장한다.
// 커스텀 데코레이터로 특정 핸들러 제외
import { SetMetadata } from '@nestjs/common';
export const SKIP_TRANSFORM_KEY = 'skipTransform';
export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);

// Interceptor에서 확인
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const skip = this.reflector.getAllAndOverride<boolean>(
      SKIP_TRANSFORM_KEY,
      [context.getHandler(), context.getClass()],
    );
    if (skip) return next.handle();

    return next.handle().pipe(
      map((data) => ({ data, statusCode: 200, timestamp: new Date().toISOString() })),
    );
  }
}

4. 패턴 3 — Timeout Interceptor: 느린 핸들러 강제 중단

NestJS 공식 문서에도 소개된 패턴으로, RxJS timeout 오퍼레이터로 요청 처리 시간을 제한한다.

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 {
  constructor(private readonly ms: number = 5000) {}

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

주의사항:

항목 설명
DB 쿼리 중단 안 됨 Timeout이 Observable을 취소해도 이미 실행 중인 DB 쿼리는 계속 실행된다. DB 레벨의 statement_timeout(MySQL: max_execution_time)을 별도로 설정해야 한다.
파일 업로드 대용량 파일 업로드 엔드포인트에는 타임아웃을 길게 설정하거나 제외해야 한다.
Kubernetes readiness 타임아웃 값은 Kubernetes readiness probe의 timeoutSeconds보다 짧아야 프로브 실패를 방지한다.

5. 패턴 4 — Cache Interceptor: 응답 캐싱

NestJS는 @nestjs/cache-manager 패키지를 통해 빌트인 CacheInterceptor를 제공한다. 공식 문서(Caching)에 따르면 @UseInterceptors(CacheInterceptor)를 붙이면 GET 요청의 응답을 자동으로 캐싱한다.

import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';

@Module({
  imports: [
    CacheModule.register({
      ttl: 30,       // 30초
      max: 100,      // 최대 100개 항목
      isGlobal: true,
    }),
  ],
})
export class AppModule {}
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';

@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductController {

  @Get()
  @CacheTTL(60)                    // 이 엔드포인트만 60초
  @CacheKey('products-list')       // 커스텀 캐시 키
  findAll() {
    return this.productService.findAll();
  }
}

빌트인 CacheInterceptor의 한계와 커스텀 전략:

  • 기본적으로 GET 요청만 캐싱한다. POST 응답을 캐싱하려면 CacheInterceptor를 상속하여 isRequestCacheable()을 오버라이드해야 한다.
  • 캐시 키는 기본적으로 URL 기반이다. Query parameter나 헤더(예: Accept-Language)에 따라 다른 응답을 캐싱하려면 trackBy()를 오버라이드한다.
  • Redis 같은 외부 스토어를 쓸 때는 cache-manager-redis-yet 패키지를 CacheModule.registerAsync()로 연결한다.

6. Interceptor 조합 시 실행 순서

여러 Interceptor를 동시에 적용하면, 등록 순서대로 before가 실행되고, 역순으로 after가 실행된다 (양파 껍질 모델).

// 등록 순서: [LoggingInterceptor, TransformInterceptor]

// 실행 순서:
// 1. LoggingInterceptor (before) → 시간 측정 시작
// 2. TransformInterceptor (before) → pass-through
// 3. Controller 핸들러
// 4. TransformInterceptor (after) → 응답 envelope 래핑
// 5. LoggingInterceptor (after) → 시간 측정 종료, 로그 출력

이 순서를 잘못 이해하면, Logging Interceptor가 래핑된 응답의 크기를 기록하는 등 의도치 않은 동작이 발생할 수 있다. 로깅은 항상 가장 바깥(첫 번째)에 배치한다.

7. 실전 체크리스트

  1. DI 필요 여부 확인: 글로벌 Interceptor에서 Logger, Reflector, ConfigService 등을 주입받아야 하면 APP_INTERCEPTOR 토큰으로 등록했는가?
  2. self-invocation 영향 없음: Interceptor는 AOP 프록시가 아닌 NestJS 요청 파이프라인에서 동작하므로 self-invocation 문제가 없다.
  3. Observable 에러 처리: tap()이나 map()에서 예외가 발생하면 Exception Filter로 전파된다. Interceptor 내부에서 catchError()를 쓸 때 에러를 삼키지 않도록 주의한다.
  4. 비동기 핸들러 호환: async 핸들러의 반환값도 NestJS가 자동으로 Observable로 감싸므로, Interceptor는 동기/비동기 핸들러 모두 동일하게 동작한다.
  5. Fastify 호환: context.switchToHttp().getRequest()의 반환 타입이 Express와 Fastify에서 다르다. 두 플랫폼을 모두 지원하려면 ExecutionContext의 제네릭을 활용한다.
  6. Interceptor 순서: 로깅은 가장 바깥, 응답 변환은 안쪽에 배치하여 올바른 데이터가 기록되게 한다.

8. 흔한 실수와 방지법

실수 증상 방지법
next.handle() 호출 누락 핸들러가 실행되지 않아 항상 빈 응답 반환 모든 분기에서 next.handle()을 반환하거나, 캐시 히트 등 의도적 단축 시에만 of(cachedData)를 반환
글로벌 Transform이 파일 다운로드를 감쌈 바이너리 파일이 JSON envelope로 래핑됨 @SkipTransform() 커스텀 데코레이터 + Reflector로 특정 핸들러 제외
Timeout이 DB 쿼리를 중단한다고 가정 Observable은 취소되지만 DB 쿼리는 계속 실행 → 리소스 낭비 MySQL max_execution_time 힌트 또는 ORM 레벨 쿼리 타임아웃 병행 설정
CacheInterceptor 키 충돌 다른 query parameter인데 같은 캐시 반환 trackBy() 오버라이드하여 query string 포함한 키 생성

정리

NestJS Interceptor는 요청-응답 양방향을 RxJS Observable로 제어하는 강력한 도구다. 로깅·응답 변환·타임아웃·캐싱 네 가지 패턴만 잘 조합해도 대부분의 횡단 관심사(cross-cutting concerns)를 깔끔하게 처리할 수 있다. 핵심은 실행 순서를 정확히 이해하고, 글로벌 적용 시 예외 케이스(파일 다운로드, SSE 등)를 데코레이터로 제외하는 설계다.

참고 자료

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