새소식

반응형
Back-end/NestJS

[NestJS | Docs] Module 알아보기 (+동적 모듈/Dynamic modules)

2024.02.05
  • -
반응형

1. Module 이란?

소프트웨어 설계에서 module이란 '애플리케이션을 기능 단위로 분해하고 추상화하여 재사용공유 가능한 수준으로 만들어진 단위'를 의미합니다. 이를 통해 나온 '모듈화'의 의미는 소프트웨어의 성능을 향상시키거나 시스템의 디버깅, 테스팅, 통합 및 수정을 용이하도록 하는 작업입니다.

 

Nest에서의 module도 이러한 개념에서 크게 벗어나지 않습니다.

 

Nest에서 module이란 @Module() 데코레이터가 붙은 클래스를 의미합니다. @Module() 데코레이터는 Nest가 애플리케이션 구조를 조직할 때 보다 더 용이하게 만들어주는 메타데이터를 제공하는 역할을 합니다.

 

공식문서 Module 구조

각 애플리케이션은 적어도 하나의 module을 가지게 되는데, 그 module은 root module이라고 불리는 모듈입니다. 가장 뿌리가 되는 module이라는 것이죠. root module은 Nest가 애플리케이션 그래프를 만들 때 사용하는 시작점입니다.

 

즉, Nest가 module과 provider의 관계 그리고 의존성을 해결하는 데에 사용하는 내부 데이터 구조가 바로 모듈이라고 할 수 있겠습니다.

 

매우 작은 애플리케이션은 이론적으로 root module 하나만을 가지겠지만, 그러한 경우가 일반적이지는 않습니다. Nest 측에서는 우리가 각 구성요소를 조직하는 데 사용하는 효율적인 방법으로 module의 사용을 강력히 권고하고 있습니다.

 

그래서 대부분의 애플리케이션에서 최종적인 모습을 보면 다수의 module이 사용되고 있으며, 각 모듈이 서로 밀접하게 연관되어 기능 집합들을 캡슐화하는 구조를 갖고 있습니다.

 

@Module() 데코레이터는 다음과 같은 속성(property)들을 가진 하나의 객체를 매개변수로 갖습니다.

  • providers: Nest injector(주입자)가 인스턴스화 시키고 최소 현재 모듈 범위에서 공유되는 provider들의 목록
  • controllers: 이 모듈에서 정의되어 인스턴스화 될 controller들의 목록
  • imports: 이 모듈에서 필요한 외부의 provider들을 import 하려는 경우, 그 provider들을 내보낸 module들의 목록
  • exports: 이 모듈이 제공하는 providers의 부분집합이며, 이 모듈을 다른 모듈에서 imports에 등록시켰을 때 사용 가능해야 함. (provider 자체 또는 provider token value로 명시할 수 있다.)

 

module은 기본적으로 provider캡슐화하도록 설정되어 있습니다. 즉, 현재 모듈에 직접적으로 포함되지 않아 있거나 import 한 모듈에서 export 하지 않은 provider를 주입하는 것이 불가능하다는 의미입니다. 그렇기에 module의 API 또는 공용 인터페이스로 module에서 export 되어진 provider를 고려해야 할 것입니다.

 

1-1. Feature modules

 

CatsController와 CatsService는 같은 feature를 가졌기에 동일한 애플리케이션 도메인에 속합니다. 둘은 밀접하게 관련되어 있기 때문에 이것들을 별도의 feature module(기능 모듈)을 마련하여 그곳으로 그 둘을 옮기는 것은 매우 합리적이라고 할 수 있습니다.

 

feature module특정 기능과 관련된 코드를 간단히 정리하여 코드를 더 체계적으로 유지하고 명확한 경계를 설정합니다.

 

이를 통해 애플리케이션 및 팀의 규모가 커질수록 복잡성을 잘 관리할 수 있고 SOLID 원칙에 따라 원활히 개발할 수 있게 됩니다.

 

아래는 지금까지 설명한 모습의 CatsModule의 모습입니다.

// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

 

cats.module.ts 파일에 CatsModule 클래스를 정의하고 이 모듈과 관련된 모든 것들은 cats 디렉토리로 옮겨줍니다. 마지막으로 해줘야 하는 일은 이 모듈을 root module(app.module)에서 import 하는 것입니다.

import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule {}

 

 

이러한 경우 아래와 같은 폴더 구조를 가지게 될 것입니다.

/src
|-- /cats
|   |-- /dto
|   |   |--create-cat.dto.ts
|   |-- interfaces
|   |   |--cat.interface.ts
|   |-- cats.controller.ts
|   |-- cats.module.ts
|   |-- cats.service.ts
|-- app.module.ts
|-- main.ts

 

 

1-2. Shared modules

