junyeokk
Blog
NestJS·2025. 11. 15

ConfigType + registerAs

NestJS의 ConfigModule.env 파일을 읽어서 ConfigService.get('KEY')로 접근하게 해준다. 간단한 앱이라면 이걸로 충분하지만, 설정이 많아지면 문제가 생긴다.

typescript
// 어디선가 오타가 나도 런타임까지 모른다
const host = configService.get('DATABSE_HOST'); // 오타!
const port = configService.get('DATABASE_PORT'); // string인데 number처럼 쓸 수도

get()의 반환 타입이 any이기 때문에 타입 체크가 안 되고, 키 이름도 문자열이라 오타를 잡을 수 없다. 설정이 20~30개가 넘어가면 어떤 키가 어디에 쓰이는지 추적하기도 어렵다.

registerAsConfigType은 이 문제를 해결한다. 설정을 네임스페이스 단위로 그룹화하고, 타입 안전하게 주입받을 수 있게 해준다.


registerAs — 설정 네임스페이스 만들기

registerAs는 설정 팩토리 함수에 네임스페이스 이름을 붙여준다. 반환값은 NestJS DI 토큰으로 사용할 수 있는 특수한 함수 객체다.

typescript
// config/database.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('database', () => ({
  host: process.env.DATABASE_HOST || 'localhost',
  port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
  username: process.env.DATABASE_USERNAME,
  password: process.env.DATABASE_PASSWORD,
  name: process.env.DATABASE_NAME,
  ssl: process.env.DATABASE_SSL === 'true',
}));

첫 번째 인자 'database'가 네임스페이스 이름이다. 이 이름으로 ConfigService.get('database')를 호출하면 팩토리 함수가 반환한 객체 전체를 가져올 수 있다. 하지만 이 방식보다 직접 주입하는 방식이 훨씬 낫다.


ConfigType — 타입 안전한 주입

registerAs가 반환한 객체에는 .KEY라는 프로퍼티가 있다. 이걸 @Inject() 데코레이터에 넣으면 해당 네임스페이스의 설정 객체를 직접 주입받을 수 있다. 그리고 ConfigType 유틸리티 타입으로 정확한 타입을 추론한다.

typescript
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './config/database.config';

@Injectable()
export class DatabaseService {
  constructor(
    @Inject(databaseConfig.KEY)
    private dbConfig: ConfigType<typeof databaseConfig>,
  ) {}

  connect() {
    // 자동완성이 된다!
    console.log(this.dbConfig.host);     // string
    console.log(this.dbConfig.port);     // number
    console.log(this.dbConfig.ssl);      // boolean
    // this.dbConfig.typo → 컴파일 에러!
  }
}

ConfigType<typeof databaseConfig>는 팩토리 함수의 반환 타입을 추출한다. 즉 { host: string; port: number; ... } 타입이 정확하게 붙는다. ConfigService.get() 방식처럼 문자열 키로 접근하는 게 아니라 객체 프로퍼티로 접근하니까 오타가 컴파일 타임에 잡힌다.


ConfigModule에 등록하기

registerAs로 만든 설정은 ConfigModule.forRoot()load 배열에 넣어야 한다.

typescript
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import databaseConfig from './config/database.config';
import appConfig from './config/app.config';
import authConfig from './config/auth.config';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [databaseConfig, appConfig, authConfig],
      isGlobal: true,
    }),
  ],
})
export class AppModule {}

isGlobal: true로 설정하면 모든 모듈에서 별도 import 없이 @Inject(databaseConfig.KEY)를 쓸 수 있다. 이걸 안 하면 해당 설정을 쓰는 모듈마다 ConfigModule을 imports에 넣어야 한다.


설정을 여러 네임스페이스로 분리하기

실제 프로젝트에서는 설정을 도메인별로 분리하는 게 좋다. 파일 하나에 모든 환경 변수를 때려넣으면 결국 .env 파일과 다를 게 없다.

text
src/
  config/
    app.config.ts        # 포트, 호스트, 환경
    database.config.ts   # DB 연결 정보
    auth.config.ts       # JWT 시크릿, 만료 시간
    s3.config.ts         # AWS S3 설정
    mail.config.ts       # 메일 서버 설정

각 파일은 같은 패턴을 따른다.

