들어가며
사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. 그 과정에서 생각하고 배웠던 점들을 하나씩 작성하고자 합니다. 프로젝트를 진행하면서, 직렬화와 역직렬화를 공부하면서, 때에 따라 JSON 객체와 클래스의 인스턴스를 알맞게 변환하는 것이 필요하다는 것을 알았습니다. 그때 활용하는 것이 class-transformer인데, 이번 기회에 class-transformer에 대해 정리하고 프로젝트를 진행해야겠다고 생각했습니다. 이 글은 TypeScript 환경에서 class-transformer 적극적으로 사용하기를 참고한 내용입니다. 링크에서 더 자세하게 내용을 살펴볼 수 있습니다.
class-transformer
백엔드 환경에서도 외부의 HTTP API를 호출하는 일이 생깁니다. 그때 응답으로 넘어온 JSON 객체는 리터럴 객체입니다. 다른 말로 하면 클래스의 인스턴스가 아닙니다. Axios를 비롯해서 NodeJS & TypeScript 환경에서 자주 사용하는 HTTP API 중 어느 것도 클래스의 인스턴스를 응답으로 넘겨주지 않습니다.
그럼 리터럴 객체로 받으면 무엇이 좋지 못할까요? 그렇다면, 리터럴 객체를 어떻게 클래스의 인스턴스로 변경해서 받을 수 있을까요. 이에 대해 알아보겠습니다.
리터럴 객체의 추가 가공
외부 API를 통해 다음과 같은 결과를 받았다고 가정해보겠습니다.
[
{
"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처럼 값만 있는 리터럴 객체면 추가 가공은 별도의 함수에서 처리해야 합니다. 이로 인해 상태와 행위가 따로 노는 응집력이 떨어지는 코드가 됩니다.
const users = api.getUsers();
return users.map(u => toFullName(u)); // 값 user와 toFullName 함수가 별도로 존재한다
export function toFullName (user) {
return `${user.firstName} ${user.lastName}`;
}
만약 여기서 isAdult와 같이 추가 가공 로직이 하나 더 있다면, 응집력은 더더욱 떨어지게 됩니다. 반면에, 받은 값 가공 로직을 클래스 내부에 둔다면 상태와 행위가 한 곳에 있는 응집력 있는 코드가 됩니다.
export class User {
id: number
firstName: string
lastName: string
age: number
getFullName() {
return this.firstName + ' ' + this.lastName
}
isAdult() {
return this.age > 36 && this.age < 60
}
}
위와 같이 작성된 코드에서는 아래와 같이 User 클래스에 모든 책임을 위임할 수 있습니다.
const users: User[] = api.getUsers();
return users.map(u => u.getFullName());
꼭 외부 API로 받은 값에서만 발생하지 않고, 프런트엔드에서 넘겨준 Request Body에서도 값과 행위가 함께 응집력 있는 코드가 필요한 경우가 대부분입니다. 흔히 말하는 OOP, 도메인 기반의 Entity 설계 등을 고려했을 때 어떤 객체에 어떤 책임을 줄 것인가는 대단히 중요합니다.
리터럴 객체의 인스턴스화
위와 같이 외부와 연동하는 상황에서 요청/응답 값을 리터럴 객체로만 다루는 것은 한계가 있습니다. 그래서 리터럴 객체에서 클래스 인스턴스 변환은 꼭 필요한 작업 중 하나인데, 이 문제를 class-transformer가 쉽게 해결할 수 있습니다.
만약 HackerNews의 정보를 가져오는 API를 만든다고 가정해보겠습니다. API 주소로 https://hacker-news.firebaseio.com/v0/item/2921983.json를 호출해보면 다음과 같은 결과가 내려옵니다.
{
"by": "norvig",
"id": 2921983,
"kids": [
2922097,
2922429,
2924562,
2922709,
2922573,
2922140,
2922141
],
"parent": 2921506,
"text": "Aw shucks, guys ... you make me blush with your compliments.<p>Tell you what, Ill make a deal: I'll keep writing if you keep reading. K?",
"time": 1314211127,
"type": "comment"
}
만약 여기서 2개의 기능이 비즈니스 로직상 필요하다고 가정하겠습니다.
- time의 ms 타임을 LocalDataTime으로 변환된 값이 필요하다.
- parent가 없는 경우엔 최상위 Item 임을 알 수 있어야 한다.
그럼 단일 클래스로는 다음과 같이 표현이 가능합니다.
export class HackerNewsItem {
by: string;
descendants: number;
id: number;
kids: number[];
parent: number;
score: number;
time: number;
title: string;
type: string;
url: string;
text: string;
constructor() {}
get createTime(): LocalDateTime {
const milliTime = this.time * 1000;
return LocalDateTime.ofInstant(Instant.ofEpochMilli(milliTime));
}
get isFirstItem(): boolean {
return !this.parent;
}
}
이를 HTTP API (ex: axios 등)과 함께 쓴다면 다음과 같이 가능합니다.
const result = api.get();
const hackerNewsItem = plainToClass(HackerNewsItem, result.data);
class-transformer의 plainToClass을 사용한다면 더 이상 리터럴 객체를 다룰 필요 없이 값과 행위가 한 곳에 모여있는 클래스 인스턴스 단위로 다룰 수 있게 됩니다.
활용 방법
지금까지 리터럴 객체를 클래스 인스턴스화 시키는 것에 대한 중요성을 알아봤다면 지금부터는 class-transformer를 어떻게 하면 더 잘 활용할 수 있는지에 대해 알아보겠습니다.
1. 제네릭을 활용한 HTTP 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);
}
이렇게 request<T> 제네릭 함수를 이용한다면 다음과 같이 편하게 HTTP API를 호출할 수 있습니다.
it('HackerNews를 통해서 가져온다', async () => {
const data: HackerNewsItem = await request<HackerNewsItem>(
{
url: 'https://hacker-news.firebaseio.com/v0/item/2921983.json',
method: 'get',
},
HackerNewsItem,
);
expect(data.type).toBe('comment');
expect(data.createTime.toString()).toBe(
LocalDateTime.of(2011, 8, 25, 3, 38, 47).toString(),
);
expect(data.isFirstItem).toBe(false);
});
2. 카멜 케이스 <-> 스네이크 케이스
class-transformer는 일반적으로 @Expose() 데코레이터를 기반으로 변환 기준을 잡습니다. 그래서 모든 클래스의 속성에 @Expose()를 선언해야 하는 게 아닐까 싶지만, 기본적으로 클래스 속성으로 선언되어 있으면 변환 대상으로 자동 인지 됩니다. 그래서 위 예제에서는 별도로 데코레이터 지정 없이도 가능했습니다. 그렇다면 @Expose()가 필요한 시점이 언제냐 하면, 서로 이름/컨벤션이 다른 경우에 유용하게 쓸 수 있습니다.
대표적으로 카카오와 같은 오픈 API들이 있는데, 이런 오픈 API들이 대부분 스네이크 케이스로 결과를 내려줍니다.
{
email: 'test@test.com',
phone_number: '+82 10-1234-1234',
};
이걸 받아서 써야 하는데, 특별한 조작이 없다면 클래스에서도 스네이크 케이스를 해야만 합니다. 거의 대부분의 프로젝트에서 카멜 케이스를 기본 컨벤션으로 가지고 있기 때문에 어떻게 하면 코드에서는 카멜 케이스를 하면서 스네이크 케이스의 응답 값을 받을 수 있을까가 문제입니다. 이때 class-transformer를 사용하면 편하게 변환이 가능합니다.
export class KakaoAccountDto {
@Expose({ name: 'email' })
public _email: string;
@Expose({ name: 'phone_number' })
public phoneNumber: string;
}
위 코드처럼 @Expose({ name: }) 을 지정하게 되면 name에 일치하는 값으로 매핑이 되어 프로젝트의 컨벤션을 훼손하지 않는 선에서 외부의 컨벤션을 대응할 수 있게 됩니다.
3. 특정 필드 제외
특정 상황에서는 클래스의 일부 필드는 비즈니스 로직에서는 사용하되, HTTP 응답에서는 제외하는 등의 로직이 필요할 때가 있습니다. 대표적으로 getter 패턴 DTO를 들 수 있는데, private으로 내부 필드는 보호하고, getter만 열어두어 무분별하게 값 변조를 막는 방식의 경우입니다.
그럴 경우 @Exclude()를 통해 private 필드는 변환 대상에서 제외하면 getter만 변환 대상에 포함되니 의도한 대로 응답 결과를 내려줄 수 있습니다.
import { Exclude, Expose } from 'class-transformer';
export class UserShowDto {
// private 필드들은 모두 @Exclude()로 제외
@Exclude() private readonly _id: number;
@Exclude() private readonly _firstName: string;
@Exclude() private readonly _lastName: string;
constructor(user: { id: any; firstName: any; lastName: any }) {
this._id = user.id;
this._firstName = user.firstName;
this._lastName = user.lastName;
}
// getter는 모두 @Expose()로 공개
@Expose()
get id(): number {
return this._id;
}
@Expose()
get firstName(): string {
return this._firstName;
}
@Expose()
get lastName(): string {
return this._lastName;
}
}
위와 같이 DTO를 활용하면, private 필드는 @Exclude를 활용해서 변환 대상에서 제외했고, getter는 모두 @Expose를 통해 노출되게끔 설정할 수 있습니다.
4. 중첩 객체 변환
클래스 안의 클래스 (중첩 객체)가 있는 인스턴스를 변환하려는 경우 중첩 객체의 타입을 알아야 합니다. TypeScript는 아직 좋은 리플렉션 기능이 없기 때문에 각 속성에 설정된 객체 타입을 명시적으로 지정해야 합니다. 이를 테면 다음과 같이 Album 클래스 내부에 Photo 클래스 타입의 변수가 있을 경우 @Type() 데코레이터를 사용해서 변환이 가능합니다.
export class Photo {
id: number
filename: string
}
import { Type, plainToClass } from 'class-transformer'
export class Album {
id: number
name: string
@Type(() => Photo)
photos: Photo[]
}
위처럼 @Type()을 이용하면 무슨 클래스 타입인지 명시를 할 수 있기 때문에 class-transformer가 변환을 할 수 있게 됩니다. 이럴 경우 다음과 같이 plainToClass로 편하게 리터럴 객체에서 클래스 인스턴스로 변환이 가능합니다.
const album = plainToClass(Album, albumJson)
마치며
앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다.
출처
'Project > 서버 개발' 카테고리의 다른 글
[Project] 프로젝트 삽질기15 (feat TypeORM Query Timeout) (0) | 2022.04.08 |
---|---|
[Project] 프로젝트 삽질기14 (feat 큐 모니터링) (0) | 2022.03.30 |
[Project] 프로젝트 삽질기12 (feat 직렬화) (0) | 2022.03.29 |
[Project] 프로젝트 삽질기11 (feat pgAdmin4) (0) | 2022.03.28 |
[Project] 프로젝트 삽질기10 (feat bull 공식문서 정리) (0) | 2022.03.16 |