Nest에서 module 클래스는 기본적으로 singleton 패턴입니다. 그렇기에 여러 module 간에 모든 provider의 동일한 인스턴스를 손쉽게 공유할 수 있습니다.

  • singleton 패턴이란? 특정 클래스의 인스턴스가 1개만 생성되는 것을 보장하는 디자인 패턴을 말합니다.

 

모든 module은 자동으로 공유됩니다.한 번 생성되면 모든 모듈에서 재사용가능한 것이죠.

 

몇 개의 다른 모듈들 사이에서 CatsService의 인스턴스를 공유하고자 하는 경우를 생각해봅시다. 이를 위해선 해당 모듈의 exports 배열에 CatsService를 추가하여 CatsService를 내보내줘야 합니다. (그래야 다른 곳에서 import 가능)

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService]
})
export class CatsModule {}

 

이제 CatsModule을 import하는 모든 모듈은 CatsService에 대해 접근을 할 수 있게 된 것이고 그 모듈들은 모두 동일한 인스턴스를 공유하여 사용하게 될 것입니다.

 

1-3. Module re-exporting

앞서 봤듯, 모듈은 해당 모듈의 내부 provider들을 export 할 수 있습니다. 심지어 그 모듈에서 import한 모듈을 또 다시 다른 모듈로 내보내는 것도 가능합니다. 아래 예시를 보면, CoreModule은 CommonModule을 불러와 CoreModule에서 다시 내보내지고 있으며, 이를 통해 CoreModule을 import하는 다른 모듈은 자동으로 CommonModule도 사용 가능하게 합니다.

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

 

1-4. Dependency injection (의존성 주입)

모듈 클래스도 클래스이므로 provider를 주입받을 수도 있습니다.

 

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {
  constructor(private catsService: CatsService) {} // 의존성 주입
}

 

하지만 모듈 클래스 그 자체는 순환 의존성(circular dependency) 때문에 자기 자신이 provider로 주입될 수는 없습니다.

 

1-5. Global modules (전역 모듈)

만약 같은 모듈 집합을 모든 곳에서 불러와야 한다면 이에 대한 설정을 하는 과정은 다소 까다로울 것입니다. Nest와 다르게 Angular의 providers는 전역 범위에서 등록되기 때문에 한 번 정의되면, 모든 곳에서 사용가능합니다.

 

하지만 Nest는 providers를 모듈 범위 내부에 캡슐화시킵니다. 캡슐화한 모듈을 먼저 불러오지 않은 곳에서는 해당 모듈의 provider를 사용할 수 없는 것 입니다.

 

그러나 helper, 데이터베이스 연결 등 모든 곳에서 즉시 사용할 수 있어야 하는 provider 집합을 제공하고 싶다면, @Global() 데코레이터를 사용하여 모듈을 전역적으로 설정하는 것이 가능합니다.

import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

 

@Global() 데코레이터는 해당 모듈을 global 범위로 만들어줍니다. 그리고 전역 모듈은 일반적으로 root module이나 핵심모듈에서 오직 한 번만 등록되어야 합니다. 위의 예시에서 CatsModule은 전역 모듈이 되어 CatsService provider는 모든 곳에서 사용 가능해지고, 해당 service를 주입하고자 하는 모듈은 imports 배열에서 CatsModule을 import 하지 않아도 됩니다.

 

그러면 모든 모듈을 애초에 전역으로 만들면 매번 import하지 않아도 되니까 편한 거 아닌가? 라고 생각하실 수도 있는데 사실 모든 모듈을 전역으로 만드는 것은 좋은 디자인 결정 사항이 아닙니다.
(낮은 모듈성, 낮은 응집도, 높은 결합도, 재사용성 감소, 이름 충돌, 성능 이슈 등의 이유가 있습니다.)

물론 전역 모듈을 사용하면 애플리케이션의 다른 부분에서 반복적으로 같은 모듈을 import 하는 코드의 양을 줄일 수 있습니다. 또한 imports 배열은 모듈의 API를 소비하는 쪽에서 사용가능하도록 하기 위해 일반적으로 선호되는 방식이기도 합니다. 그러니 반드시 필요한 것 같다고 판단될 때만 사용하는 것이 좋겠습니다.

 

Dynamic modules (동적 모듈)

Nest 모듈 시스템은 동적 모듈이라는 강력한 기능을 지원합니다. 이 기능은 provider를 등록하여 구성할 수 있는 커스터마이징이 가능한 모듈을 쉽게 만들 수 있도록 해줍니다.

 

TypeORM이나 config 모듈을 사용해보신 분들은 해당 모듈들을 import 할 때 forRoot() 와 같은 메서드를 작성하는 것을 보신적 있으실 겁니다. 해당 메서드를 통해 우리는 모듈을 사용자 맞춤형으로 설정할 수 있게 되는데 이때 사용하는 모듈 방식이 바로 동적 모듈입니다.

 

동적 모듈은 양이 많기 때문에 뒤에서 다시 다루도록 하겠습니다. 그 전에 잠시 동적 모듈이 무엇인지에 대해서만 짧게 알아보도록 하겠습니다. 

 

