← Notes

Nest.js Decorator 동작원리와 종류, Custom Decorator

Nest.js 백엔드 개발· 2 / 8
  1. 1 — Nest.js 프로젝트 생성과 구조
  2. 2 — Nest.js Decorator 동작원리와 종류, Custom Decorator
  3. 3 — Nest.js 의존성 주입 개념과 적용 방법
  4. 4 — Nest.js HTTP exceptions 에러 처리 모음
  5. 5 — Nest.js CORS 설정
  6. 6 — Nest.js 유효성 검사 — Validation Pipe와 class-validator
  7. 7 — Nest.js API 문서화 — Swagger Module
  8. 8 — Nest.js 환경 변수 관리 — Config Module

Nest.js의 주요 기능 중에는 Decorator가 있다.

그간 사용해 오면서 “@가 붙은 어떤 기능” 정도로만 알고 있었는데, 생각보다 자주, 다양하게, 중요하게 사용되는 기능 중 하나이기에 한번 정리해볼 필요성을 느꼈다. 정리를 통해 Decorator의 동작원리를 이해하고 용도별 사용 방법을 구분지어 보자.

Decorator 동작원리

데코레이터(@)는 TypeScript의 기능 중 하나로, 클래스, 메소드, 프로퍼티, 파라미터에 부가적인 메타데이터를 추가하고, 기능을 확장하고 구성하기 위한 기능이다. Nest.js에서는 내장 기능으로 데코레이터를 지원한다.

동작원리

데코레이터는 일종의 ‘함수’로서 기본적으로 ‘@decorator’ 와 같은 형식으로 적용한다. 이때 decorator는 반드시 함수여야 한다.

특정 목표에 데코레이터를 달아두면 @ 기호가 TypeScript에게 이것이 데코레이터임을 알려주고, TypeScript는 클래스 실행 시 플러그인 형태로 실행되도록 한다.

먼저 TypeScript에서 데코레이터를 사용하기 위해선 tsconfig.json 파일에서 아래 옵션을 활성화해 주어야 한다.

{
  "compilerOptions": {
    "experimentalDecorators": true, // 데코레이터 사용 가능 여부
    "emitDecoratorMetadata": true // 데코레이터에서 사용된 타입 정보 얻을 수 있는지 여부(메타데이터 생성)
  }
}

동작원리는 아래와 같다.

function readonly(writable: boolean) {
  return function (target: any, PropertyName: any): any {
    return {
      writable: !writable,
    };
  };
}

class DTO {
  @readonly(false)
  data = 0;
}
  • 미리 선언된 readonly() 함수를 data 프로퍼티에 위에 데코레이터로 장식.
  • 런타임이 실행되면서, 데코레이터 함수에 target(target), key(PropertyName) 등의 정보가 전달되고 리턴된 값으로 프로퍼티의 속성을 적용.

데코레이터의 역할은 이렇게 말할 수 있다.

@함수를 장식해줌으로써, 코드가 실행(런타임)이 되면 함수가 실행되어 클래스, 메소드, 프로퍼티, 파라미터와 같은 장식된 멤버를 보다 파워풀하게 꾸며준다. “

멀티 데코레이터

데코레이터는 하나의 멤버에 동시에 여러 개의 데코레이터를 장식할 수 있다.

@decorator1
@decorator2
login(){}

이때, 데코레이터들이 실행되는 순은 다음과 같다.

  • 평가 위에서 아래로
  • 실행: 아래서 위로

즉, 데코레이터 함수 호출과 로직의 실행은 위에 있는 데코레이터(@decorator1)가 우선이 되지만 실제 적용은 아래에 있는 데코레이터(@decorator2)가 먼저 적용된다.

이는 데코레이터 나열 순서에 있어 영향을 미치는데, 먼저 적용해야하는 데코레이터일 수록 아래에 배치해야함을 의미한다.

Decorator 종류

Nest.js의 내장 데코레이터는 기본적으로 @nestjs/common에서 제공한다.

Class 수준의 데코레이터

  • @Module(): Nest.js 애플리케이션의 Module 클래스를 정의.

    // module.ts
    import { Module } from '@nestjs/common';
    import { UserController } from './user.controller';
    import { UserService } from './user.service';
    
    @Module({
      controllers: [UserController],
      providers: [UserService]
    })
    export class UserModule {}
  • @Controller(): HTTP 요청을 처리하는 Controller 클래스를 정의.

    // controller.ts
    import { Controller } from '@nestjs/common';
    
    @Controller('user')
    export class UserController {}
  • @Injectable(): 의존성 주입이 가능한 Service 클래스를 정의.

    // service.ts
    import { Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {}
  • @Entity(): ORM 라이브러리에서 데이터베이스 테이블과 매핑 시 사용하는 Entity 클래스를 정의.

    // entity.ts
    import { Entity } from 'typeorm';
    
    @Entity({ schema: 'database', name: 'User' })
    export class User {}

Method 수준의 데코레이터

  • HTTP Method HTTP 요청 Method로 지정하는 데에 사용.

    import { ... } from '@nestjs/common';
    
    @Get()
    // GET 요청
    
    @Post()
    // POST 요청
    
    @Put()
    // PUT 요청
    
    @Patch()
    // PATCH 요청
    
    @Delete()
    // DELETE 요청

    예시(controller.ts):

    import { Controller, Delete, Get, Patch, Put, Post } from '@nestjs/common';
    
    @Controller('user')
    export class UserController {
    
      @Post('login')
      async login() {}
    
      @Post('sign-up')
      async signUp() {}
    
      @Patch('password/reset')
      async ResetPassword() {}
    
      @Get('info/:userId')
      async getUserInfo() {}
    
      @Put('info/:userId')
      async updateUserInfo() {}
    
      @Delete('withdrawal/:userId')
      async withdrawal() {}
    }
  • HTTP Request HTTP 요청 정보를 추출하는 데에 사용.

    import { ... } from '@nestjs/common';
    
    @Body()
    // 바디 데이터를 추출
    
    @Param()
    // 라우트에 정의된 경로의 파라미터를 추출
    
    @Headers()
    // 헤더 정보를 추출
    
    @Query()
    // 쿼리스트링('?')에서 파라미터를 추출
    
    @Req()
    // request 객체를 직접적으로 다루기 위해 사용
    
    @Res()
    // response 객체를 직접적으로 다루기 위해 사용
    
    * 인자에 특정값을 입력해 특정값만 추출할 수 있음

    예시(controller.ts):

    import { Controller, Delete, Get, Patch, Put, Post, Body, Param, Req, Res } from '@nestjs/common';
    
    @Controller('user')
    export class UserController {
    
      @Post('login')
      async login(@Req() req: any, @Res() res: any) {}
    
      @Post('sign-up')
      async signUp(@body('username') username: string, @body('password') password: string) {}
    
      @Patch('password/reset/:userId')
      async ResetPassword(@Param('userId') userId: number, @body('password') password: string) {}
    
      @Get('info')
      async getUserInfo(@Param('userId') userId: number) {}
    
      @Put('info')
      async updateUserInfo(@Param('userId') userId: number, @body() userData: string) {}
    
      @Delete('withdrawal')
      async withdrawal(@Param('userId') userId: number) {}
    }
  • Middleware 미들웨어로 사용하고자 하는 클래스를 적용하는 데에 사용.

    import { ... } from '@nestjs/common';
    
    @UseGuards()
    // 특정 조건을 충족하는지 검사하는 가드를 적용
    
    @UseFilters()
    // 특정 예외 처리를 하는 필터를 적용
    
    @UseInterceptors()
    // HTTP 요청이 처리되는 동안 특정한 작업을 수행하는 인터셉터를 적용
    
    @UsePipes()
    // 데이터를 유효성 검사하거나 변환하는 파이프를 적용

    예시(controller.ts):

    import { Controller, UseGuards, Post, Body, Req, Res } from '@nestjs/common';
    import { LocalAuthGuard } from '../guards/local-auth.guard';
    
    @Controller('user')
    export class UserController {
    
      @Post('login')
      @UseGuards(LocalAuthGuard)
      async login(@Req() req: any, @Res() res: any) {}
    }

외부 라이브러리 데코레이터

  • typeorm ORM 사용 시, Entity 클래스 내의 데이터베이스 컬럼을 지정하는 데에 사용.

    import { ... } from 'typeorm';
    
    @PrimaryColumn()
    // 엔티티의 프라이머리키로 사용되는 컬럼을 정의(수동 생성)
    
    @PrimaryGeneratedColumn()
    // 엔티티의 프라이머리키로 사용되는 컬럼을 정의(자동 생성: 'increment' or 'uuid')
    
    @Column()
    // 일반적인 컬럼을 정의
    
    @OneToOne()
    // 일대일(One-to-One) 관계를 정의
    
    @OneToMany()
    // 일대다(One-to-Many) 관계를 정의
    
    @ManyToMany()
    // 다대다(Many-to-Many) 관계를 정의
    
    @ManyToOne()
    // 다대일(Many-to-One) 관계를 정의
    
    @JoinColumn()
    // @OneToOne 및 @ManyToMany 관계에서 세부적인 설정을 정의(지정할 경우 해당 테이블에 외래키 설정)

    예시(entity.ts):

    import {
      Entity,
      PrimaryGeneratedColumn,
      Column,
      CreateDateColumn,
      UpdateDateColumn,
      OneToOne,
      OneToMany,
      ManyToOne,
      JoinColumn,
    } from 'typeorm';
    
    @Entity({ schema: 'database', name: 'User' })
    export class User {
      @PrimaryGeneratedColumn({ type: 'int', unsigned: true })
      id: number;
    
      @Column({ type: 'varchar', length: 30, unique: true })
      username: string;
    
      @Column({ type: 'varchar', length: 100 })
      password: string;
    
      @CreateDateColumn()
      createdAt: Date;
    
      @UpdateDateColumn()
      updatedAt: Date;
    
      @OneToOne(() => UserProfile, (userProfile) => userProfile.user, { cascade: true })
      @JoinColumn()
      userProfile: UserProfile;
    
      @OneToMany(() => UserFile, (userFile) => userFile.user)
      userFiles: UserFile[];
    
      @ManyToOne(() => Admin, (admin) => admin.user)
      admin: Admin[];
    }
  • class-validator 데이터의 유효성 검사, 변환 등의 작업에서 사용되며 보통 DTO의 특성을 정의할 때 사용.

    import { ... } from 'class-validator';
    
    # 문자열 데이터 타입 확인
    @IsString()
    // 값이 문자열인지 확인
    
    @IsEmail()
    // 값이 유효한 이메일 주소 형식인지 확인
    
    @IsUrl()
    // 값이 유효한 URL 주소 형식인지 확인합니다.
    
    @IsDateString()
    // 값이 유효한 날짜 형식인지 확인합니다.
    
    @IsBooleanString()
    //값이 부울(boolean) 문자열인지 확인합니다.
    
    @IsCreditCard()
    // 값이 신용카드 번호 형식인지 확인합니다.
    
    @MinLength()
    // 문자열의 최소 길이를 지정
    
    @MaxLength()
    // 문자열의 최대 길이를 지정
    
    
    # 숫자 데이터 타입 확인
    @IsNumber()
    // 값이 숫자인지 확인
    
    @IsInt()
    // 값이 정수인지 확인
    
    @IsPositive()
    // 값이 양수인지 확인
    
    @IsNegative()
    // 값이 음수인지 확인
    
    @Min()
    // 값이 특정 최소값보다 큰지 확인
    
    @Max()
    // 값이 특정 최대값보다 작은지 확인
    
    @IsDivisibleBy()
    // 값이 특정 숫자로 나누어 떨어지는지 확인
    
    
    # 기타 데이터 타입 확인
    @IsBoolean()
    // 값이 불리언인지 확인
    
    @IsEnum()
    // 값이 특정 enum 유형에 속하는지 확인
    
    @IsArray()
    // 값이 배열인지 확인
    
    @IsObjact()
    // 값이 객체인지 확인
    
    @IsDate()
    // 값이 날짜 타입인지 확인
    
    
    # 데이터 존재 확인
    @IsNotEmpty()
    // 값이 비어있지 않은지 확인 (null, undefined, 빈 문자열 등을 허용하지 않음)
    
    @IsNotEmptyArray()
    // 값이 비어있지 않은 배열인지 확인
    
    @IsNotEmptyObject()
    // 값이 비어있지 않은 객체인지 확인
    
    @IsOptional()
    // 값이 주어지지 않아도 통과
    
    # 특수한 유효성 검사
    @Validate()
    // 복잡한 유효성 검사 규칙을 적용
    
    @ValidateIf()
    // 주어진 조건에 따라 유효성 검사를 수행하도록 하는 조건부 유효성 검사
    
    @ValidateNested()
    // 클래스의 속성으로 정의된 객체나 배열의 내부를 검사
  • Swagger Swagger를 이용한 Api 문서화 시 사용하는 기능 및 데이터 설명, 특성을 설정하는 데에 사용.

    # 문서화
    @ApiTags()
    // 컨트롤러나 메서드에 태그를 추가(API 문서를 그룹화)
    
    @ApiOperation()
    // 컨트롤러 메서드에 대한 작업에 대한 제공(title, description)
    
    @ApiResponse()
    // API 응답 성공/실패 시 반환되는 데이터의 형식과 내용을 정의
    
    
    # 요청 데이터 설명(description, example, required, type, enum, default, schema 등을 정의)
    @ApiProperty()
    // 속성에 대한 설명 추가
    
    @ApiBody()
    // 바디에 대한 설명 추가
    
    @ApiParam()
    // 경로 매개변수에 대한 설명 추가
    
    @ApiQuery()
    // 쿼리 매개변수에 대한 설명 추가
    
    @ApiHeader()
    // 헤더에 대한 설명 추가
    
    
    # 보안 기능 관련
    @ApiBearerAuth()
    // Bearer 토큰 인증을 할 수 있도록 설정(자물쇠)
    
    
    # 미디어 타입 지정
    @ApiConsumes()
    // 요청으로 받을 미디어 타입을 지정(application/json | application/x-www-form-urlencoded | multipart/form-data)
    
    @ApiProduces()
    // 응답으로 보낼 미디어 타입을 지정(application/json | text/html | application/xml | image/jpeg, image/png, image/gif)

Custom Decorator

데코레이터를 직접 만들 수 있다는 사실은 특정 문제에 직면한 이후 해결 방법으로써 알게 되었다.

문제 발생

현재 진행 중인 프로젝트는 user를 기반으로 하는 서비스이기에 대부분의 기능에 user 인증 절차가 필요하다. 그렇기에 인증 토큰을 검증하는 JwtAuthGuard를 프로젝트 전역으로 설정해두었다.

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from '../shared/guards/jwt-auth.guard';

@Module({
  imports: [...],
  providers: [
    {
      provide: APP_GUARD,
      useClass: JwtAuthGuard,
    },
  ],
})
export class AppModule {}

하지만 이로 인해 user 인증이 필요하지 않은 메소드에서까지 인증을 요구하게 되는 문제가 발생하게 되었습니다.

커스텀 데코레이터로 문제 해결

이 문제는 특정 메소드에서는 user 인증을 무시하는 데코레이터를 만들어 해결했다.

  • 데코레이터 생성

    import { SetMetadata } from '@nestjs/common';
    
    export const IS_PUBLIC_KEY = 'isPublic';
    export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

    Nest.js에서는 SetMetadata()라는 데코레이터 설정 메소드를 제공한다. 이 함수에 keyvalue를 지정해 메타데이터를 설정할 수 있다.

    Public라는 함수의 메타데이터로 IS_PUBLIC_KEY(='isPublic')true로 설정하는 데코레이터를 등록해두었다.

  • 데코레이터 적용

    import { Body, Controller } from '@nestjs/common';
    import { UserService } from './user.service';
    import { CreateUserDto } from '../../shared/dto/user.dto';
    import { Public } from 'src/shared/decorators/skip-auth.decorator';
    
    @Controller('users')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Post('sign-up')
      @Public()
      async signUp(@Body() userData: CreateUserDto) {
        return await this.userService.signUp(userData);
      }
    }

    등록한 Public 데코레이터를 회원가입과 같은 user 인증이 필요하지 않은 메소드에 장식해두었다.

    이 데코레이터로 user 인증을 강제하는 Guard를 무시할 예정이다.

  • 데코레이터 작동

    import { Injectable } from '@nestjs/common';
    import { AuthGuard } from '@nestjs/passport';
    import { Reflector } from '@nestjs/core';
    import { IS_PUBLIC_KEY } from 'src/shared/decorators/skip-auth.decorator';
    
    @Injectable()
    export class JwtAuthGuard extends AuthGuard('jwt') {
      constructor(private reflector: Reflector) {
        super();
      }
    
      ...
    
      // 전역 Guard 예외 설정 - @Public
      canActivate(context: ExecutionContext) {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
          context.getHandler(),
          context.getClass(),
        ]);
    
        if (isPublic) return true;
        else return super.canActivate(context);
      }
    }

    Api 요청이 들어오면 전역으로 설정한 user 인증 Guard가 강제로 호출된다. 이때 Nest.js의 Guard에서 제공하는 canActivate() 메소드는 요청이 특정 엔드포인트에 도달하기 전에 실행되는 메소드로서 주어진 요청이 허용되는지 여부를 결정하는데 이 메소드에 필요한 로직을 구성하면 된다.

    1. 먼저, 현재 요청의 컨텍스트 정보가 담겨 있는 인자로 주어진 context: ExecutionContext에서 메타데이터 key(IS_PUBLIC_KEY)를 기반으로 현재 핸들러(getHandler())와 클래스(getClass())에서 메타데이터를 가져오는 가져오고,

    2. 메타데이터를 관리하는 도구인 reflector: Reflector를 이용해 메타데이터 설정 시 지정한 key의 value(true)를 찾아 변수 isPublic에 담는다.

    3. 마지막으로, isPublic의 값이 지정한대로 경우, true를 반환하여 해당 Guard를 빠져나가고, 만약 false 또는 메타데이터 값이 없는 경우, super를 통해 부모 클래스인 AuthGuardcanActivate(context)를 실행해 user 인증을 진행하게 된다.

    4. 결론적으로, @Public()을 장식한 메소드는 Guard를 무시할 수 있고 @Public()가 없는 메소드에서는 예정대로 전역으로 설정한 user 인증 Guard를 강제할 수 있다.

참고 자료

  • [1] 타입스크립트 @데코레이터 개념 & 사용법
목차