NestJS Interceptor

NestJS Interceptor란? AOP 기반 요청/응답 파이프라인의 핵심

NestJS 애플리케이션에서 “모든 API 응답을 통일된 포맷으로 감싸고 싶다”, “요청/응답 시간을 자동으로 측정하고 싶다”, “특정 조건에서 응답을 캐싱하고 싶다” — 이런 횡단 관심사(cross-cutting concerns)를 처리하는 가장 강력한 도구가 바로 Interceptor입니다.

Interceptor는 NestJS의 요청 처리 파이프라인에서 Controller 실행 전후 모두에 개입할 수 있는 유일한 계층입니다. Guard는 실행 전에만, Pipe는 데이터 변환에만, Filter는 예외 처리에만 관여하지만, Interceptor는 RxJS Observable을 활용해 요청과 응답 양쪽을 자유롭게 조작할 수 있습니다.

이 글에서는 NestInterceptor 인터페이스의 동작 원리부터, 로깅·응답 변환·캐싱·타임아웃·에러 매핑 실무 패턴, ExecutionContext 활용법, 그리고 Guard·Pipe와의 실행 순서 관계까지 완전히 다룹니다.

NestInterceptor 인터페이스: intercept 메서드 해부

모든 Interceptor는 NestInterceptor 인터페이스를 구현합니다. 핵심은 단 하나의 메서드 intercept()입니다:

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

@Injectable()
export class ExampleInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 1. Controller 실행 전 로직 (before)
    console.log('Before handler...');

    // 2. next.handle()로 Controller 실행 → Observable 반환
    return next.handle().pipe(
      // 3. Controller 실행 후 로직 (after) — RxJS 연산자 사용
      tap((data) => console.log('After handler...', data)),
    );
  }
}

핵심 개념 3가지

개념 타입 역할
ExecutionContext ArgumentsHost 확장 현재 요청의 Controller 클래스, 핸들러 메서드, 요청 타입(HTTP/WS/RPC) 접근
CallHandler handle() → Observable next.handle() 호출 = Controller 메서드 실행. 미호출 시 Controller가 실행되지 않음
Observable<any> RxJS Observable Controller의 반환값을 스트림으로 감쌈. RxJS 연산자로 변환/가공 가능

핵심 포인트: next.handle()을 호출하지 않으면 Controller가 아예 실행되지 않습니다. 이 특성을 이용해 캐싱 Interceptor에서 캐시 히트 시 Controller를 건너뛸 수 있습니다.

NestJS 요청 처리 파이프라인: 실행 순서 완전 정리

Interceptor가 다른 계층과 어떤 순서로 실행되는지 정확히 알아야 합니다:

요청 수신
  → Middleware (Express/Fastify 미들웨어)
    → Guard (인증/인가 검증)
      → Interceptor (before - Controller 실행 전)
        → Pipe (데이터 유효성 검증 + 변환)
          → Controller (핸들러 메서드 실행)
        → Interceptor (after - Controller 실행 후, RxJS pipe)
      → Exception Filter (예외 발생 시)
    응답 반환

중요: Interceptor의 “before” 로직은 Pipe보다 먼저, “after” 로직은 Controller 이후에 실행됩니다. 따라서 Interceptor에서는 아직 유효성 검증이 안 된 raw 요청 데이터를 볼 수 있고, 응답 데이터는 Controller가 반환한 최종 값을 볼 수 있습니다.

Interceptor 등록: 3가지 스코프

// 1. 메서드 레벨 — 특정 엔드포인트에만 적용
@Controller('orders')
export class OrderController {
  @Get(':id')
  @UseInterceptors(CacheInterceptor)
  findOne(@Param('id') id: string) { ... }
}

// 2. 클래스 레벨 — Controller의 모든 엔드포인트에 적용
@Controller('orders')
@UseInterceptors(LoggingInterceptor)
export class OrderController { ... }

// 3. 글로벌 레벨 — 모든 엔드포인트에 적용
// 방법 A: main.ts에서 (DI 불가)
app.useGlobalInterceptors(new LoggingInterceptor());

// 방법 B: Module에서 (DI 가능 — 권장!)
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: TransformInterceptor,
    },
  ],
})
export class AppModule {}

실행 순서: 글로벌 → 클래스 → 메서드 순으로 실행됩니다. 같은 레벨에서 여러 Interceptor가 있으면 등록 순서(배열 순서)대로 실행됩니다.