다음 예시는 DatabaseModule 동적 모듈을 정의한 코드입니다.

import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
  providers: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

forRoot() 메서드는 static으로 지정하여 해당 클래스의 인스턴스를 만들지 않아도 메서드를 사용할 수 있도록 한 것을 볼 수 있습니다.

 

forRoot() 메서드는 동기 또는 비동기적으로 동적 모듈을 반환할 수 있습니다.

 

이 모듈에서는 default로 Connection provider를 정의하지만 추가적으로 별개의 forRoot() 메서드에 전달된 entitiesoptions 객체에 따라 repository와 같은 provider 컬렉션도 제공합니다.

 

동적 모듈에서 반환되는 속성은 @Module() 데코레이터에 정의되는 기본 모듈의 메타데이터를 확장(extend)합니다(override X).

 

즉, 이는 정적으로 선언된 Connection provider와 동적으로 선언된 repository providers를 모두 모듈에서 내보내는 방식입니다.

 

만약 전역 범위에서 해당 동적 모듈을 등록하고 싶다면, global 속성값을 true로 설정하면 됩니다.

{
  global: true,
  module: DatabaseModule,
  providers: providers,
  exports: providers,
}

 

이렇게 만들어진 DatabaseModule은 다음과 같은 방식으로 import 되어 구성될 수 있습니다.

import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
  imports: [DatabaseModule.forRoot([User])],
})
export class AppModule {}

 

 

동적 모듈을 도로 다시 export 하려면 아래와 같이 exports 배열에서 forRoot() 메서드 호출을 생략한채 명시해주면 됩니다.

import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
  imports: [DatabaseModule.forRoot([User])],
  exports: [DatabaseModule],
})
export class AppModule {}

 

2. forRoot / register / forFeature

2-1. forRoot / forRootAsync

forRoot* 는 가장 처음에 연결해야 하는 것들에 대한 설정하는 역할을 합니니다. 대부분 기초 설정을 할 때 이것을 통해 하게 되고 그 안에 인자값을 전달하는 방식이기 때문에 동적 모듈을 리턴합니다. 

ConfigModule.forRoot()
TypeOrmModule.forRoot({})
MongooseModule.forRoot('mongodb://localhost/nest')
GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
    })

 

2-2. register / registerAsync

register*를 통해서도 동적 모듈을 제공할 수 있습니다. register는 메서드로 해당 메서드의 인자에 따라 제공되는 모듈의 구성이 달라집니다.

@Module({})
export class ConfigModule {
  static register(options: Record<string, any>): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        {
          provide: 'CONFIG_OPTIONS',
          useValue: options,
        },
        ConfigService,
      ],
      exports: [ConfigService],
    };
  }
}

 

그러면 아래와 같이 provider에 동적인 값들을 넣어줄 수 있습니다.

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;

  constructor(@Inject('CONFIG_OPTIONS') private options: Record<string, any>) {
    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

 

2-3. forFeature / forFeatureAsync

forFeature는 자체적인 inject token이 있는 dynamic provider를 생성하기 위해 사용됩니다.

 

예를 들어, TypeOrmModule의 경우 forRoot() 메서드로 데이터베이스 커넥션을 생성한 이후에 각 모듈에서 사용되는 엔티티를 forFeature()로 각각 지정해 주는 것이 그 예입니다.

@Module({
  imports: [TypeOrmModule.forFeature([Photo])],
  providers: [PhotoService],
  controllers: [PhotoController],
})
export class PhotoModule {}

 

이는 PhotoModule에서 Photo 엔티티를 사용한다는 의미이고 이로 인해 아래처럼 해당 엔티티에 대한 repository를 주입할 수 있게 됩니다.

@Injectable()
export class PhotoService {
  constructor(
    @InjectRepository(Photo)
    private readonly photoRepository: Repository<Photo>,
  ) {}

  findAll(): Promise<Photo[]> {
    return this.photoRepository.find();
  }
}

 

혹은 만약 다음처럼 custom repostiory를 가져와야 하는 경우라면 모듈에서 imports에 repository를 직접 명시해주어야 합니다.

// base.repository.ts
@EntityRepository(Author)
export class AuthorRepository extends Repository<Author> {}

// author.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([AuthorRepository])],
  controller: [AuthorController],
  providers: [AuthorService],
})
export class AuthorModule {}

// author.service.ts
@Injectable()
export class AuthorService {
  constructor(private readonly authorRepository: AuthorRepository) {}
}

 

 

3. Dynamic modules (동적 모듈)

Module 챕터에서 Nest 모듈의 기본적인 부분을 다루었다면 이번 챕터는 동적 모듈과 관련된 것들을 다뤄볼 것입니다.

 

3-1. 개요

Nest의 공식문서에서 다루는 대부분의 기본적인 애플리케이션 코드들은 형식적이고, 정적인 모듈들을 이용하여 설명되어 있습니다.

 

