NestJS Interceptor 실전 패턴

NestJS Interceptor란?

NestJS Interceptor@Injectable() 클래스에 NestInterceptor 인터페이스를 구현하여 만든다. 요청(Request)과 응답(Response) 양쪽 흐름에 개입할 수 있어, AOP(Aspect-Oriented Programming) 패턴을 NestJS에서 구현하는 핵심 도구다.

Interceptor가 동작하는 위치는 Guard 이후, Pipe 이전(요청 단계)이며, 컨트롤러 핸들러 실행 이후 응답 스트림에도 개입한다. 이 양방향 특성 덕분에 로깅, 캐싱, 응답 변환, 타임아웃 등 다양한 횡단 관심사를 깔끔하게 분리할 수 있다.

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

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    const req = context.switchToHttp().getRequest();
    console.log(`[REQ] ${req.method} ${req.url}`);

    return next.handle().pipe(
      tap(() => console.log(`[RES] ${req.method} ${req.url} - ${Date.now() - now}ms`)),
    );
  }
}

next.handle()이 반환하는 Observable이 핵심이다. 이 스트림을 RxJS 오퍼레이터로 조작하면 응답 데이터를 자유롭게 변환할 수 있다.

실전 패턴 1: 응답 래핑 (Response Mapping)

API 응답을 일관된 포맷으로 통일하는 것은 프론트엔드 협업의 기본이다. Interceptor로 모든 응답을 { success, data, timestamp } 형태로 래핑할 수 있다.

import { map } from 'rxjs/operators';

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

이 패턴의 장점은 컨트롤러에서 순수한 비즈니스 데이터만 반환하면 된다는 것이다. 응답 포맷 변경 시 Interceptor 하나만 수정하면 전체 API에 반영된다.

실전 패턴 2: RxJS 기반 타임아웃

외부 API 호출이나 무거운 쿼리에 타임아웃을 걸어 서버 리소스를 보호하는 패턴이다.

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

@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(
            `Request timed out after ${this.ms}ms`,
          ));
        }
        return throwError(() => err);
      }),
    );
  }
}

RxJS timeout 오퍼레이터가 지정 시간 내에 응답이 오지 않으면 TimeoutError를 발생시키고, 이를 NestJS의 RequestTimeoutException으로 변환한다. HTTP 408 상태 코드가 자동으로 반환된다.

실전 패턴 3: 인메모리 캐싱

동일한 GET 요청에 대해 일정 시간 캐시된 응답을 반환하는 Interceptor다. next.handle()을 호출하지 않으면 컨트롤러 자체가 실행되지 않는다는 점을 활용한다.

import { of } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, { data: any; expiresAt: number }>();

  constructor(private readonly ttlMs: number = 30000) {}

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

    const key = req.url;
    const cached = this.cache.get(key);

    if (cached && cached.expiresAt > Date.now()) {
      return of(cached.data); // 컨트롤러 실행 안 함
    }

    return next.handle().pipe(
      tap(data => {
        this.cache.set(key, {
          data,
          expiresAt: Date.now() + this.ttlMs,
        });
      }),
    );
  }
}

프로덕션 환경에서는 Map 대신 Redis를 사용하고, NestJS 공식 @nestjs/cache-manager 모듈과 결합하는 것이 좋다. 하지만 원리를 이해하면 커스텀 캐싱 전략을 자유롭게 구현할 수 있다.

Interceptor 바인딩 3가지 방법

방법 범위 코드
메서드/컨트롤러 특정 핸들러 또는 컨트롤러 @UseInterceptors(LoggingInterceptor)
글로벌 (모듈) 전체 애플리케이션 (DI 가능) APP_INTERCEPTOR provider 등록
글로벌 (부트스트랩) 전체 애플리케이션 (DI 불가) app.useGlobalInterceptors()

가장 권장되는 방법은 APP_INTERCEPTOR를 사용한 글로벌 등록이다. DI 컨테이너를 통해 주입되므로 다른 서비스에 의존할 수 있다.

import { APP_INTERCEPTOR } from '@nestjs/core';

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

Interceptor 실행 순서

여러 Interceptor를 등록하면 양파(Onion) 모델로 실행된다. 요청 시에는 등록 순서대로, 응답 시에는 역순으로 실행된다.

// 등록 순서: A → B → C
// 요청 흐름:  A.before → B.before → C.before → Handler
// 응답 흐름:  C.after  → B.after  → A.after  → Client

이 순서는 Express 미들웨어와 동일한 패턴이다. 로깅 Interceptor는 가장 바깥(첫 번째)에, 응답 변환은 가장 안쪽(마지막)에 배치하는 것이 일반적이다.

실전 패턴 4: ExecutionContext로 분기 처리

ExecutionContext는 현재 요청의 컨텍스트 정보를 제공한다. HTTP, WebSocket, gRPC 등 다양한 전송 계층에서 동일한 Interceptor를 재사용할 수 있다.

@Injectable()
export class SmartLoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const type = context.getType(); // 'http' | 'ws' | 'rpc'
    const handler = context.getHandler();
    const controller = context.getClass();

    // 커스텀 데코레이터 메타데이터 읽기
    const isPublic = Reflect.getMetadata('isPublic', handler);
    if (isPublic) return next.handle(); // 퍼블릭 엔드포인트는 로깅 스킵

    const label = `${controller.name}.${handler.name}`;
    console.log(`[${type.toUpperCase()}] ${label} - started`);

    return next.handle().pipe(
      tap(() => console.log(`[${type.toUpperCase()}] ${label} - completed`)),
    );
  }
}

Reflect.getMetadata를 활용하면 커스텀 데코레이터(@Public(), @CacheTTL(60) 등)와 Interceptor를 연동할 수 있어 매우 유연한 설계가 가능하다.

Interceptor vs Middleware vs Guard 비교

구분 실행 시점 응답 접근 DI 지원 주 용도
Middleware 가장 먼저 제한적 제한적 요청 전처리, CORS
Guard Middleware 이후 없음 완전 인증/인가
Interceptor Guard 이후 완전 (RxJS) 완전 로깅, 캐싱, 변환
Pipe Interceptor 이후 없음 완전 유효성 검증, 변환

Interceptor만이 요청과 응답 양쪽 모두에 개입할 수 있다는 점이 가장 큰 차별점이다. 이 특성 덕분에 실행 시간 측정, 응답 변환, 에러 매핑 같은 작업에 최적이다.

프로덕션 팁: 에러 매핑 Interceptor

외부 라이브러리에서 발생하는 에러를 NestJS HttpException으로 변환하는 패턴도 자주 쓰인다.

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

@Injectable()
export class DbErrorInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        if (err.code === '23505') { // PostgreSQL unique violation
          return throwError(() => new ConflictException('중복된 데이터입니다'));
        }
        if (err.code === '23503') { // FK violation
          return throwError(() => new BadRequestException('참조 무결성 위반'));
        }
        return throwError(() => err);
      }),
    );
  }
}

이렇게 하면 컨트롤러와 서비스 계층에서 DB 에러를 일일이 처리하지 않아도 된다. Interceptor가 횡단 관심사를 깔끔하게 분리해주는 대표적인 예시다.

정리

NestJS Interceptor는 RxJS Observable 기반으로 요청/응답 양방향에 개입하는 강력한 AOP 도구다. 응답 래핑, 타임아웃, 캐싱, 에러 매핑 등 프로덕션 필수 패턴을 Interceptor 하나로 해결할 수 있다. ExecutionContext와 커스텀 데코레이터를 결합하면 메타데이터 기반 동적 분기도 가능하다. Guard, Pipe, Middleware와의 역할을 명확히 구분하고, 양파 모델 실행 순서를 이해하면 유지보수성 높은 NestJS 애플리케이션을 설계할 수 있다.

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