junyeokk
Blog
NestJS·2024. 11. 16

NestJS Interceptor

NestJS에서 컨트롤러 메서드가 실행되기 전후로 공통 로직을 끼워 넣고 싶을 때가 있다. 요청 처리 시간을 측정하거나, 응답 데이터를 일괄 변환하거나, 특정 조건에서 캐시된 응답을 바로 돌려주고 싶을 수 있다. 이런 로직을 각 컨트롤러마다 반복해서 넣는 건 유지보수 지옥이다.

미들웨어로도 일부는 가능하지만, 미들웨어는 Express/Fastify 레벨에서 동작하기 때문에 NestJS의 실행 컨텍스트(어떤 컨트롤러의 어떤 메서드가 호출되는지)를 알 수 없다. Guard처럼 요청을 차단할 수도 없고, Pipe처럼 파라미터를 변환하지도 않는다. Interceptor는 이 틈을 메운다. 요청-응답 흐름 전체를 감싸는 래퍼로, 실행 전후 모두에 개입할 수 있는 유일한 메커니즘이다.

NestJS 요청 파이프라인에서의 위치

NestJS는 요청이 들어오면 다음 순서로 처리한다:

Middleware → Guard → Interceptor (before) → Pipe → Controller → Interceptor (after) → Exception Filter

Interceptor가 Guard 다음, Pipe 이전에 실행된다는 점이 중요하다. Guard를 통과한 요청만 Interceptor에 도달하고, Interceptor에서 요청을 변형한 뒤 Pipe로 넘어간다. 그리고 컨트롤러가 응답을 반환한 이후에도 다시 Interceptor가 개입할 수 있다. 이 "양방향" 특성이 미들웨어나 다른 컴포넌트와의 결정적 차이다.

NestInterceptor 인터페이스

Interceptor를 만들려면 NestInterceptor 인터페이스를 구현한다. 핵심 메서드는 intercept() 하나뿐이다.

typescript
Middleware → Guard → Interceptor (before) → Pipe → Controller → Interceptor (after) → Exception Filter

두 개의 인자를 받는다:

  • ExecutionContext: 현재 요청의 실행 컨텍스트. HTTP 요청의 경우 context.switchToHttp()로 Request/Response 객체에 접근하고, context.getHandler()로 호출될 컨트롤러 메서드를, context.getClass()로 컨트롤러 클래스를 참조할 수 있다.
  • CallHandler: handle() 메서드를 가진 객체. 이걸 호출하면 실제 라우트 핸들러(컨트롤러 메서드)가 실행된다. 반환값은 Observable<any>다.

핵심 아이디어는 이것이다: next.handle()을 호출하기 전이 "요청 전", 반환된 Observable에 RxJS 연산자를 붙이면 "요청 후". next.handle()을 아예 호출하지 않으면 컨트롤러 메서드가 실행되지 않는다. 이걸 이용해서 캐시가 있으면 컨트롤러를 건너뛰고 바로 응답을 돌려줄 수도 있다.

실전 패턴 1: 로깅 Interceptor

가장 기본적인 활용. 요청이 들어올 때 로그를 남기고, 응답 후 처리 시간을 측정한다.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class MyInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 요청 전 로직
    console.log('Before handler...');

    return next.handle().pipe(
      // 요청 후 로직 (RxJS 연산자 사용)
    );
  }
}

finalize는 Observable이 완료되거나 에러가 나거나 상관없이 무조건 실행되는 연산자다. 에러가 발생해도 처리 시간은 기록해야 하니까 tap보다 finalize가 적합하다.

민감한 엔드포인트(로그인, 회원가입 등)는 body에 비밀번호가 들어있을 수 있으니 로깅에서 제외하는 게 좋다:

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { Request } from 'express';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const { method, url } = request;
    const startTime = Date.now();

    console.log(JSON.stringify({
      type: 'request',
      method,
      url,
      body: request.body,
    }));

    return next.handle().pipe(
      finalize(() => {
        const elapsed = Date.now() - startTime;
        console.log(`${method} ${url} - ${elapsed}ms`);
      }),
    );
  }
}

실전 패턴 2: 메트릭 수집 Interceptor

Prometheus 같은 모니터링 시스템에 HTTP 메트릭을 보내는 패턴. Counter로 요청 수를, Histogram으로 응답 시간을 추적한다.

typescript
const excludedPaths = ['login', 'register', 'signup'];
const shouldLog = !excludedPaths.some((path) => url.includes(path));

if (shouldLog) {
  console.log(JSON.stringify({ method, url, body: request.body }));
}