모듈은 전체 애플리케이션의 모듈식 부분과 함께 사용되는 provider와 controller와 같은 구성 요소 그룹을 정의합니다. 그러한 모듈들은 이러한 컴포넌트에 대한 실행 컨텍스트(Execution Context) 또는 범위(scope)를 제공합니다.

 

예를 들어, 모듈에 정의된 provider는 내보내지 않아도 같은 모듈의 다른 멤버들은 해당 provider를 찾을 수 있습니다. provider가 모듈 외부에서 인식되는 경우엔 먼저 호스트 모듈에서 내보낸 다음 이를 소비하는 모듈에서 가져와야 합니다.

익숙한 예를 볼까요?

 

먼저 UsersService를 제공하고 이를 내보내는 UsersModule을 정의하겠습니다. 이 경우 UsersModule은 UsersService의 호스트 모듈입니다.

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

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

 

다음으로 AuthModule 를 정의하는데, 이는 UsersModule을 import 해서 UsersModule가 내보낸 provider를 AuthModule 내에서 사용할 수 있도록 합니다.

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

 

이러한 구성을 통해 AuthModule에서 호스팅되는 AuthService에 UsersService를 주입하여 사용할 수 있게 됩니다.

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}
  /*
    Implementation that makes use of this.usersService
  */
}

이러한 방식을 정적 모듈 바인딩(static module binding)이라 합니다. Nest가 모듈을 서로 연결하는 데 필요한 모든 정보는 이미 호스트와 소비자 모듈에 선언되어 있습니다.

 

이 과정 속에서 어떠한 일들이 일어나는지 살펴봅시다.

 

Nest는 AuthModule 내에서 UsersService를 다음과 같이 사용할 수 있도록 합니다:

  1. UsersModule을 인스턴스화하고, UsersModule에서 자체적으로 소비하는 다른 모듈을 일시적으로 가져와 모든 종속성을 일시적으로 해결합니다.
  2. AuthModule을 인스턴스화하고, UsersModule에서 내보낸 provider를 마치 AuthModule에서 선언되었던 것처럼 AuthModule의 컴포넌트에서 사용할 수 있도록 합니다.
  3. AuthService에 UsersService의 인스턴스를 주입합니다.

 

3-2. 동적 모듈을 사용하는 경우

정적 모듈 바인딩을 사용하면 소비하는 모듈 쪽에서 호스트 모듈의 provider가 구성되는 방식에 영향을 미치지 못합니다. 왜 이것이 중요한 것일까요? 사용 사례에 따라 다르게 동작해야 하는 범용 모듈의 경우를 생각해야 하기 때문입니다.

 

이는 많은 시스템에서 '플러그인'이라는 개념과 유사한 것이며, 기본 기능을 소비자가 사용하기 전에 약간의 구성이 필요합니다.

 

Nest에서 볼 수 있는 좋은 예가 바로 Configuration 모듈입니다. 이는 많은 애플리케이션에서 Config 모듈을 사용하여 구성의 세부 정보를 외부화하는 경우 유용합니다. 이렇게 함으로써 개발자를 위한 개발 데이터베이스, 스테이징/테스트 환경을 위한 스테이징 데이터베이스 등 다양한 배포 환경에서 애플리케이션 설정을 그에 맞게 동적으로 쉽게 변경할 수 있기 때문입니다.

 

구성에 대한 매개변수의 관리를 Config 모듈에 위임하면 애플리케이션 소스 코드가 구성 매개변수와는 독립적으로 유지될 수 있습니다.

 

여기서 문제는 구성 모듈 자체가 여기저기 다 사용될 수 있기 때문에('플러그인'과 유사) 이를 사용하는 모듈에 따라 커스터마이징 해주어야 한다는 것입니다. 이것이 바로 동적 모듈의 역할인 것입니다. 동적 모듈 기능을 사용하면 구성 모듈을 동적으로 만들어 이를 소비하는 모듈이 API를 사용함으로써 구성 모듈을 가져올 때 그것이 커스터마이징되는 방식을 설정할 수 있습니다.

 

다시 말해, 동적 모듈은 지금까지 살펴본 정적 바인딩을 사용하는 것과는 달리 한 모듈을 다른 모듈로 가져오고, 가져온 모듈의 속성과 동작을 커스터마이징할 수 있는 API를 제공합니다.

 

3-3. Config module example (구성 모듈 예제)

구성 모듈에 대한 예제를 보도록 하겠습니다. 먼저 우리의 요구 사항은 구성 모듈이 option 객체를 받아서 해당 모듈을 커스터마이징하도록 하는 것입니다.

 

기본 샘플은 프로젝트 루트 폴더에 있는 .env 파일의 위치를 하드코딩합니다. 이 기능을 구성가능하도록 하여 원하는 폴더에서 .env 파일을 관리할 수 있도록 하려 한다고 가정해 보겠습니다.

 

