NestJS Interceptor 실전 심화

NestJS Interceptor란?

NestJS Interceptor는 요청/응답 파이프라인에서 핸들러 실행 전후에 로직을 삽입하는 AOP(Aspect-Oriented Programming) 구현체입니다. NestInterceptor 인터페이스를 구현하며, RxJS Observable을 기반으로 응답 스트림을 변환·가공할 수 있습니다. Guard가 “접근 가능한가”를 판단하고, Pipe가 “데이터가 유효한가”를 검증한다면, Interceptor는 “요청/응답을 어떻게 가공할 것인가”를 담당합니다.

기본 구조와 ExecutionContext

Interceptor의 핵심은 intercept() 메서드와 CallHandler입니다.

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } 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) => {
        console.log(`[RES] ${method} ${url} - ${Date.now() - now}ms`);
      }),
    );
  }
}

next.handle()을 호출하기 전이 Pre-처리, .pipe() 안이 Post-처리입니다. next.handle()을 호출하지 않으면 핸들러가 실행되지 않아, 요청을 완전히 가로챌 수도 있습니다.

응답 변환: Response Mapping

API 응답을 일관된 포맷으로 감싸는 가장 흔한 패턴입니다.

// 표준 응답 래퍼
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(),
      })),
    );
  }
}

// 적용 전: { id: 1, name: "John" }
// 적용 후: { success: true, data: { id: 1, name: "John" }, timestamp: "..." }

이 패턴은 모든 컨트롤러에서 일일이 래핑하는 보일러플레이트를 제거합니다. 글로벌로 적용하면 전체 API가 일관된 응답 구조를 갖게 됩니다.

캐싱 Interceptor

핸들러 실행을 건너뛰고 캐시된 값을 반환하는 패턴입니다.

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(private readonly cacheManager: Cache) {}

  async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest();
    const key = `cache:${request.method}:${request.url}`;

    // 캐시 히트 → 핸들러 실행 없이 즉시 반환
    const cached = await this.cacheManager.get(key);
    if (cached) {
      return of(cached);  // next.handle() 호출 안 함!
    }

    // 캐시 미스 → 핸들러 실행 후 결과 캐싱
    return next.handle().pipe(
      tap((response) => {
        this.cacheManager.set(key, response, 60_000); // 60초 TTL
      }),
    );
  }
}

of(cached)로 즉시 Observable을 반환하면 next.handle()이 호출되지 않아 컨트롤러 로직이 완전히 스킵됩니다. 이것이 Interceptor가 Pipe나 Guard와 다른 핵심 능력입니다.

타임아웃과 에러 처리

RxJS 연산자를 활용한 타임아웃과 에러 핸들링입니다.

import { timeout, catchError, retry } from 'rxjs';

// 타임아웃 Interceptor
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(private readonly ms: number = 5000) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(this.ms),
      catchError((err) => {
        if (err.name === 'TimeoutError') {
          throw new RequestTimeoutException('요청 시간이 초과되었습니다');
        }
        throw err;
      }),
    );
  }
}

// 재시도 Interceptor
@Injectable()
export class RetryInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      retry({
        count: 3,
        delay: (error, retryCount) => {
          console.log(`재시도 ${retryCount}/3: ${error.message}`);
          return timer(retryCount * 1000); // 1초, 2초, 3초 지수 백오프
        },
      }),
    );
  }
}

RxJS의 timeout, retry, catchError 연산자를 조합하면 복잡한 에러 복구 로직을 선언적으로 구현할 수 있습니다. 외부 API 호출이 많은 서비스에서 특히 유용합니다.

메타데이터 기반 Interceptor

Reflector로 커스텀 데코레이터의 메타데이터를 읽어 동적으로 동작을 변경합니다.

// 커스텀 데코레이터
export const CacheTTL = (seconds: number) => SetMetadata('cache_ttl', seconds);
export const NoCache = () => SetMetadata('no_cache', true);