req.route?.path를 쓰는 이유가 있다. req.url/users/123처럼 실제 URL이고, req.route.path/users/:id처럼 라우트 패턴이다. 메트릭 라벨에 실제 URL을 쓰면 사용자 ID마다 별도 라벨이 생겨서 카디널리티가 폭발한다. 라우트 패턴을 써야 의미 있는 집계가 된다.

실전 패턴 3: 사용자 주입 Interceptor

Guard처럼 인증을 강제하지는 않지만, 토큰이 있으면 사용자 정보를 꺼내서 request에 붙여주는 패턴. "로그인하면 추가 기능을 제공하지만, 비로그인도 사용 가능한" 엔드포인트에 유용하다.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
import { Request, Response } from 'express';
import { Counter, Histogram } from 'prom-client';

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(
    private readonly requestsTotal: Counter,
    private readonly requestsSuccess: Counter,
    private readonly requestsFail: Counter,
    private readonly requestDuration: Histogram,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest<Request>();
    const res = context.switchToHttp().getResponse<Response>();
    const method = req.method;
    const route = (req.route as { path: string })?.path || req.url;
    const start = Date.now();

    // 메트릭 엔드포인트 자체는 측정에서 제외
    if (route.includes('metrics')) {
      return next.handle();
    }

    return next.handle().pipe(
      finalize(() => {
        const duration = (Date.now() - start) / 1000;
        const labels = { method, route };

        if (res.statusCode >= 500) {
          this.requestsFail.inc(labels);
        } else {
          this.requestsSuccess.inc(labels);
          this.requestDuration.observe(labels, duration);
        }

        this.requestsTotal.inc(labels);
      }),
    );
  }
}

Guard와의 차이가 명확하다. Guard는 인증 실패 시 예외를 던져서 요청을 차단한다. 이 Interceptor는 실패해도 request.user = null로 설정하고 넘어간다. 컨트롤러에서는 request.user가 있으면 개인화된 응답을, 없으면 기본 응답을 돌려주면 된다.

실전 패턴 4: 응답 후 사이드이펙트

컨트롤러가 응답을 반환한 다음에 비동기 작업을 실행하는 패턴. 응답 시간에 영향을 주지 않으면서 부가 작업을 처리할 수 있다.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { Observable } from 'rxjs';

@Injectable()
export class InjectUserInterceptor implements NestInterceptor {
  constructor(
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractToken(request);

    if (token) {
      try {
        const payload = this.jwtService.verify(token, {
          secret: this.configService.get('JWT_SECRET'),
        });
        request.user = payload;
      } catch {
        request.user = null;
      }
    } else {
      request.user = null;
    }

    return next.handle();
  }

