ConfigType + registerAs
NestJS의 ConfigModule은 .env 파일을 읽어서 ConfigService.get('KEY')로 접근하게 해준다. 간단한 앱이라면 이걸로 충분하지만, 설정이 많아지면 문제가 생긴다.
// 어디선가 오타가 나도 런타임까지 모른다
const host = configService.get('DATABSE_HOST'); // 오타!
const port = configService.get('DATABASE_PORT'); // string인데 number처럼 쓸 수도
get()의 반환 타입이 any이기 때문에 타입 체크가 안 되고, 키 이름도 문자열이라 오타를 잡을 수 없다. 설정이 20~30개가 넘어가면 어떤 키가 어디에 쓰이는지 추적하기도 어렵다.
registerAs와 ConfigType은 이 문제를 해결한다. 설정을 네임스페이스 단위로 그룹화하고, 타입 안전하게 주입받을 수 있게 해준다.
registerAs — 설정 네임스페이스 만들기
registerAs는 설정 팩토리 함수에 네임스페이스 이름을 붙여준다. 반환값은 NestJS DI 토큰으로 사용할 수 있는 특수한 함수 객체다.
// 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 유틸리티 타입으로 정확한 타입을 추론한다.
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 배열에 넣어야 한다.
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 파일과 다를 게 없다.
src/
config/
app.config.ts # 포트, 호스트, 환경
database.config.ts # DB 연결 정보
auth.config.ts # JWT 시크릿, 만료 시간
s3.config.ts # AWS S3 설정
mail.config.ts # 메일 서버 설정
각 파일은 같은 패턴을 따른다.
// 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,
}));
// 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가 들어가서 런타임에 터진다. Joi나 class-validator로 검증을 추가할 수 있다.
Joi를 이용한 전역 검증
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를 안 쓰고 팩토리 함수 안에서 직접 체크하는 방법도 있다.
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() 방식
@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 자동완성이 안 된다
- 제네릭으로 타입을 지정해도 실제 값과 일치하는지 보장이 없다
- 설정 키를 리팩토링할 때 어디서 쓰이는지 추적이 안 된다
직접 주입 방식
@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가 어떻게 작동하는지 이해하면 디버깅할 때 도움이 된다.
// @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;
}
registerAs('database', factory)를 호출하면 팩토리 함수에.KEY = 'database'프로퍼티를 붙인다ConfigModule.forRoot({ load: [databaseConfig] })에서 이 함수를 발견하면 내부적으로{ provide: 'database', useFactory: databaseConfig }형태의 provider를 등록한다@Inject(databaseConfig.KEY)는 곧@Inject('database')이며, NestJS DI가 해당 토큰으로 등록된 provider를 찾아서 주입한다ConfigType<typeof databaseConfig>는ReturnType<typeof databaseConfig>와 같다. 팩토리 함수의 반환 타입을 추출할 뿐이다
결국 registerAs는 "팩토리 함수 + DI 토큰"을 하나로 묶어주는 유틸리티고, ConfigType은 그 팩토리의 반환 타입을 추출하는 타입 유틸리티다. 마법 같은 건 없다.
비동기 설정 (registerAs + async)
팩토리 함수는 비동기일 수도 있다. 외부 설정 서버(AWS Parameter Store, Vault 등)에서 값을 가져와야 하는 경우에 유용하다.
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가 내부적으로 비동기 팩토리를 처리해주기 때문에 대부분 그냥 동작한다.
테스트에서의 활용
직접 주입 방식의 큰 장점 중 하나가 테스트 용이성이다.
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가 설정 프로퍼티를 자동완성해주고, 리팩토링할 때 컴파일러가 영향 범위를 알려준다.
관련 문서
- NestJS DI - 의존성 주입 기본 원리
- Module System - 모듈 구성과 isGlobal
- Zod 스키마 검증 - 런타임 검증 패턴