Framework/NestJS

NestJS 완전정복: 미들웨어, 가드, 인터셉터, 파이프의 모든 것

Joonfluence 2025. 5. 26. 09:30

1. 미들웨어(Middleware)

1.1. 개념 및 역할

미들웨어는 Express와 마찬가지로 요청(Request)과 응답(Response) 사이에서 실행되는 함수입니다. HTTP 요청이 컨트롤러의 핸들러에 도달하기 전에 실행되는 함수로, 요청(request)과 응답(response) 객체, 그리고 next() 함수를 통해 다음 미들웨어로 제어를 전달할 수 있습니다. 주로 요청의 전처리(로깅, 인증, body 파싱 등)나 후처리, 특정 조건에 따른 요청 차단 등에 사용됩니다. NestJS의 미들웨어는 Express 미들웨어와 거의 동일하게 동작하지만, Nest의 DI 시스템과 모듈 시스템에 통합되어 더 구조적으로 관리할 수 있습니다.

1.2. 사용법

1) 클래스형 미들웨어 작성 방법 및 적용 방법

기본적으로 클래스형으로 많이 작성하며, 클래스형의 경우 @Injectable() 데코레이터와 NestMiddleware 인터페이스를 사용합니다.

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(`[${req.method}] ${req.originalUrl}`);
    next();
  }
}

모듈에서 NestModule 인터페이스를 구현하고, configure() 메서드를 통해 MiddlewareConsumer를 사용하여 미들웨어를 apply()를 통해, 특정 라우트에 적용합니다.

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • forRoutes('cats')로 지정하면, 특정 경로에만 적용할 수도 있습니다.
  • 만약 특정 HTTP 메서드(GET 등)에만 미들웨어를 적용하고 싶다면, forRoutes()에 객체를 넘기고 RequestMethod를 지정할 수 있습니다.
  • 여러 미들웨어를 배열로 적용할 수 있습니다.
  • configure() 메서드는 async/await를 사용할 수 있습니다. (비동기 작업 가능)

2) 함수형 미들웨어 작성 방법 및 적용 방법

클래스가 아닌 함수로도 미들웨어를 정의할 수 있습니다.

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

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};

사용법은 동일합니다:

consumer
  .apply(logger)
  .forRoutes(CatsController);

3) 고급 기능

  • 여러 미들웨어 적용: apply() 메서드에 여러 미들웨어를 나열하여 순차적으로 적용할 수 있습니다.

apply()에 여러 미들웨어를 나열하면, 순서대로 실행됩니다.

consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);
  • 특정 경로 제외: exclude() 메서드를 사용하여 특정 경로에서 미들웨어를 제외할 수 있습니다.

exclude() 메서드를 사용하면 특정 경로나 메서드에서 미들웨어를 제외할 수 있습니다.

consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST },
    'cats/{*splat}',
  )
  .forRoutes(CatsController);

위 예시에서는 CatsController의 모든 라우트 중 GET, POST, cats/{*splat} 경로는 LoggerMiddleware가 적용되지 않습니다.

Hint: exclude()도 와일드카드 경로를 지원합니다(path-to-regexp 사용).

  • 와일드카드 경로: forRoutes() 메서드에서 와일드카드를 사용하여 경로 패턴을 지정할 수 있습니다.

forRoutes()에서 패턴 기반 경로도 지원합니다. 예를 들어, 'abcd/*splat'은 abcd/로 시작하는 모든 경로에 미들웨어를 적용합니다.

forRoutes({
  path: 'abcd/*splat',
  method: RequestMethod.ALL,
});

'splat'은 단순히 와일드카드 파라미터 이름일 뿐, 아무 이름이나 사용 가능합니다. 'abcd/'는 abcd/1, abcd/abc 등 abcd/로 시작하는 모든 경로에 매칭됩니다. abcd/만 매칭하려면 'abcd/{splat}'처럼 중괄호로 감싸야 합니다.

글로벌 미들웨어(Global Middleware)

모든 라우트에 미들웨어를 적용하려면, main.ts에서 app.use()를 사용합니다.

const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(process.env.PORT ?? 3000);

