NestJS Middleware 실전 패턴

NestJS Middleware란

NestJS Middleware는 라우트 핸들러 이전에 실행되는 함수로, Express/Fastify 미들웨어와 동일한 개념이다. Request/Response 객체에 접근하고, next()를 호출하여 다음 미들웨어나 핸들러로 제어를 넘긴다. Guard, Interceptor, Pipe보다 먼저 실행되므로 요청 전처리의 가장 앞단에 위치한다.

로깅, 인증 토큰 파싱, 요청 본문 변환, CORS 커스터마이징, 요청 추적(tracing) 등 컨트롤러에 도달하기 전에 처리해야 할 횡단 관심사에 적합하다.

NestJS 요청 파이프라인 순서

순서 계층 역할
1 Middleware 요청 전처리, 로깅, 헤더 파싱
2 Guard 인증/인가 (CanActivate)
3 Interceptor (before) 요청 변환, 타이머 시작
4 Pipe 유효성 검증, 타입 변환
5 Handler 컨트롤러 메서드 실행
6 Interceptor (after) 응답 변환, 로깅
7 Exception Filter 예외 처리 (에러 시)

클래스 기반 Middleware

NestJS의 DI 컨테이너를 활용하려면 클래스 기반 미들웨어를 사용한다. NestMiddleware 인터페이스를 구현하고, 생성자에서 서비스를 주입받을 수 있다.

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

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

  use(req: Request, res: Response, next: NextFunction) {
    // 요청에 고유 ID 부여 (분산 추적용)
    const requestId = req.headers['x-request-id'] as string || uuidv4();
    req['requestId'] = requestId;
    res.setHeader('X-Request-Id', requestId);

    // 요청 시작 시간 기록
    const startTime = Date.now();

    // 응답 완료 시 로깅
    res.on('finish', () => {
      const duration = Date.now() - startTime;
      this.logger.log(
        `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms [${requestId}]`,
      );
    });

    next();
  }
}

함수형 Middleware

DI가 필요 없는 단순한 미들웨어는 함수로 정의할 수 있다.

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

// JSON 요청 본문의 문자열 필드 trim
export function TrimBodyMiddleware(
  req: Request, res: Response, next: NextFunction,
) {
  if (req.body && typeof req.body === 'object') {
    trimStrings(req.body);
  }
  next();
}

function trimStrings(obj: Record<string, any>) {
  for (const key of Object.keys(obj)) {
    if (typeof obj[key] === 'string') {
      obj[key] = obj[key].trim();
    } else if (typeof obj[key] === 'object' && obj[key] !== null) {
      trimStrings(obj[key]);
    }
  }
}

미들웨어 등록: Module.configure()

미들웨어는 모듈의 configure() 메서드에서 등록한다. 라우트, HTTP 메서드, 와일드카드, 제외 패턴을 세밀하게 지정할 수 있다.

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

@Module({
  controllers: [UserController, ProductController, HealthController],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    // 1. 모든 라우트에 적용
    consumer
      .apply(RequestIdMiddleware)
      .forRoutes('*');

    // 2. 특정 컨트롤러에만 적용
    consumer
      .apply(AuthTokenMiddleware)
      .forRoutes(UserController, ProductController);

    // 3. 특정 메서드 + 경로에만 적용
    consumer
      .apply(TrimBodyMiddleware)
      .forRoutes(
        { path: 'users', method: RequestMethod.POST },
        { path: 'users/:id', method: RequestMethod.PATCH },
        { path: 'products', method: RequestMethod.POST },
      );

    // 4. 제외 패턴
    consumer
      .apply(RateLimitMiddleware)
      .exclude(
        { path: 'health', method: RequestMethod.GET },
        { path: 'metrics', method: RequestMethod.GET },
      )
      .forRoutes('*');

    // 5. 여러 미들웨어 체이닝 (순서대로 실행)
    consumer
      .apply(CorsMiddleware, HelmetMiddleware, CompressionMiddleware)
      .forRoutes('*');
  }
}

실전 패턴 1: 요청 로깅 + 추적

운영 환경에서 가장 필수적인 미들웨어다. 모든 HTTP 요청의 메서드, 경로, 상태 코드, 응답 시간, 요청 ID를 기록한다.

import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { AsyncLocalStorage } from 'async_hooks';

// 요청 컨텍스트를 AsyncLocalStorage로 전파
export const requestContext = new AsyncLocalStorage<{
  requestId: string;
  startTime: number;
  userId?: string;
}>();

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

  use(req: Request, res: Response, next: NextFunction) {
    const requestId = (req.headers['x-request-id'] as string) || uuidv4();
    const context = {
      requestId,
      startTime: Date.now(),
      userId: req.headers['x-user-id'] as string,
    };

    // 헤더에 요청 ID 설정
    req['requestId'] = requestId;
    res.setHeader('X-Request-Id', requestId);

    // 응답 완료 시 구조화된 로그
    res.on('finish', () => {
      const duration = Date.now() - context.startTime;
      const logData = {
        method: req.method,
        path: req.originalUrl,
        statusCode: res.statusCode,
        duration,
        requestId,
        userId: context.userId,
        userAgent: req.headers['user-agent'],
        ip: req.ip,
        contentLength: res.getHeader('content-length'),
      };

      if (res.statusCode >= 500) {
        this.logger.error(JSON.stringify(logData));
      } else if (res.statusCode >= 400) {
        this.logger.warn(JSON.stringify(logData));
      } else {
        this.logger.log(JSON.stringify(logData));
      }
    });

    // AsyncLocalStorage로 컨텍스트 전파
    requestContext.run(context, () => next());
  }
}

// 서비스에서 요청 ID 접근
@Injectable()
export class OrderService {
  private readonly logger = new Logger(OrderService.name);