예를 들어, 프로젝트 루트 아래에 있는 config라는 폴더에 다양한 .env 파일들을 저장하고 싶은 경우를 생각해 봅시다. 여러 프로젝트에서 ConfigModule을 사용할 때 서로 다른 폴더를 선택할 수 있어야 합니다.

 

동적 모듈을 사용하면 import 한 모듈에 매개변수를 전달하여 그러한 동작을 변경시킬 수 있습니다. 해당 모듈을 사용하는 모듈의 관점에서 이것이 어떻게 보일지에 대한 최종 목표에서 시작하여 거꾸로 작업하는 것이 도움이 됩니다. 먼저, 정적으로 Config 모듈을 import 하는 예제를 빠르게 살펴봅시다.

  • 즉, import 된 모듈의 동작에 영향을 주지 않는 접근 방식입니다.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

이번엔 구성 객체를 전달하는 동적 모듈 import가 어떤 모습일지 생각해봅시다.

 

이러한 두 예제 사이에 imports 배열 에서의 차이를 비교해봅시다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule.register({ folder: './config' })],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

 

위와 같은 동적 모듈 예제에서는 무슨 일이 벌어지게 될까요?

  1. ConfigModule은 평범한 클래스이므로 register() 라는 정적 메서드가 있어야 한다는 것을 유추할 수 있습니다. 이 메서드가 정적 메서드라는 것을 알 수 있는 이유는 클래스의 인스턴스가 아닌 ConfigModule 클래스에서 호출하기 때문입니다.
    • 참고: 곧 생성할 이 메서드는 임의의 이름을 가질 수 있지만, 관례에 따라 forRoot() 또는 register() 중 하나로 호출해야 합니다.
  2. register() 메서드는 우리가 정의한 것이므로 원하는 입력 인자를 받을 수 있습니다. 이 경우 적절한 속성을 가진 간단한 option 객체를 받아들이는 것이 일반적인 경우입니다.
  3. 지금까지 살펴본 익숙한 imports 목록에 반환값이 모듈 목록이 포함되어 있으므로 우리는 regiser() 메서드 역시도 module에 해당하는 무언가를 반환해야 한다는 것을 유추할 수 있습니다.

 

위에서 잠깐 살펴보았듯, 실제로 register() 메서드가 반환하는 것은 DynamicModule 입니다.

 

동적 모듈은 런타임에 생성되는 모듈로, 정적 모듈과 동일한 속성에 *module*이라는 속성이 하나 더 추가된 것에 불과합니다. 데코레이터에 전달된 모듈 옵션을 주의 깊게 살펴보면서 샘플 정적 모듈 선언을 살펴봅시다:

@Module({
  imports: [DogsModule],
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService]
})

 

동적 모듈은 정확히 동일한 인터페이스를 가진 객체와 module이라는 추가 속성 하나를 반환해야 합니다. module 속성은 모듈의 이름 역할을 하며, 아래 예시와 같이 모듈의 클래스 이름과 동일해야 합니다.

동적 모듈의 경우 모듈 옵션 객체모든 속성module을 제외하고는 선택 사항입니다.

 

정적 register() 메서드는 어떨까요? 이제 우리는 이 메서드의 임무가 DynamicModule 인터페이스를 가진 객체의 반환임을 알 수 있습니다. 이 메서드를 호출하면 정적 모듈의 경우에서 모듈 클래스의 이름을 나열하는 방식과 유사하게 import 목록에 모듈을 효과적을 제공합니다. 즉, 동적 모듈 API는 단순히 모듈을 반환하지만 @Module 데코레이터에서 속성을 수정하는 대신 그러한 속성들을 프로그래밍 방식으로 명시합니다.

 

그러나 아직 그림을 완성하기 위해선 다뤄야 할 몇 가지 세부 사항들이 남아있습니다.

  1. 이제 @Module() 데코레이터의 **imports** 속성은 모듈 클래스 이름(ex. [UsersModule]) 뿐만 아니라 동적 모듈을 반환하는 함수도 가질 수 있다는 것을 알 수 있습니다.
  2. 동적 모듈은 자체적으로 다른 모듈을 import 할 수 있습니다. 이 예제에서는 그렇게 하지는 않겠지만 동적 모듈이 다른 모듈의 provider에 의존하는 경우 선택적인 imports 속성을 사용하여 해당 provider를 가져오면 됩니다. 다시 말하지만, 이는 @Module() 데코레이터를 사용하여 정적 모듈의 메타데이터를 선언하는 방식과 정확히 비슷합니다.

이러한 이해를 바탕으로 이제 동적 구성 모듈 선언이 어떤 모습이어야 하는지 이해하기 쉬울 겁니다. 아래는 그 예시입니다.

import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({})
export class ConfigModule {
  static register(): DynamicModule {
    return {
      module: ConfigModule,
      providers: [ConfigService],
      exports: [ConfigService],
    };
  }
}