Hint: app.use()로 등록한 글로벌 미들웨어는 DI 컨테이너를 사용할 수 없습니다. DI가 필요한 경우 forRoutes('*')로 등록하세요.

이처럼 NestJS의 미들웨어 시스템은 다양한 경로, 메서드, 컨트롤러 단위로 유연하게 미들웨어를 적용/제외할 수 있으며, 함수형/클래스형, 글로벌/로컬 등 다양한 패턴을 지원합니다. 공식문서의 예제와 옵션을 적극적으로 활용하면, 복잡한 요구사항도 손쉽게 구현할 수 있습니다.

1.3. 특징 및 주의점

  • 미들웨어는 DI 컨테이너에 등록할 수 있으므로, 서비스 주입이 가능합니다.
  • 미들웨어는 라우트 핸들러보다 먼저 실행됩니다.
  • 미들웨어에서 응답을 끝내면(res.send() 등) 이후 핸들러가 실행되지 않습니다.

1.4. Spring과의 비교

  • Spring의 Filter와 유사합니다.
  • Spring Filter는 서블릿 레벨에서 동작, Nest 미들웨어는 Express 레벨에서 동작합니다.

2. 가드(Guard)

2.1. 개념 및 역할

가드는 요청이 라우트 핸들러에 도달하기 전에 실행되어, 접근 허용 여부(인증/인가 등)를 결정합니다.
즉, 인증(Authentication), 권한(Authorization) 체크에 주로 사용됩니다.

가드는 다음과 같은 상황에서 주로 사용됩니다:

  • 인증: 사용자가 로그인되어 있는지 확인
  • 권한: 사용자가 특정 리소스에 접근할 권한이 있는지 확인
  • 역할 기반 접근 제어: 사용자의 역할에 따른 접근 제어
  • API 키 검증: API 요청의 유효성 검증
  • 요청 제한: 특정 조건에 따른 요청 차단

2.2. 기본 사용법

1) 가드 정의

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> {
    // ExecutionContext는 현재 실행 컨텍스트에 대한 정보를 제공
    const request = context.switchToHttp().getRequest();

    // 인증 로직 구현
    return this.validateRequest(request);
  }

  private validateRequest(request: any): boolean {
    // 실제 인증 로직 구현
    return true; // 또는 false
  }
}

2) 가드 적용 방법

  1. 전역 적용
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard());
  await app.listen(3000);
}
  1. 컨트롤러 레벨 적용
@Controller('cats')
@UseGuards(AuthGuard)
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }
}
  1. 메서드 레벨 적용
@Controller('cats')
export class CatsController {
  @Get()
  @UseGuards(AuthGuard)
  findAll() {
    return 'This action returns all cats';
  }
}

2.3. 고급 사용법

1) 역할 기반 가드

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

// 역할 데코레이터 정의
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // 메타데이터에서 역할 정보 추출
    const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!requiredRoles) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    // 사용자의 역할과 요구되는 역할 비교
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}

2) JWT 인증 가드

import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      // JWT 토큰 검증
      const payload = await this.jwtService.verifyAsync(token);
      request['user'] = payload;
      return true;
    } catch {
      throw new UnauthorizedException();
    }
  }

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

3) API 키 가드

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

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const apiKey = request.headers['x-api-key'];

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

    // API 키 검증
    const isValid = this.validateApiKey(apiKey);
    if (!isValid) {
      throw new UnauthorizedException('Invalid API key');
    }

    return true;
  }

  private validateApiKey(apiKey: string): boolean {
    const validApiKey = this.configService.get<string>('API_KEY');
    return apiKey === validApiKey;
  }
}

2.4. Spring과의 비교

1) 공통점

  • 인증/인가 로직의 분리
  • 선언적 프로그래밍 지원
  • DI 컨테이너와의 통합

2) 차이점

  1. 구현 방식
// NestJS - 가드 기반
@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    // 인증 로직
    return true;
  }
}

// Spring - 인터셉터 기반
@Component
public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) {
        // 인증 로직
        return true;
    }
}
  1. 적용 범위
// NestJS - 더 세밀한 적용 가능
@Controller('cats')
export class CatsController {
  @Get()
  @UseGuards(AuthGuard, RolesGuard)
  findAll() {
    // 여러 가드 조합 가능
  }
}

