- Published on
class-transformer의 discriminator 기능 커스터마이징
외부 라이브러리의 한계로 기술적 요구사항을 구현하기 어려운 상황에서, 처음으로 패키지를 직접 수정해 커스텀 패키지를 만들어 해결한 경험을 하게 됐습니다.
npm 패키지인 class-transformer의 '@Type()' 데코레이터의 'discriminator' 옵션을 사용해야 했습니다. 이 옵션은 한 필드를 기준으로 자식 클래스를 선택해 변환하는데, 사내 요구사항을 충족하려면 두 개 이상의 필드를 기준으로 자식 클래스를 선택하는 기능이 필요했습니다.
최근 유지보수가 중단된 라이브러리였기 때문에, 패키지를 fork하여 수정했고, 사내 커스텀 패키지로 발행해 현재도 유용하게 사용하고 있습니다.
이 경험을 통해 처음으로 라이브러리를 커스텀하여 사용하는 경험을 했고 내용을 공유합니다.
class-transformer 소개
class-transformer는 JavaScript와 TypeScript 환경에서 객체 간의 변환, 클래스 인스턴스로 변환 또는 그 반대를 데코레이터 기반으로 지원하는 라이브러리입니다. 백엔드에서 JSON 형태로 데이터를 받아 이를 TypeScript 클래스 인스턴스로 변환하여 타입 안전성을 확보하거나, 프론트엔드에서 사용자 입력을 받아 이를 서버에 전송하기 전에 특정 클래스 형태로 변환하는 등의 작업이 가능합니다. 이를 통해 코드의 가독성과 유지 보수성을 높일 수 있으며, 타입 안전성을 강화하여 런타임 오류를 줄일 수 있습니다.
discriminator 기능
class-transformer의 @Type() 데코레이터에서 제공하는 discriminator 기능은 JSON 객체를 클래스로 변환할 때, 특정 필드의 값을 기준으로 어떤 자식 클래스를 사용할지를 자동으로 결정하는 기능입니다.
class-transformer 패키지의 README.md 예시를 가져왔습니다.
JSON input
{
"id": 1,
"name": "foo",
"topPhoto": {
"id": 9,
"filename": "cool_wale.jpg",
"depth": 1245,
"__type": "underwater"
}
}
import { Type, plainToInstance } from 'class-transformer'
export abstract class Photo {
id: number
filename: string
}
export class Landscape extends Photo {
panorama: boolean
}
export class Portrait extends Photo {
person: Person
}
export class UnderWater extends Photo {
depth: number
}
export class Album {
id: number
name: string
@Type(() => Photo, {
discriminator: {
property: '__type',
subTypes: [
{ value: Landscape, name: 'landscape' },
{ value: Portrait, name: 'portrait' },
{ value: UnderWater, name: 'underwater' },
],
},
})
topPhoto: Landscape | Portrait | UnderWater
}
let album = plainToInstance(Album, albumJson)
// now album is Album object with a UnderWater object without `__type` property.
이렇게 네스팅된 클래스의 타입을 분기처리할 때 사용하는 옵션입니다. 도메인이 복잡할수록 사용할 일이 많아질 것입니다.
내가 겪은 문제
실무에서 사용하는 도메인은 매우 복잡합니다. 학습 자료라는 개념은 평가원 기출, 내신, 노트, PDF 등 여러 종류의 학습 자료로 분류됩니다. 기능을 구현하면서 컴파일 타임에 오류를 잡기 위해서는 어떤 자식 클래스의 인스턴스가 되는지가 매우 중요했습니다. 이 과정에서 문제가 발생하는데요. 하나의 property로는 분기처리할 수 없고 두 개의 property가 필요한 경우가 있습니다.
실무에서 사용하는 학습 자료 예시보다 이해가 쉽도록 위에 제시한 Album, Photo를 예시로 들어보겠습니다.
발생한 문제 예시
위 예시를 글로 표현해보자면 다음과 같습니다.
- Album 클래스는 topPhoto 프로퍼티를 가집니다.
- 값은 Photo 클래스의 자식 클래스인 Landscape, Portrait, UnderWater 타입입니다.
__type필드의 값에 따라 어떤 클래스인지 정해집니다. 여기서 하나의 필드가 아닌 두 필드로 자식 클래스가 결정되는 경우를 생각해봅니다.__type이 landscape이고__subType이 forest인 경우 Forest라는 자식 클래스, ocean인 경우 Ocean이라는 자식 클래스로 설정된다면 어떻게 해야 할까요? (추가로 portrait의 경우에도 man, woman에 따라 ManPortrait, WomanPortrait으로 나누어지도록 설정하고 쉬운 예시를 위해 underwater 케이스는 생략하겠습니다.)
해결 방법
구현 방향
discriminator의 property를 __type, __subType 2개 사용하면 됩니다.
| __type | __sutType | value |
|---|---|---|
| landscape | forest | Forest |
| landscape | ocean | Ocean |
| portrait | man | ManPortrait |
| portrait | woman | WomanPortrait |
이를 구현하면 다음과 같은 기능이 가능해야합니다.
// ...Photo 클래스 선언
export class Album {
id: number
name: string
@Type(() => Photo, {
discriminator: {
property: ['__type', '__subType'],
subTypes: [
{ value: Forest, name: ['landscape', 'forest'] },
{ value: Ocean, name: ['landscape', 'ocean'] },
{ value: ManPortrait, name: ['portrait', 'man'] },
{ value: WomanPortrait, name: ['portrait', 'woman'] },
],
},
})
topPhoto: Forest | Ocean | ManPortrait | WomanPortrait
}
let album = plainToInstance(Album, albumJson)
property와 subTypes[n].name에 배열을 받을 수 있도록 타입 수정을 하고 구현부를 수정했습니다.
코드 수정
수정해야할 부분은 아래와 같습니다.
packages/class-transformer/src/TransformOperationExecutor.ts 파일입니다.
Before
// ...
if (!this.options.enableCircularCheck || !this.isCircular(subValue)) {
let realTargetType;
if (
typeof targetType !== 'function' &&
targetType &&
targetType.options &&
targetType.options.discriminator &&
targetType.options.discriminator.property &&
targetType.options.discriminator.subTypes
) {
if (this.transformationType === TransformationType.PLAIN_TO_CLASS) {
realTargetType = targetType.options.discriminator.subTypes.find(
subType =>
subType.name === subValue[(targetType as { options: TypeOptions }).options.discriminator.property]
);
const options: TypeHelpOptions = { newObject: newValue, object: subValue, property: undefined };
const newType = targetType.typeFunction(options);
realTargetType === undefined ? (realTargetType = newType) : (realTargetType = realTargetType.value);
if (!targetType.options.keepDiscriminatorProperty)
delete subValue[targetType.options.discriminator.property];
}
if (this.transformationType === TransformationType.CLASS_TO_CLASS) {
realTargetType = subValue.constructor;
}
if (this.transformationType === TransformationType.CLASS_TO_PLAIN) {
subValue[targetType.options.discriminator.property] = targetType.options.discriminator.subTypes.find(
subType => subType.value === subValue.constructor
).name;
}
} else {
realTargetType = targetType;
}
// ...
After
// ...
if (!this.options.enableCircularCheck || !this.isCircular(subValue)) {
let realTargetType;
if (
typeof targetType !== 'function' &&
targetType &&
targetType.options &&
targetType.options.discriminator &&
targetType.options.discriminator.property &&
targetType.options.discriminator.subTypes
) {
const property = (targetType as { options: TypeOptions }).options.discriminator.property;
if (this.transformationType === TransformationType.PLAIN_TO_CLASS) {
realTargetType = targetType.options.discriminator.subTypes.find(subType => {
return this.subTypePredicate(property, subType.name, subValue);
});
const options: TypeHelpOptions = { newObject: newValue, object: subValue, property: undefined };
const newType = targetType.typeFunction(options);
realTargetType === undefined ? (realTargetType = newType) : (realTargetType = realTargetType.value);
if (!targetType.options.keepDiscriminatorProperty) {
const property = targetType.options.discriminator.property;
if (Array.isArray(property)) {
property.forEach(property => delete subValue[property]);
} else {
delete subValue[property];
}
}
}
if (this.transformationType === TransformationType.CLASS_TO_CLASS) {
realTargetType = subValue.constructor;
}
if (this.transformationType === TransformationType.CLASS_TO_PLAIN) {
this.assignSubTypeName(property, targetType.options.discriminator.subTypes, subValue);
}
} else {
realTargetType = targetType;
}
// ...
마무리
이번 경험은 단순히 외부 라이브러리를 사용하는 것을 넘어, 사내 요구사항에 맞춰 직접 기능을 커스터마이징한 첫 사례였습니다. 처음에는 라이브러리의 구조를 파악하고 문제를 해결할 방법을 찾는 과정이 쉽지 않았지만, 이를 통해 기술적인 제약을 극복하고 해결책을 만들어내는 자신감을 얻게 되었습니다. 특히, 유지보수가 중단된 라이브러리를 수정해 사내 전용 패키지로 발행함으로써 팀 전체의 생산성을 높이고 공유 가능한 솔루션을 제공했다는 점에서 더욱 의미 있는 작업이었습니다.
앞으로도 비슷한 상황에 직면했을 때 기술적인 한계를 극복하기 위해 도전하고, 공유와 협업을 통해 더 나은 기술 문화를 만들어가는 것을 목표로 합니다. 이번 경험은 단순히 문제를 해결하는 것을 넘어, 개발자로서 지속적으로 성장하고자 하는 제 목표를 다시금 확인하는 계기가 되었습니다.