이제 조각들이 어떻게 서로 연결되는지 명확해지나요? ConfigModule.register(...)를 호출하면 지금까지 @Module() 데코레이터를 통해 메타데이터로 제공했던 것과 본질적으로 동일한 속성을 가진 DynamicModule 객체가 반환됩니다.

 

하지만 아직 우리가 원하는 대로 구성할 수 있는 기능을 추가하지 않았기 때문에 그다지 흥미로울 건 없습니다. 그럼 이제 그 부분에 대해 다뤄보도록 합시다.

 

3-4. Module configuration (모듈 구성)

위에서 추측한 것처럼 정적 register() 메서드에서 option 객체를 전달하는 것이 ConfigModule의 동작을 커스터마이징하는 가장 확실한 해결책입니다. 소비하는 쪽 모듈의 import 속성을 다시 한 번 살펴봅시다.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [ConfigModule.register({ folder: './config' })],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

이렇게 하면 options 객체를 동적 모듈에 전달할 수 있습니다. 그러면 이 options 객체를 ConfigModule에서 어떻게 사용할까요?

 

한 번 생각해볼까요? 기본적으로 ConfigModule은 다른 provider가 사용할 수 있도록 주입가능한(Injectable) 서비스인 ConfigService를 제공하고 export하기 위한 호스트입니다.

 

실제로 동작을 커스터마이징 하기 위해 options 객체를 읽어야 하는 것은 ConfigService입니다. 지금은 register() 메서드에서 사실은 아직 모르지만 일단 options 을 ConfigService로 가져오는 방법을 알고있다고 가정해 보겠습니다.

 

이 가정을 바탕으로 service에서 몇 가지를 변경하여 옵션 객체의 속성에 따라 동작을 커스터마이징할 수 있습니다. (참고: 아래 코드에서 실제로 어떻게 넘길지 결정하지 않았기 때문에 하드코딩했지만 이후에 바꿔줄 것입니다.)

import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;

  constructor() {
    const options = { folder: './config' };

    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

 

이제 ConfigService는 options에서 지정한 폴더에서 .env 파일을 찾는 방법에 대해 알고 있습니다.

 

남은 작업은 register() 단계의 options 객체를 ConfigService에 주입하는 부분을 실제로 만들어주는 것입니다. 이를 위해서는 당연히도 의존성 주입을 사용할 것입니다. ConfigModule은 ConfigServcie를 제공합니다. ConfigService는 런타임에만 제공되는 options 객체에 따라 달라집니다. 그렇기 때문에 런타임 시에 먼저 options 객체를 Nest IoC 컨테이너에 바인딩한 다음 Nest가 그 option 객체를 ConfigService에 주입하도록 해야 합니다.

 

custom provider 챕터에서 provider는 서비스 뿐만 아니라 모든 값을 포함할 수 있다는 것이 나와있기 때문에 의존성 주입을 사용하여 간단한 options 객체를 처리해도 괜찮습니다.

 

먼저 options 객체를 IoC 컨테이너에 바인딩해 보겠습니다. 이 작업은 정적 register() 메서드에서 수행합니다. 우리는 모듈을 동적으로 구성하고 있으며 모듈의 속성 중 하나는 provider 목록임을 기억해야 합니다.

 

따라서 우리가 해야 할 일은 options 객체를 provider로 정의하는 것입니다. 이렇게 하면 다음 단계에서 활용하게 될 ConfigService에 주입할 수 있게 됩니다. 아래 코드에서 provider 목록에 주목해봅시다.

import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({})
export class ConfigModule {
  static register(options: Record<string, any>): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        {
          provide: 'CONFIG_OPTIONS',
          useValue: options,
        },
        ConfigService,
      ],
      exports: [ConfigService],
    };
  }
}

 

이제 ConfigService에 'CONFIG_OPTIONS' provider를 주입하여 프로세스를 완료할 수 있습니다. 클래스가 아닌 token을 사용하여 provider를 정의할 때는 @Inject() 데코레이터를 사용해야 합니다:

