NestJS Interceptor란?
NestJS Interceptor는 요청/응답 파이프라인에서 AOP(관점 지향 프로그래밍) 패턴을 구현하는 핵심 컴포넌트입니다. 컨트롤러 로직 전후에 추가 동작을 삽입할 수 있어, 로깅·캐싱·응답 변환·타임아웃 등 횡단 관심사를 깔끔하게 분리합니다. 이 글에서는 NestInterceptor 인터페이스부터 RxJS 연산자 활용, 실무 패턴까지 심화 내용을 다룹니다.
기본 구조: NestInterceptor 인터페이스
Interceptor는 NestInterceptor 인터페이스의 intercept() 메서드를 구현합니다. ExecutionContext로 요청 정보에 접근하고, CallHandler의 handle()로 다음 핸들러를 호출합니다.
import {
Injectable, NestInterceptor,
ExecutionContext, CallHandler,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const now = Date.now();
console.log(`[REQ] ${method} ${url}`);
return next.handle().pipe(
tap((data) => {
const ms = Date.now() - now;
console.log(`[RES] ${method} ${url} - ${ms}ms`);
}),
);
}
}
next.handle() 이전의 코드는 요청 전에, pipe() 내부의 코드는 응답 후에 실행됩니다. 이것이 Interceptor의 핵심 — 요청과 응답 양쪽을 모두 제어할 수 있다는 점입니다.
적용 범위: Controller · Method · Global
Interceptor는 세 가지 범위로 적용할 수 있습니다:
// 1. 메서드 단위
@UseInterceptors(LoggingInterceptor)
@Get('users')
findAll() { ... }
// 2. 컨트롤러 단위
@UseInterceptors(LoggingInterceptor)
@Controller('users')
export class UsersController { ... }
// 3. 글로벌 (app.module.ts)
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
],
})
export class AppModule {}
APP_INTERCEPTOR를 사용하면 DI 컨테이너를 통해 등록되므로 다른 서비스를 주입받을 수 있습니다. app.useGlobalInterceptors()는 DI 없이 등록하는 방식입니다.
응답 변환: map 연산자
API 응답을 일관된 형태로 래핑하는 것은 가장 흔한 Interceptor 패턴입니다.
import { map } from 'rxjs';
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(),
})),
);
}
}
이렇게 하면 모든 컨트롤러의 반환값이 자동으로 { success, data, timestamp } 형태로 래핑됩니다. 컨트롤러는 순수 데이터만 반환하면 됩니다.
타임아웃 처리: timeout 연산자
느린 요청을 자동으로 중단하는 타임아웃 Interceptor입니다:
import { timeout, catchError } from 'rxjs';
import { TimeoutError } from 'rxjs';
import { RequestTimeoutException } from '@nestjs/common';
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly ms: number = 5000) {}
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
timeout(this.ms),
catchError((err) => {
if (err instanceof TimeoutError) {
throw new RequestTimeoutException(
`Request timed out after ${this.ms}ms`,
);
}
throw err;
}),
);
}
}
// 사용: 특정 엔드포인트에 10초 타임아웃
@UseInterceptors(new TimeoutInterceptor(10000))
@Get('heavy-report')
generateReport() { ... }
이 패턴은 NestJS Custom Decorator와 조합하면 데코레이터로 타임아웃 값을 선언적으로 지정할 수 있습니다.
캐싱 Interceptor: 커스텀 캐시 전략
NestJS 내장 CacheInterceptor 대신 세밀한 제어가 필요할 때 직접 구현합니다:
import { of, tap } from 'rxjs';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
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 request = context.switchToHttp().getRequest();
// GET 요청만 캐싱
if (request.method !== 'GET') {
return next.handle();
}
const key = `cache:${request.url}`;
const cached = await this.cache.get(key);
if (cached) {
return of(cached); // 캐시 히트 → Observable로 즉시 반환
}
return next.handle().pipe(
tap(async (data) => {
await this.cache.set(key, data, 60_000); // 60초 TTL
}),
);
}
}
에러 매핑 Interceptor
외부 라이브러리의 에러를 NestJS HTTP 예외로 변환합니다:
import { catchError, throwError } from 'rxjs';
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
catchError((error) => {
// TypeORM/Prisma 에러 → HTTP 예외 변환
if (error.code === '23505') { // unique violation
return throwError(
() => new ConflictException('리소스가 이미 존재합니다'),
);
}
if (error.code === '23503') { // FK violation
return throwError(
() => new BadRequestException('참조하는 리소스가 없습니다'),
);
}
return throwError(() => error);
}),
);
}
}
이 패턴은 NestJS ExceptionFilter 에러 처리와 역할이 다릅니다. ExceptionFilter는 이미 던져진 예외를 처리하고, Interceptor는 예외를 변환하거나 전파 전에 가공합니다.
실행 순서와 다중 Interceptor
여러 Interceptor를 동시에 적용하면 등록 순서대로 요청을 감싸고, 역순으로 응답을 처리합니다:
@UseInterceptors(LoggingInterceptor, TransformInterceptor, TimeoutInterceptor)
@Controller('orders')
export class OrdersController { ... }
// 실행 순서 (양파 모델):
// 요청 → Logging → Transform → Timeout → Controller
// 응답 ← Logging ← Transform ← Timeout ← Controller
| 컴포넌트 | 실행 시점 | 용도 |
|---|---|---|
| Middleware | 가장 먼저 | CORS, 로깅, 인증 토큰 파싱 |
| Guard | Middleware 후 | 인가(Authorization) 결정 |
| Interceptor (전) | Guard 후 | 요청 로깅, 캐시 확인 |
| Pipe | Interceptor 후 | 유효성 검사, 변환 |
| Controller | Pipe 후 | 비즈니스 로직 |
| Interceptor (후) | Controller 후 | 응답 변환, 캐시 저장 |
Reflector와 커스텀 메타데이터
Interceptor에서 메타데이터를 읽어 동작을 동적으로 변경할 수 있습니다:
// 커스텀 데코레이터
export const CacheTTL = (seconds: number) =>
SetMetadata('cache-ttl', seconds);
export const NoCache = () => SetMetadata('no-cache', true);
// Interceptor에서 메타데이터 읽기
@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
async intercept(context: ExecutionContext, next: CallHandler) {
const noCache = this.reflector.getAllAndOverride<boolean>(
'no-cache',
[context.getHandler(), context.getClass()],
);
if (noCache) return next.handle();
const ttl = this.reflector.getAllAndOverride<number>(
'cache-ttl',
[context.getHandler(), context.getClass()],
) ?? 60;
const key = `cache:${context.switchToHttp().getRequest().url}`;
const cached = await this.cache.get(key);
if (cached) return of(cached);
return next.handle().pipe(
tap(async (data) => {
await this.cache.set(key, data, ttl * 1000);
}),
);
}
}
// 사용
@CacheTTL(300) // 5분 캐시
@Get('products')
findAll() { ... }
@NoCache() // 캐시 비활성화
@Get('products/realtime')
findRealtime() { ... }
StreamableFile과 Interceptor 주의사항
파일 다운로드 등 StreamableFile을 반환하는 경우, TransformInterceptor가 응답을 래핑하면 스트림이 깨집니다. 반환 타입을 체크해야 합니다:
@Injectable()
export class SafeTransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map((data) => {
// StreamableFile이면 변환하지 않음
if (data instanceof StreamableFile) return data;
// 이미 래핑된 응답이면 패스
if (data?.success !== undefined) return data;
return { success: true, data, timestamp: new Date().toISOString() };
}),
);
}
}
마무리
NestJS Interceptor는 RxJS Observable을 기반으로 요청·응답 파이프라인의 양방향 제어를 가능하게 합니다. 응답 변환, 캐싱, 타임아웃, 에러 매핑, 로깅 등 횡단 관심사를 컨트롤러 로직과 완전히 분리할 수 있어, 코드 재사용성과 유지보수성이 크게 향상됩니다. Reflector를 활용한 메타데이터 기반 동적 제어까지 마스터하면 선언적이고 유연한 API 설계가 가능합니다.