실무 패턴 1: 로깅 Interceptor — 요청/응답 시간 측정

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

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

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const { method, url, ip } = request;
    const userAgent = request.get('user-agent') || '';
    const className = context.getClass().name;
    const handlerName = context.getHandler().name;

    const now = Date.now();

    this.logger.log(
      `→ ${method} ${url} [${className}.${handlerName}] from ${ip}`,
    );

    return next.handle().pipe(
      tap({
        next: (data) => {
          const response = context.switchToHttp().getResponse();
          const elapsed = Date.now() - now;
          this.logger.log(
            `← ${method} ${url} ${response.statusCode} ${elapsed}ms`,
          );
        },
        error: (error) => {
          const elapsed = Date.now() - now;
          this.logger.error(
            `✗ ${method} ${url} ${error.status || 500} ${elapsed}ms - ${error.message}`,
          );
        },
      }),
    );
  }
}

이 패턴은 tap 연산자를 사용합니다. tap은 스트림 데이터를 변경하지 않고 부수 효과(side effect)만 실행하므로 로깅에 적합합니다.

실무 패턴 2: 응답 변환 Interceptor — 통일된 API 포맷

// 모든 API 응답을 { success, data, timestamp } 형태로 통일
@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(),
      })),
    );
  }
}

// 사용 결과
// Controller가 { id: 1, name: 'item' }을 반환하면:
// {
//   "success": true,
//   "data": { "id": 1, "name": "item" },
//   "timestamp": "2026-02-22T09:00:00.000Z"
// }

// 특정 엔드포인트에서 변환 건너뛰기
export const SKIP_TRANSFORM = 'SKIP_TRANSFORM';

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const skipTransform = this.reflector.getAllAndOverride<boolean>(
      SKIP_TRANSFORM,
      [context.getHandler(), context.getClass()],
    );

    if (skipTransform) {
      return next.handle(); // 변환 없이 그대로 반환
    }

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

// 건너뛰기 적용
@Get('health')
@SetMetadata(SKIP_TRANSFORM, true)
healthCheck() {
  return { status: 'ok' };
}

실무 패턴 3: 캐싱 Interceptor — Controller 실행 자체를 건너뛰기

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

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

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    // GET 요청만 캐싱
    const request = context.switchToHttp().getRequest();
    if (request.method !== 'GET') {
      return next.handle();
    }

    // 캐시 TTL 메타데이터 확인
    const ttl = this.reflector.get<number>('cacheTtl', context.getHandler());
    if (!ttl) {
      return next.handle(); // TTL 미설정 → 캐싱 안 함
    }

    const cacheKey = `http:${request.url}`;
    const cached = await this.cacheManager.get(cacheKey);

    if (cached) {
      // 캐시 히트 → Controller 실행하지 않고 즉시 반환!
      return of(cached);
    }

    // 캐시 미스 → Controller 실행 후 결과 캐싱
    return next.handle().pipe(
      tap((response) => {
        this.cacheManager.set(cacheKey, response, ttl * 1000);
      }),
    );
  }
}

// 커스텀 데코레이터
export const CacheTTL = (seconds: number) => SetMetadata('cacheTtl', seconds);

// 사용
@Get('products')
@CacheTTL(60) // 60초 캐싱
findAll() {
  return this.productService.findAll();
}

핵심: of(cached)로 즉시 Observable을 반환하면 next.handle()이 호출되지 않습니다. 즉, Controller 메서드 자체가 실행되지 않아 DB 쿼리도 발생하지 않습니다.

실무 패턴 4: 타임아웃 Interceptor — 느린 요청 강제 종료

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

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

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 핸들러별 커스텀 타임아웃 지원
    const customTimeout = this.reflector?.get<number>(
      'timeout',
      context.getHandler(),
    );
    const ms = customTimeout ?? this.defaultMs;

    return next.handle().pipe(
      timeout(ms),
      catchError((err) => {
        if (err instanceof TimeoutError) {
          return throwError(
            () => new RequestTimeoutException(
              `요청이 ${ms}ms 내에 완료되지 않았습니다.`,
            ),
          );
        }
        return throwError(() => err);
      }),
    );
  }
}

// 커스텀 타임아웃 데코레이터
export const SetTimeout = (ms: number) => SetMetadata('timeout', ms);

// 사용
@Get('report')
@SetTimeout(30000) // 리포트 생성은 30초 허용
generateReport() {
  return this.reportService.generate();
}

실무 패턴 5: 에러 매핑 Interceptor — 예외 변환

import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';

