NestJS Interceptor란
Interceptor는 NestJS 요청 파이프라인에서 핸들러 실행 전후에 로직을 삽입하는 계층이다. RxJS Observable 기반으로 동작하며, 응답 변환, 로깅, 캐싱, 타임아웃, 예외 매핑 등 횡단 관심사를 깔끔하게 처리한다. Guard 이후, Pipe 이전에 실행되며 응답 스트림까지 제어할 수 있다는 점에서 가장 유연한 계층이다.
NestInterceptor 인터페이스의 intercept() 메서드는 ExecutionContext와 CallHandler를 받아 Observable을 반환한다. CallHandler.handle()을 호출해야 실제 핸들러가 실행되며, 호출하지 않으면 요청이 차단된다.
Interceptor 기본 구조
import {
Injectable, NestInterceptor, ExecutionContext, CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap, map } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('Interceptor');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const method = req.method;
const url = req.url;
const now = Date.now();
// before handler
this.logger.log(`▶ ${method} ${url}`);
return next.handle().pipe(
// after handler
tap((data) => {
const duration = Date.now() - now;
this.logger.log(`◀ ${method} ${url} — ${duration}ms`);
}),
);
}
}
Interceptor 등록 방법
// 1. 글로벌 등록 (main.ts) — DI 불가
app.useGlobalInterceptors(new LoggingInterceptor());
// 2. 글로벌 등록 (모듈) — DI 가능 ✅
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
],
})
export class AppModule {}
// 3. 컨트롤러 레벨
@UseInterceptors(LoggingInterceptor)
@Controller('orders')
export class OrderController {}
// 4. 메서드 레벨
@UseInterceptors(CacheInterceptor)
@Get(':id')
async findOne(@Param('id') id: string) {}
실전 패턴 1: 응답 래핑 (Transform)
모든 API 응답을 일관된 구조로 래핑한다. 프론트엔드와의 계약을 표준화하는 가장 흔한 패턴이다.
// 응답 구조: { success: true, data: ..., timestamp: ... }
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
path: string;
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
const req = context.switchToHttp().getRequest();
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
path: req.url,
})),
);
}
}
// 결과:
// GET /users/1
// {
// "success": true,
// "data": { "id": "1", "name": "Alice" },
// "timestamp": "2026-02-25T21:00:00.000Z",
// "path": "/users/1"
// }
실전 패턴 2: 타임아웃
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 timeoutMs: number = 5000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError((err) => {
if (err instanceof TimeoutError) {
return throwError(
() => new RequestTimeoutException(
`요청이 ${this.timeoutMs}ms 내에 완료되지 않았습니다`,
),
);
}
return throwError(() => err);
}),
);
}
}
// 메서드별 다른 타임아웃 적용
@Get('report')
@UseInterceptors(new TimeoutInterceptor(30000)) // 30초
async generateReport() {}
@Get(':id')
@UseInterceptors(new TimeoutInterceptor(3000)) // 3초
async findOne() {}
실전 패턴 3: 캐시 Interceptor
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
// 커스텀 데코레이터: TTL 설정
export const CacheTTL = (seconds: number) =>
SetMetadata('cache-ttl', seconds);
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
private readonly cacheService: CacheService,
private readonly reflector: Reflector,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest();
// GET 요청만 캐싱
if (req.method !== 'GET') {
return next.handle();
}
const ttl = this.reflector.get<number>(
'cache-ttl',
context.getHandler(),
) ?? 60;
const cacheKey = `http:${req.url}`;
const cached = await this.cacheService.get(cacheKey);
if (cached) {
return of(cached); // 캐시 히트 → 핸들러 실행 안 함
}
return next.handle().pipe(
tap(async (data) => {
await this.cacheService.set(cacheKey, data, ttl);
}),
);
}
}
// 사용
@Get('popular')
@CacheTTL(300) // 5분 캐시
async getPopularProducts() {}
실전 패턴 4: 에러 매핑
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
private readonly logger = new Logger('ErrorMapping');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
catchError((error) => {
// 도메인 예외 → HTTP 예외 변환
if (error instanceof DomainException) {
return throwError(
() => new BadRequestException(error.message),
);
}
if (error instanceof EntityNotFoundException) {
return throwError(
() => new NotFoundException(error.message),
);
}
if (error instanceof ConcurrencyException) {
return throwError(
() => new ConflictException('동시 수정 충돌이 발생했습니다'),
);
}
// 예상치 못한 에러 로깅
this.logger.error(
`Unhandled error: ${error.message}`,
error.stack,
);
return throwError(() => error);
}),
);
}
}
실전 패턴 5: 실행 시간 + 메트릭
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const controller = context.getClass().name;
const handler = context.getHandler().name;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
this.metricsService.recordHttpRequest({
method: req.method,
path: req.route?.path || req.url,
controller,
handler,
statusCode: context.switchToHttp().getResponse().statusCode,
duration,
});
}),
catchError((error) => {
const duration = Date.now() - start;
this.metricsService.recordHttpRequest({
method: req.method,
path: req.route?.path || req.url,
controller,
handler,
statusCode: error.status || 500,
duration,
});
return throwError(() => error);
}),
);
}
}
실전 패턴 6: 응답 직렬화 (Exclude 필드)
import { ClassSerializerInterceptor } from '@nestjs/common';
import { Exclude, Expose, Transform } from 'class-transformer';
// Entity에 직렬화 규칙 정의
export class UserEntity {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
email: string;
@Exclude()
password: string; // 응답에서 제외
@Exclude()
deletedAt: Date | null; // 응답에서 제외
@Expose()
@Transform(({ value }) => value?.toISOString())
createdAt: Date;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
// 컨트롤러에서 사용
@UseInterceptors(ClassSerializerInterceptor)
@Controller('users')
export class UserController {
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.userService.findById(id);
return new UserEntity(user);
// password, deletedAt 필드가 자동으로 제거됨
}
}
ExecutionContext 활용
Interceptor는 ExecutionContext를 통해 컨트롤러, 핸들러, 메타데이터에 접근할 수 있다. NestJS Custom Decorators 가이드에서 다룬 SetMetadata와 결합하면 강력한 패턴을 구현할 수 있다.
// Reflector로 메타데이터 읽기
const isPublic = this.reflector.getAllAndOverride<boolean>(
'isPublic',
[context.getHandler(), context.getClass()],
);
// 컨트롤러/핸들러 이름
const controllerName = context.getClass().name;
const handlerName = context.getHandler().name;
// HTTP 컨텍스트
const req = context.switchToHttp().getRequest();
const res = context.switchToHttp().getResponse();
// WebSocket 컨텍스트
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
Interceptor 실행 순서
여러 Interceptor가 적용될 때 바깥에서 안으로 실행되고, 응답은 안에서 바깥으로 전파된다. NestJS Middleware 가이드의 파이프라인 순서와 함께 이해하면 전체 흐름을 파악할 수 있다.
// 등록 순서: [A, B, C]
// 실행 흐름:
// A.before → B.before → C.before
// → Handler 실행 →
// C.after → B.after → A.after
// APP_INTERCEPTOR 등록 순서가 실행 순서를 결정
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }, // 1st (가장 바깥)
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, // 2nd
{ provide: APP_INTERCEPTOR, useClass: TimeoutInterceptor }, // 3rd (가장 안쪽)
],
})
정리: Interceptor 설계 체크리스트
- 응답 래핑: TransformInterceptor로 일관된 API 응답 구조 보장
- 타임아웃: TimeoutInterceptor로 느린 요청 강제 종료
- 캐싱: GET 요청에 대한 응답 캐시 + TTL 메타데이터
- 에러 매핑: 도메인 예외 → HTTP 예외 자동 변환
- 직렬화: ClassSerializerInterceptor + @Exclude로 민감 필드 제거
- 순서 주의: Metrics(바깥) → Transform → Timeout(안쪽) 순서가 일반적
- handle() 필수: next.handle()을 호출하지 않으면 핸들러가 실행되지 않음