  private extractToken(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

tap은 Observable의 값을 변경하지 않고 사이드이펙트만 실행한다. 비동기 작업을 void로 실행하는 이유는, await를 걸면 응답이 지연되기 때문이다. 사용자 활동 기록은 실패해도 사용자 경험에 영향이 없으니 fire-and-forget 방식이 적합하다.

실전 패턴 5: 응답 변환 Interceptor

컨트롤러가 반환한 데이터를 일관된 형태로 감싸는 패턴. 모든 API 응답을 { data: ..., timestamp: ... } 형태로 통일하고 싶을 때 유용하다.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';

@Injectable()
export class TrackActivityInterceptor implements NestInterceptor {
  constructor(
    private readonly activityService: ActivityService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();

    return next.handle().pipe(
      tap(() => {
        // 응답 반환 후 비동기로 실행 (await 안 함)
        void (async () => {
          if (request.user) {
            await this.activityService.recordActivity(request.user.id);
          }
        })();
      }),
    );
  }
}

map 연산자로 응답 데이터를 변환한다. 컨트롤러에서는 순수 데이터만 반환하면 Interceptor가 알아서 래핑해준다. 관심사 분리가 깔끔하게 된다.

실전 패턴 6: 캐시 Interceptor

캐시가 있으면 컨트롤러를 아예 실행하지 않고 바로 응답하는 패턴.

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, any> {
  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
    return next.handle().pipe(
      map((data) => ({
        data,
        statusCode: context.switchToHttp().getResponse().statusCode,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

of()로 값을 즉시 Observable로 감싸서 반환하면 next.handle()이 호출되지 않는다. 이게 Interceptor만의 강력한 능력이다. 미들웨어에서는 이렇게 라우트 핸들러를 건너뛸 수 없다.

자주 쓰는 RxJS 연산자 정리

Interceptor에서 핵심이 되는 RxJS 연산자들을 정리하면:

연산자용도에러 시 실행
tap사이드이펙트 (값 변경 없음)
map응답 데이터 변환
finalize정리 작업 (완료/에러 무관)
catchError에러를 잡아서 다른 Observable로 대체
timeout일정 시간 초과 시 에러 발생-

tap vs finalize의 선택 기준은 명확하다. 에러가 나도 실행해야 하는 로직이면 finalize, 성공한 경우에만 실행할 로직이면 tap이다. 로깅이나 메트릭은 에러 케이스도 기록해야 하니 finalize가 맞고, 캐시 저장은 성공한 응답만 캐시해야 하니 tap이 맞다.

Interceptor 등록 방법

세 가지 범위로 등록할 수 있다.

1. 메서드 레벨

typescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  private cache = new Map<string, any>();

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const key = request.url;

    // 캐시 히트: 컨트롤러 실행 안 함
    if (this.cache.has(key)) {
      return of(this.cache.get(key));
    }

    // 캐시 미스: 컨트롤러 실행 후 결과 캐시
    return next.handle().pipe(
      tap((response) => {
        this.cache.set(key, response);
      }),
    );
  }
}

2. 컨트롤러 레벨

typescript
@UseInterceptors(LoggingInterceptor)
@Get('items')
findAll() {
  return this.itemService.findAll();
}

3. 글로벌 레벨

typescript
@UseInterceptors(LoggingInterceptor)
@Controller('items')
export class ItemController {
  // 이 컨트롤러의 모든 메서드에 적용
}

글로벌 등록 시 APP_INTERCEPTOR 토큰을 쓰는 방식을 권장한다. main.ts에서 new로 생성하면 의존성 주입을 받을 수 없지만, 모듈 프로바이더로 등록하면 다른 서비스를 주입받을 수 있다. 메트릭 Interceptor처럼 외부 의존성이 필요한 경우 반드시 이 방식을 써야 한다.

여러 Interceptor의 실행 순서

글로벌 → 컨트롤러 → 메서드 순서로 실행된다. 그리고 같은 레벨에 여러 개를 등록하면 등록 순서대로 실행된다.

typescript
// main.ts에서 직접 등록 (DI 불가)
app.useGlobalInterceptors(new LoggingInterceptor());

// 또는 모듈에서 등록 (DI 가능 — 권장)
@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: LoggingInterceptor,
    },
  ],
})
export class AppModule {}

요청 시에는 First → Second 순으로 실행되고, 응답 시에는 역순으로 Second → First 순으로 실행된다. 양파 껍질(Onion) 모델과 같다:

요청 → [First before] → [Second before] → Controller → [Second after] → [First after] → 응답

Interceptor vs 미들웨어 vs Guard vs Pipe

특성미들웨어GuardInterceptorPipe
실행 시점가장 먼저미들웨어 후Guard 후Interceptor 후
ExecutionContext 접근
응답 변환 가능
요청 차단 가능✅ (next 안 부름)✅ (false 반환)✅ (handle 안 부름)✅ (예외 던짐)
응답 후 로직
DI 지원제한적

Interceptor만의 고유 능력은 두 가지다: 응답 데이터를 변환할 수 있고, 응답 후에도 로직을 실행할 수 있다. 이 두 가지가 필요하면 Interceptor가 정답이다.

주의할 점

1. 비동기 사이드이펙트의 에러 처리

fire-and-forget으로 비동기 작업을 실행할 때, 에러가 발생하면 unhandled promise rejection이 된다. 프로덕션에서는 반드시 try-catch로 감싸야 한다:

typescript
@UseInterceptors(FirstInterceptor, SecondInterceptor)
@Controller('items')
export class ItemController {}

2. SSE/WebSocket과의 호환성

Interceptor는 기본적으로 HTTP 요청-응답 사이클을 전제한다. SSE처럼 스트리밍 응답이나 WebSocket에서는 switchToHttp()가 의미 없을 수 있다. context.getType()으로 요청 타입을 확인하고 분기하는 게 안전하다:

typescript
요청 → [First before] → [Second before] → Controller → [Second after] → [First after] → 응답

3. 순환 의존성

글로벌 Interceptor에서 많은 서비스를 주입받다 보면 순환 의존성이 생길 수 있다. 이때는 @Inject(forwardRef(() => SomeService)) 또는 모듈 구조를 재설계하는 게 낫다.

정리

  • Guard 이후, Pipe 이전에 실행되며 응답 후에도 개입할 수 있는 유일한 메커니즘이다
  • next.handle() 호출 전이 요청 전, 반환된 Observable에 RxJS 연산자를 붙이면 요청 후이며, 호출 자체를 생략하면 컨트롤러를 건너뛸 수 있다
  • 로깅/메트릭은 finalize, 캐시 저장은 tap, 응답 래핑은 map으로 용도에 맞는 연산자를 선택한다

관련 문서