@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError((error) => {
        // TypeORM/MikroORM 에러를 HTTP 에러로 변환
        if (error.code === '23505') {
          // PostgreSQL unique violation
          return throwError(
            () => new ConflictException('이미 존재하는 리소스입니다.'),
          );
        }

        if (error.code === '23503') {
          // foreign key violation
          return throwError(
            () => new BadRequestException('참조하는 리소스가 존재하지 않습니다.'),
          );
        }

        if (error.name === 'EntityNotFoundError') {
          return throwError(
            () => new NotFoundException('리소스를 찾을 수 없습니다.'),
          );
        }

        // 알 수 없는 에러는 그대로 전파
        return throwError(() => error);
      }),
    );
  }
}

ExecutionContext 심화: 메타데이터와 Reflector 활용

ExecutionContext는 현재 요청에 대한 풍부한 정보를 제공합니다:

@Injectable()
export class SmartInterceptor implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 현재 Controller 클래스
    const controllerClass = context.getClass();
    // → OrderController

    // 현재 핸들러 메서드
    const handler = context.getHandler();
    // → findOne

    // 요청 타입 판별
    const type = context.getType(); // 'http' | 'ws' | 'rpc'

    // HTTP 요청/응답 객체
    if (type === 'http') {
      const request = context.switchToHttp().getRequest();
      const response = context.switchToHttp().getResponse();
    }

    // WebSocket
    if (type === 'ws') {
      const client = context.switchToWs().getClient();
      const data = context.switchToWs().getData();
    }

    // 커스텀 메타데이터 읽기 (Reflector)
    const roles = this.reflector.get<string[]>('roles', handler);
    const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
      handler,
      controllerClass,
    ]);

    return next.handle();
  }
}

getAllAndOverride vs getAllAndMerge

메서드 동작 용도
get(key, target) 단일 타겟에서만 조회 메서드 또는 클래스 하나만
getAllAndOverride(key, targets[]) 첫 번째로 발견된 값 반환 메서드가 클래스를 오버라이드
getAllAndMerge(key, targets[]) 모든 값을 배열로 합침 메서드 + 클래스 역할 합산

RxJS 연산자 활용: Interceptor의 진짜 힘

Interceptor의 강력함은 RxJS 연산자에서 나옵니다. 자주 사용하는 연산자와 용도:

연산자 용도 예시
map 응답 데이터 변환 통일 포맷 래핑, 필드 제거
tap 부수 효과 실행 (데이터 미변경) 로깅, 메트릭 수집, 캐시 저장
catchError 에러 가로채기 DB 에러 → HTTP 에러 변환
timeout 시간 초과 강제 느린 요청 타임아웃
retry 실패 시 재시도 일시적 DB 연결 오류 재시도
finalize 성공/실패 무관 정리 리소스 해제, 커넥션 반환

retry + delay 패턴: 자동 재시도

import { retry, timer } from 'rxjs';

@Injectable()
export class RetryInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      retry({
        count: 3,              // 최대 3번 재시도
        delay: (error, retryCount) => {
          // 재시도 가능한 에러만
          if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
            const delayMs = Math.pow(2, retryCount) * 100; // 지수 백오프
            return timer(delayMs);
          }
          throw error; // 재시도 불가 에러는 즉시 전파
        },
      }),
    );
  }
}

실무 패턴 6: 직렬화 Interceptor — class-transformer 자동 적용

import { ClassSerializerInterceptor } from '@nestjs/common';

// NestJS 내장 ClassSerializerInterceptor 활용
// Entity에서 @Exclude(), @Expose(), @Transform() 데코레이터 사용

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Expose()
  name: string;

  @Column()
  @Expose()
  email: string;

  @Column()
  @Exclude()  // 응답에서 제외!
  password: string;

  @Column()
  @Transform(({ value }) => value.toISOString())
  createdAt: Date;
}

// 글로벌 등록
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: ClassSerializerInterceptor,
    },
  ],
})
export class AppModule {}

// 그룹 기반 노출 제어
@Entity()
export class User {
  @Expose({ groups: ['admin'] })
  internalNote: string;
}

@Get(':id')
@SerializeOptions({ groups: ['admin'] })
findOneForAdmin(@Param('id') id: string) {
  return this.userService.findOne(id);
}

실무 패턴 7: 파일 업로드 로깅 + 속도 측정 복합 Interceptor

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

  constructor(
    @Inject('METRICS_SERVICE') private metrics: MetricsService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const controller = context.getClass().name;
    const handler = context.getHandler().name;
    const start = process.hrtime.bigint();

    return next.handle().pipe(
      tap({
        next: () => {
          const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
          
          // 메트릭 수집 (Prometheus 등)
          this.metrics.recordLatency(controller, handler, method, elapsed);
          
          // 느린 요청 경고
          if (elapsed > 1000) {
            this.logger.warn(
              `🐢 Slow request: ${method} ${url} took ${elapsed.toFixed(0)}ms`,
            );
          }
        },
        error: (error) => {
          const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
          this.metrics.recordError(controller, handler, method, error.status);
        },
      }),
      finalize(() => {
        // 성공/실패 무관 항상 실행
        this.metrics.incrementRequestCount(controller, handler, method);
      }),
    );
  }
}

