[NestJS] 모듈(Module)

Devhslee·2023년 10월 14일

NestJS 기초

목록 보기
2/6

공식 문서에선 Controller, Provider, Module 순으로 이 세 가지를 설명하고 있지만
Controller나 Provider는 일단 Module이 만들어지고 나서 거기에 등록되는 게 우선이므로 Module부터 포스팅했다.

일단 NestJS 애플리케이션에서 module들이 어떻게 구성되는지를 보자.

Application Module(AppModule)이 최상단에 위치하고 그 module에 Users Module, Orders Module, Chat Module 등이 구성되어 있으며(정확히 말하자면 import하고 있는 것이다) 또 한 module에 다른 여러 개의 module들이 구성되고 있는 식이다.

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

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

@nestjs/cli로 생성된 NestJS의 bolierplate 코드들을 보면 app.module.ts가 있는데 이 녀석이 바로 NestJS 애플리케이션의 root module이다. NestJS는 이 root module로부터 시작하여 다른 module간의 의존성을 찾고 애플리케이션을 구성해 나간다.

앞선 글에서 NestJS는 관련있는 코드들을 모아 모듈화를 하여 사용한다고 했었다. 가령 사용자에 관한 기능이 관련된 코드들, 예를 들어 회원가입, 로그인, 정보수정 등의 요청을 받아들이는 controller나 service, 관련 db repository, dto 등등이 UserModule안에서 관리되는 식이다.


module 생성하기

수동으로 코드를 짜도 상관은 없지만 root module 외의 별도의 module를 생성해서 NestJS 가 인식할 수 있게 하려면 AppModule에도 import 해줘야 한다 (Nest는 AppModule로부터 시작해서 module 의존성을 풀어 나가기 때문) 즉 2번이나 코드를 수정해야 한다는 뜻.

그럴 필요 없이 한 번으로 module를 생성하고 AppModule이 생성한 module을 import할 수 있게 하려면 cli를 사용하면 된다.

$ nest g module User

module 말고 줄임말은 mo를 써도 결과는 똑같다.

$ nest g mo User

그러면 다음과 같이 두 개의 변화가 일어난 걸 볼 수 있다.

우선 user 라는 디렉토리가 하나 생기고 그 안에 user.module.ts가 생성되었다.

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

@Module({})
export class UserModule {}

아직 module 안에 controller나 service를 생성해주지 않아서 데코레이터 안이 비어있다.

그리고 AppModule를 확인해보면 아까 생성해준 UserModule이 import되고 있음을 볼 수 있다.

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

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

이런 식으로 만들어진 NestJS의 모든 module들은 기본적으로 Singleton이다. 즉, 각 module의 인스턴스는 하나만 생성되며, 각 module에서 export하고 있는 provider도 마찬가지로 singleton이므로 여러 module간에 같은 인스턴스를 공유할 수 있게 되는 것이다.

가령, 다음과 같이 UserModule에서 UserService를 export하고 있다면,

import { Module } from '@nestjs/common';
import { UserService } from './user.service';

@Module({
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

UserModule를 import한 CartModule 내에서 UserService를 주입받아 사용할 수 있다.

// cart.module.ts
import { Module } from '@nestjs/common';
import { UserModule } from '.user/user.module';

@Module({
  imports: [UserModule]
  providers: [CartService],
})
export class CartModule {}
// cart.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '.user/user.service';

@Injectable()
export class CartService {
  constructor(private readonly userService: UserService) {}
  ...
}

module의 구성요소

@Module 데코레이터의 파라미터로 들어가는 ModuleMetadata를 자세히 뜯어보면 다음과 같다.

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>;
}


좀 더 자세히 설명을 해보자면,

controllers
이 module에서 정의된 controller들.
(한 모듈에 컨트롤러가 여러 개 있는 경우는 잘 못 본 것 같긴 하다)

providers
이 module에서 정의되었거나, 적어도 이 module안에서 공유되어야 할 provider들.
(Nest Injector에 의해 module 안에서 주입될 수 있는 것들)

imports
이 module에서 필요한 provider를 export하고 있는 다른 module.

예를 들어, 주문관련 기능을 담당하는 OrderModule에서 장바구니를 조회하는 로직이 필요해서 해당 로직이 있는 CartService를 사용하고자 한다고 치자. 이 CartService는 OrderModule 내에서 정의된 provider가 아니기 때문에 그냥 주입하면 NestJS가 얘를 찾을 수 없다고 에러를 띄운다.

OrderModule에서 CartService를 찾을 수 있게 하려면 이 CartService를 export하고 있는 CartModule를 import 해야 하는 것이다.

exports
다른 module에서 사용할 수 있게끔 내보내는 이 module에 속한 provider나 module들. (module export는 밑에서 설명)

위에서 말한 CartModule이 바로 이 예이다. CartModule에서 정의된 CartService가 OrderModule과 같은 다른 module에서 주입할 수 있게 하려면 CartModule의 exports 부분에 CartService가 있어야 한다.


즉, 위의 사실들을 다르게 정리해보면

1) 현재 module의 구성원이 아니거나
2) import하고 있는 module에서 export하고 있지 않은

provider는 주입할 수 없다는 뜻이 된다.


module의 핵심

즉, NestJS에선 module안에서 provider를 정의하고 이를 다른 module에서 import함으로써 필요한 곳에서 해당 provider를 주입하여 사용하는 식으로 의존성을 해결하는 것이다.


module re-export

이건 module 재사용성을 더 극대화하는 방법이다.

@Module 안의 exports 부분엔 provider도 들어갈 수 있지만 module도 들어갈 수 있다.

즉, 현재 module에서 import하고 있는 module들을 고스란히 다시 내보내서 현재 module을 import할 다른 module에서도 이 module들을 사용할 수 있게끔 하는 것이다.

예를 들어 CartModule과 UserModule를 import하고 있는 CommonModule이 있다고 하자. CartModule은 CartService를 export, UserModule은 UserService를 export하고 있다.

import { Module } from '@nestjs/common';
import { CartModule } from 'src/cart/cart.module';
import { UserModule } from 'src/user/user.module';
import { CommonService } from './common.service';

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

CommonModule은 import한 CartModule, UserModule를 다시 export하고 있다. 만약 이 CommonModule를 다른 module이 import하게 되면 어떻게 될까?

SampleModule이 이 CommonModule를 import하게 된다고 치자.

import { Module } from '@nestjs/common';
import { SampleService } from './sample.service';
import { CommonModule } from 'src/common/common.module';

@Module({
  imports: [CommonModule],
  providers: [SampleService],
})
export class SampleModule {}

그리고 다음은 SampleService에서 CartService와 UserService를 주입하고 있는 예시이다.

import { Injectable } from '@nestjs/common';
import { CartService } from 'src/cart/cart.service';
import { CommonService } from 'src/common/common.service';
import { UserService } from 'src/user/user.service';

@Injectable()
export class SampleService {
  constructor(
    private readonly cartService: CartService,
    private readonly userService: UserService,
  ) {}
}

위와 같이 코드를 작성하고 실행하면 SampleModule이 CartModule과 UserModule를 import하지 않았음에도 불구하고 에러가 나지 않는다.

SampleModule이 CartModule과 UserModule를 export하고 있는 CommonModule를 import했기 때문이다.

만약 재사용되는 module들이 많고 여러 곳에서 import해야 한다면 이런 식으로 module를 export하는 식으로 코드의 중복을 줄일 수 있을 것이다.


global module (전역 모듈)

위에서 module은 provider 의존성 주입의 핵심이라고 말을 했었다. 즉 module은 provider를 캡슐화한 것이다.

Angular에선 기본적으로 provider가 global scope이기 때문에 한번 정의되면 어디서나 사용할 수 있지만 NestJS에선 해당 provider를 export하고 있는 module를 import하지 않는 이상 사용할 수 없다.


어쨌거나, 여러 module에서 사용될 수 있는 기능을 가진 module(e.g. caching, email, config, ...)이 있다고 할 때,

매번 이 module을 import해주는 것도 상당히 귀찮은 일이다. 매번 import할 필요 없이 module을 global scope로 설정해주면 어떨까?

global scope로 설정해주고 싶은 module에 @Global 데코레이터를 달아주자.

다음과 같이 EmailModule를 global module로 설정해주었다.

import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';

