NestJS Middleware 실전 설계

NestJS Middleware란?

NestJS의 Middleware는 라우트 핸들러 실행 에 요청/응답 객체에 접근하는 함수다. Express/Fastify 미들웨어와 동일한 개념이며, NestJS 요청 라이프사이클에서 가장 먼저 실행된다. Guard, Interceptor, Pipe보다 앞서 동작하므로, 인증 토큰 파싱, 요청 로깅, CORS, 요청 본문 변환 등 저수준 전처리에 적합하다.

요청 라이프사이클에서의 위치

순서 단계 특징
1 Middleware req/res 직접 접근, next() 호출
2 Guard ExecutionContext, 인가 결정
3 Interceptor (Before) RxJS Observable, AOP
4 Pipe 유효성 검증, 변환
5 Handler 컨트롤러 메서드

Middleware는 Express/Fastify의 req, res, next에 직접 접근하므로, ExecutionContext가 필요 없는 범용적인 전처리에 사용한다.

클래스 Middleware: DI 지원

NestJS의 DI 컨테이너를 활용할 수 있는 클래스 기반 미들웨어:

import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class RequestLoggerMiddleware implements NestMiddleware {
  private readonly logger = new Logger('HTTP');

  use(req: Request, res: Response, next: NextFunction) {
    const { method, originalUrl, ip } = req;
    const userAgent = req.get('user-agent') || '';
    const start = Date.now();

    res.on('finish', () => {
      const { statusCode } = res;
      const duration = Date.now() - start;
      this.logger.log(
        `${method} ${originalUrl} ${statusCode} ${duration}ms - ${ip} ${userAgent}`,
      );
    });

    next();
  }
}

res.on('finish')를 활용하면 응답 완료 후 상태 코드와 처리 시간을 기록할 수 있다. @Injectable() 데코레이터로 다른 서비스를 주입받을 수 있다는 것이 함수형 미들웨어와의 핵심 차이다.

함수형 Middleware: 간단한 처리

DI가 필요 없는 간단한 미들웨어는 함수로 작성한다:

import { Request, Response, NextFunction } from 'express';

// 요청 ID 부여
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
  const requestId = req.headers['x-request-id'] as string
    || crypto.randomUUID();
  req['requestId'] = requestId;
  res.setHeader('X-Request-Id', requestId);
  next();
}

// JSON 응답 시간 헤더
export function responseTimeMiddleware(req: Request, res: Response, next: NextFunction) {
  const start = Date.now();
  res.on('finish', () => {
    res.setHeader('X-Response-Time', `${Date.now() - start}ms`);
  });
  next();
}

Middleware 등록: Module의 configure

미들웨어는 모듈의 configure 메서드에서 등록한다:

import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common';

@Module({ ... })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      // 모든 라우트에 적용
      .apply(RequestLoggerMiddleware, requestIdMiddleware)
      .forRoutes('*')

      // 특정 경로+메서드에만 적용
      .apply(AuthTokenMiddleware)
      .exclude(
        { path: 'health', method: RequestMethod.GET },
        { path: 'auth/login', method: RequestMethod.POST },
      )
      .forRoutes('*')

      // 특정 컨트롤러에만 적용
      .apply(AdminAuditMiddleware)
      .forRoutes(AdminController);
  }
}

apply()에 여러 미들웨어를 전달하면 순서대로 실행된다. exclude()로 특정 경로를 제외할 수 있고, forRoutes()에 컨트롤러 클래스를 넘길 수도 있다.

글로벌 Middleware: main.ts

모든 라우트에 무조건 적용할 미들웨어는 main.ts에서 등록한다:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 글로벌 미들웨어 (함수형만 가능 — DI 없음)
  app.use(requestIdMiddleware);
  app.use(helmet());
  app.use(compression());

  // CORS 설정
  app.enableCors({
    origin: ['https://app.example.com'],
    credentials: true,
  });

  await app.listen(3000);
}
bootstrap();

주의: app.use()로 등록하는 글로벌 미들웨어는 DI를 사용할 수 없다. DI가 필요한 글로벌 미들웨어는 AppModule.configure()에서 forRoutes('*')로 등록해야 한다.

실전 패턴 1: 인증 토큰 파싱

Guard에서 인가를 처리하기 전에, Middleware에서 토큰을 파싱해 req.user에 세팅하는 패턴:

