[NestJS | Docs] Configuration 사용법
2024.02.23- -
1. Configuration
애플리케이션은 대개 서로 다른 환경에서 실행됩니다. 그렇기에 환경에 따라 다른 구성 설정을 사용해야 해주어야 하는데요.
예를 들어, 일반적으로 로컬 환경에서는 로컬 DB 인스턴스에만 유효한 특정 데이터베이스 자격 증명에 사용되는 경우가 있습니다. 프로덕션 환경에서는 별도의 DB 자격 증명 세트를 사용합니다. 구성 변수(configuration variables)는 변경되기 때문에 해당 환경에 구성 변수를 저장하는 것이 가장 좋은 방법입니다.
외부에서 정의된 환경 변수는 process.env를 통해 Node.js 내부에서 볼 수 있습니다. 환경 변수를 각 환경에서 개별적으로 설정하여 여러 환경이라 발생할 수 있는 문제를 해결할 수 있습니다. 하지만 이러한 값을 쉽게 모킹하거나 변경할 필요가 있는 개발 및 테스트 환경에서는 매우 번거로운 작업일 수 있습니다.
Node.js 애플리케이션에서는 각 환경을 나타내기 위해 각 키가 특정 값을 나타내는 키-값 쌍을 포함하고 있는 .env 파일을 사용하는 것이 일반적입니다. 다른 환경에서 동일한 앱을 실행하려면 해당 환경에 맞게끔 올바른 .env 파일로 교체하기만 하면 됩니다.
Nest에서 이 기술을 사용하기 위한 가장 좋은 접근 방식은 적절한 .env 파일을 로드하는 ConfigService를 제공하는 ConfigModule을 만드는 것입니다. 이 모듈을 직접 작성할 수도 있지만, 편의를 위해 Nest는 @nestjs/config 패키지를 기본으로 제공합니다.
1-1. Installation (설치)
사용하려면 먼저 필수 의존성을 설치해야 합니다.
$ npm i --save @nestjs/config
@nestjs/config 패키지는 내부적으로 dotenv를 사용하며 TypeScript 4.1 이상의 버전에서만 동작합니다.
1-2. 사용법
설치 프로세스가 완료되면 ConfigModule을 import 할 수 있습니다. 일반적으로 루트 AppModule에서 import하고 .forRoot() 정적 메서드를 사용하여 동작을 제어합니다.
이 단계에서는 환경 변수 키/값 쌍을 파싱하고 확인합니다. 이후에 다른 기능 모듈에서 ConfigModule의 ConfigService 클래스에 접근하기 위한 몇 가지 옵션을 살펴볼 것입니다.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule.forRoot()],
})
export class AppModule {}
위의 코드는 기본 위치(프로젝트 루트 디렉토리)에서 .env 파일을 load 및 parsing하고, .env 파일의 키/값 쌍을 process.env에 할당된 환경 변수와 병합하여, 그 결과를 ConfigService를 통해 액세스할 수 있는 비공개 구조에 저장하는 과정을 거치게 됩니다.
forRoot() 메서드는 이러한 구문 분석/병합된 구성 변수를 읽기 위한 get() 메서드를 제공하는 ConfigService 공급자를 등록합니다. nestjs/config는 dotenv에 의존하기 때문에 환경 변수 이름의 충돌을 해결하기 위해 해당 패키지의 규칙을 사용합니다. 키가 런타임 환경의 환경 변수(예: export DATABASE_USER=test와 같은 OS 셸 내보내기를 통해)와 .env 파일에 모두 존재하는 경우, 런타임 환경 변수가 우선적으로 고려됩니다.
샘플 .env 파일은 다음과 같습니다:
DATABASE_USER=test
DATABASE_PASSWORD=test
1-3. Custom env file path
기본적으로 패키지는 애플리케이션의 루트 디렉터리에서 .env 파일을 찾습니다. .env 파일의 다른 경로를 지정하려면 다음과 같이 forRoot()에 전달하는 (선택적) 옵션 객체의 envFilePath 속성을 설정합니다:
ConfigModule.forRoot({
envFilePath: '.development.env',
});
다음과 같이 .env 파일의 경로를 여러 개 지정할 수도 있습니다:
ConfigModule.forRoot({
envFilePath: ['.env.development.local', '.env.development'],
});
변수가 여러 파일에 있는 경우 첫 번째 파일이 우선합니다.
1-4. Disable env variables loading
.env 파일을 로드하지 않고 대신 런타임 환경의 환경 변수에 간단히 액세스하고 싶다면(예: export DATABASE_USER=test와 같은 OS 셸 내보내기) 다음과 같이 옵션 개체의 ignoreEnvFile 속성을 true로 설정합니다:
ConfigModule.forRoot({
ignoreEnvFile: true,
});
1-5. Use module globally
만약 다른 모듈에서 ConfigModule을 사용하려고 한다면 Nest의 다른 표준 모듈들과 마찬가지로 사용하려는 모듈에서 해당 모듈을 import 해주어야 합니다.
그렇게 하지 않고 아래 그림과 같이 옵션 객체의 isGlobal 속성을 true로 설정하여 전역 모듈로 선언할 수도 있습니다. 이 경우 루트 모듈(예: AppModule)에 로드된 후에는 다른 모듈에서 ConfigModule을 import하지 않아도 됩니다.
ConfigModule.forRoot({
isGlobal: true,
});
1-6. Custom configuration files
좀 더 복잡한 프로젝트의 경우 custom구성 파일을 활용하여 중첩 구성 객체를 반환할 수 있습니다. 이렇게 하면 관련 구성 설정을 기능별로 그룹화하고(예: 데이터베이스 관련 설정), 관련 설정을 개별 파일에 저장함으로써 독립적으로 관리할 수 있습니다.
custom 구성 파일은 configuration 객체를 반환하는 팩토리 함수를 내보냅니다. configuration 객체는 임의의 중첩 일반 JavaScript 객체일 수 있습니다.
process.env 객체에는 fully resolved 환경 변수 키/값 쌍이 포함됩니다(위에서 설명한 대로 .env 파일과 외부 정의 변수가 해결되고 병합됨). 여기서는 반환된 구성 객체를 제어하기 때문에 필요한 로직을 추가하여 값을 적절한 유형으로 캐스팅하고 기본값을 설정하는 등의 작업을 수행할 수 있습니다.
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432
}
});
이 파일은 ConfigModule.forRoot() 메서드에 전달하는 옵션 객체의 load 속성을 사용하여 로드합니다:
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
}),
],
})
export class AppModule {}
load 속성에 할당된 값은 배열이므로 여러 구성 파일을 로드할 수 있습니다(예: load: [databaseConfig, authConfig]).
1-7. Using the ConfigService
ConfigService의 구성 값에 접근하려면 당연하게도 먼저 ConfigService를 주입해야 합니다. 다른 프로바이더들과 마찬가지로 해당 프로바이더를 포함하는 모듈인 ConfigModule을 사용할 모듈에서 import 해야 합니다(ConfigModule.forRoot() 메서드에 전달된 옵션 객체의 isGlobal 속성을 true로 설정하지 않는 한). 아래와 같이 기능 모듈에서 import 할 수 있습니다.
// feature.module.ts
@Module({
imports: [ConfigModule],
// ...
})
그런 다음 표준 생성자 주입 방식을 사용하여 주입할 수 있습니다:
constructor(private configService: ConfigService) {}
그리고 이를 사용할 때는 아래와 같이 사용할 수 있습니다.
// get an environment variable
const dbUser = this.configService.get<string>('DATABASE_USER');
// get a custom configuration value
const dbHost = this.configService.get<string>('database.host');
위와 같이 configService.get() 메서드를 사용하여 변수 이름을 전달하여 간단한 환경 변수를 가져옵니다. 위와 같이 타입을 전달하여 타입스크립트 Type Hint를 수행할 수 있습니다(예: get<string>(...)). 위의 두 번째 예에서와 같이 get() 메서드는 중첩 custom 구성 객체(custom 구성 파일을 통해 생성됨)를 탐색할 수도 있습니다.
인터페이스를 Type Hint로 사용하여 중첩된 전체 custom 구성 객체를 가져올 수도 있습니다:
interface DatabaseConfig {
host: string;
port: number;
}
const dbConfig = this.configService.get<DatabaseConfig>('database');
// you can now use `dbConfig.port` and `dbConfig.host`
const port = dbConfig.port;
또한 get() 메서드는 아래와 같이 키가 존재하지 않을 때 반환되는 기본값(default 값)을 정의하는 선택적 두 번째 인수를 받습니다:
// use "localhost" when "database.host" is not defined
const dbHost = this.configService.get<string>('database.host', 'localhost');
ConfigService에는 두 개의 선택적 Generic(유형 인수)이 있습니다. 첫 번째는 존재하지 않는 구성 속성에 액세스하는 것을 방지하는 데 도움이 됩니다. 아래와 같이 사용합니다:
interface EnvironmentVariables {
PORT: number;
TIMEOUT: string;
}
// somewhere in the code
constructor(private configService: ConfigService<EnvironmentVariables>) {
const port = this.configService.get('PORT', { infer: true });
// TypeScript Error: this is invalid as the URL property is not defined in EnvironmentVariables
const url = this.configService.get('URL', { infer: true });
}
위 코드 와 같이 infer 속성을 true로 설정하면 ConfigService#get 메서드에서는 지정한 인터페이스를 기반으로 속성 유형을 자동으로 추론합니다(예: PORT는 EnvironmentVariables 인터페이스에 숫자 유형이 있으므로 TypeScript에서 strictNullChecks 플래그를 사용하지 않는 경우 typeof port === "숫자").
또한 유추 기능을 사용하면 점 표기법을 사용하는 경우에도 다음과 같이 중첩 custom 구성 객체의 속성 유형을 유추할 수 있습니다:
constructor(private configService: ConfigService<{ database: { host: string } }>) {
const dbHost = this.configService.get('database.host', { infer: true })!;
// typeof dbHost === "string" |
// +--> non-null assertion operator
}
두 번째 Generic은 첫 번째 제네릭에 의존하며, strictNullChecks가 켜져 있을 때 ConfigService의 메서드가 반환할 수 있는 모든 undefined 타입을 제거하는 type assertion으로 작동합니다. 예를 들어
// ...
constructor(private configService: ConfigService<{ PORT: number }, true>) {
// ^^^^
const port = this.configService.get('PORT', { infer: true });
// ^^^ port의 타입은 'number' 여야 하므로 더 이상 TS type assertion이 필요없습니다.
}
1-8. Configuration namespaces
위의 custom 구성 파일에 표시된 것처럼 여러 개의 custom 구성 파일을 정의하고 로드할 수도 있습니다. 해당 섹션에 표시된 것처럼 중첩 구성 객체를 사용하여 복잡한 구성 객체 계층 구조를 관리할 수 있습니다.
또는 다음과 같이 @nestjs/config 패키지의 registerAs() 함수를 사용하여 "namespaced" 구성 개체를 반환할 수도 있습니다:
// config/database.config.ts
export default registerAs('database', () => ({
host: process.env.DATABASE_HOST,
port: process.env.DATABASE_PORT || 5432
}));
custom 구성 파일과 마찬가지로, registerAs() 팩토리 함수 내부의 process.env 객체에는 fully resolved 환경 변수 키/값 쌍이 포함됩니다( 위에서 설명한 대로 resolve 및 merge된 .env 파일과 외부 정의 변수).
import databaseConfig from './config/database.config';
@Module({
imports: [
ConfigModule.forRoot({
load: [databaseConfig],
}),
],
})
export class AppModule {}
이제 데이터베이스 namespace에서 host 값을 가져오려면 점 표기법을 사용합니다. namespace 이름에 해당하는 속성 이름의 접두사로 'database'를 사용합니다(registerAs() 함수의 첫 번째 인수로 전달된 값):
const dbHost = this.configService.get<string>('database.host');
합리적인 대안은 데이터베이스 namespace를 직접 주입하는 것입니다. 이렇게 하면 강력한 타이핑의 이점을 누릴 수 있습니다:
constructor(
@Inject(databaseConfig.KEY)
private dbConfig: ConfigType<typeof databaseConfig>,
) {}
1-9. Cache environment variables
때에 따라서 process.env에 액세스하는 속도가 느려지는 경우가 있는데, 이 경우에 ConfigModule.forRoot()에 전달되는 옵션 객체의 cache 속성을 설정하여 process.env에 저장된 변수와 관련하여 ConfigService#get 메서드의 성능을 향상시킬 수 있습니다.
ConfigModule.forRoot({
cache: true,
});
1-10. Partial registration
지금까지는 forRoot() 메서드를 사용하여 루트 모듈(예: AppModule)에서 구성 파일을 처리했습니다. 기능별 구성 파일이 여러 다른 디렉터리에 있는 더 복잡한 프로젝트 구조를 가지고 있는 경우, 이러한 파일을 모두 루트 모듈에 로드하는 대신 각 기능 모듈과 관련된 구성 파일만 참조하는 부분 등록(partial registration)이라는 기능을 사용할 수 있습니다. 이 partial registration을 수행하려면 다음과 같이 기능 모듈 내에서 forFeature() 정적 메서드를 사용합니다:
import databaseConfig from './config/database.config';
@Module({
imports: [ConfigModule.forFeature(databaseConfig)],
})
export class DatabaseModule {}
경우에 따라 생성자가 아닌 onModuleInit() 훅을 사용하여 부분 등록을 통해 로드된 속성에 액세스해야 할 수도 있습니다. 이는 모듈 초기화 중에 forFeature() 메서드가 실행되기 때문이고 모듈 초기화 순서가 불확실하기 때문입니다. 다른 모듈에서 이러한 방식으로 로드한 값을 생성자에서 액세스하는 경우 구성이 의존하는 모듈이 아직 초기화되지 않았을 수 있습니다. onModuleInit() 메서드는 의존하고 있는 모든 모듈이 초기화된 후에만 실행되므로 이 기법은 안전합니다.
1-11. Schema validation
필요한 환경 변수가 제공되지 않았거나 특정 유효성 검사 규칙을 충족하지 않는 경우 애플리케이션 시작 중에 예외를 던지는 것이 표준 관행입니다. @nestjs/config 패키지를 사용하면 두 가지 방법으로 이를 수행할 수 있습니다:
- Joi 내장 유효성 검사기. Joi를 사용하면 객체 스키마를 정의하고 이에 대해 JavaScript 객체의 유효성을 검사할 수 있습니다.
- 환경 변수를 입력으로 받는 custom validate() 함수.
Joi를 사용하려면 Joi 패키지를 설치해야 합니다:
$ npm install --save joi
이제 아래와 같이 Joi 유효성 검사 스키마를 정의하고 forRoot() 메서드의 옵션 객체의 validationSchema 속성을 통해 이를 전달할 수 있습니다:
import * as Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test', 'provision')
.default('development'),
PORT: Joi.number().default(3000),
}),
}),
],
})
export class AppModule {}
기본적으로 모든 shcema key는 선택 사항으로 간주됩니다. 여기서는 환경(.env 파일 또는 프로세스 환경)에서 이러한 변수를 제공하지 않을 경우 사용되는 NODE_ENV 및 PORT에 대한 기본값을 설정합니다. 또는 required() 유효성 검사 메서드를 사용하여 환경(.env 파일 또는 프로세스 환경)에 값이 정의되어 있어야 함을 요구할 수 있습니다. 이 경우 유효성 검사 단계는 환경에 변수를 제공하지 않는 경우 예외를 발생시킵니다.
기본적으로 unknown 환경 변수(스키마에 키가 없는 환경 변수)가 허용되며 유효성 검사 예외를 트리거하지 않습니다. 또한 기본적으로 모든 유효성 검사 오류가 보고됩니다. forRoot() 옵션 객체의 validationOptions 키를 통해 옵션 객체를 전달하여 이러한 동작을 변경할 수 있습니다. 이 옵션 객체에는 Joi 유효성 검사 옵션에서 제공하는 모든 표준 유효성 검사 옵션 속성이 포함될 수 있습니다. 예를 들어 위의 두 설정을 기본값과 반대로 설정하려면 다음과 같이 옵션을 전달합니다:
import * as Joi from 'joi';
@Module({
imports: [
ConfigModule.forRoot({
validationSchema: Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test', 'provision')
.default('development'),
PORT: Joi.number().default(3000),
}),
validationOptions: {
allowUnknown: false,
abortEarly: true,
},
}),
],
})
export class AppModule {}
nestjs/config 패키지는 기본 설정을 사용합니다:
- allowUnknown: 환경 변수에 알 수 없는 키를 허용할지 여부를 제어합니다. 기본값은 true입니다.
- abortEarly: true면 첫 번째 오류에 대한 유효성 검사를 중지하고, false이면 모든 오류를 반환합니다. 기본값은 false입니다.
유효성 검사 옵션 객체를 전달하기로 결정한 후에는 명시적으로 전달하지 않은 모든 설정의 기본값은 @nestjs/config 기본값이 아닌 Joi 표준 기본값으로 설정된다는 점에 유의해야 합니다. 예를 들어, custom ValidationOptions 객체에서 allowUnknowns를 지정하지 않은 채로 두면 Joi 기본값인 false가 됩니다. 따라서 custom 객체에서 이 두 가지 설정을 모두 지정하는 것이 일반적으론 가장 안전하다고 할 수 있습니다.
1-12. Custom validate function
또는 환경 변수가 포함된 객체(환경 파일 및 프로세스에서)를 가져와서 필요한 경우에 변환/변형할 수 있도록 validated 환경 변수가 포함된 객체를 반환하는 동기 validate 함수를 지정할 수도 있습니다. 이 함수가 오류를 발생시키면 애플리케이션은 부트스트랩되지 않습니다.
이 예제에서는 class-transformer와 class-validator 패키지를 사용하여 진행하겠습니다.
- 유효성 검사 제약 조건이 있는 클래스,
- 유효성 검사 함수를 정의하고, plainToInstance 및 validateSync 함수를 사용하는 유효성 검사 함수를 정의
// env.validation.ts
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, validateSync } from 'class-validator';
enum Environment {
Development = "development",
Production = "production",
Test = "test",
Provision = "provision",
}
class EnvironmentVariables {
@IsEnum(Environment)
NODE_ENV: Environment;
@IsNumber()
PORT: number;
}
export function validate(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(
EnvironmentVariables,
config,
{ enableImplicitConversion: true },
);
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(errors.toString());
}
return validatedConfig;
}
이 설정이 완료되면 다음과 같이 유효성 검사 함수를 ConfigModule의 구성 옵션으로 사용합니다:
import { validate } from './env.validation';
@Module({
imports: [
ConfigModule.forRoot({
validate,
}),
],
})
export class AppModule {}
1-13. Custom getter function
ConfigService는 키별로 구성 값을 검색하는 일반 get() 메서드를 정의합니다. 좀 더 자연스러운 코딩 스타일을 구현하기 위해 getter 함수를 추가할 수도 있습니다:
@Injectable()
export class ApiConfigService {
constructor(private configService: ConfigService) {}
get isAuthEnabled(): boolean {
return this.configService.get('AUTH_ENABLED') === 'true';
}
}
다음과 같이 이 getter 함수를 사용할 수 있습니다:
@Injectable()
export class AppService {
constructor(apiConfigService: ApiConfigService) {
if (apiConfigService.isAuthEnabled) {
// Authentication is enabled
}
}
}
1-14. Environment variables loaded hook
모듈 구성이 환경 변수에 의존하고 이러한 변수가 .env 파일에서 로드되는 경우, 다음 예제를 참조하여 ConfigModule.envVariablesLoaded 훅을 사용하여 process.env 객체와 상호 작용하기 전에 파일이 로드되었는지 확인할 수 있습니다:
export async function getStorageModule() {
await ConfigModule.envVariablesLoaded;
return process.env.STORAGE === 'S3' ? S3StorageModule : DefaultStorageModule;
}
이는 ConfigModule.envVariablesLoaded 프로미스가 해결된 후 모든 구성 변수가 로드되는 것을 보장합니다.
1-15. Conditional module configuration
모듈에서 조건부로 로드하고 환경 변수에 조건을 지정하고 싶은 경우가 있을 수 있습니다. 다행히도 @nestjs/config는 이를 수행할 수 있는 ConditionalModule을 제공합니다.
@Module({
imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(FooModule, 'USE_FOO')],
})
export class AppModule {}
위의 모듈은 .env 파일에 환경 변수 USE_FOO에 대해 false 값이 없는 경우에만 FooModule에 로드됩니다. 또한 custom 조건식을 직접 전달할 수도 있는데, 이 함수는 ConditionalModule이 처리할 bool을 반환해야 하는 process.env 참조를 받습니다:
@Module({
imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(FooBarModule, (env: NodeJS.ProcessEnv) => !!env['foo'] && !!env['bar'])],
})
export class AppModule {}
ConditionalModule을 사용할 때는 ConfigModule이 애플리케이션에 로드되어 있는지 확인하여 ConfigModule.envVariablesLoaded 훅이 제대로 참조되고 활용될 수 있도록 하는 것이 중요합니다. 5초 이내에 hook이 true로 전환되지 않거나 사용자가 registerWhen 메서드의 세 번째 옵션 매개변수에서 설정한 시간 초과(밀리초)값을 넘어서는 순간 ConditionalModule은 오류를 발생시키고 Nest가 애플리케이션 시작을 중단합니다.
1-16. Expandable variables
@nestjs/config 패키지는 환경 변수 확장을 지원합니다. 이 기술을 사용하면 한 변수가 다른 변수의 정의 내에서 참조되는 중첩된 환경 변수를 만들 수 있습니다.
APP_URL=mywebsite.com
SUPPORT_EMAIL=support@${APP_URL}
이 구조에서는 변수 SUPPORT_EMAIL이 'support@mywebsite.com'로 값이 결정됩니다. SUPPORT_EMAIL 정의 내에서 변수 APP_URL의 값 확인을 트리거하기 위해 ${...} 구문을 사용한다는 점에 유의합니다.
이를 사용하기 위해서 아래와 같이 ConfigModule의 forRoot() 메서드에 전달된 옵션 객체에서 expandVariables 속성을 사용하여 환경 변수 확장을 활성화합니다:
@Module({
imports: [
ConfigModule.forRoot({
// ...
expandVariables: true,
}),
],
})
export class AppModule {}
1-17. Using in the main.ts
구성이 서비스에 저장되어 있더라도 main.ts 파일에서 여전히 사용할 수 있습니다. 이렇게 하면 애플리케이션 port나 CORS 호스트와 같은 변수를 저장하는 데 유용하게 사용할 수 있습니다.
이 파일에 액세스하려면 app.get() 메서드에 서비스 참조를 사용하면 됩니다:
const configService = app.get(ConfigService);
그런 다음 평소처럼 구성 키로 get 메서드를 호출하여 사용하면 되겠습니다:
const port = configService.get('PORT');
'Back-end > NestJS' 카테고리의 다른 글
[NestJS | Docs] Middleware 알아보기 (0) | 2024.02.28 |
---|---|
[NestJS | Docs] Execution context 알아보기 (feat. ArgumentHost) (0) | 2024.02.27 |
[NestJS | Docs] Custom Decorators 알아보기 (커스텀 데코레이터) (0) | 2024.02.21 |
[NestJS | Docs] Passport 알아보기 (feat. authentication) (0) | 2024.02.20 |
[NestJS] NestJS 트랜잭션(Transaction) 관리하기(With TypeORM) (0) | 2024.02.17 |
소중한 공감 감사합니다