// Spring - 주로 메서드 레벨에서 적용
@RestController
public class CatsController {
    @GetMapping
    @PreAuthorize("hasRole('ADMIN')")
    public List<Cat> findAll() {
        // 주로 단일 보안 어노테이션 사용
    }
}
  1. 예외 처리
// NestJS - 가드 내부에서 예외 처리
@Injectable()
export class CustomGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    if (!this.isValid()) {
      throw new UnauthorizedException('Invalid request');
    }
    return true;
  }
}

// Spring - 별도의 예외 처리기 필요
@ControllerAdvice
public class SecurityExceptionHandler {
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<?> handleAccessDeniedException(
        AccessDeniedException ex) {
        // 예외 처리 로직
    }
}

2.5. NestJS 가드의 특성

  1. 컨텍스트 활용
@Injectable()
export class ContextAwareGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    // HTTP 컨텍스트
    if (context.getType() === 'http') {
      const request = context.switchToHttp().getRequest();
      const response = context.switchToHttp().getResponse();
      // HTTP 관련 로직
    }

    // WebSocket 컨텍스트
    if (context.getType() === 'ws') {
      const client = context.switchToWs().getClient();
      const data = context.switchToWs().getData();
      // WebSocket 관련 로직
    }

    return true;
  }
}
  1. 메타데이터 활용
@Injectable()
export class MetadataGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    // 메타데이터에서 필요한 정보 추출
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    const isPublic = this.reflector.get<boolean>('isPublic', context.getHandler());

    // public 엔드포인트는 검증 건너뛰기
    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    // 역할 기반 접근 제어
    return roles.some(role => user.roles?.includes(role));
  }
}

2.6. 모범 사례

  1. 가드 조합
@Injectable()
export class CombinedGuard implements CanActivate {
  constructor(
    private readonly authGuard: AuthGuard,
    private readonly rolesGuard: RolesGuard,
    private readonly apiKeyGuard: ApiKeyGuard,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 순차적으로 가드 적용
    const isAuthenticated = await this.authGuard.canActivate(context);
    if (!isAuthenticated) {
      return false;
    }

    const hasRole = await this.rolesGuard.canActivate(context);
    if (!hasRole) {
      return false;
    }

    return this.apiKeyGuard.canActivate(context);
  }
}
  1. 조건부 가드
@Injectable()
export class ConditionalGuard implements CanActivate {
  constructor(
    private readonly condition: (context: ExecutionContext) => boolean,
    private readonly guard: CanActivate,
  ) {}

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    if (this.condition(context)) {
      return this.guard.canActivate(context);
    }
    return true;
  }
}
  1. 캐싱 가드
@Injectable()
export class CachingGuard implements CanActivate {
  private cache = new Map<string, boolean>();

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const cacheKey = this.generateCacheKey(request);

    if (this.cache.has(cacheKey)) {
      return this.cache.get(cacheKey);
    }

    const result = this.validateRequest(request);
    this.cache.set(cacheKey, result);
    return result;
  }

  private generateCacheKey(request: any): string {
    return `${request.method}:${request.url}:${request.headers.authorization}`;
  }

  private validateRequest(request: any): boolean {
    // 요청 검증 로직
    return true;
  }
}
  1. 로깅 가드
@Injectable()
export class LoggingGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();

    console.log('Guard Input:', {
      method: request.method,
      url: request.url,
      headers: request.headers,
      user: request.user,
    });

    const result = this.validateRequest(request);

    console.log('Guard Output:', {
      allowed: result,
      timestamp: new Date().toISOString(),
    });

    return result;
  }

  private validateRequest(request: any): boolean {
    // 요청 검증 로직
    return true;
  }
}
  1. 에러 처리 가드