import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import { Injectable, Inject } from '@nestjs/common';
import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
  private readonly envConfig: EnvConfig;

  constructor(@Inject('CONFIG_OPTIONS') private options: Record<string, any>) {
    const filePath = `${process.env.NODE_ENV || 'development'}.env`;
    const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
    this.envConfig = dotenv.parse(fs.readFileSync(envFile));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

 

마지막으로 한 가지 참고할 사항은, 이를 더 간단하게 하기 위해 문자열 기반의 인젝션 토큰('CONFIG_OPTIONS')를 사용했는데, 이때 가장 좋은 방법은 해당 문자열을 별도의 파일에 상수(또는 심볼)로 정의하고 해당 파일과 변수를 가져오는 것입니다. (ex. constant.ts)

export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';

 

위 코드의 full example은 여기에서 확인하실 수 있습니다.

 

3-5. Community guidelines (커뮤니티 규칙)

일부 @nestjs/ 관련 패키지에서 forRoot, register, forFeature와 같은 메서드가 사용되는데, 이러한 메서드들의 차이점이 무엇인지 더 자세히 알아보도록 합시다.

 

이에 대한 명확한 규칙이 정해진 것은 아니지만 Nest 공식 문서에서는 다음 가이드라인을 따를 것을 권장하고 있습니다.

 

모듈을 만들 때는 다음 규칙에 의거합니다:

  • register()를 사용하면 특정 구성을 통해 동적 모듈을 구성합니다. 호출 모듈에서만 사용할 수 있도록
    • 예를 들어, Nest의 @nestjs/axios: HttpModule.register({ baseUrl: 'someUrl' })를 생각해봅시다. 다른 모듈에서 HttpModule.register({ baseUrl: 'somewhere else' })를 사용한다면 이는 다른 구성을 갖게 됩니다. 원하는 만큼 많은 모듈에 대해서 이 작업을 수행할 수 있습니다.
  • forRoot() 를 사용하면 동적 모듈을 한 번 구성하고 여러 곳에서 해당 구성을 재사용할 수 있습니다(추상화되어 있기 때문에 무의식적으로 재사용할 수도 있긴 하지만 말이죠)
    • 이것이 바로 하나의 GraphQLModule.forRoot(), 하나의 TypeOrmModule.forRoot() 등이 있는 이유입니다.
  • forFeature()의 경우 동적 모듈의 forRoot 구성을 사용해야 하지만 호출 모듈의 필요에 따라 일부 구성을 수정해야 합니다. 
    • ex. 이 모듈이 접근할 수 있는 repository 또는 logger가 사용해야 하는 context

 

이러한 것들은 모두 일반적으로 비동기 함수인 registerAsync, forRootAsync, forFeatureAsync도 있으며, 이들은 같은 의미를 지니지만 구성에도 Nest 의존성 주입을 사용할 수 있다는 것이 차이점입니다.

 

3-6. Configurable module builder (구성가능한 모듈 빌더)

비동기 메서드(registerAsync, forRootAsync 등)를 사용하는 고도의 구성 가능한 동적 모듈을 수동으로 생성하는 것은 특히 초보자에게는 매우 복잡하기 때문에 Nest는 이 과정을 용이하게 하고 단 몇 줄의 코드만으로 모듈의 "청사진"을 구성할 수 있게 하는 ConfigurableModuleBuilder 클래스를 제공합니다.

 

예를 들어, 위에서 사용한 예제(ConfigModule)를 ConfigurableModuleBuilder를 사용하도록 변환해 보겠습니다. 시작하기 전에 ConfigModule이 어떤 옵션을 가져오는지 나타내는 전용 인터페이스를 먼저 만들어 보겠습니다.

// ./interfaces/config-module-options.interface'
export interface ConfigModuleOptions {
  folder: string;
}

 

이렇게 하면 기존 config.module.ts 파일과 함께 새로운 전용 파일을 만들고 이름을 config.module-definition.ts로 지정합니다.

 

이 파일에서 ConfigurableModuleBuilder를 활용하여 ConfigModule의 정의 부분을 만들어보겠습니다.

// config.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { ConfigModuleOptions } from './interfaces/config-module-options.interface';

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<ConfigModuleOptions>().build();

 

이제 config.module.ts 파일을 열고 자동 생성된 ConfigurableModuleClass를 활용하도록 수정해보겠습니다.

import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurableModuleClass } from './config.module-definition';

@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {}

 

ConfigurableModuleClass를 확장한다는 것은 이제 ConfigModule이(이전 custom 구현에서와 같이) register 메서드뿐만 아니라 비동기 팩토리를 제공하여 소비자가 해당 모듈을 비동기적으로 구성할 수 있도록 하는 registerAsync 메서드도 제공한다는 의미입니다.

@Module({
  imports: [
    ConfigModule.register({ folder: './config' }),
    // or alternatively:
    // ConfigModule.registerAsync({
    //   useFactory: () => {
    //     return {
    //       folder: './config',
    //     }
    //   },
    //   inject: [...any extra dependencies...]
    // }),
  ],
})
export class AppModule {}

 

마지막으로, 지금까지 사용한 'CONFIG_OPTIONS' 대신 생성된 모듈 options의 provider를 주입하도록 ConfigService 클래스를 업데이트해 봅시다.

@Injectable()
export class ConfigService {
  constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) { ... }
}

 

3-7. Custom method key

ConfigurableModuleClass는 기본적으로 register와 그에 대응하는 registerAsync 메서드를 제공합니다. 다른 메서드 이름을 사용하려면 다음과 같이 ConfigurableModuleBuilder#setClassMethodName 메서드를 사용합니다.

// config.module-definition.ts
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<ConfigModuleOptions>().setClassMethodName('forRoot').build();

 

이 구조는 ConfigurableModuleBuilder가 대신 forRoot 및 forRootAsync를 제공하는 클래스를 생성하도록 지시합니다.

@Module({
  imports: [
    ConfigModule.forRoot({ folder: './config' }), // <-- note the use of "forRoot" instead of "register"
    // or alternatively:
    // ConfigModule.forRootAsync({
    //   useFactory: () => {
    //     return {
    //       folder: './config',
    //     }
    //   },
    //   inject: [...any extra dependencies...]
    // }),
  ],
})
export class AppModule {}

 

 

3-8. Custom options factory class

registerAsync 메서드를 사용하면 소비자가 모듈의 구성으로 확인되는 provider 정의를 전달할 수 있으므로, 라이브러리 소비자는 구성 객체를 구성하는 데 사용할 클래스를 제공할 수 있습니다.

@Module({
  imports: [
    ConfigModule.registerAsync({
      useClass: ConfigModuleOptionsFactory,
    }),
  ],
})
export class AppModule {}

 

이 클래스는 기본적으로 모듈 구성 객체를 반환하는 create() 메서드를 제공해야 합니다. 그러나 라이브러리가 다른 명명 규칙을 따르는 경우, 해당 동작을 변경하고 ConfigurableModuleBuilder#setFactoryMethodName 메서드를 사용하여 다른 메서드(예: createConfigOptions)를 ConfigurableModuleBuilder에 지시할 수 있습니다.

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
  new ConfigurableModuleBuilder<ConfigModuleOptions>().setFactoryMethodName('createConfigOptions').build();

 

 

이제 ConfigModuleOptionsFactory 클래스는 create 대신 createConfigOptions 메서드를 제공해야 합니다.

@Module({
  imports: [
    ConfigModule.registerAsync({
      useClass: ConfigModuleOptionsFactory, // <-- 이 클래스는 "createConfigOptions" 메서드를 제공해야 함.
    }),
  ],
})
export class AppModule {}

 

3-9. Extra options (이외의 옵션)

모듈의 동작 방식을 결정하는 추가 옵션(이러한 옵션의 좋은 예는 isGlobal 또는 global)이 필요하지만 동시에 MODULE_OPTIONS_TOKEN provider에 해당 옵션이 포함이 되어있지 않은 경우가 있습니다.

  • 예를 들어, 해당 모듈에 등록된 service/provider와 관련이 없기 때문에 ConfigService는 호스트 모듈이 전역 모듈로 등록되었는지 여부를 알 필요가 없습니다.

이러한 경우 ConfigurableModuleBuilder#setExtras 메서드를 사용할 수 있습니다.

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>()
  .setExtras(
    {
      isGlobal: true,
    },
    (definition, extras) => ({
      ...definition,
      global: extras.isGlobal,
    }),
  )
  .build();

 

위의 예에서 setExtras 메서드에 전달된 첫 번째 인수는 "extra" 속성에 대한 기본값이 포함된 객체입니다. 두 번째 인수는 자동 생성된 모듈 정의를 나타내는 definition(provider, exports 등 포함)과 추가 속성(소비자가 지정하거나 default 값)을 나타내는 extras 객체를 나타내는 추가 객체를 취하는 함수입니다.

 

이 함수의 반환 값은 수정된 모듈의 definition입니다. 이러한 특정 예제에서는 extras.isGlobal 속성을 가져와서 모듈 정의의 전역 속성에 할당합니다.

 

이제 이 모듈을 사용할 때 다음과 같이 추가적인 isGlobal 플래그를 전달할 수 있습니다.

@Module({
  imports: [
    ConfigModule.register({
      isGlobal: true,
      folder: './config',
    }),
  ],
})
export class AppModule {}

 

하지만 isGlobal은 'extras' 속성으로 선언되었기 때문에 MODULE_OPTIONS_TOKEN provider에서는 사용할 수 없습니다.

@Injectable()
export class ConfigService {
  constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) {
    // "options" object will not have the "isGlobal" property
    // ...
  }
}

 

3-10. Extending auto-generated methods

자동으로 생성되는 static 메서드(Register, registerAsync 등)는 필요에 따라 확장될 수 있습니다.

import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurableModuleClass, ASYNC_OPTIONS_TYPE, OPTIONS_TYPE } from './config.module-definition';

@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {
  static register(options: typeof OPTIONS_TYPE): DynamicModule {
    return {
      // your custom logic here
      ...super.register(options),
    };
  }

  static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
    return {
      // your custom logic here
      ...super.registerAsync(options),
    };
  }
}

모듈 definition 파일에서 내보내야 하는 OPTIONS_TYPE 및 ASYNC_OPTIONS_TYPE 타입의 사용에 유의해야 합니다.

 

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder<ConfigModuleOptions>().build();

 

이상으로 NestJS 모듈(Module)편이었습니다.

 

감사합니다.

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.