@Global()
@Module({
  providers: [EmailService],
  exports: [EmailService],
})
export class EmailModule {}

@Global 데코레이터를 붙여두었으니 다른 module에서 import할 필요 없이 바로 사용할 수 있다.

import { Injectable } from '@nestjs/common';
import { EmailService } from './email/email.service';

@Injectable()
export class UserService {
  constructor(private readonly emailService: EmailService) {}
}

위와 같이 코드를 적고 실행하면 에러 없이 정상적으로 작동한다.


module 사용의 장점

연관된 domain(e.g. 결제, 인증, 이메일, 콘텐츠, 등등..)끼리 module로 묶어 프로젝트를 구성하면 몇 가지 장점이 있다.

1) 구조화

애플리케이션에 기능이 추가되고 규모가 커지면 커질 수록 잘 구조화되지 않으면 가독성도 떨어지고 유지보수도 힘들어진다.

가령
사용자 인증, 주문, 장바구니, 채팅, 결제 등의 코드들이 구조화되지 않고 그냥 뒤섞여 있다면 주문 로직이 바뀌었을 때
'어... 음.. 어떤 코드에 관련 로직이 있었더라...'
하고 온갖 코드를 뒤적거리게되는 것이다.

관련있는 기능들끼리 모듈화를 했다면 주문 관련 로직을 수정할 때 OrderModule을 찾아 service 코드를 수정해주면 그만인 것이다.

물론 자기 자신이 짠 코드라면 어디에 어떤 부분이 있는지 어느정도는 알겠지만, 다른 사람이 내가 짠 코드를 수정해야 할 때 OrderModule만 보고도
'아 이거 주문기능 관련된 곳인가 보네'
하고 감은 잡을 수 있게 되는 것이다.

2) DI

기본적으로 NestJS는 의존성 주입을 Module을 통해 관리한다. 예를 들어 Caching 기능을 하는 CachingService가 있다고 할 때,

import { Module } from '@nestjs/common';
import { CachingService } from './caching.service';

@Module({
  imports: [],
  controllers: [],
  providers: [CachingService],
  exports: [CachingService]
})
export class CachingModule {}

PaymentModule에 속한 PaymentService에서 이 CachingService를 사용하고자 한다면

import { Module } from '@nestjs/common';
import { CachingModule } from './caching/caching.module';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';

@Module({
  imports: [CachingModule],
  controllers: [PaymentController],
  providers: [PaymentService],
})
export class PaymentModule {}

PaymentModule에 CachingService를 export하고 있는 CachingModule를 import하면 그만이다.

이렇게 하면 중복 코드를 쓸 필요 없이 재사용성을 높일 수 있다.


여담

NestJS 프로젝트의 구조에 대해선 분분한 의견이 있는 편이다.

NestJS 공식 문서는 기본적으로 연관된 기능(도메인)끼리 모듈화를 하여 코드의 응집력을 높일 것을 권장하고 있다.

가령, 이런식으로

src
├ user
│  ├ dto
│  │  ├ create-user.dto.ts
│  │  └ check-user.dto.ts
│  ├ entities
│  │  └ user.entity.ts
│  ├ user.module.ts
│  ├ user.controller.ts
│  └ user.service.ts
│ 
├ app.module.ts
├ app.controller.ts
├ app.service.ts
└ main.ts

위는 기능끼리 코드를 엮은 것이라면, 역할(layer)별로 코드를 묶는 방법이 있다.
이런식으로,

src
├ controllers
│  ├ user.controller.ts
│  └ order.controller.ts
├ services
│  ├ user.service.ts
│  └ order.service.ts
├ repositories
│  ├ user.repository.ts
│  ├ order.repository.ts
│  └ item.repository.ts
├ entities
│  ├ user.entity.ts
│  ├ order.entity.ts
│  └ item.entity.ts
├ app.module.ts
├ app.controller.ts
├ app.service.ts
└ main.ts

어느 쪽이든 다 장단점이 있으니 디렉토리를 어떤 식으로 구성하냐는 본인 마음이겠지만, 도메인 분리가 명확하고 규모가 커진다면 첫번째 방식이 확실히 한 눈에 보기에 더 명확해 보이긴 한 것 같다.

profile
코딩-버그-좌절-해결-희열

0개의 댓글