들어가며: 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)에 명시된 실행 순서:
- Middleware (Express/Fastify 레벨)
- Guard (인증/인가 판단)
- Interceptor — before (
next.handle()호출 전) - Pipe (유효성 검증/변환)
- Controller 핸들러
- Interceptor — after (
next.handle()의 Observable 구독 후) - 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. 실전 체크리스트
- DI 필요 여부 확인: 글로벌 Interceptor에서
Logger,Reflector,ConfigService등을 주입받아야 하면APP_INTERCEPTOR토큰으로 등록했는가? - self-invocation 영향 없음: Interceptor는 AOP 프록시가 아닌 NestJS 요청 파이프라인에서 동작하므로 self-invocation 문제가 없다.
- Observable 에러 처리:
tap()이나map()에서 예외가 발생하면 Exception Filter로 전파된다. Interceptor 내부에서catchError()를 쓸 때 에러를 삼키지 않도록 주의한다. - 비동기 핸들러 호환:
async핸들러의 반환값도 NestJS가 자동으로 Observable로 감싸므로, Interceptor는 동기/비동기 핸들러 모두 동일하게 동작한다. - Fastify 호환:
context.switchToHttp().getRequest()의 반환 타입이 Express와 Fastify에서 다르다. 두 플랫폼을 모두 지원하려면ExecutionContext의 제네릭을 활용한다. - 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 등)를 데코레이터로 제외하는 설계다.