NestJS Interceptor 6가지 패턴

NestJS Interceptor란

Interceptor는 NestJS 요청 파이프라인에서 핸들러 실행 전후에 로직을 삽입하는 계층이다. RxJS Observable 기반으로 동작하며, 응답 변환, 로깅, 캐싱, 타임아웃, 예외 매핑 등 횡단 관심사를 깔끔하게 처리한다. Guard 이후, Pipe 이전에 실행되며 응답 스트림까지 제어할 수 있다는 점에서 가장 유연한 계층이다.

NestInterceptor 인터페이스의 intercept() 메서드는 ExecutionContextCallHandler를 받아 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()을 호출하지 않으면 핸들러가 실행되지 않음
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux