CoreConfigModule 구현 가이드라인
1. 개요
CoreConfigModule은 DTA-WIDE 프로젝트의 환경 변수와 구성 관리를 담당하는 핵심 라이브러리입니다. 이 모듈은 모든 마이크로서비스와 도메인 모듈에서 필요한 구성 정보를 일관된 방식으로 제공합니다.
1.1 프로젝트 구조
CoreConfigModule은 모든 서비스와 모듈에서 공통으로 사용되는 핵심 기능이므로 libs/ 디렉토리 아래에 위치합니다.
libs/
core/
config/
src/
lib/
initializers/
environment-initializer.ts
loaders/
cloud-secret-loader.ts
file-loader.ts
namespaces/
app.config.ts
cloud.config.ts
database.config.ts
logging.config.ts
external-api.config.ts
validators/
env-validators.ts
core-config.module.ts
index.ts
README.md
tsconfig.json
모든 구성 관련 코드는 libs/core/config 디렉토리에 구현하고, 필요한 서비스에서 import { CoreConfigModule } from '@libs/core/config';와 같이 임포트하여 사용합니다. 이를 통해 구성 관리의 일관성을 보장하고 중앙 집중식 환경 변수 관리가 가능해집니다.
2. 설계 원칙
- 단일 책임 원칙: 구성 관리만 담당하며 다른 인프라 관심사는 다루지 않습니다.
- 환경 독립성: 개발, 테스트, 스테이징, 프로덕션 등 모든 환경에서 동일한 방식으로 동작해야 합니다.
- 보안 우선: 민감한 정보는 적절히 보호되어야 합니다.
- 유효성 검증: 모든 구성 값은 유효성이 검증되어야 합니다.
- 확장성: 새로운 구성 소스를 쉽게 추가할 수 있어야 합니다.
- 순환 참조 방지: 모듈 간 순환 참조가 발생하지 않도록 설계합니다.
3. 주요 컴포넌트
3.1 EnvironmentInitializer
애플리케이션 시작 시 다양한 소스에서 환경 변수를 로드하여 process.env에 설정하는 컴포넌트입니다.
// libs/core/config/src/lib/initializers/environment-initializer.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
/**
* 애플리케이션 시작 시 환경 변수를 초기화하는 컴포넌트입니다.
* 이 컴포넌트는 NestJS 라이프사이클 초기에 실행됩니다.
*/
@Injectable()
export class EnvironmentInitializer implements OnModuleInit {
/**
* 모듈 초기화 시 환경 변수를 로드합니다.
*/
async onModuleInit(): Promise<void> {
await this.initializeEnvironmentVariables();
}
/**
* 다양한 소스에서 환경 변수를 로드하여 process.env에 설정합니다.
*/
private async initializeEnvironmentVariables(): Promise<void> {
// CONFIGURATION 환경 변수에서 JSON 구성 로드
if (process.env.CONFIGURATION) {
this.loadFromJsonConfiguration();
} else {
// 로컬 개발 환경인 경우 로컬 구성 파일 사용
const runtimeEnv = process.env.RUNTIME_ENV || 'local';
const deployEnv = process.env.DEPLOY_ENV || 'dev';
if (runtimeEnv === 'local') {
const serviceName = process.env.SERVICE_NAME || 'dta-wide-api';
try {
await this.loadLocalConfiguration(runtimeEnv, deployEnv, serviceName);
} catch (error) {
console.error(`Failed to load local configuration: ${getErrorMessage(error)}`);
}
} else if (runtimeEnv === 'cloudrun') {
// Cloud Run 환경에서는 환경 변수가 이미 설정되어 있음
console.info('Running in Cloud Run environment, using predefined environment variables');
}
}
}
/**
* JSON 형식의 CONFIGURATION 환경 변수에서 구성을 로드합니다.
*/
private loadFromJsonConfiguration(): void {
try {
const jsonConfig = JSON.parse(process.env.CONFIGURATION!);
// DEPLOY_ENV가 설정되어 있는지 확인 (필수 값)
if (!jsonConfig.DEPLOY_ENV) {
console.error('DEPLOY_ENV is not set in CONFIGURATION');
}
// 환경 변수에 JSON 구성 설정
Object.entries(jsonConfig).forEach(([key, value]) => {
// 기존 process.env에 값이 없을 때만 설정 (덮어쓰기 방지)
if (process.env[key] === undefined) {
process.env[key] = String(value);
}
});
} catch (error) {
console.error('Failed to parse CONFIGURATION environment variable:', error);
}
}
/**
* 로컬 개발 환경에서 구성 파일을 로드합니다.
* 서비스별 JSON 파일(예: dta-wide-api.json)에서 설정을 로드합니다.
*/
private async loadLocalConfiguration(runtimeEnv: string, deployEnv: string, serviceName: string): Promise<void> {
try {
const workspaceRoot = process.env.NX_WORKSPACE_ROOT || process.cwd();
// 서비스별 JSON 파일 경로
const filePath = path.join(
workspaceRoot,
'cloudrun-deploy',
'secret',
runtimeEnv,
deployEnv,
`${serviceName}.json`
);
console.info(`Loading configuration from: ${filePath}`);
// 파일이 존재하는지 확인
if (!fs.existsSync(filePath)) {
console.warn(`Configuration file not found: ${filePath}`);
console.warn('Using default environment variables instead');
return;
}
// JSON 파일 로드
const fileContent = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(fileContent);
// 환경 변수로 설정
Object.entries(config).forEach(([key, value]) => {
// 기존 process.env에 값이 없을 때만 설정 (덮어쓰기 방지)
if (process.env[key] === undefined) {
process.env[key] = String(value);
}
});
console.info(`Successfully loaded configuration for ${serviceName} in ${runtimeEnv}/${deployEnv} environment`);
} catch (error) {
throw new Error(`Failed to load local configuration: ${getErrorMessage(error)}`);
}
}
}
3.2 설정 네임스페이스
NestJS ConfigModule의 registerAs 기능을 활용하여 각 도메인별 설정을 네임스페이스로 분리합니다.
// libs/core/config/src/lib/namespaces/app.config.ts
import { registerAs } from '@nestjs/config';
/**
* 앱 기본 설정
*/
export const appConfig = registerAs('app', () => ({
name: process.env.APP_NAME || 'dta-wide-api',
port: parseInt(process.env.PORT || '3000', 10),
host: process.env.HOST || 'localhost',
runtimeEnv: process.env.RUNTIME_ENV || 'local',
deployEnv: process.env.DEPLOY_ENV || 'dev',
serviceName: process.env.SERVICE_NAME || 'dta-wide-api',
apiHost: process.env.API_HOST || 'http://localhost:3000',
}));
// libs/core/config/src/lib/namespaces/cloud.config.ts
import { registerAs } from '@nestjs/config';
/**
* 클라우드 설정
*/
export const cloudConfig = registerAs('cloud', () => ({
provider: process.env.CLOUD_PROVIDER || 'GCP',
projectId: process.env.CLOUD_PROJECT_ID,
region: process.env.CLOUD_REGION || 'asia-northeast3',
bucketName: process.env.GCS_BUCKET_NAME,
}));
// libs/core/config/src/lib/namespaces/database.config.ts
import { registerAs } from '@nestjs/config';
/**
* 데이터베이스 설정
*/
export const databaseConfig = registerAs('database', () => ({
postgres: {
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
schema: process.env.DATABASE_SCHEMA || 'public',
ssl: process.env.DATABASE_SSL_ENABLED === 'true',
},
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
db: parseInt(process.env.REDIS_DB || '0', 10),
},
}));
// libs/core/config/src/lib/namespaces/logging.config.ts
import { registerAs } from '@nestjs/config';
/**
* 로깅 설정
*/
export const loggingConfig = registerAs('logging', () => ({
level: process.env.LOG_LEVEL || 'info',
json: process.env.LOG_JSON === 'true',
console: process.env.LOG_CONSOLE_ENABLED !== 'false',
bigquery: {
enabled: process.env.LOG_BIGQUERY_ENABLED === 'true',
projectId: process.env.LOG_BIGQUERY_PROJECT_ID,
datasetId: process.env.LOG_BIGQUERY_DATASET_ID,
tableId: process.env.LOG_BIGQUERY_TABLE_ID,
retentionDays: parseInt(process.env.LOG_BIGQUERY_RETENTION_DAYS || '365', 10),
},
alerts: {
enabled: process.env.LOG_ALERTS_ENABLED === 'true',
email: process.env.LOG_ALERTS_EMAIL,
slack: process.env.LOG_ALERTS_SLACK_WEBHOOK,
},
}));
// libs/core/config/src/lib/namespaces/external-api.config.ts
import { registerAs } from '@nestjs/config';
/**
* 외부 API 설정
*/
export const externalApiConfig = registerAs('externalApi', () => ({
openai: {
apiKey: process.env.OPENAI_API_KEY,
model: process.env.OPENAI_MODEL || 'gpt-4',
timeout: parseInt(process.env.OPENAI_TIMEOUT || '30000', 10),
},
google: {
apiKey: process.env.GOOGLE_API_KEY,
searchEngineId: process.env.GOOGLE_SEARCH_ENGINE_ID,
},
sendgrid: {
apiKey: process.env.SENDGRID_API_KEY,
fromEmail: process.env.SENDGRID_FROM_EMAIL,
},
connectDtx: {
baseUrl: process.env.CONNECT_DTX_BASE_URL,
apiKey: process.env.CONNECT_DTX_API_KEY,
timeout: parseInt(process.env.CONNECT_DTX_TIMEOUT || '30000', 10),
},
}));
3.3 ConfigService
환경 변수와 구성 값에 접근하는 일관된 인터페이스를 제공하는 서비스입니다. 직접 process.env에 접근하거나 NestJS ConfigService를 통해 네임스페이스 구성에 접근하는 기능을 제공합니다.
// libs/core/config/src/lib/config.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService as NestConfigService } from '@nestjs/config';
@Injectable()
export class ConfigService {
constructor(private readonly nestConfigService: NestConfigService) {}
/**
* 환경 변수 값을 가져옵니다.
* @param key 환경 변수 키
* @param defaultValue 기본값 (키가 없을 경우)
* @returns 환경 변수 값 (기본값이 제공된 경우 기본값)
*/
get<T>(key: string, defaultValue?: T): T {
const value = process.env[key];
if (value === undefined) {
return defaultValue as T;
}
return this.convertToType<T>(value);
}
/**
* 필수 환경 변수 값을 가져옵니다. 값이 없으면 오류를 발생시킵니다.
* @param key 환경 변수 키
* @returns 환경 변수 값
* @throws 키가 없는 경우 오류
*/
getRequired<T>(key: string): T {
const value = this.get<T>(key);
if (value === undefined) {
throw new Error(`Required configuration key "${key}" is missing`);
}
return value;
}
/**
* 네임스페이스 내의 구성 값을 가져옵니다.
* @param namespace 네임스페이스
* @param key 구성 키
* @param defaultValue 기본값 (키가 없을 경우)
* @returns 구성 값
*/
getConfig<T>(namespace: string, key: string, defaultValue?: T): T {
return this.nestConfigService.get<T>(`${namespace}.${key}`, defaultValue);
}
/**
* 필수 네임스페이스 구성 값을 가져옵니다. 값이 없으면 오류를 발생시킵니다.
* @param namespace 네임스페이스
* @param key 구성 키
* @returns 구성 값
* @throws 키가 없는 경우 오류
*/
getRequiredConfig<T>(namespace: string, key: string): T {
const value = this.getConfig<T>(namespace, key);
if (value === undefined) {
throw new Error(`Required configuration key "${namespace}.${key}" is missing`);
}
return value;
}
/**
* 네임스페이스 전체 구성을 가져옵니다.
* @param namespace 네임스페이스
* @returns 네임스페이스 구성 객체
*/
getNamespace<T extends Record<string, any>>(namespace: string): T {
const config = this.nestConfigService.get<T>(namespace);
if (!config) {
return {} as T;
}
return config;
}
/**
* 현재 환경(development, test, production)을 가져옵니다.
* @returns 현재 환경
*/
getEnvironment(): string {
return this.getRequired<string>('NODE_ENV');
}
/**
* 현재 환경이 개발 환경인지 확인합니다.
* @returns 개발 환경 여부
*/
isDevelopment(): boolean {
return this.getEnvironment() === 'development';
}
/**
* 현재 환경이 테스트 환경인지 확인합니다.
* @returns 테스트 환경 여부
*/
isTest(): boolean {
return this.getEnvironment() === 'test';
}
/**
* 현재 환경이 프로덕션 환경인지 확인합니다.
* @returns 프로덕션 환경 여부
*/
isProduction(): boolean {
return this.getEnvironment() === 'production';
}
/**
* 문자열 값을 지정된 타입으로 변환합니다.
* @param value 변환할 문자열 값
* @returns 변환된 값
*/
private convertToType<T>(value: string): T {
// 타입에 따른 변환 로직
const typeofPlaceholder = {} as T;
if (typeof typeofPlaceholder === 'number') {
return Number(value) as unknown as T;
} else if (typeof typeofPlaceholder === 'boolean') {
return (value.toLowerCase() === 'true') as unknown as T;
}
return value as unknown as T;
}
}
3.4 ConfigValidationSchema
환경 변수와 구성 값의 유효성을 검증하기 위한 스키마 정의입니다.
// libs/core/config/src/lib/schemas/app.schema.ts
import * as Joi from 'joi';
export const appConfigSchema = {
APP_NAME: Joi.string().default('dta-wide-api'),
PORT: Joi.number().default(3000),
HOST: Joi.string().default('localhost'),
NODE_ENV: Joi.string()
.valid('development', 'test', 'production')
.default('development'),
RUNTIME_ENV: Joi.string()
.valid('local', 'cloudrun')
.default('local'),
DEPLOY_ENV: Joi.string()
.valid('dev', 'staging', 'prod')
.default('dev'),
SERVICE_NAME: Joi.string().required(),
API_HOST: Joi.string().when('RUNTIME_ENV', {
is: 'local',
then: Joi.string().default('http://localhost:3000'),
otherwise: Joi.string().required(),
}),
};
// libs/core/config/src/lib/schemas/cloud.schema.ts
import * as Joi from 'joi';
export const cloudConfigSchema = {
CLOUD_PROVIDER: Joi.string().default('GCP'),
CLOUD_PROJECT_ID: Joi.string().required(),
CLOUD_REGION: Joi.string().default('asia-northeast3'),
GCS_BUCKET_NAME: Joi.string().when('RUNTIME_ENV', {
is: 'cloudrun',
then: Joi.string().required(),
otherwise: Joi.string().optional(),
}),
};
// libs/core/config/src/lib/schemas/database.schema.ts
import * as Joi from 'joi';
export const databaseConfigSchema = {
DATABASE_HOST: Joi.string().required(),
DATABASE_PORT: Joi.number().default(5432),
DATABASE_USERNAME: Joi.string().required(),
DATABASE_PASSWORD: Joi.string().required(),
DATABASE_NAME: Joi.string().required(),
DATABASE_SCHEMA: Joi.string().default('public'),
DATABASE_SSL_ENABLED: Joi.boolean().default(false),
REDIS_HOST: Joi.string().when('RUNTIME_ENV', {
is: 'cloudrun',
then: Joi.string().required(),
otherwise: Joi.string().optional(),
}),
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().optional(),
REDIS_DB: Joi.number().default(0),
};
3.5 EncryptionUtil
암호화 및 복호화 기능을 제공하는 유틸리티입니다. 순환 참조를 방지하기 위해 직접 환경 변수에 접근합니다.
// libs/core/config/src/lib/utils/encryption.util.ts
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
@Injectable()
export class EncryptionUtil {
private readonly algorithm = 'aes-256-cbc';
private readonly key: Buffer;
constructor() {
try {
// 환경 변수에서 직접 암호화 키 로드
const encryptionKey = process.env.ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error('ENCRYPTION_KEY is not defined');
}
// 32바이트 키 생성 (aes-256-cbc 알고리즘용)
this.key = crypto.createHash('sha256').update(encryptionKey).digest();
} catch (error) {
throw new Error(`Failed to initialize encryption: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 문자열을 암호화합니다.
* @param text 암호화할 문자열
* @returns 암호화된 문자열 (hex 인코딩)
*/
encrypt(text: string): string {
const iv = crypto.randomBytes(16); // 초기화 벡터
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
// IV와 암호화된 텍스트를 함께 저장 (IV는 복호화에 필요)
return `${iv.toString('hex')}:${encrypted}`;
}
/**
* 암호화된 문자열을 복호화합니다.
* @param encryptedText 암호화된 문자열 (hex 인코딩)
* @returns 복호화된 문자열
*/
decrypt(encryptedText: string): string {
const [ivHex, encrypted] = encryptedText.split(':');
if (!ivHex || !encrypted) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
4. 구현 가이드
4.1 모듈 구조
libs/core/config/
├── src/
│ ├── lib/
│ │ ├── config.module.ts # 모듈 정의
│ │ ├── config.service.ts # 구성 서비스
│ │ ├── initializers/ # 초기화 컴포넌트
│ │ │ ├── environment-initializer.ts # 환경 변수 초기화
│ │ │ └── index.ts # 초기화 컴포넌트 내보내기
│ │ ├── namespaces/ # 도메인별 설정 네임스페이스
│ │ │ ├── app.config.ts # 앱 설정
│ │ │ ├── cloud.config.ts # 클라우드 설정
│ │ │ ├── database.config.ts # 데이터베이스 설정
│ │ │ ├── logging.config.ts # 로깅 설정
│ │ │ ├── external-api.config.ts # 외부 API 설정
│ │ │ └── index.ts # 네임스페이스 내보내기
│ │ ├── schemas/ # 검증 스키마
│ │ │ ├── app.schema.ts # 애플리케이션 구성 스키마
│ │ │ ├── cloud.schema.ts # 클라우드 구성 스키마
│ │ │ ├── database.schema.ts # 데이터베이스 구성 스키마
│ │ │ └── index.ts # 스키마 내보내기
│ │ ├── interfaces/ # 인터페이스 정의
│ │ │ ├── config-options.interface.ts # 옵션 인터페이스
│ │ │ └── index.ts # 인터페이스 내보내기
│ │ ├── utils/ # 유틸리티 함수
│ │ │ ├── encryption.util.ts # 암호화 유틸리티
│ │ │ └── index.ts # 유틸리티 내보내기
│ │ └── index.ts # 공개 API
│ └── index.ts # 라이브러리 진입점
└── test/ # 테스트 코드
4.2 ConfigModule 구현
// libs/core/config/src/lib/config.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigModule as NestConfigModule } from '@nestjs/config';
import { ConfigService } from './config.service';
import { ConfigOptions } from './interfaces';
import {
appConfigSchema,
cloudConfigSchema,
databaseConfigSchema
} from './schemas';
import {
appConfig,
cloudConfig,
databaseConfig,
loggingConfig,
externalApiConfig
} from './namespaces';
import { EnvironmentInitializer } from './initializers/environment-initializer';
import { EncryptionUtil } from './utils';
import * as Joi from 'joi';
@Module({})
export class CoreConfigModule {
static forRoot(options: ConfigOptions = {}): DynamicModule {
const {
isGlobal = false,
validationSchema = null,
validationOptions = {},
cache = true,
} = options;
// 기본 검증 스키마와 사용자 정의 스키마 병합
const defaultSchema = Joi.object({
...appConfigSchema,
...cloudConfigSchema,
...databaseConfigSchema,
});
const schema = validationSchema
? defaultSchema.concat(validationSchema)
: defaultSchema;
return {
module: CoreConfigModule,
imports: [
NestConfigModule.forRoot({
isGlobal,
// .env 파일 무시 - EnvironmentInitializer에서 환경 변수 로드
ignoreEnvFile: true,
load: [
appConfig,
cloudConfig,
databaseConfig,
loggingConfig,
externalApiConfig,
],
validationSchema: schema,
validationOptions: {
allowUnknown: true,
abortEarly: false,
...validationOptions,
},
cache,
}),
],
providers: [
// 최우선 순위로 환경 초기화 컴포넌트 등록
EnvironmentInitializer,
ConfigService,
EncryptionUtil,
],
exports: [ConfigService, EncryptionUtil],
};
}
}
5. 사용 예시
5.1 App Module에서 사용
// apps/dta-wide-api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { CoreConfigModule } from '@core/config';
@Module({
imports: [
CoreConfigModule.forRoot({
isGlobal: true,
// 추가 검증 스키마 지정 가능
// validationSchema: Joi.object({ ... }),
}),
// 다른 모듈 임포트...
],
})
export class AppModule {}
5.2 서비스에서 사용
// apps/dta-wide-api/src/app/services/example.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@core/config';
@Injectable()
export class ExampleService {
constructor(private readonly configService: ConfigService) {}
getServerInfo() {
// 단일 환경 변수에 접근
return {
port: this.configService.get<number>('PORT', 3000),
host: this.configService.get<string>('HOST', 'localhost'),
environment: this.configService.getEnvironment(),
};
}
getAppInfo() {
// 네임스페이스를 통한 접근
const appConfig = this.configService.getNamespace<{
name: string;
runtimeEnv: string;
deployEnv: string;
serviceName: string;
apiHost: string;
}>('app');
return {
name: appConfig.name,
runtimeEnv: appConfig.runtimeEnv,
deployEnv: appConfig.deployEnv,
serviceName: appConfig.serviceName,
apiHost: appConfig.apiHost,
};
}
getDatabaseInfo() {
// 네임스페이스 내의 특정 값에 접근
return {
host: this.configService.getRequiredConfig<string>('database', 'postgres.host'),
port: this.configService.getConfig<number>('database', 'postgres.port', 5432),
database: this.configService.getRequiredConfig<string>('database', 'postgres.database'),
// 비밀번호 등 민감한 정보는 노출하지 않음
};
}
getOpenAISettings() {
// 네임스페이스 내의 객체에 접근
const openaiConfig = this.configService.getConfig<{
apiKey: string;
model: string;
timeout: number;
}>('externalApi', 'openai');
return {
model: openaiConfig.model,
timeout: openaiConfig.timeout,
// apiKey는 민감 정보이므로 노출하지 않음
};
}
}
6. 테스트 전략
6.1 단위 테스트
// libs/core/config/test/config.service.spec.ts
import { Test } from '@nestjs/testing';
import { ConfigService } from '../src/lib/config.service';
describe('ConfigService', () => {
let service: ConfigService;
beforeEach(async () => {
// 테스트 환경 변수 설정
process.env.TEST_KEY = 'test-value';
process.env.TEST_NUMBER = '42';
process.env.TEST_BOOLEAN = 'true';
const moduleRef = await Test.createTestingModule({
providers: [ConfigService],
}).compile();
service = moduleRef.get<ConfigService>(ConfigService);
});
afterEach(() => {
// 테스트 후 환경 변수 정리
delete process.env.TEST_KEY;
delete process.env.TEST_NUMBER;
delete process.env.TEST_BOOLEAN;
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should get string configuration value', () => {
expect(service.get<string>('TEST_KEY')).toEqual('test-value');
});
it('should get number configuration value', () => {
expect(service.get<number>('TEST_NUMBER')).toEqual(42);
});
it('should get boolean configuration value', () => {
expect(service.get<boolean>('TEST_BOOLEAN')).toEqual(true);
});
it('should return default value when key not exists', () => {
expect(service.get<string>('NON_EXISTING_KEY', 'default')).toEqual('default');
});
it('should throw error when required key not exists', () => {
expect(() => service.getRequired<string>('NON_EXISTING_KEY')).toThrow();
});
});
6.2 암호화 유틸리티 테스트
// libs/core/config/test/encryption.util.spec.ts
import { Test } from '@nestjs/testing';
import { EncryptionUtil } from '../src/lib/utils';
describe('EncryptionUtil', () => {
let encryptionUtil: EncryptionUtil;
beforeEach(async () => {
// 테스트용 환경 변수 설정
process.env.ENCRYPTION_KEY = 'test-encryption-key';
const moduleRef = await Test.createTestingModule({
providers: [EncryptionUtil],
}).compile();
encryptionUtil = moduleRef.get<EncryptionUtil>(EncryptionUtil);
});
afterEach(() => {
// 테스트 후 환경 변수 정리
delete process.env.ENCRYPTION_KEY;
});
it('should encrypt and decrypt text correctly', () => {
const originalText = 'sensitive-data-123';
const encrypted = encryptionUtil.encrypt(originalText);
expect(encrypted).not.toEqual(originalText);
const decrypted = encryptionUtil.decrypt(encrypted);
expect(decrypted).toEqual(originalText);
});
it('should throw error for invalid encrypted format', () => {
expect(() => encryptionUtil.decrypt('invalid-format')).toThrow();
});
});
6.3 통합 테스트
// libs/core/config/test/config.module.spec.ts
import { Test } from '@nestjs/testing';
import { CoreConfigModule } from '../src/lib/config.module';
import { ConfigService } from '../src/lib/config.service';
import { EncryptionUtil } from '../src/lib/utils';
import * as Joi from 'joi';
describe('CoreConfigModule', () => {
describe('forRoot', () => {
it('should provide ConfigService and EncryptionUtil', async () => {
// 테스트용 환경 변수 설정
process.env.DATABASE_HOST = 'localhost';
process.env.ENCRYPTION_KEY = 'test-key';
const moduleRef = await Test.createTestingModule({
imports: [
CoreConfigModule.forRoot({
isGlobal: true,
}),
],
}).compile();
const configService = moduleRef.get<ConfigService>(ConfigService);
const encryptionUtil = moduleRef.get<EncryptionUtil>(EncryptionUtil);
expect(configService).toBeDefined();
expect(encryptionUtil).toBeDefined();
});
it('should validate configuration using provided schema', async () => {
// 필수 환경 변수 없이 테스트
delete process.env.DATABASE_HOST;
// 검증 스키마에 DATABASE_HOST가 필수로 설정되어 있으므로 오류 발생 예상
const testFn = async () => {
await Test.createTestingModule({
imports: [
CoreConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
DATABASE_HOST: Joi.string().required(),
}),
}),
],
}).compile();
};
await expect(testFn()).rejects.toThrow();
});
it('should allow custom validation schema', async () => {
// 커스텀 스키마 설정
process.env.CUSTOM_REQUIRED = 'test-value';
const moduleRef = await Test.createTestingModule({
imports: [
CoreConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
CUSTOM_REQUIRED: Joi.string().required(),
}),
}),
],
}).compile();
const configService = moduleRef.get<ConfigService>(ConfigService);
expect(configService).toBeDefined();
expect(configService.get<string>('CUSTOM_REQUIRED')).toEqual('test-value');
});
});
});
7. 확장 방법
7.1 환경 변수 초기화 확장
새로운 환경 변수 소스(예: Consul, Vault, AWS Parameter Store 등)를 추가하려면 EnvironmentInitializer를 확장하세요:
// libs/core/config/src/lib/initializers/environment-initializer.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import * as vault from 'node-vault'; // 예시: Vault 통합
@Injectable()
export class EnvironmentInitializer implements OnModuleInit {
async onModuleInit(): Promise<void> {
await this.initializeEnvironmentVariables();
}
private async initializeEnvironmentVariables(): Promise<void> {
// 기존 로직: CONFIGURATION 환경 변수 및 로컬 파일에서 로드
// ... 기존 코드 ...
// 추가: Vault에서 환경 변수 로드
if (process.env.USE_VAULT === 'true') {
await this.loadFromVault();
}
}
// 추가: Vault에서 환경 변수 로드
private async loadFromVault(): Promise<void> {
try {
const vaultClient = vault({
endpoint: process.env.VAULT_ENDPOINT,
token: process.env.VAULT_TOKEN,
});
const result = await vaultClient.read('secret/data/dta-wide');
if (result && result.data && result.data.data) {
const secrets = result.data.data;
// 환경 변수로 설정
Object.entries(secrets).forEach(([key, value]) => {
if (process.env[key] === undefined) {
process.env[key] = String(value);
}
});
}
} catch (error) {
console.error('Failed to load secrets from Vault:', error);
}
}
}
7.2 ConfigService 확장
필요에 따라 ConfigService에 새로운 메서드를 추가할 수 있습니다:
// libs/core/config/src/lib/config.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class ConfigService {
// 기존 메서드...
/**
* 보안 관련 환경 변수가 올바르게 구성되어 있는지 확인합니다.
* @returns 보안 구성 유효성 여부
*/
isSecurityConfigValid(): boolean {
const requiredSecurityKeys = [
'JWT_SECRET',
'ENCRYPTION_KEY',
'API_KEY',
];
return requiredSecurityKeys.every(key =>
this.get<string>(key) !== undefined && this.get<string>(key) !== ''
);
}
/**
* URL 환경 변수를 조합하여 완전한 URL을 생성합니다.
* @param serviceName 서비스 이름
* @returns 완전한 서비스 URL
*/
getServiceUrl(serviceName: string): string {
const protocol = this.get<string>('API_PROTOCOL', 'https');
const domain = this.getRequired<string>('API_DOMAIN');
const prefix = this.get<string>('API_PREFIX', 'api');
return `${protocol}://${domain}/${prefix}/${serviceName}`;
}
}
7.3 Cloud Run과 Secret Manager 통합
Cloud Run 서비스 배포 시 Secret Manager에서 구성 값을 가져와 환경 변수로 주입하는 방법:
-
Terraform을 통한 Secret Manager 리소스 생성:
# Secret Manager 리소스 정의 예시
terraform {
source = "git::git@github.com:weltcorp/gops-terraform-module.git//modules//secret"
}
remote_state {
backend = "gcs"
config = {
project = "dta-cloud-de-dev"
location = "europe-west3"
bucket = "dta-cloud-de-dev-tf-state"
prefix = "secret/dta-wide-api"
impersonate_service_account = "terraform@dta-cloud-de-dev.iam.gserviceaccount.com"
}
}
inputs = {
project = "dta-cloud-de-dev"
terraform_service_account = "terraform@dta-cloud-de-dev.iam.gserviceaccount.com"
secret = [
{
name = "dta-wide-api"
}
]
secret_version = [
{
secret_name = "dta-wide-api"
secret_data_file = "dta-wide-api.json"
}
]
} -
JSON 구성 파일 예시 (dta-wide-api.json):
{
"RUNTIME_ENV": "cloudrun",
"DEPLOY_ENV": "dev",
"CLOUD_PROVIDER": "GCP",
"SERVICE_NAME": "dta-wide-api",
"API_HOST": "https://dta-wide-api-dev.weltcorp.com",
"ENCRYPTION_KEY": "secure-encryption-key-value",
"JWT_SECRET": "secure-jwt-secret-value"
} -
Terraform으로 Cloud Run 서비스 배포 시 Secret Manager 통합:
cloud_run_service = [
{
name = "dta-wide-api"
template = {
revision = local.template_revision_name
containers = [
{
name = "dta-wide-api"
image = "asia.gcr.io/dta-cloud-de-dev/dta-wide-api:dta-wide-api-${local.container_image}"
env = [
{
name = "CONFIGURATION"
value_source = {
secret_key_ref = {
secret = "projects/dta-cloud-de-dev/secrets/dta-wide-api"
version = local.template_secret_version
}
}
}
]
}
]
}
}
]
이 방식의 장점:
- 순환 참조 없이 구성 값 접근 가능
- 배포 시점에 자동으로 환경 변수 설정
- Secret Manager의 최신 값을 항상 사용
- 개발 환경과 프로덕션 환경 간 일관된 방식
8. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-03-20 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-03-30 | bok@weltcorp.com | 구성 모듈 위치 libs/ 경로로 명시 |