@Injectable()
export class AuthTokenMiddleware implements NestMiddleware {
  constructor(private readonly jwtService: JwtService) {}

  use(req: Request, res: Response, next: NextFunction) {
    const token = this.extractToken(req);
    if (token) {
      try {
        const payload = this.jwtService.verify(token);
        req['user'] = payload;
      } catch {
        // 토큰 검증 실패 → req.user는 undefined
        // Guard에서 인가 결정을 내림
      }
    }
    next(); // 토큰이 없어도 다음으로 진행 (Guard가 처리)
  }

  private extractToken(req: Request): string | null {
    const auth = req.headers.authorization;
    if (auth?.startsWith('Bearer ')) {
      return auth.slice(7);
    }
    return req.cookies?.['access_token'] || null;
  }
}

Middleware는 토큰 파싱만 담당하고, 인가 결정NestJS Guard에서 처리한다. 관심사를 분리하면 Public API와 Protected API를 유연하게 처리할 수 있다.

실전 패턴 2: 요청 본문 로깅 + 감사

@Injectable()
export class AuditMiddleware implements NestMiddleware {
  constructor(private readonly auditService: AuditService) {}

  use(req: Request, res: Response, next: NextFunction) {
    // 읽기 요청은 건너뜀
    if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
      return next();
    }

    const auditData = {
      method: req.method,
      path: req.originalUrl,
      userId: req['user']?.sub,
      ip: req.ip,
      body: this.sanitizeBody(req.body),
      timestamp: new Date(),
    };

    // 비동기로 감사 로그 저장 (요청 블로킹 없음)
    this.auditService.log(auditData).catch(() => {});

    next();
  }

  private sanitizeBody(body: any): any {
    if (!body) return null;
    const sanitized = { ...body };
    // 민감 필드 마스킹
    const sensitiveFields = ['password', 'token', 'secret', 'creditCard'];
    for (const field of sensitiveFields) {
      if (sanitized[field]) sanitized[field] = '***';
    }
    return sanitized;
  }
}

실전 패턴 3: Rate Limiting

@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
  private readonly store = new Map<string, { count: number; resetAt: number }>();

  constructor(
    @Inject('RATE_LIMIT_OPTIONS')
    private readonly options: { windowMs: number; max: number },
  ) {}

  use(req: Request, res: Response, next: NextFunction) {
    const key = req.ip || 'unknown';
    const now = Date.now();
    const record = this.store.get(key);

    if (!record || now > record.resetAt) {
      this.store.set(key, { count: 1, resetAt: now + this.options.windowMs });
      return next();
    }

    if (record.count >= this.options.max) {
      res.status(429).json({
        statusCode: 429,
        message: 'Too Many Requests',
        retryAfter: Math.ceil((record.resetAt - now) / 1000),
      });
      return;
    }

    record.count++;
    next();
  }
}

Middleware vs Guard vs Interceptor

비교 Middleware Guard Interceptor
접근 대상 req, res, next ExecutionContext ExecutionContext + Observable
핸들러 정보 ❌ 모름 ✅ Reflector 사용 가능 ✅ Reflector 사용 가능
응답 변환 res 직접 조작 ✅ RxJS map/tap
적용 범위 경로 기반 컨트롤러/핸들러 기반 컨트롤러/핸들러 기반
적합한 용도 요청 전처리, 로깅 인가 결정 응답 변환, 캐싱

Middleware는 어떤 핸들러가 실행될지 모른다는 점이 핵심 차이다. 커스텀 데코레이터(@Public(), @Roles())에 따라 동작을 바꿔야 하는 로직은 Guard나 Interceptor에서 처리해야 한다.

정리

용도 권장 위치
요청 ID, 헤더 추가 함수형 Middleware (main.ts)
토큰 파싱, req.user 세팅 클래스 Middleware (DI)
HTTP 로깅, 감사 클래스 Middleware
Rate Limiting 클래스 Middleware
CORS, Helmet, Compression app.use() (main.ts)
인가, 역할 기반 접근 Guard (Middleware ❌)

Middleware는 NestJS 요청 파이프라인의 첫 번째 관문이다. Express/Fastify 레벨의 저수준 처리에 집중하고, NestJS의 데코레이터 메타데이터가 필요한 로직은 Guard와 Interceptor에 위임하는 것이 올바른 설계다.

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