@Injectable()
export class ErrorHandlingGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    try {
      return this.validateRequest(context);
    } catch (error) {
      // 에러 로깅
      console.error('Guard Error:', error);

      // 에러 타입에 따른 처리
      if (error instanceof UnauthorizedException) {
        throw new UnauthorizedException('인증이 필요합니다.');
      }
      if (error instanceof ForbiddenException) {
        throw new ForbiddenException('접근 권한이 없습니다.');
      }

      // 기타 예상치 못한 에러
      throw new InternalServerErrorException('서버 에러가 발생했습니다.');
    }
  }

  private validateRequest(context: ExecutionContext): boolean {
    // 요청 검증 로직
    return true;
  }
}
  1. 성능 모니터링 가드
@Injectable()
export class PerformanceGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const start = performance.now();

    const result = this.validateRequest(context);

    const end = performance.now();
    const duration = end - start;

    // 성능 메트릭 기록
    this.recordMetrics(context, duration);

    return result;
  }

  private validateRequest(context: ExecutionContext): boolean {
    // 요청 검증 로직
    return true;
  }

  private recordMetrics(context: ExecutionContext, duration: number): void {
    const request = context.switchToHttp().getRequest();
    console.log('Performance Metrics:', {
      endpoint: request.url,
      method: request.method,
      duration: `${duration.toFixed(2)}ms`,
      timestamp: new Date().toISOString(),
    });
  }
}

3. 인터셉터(Interceptor)

3.1. 개념 및 역할

인터셉터는 요청-응답 흐름을 가로채서 추가 작업(로깅, 응답 변환, 캐싱, 예외 변환 등)을 수행합니다.
AOP(관점 지향 프로그래밍)와 유사하게, 메서드 실행 전/후, 예외 발생 시, 응답 반환 직전 등 다양한 시점에 개입할 수 있습니다.

인터셉터는 다음과 같은 기능을 제공합니다.

  • 메서드 실행 전/후 추가 로직 바인딩
  • 함수에서 반환된 결과 변환
  • 함수에서 던져진 예외 변환
  • 기본 함수 동작 확장
  • 특정 조건에 따라 함수 동작 완전 재정의

3.2. 기본 사용법

1) 인터셉터 정의

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

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 요청 처리 전 로깅
    console.log('Before...');
    const now = Date.now();

    // next.handle()을 호출하여 다음 인터셉터나 컨트롤러로 제어를 전달
    return next
      .handle()
      .pipe(
        // tap 연산자를 사용하여 응답 처리 후 로깅
        tap(() => console.log(`After... ${Date.now() - now}ms`)),
      );
  }
}

2) 인터셉터 적용 방법

  1. 전역 적용
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new LoggingInterceptor());
  await app.listen(3000);
}
  1. 컨트롤러 레벨 적용
@Controller('cats')
@UseInterceptors(LoggingInterceptor)
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }
}
  1. 메서드 레벨 적용
@Controller('cats')
export class CatsController {
  @Get()
  @UseInterceptors(LoggingInterceptor)
  findAll() {
    return 'This action returns all cats';
  }
}

3.3. 고급 사용법

1) 응답 데이터 변환

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

export interface Response<T> {
  data: T;
  timestamp: string;
}

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

2) 예외 처리

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, HttpException } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        if (err instanceof HttpException) {
          // HTTP 예외를 커스텀 응답으로 변환
          return throwError(() => ({
            statusCode: err.getStatus(),
            message: err.message,
            timestamp: new Date().toISOString(),
          }));
        }
        return throwError(() => err);
      }),
    );
  }
}

3) 캐싱 구현

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 cacheKey = request.url;

    // 캐시된 응답이 있는지 확인
    if (this.cache.has(cacheKey)) {
      return of(this.cache.get(cacheKey));
    }

    // 캐시된 응답이 없으면 요청 처리 후 캐시에 저장
    return next.handle().pipe(
      tap(response => {
        this.cache.set(cacheKey, response);
      }),
    );
  }
}

3.4. Spring AOP와의 비교

1) 공통점

  • AOP 개념: 두 프레임워크 모두 관점 지향 프로그래밍의 개념을 따릅니다.
  • 횡단 관심사 분리: 비즈니스 로직과 횡단 관심사(로깅, 캐싱 등)를 분리합니다.
  • 데코레이터 패턴: 어노테이션/데코레이터를 통한 선언적 프로그래밍을 지원합니다.

2) 차이점

  1. 실행 시점
