NestJS Interceptor란?
NestJS의 Interceptor는 AOP(Aspect-Oriented Programming) 패턴을 구현하는 핵심 도구다. 컨트롤러 핸들러의 실행 전후에 로직을 삽입할 수 있어, 로깅·캐싱·응답 변환·타임아웃 등 횡단 관심사를 깔끔하게 분리할 수 있다.
Interceptor는 NestInterceptor 인터페이스를 구현하며, RxJS Observable을 통해 응답 스트림을 조작한다. Guard가 “실행할지 말지”를 결정한다면, Interceptor는 “실행 전후에 무엇을 할지”를 결정한다.
Interceptor 실행 흐름
NestJS 요청 라이프사이클에서 Interceptor의 위치를 정확히 이해해야 한다:
| 순서 | 단계 | 역할 |
|---|---|---|
| 1 | Middleware | 요청 전처리 (Express/Fastify 레벨) |
| 2 | Guard | 인가 결정 (실행 여부) |
| 3 | Interceptor (Before) | 핸들러 실행 전 로직 |
| 4 | Pipe | 유효성 검증·변환 |
| 5 | Handler | 컨트롤러 메서드 실행 |
| 6 | Interceptor (After) | 응답 후처리 |
| 7 | Exception Filter | 예외 처리 |
기본 구조: NestInterceptor 인터페이스
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class BasicInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 핸들러 실행 전 로직
console.log('Before handler...');
return next.handle().pipe(
// 핸들러 실행 후 로직 (RxJS 연산자 활용)
);
}
}
ExecutionContext는 현재 요청의 메타데이터(컨트롤러, 핸들러, 요청 타입 등)에 접근할 수 있고, next.handle()은 실제 핸들러를 실행하는 Observable을 반환한다.
패턴 1: 로깅 Interceptor
가장 흔한 패턴. 요청 처리 시간과 메타데이터를 자동으로 기록한다:
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
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 now = Date.now();
this.logger.log(`[${method}] ${url} → ${controller}.${handler}()`);
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 | ERROR: ${err.message}`,
);
},
}),
);
}
}
tap 연산자는 스트림을 변경하지 않고 부수 효과만 실행한다. next와 error 콜백으로 성공·실패 모두 로깅할 수 있다.
패턴 2: 응답 래핑 Interceptor
API 응답을 일관된 형태로 감싸는 패턴. 프론트엔드와의 계약을 표준화한다:
import { map } from 'rxjs/operators';
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@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(),
})),
);
}
}
map 연산자로 핸들러의 반환값을 표준 응답 객체로 감싼다. 컨트롤러에서는 순수 데이터만 반환하면 된다:
// 컨트롤러 반환: { id: 1, name: 'Edgar' }
// 실제 응답: { success: true, data: { id: 1, name: 'Edgar' }, timestamp: '...' }
패턴 3: 캐싱 Interceptor
핸들러 실행을 건너뛰고 캐시된 응답을 반환하는 패턴. next.handle()을 호출하지 않으면 핸들러가 실행되지 않는다는 점이 핵심이다:
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(private readonly cacheService: CacheService) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
// GET 요청만 캐싱
if (request.method !== 'GET') {
return next.handle();
}
const cacheKey = `cache:${request.url}`;
const cached = await this.cacheService.get(cacheKey);
if (cached) {
return of(cached); // 핸들러 실행 건너뜀
}
return next.handle().pipe(
tap((response) => {
this.cacheService.set(cacheKey, response, 60); // 60초 TTL
}),
);
}
}
캐시 히트 시 of(cached)로 즉시 응답하고, 미스 시 핸들러를 실행한 뒤 결과를 캐싱한다. NestJS Guard 인가 설계와 결합하면 인가 후 캐시 조회가 이루어져 보안과 성능을 모두 확보할 수 있다.
패턴 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 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);
}),
);
}
}
RxJS의 timeout 연산자가 지정 시간 내에 응답이 없으면 TimeoutError를 발생시키고, 이를 NestJS의 RequestTimeoutException으로 변환한다.
패턴 5: Reflector 기반 조건부 Interceptor
커스텀 데코레이터와 Reflector를 결합해 핸들러별로 동작을 제어하는 고급 패턴:
import { SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
// 커스텀 데코레이터
export const CACHE_TTL_KEY = 'cache_ttl';
export const CacheTTL = (seconds: number) =>
SetMetadata(CACHE_TTL_KEY, seconds);
export const NO_CACHE_KEY = 'no_cache';
export const NoCache = () => SetMetadata(NO_CACHE_KEY, true);
@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly cacheService: CacheService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// @NoCache() 데코레이터가 있으면 캐싱 스킵
const noCache = this.reflector.getAllAndOverride<boolean>(
NO_CACHE_KEY,
[context.getHandler(), context.getClass()],
);
if (noCache) return next.handle();
// @CacheTTL(120) 으로 핸들러별 TTL 지정
const ttl = this.reflector.getAllAndOverride<number>(
CACHE_TTL_KEY,
[context.getHandler(), context.getClass()],
) ?? 60;
// ... 캐싱 로직 (ttl 활용)
return next.handle();
}
}
// 사용 예시
@Controller('products')
export class ProductController {
@Get()
@CacheTTL(300) // 5분 캐시
findAll() { /* ... */ }
@Get('realtime')
@NoCache() // 캐시 비활성화
getRealtime() { /* ... */ }
}
Reflector.getAllAndOverride는 핸들러 → 클래스 순으로 메타데이터를 탐색해, 메서드 레벨 설정이 클래스 레벨 설정을 오버라이드하게 만든다.
Interceptor 적용 범위
Interceptor는 세 가지 범위로 적용할 수 있다:
// 1. 메서드 레벨
@UseInterceptors(LoggingInterceptor)
@Get()
findAll() { }
// 2. 컨트롤러 레벨
@UseInterceptors(LoggingInterceptor)
@Controller('users')
export class UserController { }
// 3. 글로벌 레벨 (DI 지원)
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule { }
글로벌 Interceptor는 APP_INTERCEPTOR 토큰으로 등록해야 DI 컨테이너에서 다른 서비스를 주입받을 수 있다. NestJS Dynamic Module 설계와 함께 사용하면 모듈별로 설정 가능한 Interceptor를 만들 수 있다.
다중 Interceptor 실행 순서
여러 Interceptor를 적용할 때 실행 순서를 이해해야 한다:
@UseInterceptors(LoggingInterceptor, TransformInterceptor, CacheInterceptor)
@Get()
findAll() { }
// 실행 순서 (양파 모델):
// → Logging.before → Transform.before → Cache.before
// → Handler 실행
// ← Cache.after → Transform.after → Logging.after
배열 순서대로 before 로직이 실행되고, after 로직은 역순으로 실행된다. 이 “양파 모델”을 이해하면 Interceptor 조합 시 의도한 대로 동작하게 배치할 수 있다.
실전 팁: 에러 처리와 Interceptor
Interceptor의 after 로직에서 에러를 다루는 방법:
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) => {
// 외부 서비스 에러를 내부 에러로 매핑
if (error.code === 'ECONNREFUSED') {
return throwError(
() => new ServiceUnavailableException('외부 서비스 연결 불가'),
);
}
return throwError(() => error);
}),
);
}
}
catchError로 에러를 가로채 변환할 수 있지만, 일반적인 예외 처리는 Exception Filter에서 하는 것이 NestJS의 설계 의도에 맞다. Interceptor에서는 에러 매핑이나 로깅에 집중하자.
정리
| 패턴 | 핵심 RxJS 연산자 | 용도 |
|---|---|---|
| 로깅 | tap | 요청 추적, 성능 측정 |
| 응답 래핑 | map | 표준 API 응답 포맷 |
| 캐싱 | of, tap | 핸들러 바이패스 |
| 타임아웃 | timeout, catchError | 느린 응답 방어 |
| 조건부 제어 | Reflector | 핸들러별 동작 분기 |
Interceptor는 NestJS의 AOP를 실현하는 가장 강력한 도구다. Guard → Interceptor → Pipe → Handler의 라이프사이클을 이해하고, RxJS 연산자를 활용하면 횡단 관심사를 우아하게 분리할 수 있다.