NestJS Interceptor란? AOP 기반 요청/응답 파이프라인의 핵심
NestJS 애플리케이션에서 “모든 API 응답을 통일된 포맷으로 감싸고 싶다”, “요청/응답 시간을 자동으로 측정하고 싶다”, “특정 조건에서 응답을 캐싱하고 싶다” — 이런 횡단 관심사(cross-cutting concerns)를 처리하는 가장 강력한 도구가 바로 Interceptor입니다.
Interceptor는 NestJS의 요청 처리 파이프라인에서 Controller 실행 전후 모두에 개입할 수 있는 유일한 계층입니다. Guard는 실행 전에만, Pipe는 데이터 변환에만, Filter는 예외 처리에만 관여하지만, Interceptor는 RxJS Observable을 활용해 요청과 응답 양쪽을 자유롭게 조작할 수 있습니다.
이 글에서는 NestInterceptor 인터페이스의 동작 원리부터, 로깅·응답 변환·캐싱·타임아웃·에러 매핑 실무 패턴, ExecutionContext 활용법, 그리고 Guard·Pipe와의 실행 순서 관계까지 완전히 다룹니다.
NestInterceptor 인터페이스: intercept 메서드 해부
모든 Interceptor는 NestInterceptor 인터페이스를 구현합니다. 핵심은 단 하나의 메서드 intercept()입니다:
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class ExampleInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 1. Controller 실행 전 로직 (before)
console.log('Before handler...');
// 2. next.handle()로 Controller 실행 → Observable 반환
return next.handle().pipe(
// 3. Controller 실행 후 로직 (after) — RxJS 연산자 사용
tap((data) => console.log('After handler...', data)),
);
}
}
핵심 개념 3가지
| 개념 | 타입 | 역할 |
|---|---|---|
ExecutionContext |
ArgumentsHost 확장 | 현재 요청의 Controller 클래스, 핸들러 메서드, 요청 타입(HTTP/WS/RPC) 접근 |
CallHandler |
handle() → Observable | next.handle() 호출 = Controller 메서드 실행. 미호출 시 Controller가 실행되지 않음 |
Observable<any> |
RxJS Observable | Controller의 반환값을 스트림으로 감쌈. RxJS 연산자로 변환/가공 가능 |
핵심 포인트: next.handle()을 호출하지 않으면 Controller가 아예 실행되지 않습니다. 이 특성을 이용해 캐싱 Interceptor에서 캐시 히트 시 Controller를 건너뛸 수 있습니다.
NestJS 요청 처리 파이프라인: 실행 순서 완전 정리
Interceptor가 다른 계층과 어떤 순서로 실행되는지 정확히 알아야 합니다:
요청 수신
→ Middleware (Express/Fastify 미들웨어)
→ Guard (인증/인가 검증)
→ Interceptor (before - Controller 실행 전)
→ Pipe (데이터 유효성 검증 + 변환)
→ Controller (핸들러 메서드 실행)
→ Interceptor (after - Controller 실행 후, RxJS pipe)
→ Exception Filter (예외 발생 시)
응답 반환
중요: Interceptor의 “before” 로직은 Pipe보다 먼저, “after” 로직은 Controller 이후에 실행됩니다. 따라서 Interceptor에서는 아직 유효성 검증이 안 된 raw 요청 데이터를 볼 수 있고, 응답 데이터는 Controller가 반환한 최종 값을 볼 수 있습니다.
Interceptor 등록: 3가지 스코프
// 1. 메서드 레벨 — 특정 엔드포인트에만 적용
@Controller('orders')
export class OrderController {
@Get(':id')
@UseInterceptors(CacheInterceptor)
findOne(@Param('id') id: string) { ... }
}
// 2. 클래스 레벨 — Controller의 모든 엔드포인트에 적용
@Controller('orders')
@UseInterceptors(LoggingInterceptor)
export class OrderController { ... }
// 3. 글로벌 레벨 — 모든 엔드포인트에 적용
// 방법 A: main.ts에서 (DI 불가)
app.useGlobalInterceptors(new LoggingInterceptor());
// 방법 B: Module에서 (DI 가능 — 권장!)
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
})
export class AppModule {}
실행 순서: 글로벌 → 클래스 → 메서드 순으로 실행됩니다. 같은 레벨에서 여러 Interceptor가 있으면 등록 순서(배열 순서)대로 실행됩니다.
실무 패턴 1: 로깅 Interceptor — 요청/응답 시간 측정
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const className = context.getClass().name;
const handlerName = context.getHandler().name;
const now = Date.now();
this.logger.log(
`→ ${method} ${url} [${className}.${handlerName}] from ${ip}`,
);
return next.handle().pipe(
tap({
next: (data) => {
const response = context.switchToHttp().getResponse();
const elapsed = Date.now() - now;
this.logger.log(
`← ${method} ${url} ${response.statusCode} ${elapsed}ms`,
);
},
error: (error) => {
const elapsed = Date.now() - now;
this.logger.error(
`✗ ${method} ${url} ${error.status || 500} ${elapsed}ms - ${error.message}`,
);
},
}),
);
}
}
이 패턴은 tap 연산자를 사용합니다. tap은 스트림 데이터를 변경하지 않고 부수 효과(side effect)만 실행하므로 로깅에 적합합니다.
실무 패턴 2: 응답 변환 Interceptor — 통일된 API 포맷
// 모든 API 응답을 { success, data, timestamp } 형태로 통일
@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(),
})),
);
}
}
// 사용 결과
// Controller가 { id: 1, name: 'item' }을 반환하면:
// {
// "success": true,
// "data": { "id": 1, "name": "item" },
// "timestamp": "2026-02-22T09:00:00.000Z"
// }
// 특정 엔드포인트에서 변환 건너뛰기
export const SKIP_TRANSFORM = 'SKIP_TRANSFORM';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const skipTransform = this.reflector.getAllAndOverride<boolean>(
SKIP_TRANSFORM,
[context.getHandler(), context.getClass()],
);
if (skipTransform) {
return next.handle(); // 변환 없이 그대로 반환
}
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}
// 건너뛰기 적용
@Get('health')
@SetMetadata(SKIP_TRANSFORM, true)
healthCheck() {
return { status: 'ok' };
}
실무 패턴 3: 캐싱 Interceptor — Controller 실행 자체를 건너뛰기
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private reflector: Reflector,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
// GET 요청만 캐싱
const request = context.switchToHttp().getRequest();
if (request.method !== 'GET') {
return next.handle();
}
// 캐시 TTL 메타데이터 확인
const ttl = this.reflector.get<number>('cacheTtl', context.getHandler());
if (!ttl) {
return next.handle(); // TTL 미설정 → 캐싱 안 함
}
const cacheKey = `http:${request.url}`;
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
// 캐시 히트 → Controller 실행하지 않고 즉시 반환!
return of(cached);
}
// 캐시 미스 → Controller 실행 후 결과 캐싱
return next.handle().pipe(
tap((response) => {
this.cacheManager.set(cacheKey, response, ttl * 1000);
}),
);
}
}
// 커스텀 데코레이터
export const CacheTTL = (seconds: number) => SetMetadata('cacheTtl', seconds);
// 사용
@Get('products')
@CacheTTL(60) // 60초 캐싱
findAll() {
return this.productService.findAll();
}
핵심: of(cached)로 즉시 Observable을 반환하면 next.handle()이 호출되지 않습니다. 즉, Controller 메서드 자체가 실행되지 않아 DB 쿼리도 발생하지 않습니다.
실무 패턴 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 defaultMs: number = 5000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 핸들러별 커스텀 타임아웃 지원
const customTimeout = this.reflector?.get<number>(
'timeout',
context.getHandler(),
);
const ms = customTimeout ?? this.defaultMs;
return next.handle().pipe(
timeout(ms),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(
() => new RequestTimeoutException(
`요청이 ${ms}ms 내에 완료되지 않았습니다.`,
),
);
}
return throwError(() => err);
}),
);
}
}
// 커스텀 타임아웃 데코레이터
export const SetTimeout = (ms: number) => SetMetadata('timeout', ms);
// 사용
@Get('report')
@SetTimeout(30000) // 리포트 생성은 30초 허용
generateReport() {
return this.reportService.generate();
}
실무 패턴 5: 에러 매핑 Interceptor — 예외 변환
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) => {
// TypeORM/MikroORM 에러를 HTTP 에러로 변환
if (error.code === '23505') {
// PostgreSQL unique violation
return throwError(
() => new ConflictException('이미 존재하는 리소스입니다.'),
);
}
if (error.code === '23503') {
// foreign key violation
return throwError(
() => new BadRequestException('참조하는 리소스가 존재하지 않습니다.'),
);
}
if (error.name === 'EntityNotFoundError') {
return throwError(
() => new NotFoundException('리소스를 찾을 수 없습니다.'),
);
}
// 알 수 없는 에러는 그대로 전파
return throwError(() => error);
}),
);
}
}
ExecutionContext 심화: 메타데이터와 Reflector 활용
ExecutionContext는 현재 요청에 대한 풍부한 정보를 제공합니다:
@Injectable()
export class SmartInterceptor implements NestInterceptor {
constructor(private reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 현재 Controller 클래스
const controllerClass = context.getClass();
// → OrderController
// 현재 핸들러 메서드
const handler = context.getHandler();
// → findOne
// 요청 타입 판별
const type = context.getType(); // 'http' | 'ws' | 'rpc'
// HTTP 요청/응답 객체
if (type === 'http') {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
}
// WebSocket
if (type === 'ws') {
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
}
// 커스텀 메타데이터 읽기 (Reflector)
const roles = this.reflector.get<string[]>('roles', handler);
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
handler,
controllerClass,
]);
return next.handle();
}
}
getAllAndOverride vs getAllAndMerge
| 메서드 | 동작 | 용도 |
|---|---|---|
get(key, target) |
단일 타겟에서만 조회 | 메서드 또는 클래스 하나만 |
getAllAndOverride(key, targets[]) |
첫 번째로 발견된 값 반환 | 메서드가 클래스를 오버라이드 |
getAllAndMerge(key, targets[]) |
모든 값을 배열로 합침 | 메서드 + 클래스 역할 합산 |
RxJS 연산자 활용: Interceptor의 진짜 힘
Interceptor의 강력함은 RxJS 연산자에서 나옵니다. 자주 사용하는 연산자와 용도:
| 연산자 | 용도 | 예시 |
|---|---|---|
map |
응답 데이터 변환 | 통일 포맷 래핑, 필드 제거 |
tap |
부수 효과 실행 (데이터 미변경) | 로깅, 메트릭 수집, 캐시 저장 |
catchError |
에러 가로채기 | DB 에러 → HTTP 에러 변환 |
timeout |
시간 초과 강제 | 느린 요청 타임아웃 |
retry |
실패 시 재시도 | 일시적 DB 연결 오류 재시도 |
finalize |
성공/실패 무관 정리 | 리소스 해제, 커넥션 반환 |
retry + delay 패턴: 자동 재시도
import { retry, timer } from 'rxjs';
@Injectable()
export class RetryInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
retry({
count: 3, // 최대 3번 재시도
delay: (error, retryCount) => {
// 재시도 가능한 에러만
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
const delayMs = Math.pow(2, retryCount) * 100; // 지수 백오프
return timer(delayMs);
}
throw error; // 재시도 불가 에러는 즉시 전파
},
}),
);
}
}
실무 패턴 6: 직렬화 Interceptor — class-transformer 자동 적용
import { ClassSerializerInterceptor } from '@nestjs/common';
// NestJS 내장 ClassSerializerInterceptor 활용
// Entity에서 @Exclude(), @Expose(), @Transform() 데코레이터 사용
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
@Expose()
name: string;
@Column()
@Expose()
email: string;
@Column()
@Exclude() // 응답에서 제외!
password: string;
@Column()
@Transform(({ value }) => value.toISOString())
createdAt: Date;
}
// 글로벌 등록
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ClassSerializerInterceptor,
},
],
})
export class AppModule {}
// 그룹 기반 노출 제어
@Entity()
export class User {
@Expose({ groups: ['admin'] })
internalNote: string;
}
@Get(':id')
@SerializeOptions({ groups: ['admin'] })
findOneForAdmin(@Param('id') id: string) {
return this.userService.findOne(id);
}
실무 패턴 7: 파일 업로드 로깅 + 속도 측정 복합 Interceptor
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
private readonly logger = new Logger('Performance');
constructor(
@Inject('METRICS_SERVICE') private metrics: MetricsService,
) {}
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 start = process.hrtime.bigint();
return next.handle().pipe(
tap({
next: () => {
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
// 메트릭 수집 (Prometheus 등)
this.metrics.recordLatency(controller, handler, method, elapsed);
// 느린 요청 경고
if (elapsed > 1000) {
this.logger.warn(
`🐢 Slow request: ${method} ${url} took ${elapsed.toFixed(0)}ms`,
);
}
},
error: (error) => {
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
this.metrics.recordError(controller, handler, method, error.status);
},
}),
finalize(() => {
// 성공/실패 무관 항상 실행
this.metrics.incrementRequestCount(controller, handler, method);
}),
);
}
}
여러 Interceptor 조합: 실행 순서와 설계 원칙
// 글로벌 등록 순서 = 실행 순서
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, // 1번
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, // 2번
{ provide: APP_INTERCEPTOR, useClass: ErrorMappingInterceptor }, // 3번
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, // 4번
],
})
export class AppModule {}
// 실행 흐름:
// 요청 → Logging(before) → Timeout(before) → ErrorMapping(before) → Transform(before)
// → Controller
// 응답 ← Transform(after) ← ErrorMapping(after) ← Timeout(after) ← Logging(after)
// (양파 껍질 구조: 먼저 들어간 것이 마지막에 나옴)
설계 원칙:
- 로깅은 가장 바깥(첫 번째)에 두어 전체 요청 시간을 측정합니다.
- 타임아웃은 로깅 다음에 두어 타임아웃 에러도 로깅됩니다.
- 에러 매핑은 변환 전에 두어 에러가 먼저 적절한 HTTP 예외로 변환됩니다.
- 응답 변환은 가장 안쪽(마지막)에 두어 최종 데이터를 래핑합니다.
테스트: Interceptor 단위 테스트 작성
describe('TransformInterceptor', () => {
let interceptor: TransformInterceptor<any>;
beforeEach(() => {
interceptor = new TransformInterceptor();
});
it('should wrap response in standard format', (done) => {
const mockData = { id: 1, name: 'test' };
// CallHandler 모킹
const callHandler: CallHandler = {
handle: () => of(mockData),
};
// ExecutionContext 모킹
const context = createMock<ExecutionContext>();
interceptor.intercept(context, callHandler).subscribe({
next: (result) => {
expect(result).toEqual({
success: true,
data: mockData,
timestamp: expect.any(String),
});
done();
},
});
});
it('should pass through errors', (done) => {
const callHandler: CallHandler = {
handle: () => throwError(() => new Error('test error')),
};
const context = createMock<ExecutionContext>();
interceptor.intercept(context, callHandler).subscribe({
error: (err) => {
expect(err.message).toBe('test error');
done();
},
});
});
});
Interceptor vs Guard vs Pipe vs Filter: 선택 기준 정리
| 계층 | 실행 시점 | 주요 용도 | 응답 가공 |
|---|---|---|---|
| Guard | Controller 전 | 인증/인가 (실행 여부 결정) | 불가 |
| Interceptor | Controller 전후 | 로깅, 캐싱, 변환, 타임아웃 | 가능 (RxJS) |
| Pipe | Controller 전 (파라미터별) | 유효성 검증, 타입 변환 | 불가 |
| Filter | 예외 발생 후 | 예외 → HTTP 응답 변환 | 에러 응답만 |
규칙: “이 로직이 요청과 응답 양쪽에 관여하는가?” → Interceptor. “실행 여부만 결정하는가?” → Guard. “입력값 변환/검증인가?” → Pipe. “에러 처리인가?” → Filter.
정리: Interceptor 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 글로벌 Interceptor는 APP_INTERCEPTOR로 등록 (DI 지원) | ☐ |
| 로깅 Interceptor는 가장 바깥에 배치 (전체 시간 측정) | ☐ |
| 응답 변환 Interceptor는 가장 안쪽에 배치 | ☐ |
| tap으로 부수 효과, map으로 데이터 변환 구분 | ☐ |
| 캐싱 시 next.handle() 미호출로 Controller 건너뛰기 | ☐ |
| 타임아웃 Interceptor에 catchError로 적절한 HTTP 예외 변환 | ☐ |
| SetMetadata + Reflector로 핸들러별 동작 커스터마이징 | ☐ |
| Interceptor 단위 테스트에서 CallHandler/ExecutionContext 모킹 | ☐ |
| 에러 매핑은 Interceptor(catchError) 또는 Filter 중 하나만 선택 | ☐ |
| 양파 구조(onion model) 이해하고 순서 설계 | ☐ |
NestJS Interceptor는 Guard, Pipe, Filter가 각각 담당하지 못하는 요청/응답 양방향 가공이라는 고유 영역을 가집니다. RxJS Observable 파이프라인을 활용하면 로깅, 캐싱, 변환, 타임아웃, 재시도 등 거의 모든 횡단 관심사를 선언적이고 조합 가능한 형태로 구현할 수 있습니다. 핵심은 각 Interceptor를 단일 책임으로 작게 만들고, 양파 구조의 실행 순서를 의도적으로 설계하는 것입니다.