NestJS 요청 생명주기

seongha_h·2024년 12월 26일

NestJS

목록 보기
1/3

요청 생명주기

Nest 어플리케이션의 요청 생명주기는 다음과 같습니다.

  1. Middleware
  2. Guards
  3. Interceptors
  4. Pipes
  5. Controller
  6. Service
  7. Interceptors
  8. Exception Filters
  9. Response

위와 같은 순서대로 요청이 처리됩니다.
controller, service 는 익숙하기 때문에 빼고 나머지 요소들을 살펴보겠습니다.

권장 사용 방법

유효성 검사는 다음과 같이 구현하는 것이 좋습니다.

  1. Pipe를 사용한 데이터 유효성 검사
  2. Guard를 사용한 인증/인가 검사
  3. Interceptor를 사용한 요청/응답 변환
  4. Middleware는 로깅, CORS 등 일반적인 요청 처리에 사용

Middleware

미들웨어는 라우트 핸들러 이전에 호출되는 함수입니다. nest 미들웨어는 기본적으로 express 미들웨어와 동일합니다. express 미들웨어의 역할과 마찬가지로 요청 및 응답 객체를 수정하거나, 요청 처리 체인을 종료할 수 있습니다.
주로 인증, 로깅, 요청파싱 등에 사용합니다.

@Injectable 데코레이터가 있는 클래스나 함수로 사용자 정의 nest 미들웨어를 구현합니다.

클래스로 사용하는 방법

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...');
    next();
  }
}

===

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)
      .exclude(
        { path: 'health', method: RequestMethod.ALL }
      )
      .forRoutes(
       { path: 'cats', method: RequestMethod.GET },
       { path: 'dogs', method: RequestMethod.GET }
      );
  }
}
  • MiddlewareConsumer: 헬퍼 클래스입니다. 미들웨어를 관리하기 위한 기본 제공 방법을 제공합니다.
  • forRouters : 미들웨어를 적용할 경로를 선택할 수 있습니다. (와일드카드 사용가능)
  • exclude : 미들웨어 적용을 제외하고 싶은 경로를 선택할 수도 있습니다.

함수로 사용하는 방법


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

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

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(logger).forRoutes(
	    { path: '', method: RequestMethod.GET }
    );
  }
}
  • 다중 미들웨어

미들웨어는 순차적으로 실행됩니다. 따라서 여러개의 미들웨어를 순서대로 적용하면 쉼표를 이용하여 순서대로 배치하면 됩니다.

consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);
  • 글로벌 미들웨어

등록된 모든 경로에 미들웨어를 한번에 바인딩하려면 use() 메서드를 사용하면 됩니다.

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

Guards

가드는 특정 요청이 라우트 핸들러에 의해 처리될지 여부를 결정합니다:

  • 주로 인증과 권한 부여에 사용됩니다
  • canActivate() 함수를 통해 true/false를 반환하여 요청 진행 여부를 결정합니다
  • 토큰 검증, 역할 기반 접근 제어 등을 구현할 때 사용됩니다

middleware는 실행 컨텍스트에 접근할 수 없지만 Gurad는 가능합니다. 이를 이용하여 세부적인 제어가 가능합니다. 또한, 컨트롤러 메서드 등에 붙여 유연하게 사용이 가능합니다.

Interceptors

인터셉터는 AOP에서 영감을 받아 만들어 졌기에 다음과 같은 기능을 추가할 수 있습니다.

  • 메소드 실행 전/후에 추가 로직을 바인딩
  • 함수에서 반환된 결과를 변환
  • 함수에서 발생한 예외를 변환
  • 기본 함수 동작을 확장
  • 캐싱, 로깅, 응답 매핑 등에 활용됩니다

기초

인터셉터는 두개의 인수를 사용하는 intercept() 메서드를 구현합니다.
첫번째는 실행 컨텍스트, 두번째는 Call Handler 인터페이스 입니다.
인터셉터의 특정 지점에서 경로 핸들러 메서드를 호출하는데 사용할 수 있는 handler 메서드를 구현합니다.

// transform.interceptor.ts
@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString()
      }))
    );
  }
}

intercept 메서드는 Observable을 반환하고 있습니다. 이게 뭘까요..?

Observable
Observablerxjs(Reactive Extensions For JavaScript) 라이브러리에서 가져와 사용하고 있습니다.
rxjs를 간단히 말하면 이벤트나 비동기, 시간을 마치 Array처럼 다룰 수 있게 만들어주는 라이브러리라고 합니다.
관련 링크 확인하기

rxJS는 이러한 비동기 이벤트 기반의 프로그램 작성을 돕기 위해 함수형 프로그래밍을 이용해 이벤트 스트림을 Observable이라는 객체로 표현합니다.
Observable은 Array와 Promise 성질을 모두 가진 이벤트를 다루는 새로운 객체 타입입니다.

응답 맵핑

우리는 이미 handler()가 Observable을 반환한다는 것을 알고 있습니다. 스트림에는 경로 핸들러에서 반환된 값이 포함되어 있으므로 RxJS의 map() 연산자를 사용하여 쉽게 변경할 수 있습니다.

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

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

@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(
      map(data => ({
        status: 'success',
        data
      }))
    );
  }
}

//전역에 적용
app.useGlobalInterceptors(new ResponseInterceptor());

Pipes

파이프는 두 가지 일반적인 사용 사례가 있습니다:

  • 변환: 입력 데이터를 원하는 형식으로 변환 (예: 문자열을 정수로)
  • 유효성 검사: 입력 데이터를 평가하고 유효한 경우 변경되지 않은 상태로 전달

파이프는 컨트롤러 경로 처리기에 처리되는 인수에 대해 작동합니다.

Nest에는 기본적으로 사용할 수 있는 다양한 파이프가 내장되어 있습니다. 또한, 커스텀 파이프를 생성할 수 있습니다.

기본 파이프 목록

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

파이프 적용 방법

// 1. 전역 적용
// main.ts
app.useGlobalPipes(new ValidationPipe());

// 2. 컨트롤러 레벨 적용
@Controller('notifications')
@UsePipes(CustomPipe)
export class NotificationsController {}

// 3. 라우트 레벨 적용
@Get(':id')
@UsePipes(CustomPipe)
findOne(@Param('id') id: string) {}

// 4. 파라미터 레벨 적용
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {}

커스텀 파이프

모든 파이프는 PipeTransform 이라는 인터페이스를 사용하기 위한 transform 메서드를 작성해야만 합니다.

transform

transform 메서드는 두가지 변수를 갖습니다.

  • value : 파이프에서 검증하거나 변환할 대상입니다.
  • metadata : 두 번째 매개변수인 metadata는 ArgumentMetadata 인터페이스를 따르며 다음 속성들을 포함합니다:
typescriptexport interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;
  data?: string;
}
  • type: 인자가 어디서 추출되었는지 나타냅니다
    • body: @Body() 데코레이터
    • query: @Query() 데코레이터
    • param: @Param() 데코레이터
    • custom: 커스텀 데코레이터
  • metatype: 인자의 타입을 나타내는 클래스 생성자 함수입니다
    • String, Boolean, Number 등의 타입이나 커스텀 클래스가 될 수 있습니다
  • data: 데코레이터에 전달된 문자열입니다
    • 예: @Body('user') => data는 'user'
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    //유효성 검사 로직
    return value;
  }
}

스키마 기반 유효성검사 (pipe 가 생겨난 이유)

아래 Dto를 요청시 검증에 사용하려고 합니다. 클래스 내의 3개의 필드를 controller 메서드 내부에서 처리할 수 있지만, 이는 SRP(단일 책임 원칙)을 위반하므로 이상적이지 않습니다.

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

또한 유효성 검사 함수를 만드는 것 역시 controller에서 호출해야만 하는 단점이 있습니다.
미들웨어를 활용하는 방법도 있지만, 실행 컨텍스트에 접근하지 못하고, 모든 상황에서 재사용가능한 미들웨어를 만들기 어렵습니다.

zod 와 함께 사용하기

zod를 이용하면 간단한 방법으로 스키마를 생성하고 검증할 수 있습니다. schema.parse() 메서드를 이용하여 인수에 대한 유효성 검사를 진행할 수 있습니다.

유효성 검사 파이프를 바인딩하는 방법은 다음과 같습니다.
먼저 아래와 같은 ZodValidationPipe 를 생성하고 사용합니다.

  1. ZodValidationPipe의 인스턴스 생성
  2. 파이프의 클래스 생성자에 컨텍스트별 zod 스키마를 전달합니다.
  3. 파이프를 메서드에 바인딩
  • ZodValidationPipe을 사용하는 방법 (없어도 됨. 패키지에서 기본제공)
    import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
    import { ZodSchema  } from 'zod';
    
    //ZodValidationPipe  (없어도 됨, zod 패키지가 이미 해줌.)
    export class ZodValidationPipe implements PipeTransform {
      constructor(private schema: ZodSchema) {}
    
      transform(value: unknown, metadata: ArgumentMetadata) {
        try {
          const parsedValue = this.schema.parse(value);
          return parsedValue;
        } catch (error) {
          throw new BadRequestException('Validation failed');
        }
      }
    }
    
    ========
    //zod 스키마 
    export const createCatSchema = z
      .object({
        name: z.string(),
        age: z.number(),
        breed: z.string(),
      })
      .required();
    
    export type CreateCatDto = z.infer<typeof createCatSchema>;
    
    ======
    //메서드에 바인딩 
    @Post()
    async create(@Body() createCatDto: CreateCatDto) {
      this.catsService.create(createCatDto);
    }
  • 실제 사용 예제 참고
    // cat.schema.ts
    import { z } from 'zod';
    
    // 가능한 고양이 품종을 enum으로 정의
    const CatBreeds = z.enum([
      'PERSIAN',
      'SIAMESE',
      'MAINE_COON',
      'RAGDOLL',
      'BRITISH_SHORTHAIR',
      'SPHYNX',
      'OTHER',
    ]);
    
    export const createCatSchema = z
      .object({
        name: z
          .string()
          .min(2, '이름은 최소 2글자 이상이어야 합니다')
          .max(30, '이름은 최대 30글자까지 가능합니다')
          .trim(), // 앞뒤 공백 제거
    
        age: z
          .number()
          .int('나이는 정수여야 합니다')
          .min(0, '나이는 0살 이상이어야 합니다')
          .max(30, '나이는 30살 이하여야 합니다'),
    
        breed: CatBreeds,
    
        // 선택적 필드 추가
        weight: z
          .number()
          .positive('체중은 양수여야 합니다')
          .max(30, '체중은 30kg 이하여야 합니다')
          .optional(),
    
        description: z
          .string()
          .max(500, '설명은 최대 500자까지 가능합니다')
          .optional(),
    
        isVaccinated: z.boolean().default(false),
    
        birthDate: z
          .string()
          .datetime()
          .refine(
            (date) => new Date(date) <= new Date(),
            '생년월일은 현재 날짜보다 이후일 수 없습니다',
          )
          .optional(),
    
        // 중첩된 객체 예시
        medicalInfo: z
          .object({
            lastCheckup: z.string().datetime().optional(),
            allergies: z.array(z.string()).default([]),
            specialNeeds: z.string().optional(),
          })
          .optional(),
      })
      .strict(); // 정의되지 않은 필드 거부
    
    // DTO 타입 추출
    export type CreateCatDto = z.infer<typeof createCatSchema>;
    
    === 
    //controller
      @Post()
      async create(@Body() createCatDto: CreateCatDto) {
        // this.catsService.create(createCatDto);
        return createCatDto;
      }

zod VS class Validator

Class Validator

  • 데코레이터 기반으로 작동합니다
  • 런타임에서만 유효성 검사가 이루어집니다
  • 타입 추론이 제한적입니다

Zod

  • TypeScript 기반으로 설계되었습니다
  • 컴파일 타임과 런타임 모두에서 타입 검사가 가능합니다
  • 자동으로 TypeScript 타입을 추론합니다

Controller & Service

  • Controller: 클라이언트의 요청을 처리하고 응답을 반환합니다
  • Service: 실제 비즈니스 로직을 포함하며, 데이터베이스 조작이나 외부 서비스 호출 등을 담당합니다

Exception Filters

예외 필터는 애플리케이션에서 처리되지 않은 예외를 처리합니다
기본적으로 HttpException 클래스와 그 하위 클래스의 예외를 처리하도록 되어 있습니다. 이 클래스의 예외가 아닌경우 500 에러를 반환하게 되어 있습니다.

기본적으로 아래처럼 예외를 던질 수 있습니다.

throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
//또는
throw new ForbiddenException()

내장 예외에 정보 전달하기

모든 내장 예외는 옵션을 이용하여 원인과 설명을 모두 제공할 수 있습니다.
기본 옵션으로 던지면, 아래와 같이 응답이 작성됩니다.

throw new BadRequestException('Something bad happened', {
  cause: new Error(),
  description: 'Some error description',
});
===
{
  "message": "Something bad happened",
  "error": "Some error description",
  "statusCode": 400
}

Custom Exception

대부분의 경우 custom에러를 작성할 필요가 없지만, 커스텀 에러를 만들어야 하는 경우 다음과 같이 생성할 수 있습니다.

export class MyException extends HttpException {
  constructor() {
    super('my exception', HttpStatus.FORBIDDEN);
  }
}

Exception Filter

기본 예외 필터가 자동으로 많은 예외들을 처리할 수 있지만, 예외 레이어에 대한 제어가 필요할 수 있습니다. 예를들어 로깅을 하거나 응답 형식을 변환해야하는 경우가 있습니다. 필터는 이런 목적으로 설계되었고, 응답 내용을 제어할 수 있습니다.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

@Catch 데코레이터를 이용하여 예외를 바인딩하여 이 필터가 특정 예외 클래스만 찾고 있음을 Nest에 알립니다. 이 Cathc에 쉼표를 이용하여 여러개를 넣을 수 있습니다.

적용 범위
Exception filters can be scoped at different levels

  • Method-scoped of the controller/resolver/gateway
  • Controller-scoped
  • Global-scoped

예외 필터는 컨트롤러, 메서드, 전체 등등 여러 범위에 지정할 수 있습니다.

//메서드
@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}
//컨트롤러
@UseFilters(new HttpExceptionFilter())
export class CatsController {}
//글로벌
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

적절히 사용하기 위한 고민

기본 예외 클래스들을 활용하고, enum으로 생성한 커스텀 에러 코드와 메서드를 활용하는 방법

장점 : enum을 통해서 에러코드와 메세지 관리를 편하게 할 수 있음. 그리고 커스텀 에러 클래스를 생성하지 않아도 됨.

throw new BadRequestException({
   code: ErrorCode.USER_ALREADY_EXISTS,
   message: ErrorMessage[ErrorCode.USER_ALREADY_EXISTS]
});

결론

내장 예외를 활용할 수 있는 경우는 활용하고, 활용하지 못하는 소켓과 같은 것들은 커스텀 에러를 사용하기

가장 좋은 방법은 두 가지를 적절히 혼합하여 사용하는 것입니다. 일반적인 HTTP 오류는 내장 예외를, 특수한 비즈니스 로직에는 커스텀 예외를 사용하는 것이 효율적입니다.

처리 순서

  • 각 단계는 순차적으로 실행되며, 이전 단계에서 요청이 거부되면 다음 단계로 진행되지 않습니다
  • 예외가 발생하면 Exception Filters로 바로 이동하여 처리됩니다
  • 전역 범위에서 컨트롤러, 라우트 범위로 좁혀가며 실행되므로, 더 구체적인 범위의 설정이 우선 적용됩니다
profile
https://github.com/Fixtar

0개의 댓글