NestJS Module System
NestJS 애플리케이션은 모듈(Module)이라는 단위로 구성된다. 모듈은 관련된 기능을 하나로 묶는 컨테이너다. Controller, Service, Repository 같은 구성 요소들이 흩어져 있으면 애플리케이션이 커질수록 관리가 어려워지는데, 모듈이 이 문제를 해결한다.
왜 모듈이 필요한가
Node.js의 일반적인 Express 애플리케이션을 생각해보자. 라우터 파일에서 직접 서비스 함수를 import하고, 서비스 함수에서 직접 DB 연결을 import한다. 애플리케이션이 작을 때는 문제가 없지만, 기능이 수십 개로 늘어나면 어떤 파일이 어떤 파일에 의존하는지 추적하기 어려워진다. 의존성이 꼬이고, 하나를 바꾸면 예상치 못한 곳이 깨진다.
NestJS의 모듈 시스템은 이 문제를 구조적으로 해결한다. 각 모듈이 "나는 이런 기능을 제공하고, 이런 기능이 필요하다"고 명시적으로 선언하기 때문에, 의존 관계가 코드에 그대로 드러난다.
@Module 데코레이터
모듈은 @Module() 데코레이터가 붙은 클래스다. 이 데코레이터는 네 가지 속성을 받는다.
@Module({
imports: [], // 이 모듈이 필요로 하는 다른 모듈
controllers: [], // 이 모듈이 정의하는 컨트롤러
providers: [], // 이 모듈이 정의하는 서비스, 레포지토리 등
exports: [], // 다른 모듈에 공개할 provider
})
export class SomeModule {}
각 속성의 역할을 하나씩 살펴보자.
providers
provider는 NestJS의 DI(Dependency Injection) 컨테이너가 관리하는 객체다. Service, Repository, Factory, Helper 등 비즈니스 로직을 담당하는 클래스가 여기에 등록된다. providers에 등록된 클래스는 같은 모듈 내에서 자유롭게 주입받아 사용할 수 있다.
@Module({
providers: [
AdminStoresService,
AdminDevicesService,
AdminAIModelsService,
],
})
export class AdminModule {}
위 코드에서 AdminStoresService를 providers에 등록하면, 같은 모듈의 Controller에서 생성자 주입을 통해 사용할 수 있다. 중요한 점은 providers에 등록된 클래스는 기본적으로 해당 모듈 내부에서만 접근 가능하다는 것이다. 다른 모듈에서 사용하려면 exports에 명시해야 한다.
controllers
HTTP 요청을 받아 처리하는 컨트롤러를 등록한다. 컨트롤러는 라우팅과 요청/응답 처리만 담당하고, 실제 비즈니스 로직은 provider에 위임한다.
@Module({
controllers: [
AdminStoresController,
AdminDevicesController,
AdminAIModelsController,
],
})
export class AdminModule {}
imports
다른 모듈의 기능을 가져올 때 사용한다. import한 모듈이 export하는 provider를 현재 모듈에서 주입받아 쓸 수 있다.
@Module({
imports: [
StoresModule, // StoresRepository를 사용하기 위해
DevicesModule, // DevicesRepository를 사용하기 위해
RepositoriesModule, // 여러 Repository를 한번에 사용하기 위해
],
})
export class AdminModule {}
위 코드에서 AdminModule은 StoresModule을 import한다. StoresModule이 StoresRepository를 export하고 있기 때문에, AdminModule의 Service에서 StoresRepository를 주입받아 사용할 수 있다.
exports
현재 모듈의 provider를 다른 모듈에 공개한다. exports에 등록하지 않으면 해당 provider는 모듈 내부에서만 사용 가능하다.
@Module({
imports: [MikroOrmModule.forFeature([Store])],
providers: [StoresRepository],
exports: [StoresRepository], // 다른 모듈에서 사용 가능하도록 공개
})
export class StoresModule {}
StoresModule은 StoresRepository를 providers에 등록하고 동시에 exports에도 넣었다. 이렇게 해야 AdminModule이나 KioskModule에서 StoresModule을 import했을 때 StoresRepository를 주입받을 수 있다. exports에 넣지 않으면 다른 모듈에서 접근이 불가능하다.
모듈 트리
NestJS 애플리케이션은 하나의 루트 모듈에서 시작해 트리 구조를 형성한다. 루트 모듈은 보통 AppModule이다.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MikroOrmModule.forRoot(config),
AdminModule,
KioskModule,
S3Module,
AssetsModule,
// ...
],
})
export class AppModule {}
AppModule이 AdminModule과 KioskModule을 import하고, AdminModule은 다시 StoresModule과 DevicesModule을 import한다. 이렇게 모듈이 모듈을 import하면서 트리가 만들어진다.
AppModule
├── AdminModule
│ ├── StoresModule
│ ├── DevicesModule
│ ├── RepositoriesModule
│ └── AssetsModule
├── KioskModule
│ ├── StoresModule (AdminModule과 동일한 모듈)
│ ├── DevicesModule
│ ├── RepositoriesModule
│ ├── SessionsModule
│ ├── PaymentsModule
│ └── SalesModule
├── S3Module
└── AssetsModule
여기서 주목할 점은 StoresModule이 AdminModule과 KioskModule 양쪽에서 import된다는 것이다. NestJS에서 모듈은 기본적으로 싱글톤이다. 같은 모듈을 여러 곳에서 import해도 인스턴스는 하나만 생성되고, 그 안의 provider도 하나의 인스턴스를 공유한다.
동적 모듈: forRoot와 forFeature
지금까지 본 모듈은 정적 모듈이다. @Module() 데코레이터에 모든 설정이 고정되어 있다. 그런데 데이터베이스 연결 정보처럼 실행 시점에 설정이 달라져야 하는 경우가 있다.
동적 모듈(Dynamic Module)은 설정 값을 인자로 받아 모듈을 생성한다. forRoot()와 forFeature()가 대표적인 패턴이다.
forRoot
애플리케이션 전체에서 한 번만 호출하는 설정이다. 보통 루트 모듈에서 사용한다.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // 환경 변수 설정 (전역)
MikroOrmModule.forRoot(config), // DB 연결 설정 (전역)
],
})
export class AppModule {}
MikroOrmModule.forRoot(config)는 PostgreSQL 연결 정보, 엔티티 목록, 마이그레이션 경로 등을 받아서 데이터베이스 연결을 초기화한다. 애플리케이션 전체에서 하나의 DB 연결을 공유하므로 루트 모듈에서 한 번만 호출한다.
forFeature
특정 모듈에서 사용할 기능을 등록할 때 쓴다. 여러 모듈에서 각각 호출할 수 있다.
@Module({
imports: [MikroOrmModule.forFeature([Store])],
providers: [StoresRepository],
exports: [StoresRepository],
})
export class StoresModule {}
MikroOrmModule.forFeature([Store])는 Store 엔티티에 대한 Repository를 현재 모듈의 DI 컨테이너에 등록한다. forRoot로 설정한 전역 DB 연결 위에서, 이 모듈이 사용할 엔티티만 골라서 등록하는 것이다.
forRoot가 "데이터베이스에 연결한다"면, forFeature는 "이 모듈에서는 이 테이블을 사용한다"고 선언하는 것이다.
ConfigModule에서의 forRoot/forFeature
설정 모듈도 같은 패턴을 따른다.
// AppModule에서 전역 설정
ConfigModule.forRoot({ isGlobal: true })
// AdminModule에서 S3 설정만 추가 등록
ConfigModule.forFeature(s3Config)
forRoot로 .env 파일의 환경 변수를 전역으로 로드하고, forFeature로 특정 모듈에 필요한 설정(여기서는 S3 관련 설정)을 추가로 등록한다.
글로벌 모듈
기본적으로 모듈의 provider는 해당 모듈을 import한 곳에서만 사용 가능하다. 그런데 ConfigModule처럼 거의 모든 모듈에서 필요한 기능이 있다면, 매번 import하는 건 번거롭다.
isGlobal: true 옵션이나 @Global() 데코레이터를 사용하면 해당 모듈의 provider를 애플리케이션 전체에서 import 없이 사용할 수 있다.
ConfigModule.forRoot({ isGlobal: true })
이렇게 설정하면 어떤 모듈에서든 ConfigService를 import 없이 주입받을 수 있다. 편리하지만 남용하면 의존 관계가 불명확해지므로, 정말 전역적으로 필요한 설정이나 유틸리티에만 사용하는 것이 좋다.
모듈 설계 패턴
모듈을 어떤 기준으로 나눌지는 프로젝트마다 다르다. 일반적으로 두 가지 접근이 있다.
도메인 모듈
비즈니스 도메인 단위로 나눈다. 하나의 엔티티와 그에 대한 Repository를 묶어서 독립적인 모듈로 만든다.
// 매장 도메인
@Module({
imports: [MikroOrmModule.forFeature([Store])],
providers: [StoresRepository],
exports: [StoresRepository],
})
export class StoresModule {}
// 디바이스 도메인
@Module({
imports: [MikroOrmModule.forFeature([Device, Store])],
providers: [DevicesRepository],
exports: [DevicesRepository],
})
export class DevicesModule {}
도메인 모듈은 데이터 접근 계층만 담당한다. Repository를 export해서 다른 모듈이 가져다 쓸 수 있게 한다.
기능 모듈
사용자 역할이나 기능 단위로 나눈다. 도메인 모듈을 import해서 조합하고, 비즈니스 로직(Service)과 API 엔드포인트(Controller)를 정의한다.
@Module({
imports: [
StoresModule, // 도메인 모듈 조합
DevicesModule,
RepositoriesModule,
],
controllers: [AdminStoresController, AdminDevicesController],
providers: [AdminStoresService, AdminDevicesService],
})
export class AdminModule {}
AdminModule과 KioskModule이 같은 StoresModule을 import하지만, 각자의 Service와 Controller에서 다른 비즈니스 로직을 구현한다. 관리자는 매장을 생성/수정/삭제할 수 있지만, 키오스크는 매장 정보를 조회만 하는 식이다.
이 구조의 핵심은 데이터 접근(도메인 모듈)과 비즈니스 로직(기능 모듈)의 분리다. Repository는 한 번만 정의하고, 여러 기능 모듈에서 재사용한다.
정리
- 모듈은 providers/controllers/imports/exports 네 속성으로 의존 관계를 명시적으로 선언하고, 같은 모듈을 여러 곳에서 import해도 싱글톤으로 공유된다
- forRoot는 전역 설정을 한 번만, forFeature는 모듈별로 필요한 기능을 등록하는 패턴이며, @Global()은 인프라성 모듈에만 제한적으로 사용한다
- 도메인 모듈(데이터 접근)과 기능 모듈(비즈니스 로직)을 분리하면 Repository 재사용과 역할별 독립 배치가 가능해진다