// NestJS 인터셉터
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const now = Date.now();
    return next.handle().pipe(
      tap(() => console.log(`Execution time: ${Date.now() - now}ms`)),
    );
  }
}

// Spring AOP
@Aspect
@Component
public class TimingAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public Object timeMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long end = System.currentTimeMillis();
        System.out.println("Execution time: " + (end - start) + "ms");
        return result;
    }
}
  1. 비동기 처리
// NestJS - RxJS Observable 기반
@Injectable()
export class AsyncInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      // 비동기 스트림 처리
      map(data => this.transformData(data)),
      catchError(err => this.handleError(err))
    );
  }
}

// Spring - CompletableFuture 기반
@Aspect
@Component
public class AsyncAspect {
    @Around("execution(* com.example.service.*.*(..))")
    public CompletableFuture<Object> asyncMethod(ProceedingJoinPoint joinPoint) {
        return CompletableFuture.supplyAsync(() -> {
            try {
                return joinPoint.proceed();
            } catch (Throwable e) {
                throw new CompletionException(e);
            }
        });
    }
}
  1. DI 통합
// NestJS - DI 컨테이너와 완벽한 통합
@Injectable()
export class ServiceAwareInterceptor implements NestInterceptor {
  constructor(private readonly someService: SomeService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // 서비스 주입 및 사용
    return next.handle().pipe(
      map(data => this.someService.process(data))
    );
  }
}

// Spring - DI 컨테이너와 통합
@Aspect
@Component
public class ServiceAwareAspect {
    @Autowired
    private SomeService someService;

    @Around("execution(* com.example.service.*.*(..))")
    public Object serviceAwareMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = joinPoint.proceed();
        return someService.process(result);
    }
}

3.5. NestJS 인터셉터의 특성

  1. RxJS 기반
  • Observable 스트림을 통한 비동기 처리
  • 강력한 스트림 조작 연산자 제공
  • 메모리 누수 방지를 위한 자동 구독 해제
  1. 실행 순서
// 인터셉터 체인
@Injectable()
export class ChainInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('First interceptor');
    return next.handle().pipe(
      tap(() => console.log('First interceptor after')),
    );
  }
}

@Injectable()
export class SecondInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    console.log('Second interceptor');
    return next.handle().pipe(
      tap(() => console.log('Second interceptor after')),
    );
  }
}

// 적용 순서
@UseInterceptors(ChainInterceptor, SecondInterceptor)
@Get()
findAll() {
  return 'This action returns all cats';
}
  1. 컨텍스트 활용
@Injectable()
export class ContextAwareInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();

    // 요청 컨텍스트 활용
    const user = request.user;
    const method = request.method;

    return next.handle().pipe(
      tap(() => {
        // 응답 컨텍스트 활용
        response.setHeader('X-Custom-Header', 'value');
      }),
    );
  }
}
  1. 메타데이터 활용
@Injectable()
export class MetadataInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const handler = context.getHandler();
    const metadata = Reflect.getMetadata('custom:metadata', handler);

    return next.handle().pipe(
      map(data => ({
        ...data,
        metadata,
      })),
    );
  }
}

3.6. 모범 사례

  1. 인터셉터 조합
@Injectable()
export class CombinedInterceptor implements NestInterceptor {
  constructor(
    private readonly loggingInterceptor: LoggingInterceptor,
    private readonly transformInterceptor: TransformInterceptor,
    private readonly cacheInterceptor: CacheInterceptor,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return this.loggingInterceptor
      .intercept(context, {
        handle: () => this.transformInterceptor
          .intercept(context, {
            handle: () => this.cacheInterceptor
              .intercept(context, next),
          }),
      });
  }
}
  1. 조건부 인터셉터
@Injectable()
export class ConditionalInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();

    // 특정 조건에 따라 인터셉터 적용
    if (request.headers['x-skip-interceptor']) {
      return next.handle();
    }

    return next.handle().pipe(
      map(data => this.transformData(data)),
    );
  }
}

4. 파이프(Pipe)

4.1. 개념 및 역할

파이프는 요청 데이터의 유효성 검사(Validation)와 변환(Transformation) 을 담당합니다.
컨트롤러 핸들러에 전달되기 전에 데이터를 가공하거나, 유효하지 않은 데이터는 예외를 발생시켜 요청을 차단할 수 있습니다.