typescript
// config/auth.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('auth', () => ({
  jwtSecret: process.env.JWT_SECRET,
  jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
  refreshSecret: process.env.JWT_REFRESH_SECRET,
  refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
  bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS, 10) || 10,
}));
typescript
// config/s3.config.ts
import { registerAs } from '@nestjs/config';

export default registerAs('s3', () => ({
  bucket: process.env.S3_BUCKET,
  region: process.env.S3_REGION || 'ap-northeast-2',
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  cdnDomain: process.env.CDN_DOMAIN,
}));

이렇게 분리하면 각 서비스는 자기가 필요한 설정만 주입받는다. S3Service가 JWT 시크릿을 알 필요는 없다.


유효성 검증 추가하기

registerAs만으로는 필수 환경 변수가 빠졌을 때 감지할 수 없다. 앱이 뜬 다음에 undefined가 들어가서 런타임에 터진다. Joiclass-validator로 검증을 추가할 수 있다.

Joi를 이용한 전역 검증

typescript
import * as Joi from 'joi';

@Module({
  imports: [
    ConfigModule.forRoot({
      load: [databaseConfig, authConfig],
      validationSchema: Joi.object({
        DATABASE_HOST: Joi.string().required(),
        DATABASE_PORT: Joi.number().default(5432),
        JWT_SECRET: Joi.string().required(),
        NODE_ENV: Joi.string()
          .valid('development', 'production', 'test')
          .default('development'),
      }),
      validationOptions: {
        allowUnknown: true,    // .env에 정의 안 된 키 허용
        abortEarly: false,     // 모든 에러를 한 번에 보여줌
      },
    }),
  ],
})
export class AppModule {}

validationSchema는 앱이 시작할 때 .env의 원시 값(문자열)을 검증한다. 필수 값이 없으면 앱이 아예 뜨지 않는다. abortEarly: false로 설정하면 빠진 환경 변수를 한 번에 다 알려줘서 하나씩 고치는 삽질을 줄일 수 있다.

팩토리 내부에서 직접 검증

Joi를 안 쓰고 팩토리 함수 안에서 직접 체크하는 방법도 있다.

typescript
export default registerAs('database', () => {
  const host = process.env.DATABASE_HOST;
  const port = parseInt(process.env.DATABASE_PORT, 10);

  if (!host) {
    throw new Error('DATABASE_HOST 환경 변수가 설정되지 않았습니다.');
  }

  if (isNaN(port)) {
    throw new Error('DATABASE_PORT가 유효한 숫자가 아닙니다.');
  }

  return {
    host,
    port,
    username: process.env.DATABASE_USERNAME || 'postgres',
    password: process.env.DATABASE_PASSWORD || '',
    name: process.env.DATABASE_NAME || 'app',
  };
});

이 방식의 장점은 네임스페이스 단위로 검증 로직이 응집된다는 것이다. 해당 설정 파일만 보면 어떤 환경 변수가 필수이고 어떤 게 기본값이 있는지 한눈에 파악할 수 있다.


ConfigService.get() vs 직접 주입 비교

두 방식을 나란히 놓고 보면 차이가 명확하다.

ConfigService.get() 방식

typescript
@Injectable()
export class SomeService {
  constructor(private configService: ConfigService) {}

  doSomething() {
    // 타입이 any — 오타나 타입 실수를 못 잡음
    const host = this.configService.get('database.host');
    const port = this.configService.get<number>('database.port');
  }
}
  • 키가 문자열이라 IDE 자동완성이 안 된다
  • 제네릭으로 타입을 지정해도 실제 값과 일치하는지 보장이 없다
  • 설정 키를 리팩토링할 때 어디서 쓰이는지 추적이 안 된다

직접 주입 방식

typescript
@Injectable()
export class SomeService {
  constructor(
    @Inject(databaseConfig.KEY)
    private dbConfig: ConfigType<typeof databaseConfig>,
  ) {}

  doSomething() {
    // 타입이 정확 — 자동완성, 컴파일 타임 체크
    const host = this.dbConfig.host;
    const port = this.dbConfig.port;
  }
}
  • 프로퍼티 접근이라 IDE 자동완성이 된다
  • 타입이 팩토리 함수에서 자동 추론된다
  • 설정 키를 바꾸면 컴파일 에러로 즉시 잡힌다
  • 의존성이 명시적이라 테스트할 때 모킹이 쉽다

