작성자는 쉐퍼드23의 Product Manager & Software Engineer로 재직하며 카페24 플랫폼을 대상으로 하는 Contextual Bandit 기반의 개인화 상품 추천 플러그인 PickHound의 개발 부문을 담당한 바가 있습니다. (See: 경력 기술서 - PickHound)
작성자는 NestJS를 이용해 메인 백엔드 서비스를 개발하며, 공식 Documentation에서 권장하는 class-validator
, class-transformer
, ValidationPipe
를 이용한 DTO 유효성 검사를 적극적으로 활용하고 있습니다. (See: Validation - NestJS)
한편, 작성자는 Lerna와 NestJS CLI를 이용해 Monorepo 구조로 프로젝트를 구성하고, 클라이언트 단에서 활용할 수 있는 DTO 패키지를 별도로 분리하여 관리하고 있습니다. 그러나 이 과정에서, @IsInt()
등으로 타입이 엄격히 정해진 프로퍼티에 string
이 들어가도 이를 막지 않는 등 NestJS의 ValidationPipe
가 제대로 작동하지 않는 문제를 발견했습니다.
본 글에서는 이러한 문제를 해결하기 위해 작성자가 시도한 방법들을 기술합니다.
문제의 원인 파악
문제를 해결하려면 이에 앞서 원인을 먼저 파악해야 했습니다.
문제의 구체화
위에서 언급했듯이 패키지 공유를 위해 모노리포를 공유했고, core
패키지에서는 메인 서버 개발을, shared
패키지에서는 클라이언트 단과 공유할 DTO 모음 패키지를 개발하고 있었습니다.
작성자는 여기서 ValidationPipe
가 core
패키지에 있는 DTO에는 제대로 작동하지만, shared
패키지에 있는 DTO에는 제대로 작동하지 않는 문제를 발견했습니다. shared
패키지의 DTO는 프로퍼티에 어떤 class-validator
데코레이터를 추가해도 ValidationPipe
가 이를 무시하고, 아무 값이라도 프로퍼티에 할당되면 이를 통과시켜버리는 것이었습니다.
이에, 작성자는 ValidationPipe
의 작동 원리가 정확히 무엇인지를 파악해 무엇이 core
패키지와 shared
패키지의 DTO를 구분하는 요인이 되었는지를 찾고자 했습니다.
ValidationPipe
NestJS는 class-validator
, class-transformer
를 이용해 DTO의 유효성을 검사하고 주어진 타입으로 변환합니다. 이는 공식 Documentation에 나오는 내용인데, 이것이 구체적으로 어떻게 이루어지는지를 알아보기 위해 ValidationPipe의 코드를 뜯어봤습니다.
코드의 작동방식은 생각보다 단순하고 깔끔했습니다. 기본적으로 ValidationPipe
객체는 class-validator
와 class-transformer
패키지를 아래와 같이 그대로 불러와 사용하는 것이었습니다.
@Injectable()
export class ValidationPipe implements PipeTransform<any> {
/* ... Properties */
constructor(@Optional() options?: ValidationPipeOptions) {
/* ... Routines */
classValidator = this.loadValidator(options.validatorPackage);
classTransformer = this.loadTransformer(options.transformerPackage);
}
protected loadValidator(
validatorPackage?: ValidatorPackage,
): ValidatorPackage {
return (
validatorPackage ??
loadPackage("class-validator", "ValidationPipe", () =>
require("class-validator"),
)
);
}
protected loadTransformer(
transformerPackage?: TransformerPackage,
): TransformerPackage {
return (
transformerPackage ??
loadPackage("class-transformer", "ValidationPipe", () =>
require("class-transformer"),
)
);
}
/* ... Methods */
}
이에, class-validator
와 class-transformer
의 문제일 확률이 높겠다는 예상이 있어 두 패키지의 코드를 뜯어보았습니다.
class-validator
& class-transformer
class-validator와 class-transformer는 Typescript의 Decorators 기능을 적극 활용해 DTO의 유효성 검사와 타입 변환을 수행하는 패키지입니다.
이 두개의 패키지는 둘 다 MetadataStorage
라는 객체를 구현해 Typescript 컴파일 타임에 실행되는 데코레이터 함수에서 이 객체의 메서드를 호출해 프로퍼티에 추가된 데코레이터를 저장합니다. 일례로 class-validator
의 @IsInt() 데코레이터의 작동방식은 아래와 같습니다.
IsInt
는 ValidateBy
함수를 호출합니다.
이때 validator.validate
의 인자로 isInt
함수를 전달합니다.
export function isInt(val: unknown): val is Number {
return typeof val === "number" && Number.isInteger(val);
}
export function IsInt(
validationOptions?: ValidationOptions,
): PropertyDecorator {
return ValidateBy(
{
name: IS_INT,
validator: {
validate: (value, args): boolean => isInt(value),
defaultMessage: buildMessage(
(eachPrefix) => eachPrefix + "$property must be an integer number",
validationOptions,
),
},
},
validationOptions,
);
}