파이프는 다음과 같은 상황에서 주로 사용됩니다.

  • 데이터 변환: 문자열을 정수로 변환
  • 데이터 유효성 검사: 입력값이 유효한지 검증
  • 기본값 설정: 누락된 파라미터에 기본값 할당
  • 데이터 정제: 불필요한 공백 제거, 특수문자 처리 등

4.2. 기본 사용법

1) 파이프 정의

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    // value: 변환할 입력값
    // metadata: 파이프가 적용된 파라미터의 메타데이터
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

2) 파이프 적용 방법

  1. 전역 적용
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
  1. 컨트롤러 레벨 적용
@Controller('cats')
@UsePipes(ValidationPipe)
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }
}
  1. 메서드 레벨 적용
@Controller('cats')
export class CatsController {
  @Get()
  @UsePipes(ValidationPipe)
  findAll() {
    return 'This action returns all cats';
  }
}
  1. 파라미터 레벨 적용
@Controller('cats')
export class CatsController {
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    // id는 number 타입으로 변환됨
    return `This action returns cat #${id}`;
  }
}

4.3. 고급 사용법

1) 커스텀 유효성 검사 파이프

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class CustomValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    // 메타데이터에서 타입 정보 추출
    const { metatype } = metadata;

    // 타입이 없거나 기본 타입인 경우 검증 건너뛰기
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }

    // 값이 비어있는 경우
    if (!value) {
      throw new BadRequestException('Value is required');
    }

    // 타입 검증
    if (typeof value !== typeof new metatype()) {
      throw new BadRequestException('Type validation failed');
    }

    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

2) DTO 기반 유효성 검사

// create-cat.dto.ts
import { IsString, IsInt, Min, Max } from 'class-validator';

export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  @Min(0)
  @Max(20)
  age: number;
}

// cats.controller.ts
@Controller('cats')
export class CatsController {
  @Post()
  @UsePipes(new ValidationPipe({ transform: true }))
  create(@Body() createCatDto: CreateCatDto) {
    return 'This action adds a new cat';
  }
}

3) 비동기 파이프

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AsyncValidationPipe implements PipeTransform {
  async transform(value: any, metadata: ArgumentMetadata): Promise<any> {
    try {
      // 비동기 검증 로직
      const isValid = await this.validateAsync(value);
      if (!isValid) {
        throw new BadRequestException('Validation failed');
      }
      return value;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }

  private async validateAsync(value: any): Promise<boolean> {
    // 비동기 검증 로직 구현
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(true);
      }, 1000);
    });
  }
}

4.4. Spring과의 비교

1) 공통점

  • 데이터 검증과 변환의 책임 분리
  • 선언적 프로그래밍 지원
  • DI 컨테이너와의 통합

2) 차이점

  1. 구현 방식
// NestJS - 파이프 기반
@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    // 검증 로직
    return value;
  }
}

// Spring - Validator 기반
@Component
public class CustomValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return User.class.equals(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        // 검증 로직
    }
}
  1. 적용 범위
// NestJS - 더 세밀한 적용 가능
@Controller('cats')
export class CatsController {
  @Get(':id')
  findOne(
    @Param('id', ParseIntPipe) id: number,
    @Query('age', ParseIntPipe) age: number,
    @Body(ValidationPipe) createCatDto: CreateCatDto
  ) {
    // 각 파라미터별로 다른 파이프 적용 가능
  }
}

// Spring - 주로 DTO 레벨에서 적용
@RestController
public class CatsController {
    @PostMapping
    public ResponseEntity<?> create(@Valid @RequestBody CreateCatDto dto) {
        // DTO 레벨에서 검증
    }
}
  1. 예외 처리
// NestJS - 파이프 내부에서 예외 처리
@Injectable()
export class CustomPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (!value) {
      throw new BadRequestException('Value is required');
    }
    return value;
  }
}

// Spring - 별도의 예외 처리기 필요
@ControllerAdvice
public class ValidationExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(
        MethodArgumentNotValidException ex) {
        // 예외 처리 로직
    }
}

