TypeScript 환경에서 Rest API를 호출하거나 받는 경우가 많습니다. 그럴 때 가장 많이 사용되는 것은 JSON 객체를 사용합니다. 다른 환경은 모르겠지만 TypeScript & NodeJS(NestJS) 환경에서는 당황스러운 일이 있곤합니다. 바로 인스턴스 변환을 지원하지 않는다는 점이죠.
이 말의 뜻은 API 중 어느 것도 클래스의 인스턴스를 응답으로 넘겨주지 않습니다. 그렇기 때문에 Json 파일을 전달 받으면 NodeJS 진영에서는 이것을 인스턴스로 변환하지 않습니다.
그렇다면 NodeJS 진영에서는 어떻게 요청 데이터를 인스턴스로 변환하는지 살펴보도록 하겠습니다.
class-transformer 이란?
문제 1. – JavaScript
JavaScript에는 두 가지 유형의 객체가 있습니다.
- 일반(리터럴) 객체
- 클래스(생성자) 객체
일반 객체란 클래스의 인스턴스(Object) 객체입니다.
클래스 객체란 자체 정의된 생성자, 속성 및 메서드가 있는 클래스의 인스턴스입니다.
여기서 문제가 있습니다. 바로 일반 자바스크립트 객체를 가지고 있는 ES6부터 지원한 클래스로 변환하고 싶은 경우 입니다.
예를 들어 NodeJS, NestJS로 백엔드를 구성해서 API 중 요청 받은 값 중 json를 가져올 때 클래스 인스턴스가 아닌 일반 자바스크립트 객체가 생성됩니다.
[
{
"id": 1,
"firstName": "Johny",
"lastName": "Cage",
"age": 27
},
{
"id": 2,
"firstName": "Ismoil",
"lastName": "Somoni",
"age": 50
},
{
"id": 3,
"firstName": "Luke",
"lastName": "Dacascos",
"age": 12
}
]
위와 같은 json 데이터가 있습니다. 그리고 아래와 같은 클래스가 있죠.
export class User {
id: number;
firstName: string;
lastName: string;
age: number;
getName() {
return this.firstName + ' ' + this.lastName;
}
isAdult() {
return this.age > 36 && this.age < 60;
}
}
이것을 사용하기 위해 다음과 같은 코드를 작성해서 실행하려고 합니다.
fetch('users.json').then((users: User[]) => {
// 여기에 User Class를 통해 다양한 처리를 할 예정입니다만..?
});
이렇게 코드를 작성해서 users를 호출하는 순간 오류가 발생합니다. 에러 내용은 users는 class 객체가 아닌 일반 자바스크립트 객체의 배열로 넘어오기 때문에 사용할 수 없게 됩니다.
해결 방안
이 문제를 해결하기 위해서는 리터럴 객체를 클래스 인스턴스 변환이 꼭 필요합니다. 여기에 필요한 것은 class-transformer 라이브러리 입니다. class-transformer는 단어 그대로 class로 변환해주는 라이브러리입니다.
fetch('users.json').then((users: Object[]) => {
const realUsers = plainToClass(User, users);
});
위 코드에 있는 plainToClass는 전달받은 리터럴 객체를 클래스객체로 변환해 주는 method 입니다. 이를 통해 문제가 되었단 리터럴 객체를 클래스 객체로 활용할 수 있게 됩니다.
class-transformer이 지원하는 기능
plainToClass
앞선 코드에서 보았던 plainToClass method 입니다. 이것은 자바스크립트 객체를 특정 클래스의 인스턴스로 변환합니다.
import { plainToClass } from 'class-transformer';
let users = plainToClass(User, userJson); // to convert user plain object a single user. also supports arrays
classToPlain
이 method는 클래스를 객체를 일반 자바스크립트 객체로 다시 변환합니다.
import { classToPlain } from 'class-transformer';
let photo = classToPlain(photo);
instanceToInstance
이 method는 클래스 객체를 새로운 인스턴스로 변환합니다. 객체를 전체 복제한다고 보시면 됩니다.
import { instanceToInstance } from 'class-transformer';
let photo = instanceToInstance(photo);
serialize & deserialize
직렬화와 역직렬화는 아래 코드처럼 사용할 수 있습니다. 데이터 크기를 줄여서 주고 받을 때 사용합니다.
import { serialize, deserialize, deserializeArray } from 'class-transformer';
let photo = serialize(photo);
let photo = deserialize(Photo, photo);
let photos = deserializeArray(Photo, photos); // 배열을 역직렬화합니다.
제네릭을 활용한 API 함수
앞서 다룬 plainToClass는 계속해서 호출해야 한다는 단점이 있습니다.
이것이 귀찮다면 별도의 함수를 만들어서 사용할 수 있습니다.
export const instance: AxiosInstance = axios.create({
responseType: 'json',
validateStatus(status) {
return [200].includes(status);
},
});
export async function request<T>(
config: AxiosRequestConfig,
classType: any,
): Promise<T> {
const response = await instance.request<T>(config);
return plainToClass<T, AxiosResponse['data']>(classType, response.data);
}
위와 같이 말이죠. Axios를 많이 사용하기 때문에 Axios 객체로 만들었는데요. 비슷하게 다른 곳에서도 제네릭으로 만들 수 있습니다.
export class PaginatedResponseDto<T> {
@Exclude()
private type: Function;
@Expose()
@ApiProperty()
@Type(opt => (opt.newObject as PaginatedResponseDto<T>).type)
data: T[];
constructor(type: Function) {
this.type = type;
}
}
위 코드는 NestJS에서 많이 사용하는 코드입니다. DTO 클래스를 만들 때 제네릭을 통해 리터럴 객체를 클래스 객체로 자동으로 변환합니다.
위와 같이 작성하면 Request 요청 중 Post로 받을 때 body json을 자동으로 클래스 객체로 치환해줍니다. 그리고 데코레이터 설정에 따라 필요한 값을 매핑하거나 제외할 수 있습니다. 그리고 복잡한 Type도 대응해서 매핑이 가능합니다.
중첩 객체 매핑
클래스 내부에는 일반 속성 뿐만 아니라 다른 클래스를 넣어 사용하는 중첩된 객체도 존재합니다. 물론 일반 리터럴 객체에도 중첩된 값들이 들어갈 수 있습니다. 이것을 매핑하기 위해서는 @Type 데코레이터를 사용합니다.
import { Type, plainToClass } from 'class-transformer';
export class Album {
id: number;
name: string;
@Type(() => Photo)
photos: Photo[];
}
export class Photo {
id: number;
filename: string;
}
let album = plainToClass(Album, albumJson);
위와 같이 Type 데코레이터를 통해 중첩된 객체를 클래스로 매핑 할 수 있습니다.
데코레이터 사용법
@Expose
expose 데코레이터는 getter 또는 method에 사용할 수 있으며, 사용하면 반환하는 내용을 노출할 수 있습니다.
import { Expose } from 'class-transformer';
export class User {
@Expose({ name: 'uid' })
id: number;
firstName: string;
lastName: string;
password: string;
@Expose()
get name() {
return this.firstName + ' ' + this.lastName;
}
@Expose()
getFullName() {
return this.firstName + ' ' + this.lastName;
}
}
@Exclude
특정 속성을 제외해서 반환을 막을 수 있습니다.
import { Exclude } from 'class-transformer';
export class User {
id: number;
email: string;
@Exclude({ toPlainOnly: true })
password: string;
}
@Type
일반 자바스크립트 객체를 다양한 형태로 변환하는 데코레이터입니다.
날짜 문자열을 Date 객체로 반환하고, 배열로 변경하거나, Transform 데코레이터를 활용하여 추가 데이터 변환을 할 수 있습니다.
import { Type } from 'class-transformer';
import * as moment from 'moment';
import { Moment } from 'moment';
export class Skill {
name: string;
}
export class Weapon {
name: string;
range: number;
}
export class Player {
name: string;
@Type(() => Date)
registrationDate: Date;
@Type(() => Date)
@Transform(({ value }) => moment(value), { toClassOnly: true })
date: Moment;
@Type(() => Skill)
skills: Set<Skill>;
@Type(() => Weapon)
weapons: Map<string, Weapon>;
}
마무리
여기까지가 class-transformer와 많이 사용하는 데코레이터 @Expose에 대한 내용을 다뤘습니다.
공부하면서 javascript는 클래스 객체가 도입된 지 얼마 되지 않았고, TypeScript까지 나오면서 객체지향 프로그래밍으로 많은 시도를 하고 있습니다. 그러다보니 원래 리터럴 객체를 사용했던 JavaScript에서 클래스로 맵핑하는 과정이 순조롭지 않았습니다. 이 부분이 다른 객체지향 언어와의 차이점이 되겠네요
다행히 class-transformer로 어느 정도 문제를 해결할 수 있기 때문에 다행이죠.
참고자료
https://jojoldu.tistory.com/617
https://github.com/typestack/class-transformer#what-is-class-transformer