여러 Interceptor 조합: 실행 순서와 설계 원칙

// 글로벌 등록 순서 = 실행 순서
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },      // 1번
    { provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor },      // 2번
    { provide: APP_INTERCEPTOR, useClass: ErrorMappingInterceptor }, // 3번
    { provide: APP_INTERCEPTOR, useClass: TransformInterceptor },    // 4번
  ],
})
export class AppModule {}

// 실행 흐름:
// 요청 → Logging(before) → Timeout(before) → ErrorMapping(before) → Transform(before)
//   → Controller
// 응답 ← Transform(after) ← ErrorMapping(after) ← Timeout(after) ← Logging(after)
// (양파 껍질 구조: 먼저 들어간 것이 마지막에 나옴)

설계 원칙:

  • 로깅은 가장 바깥(첫 번째)에 두어 전체 요청 시간을 측정합니다.
  • 타임아웃은 로깅 다음에 두어 타임아웃 에러도 로깅됩니다.
  • 에러 매핑은 변환 전에 두어 에러가 먼저 적절한 HTTP 예외로 변환됩니다.
  • 응답 변환은 가장 안쪽(마지막)에 두어 최종 데이터를 래핑합니다.

테스트: Interceptor 단위 테스트 작성

describe('TransformInterceptor', () => {
  let interceptor: TransformInterceptor<any>;

  beforeEach(() => {
    interceptor = new TransformInterceptor();
  });

  it('should wrap response in standard format', (done) => {
    const mockData = { id: 1, name: 'test' };

    // CallHandler 모킹
    const callHandler: CallHandler = {
      handle: () => of(mockData),
    };

    // ExecutionContext 모킹
    const context = createMock<ExecutionContext>();

    interceptor.intercept(context, callHandler).subscribe({
      next: (result) => {
        expect(result).toEqual({
          success: true,
          data: mockData,
          timestamp: expect.any(String),
        });
        done();
      },
    });
  });

  it('should pass through errors', (done) => {
    const callHandler: CallHandler = {
      handle: () => throwError(() => new Error('test error')),
    };

    const context = createMock<ExecutionContext>();

    interceptor.intercept(context, callHandler).subscribe({
      error: (err) => {
        expect(err.message).toBe('test error');
        done();
      },
    });
  });
});

Interceptor vs Guard vs Pipe vs Filter: 선택 기준 정리

계층 실행 시점 주요 용도 응답 가공
Guard Controller 전 인증/인가 (실행 여부 결정) 불가
Interceptor Controller 전후 로깅, 캐싱, 변환, 타임아웃 가능 (RxJS)
Pipe Controller 전 (파라미터별) 유효성 검증, 타입 변환 불가
Filter 예외 발생 후 예외 → HTTP 응답 변환 에러 응답만

규칙: “이 로직이 요청과 응답 양쪽에 관여하는가?” → Interceptor. “실행 여부만 결정하는가?” → Guard. “입력값 변환/검증인가?” → Pipe. “에러 처리인가?” → Filter.

정리: Interceptor 설계 체크리스트

항목 체크
글로벌 Interceptor는 APP_INTERCEPTOR로 등록 (DI 지원)
로깅 Interceptor는 가장 바깥에 배치 (전체 시간 측정)
응답 변환 Interceptor는 가장 안쪽에 배치
tap으로 부수 효과, map으로 데이터 변환 구분
캐싱 시 next.handle() 미호출로 Controller 건너뛰기
타임아웃 Interceptor에 catchError로 적절한 HTTP 예외 변환
SetMetadata + Reflector로 핸들러별 동작 커스터마이징
Interceptor 단위 테스트에서 CallHandler/ExecutionContext 모킹
에러 매핑은 Interceptor(catchError) 또는 Filter 중 하나만 선택
양파 구조(onion model) 이해하고 순서 설계

NestJS Interceptor는 Guard, Pipe, Filter가 각각 담당하지 못하는 요청/응답 양방향 가공이라는 고유 영역을 가집니다. RxJS Observable 파이프라인을 활용하면 로깅, 캐싱, 변환, 타임아웃, 재시도 등 거의 모든 횡단 관심사를 선언적이고 조합 가능한 형태로 구현할 수 있습니다. 핵심은 각 Interceptor를 단일 책임으로 작게 만들고, 양파 구조의 실행 순서를 의도적으로 설계하는 것입니다.

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