일반적으로 NestJS에서 ConfigModule과 Joi를 함께 사용하면 다음과 같은 중복 작업이 발생합니다.
- app.config.ts: 클래스 내부에 타입과 필드 정의
- validation-schema.ts: Joi를 이용해 동일한 필드들에 대한 검증 로직 작성
이 방식은 환경 변수가 추가될 때마다 두 파일을 모두 수정해야 하며, 실수로 한 곳을 누락할 위험이 있습니다.
이를 해결하기 위해 데코레이터 하나로 필드 정의와 검증을 동시에 처리하는 구조를 설계했습니다.
핵심 아키텍처
- Metadata Reflection: 클래스 필드에 @JoiValidate 데코레이터를 달아 Joi 스키마 정보를 메타데이터로 저장합니다.
- Dynamic Schema Building: 모듈 초기화 단계에서 클래스에 저장된 메타데이터를 읽어와 하나의 Joi.object() 스키마를 동적으로 생성합니다.
- Auto Mapping: 클래스 생성자에서 ConfigService를 통해 검증된 값을 자신의 필드에 자동으로 할당합니다.
커스텀 데코레이터 및 상수 (joi-validate.decorator.ts)
Reflect-metadata를 활용해 각 프로퍼티가 가져야 할 검증 규칙을 클래스 레벨의 맵에 저장합니다.
// joi-validate.decorator.ts
export function JoiValidate(schema: Joi.Schema) {
return (target: object, propertyKey: string) => {
// 기존에 저장된 스키마 맵을 가져오거나 새로 생성
const schemas = (Reflect.getMetadata(JOI_SCHEMAS_KEY, target) as object) || {};
schemas[propertyKey] = schema;
// 메타데이터에 다시 저장
Reflect.defineMetadata(JOI_SCHEMAS_KEY, schemas, target);
};
}
공급원 (app-config.ts)
이제 이 파일 하나만 관리하면 됩니다. 타입 정의와 검증 규칙이 한곳에 모여 있습니다.
@Injectable()
export class AppConfig {
// 서버 포트
@JoiValidate(Joi.number().required())
readonly PORT: number;
constructor(private configService: ConfigService) {
// 생성자에서 ConfigService의 값을 클래스 필드에 자동 바인딩
const keys = Object.keys(this);
keys.forEach(key => {
(this as Record<string, unknown>)[key] = this.configService.get<unknown>(key);
});
}
}
생성자 로직을 통해 config.PORT처럼 직관적으로 환경 변수에 접근할 수 있으며, 타입 안전성도 보장됩니다.
동적 스키마 주입 (app-config.module.ts)
ConfigModule.forRoot 설정 시, 클래스 메타데이터로부터 스키마를 추출해 주입합니다.
ConfigModule.forRoot({
validationSchema: (() => {
// AppConfig 클래스 프로토타입에 저장된 모든 Joi 스키마를 객체로 변환
const schemas = (Reflect.getMetadata(JOI_SCHEMAS_KEY, AppConfig.prototype) as object) || {};
return Joi.object(schemas).options({ convert: true });
})(),
}),
이 구조의 장점
- 유지보수성 향상: 새로운 환경 변수를 추가할 때 AppConfig 클래스에 필드와 데코레이터만 추가하면 끝납니다.
- 가독성: 각 변수가 어떤 제약 조건(필수 여부, 타입 등)을 가지는지 코드 상에서 직관적으로 확인할 수 있습니다.
- 타입 안정성: 클래스 필드에 정의된 타입을 그대로 사용하므로 IDE의 자동 완성 기능을 100% 활용할 수 있습니다.
댓글 0
댓글은 회원만 작성할 수 있습니다.
로그인하고 댓글 달기댓글을 불러오는 중입니다...
