icon
2장 : NestJS 핵심 개념 심화

미들웨어, 인터셉터, 파이프, 가드


NestJS 애플리케이션은 클라이언트로부터 요청을 받아 응답을 보내기까지 다양한 단계를 거칩니다. 이 과정에서 요청 데이터를 변환하거나, 유효성을 검사하거나, 권한을 확인하는 등 여러 부가적인 작업들이 수행됩니다. NestJS는 이러한 작업들을 효율적으로 처리할 수 있도록 미들웨어(Middleware), 인터셉터(Interceptor), 파이프(Pipe), 가드(Guard) 라는 강력한 기능을 제공합니다. 이들은 마치 공장의 컨베이어 벨트처럼 순서대로 동작하며 요청 처리 파이프라인을 구성합니다.

이 절에서는 각 요소가 무엇이며, 어떤 역할을 수행하고, 언제 사용해야 하는지 자세히 알아보겠습니다.


미들웨어: Express.js와의 연결 고리

NestJS는 내부적으로 강력한 HTTP 서버 프레임워크인 Express.js를 사용합니다(기본값). 그리고 미들웨어는 바로 이 Express.js의 미들웨어 개념을 NestJS에서도 사용할 수 있도록 제공하는 기능입니다. 미들웨어는 HTTP 요청이 라우트 핸들러(컨트롤러 메서드)에 도달하기 전 또는 후에 실행되는 함수입니다.

주요 특징 및 사용 사례

  • 가장 먼저 실행: 요청 파이프라인에서 가장 먼저 실행되는 단계 중 하나입니다.
  • 요청 객체 접근: req, res, next 객체에 직접 접근하여 요청 헤더, 바디, 응답 등을 조작할 수 있습니다.
  • 인증 및 로깅: 모든 요청에 대해 공통적으로 적용되는 로깅, 인증/인가(세션 기반), CORS 처리 등에 적합합니다.

작성 및 적용 방법

미들웨어는 클래스 기반 또는 함수 기반으로 작성할 수 있습니다. 클래스 기반 미들웨어를 사용하려면 NestMiddleware 인터페이스를 구현해야 합니다.

src/common/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log(`Request... ${req.method} ${req.originalUrl}`);
    next(); // 다음 미들웨어 또는 라우트 핸들러로 제어를 넘깁니다.
  }
}

미들웨어는 모듈에서 .configure() 메서드를 사용하여 적용합니다.

src/app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { LoggerMiddleware } from './common/middleware/logger.middleware';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware) // LoggerMiddleware를 적용합니다.
      .forRoutes('*'); // 모든 경로에 적용합니다. 특정 경로만 지정할 수도 있습니다.
      // .forRoutes({ path: 'cats', method: RequestMethod.GET }); // 특정 경로와 메서드에 적용
  }
}

가드: 권한 및 인증 관리

가드는 특정 라우트 핸들러가 실행되기 전에 요청을 가로채서 특정 조건(주로 권한이나 인증 정보)을 만족하는지 확인하는 역할을 합니다. 마치 클럽 입구에서 신분증을 검사하는 경비원과 같습니다. 조건이 만족되면 요청은 다음 단계로 진행되지만, 그렇지 않으면 즉시 HTTP 응답(예: 401 Unauthorized, 403 Forbidden)을 반환합니다.

주요 특징 및 사용 사례

  • 실행 시점: 미들웨어보다 늦게, 하지만 인터셉터와 파이프보다 먼저 실행됩니다.
  • 권한 부여: 사용자 역할 기반 접근 제어(RBAC), JWT 토큰 유효성 검증 등 인증(Authentication)인가(Authorization) 로직에 최적화되어 있습니다.
  • CanActivate 인터페이스: CanActivate 인터페이스를 구현하고 canActivate() 메서드에서 boolean 값을 반환합니다.

작성 및 적용 방법

src/auth/guards/auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    // 실제 로직: JWT 토큰 유효성 검사, 사용자 역할 확인 등
    // 예시: request.user 객체에 사용자가 있다면 true 반환
    return !!request.user; // 가짜 인증 로직
  }
}

가드는 컨트롤러나 메서드 레벨에서 @UseGuards() 데코레이터를 사용하여 적용합니다.

src/users/users.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/guards/auth.guard'; // 가드 임포트

@Controller('users')
export class UsersController {
  @UseGuards(AuthGuard) // 이 컨트롤러의 모든 라우트에 AuthGuard 적용
  @Get()
  findAll(): string {
    return 'This is a protected resource (all users).';
  }

  @UseGuards(AuthGuard) // 특정 라우트에만 AuthGuard 적용
  @Get(':id')
  findOne(): string {
    return 'This is a protected resource (single user).';
  }
}

파이프: 데이터 변환 및 유효성 검사

파이프는 들어오는 요청 데이터를 변환(Transformation) 하거나 유효성 검사(Validation) 를 수행하는 역할을 합니다. 컨트롤러의 라우트 핸들러로 데이터가 전달되기 전에 데이터를 원하는 형식으로 가공하거나, 정의된 규칙에 따라 데이터가 올바른지 확인하는 데 사용됩니다.

