NestJS ConfigModule 동작 원리 완벽 가이드: ConfigService 값 조회 실패 해결
ConfigService에서 환경변수가 조회되지 않는 문제의 근본 원인을 코드 레벨에서 분석합니다. DynamicModule 생성 과정과 Provider 주입 메커니즘을 상세히 파헤쳐 실전 해결 방법을 제시합니다.
문제 상황
ConfigService에서 값이 불러와지지 않는다?
타임스프레드에서는 AWS Secrets Manager를 통해 가져온 값을 NestJS의 ConfigService에 등록해두고 사용하고 싶었습니다. 이를 위해 다음과 같은 작업을 진행했습니다.
1. AppModule에서 ConfigModule 등록
AppModule에서 ConfigModule을 등록할 때, load를 통해 환경변수를 설정했습니다. (getSecretValue는 AWS Secrets Manager를 통해 값을 가져오는 함수입니다)
@Module({
imports: [
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
cache: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('dev', 'ci', 'test', 'qa', 'production')
.required(),
DATABASE_URL: Joi.string().required(),
REDIS_URL: Joi.string().required(),
PORT: Joi.number().default(3000),
}),
validationOptions: {
allowUnknown: false,
abortEarly: true,
},
load: [getSecretValue],
}),
// ... 기타 모듈들
],
})
export class AppModule {}2. AuthModule에 ConfigModule 추가
AppleStrategy를 provide하고 있는 AuthModule에 ConfigModule을 추가했습니다.
@Module({
imports: [
forwardRef(() => UserModule),
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET,
}),
ConfigModule, // ConfigModule 추가
RouletteModule,
],
controllers: [AuthController],
providers: [
AuthService,
KakaoStrategy,
FacebookStrategy,
NaverStrategy,
AppleStrategy,
EmailStrategy,
],
exports: [AuthService],
})
export class AuthModule {}3. AppleStrategy에서 ConfigService 사용
AppleStrategy에서 ConfigService를 주입받아 사용했습니다.
@Injectable()
export class AppleStrategy extends PassportStrategy(Strategy, 'apple') {
private static readonly JWKS = createRemoteJWKSet(
new URL('https://appleid.apple.com/auth/keys'),
);
constructor(configService: ConfigService) {
const clientId = configService.get<string>('APPLE_CLIENT_ID');
const teamId = configService.get<string>('APPLE_TEAM_ID');
const keyId = configService.get<string>('APPLE_KEY_ID');
const redirectUrl = configService.get<string>('APPLE_REDIRECT_URL');
const decodedBuffer = Buffer.from(
configService.get<string>('APPLE_PRIVATE_KEY'),
'base64',
);
const privateKey = decodedBuffer.toString('utf-8');
super(
{
clientID: clientId,
teamID: teamId,
keyID: keyId,
callbackURL: redirectUrl,
privateKeyString: privateKey,
},
AppleStrategy.verify,
);
}
// ...
}문제 발생
이렇게 작업했을 때 다음 값들을 제대로 가져오지 못했습니다:
const clientId = configService.get<string>('APPLE_CLIENT_ID');
const teamId = configService.get<string>('APPLE_TEAM_ID');
const keyId = configService.get<string>('APPLE_KEY_ID');
const redirectUrl = configService.get<string>('APPLE_REDIRECT_URL');시도한 해결 방법
이를 해결하기 위해 AuthModule에서 ConfigModule을 제거해봤습니다.
@Module({
imports: [
forwardRef(() => UserModule),
JwtModule.register({
global: true,
secret: process.env.JWT_SECRET,
}),
// ConfigModule 제거!
RouletteModule,
],
// ...
})
export class AuthModule {}놀랍게도 문제가 해결되었습니다! AuthModule에서 ConfigModule을 import하지 않게 바꾸니 정상적으로 동작했습니다.
의문점
문제는 해결되었지만, 의문이 생겼습니다.
NestJS 공식 문서에서는 분명히 다음과 같이 설명하고 있습니다:
ConfigModule.forRoot()에서isGlobal: true인 경우, 다른 모듈에서 import하지 않고도ConfigService를 사용할 수 있다.
하지만 “다른 모듈에서 ConfigModule을 import하면 안 된다”라고는 나와있지 않습니다.
그렇다면 ConfigModule을 import했을 때와 하지 않았을 때, 왜 다르게 동작하는 걸까요?
원인 파악
ConfigModule의 import 유무에 따라 다르게 동작하는 원인을 파악하기 위해서는 NestJS의 ConfigModule이 어떻게 만들어져 있는지 확인해야 합니다.
ConfigModule.forRoot()가 반환하는 DynamicModule
ConfigModule 소스코드를 살펴보면, forRoot()는 DynamicModule을 반환합니다.
return {
module: ConfigModule,
global: options.isGlobal,
providers: isConfigToLoad
? [
...providers,
{
provide: CONFIGURATION_LOADER,
useFactory: (
host: Record<string, any>,
...configurations: Record<string, any>[]
) => {
configurations.forEach((item, index) =>
this.mergePartial(host, item, providers[index]),
);
},
inject: [CONFIGURATION_TOKEN, ...configProviderTokens],
},
]
: providers,
exports: [ConfigService, ...configProviderTokens],
};반환되고 있는 것은 크게 두 가지입니다:
- 어딘가에서 만들어졌을
providers CONFIGURATION_LOADER를 토큰으로 한 provider
이 두 가지를 순서대로 살펴봅시다.
Providers의 구성
1. providers 생성 과정
const providers = (options.load || [])
.map(factory =>
createConfigProvider(factory as ConfigFactory & ConfigFactoryKeyHost),
)
.filter(item => item) as FactoryProvider[];options.load가 없는 경우 → 빈 리스트options.load가 있는 경우 →load에 들어간 객체를FactoryProvider로 변환해서 모아둔 리스트
createConfigProvider 함수를 확인하면, load에 들어간 function이 useFactory에 들어가는 것을 확인할 수 있습니다.
2. Provider 토큰 목록 생성
만들어진 provider에서 provide 토큰만 가져와 토큰 목록을 만듭니다:
const configProviderTokens = providers.map(item => item.provide);3. ConfigService Provider 생성
const configServiceProvider = {
provide: ConfigService,
useFactory: (configService: ConfigService) => {
if (options.cache) {
(configService as any).isCacheEnabled = true;
}
configService.setEnvFilePaths(envFilePaths);
return configService;
},
inject: [CONFIGURATION_SERVICE_TOKEN, ...configProviderTokens],
};
providers.push(configServiceProvider);Factory를 사용해서 새로운 ConfigService를 반환합니다. 여기서 주입되는 configService는 CONFIGURATION_SERVICE_TOKEN 토큰으로 가져오며, 이는 ConfigHostModule에서 제공합니다.
4. ConfigHostModule
@Global()
@Module({
providers: [
{
provide: CONFIGURATION_TOKEN,
useFactory: () => ({}),
},
{
provide: CONFIGURATION_SERVICE_TOKEN,
useClass: ConfigService,
},
],
exports: [CONFIGURATION_TOKEN, CONFIGURATION_SERVICE_TOKEN],
})
export class ConfigHostModule {}ConfigHostModule은 Global 모듈로 되어있어서 import하지 않고도 inject할 수 있습니다.
Providers 최종 구성
load가 있는 경우:
- load 내부 함수를 factory로 하는 provider 리스트
- ConfigService를 provide하는 provider
load가 없는 경우:
- ConfigService를 provide하는 provider
CONFIGURATION_LOADER의 역할
const isConfigToLoad = options.load && options.load.length;
return {
module: ConfigModule,
global: options.isGlobal,
providers: isConfigToLoad
? [
...providers,
{
provide: CONFIGURATION_LOADER,
useFactory: (
host: Record<string, any>,
...configurations: Record<string, any>[]
) => {
configurations.forEach((item, index) =>
this.mergePartial(host, item, providers[index]),
);
},
inject: [CONFIGURATION_TOKEN, ...configProviderTokens],
},
]
: providers,
exports: [ConfigService, ...configProviderTokens],
};load가 있는 경우, CONFIGURATION_LOADER를 토큰으로 하는 provider가 추가됩니다. 이 provider는 CONFIGURATION_TOKEN으로 주입된 host에 나머지 환경변수들을 넣어줍니다.
ConfigService의 get() 메서드
우리는 보통 환경 변수를 가져올 때 configService.get('something')을 사용합니다:
get<T = any>(
propertyPath: KeyOf<K>,
defaultValueOrOptions?: T | ConfigGetOptions,
options?: ConfigGetOptions,
): T | undefined {
const validatedEnvValue = this.getFromValidatedEnv(propertyPath);
if (!isUndefined(validatedEnvValue)) {
return validatedEnvValue;
}
const processEnvValue = this.getFromProcessEnv(propertyPath, defaultValue);
if (!isUndefined(processEnvValue)) {
return processEnvValue;
}
const internalValue = this.getFromInternalConfig(propertyPath);
if (!isUndefined(internalValue)) {
return internalValue;
}
return defaultValue as T;
}값을 찾는 순서:
validatedEnvValueprocessEnvValueinternalValue
getFromInternalConfig
private getFromInternalConfig<T = any>(
propertyPath: KeyOf<K>,
): T | undefined {
const internalValue = get(this.internalConfig, propertyPath);
return internalValue;
}this.internalConfig에서 값을 찾아옵니다. 이 internalConfig는 ConfigService 생성 시 주입됩니다:
constructor(
@Optional()
@Inject(CONFIGURATION_TOKEN)
private readonly internalConfig: Record<string, any> = {},
) {}즉, internalValue에 값을 추가해주기 위해서는 ConfigHostModule에서 CONFIGURATION_TOKEN 토큰으로 제공하는 {} 객체에 값을 추가해주는 과정이 필요합니다.
DynamicModule 최종 정리
이제 forRoot의 결과물을 완전히 이해할 수 있습니다:
load가 있는 경우:
- ConfigService를 provide하는 provider
- load 내부 함수를 factory로 하는 provider 리스트 → 이를 통해 ConfigService의 InternalValue 값 등록
load가 없는 경우:
- ConfigService를 provide하는 provider
그리고 global 옵션에 따라 이 DynamicModule을 Global 모듈로 등록할 수 있습니다.
왜 만들어진 DynamicModule을 쓰지 못했는가?
문제의 핵심: 비동기 모듈 초기화
타임스프레드에서는 main.ts에서 AppModule을 가지고 NestJS 서버를 실행시킵니다:
const server = await NestFactory.create<NestExpressApplication>(AppModule, {
bodyParser: false,
logger: logger,
});AppModule에는 ConfigModule을 제외하고도 수많은 모듈을 import합니다:
@Module({
imports: [
ConfigModule.forRoot({
// ... 설정
load: [getSecretValue],
}),
RepoModule,
CacheDBModule,
NewsModule,
// ... 많은 모듈들
BaseControllersModule,
],
controllers: [ProxyController],
})
export class AppModule implements NestModule {}중요한 점: 모듈이 import되는 순서는 보장되지 않습니다. 비동기로 처리됩니다.
잘못된 ConfigService 주입
최초 문제가 발생했던 AuthModule도 ConfigModule과 동시에 비동기로 만들어집니다.
AuthModule에서 ConfigModule을 import하면:
ConfigModule.forRoot()의 결과물(DynamicModule)을 import하는 것이 아니라ConfigModule이 기본적으로 제공하는 다음 형태가 import됩니다:
@Module({
imports: [ConfigHostModule],
providers: [
{
provide: ConfigService,
useExisting: CONFIGURATION_SERVICE_TOKEN,
},
],
exports: [ConfigHostModule, ConfigService],
})
export class ConfigModule {
// ...
}이 모듈을 통해 제공되는 ConfigService는 ConfigHostModule이 제공하는 ConfigService이고, 이는 factory 패턴으로 만들어지지 않았기 때문에 internalConfig가 비어있습니다({}).
결론
ConfigModule과 동시에 만들어지는 다른 모듈들에서 ConfigModule을 import하고 ConfigService를 주입받으려 한다면:
- ❌ 잘못된 주입:
ConfigHostModule이 제공하는 빈ConfigService - ✅ 올바른 주입:
forRoot()를 통해 만들어진 DynamicModule이 제공하는ConfigService
그래서 configService.get()으로 환경 변수를 얻으려 할 때:
- Beanstalk 환경변수는 가져와졌지만
load에 넣어둔 값은 사용할 수 없었던 것입니다
해결 방법
ConfigModule.forRoot()에서 isGlobal: true로 설정했다면:
- 다른 모듈에서
ConfigModule을 import하지 마세요 - Global 모듈로 등록된
ConfigService를 바로 주입받아 사용하세요
// ❌ 잘못된 방법
@Module({
imports: [ConfigModule], // 불필요!
providers: [AppleStrategy],
})
export class AuthModule {}
// ✅ 올바른 방법
@Module({
imports: [], // ConfigModule import 없이
providers: [AppleStrategy], // ConfigService 바로 주입 가능
})
export class AuthModule {}