@Injectable()
export class SmartCacheInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly cache: Cache,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 메타데이터 읽기
    const noCache = this.reflector.get<boolean>('no_cache', context.getHandler());
    if (noCache) return next.handle();

    const ttl = this.reflector.getAllAndOverride<number>('cache_ttl', [
      context.getHandler(),
      context.getClass(),
    ]) ?? 60;

    const key = this.buildKey(context);
    // ... 캐시 로직 (ttl 활용)
  }
}

// 컨트롤러에서 사용
@Controller('products')
@CacheTTL(300) // 클래스 레벨: 5분
export class ProductController {

  @Get()
  findAll() { ... } // 5분 캐시

  @Get(':id')
  @CacheTTL(60) // 메서드 레벨 오버라이드: 1분
  findOne() { ... }

  @Post()
  @NoCache() // 캐시 제외
  create() { ... }
}

getAllAndOverride는 메서드 → 클래스 순서로 메타데이터를 찾아 첫 번째 값을 반환합니다. 이 패턴은 NestJS ExecutionContext에서 다루는 Reflector 메커니즘의 실전 활용입니다.

파일 직렬화: Exclude와 Serialize

class-transformer와 연동하여 응답에서 민감한 필드를 자동 제거합니다.

import { ClassSerializerInterceptor } from '@nestjs/common';

// 글로벌 적용 (main.ts)
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

// Entity에서 직렬화 제어
import { Exclude, Expose, Transform } from 'class-transformer';

export class UserEntity {
  id: number;
  name: string;

  @Exclude()
  password: string;  // 응답에서 자동 제거

  @Exclude()
  internalNote: string;

  @Expose({ groups: ['admin'] })
  role: string;  // admin 그룹에서만 노출

  @Transform(({ value }) => value.toISOString())
  createdAt: Date;  // 포맷 변환

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial);
  }
}

// 컨트롤러
@Get(':id')
@SerializeOptions({ groups: ['admin'] }) // admin 그룹 활성화
findOne(@Param('id') id: string) {
  return new UserEntity(this.userService.findOne(id));
}

ClassSerializerInterceptor는 NestJS 빌트인으로, @Exclude()가 붙은 필드를 응답에서 자동 제거합니다. password 같은 민감 정보가 실수로 노출되는 것을 방지하는 보안 레이어입니다.

적용 범위와 실행 순서

// 1. 글로벌
app.useGlobalInterceptors(new LoggingInterceptor());

// 2. 모듈 (DI 활용 가능)
@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})

// 3. 컨트롤러
@UseInterceptors(CacheInterceptor)
@Controller('products')

// 4. 메서드
@UseInterceptors(new TimeoutInterceptor(10000))
@Get(':id')

실행 순서: 글로벌 → 컨트롤러 → 메서드 (Pre-처리). Post-처리는 역순입니다. 여러 Interceptor가 있을 때 next.handle()의 호출 체인이 양파 껍질처럼 동작합니다. NestJS Middleware가 가장 먼저 실행되고, Guard → Interceptor → Pipe → Handler 순서로 요청이 흐릅니다.

실전 베스트 프랙티스

패턴 용도 적용 범위
Response Transform 일관된 API 응답 포맷 글로벌
Logging 요청/응답 로깅, 소요 시간 글로벌
Serialization 민감 필드 제거 글로벌
Cache 응답 캐싱 컨트롤러/메서드
Timeout 요청 타임아웃 제어 글로벌/메서드
Retry 외부 호출 재시도 메서드

정리

NestJS Interceptor는 RxJS Observable 기반으로 요청/응답 스트림을 자유롭게 제어하는 강력한 AOP 도구입니다. 응답 변환, 캐싱, 타임아웃, 직렬화 등 횡단 관심사를 핸들러 코드에서 분리하여 재사용 가능한 모듈로 만들 수 있습니다. Reflector와 커스텀 데코레이터를 결합하면 선언적이고 유연한 동작 제어가 가능합니다. 글로벌에는 로깅·변환·직렬화를, 메서드 단위로는 캐시·타임아웃을 적용하는 것이 실무 표준입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux