본문 바로가기
카테고리 없음

Nest.js(3) 프러바이더, 모듈, 파이프

by Chars4785 2022. 6. 24.

프러바이더(Provider)

컨트롤러는 요청과 응답을 가공하고 처리하는 역할을 맡는다고 했습니다.

만약 음식 배달 앱에서 메뉴 목록 조회를 요청했다고 했을 때, 사용자 주변에 위치한 가게를 DB에서 검색하는 작업을 수행해야 합니다. 또 사용자가 좋아할 만한 메뉴가 학습되어 있다고 하면 이를 기반으로 추천 메뉴 구성을 바꿀 수도 있을 것입니다.

 

앱이 제공하고자 하는 핵심 기능, 즉 비즈니스 로직을 수행하는 역할을 하는 것이 프로바이더입니다. controlloer 에서 할수 있지만 단일 책임 원칙( SRP, single responsbility principle ) 나눠 줘야 한다. Nest에서 제공하는 프로바이더의 핵심은 의존성을 주입할 수 있다는 점입니다.

 

💡 의존성 주입(Dependency Injection, DI)은 OOP에서 많이 활용하고 있는 기법입니다. 의존성 주입을 이용하면 객체를 생성하고 사용할 때 관심사를 분리할 수 있습니다. 이는 코드 가독성과 재사용성이 높은 SW를 만들게 도와줍니다

 

컨트롤러는 비즈니스 로직을 직접 수행하지 않습니다. 컨트롤러에 연결된 UsersService에서 수행합니다. UsersService는 UsersController의 생성자에서 주입받아, usersService라는 객체 멤버 변수에 할당되어 사용되고 있습니다.

import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
    ...

  remove(id: number) {
    return `This action removes a #${id} user`;
  }
}

@Injectable 데코레이터를 주목하세요. UsersService 클래스에 이 데코레이터를 선언함으로써 다른 어떤 Nest 컴포넌트에서도 주입할 수 있는 프로바이더가 됩니다. 별도의 스코프(Scope)를 지정해 주지 않으면 일반적으로 싱글톤 인스턴스가 생성됩니다.

프로바이더 등록

프로바이더 인스턴스 역시 모듈에서 사용할 수 있도록 등록을 해 주어야 합니다. 자동 생성된 코드에서 UsersModule 모듈에 등록해 둔 것을 볼수 있습니다.

@Module({
    ...
  providers: [UsersService]
})
export class UsersModule {}

속성 ( Property ) 기반 주입

DI (Dependency Injection)

만약 DI를 사용하지 않고 Coffee 클래스의 상속을 받은 Cappuccino나 Americano 클래스를 사용해야 한다면 다음과 같이 직접 수정해 줘야 합니다.

class Coffee {...} // interface로 설계할 수도 있다

// Coffee 클래스를 상속
class Cappuccino extends Coffee {...}
class Americano extends Coffee {...}

// Programmer.java
class Programmer {
    private Coffee coffee;

    public Programmer() {
    	this.coffee = new Cappuccino(); // 직접 수정
        // 또는 
        this.coffee = new Americano(); // 직접 수정
    }
    
    ...
}

의존성 주입(DI)을 이용한다면 아래와 같이 할 수 있습니다.

// Programmer.java
class Programmer {
    private Coffee coffee;

    // 그 날 마실 커피를 고를 수 있게된 개발자
    public Programmer(Coffee coffee) {
    	this.coffee = coffee;
    }
    
    public startProgramming() {
    	this.coffee.drink();
        ...
    }
}

위와 같이 필요한(의존하는) 클래스를 직접 생성하는 것이 아닌, 주입해줌으로써 객체 간의 결합도를 줄이고 좀 더 유연한 코드를 작성할 수 있게됩니다.

즉, 한 클래스를 수정하였을 때, 다른 클래스도 수정해야 하는 상황을 막아줄 수 있습니다.

 

동일하게 nest js 에서 발생된다.

@Controller('user')
export class UserController {
 
  // 의존성(Dependency) 주입
  **constructor(private userService: UserService) {
    this.userService = userService;
  }**
  
  ...

추후 공부를 더 하고 정리 필요할듯 하다. 실제로 실습을 해봐야 느낌이 올듯 하다.

정리 요망

- 커스텀 프로바이더, 스코프 등 정리

 

모듈( Module )

일반적으로 모듈이라고 하면 조그만 클래스나 함수처럼 한가지 일만 수행하는 소프트웨어 컴포넌트가 아니라, 여러 컴포넌트를 조합하여 작성한 좀 더 큰 작업을 수행하는 단위를 말합니다. 음식 배달 서비스에서 유저의 정보를 관리하고 로그인을 처리하는 UsersModule, 유저의 주문을 저장하고 주문 상태를 관리하는 OrdersModule, 가게 사장님과의 채팅기능을 담당하는 ChatModule 등 여러개의 모듈이 모여 배달 서비스를 이루게 됩니다. 매우 작은 애플리케이션이라면 하나의 모듈만 있어도 충분하겠지만 응집도를 높이는 작업을 게을리 하면 의존관계가 복잡한 코드로 변하는 것은 시간 문제입니다.

모듈은 @Module() 데코레이터를 사용합니다. @Module 데코레이터의 인자로 ModuleMetadata를 받습니다. ModuleMetadata의 인터페이스는 다음과 같습니다.

export declare function Module(metadata: ModuleMetadata): ClassDecorator;

export interface ModuleMetadata {
    imports?: Array<Type<any> | DynamicModule | Promise<DynamicModule> | ForwardReference>;
    controllers?: Type<any>[];
    providers?: Provider[];
    exports?: Array<DynamicModule | Promise<DynamicModule> | string | symbol | Provider | ForwardReference | Abstract<any> | Function>;
}
  • import: 이 모듈에서 사용하기 위한 프로바이더를 가지고 있는 다른 모듈을 가져옵니다. 음식 배달 서비스에서 UsersModule, OrdersModule, ChatModule을 가져와서 함께 빌드되도록 합니다.
  • controllers / providers: 앞선 장에서 이미 사용해 보았습니다. 모듈 전반에서 컨트롤러와 프로바이더를 사용할 수 있도록 Nest가 객체를 생성하고 주입할 수 있도록 해 줍니다.
  • export: 이 모듈에서 제공하는 컴포넌트를 다른 모듈에서 import 해서 사용하고자 한다면 export 해야 합니다. 예를 들어 모듈 A, B, C가 있다고 할 때 만약 A 모듈에서 B 모듈을 가져오고 C 모듈이 A를 가져왔다고 합시다. 이때 C모듈이 B모듈을 사용하도록 하고 싶다면 가져온 모듈을 내보내야(export) 합니다. export로 선언했다는 뜻은 어디에서나 가져다 쓸 수 있으므로 public 인터페이스 또는 API로 간주됩니다.

모듈 다시 내보내기

  • CoreModule.ts
@Module({
  imports: [CommonModule],
  exports: [CommonModule],
})
export class CoreModule { }

CoreModule은 CommonModule을 가져온 후 다시 내보냅니다.

@Module({
  imports: [CoreModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
@Controller()
export class AppController {
  constructor(private readonly commonService: CommonService) { }

  @Get('/common-hello')
  getCommonHello(): string {
    return this.commonService.hello();
  }
}

 

$ curl -X GET http://localhost:3000/common-hello
Hello from CommonService

export 를 통해서 다시 사용할수 있다고 하는데 주입성하고 무슨 차이가 있는지는 잘 모르겠다.

전역모듈

Nest는 모듈 범위내에서 프로바이더를 캡슐화합니다. 따라서 어떤 모듈에 있는 프로바이더를 사용하려면 모듈을 먼저 가져와야 합니다. 하지만 헬퍼와 같은 공통 기능이나 DB 연결과 같은 전역적으로 쓸 수 있어야 하는 프로바이더가 필요한 경우가 있습니다. 이런 프로바이더를 모아 전역 모듈로 제공하면 됩니다.

객체지향 언어를 많이 다루어 본 독자가 있다면 모든 것을 전역으로 만드는 게 SW 구조상 좋지 않다는 것을 알고 계실 겁니다. 모듈은 응집도를 높이기 위함이라 했는데 모든 것을 전역으로 만들면 기능이 어디에나 존재하게 된다는 뜻이므로 응집도가 떨어지게 될 것입니다. 꼭 필요한 기능만 모아 전역 모듈로 사용하도록 하세요.

@Global()
@Module({
  providers: [CommonService],
  exports: [CommonService],
})
export class CommonModule { }

동적 모듈

정적 모듈에 비해 동적 모듈을 사용하면 코드가 간결해 집니다. 모듈 인스턴스가 생성될 때 결정되기는 하지만 모듈 인스턴스마다 다르게 결정되어야 하는 것들을 소비모듈에서 지정할 수 있기 때문입니다. 또한 동적 모듈은 정적모듈과 함께 제공할 수도 있습니다.

동적 모듈의 대표적인 예로 보통 Config라고 부르는 모듈이 있습니다. Config 모듈은 실행환경에 따라 서버에 설정되는 환경변수를 관리하는 모듈입니다. ConfigModule을 동적으로 생성하는 예를 보기 전에 잠시 Node.js 서버에서 일반적으로 사용하는 환경변수 관리 방법을 살펴보겠습니다.

Nest에서 제공하는 Config 패키지

npm i --save @nestjs/config
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [ConfigModule.forRoot({
    envFilePath: (process.env.NODE_ENV === 'production') ? '.production.env'
      : (process.env.NODE_ENV === 'stage') ? '.stage.env' : '.development.env'
  })],
  controllers: [AppController],
  providers: [AppService, ConfigService],
})
export class AppModule { }

이제 다시 앞에서와 같이 환경변수 값이 잘 출력되는지 확인해 보세요.

파이프 ( Pipe )

파이프는 요청이 라우터 핸들러로 전달되기 전에 요청 객체를 변환할 수 있는 기회를 제공합니다. 미들웨어 비슷하지만

- 미들웨어는 애플리케이션의 모든 컨텍스트에서 사용하도록 할 수 없습니다

- 미들웨어는 현재 요청이 어떤 핸들러에서 수행되는지, 어떤 파라미터를 가지고 있는지에 대한 실행 컨텍스트를 알지 못하기 때문입니다.

파이프는 보통 다음 2가지의 목적으로 사용합니다.
- 변환(Transformation): 입력데이터를 원하는 형식으로 변환
ex) /users/user/1 내의 경로 파라미터 문자열 1을 정수로 변환
- 유효성 검사(Validation): 입력 데이터가 사용자가 정한 기준에 유효하지 않은 경우 예외 처리

@nest/common 패키지에는 내장 파이프가 마련되어 있습니다.

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe

받은 값을 문자열에서 정수로 변환하기 위해서 컨트롤러에서 id를 매번 정수형으로 변환해서 써야하는 것은 불필요한 중복코드를 양산하게 됩니다. 아래 코드와 같이 @Param 데코레이터의 두번째 인자로 파이프를 넘겨 현재 실행 컨텍스트(ExecutionContext)에 바인딩할 수 있습니다.

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return this.usersService.findOne(id);
}

이제 id에 정수로 파싱가능하지 않은 문자를 전달해 봅시다.

 

DefaultValuePipe는 인자의 값에 기본값을 설정할 때 사용합니다. 

@Get()
findAll(
  @Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {
  console.log(offset, limit);

  return this.usersService.findAll();
}

파이프의 내부 구현 이해하기

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

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
        console.log(metadata);
    return value;
  }
}

PipeTransform의 원형은 다음처럼 정의되어 있습니다.

export interface PipeTransform<T = any, R = any> {
    transform(value: T, metadata: ArgumentMetadata): R;
}

구현해야 하는 transform 함수는 2개의 매개변수를 가지고 있습니다.

  • value: 현재 파이프에 전달된 인자
  • metadata: 현재 파이프에 전달된 인자의 메타데이터

유효성 검사 파이프 만들기

💡 Nest 공식 문서에는 @UsePipes 데코레이터와 Joi 라이브러리를 이용하여 커스텀 파이프를 바인딩하는 방법을 설명하고 있습니다. Joi는 널리 사용되는 유효성 검사 라이브러리입니다

 

ValidationPipe를 모든 핸들러에 일일이 지정하지 않고 전역으로 설정하려면 부트스트랩 과정에서 적용하면 됩니다. 이미 앞에서 다른 컴포넌트를 전역으로 지정할 때 보았던 것과 유사합니다.

import { ValidationPipe } from './validation.pipe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe())
  await app.listen(3000);
}
bootstrap();

ValidationPipe를 직접 만들어 사용해 보았습니다. 하지만 이미 Nest가 마련해둔 ValidationPipe가 있기 때문에 굳이 따로 만들 필요는 없습니다. 동작원리만 파악하고 Nest가 제공하는 ValidationPipe를 가져다 쓰도록 하세요.

유저 생성 본문의 유효성 검사

class-validator를 이용하여 위 규칙을 적용해 보았습니다.

import { IsEmail, IsString, Matches, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(30)
  readonly name: string;

  @IsString()
  @IsEmail()
  @MaxLength(60)
  readonly email: string;

  @IsString()
  @Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
  readonly password: string;
}

우리가 방금 적용한 유효성 검사 규칙에서 password는 @Matches()를 통한 정규표현식을 이용했습니다.

정규표현식은 문자열 규칙을 판단하는 강력한 도구입니다. 많은 곳에 사용되니 반드시 따로 학습해야 합니다. 언어별로 사용법이 조금씩 다르니 여러분이 주로 사용하는 언어기반으로 학습하세요.

@Transform

name 속성에 @Transform 데코레이터를 다음과 같이 적용해서 TransformFnParams에 어떤 값들이 전달되는지 확인해 봅시다. transformFn은 변형한 속성을 다시 리턴해야 하는데 아무 작업도 하지 않았으니, param.value를 그대로 돌려줍니다.

@Transform(params => {
  console.log(params);
  return params.value;
})
@IsString()
@MinLength(2)
@MaxLength(30)
readonly name: string;

직접 개발도 가능합니다.

 

 

 

 

 

참고

https://velog.io/@wlsdud2194/what-is-di

 

[DI] Dependency Injection이란 무엇일까?

Dependency Injection, 의존성 주입이 무엇이고 어떤 이점이 있는지 예시를 통해 정리한 글입니다.

velog.io

https://wikidocs.net/148511

댓글