NestJS Global Module
NestJS에서 모듈 A가 모듈 B의 서비스를 사용하려면, 모듈 A의 imports 배열에 모듈 B를 등록해야 한다.
@Module({
imports: [LoggerModule],
providers: [UserService],
})
export class UserModule {}
이 구조는 의존 관계를 명확하게 만들어 주지만, 거의 모든 모듈에서 공통으로 사용하는 서비스가 있을 때 문제가 된다. 로거, 데이터베이스 연결, Redis 클라이언트, 메트릭 수집기 같은 인프라 서비스는 사실상 모든 모듈에서 필요하다. 이런 서비스를 사용하는 모듈이 20개라면, 20개 모듈 전부의 imports에 해당 모듈을 일일이 등록해야 한다.
// 20개 모듈 전부에 이걸 반복해야 한다
@Module({
imports: [LoggerModule, RedisModule, MetricsModule],
// ...
})
export class SomeModule {}
이건 단순 반복이고, 새 모듈을 만들 때마다 빠뜨리기 쉽다. 빠뜨리면 런타임에 의존성 주입 에러가 나는데, 에러 메시지가 직관적이지 않아서 디버깅에 시간을 쓰게 된다.
@Global 데코레이터
@Global() 데코레이터를 모듈에 붙이면, 그 모듈을 한 번만 등록하면 애플리케이션 전체에서 해당 모듈의 exports를 사용할 수 있다.
import { Global, Module } from '@nestjs/common';
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
이 모듈을 루트 모듈(AppModule)에서 한 번만 import하면, 다른 모든 모듈에서 LoggerService를 별도 import 없이 주입받을 수 있다.
@Module({
imports: [LoggerModule], // 여기서 한 번만
})
export class AppModule {}
// 다른 모듈에서 LoggerModule import 없이 바로 사용
@Injectable()
export class UserService {
constructor(private readonly logger: LoggerService) {}
}
핵심은 exports에 등록된 provider만 전역으로 노출된다는 점이다. @Global()을 붙였다고 모듈의 모든 provider가 외부에 공개되는 게 아니다. exports 배열에 명시한 것만 다른 모듈에서 주입받을 수 있고, 나머지는 여전히 모듈 내부에서만 접근 가능하다.
동작 원리
NestJS의 의존성 주입 시스템은 모듈 스코프 기반이다. 기본적으로 각 모듈은 자체 provider만 주입할 수 있고, 외부 모듈의 provider를 사용하려면 해당 모듈을 명시적으로 import해야 한다.
@Global()은 이 스코프 규칙에 예외를 만든다. NestJS 내부의 모듈 컨테이너가 글로벌 모듈의 exports를 모든 모듈의 주입 가능 범위에 자동으로 추가한다. 구체적으로는:
- 앱 부트스트랩 시 NestJS가 모듈 그래프를 스캔한다
@Global()이 붙은 모듈을 발견하면 글로벌 모듈 목록에 등록한다- 각 모듈의 의존성을 해석할 때, 글로벌 모듈의 exports를 암묵적으로 포함시킨다
이 과정은 컴파일(부트스트랩) 타임에 일어나기 때문에 런타임 성능에 영향을 주지 않는다.
전역 모듈이 적합한 경우
모든 모듈을 전역으로 만들면 편하겠지만, 그러면 모듈 시스템의 의미가 없어진다. 어떤 모듈이 어떤 서비스에 의존하는지 코드만 보고 파악할 수 없게 되고, 모듈 간 결합도가 보이지 않게 증가한다.
전역 모듈이 적합한 경우는 명확하다:
인프라/횡단 관심사(Cross-Cutting Concerns)
- 로거: 모든 서비스에서 로그를 남긴다
- 데이터베이스 연결: 거의 모든 모듈에서 DB에 접근한다
- Redis 클라이언트: 캐시, 세션, 큐 등 여러 곳에서 사용한다
- 메트릭 수집: HTTP 요청, 비즈니스 이벤트 등을 전역에서 추적한다
- 메시지 큐 연결: 여러 모듈에서 메시지를 발행한다
이런 서비스는 "거의 모든 곳에서 사용"하면서 "비즈니스 로직과 독립적"이라는 공통점이 있다.
전역으로 만들면 안 되는 경우
- 비즈니스 도메인 모듈 (UserModule, OrderModule 등)
- 특정 기능에만 사용되는 모듈
- 외부 API 연동 모듈 (결제, 알림 등)
이런 모듈은 의존 관계가 명시적으로 드러나야 한다. 어떤 모듈이 결제 서비스를 사용하는지 imports를 보고 바로 알 수 있어야 코드를 이해하고 리팩터링할 수 있다.
전역 모듈 설계 시 주의점
exports를 최소화하라
전역 모듈이라도 외부에 노출하는 provider는 최소한으로 유지해야 한다. 내부 구현용 provider까지 exports하면 다른 모듈이 내부 구현에 의존하게 되어 변경이 어려워진다.
@Global()
@Module({
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: (config: ConfigService) => {
return new Redis({
host: config.get('REDIS_HOST'),
port: config.get('REDIS_PORT'),
});
},
inject: [ConfigService],
},
RedisService,
],
exports: [RedisService], // REDIS_CLIENT는 노출하지 않음
})
export class RedisModule {}
REDIS_CLIENT(ioredis 인스턴스)를 직접 노출하면, 다른 모듈이 Redis 명령어를 직접 실행하게 된다. 나중에 Redis 라이브러리를 교체하거나 연결 방식을 바꾸면 모든 사용처를 수정해야 한다. RedisService만 exports해서 추상화 계층을 유지하는 게 좋다.
forRoot / forRootAsync 패턴과 함께 쓰기
전역 모듈은 보통 설정이 필요하다. forRoot() 정적 메서드로 설정을 받고, 그 안에서 @Global()을 적용하는 패턴이 일반적이다.
@Module({})
export class CacheModule {
static forRoot(options: CacheOptions): DynamicModule {
return {
module: CacheModule,
global: true, // DynamicModule에서는 이렇게 설정
providers: [
{
provide: 'CACHE_OPTIONS',
useValue: options,
},
CacheService,
],
exports: [CacheService],
};
}
}
DynamicModule을 반환할 때는 @Global() 데코레이터 대신 global: true 속성을 사용한다. 동일한 효과다.
@Module({
imports: [
CacheModule.forRoot({ ttl: 60, max: 100 }),
],
})
export class AppModule {}
비동기 설정이 필요하면 forRootAsync()를 만든다:
static forRootAsync(options: {
inject: any[];
useFactory: (...args: any[]) => CacheOptions | Promise<CacheOptions>;
}): DynamicModule {
return {
module: CacheModule,
global: true,
providers: [
{
provide: 'CACHE_OPTIONS',
inject: options.inject,
useFactory: options.useFactory,
},
CacheService,
],
exports: [CacheService],
};
}
@Module({
imports: [
CacheModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
ttl: config.get('CACHE_TTL'),
max: config.get('CACHE_MAX'),
}),
}),
],
})
export class AppModule {}
순환 의존성 주의
전역 모듈이 다른 전역 모듈에 의존하면 순환 참조가 발생할 수 있다. 예를 들어 LoggerModule이 RedisModule을 사용하고, RedisModule이 LoggerModule을 사용하는 경우다.
// ❌ 순환 참조
@Global()
@Module({
imports: [RedisModule], // RedisModule도 Global이면서 LoggerModule을 쓴다면?
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
이런 경우 forwardRef()로 해결할 수 있지만, 근본적으로는 의존 방향을 정리하는 게 맞다. 인프라 모듈 간에도 의존 계층을 명확히 하자. 보통 Redis → Logger 방향은 괜찮지만, Logger → Redis 방향은 피해야 한다.
@Global() vs imports 반복: 판단 기준
| 기준 | imports 반복 | @Global() |
|---|---|---|
| 사용하는 모듈 수 | 2~3개 | 5개 이상 (거의 전부) |
| 서비스 성격 | 비즈니스 로직 | 인프라/횡단 관심사 |
| 의존 관계 파악 | 명시적 | 암묵적 |
| 유지보수 비용 | import 누락 위험 | 과도한 전역화 위험 |
5개 이상의 모듈에서 사용하는 순수 인프라 서비스라면 @Global()을 쓰는 게 합리적이다. 그 외에는 명시적 import가 낫다. "편하니까"가 아니라 "이 서비스가 정말 전역적인가"를 기준으로 판단하자.
NestJS 내장 글로벌 모듈
NestJS 자체에서도 일부 모듈을 글로벌로 동작하게 만들 수 있다. 대표적으로 ConfigModule:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // 전역 등록
envFilePath: '.env',
}),
],
})
export class AppModule {}
isGlobal: true를 설정하면 ConfigService를 어디서든 주입받을 수 있다. TypeOrmModule.forRoot()도 전역으로 등록되어서 Repository를 어느 모듈에서나 사용할 수 있다 (단, TypeOrmModule.forFeature()로 엔티티 등록은 각 모듈에서 해야 한다).
이처럼 라이브러리 모듈이 isGlobal 옵션을 지원하는 경우, 직접 @Global()을 붙이는 대신 해당 옵션을 사용하면 된다. 내부적으로 동일한 메커니즘이다.
정리
- @Global()은 인프라/횡단 관심사(로거, Redis, 메트릭)에만 사용하고, 비즈니스 모듈은 명시적 imports를 유지한다
- exports에 등록한 provider만 전역 노출되므로, 내부 구현(REDIS_CLIENT 등)은 숨기고 추상화된 Service만 exports한다
- DynamicModule에서는 global: true 속성을, forRoot/forRootAsync 패턴과 결합해서 설정 주입까지 한 번에 처리한다