  async createOrder(dto: CreateOrderDto) {
    const ctx = requestContext.getStore();
    this.logger.log(
      `Creating order [requestId=${ctx?.requestId}]`,
    );
    // ...
  }
}

실전 패턴 2: API Key 인증 미들웨어

import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyMiddleware implements NestMiddleware {
  constructor(private readonly config: ConfigService) {}

  use(req: Request, res: Response, next: NextFunction) {
    const apiKey = req.headers['x-api-key'] as string;

    if (!apiKey) {
      throw new UnauthorizedException('API key is required');
    }

    const validKeys = this.config.get<string[]>('api.keys');
    if (!validKeys.includes(apiKey)) {
      throw new UnauthorizedException('Invalid API key');
    }

    // API key에서 클라이언트 정보 추출
    req['apiClient'] = this.resolveClient(apiKey);
    next();
  }

  private resolveClient(key: string) {
    // 키별 클라이언트 매핑 (실제로는 DB나 캐시에서 조회)
    const clients = this.config.get('api.clientMap');
    return clients[key] || { name: 'unknown', tier: 'free' };
  }
}

실전 패턴 3: 요청 본문 크기 제한

import { Injectable, NestMiddleware, PayloadTooLargeException } from '@nestjs/common';

@Injectable()
export class BodySizeLimitMiddleware implements NestMiddleware {
  private readonly maxSize: number;

  constructor() {
    this.maxSize = 1024 * 1024; // 1MB
  }

  use(req: Request, res: Response, next: NextFunction) {
    const contentLength = parseInt(
      req.headers['content-length'] || '0', 10,
    );

    if (contentLength > this.maxSize) {
      throw new PayloadTooLargeException(
        `Body exceeds ${this.maxSize} bytes limit`,
      );
    }

    // 스트리밍 본문도 체크
    let received = 0;
    const originalOn = req.on.bind(req);
    req.on = (event: string, listener: any) => {
      if (event === 'data') {
        const wrappedListener = (chunk: Buffer) => {
          received += chunk.length;
          if (received > this.maxSize) {
            throw new PayloadTooLargeException(
              `Body exceeds ${this.maxSize} bytes limit`,
            );
          }
          listener(chunk);
        };
        return originalOn(event, wrappedListener);
      }
      return originalOn(event, listener);
    };

    next();
  }
}

글로벌 Middleware vs 모듈 Middleware

글로벌 미들웨어는 main.ts에서 Express/Fastify 레벨로 등록한다. NestJS DI를 사용할 수 없지만, 모든 요청에 가장 먼저 적용된다.

// main.ts — 글로벌 (DI 불가)
import helmet from 'helmet';
import compression from 'compression';

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

  // Express 미들웨어 직접 등록
  app.use(helmet());
  app.use(compression());

  // 커스텀 함수형 미들웨어
  app.use((req, res, next) => {
    res.setHeader('X-Powered-By', 'NestJS');
    next();
  });

  await app.listen(3000);
}

// Module.configure() — 모듈 레벨 (DI 가능)
// → ConfigService, Repository 등 주입 가능
// → 라우트별 세밀한 적용/제외 가능
구분 글로벌 (main.ts) 모듈 (configure)
DI 지원
라우트 제어 전체 적용 세밀한 제어
실행 순서 가장 먼저 글로벌 이후
적합한 용도 helmet, cors, compression 로깅, 인증, 추적

Middleware vs Guard vs Interceptor 선택 기준

NestJS의 요청 처리 계층을 올바르게 선택하는 것이 핵심이다. NestJS Custom Decorators 가이드에서 다룬 메타데이터 기반 패턴과 결합하면 더 강력한 구조를 만들 수 있다.

질문 선택
ExecutionContext (메타데이터) 접근 필요? Guard 또는 Interceptor
Request/Response 원본 조작 필요? Middleware
true/false로 접근 허용/거부? Guard
응답 변환이나 에러 매핑 필요? Interceptor
Express 서드파티 미들웨어 통합? Middleware (main.ts)

테스트 전략

import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';

describe('TracingMiddleware (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = module.createNestApplication();
    await app.init();
  });

  it('응답에 X-Request-Id 헤더가 포함됨', async () => {
    const res = await request(app.getHttpServer())
      .get('/health')
      .expect(200);

    expect(res.headers['x-request-id']).toBeDefined();
    expect(res.headers['x-request-id']).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-/,
    );
  });

  it('클라이언트가 보낸 X-Request-Id를 유지함', async () => {
    const customId = 'test-trace-12345';
    const res = await request(app.getHttpServer())
      .get('/health')
      .set('X-Request-Id', customId)
      .expect(200);

    expect(res.headers['x-request-id']).toBe(customId);
  });

  it('API Key 없으면 401 반환', async () => {
    await request(app.getHttpServer())
      .get('/api/users')
      .expect(401);
  });
});

정리: Middleware 설계 체크리스트

  • DI 필요 여부: 서비스 주입이 필요하면 클래스 기반, 아니면 함수형
  • 등록 위치: 글로벌(main.ts)은 DI 불가, 모듈(configure)은 라우트 제어 가능
  • 실행 순서: 글로벌 → 모듈 순. apply() 체이닝 순서가 실행 순서
  • next() 필수: 호출하지 않으면 요청이 중단됨 (의도적 차단 제외)
  • AsyncLocalStorage: 요청 컨텍스트를 서비스 계층까지 전파할 때 활용
  • 계층 선택: 원본 req/res 조작 → Middleware, 메타데이터 기반 → Guard/Interceptor

Middleware는 NestJS 요청 파이프라인의 첫 관문이다. NestJS Exception Filter 가이드와 함께 이해하면 요청의 시작부터 에러 처리까지 전체 흐름을 설계할 수 있다.

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