개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

NestJS Interceptor — Controller 전후를 가로채는 방법

2025년 3월 14일
NestJSInterceptorNestInterceptorLoggingCache

HTTP 요청이 Controller에 닿기 전, 혹은 응답이 클라이언트에 돌아가기 직전에 공통 로직을 끼워 넣고 싶을 때 Interceptor가 정답이다. Middleware가 Express 수준에서 동작하는 것과 달리, Interceptor는 NestJS의 실행 컨텍스트와 RxJS Observable 스트림 위에서 동작하기 때문에 응답 값 자체를 변환하거나 스트림을 교체하는 일도 가능하다.

요청 흐름에서의 위치

Client
  │
  ▼
[ Middleware ]
  │
  ▼
[ Guards ]
  │
  ▼
[ Interceptor — before ]  ◀─ next.handle() 호출 전
  │
  ▼
[ Pipes ]
  │
  ▼
[ Controller / Route Handler ]
  │
  ▼
[ Interceptor — after ]   ◀─ Observable pipe() 영역
  │
  ▼
[ Exception Filters ]
  │
  ▼
Client

Observable: RxJS의 비동기 스트림 타입. next.handle이 반환하는 Observable을 pipe로 가공하면 응답 값을 변환할 수 있다.

NestInterceptor 구현

NestInterceptor 인터페이스를 구현하고 intercept 메서드를 정의한다.

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

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

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

CallHandler: next.handle을 호출해야 실제 Route Handler가 실행된다. 호출하지 않으면 요청 처리가 멈춘다.

next.handle 이전 코드는 before 단계, pipe 안은 after 단계다. 이 구조 덕분에 하나의 메서드로 전·후 처리를 모두 담당한다.

주요 활용 패턴

패턴RxJS 연산자설명
로깅tap응답 값을 바꾸지 않고 부수 효과만 실행
응답 변환map{ data: value } 형태로 래핑
캐싱ofObservable 교체로 DB 조회 없이 즉시 반환
Timeouttimeout지정 시간 초과 시 에러 발생

응답 변환

import { map } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<{ data: T }> {
    return next.handle().pipe(map((value) => ({ data: value })));
  }
}

캐싱

import { of } from 'rxjs';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, unknown>();

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const key = context.switchToHttp().getRequest().url;
    if (this.cache.has(key)) {
      return of(this.cache.get(key)); // next.handle() 자체를 건너뜀
    }
    return next.handle().pipe(
      tap((value) => this.cache.set(key, value)),
    );
  }
}

Timeout

import { timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(3000));
  }
}

@UseInterceptors 적용 범위

// 특정 핸들러에만
@UseInterceptors(LoggingInterceptor)
@Get(':id')
findOne(@Param('id') id: string) { ... }

// 컨트롤러 전체
@UseInterceptors(LoggingInterceptor)
@Controller('users')
export class UsersController { ... }

// 전역 (main.ts)
app.useGlobalInterceptors(new LoggingInterceptor());

// 전역 + DI 사용 가능 (AppModule)
providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }]

APP_INTERCEPTOR: NestJS DI 컨테이너가 관리하는 전역 Interceptor 토큰. useGlobalInterceptors와 달리 의존성 주입이 가능하다.

of로 Observable을 교체하면 Route Handler 자체가 실행되지 않는다는 점이 캐싱 구현의 핵심이다. 반면 tap은 스트림을 통과시키면서 부수 효과만 추가하므로 로깅에 적합하다. 두 연산자의 차이를 정확히 이해하면 Interceptor 설계가 단순해진다.

← 이전 글NestJS Pipe — 입력 데이터의 검증과 변환을 담당하는 파이프
다음 글 →NestJS Exception Filter — 예외를 중앙에서 처리하는 방법