4.5. NestJS 파이프의 특성

  1. 타입 변환
@Injectable()
export class TypeConversionPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    const { type } = metadata;

    switch (type) {
      case 'param':
        return parseInt(value, 10);
      case 'query':
        return value.toLowerCase();
      case 'body':
        return JSON.parse(value);
      default:
        return value;
    }
  }
}
  1. 데이터 정제
@Injectable()
export class SanitizationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (typeof value === 'string') {
      // 공백 제거
      value = value.trim();
      // 특수문자 이스케이프
      value = this.escapeHtml(value);
    }
    return value;
  }

  private escapeHtml(unsafe: string): string {
    return unsafe
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }
}
  1. 기본값 설정
@Injectable()
export class DefaultValuePipe implements PipeTransform {
  constructor(private defaultValue: any) {}

  transform(value: any, metadata: ArgumentMetadata) {
    if (value === undefined || value === null) {
      return this.defaultValue;
    }
    return value;
  }
}

4.6. 모범 사례

  1. 파이프 조합
@Injectable()
export class CombinedPipe implements PipeTransform {
  constructor(
    private readonly validationPipe: ValidationPipe,
    private readonly sanitizationPipe: SanitizationPipe,
  ) {}

  transform(value: any, metadata: ArgumentMetadata) {
    // 순차적으로 파이프 적용
    const sanitized = this.sanitizationPipe.transform(value, metadata);
    return this.validationPipe.transform(sanitized, metadata);
  }
}
  1. 조건부 파이프
@Injectable()
export class ConditionalPipe implements PipeTransform {
  constructor(
    private readonly condition: (value: any) => boolean,
    private readonly pipe: PipeTransform,
  ) {}

  transform(value: any, metadata: ArgumentMetadata) {
    if (this.condition(value)) {
      return this.pipe.transform(value, metadata);
    }
    return value;
  }
}
  1. 캐싱 파이프
@Injectable()
export class CachingPipe implements PipeTransform {
  private cache = new Map<string, any>();

  transform(value: any, metadata: ArgumentMetadata) {
    const key = JSON.stringify(value);

    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const result = this.processValue(value);
    this.cache.set(key, result);
    return result;
  }

  private processValue(value: any): any {
    // 값 처리 로직
    return value;
  }
}
  1. 로깅 파이프
@Injectable()
export class LoggingPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    console.log(`Pipe Input:`, {
      value,
      type: metadata.type,
      metatype: metadata.metatype?.name,
    });

    const result = this.processValue(value);

    console.log(`Pipe Output:`, {
      input: value,
      output: result,
    });

    return result;
  }

  private processValue(value: any): any {
    // 값 처리 로직
    return value;
  }
}

5. 종합 비교 및 활용 전략

NestJS 개념 Spring 대응 개념 주요 역할/특징
Middleware Filter 요청/응답 전후 처리, 로깅, 인증 등
Guard Security Filter, AOP 인증/인가, 접근 제어
Interceptor HandlerInterceptor, AOP 로깅, 응답 변환, 캐싱, 예외 변환 등
Pipe Validator, @InitBinder 유효성 검사, 데이터 변환
  • 미들웨어: 요청 흐름의 가장 앞단에서 공통 로직 처리(로깅, 인증 등)
  • 가드: 인증/인가 등 접근 제어(핸들러 진입 전)
  • 인터셉터: 요청/응답 흐름 가로채기(로깅, 응답 변환, 캐싱 등)
  • 파이프: 데이터 유효성 검사 및 변환(핸들러 파라미터 직전)

NestJS는 이 네 가지 계층을 통해 관심사의 분리(Separation of Concerns)를 극대화하며,
각 계층별로 DI, 데코레이터, 모듈 시스템과 결합해 대규모 애플리케이션에서도
유지보수성과 확장성을 보장합니다.

6. 공식 문서 참고

공식 문서의 예제와 설명을 직접 따라해보면, 각 개념의 동작 원리와 실전 적용법을
더 깊이 이해할 수 있습니다.
특히, Spring 경험자라면 각 계층의 대응 개념을 비교하며 학습하면 전환이 훨씬 쉽습니다.

반응형