NestJS ConfigModule 동작 원리 완벽 가이드: ConfigService 값 조회 실패 해결

· 유창연 · 10 min read

ConfigService에서 환경변수가 조회되지 않는 문제의 근본 원인을 코드 레벨에서 분석합니다. DynamicModule 생성 과정과 Provider 주입 메커니즘을 상세히 파헤쳐 실전 해결 방법을 제시합니다.

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하고 있는 AuthModuleConfigModule을 추가했습니다.

@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],
};

반환되고 있는 것은 크게 두 가지입니다:

  1. 어딘가에서 만들어졌을 providers
  2. 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를 반환합니다. 여기서 주입되는 configServiceCONFIGURATION_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가 있는 경우:

  1. load 내부 함수를 factory로 하는 provider 리스트
  2. 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;
}

값을 찾는 순서:

  1. validatedEnvValue
  2. processEnvValue
  3. internalValue

getFromInternalConfig

private getFromInternalConfig<T = any>(
  propertyPath: KeyOf<K>,
): T | undefined {
  const internalValue = get(this.internalConfig, propertyPath);
  return internalValue;
}

this.internalConfig에서 값을 찾아옵니다. 이 internalConfigConfigService 생성 시 주입됩니다:

constructor(
  @Optional()
  @Inject(CONFIGURATION_TOKEN)
  private readonly internalConfig: Record<string, any> = {},
) {}

즉, internalValue에 값을 추가해주기 위해서는 ConfigHostModule에서 CONFIGURATION_TOKEN 토큰으로 제공하는 {} 객체에 값을 추가해주는 과정이 필요합니다.

DynamicModule 최종 정리

이제 forRoot의 결과물을 완전히 이해할 수 있습니다:

load가 있는 경우:

  1. ConfigService를 provide하는 provider
  2. 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 주입

최초 문제가 발생했던 AuthModuleConfigModule과 동시에 비동기로 만들어집니다.

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 {
  // ...
}

이 모듈을 통해 제공되는 ConfigServiceConfigHostModule이 제공하는 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 {}

댓글

Back to Blog

관련 게시글

View All Posts »
Nestjs의 CQRS와 SAGA 패턴

Nestjs의 CQRS와 SAGA 패턴

NestJS에서 CQRS와 SAGA 패턴을 활용하여 복잡한 도메인 로직을 분리하고, 오케스트레이션과 코레오그래피 방식으로 분산 트랜잭션을 관리하는 방법을 정리합니다.