거의 모든 상황에서 직접 주입 방식이 낫다. ConfigService.get()을 쓸 이유가 있다면, 동적으로 키를 결정해야 하는 드문 경우 정도다.


내부 동작 원리

registerAs가 어떻게 작동하는지 이해하면 디버깅할 때 도움이 된다.

typescript
// @nestjs/config 내부 (단순화)
export function registerAs<T>(
  token: string,
  configFactory: () => T,
): (() => T) & { KEY: string } {
  const fn = configFactory as any;
  fn.KEY = token;

  // ConfigModule이 이 함수를 provider로 등록한다
  // { provide: token, useFactory: configFactory }
  return fn;
}
  1. registerAs('database', factory)를 호출하면 팩토리 함수에 .KEY = 'database' 프로퍼티를 붙인다
  2. ConfigModule.forRoot({ load: [databaseConfig] })에서 이 함수를 발견하면 내부적으로 { provide: 'database', useFactory: databaseConfig } 형태의 provider를 등록한다
  3. @Inject(databaseConfig.KEY)는 곧 @Inject('database')이며, NestJS DI가 해당 토큰으로 등록된 provider를 찾아서 주입한다
  4. ConfigType<typeof databaseConfig>ReturnType<typeof databaseConfig>와 같다. 팩토리 함수의 반환 타입을 추출할 뿐이다

결국 registerAs는 "팩토리 함수 + DI 토큰"을 하나로 묶어주는 유틸리티고, ConfigType은 그 팩토리의 반환 타입을 추출하는 타입 유틸리티다. 마법 같은 건 없다.


비동기 설정 (registerAs + async)

팩토리 함수는 비동기일 수도 있다. 외부 설정 서버(AWS Parameter Store, Vault 등)에서 값을 가져와야 하는 경우에 유용하다.

typescript
export default registerAs('secrets', async () => {
  const ssmClient = new SSMClient({ region: 'ap-northeast-2' });

  const result = await ssmClient.send(
    new GetParameterCommand({
      Name: '/myapp/production/db-password',
      WithDecryption: true,
    }),
  );

  return {
    dbPassword: result.Parameter.Value,
    // ...다른 시크릿들
  };
});

비동기 팩토리를 쓸 때는 ConfigModule.forRoot()가 아니라 ConfigModule.forRootAsync()를 사용해야 할 수도 있다. 하지만 load 배열에 넣는 방식은 NestJS가 내부적으로 비동기 팩토리를 처리해주기 때문에 대부분 그냥 동작한다.


테스트에서의 활용

직접 주입 방식의 큰 장점 중 하나가 테스트 용이성이다.

typescript
describe('DatabaseService', () => {
  let service: DatabaseService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        DatabaseService,
        {
          provide: databaseConfig.KEY,
          useValue: {
            host: 'localhost',
            port: 5432,
            username: 'test',
            password: 'test',
            name: 'test_db',
            ssl: false,
          },
        },
      ],
    }).compile();

    service = module.get(DatabaseService);
  });

  it('should connect with test config', () => {
    // ConfigModule 없이도 테스트 가능
    expect(service.connect()).toBeDefined();
  });
});

ConfigService를 통째로 모킹할 필요 없이, 해당 네임스페이스의 설정 객체만 직접 넣어주면 된다. 테스트가 훨씬 단순해지고, 어떤 설정에 의존하는지 명확하게 보인다.


정리

개념역할
registerAs(token, factory)설정 팩토리에 네임스페이스를 부여하고 DI 토큰을 생성
.KEY주입 시 사용할 토큰 문자열 (@Inject(config.KEY))
ConfigType<typeof config>팩토리 반환 타입을 추출하는 유틸리티 타입
load: [...]ConfigModule에 네임스페이스 설정을 등록

핵심은 문자열 기반 접근(get('key'))에서 타입 안전한 객체 주입으로 전환하는 것이다. 설정이 10개를 넘어가면 이 패턴의 가치를 체감하게 된다. 오타로 인한 런타임 에러가 사라지고, IDE가 설정 프로퍼티를 자동완성해주고, 리팩토링할 때 컴파일러가 영향 범위를 알려준다.

관련 문서