NestJS Interceptor란?
NestJS Interceptor는 @Injectable() 클래스로, NestInterceptor 인터페이스를 구현합니다. AOP(Aspect-Oriented Programming) 패턴에서 영감을 받아 요청/응답 흐름의 전·후 처리를 깔끔하게 분리할 수 있습니다.
Guard가 “접근 허용 여부”를 결정한다면, Interceptor는 “요청이 컨트롤러에 도달하기 전과 응답이 클라이언트에 전달되기 전에 무엇을 할 것인가”를 담당합니다. 로깅, 캐싱, 응답 변환, 타임아웃 등 횡단 관심사(Cross-Cutting Concern)를 처리하는 핵심 메커니즘입니다.
Interceptor 실행 흐름
NestJS의 요청 처리 파이프라인에서 Interceptor의 위치를 이해하는 것이 중요합니다:
Client Request
→ Middleware
→ Guard
→ Interceptor (before)
→ Pipe
→ Controller Method
→ Interceptor (after)
→ Exception Filter (에러 시)
→ Response
intercept() 메서드는 ExecutionContext와 CallHandler를 받습니다. CallHandler.handle()이 반환하는 Observable을 통해 응답 스트림을 조작할 수 있습니다.
기본 구조: LoggingInterceptor
가장 흔한 패턴인 로깅 인터셉터부터 살펴봅시다:
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(() =>
this.logger.log(`${method} ${url} — ${Date.now() - now}ms`),
),
);
}
}
tap 연산자는 응답 데이터를 변경하지 않고 부수 효과만 실행합니다. 요청 시작 시점(next.handle() 호출 전)과 응답 완료 시점(pipe 내부)을 모두 활용하는 것이 핵심입니다.
응답 변환: TransformInterceptor
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 연산자로 컨트롤러가 반환한 데이터를 { success, data, timestamp } 구조로 감쌉니다. 모든 엔드포인트에 적용하면 프론트엔드가 일관된 응답 구조를 기대할 수 있습니다.
타임아웃 처리: TimeoutInterceptor
느린 외부 API 호출이나 DB 쿼리로 인한 무한 대기를 방지하는 패턴입니다:
import {
RequestTimeoutException,
} from '@nestjs/common';
import {
timeout, catchError,
} from 'rxjs/operators';
import { throwError, TimeoutError } from 'rxjs';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly ms = 5000) {}
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
timeout(this.ms),
catchError((err) =>
err instanceof TimeoutError
? throwError(() => new RequestTimeoutException(
`요청이 ${this.ms}ms를 초과했습니다`,
))
: throwError(() => err),
),
);
}
}
RxJS의 timeout 연산자를 활용해 지정된 시간 내에 응답이 오지 않으면 RequestTimeoutException을 던집니다. 생성자를 통해 타임아웃 값을 주입받아 유연하게 사용할 수 있습니다.
캐싱 Interceptor: Redis 연동
GET 요청의 응답을 캐싱하는 고급 패턴입니다. NestJS 내장 CacheInterceptor 대신 직접 구현하면 세밀한 제어가 가능합니다:
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject } from '@nestjs/common';
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Cache } from 'cache-manager';
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest();
if (req.method !== 'GET') return next.handle();
const key = `cache:${req.url}`;
const cached = await this.cache.get(key);
if (cached) return of(cached);
return next.handle().pipe(
tap((response) => this.cache.set(key, response, 60_000)),
);
}
}
GET이 아닌 요청은 캐싱을 건너뛰고, 캐시 히트 시 of()로 즉시 반환합니다. TTL은 60초로 설정했지만, 커스텀 데코레이터로 엔드포인트별 TTL을 지정할 수도 있습니다.
커스텀 데코레이터와 조합
Interceptor의 진가는 Reflector와 커스텀 데코레이터를 조합할 때 발휘됩니다:
// cache-ttl.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CacheTTL = (seconds: number) =>
SetMetadata('cache-ttl', seconds);
// controller에서 사용
@Get('products')
@CacheTTL(300) // 5분 캐시
findAll() { ... }
// interceptor에서 메타데이터 읽기
@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const ttl = this.reflector.get<number>(
'cache-ttl',
context.getHandler(),
);
if (!ttl) return next.handle();
const key = `cache:${context.switchToHttp().getRequest().url}`;
const cached = await this.cache.get(key);
if (cached) return of(cached);
return next.handle().pipe(
tap((res) => this.cache.set(key, res, ttl * 1000)),
);
}
}
@CacheTTL() 데코레이터가 없는 엔드포인트는 캐싱을 건너뛰고, 있는 엔드포인트만 지정된 TTL로 캐싱합니다. 이 패턴은 NestJS Guard 접근 제어에서 다룬 Reflector 활용법과 동일합니다.
에러 매핑 Interceptor
도메인 에러를 HTTP 에러로 변환하는 패턴입니다. NestJS Exception Filter와 함께 사용하면 더욱 강력합니다:
import { catchError } from 'rxjs/operators';
import {
NotFoundException,
ConflictException,
} from '@nestjs/common';
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
catchError((error) => {
if (error.code === 'ENTITY_NOT_FOUND') {
throw new NotFoundException(error.message);
}
if (error.code === 'DUPLICATE_ENTRY') {
throw new ConflictException(error.message);
}
throw error; // 알 수 없는 에러는 그대로 전파
}),
);
}
}
Interceptor 적용 범위
Interceptor는 세 가지 범위로 적용할 수 있습니다:
| 범위 | 적용 방법 | 영향 범위 |
|---|---|---|
| 메서드 | @UseInterceptors(LoggingInterceptor) |
해당 핸들러만 |
| 컨트롤러 | 클래스 레벨 데코레이터 | 컨트롤러 전체 |
| 글로벌 | app.useGlobalInterceptors() 또는 APP_INTERCEPTOR |
전체 애플리케이션 |
글로벌 적용 시 DI를 활용하려면 APP_INTERCEPTOR 토큰을 사용해야 합니다:
// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
})
export class AppModule {}
여러 Interceptor를 등록하면 등록 순서대로 before 로직이 실행되고, 역순으로 after 로직이 실행됩니다 (스택 구조).
실전 팁: Interceptor vs Middleware vs Guard
각각의 역할을 명확히 구분해야 코드가 깔끔해집니다:
- Middleware: 요청 전처리 (CORS, 바디 파싱, 로그). ExecutionContext 접근 불가
- Guard: 인증·인가 판단. true/false만 반환
- Interceptor: 요청 전·후 변환, 캐싱, 로깅, 타임아웃. RxJS Observable로 응답 스트림 제어
- Pipe: 입력값 검증·변환. 파라미터 단위
Interceptor는 RxJS Observable을 다루므로 비동기 스트림 조작이 자유롭습니다. 이것이 단순 Middleware와의 가장 큰 차별점입니다.
성능 측정 Interceptor 패턴
프로덕션에서 유용한 성능 측정 + 느린 쿼리 알림 패턴입니다:
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
private readonly logger = new Logger('Perf');
private readonly threshold: number;
constructor(@Inject('SLOW_THRESHOLD') threshold = 3000) {
this.threshold = threshold;
}
intercept(context: ExecutionContext, next: CallHandler) {
const cls = context.getClass().name;
const handler = context.getHandler().name;
const start = process.hrtime.bigint();
return next.handle().pipe(
tap(() => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
if (ms > this.threshold) {
this.logger.warn(
`🐢 SLOW ${cls}.${handler} — ${ms.toFixed(1)}ms`,
);
}
}),
);
}
}
3초를 초과하는 핸들러를 자동으로 감지해 경고 로그를 남깁니다. 모니터링 시스템과 연동하면 병목 지점을 빠르게 파악할 수 있습니다.
마무리
NestJS Interceptor는 횡단 관심사를 깔끔하게 분리하는 핵심 도구입니다. 로깅, 응답 변환, 캐싱, 타임아웃, 에러 매핑, 성능 측정 등 다양한 패턴을 RxJS Observable 기반으로 구현할 수 있습니다. Guard, Pipe, Filter와 역할을 명확히 나누고, Reflector와 커스텀 데코레이터를 조합하면 선언적이고 재사용 가능한 아키텍처를 만들 수 있습니다.