주요 특징 및 사용 사례

  • 실행 시점: 가드 다음에 실행되며, 요청 핸들러 메서드에 전달되기 직전에 실행됩니다.
  • 데이터 처리: stringnumber로 변환하거나, DTO(Data Transfer Object) 유효성 검사를 수행합니다.
  • PipeTransform 인터페이스: PipeTransform 인터페이스를 구현하고 transform() 메서드를 오버라이드합니다.

작성 및 적용 방법

NestJS는 ValidationPipe, ParseIntPipe 등 유용한 빌트인 파이프를 제공합니다. 커스텀 파이프도 쉽게 만들 수 있습니다.

src/common/pipes/validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class PositiveIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10); // 문자열을 숫자로 변환
    if (isNaN(val) || val < 0) { // 변환 실패 또는 음수인 경우 예외 발생
      throw new BadRequestException('Validation failed: ID must be a positive integer.');
    }
    return val;
  }
}

파이프는 @UsePipes() 데코레이터를 사용하거나, 메서드 매개변수 레벨에 직접 적용할 수 있습니다.

src/items/items.controller.ts
import { Controller, Get, Param, UsePipes } from '@nestjs/common';
import { PositiveIntPipe } from '../common/pipes/positive-int.pipe';

@Controller('items')
export class ItemsController {
  // @UsePipes(PositiveIntPipe) // 컨트롤러 레벨에 적용
  @Get(':id')
  // 매개변수 레벨에 파이프 적용. id가 숫자이고 양수인지 검증합니다.
  findOne(@Param('id', PositiveIntPipe) id: number): string {
    return `Item with ID: ${id}`;
  }
}

인터셉터: 응답 변환 및 부가 로직

인터셉터는 AOP(Aspect-Oriented Programming) 개념을 구현한 강력한 기능으로, 요청 전/후에 추가적인 로직을 삽입하거나, 응답 데이터를 변환하거나, 예외를 처리하는 등의 역할을 수행합니다. 마치 함수 호출 전후에 특정 작업을 수행하는 로직을 삽입하는 것과 유사합니다.

주요 특징 및 사용 사례

  • 실행 시점: 파이프 다음에 실행되며, 라우트 핸들러가 실행된 후 응답이 클라이언트에 전달되기 직전에 실행됩니다.
  • 반응형 프로그래밍: RxJS의 Observable을 사용하여 비동기 스트림을 조작하고 변환할 수 있습니다.
  • 로깅, 캐싱, 응답 변환, 예외 처리, 트랜잭션 관리 등에 활용됩니다.

작성 및 적용 방법

인터셉터는 NestInterceptor 인터페이스를 구현하고 intercept() 메서드를 오버라이드합니다.

src/common/interceptors/logging.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  statusCode: number;
  message: string;
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => ({ // 컨트롤러가 반환한 데이터를 감싸서 응답합니다.
        statusCode: context.switchToHttp().getResponse().statusCode,
        message: 'Success',
        data: data,
      })),
    );
  }
}

인터셉터는 @UseInterceptors() 데코레이터를 사용하여 적용합니다.

src/app.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { AppService } from './app.service';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';

@Controller()
@UseInterceptors(TransformInterceptor) // 이 컨트롤러의 모든 응답을 변환
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello(); // "Hello World!" 문자열 반환
  }
}
// 결과 응답: { statusCode: 200, message: "Success", data: "Hello World!" }

요청 처리 파이프라인 요약 및 실행 순서

NestJS의 요청 처리 파이프라인은 다음과 같은 순서로 실행됩니다.

미들웨어 (Middleware): 가장 먼저 실행되어 공통적인 전처리(로깅, 인증 등)를 수행합니다.

가드 (Guards): 미들웨어 다음으로 실행되어 라우트 핸들러에 대한 접근 권한을 확인합니다.

인터셉터 (Interceptors) - 전(Pre-handler): 라우트 핸들러 실행 전에 추가 로직(예: 요청 로깅, 캐싱)을 수행합니다.

파이프 (Pipes): 라우트 핸들러의 매개변수가 유효한지 검사하거나 변환합니다.

라우트 핸들러 (Controller Method): 컨트롤러의 실제 비즈니스 로직(서비스 호출 등)이 실행됩니다.

인터셉터 (Interceptors) - 후(Post-handler): 라우트 핸들러의 응답이 반환된 후 최종 응답 변환, 예외 처리 등을 수행합니다.

이러한 파이프라인의 각 요소는 서로 다른 관심사를 분리하고, 애플리케이션의 유지보수성과 확장성을 크게 향상시킵니다. 적절한 시점에 적절한 기능을 사용하여 여러분의 NestJS 애플리케이션을 더욱 강력하게 만들어보세요.

다음 절에서는 NestJS에서 예외를 효과적으로 처리하는 방법에 대해 알아보겠습니다.