이 글이 OOP가 좋더라, OOP를 해야한다를 말하려는 글은 아니다. 오히려 OOP가 아닐 가능성이 더 높다. (class를 쓴다고 해서 반드시 OOP는 아닌 것처럼). 다만 최근 회사에서 신규 서비스 개발을 하며 프론트엔드에 OOP를 녹이고자 여러 시도가 있었는데 (FP+OOP), 그 과정 중 하나였던 ‘서버 데이터를 class로 관리하기’에 대해 어떤 식으로 진행되었는지 기록해보려고 한다.
왜 필요했나?
돌이켜보면 개발을 막 처음 시작했을 때부터, 짧은 프론트엔드 개발 경험에서 마주했던 대부분의 절차는 다음과 같았다.
- 뷰 마크업을 한다
- 스타일을 입힌다
- 인터랙션을 위해 필요한 로직을 추가한다
- 로직이 복잡해지면 커스텀 훅으로 분리한다
이렇게 하다 보면 다음과 같은 문제를 마주하게 된다.
- 이게 뷰 로직인지 비즈니스 로직인지 구분하기 어려울 정도로 엉킨다.
- 그러므로 테스트가 어려워진다.
- 규모가 커지면 코드가 여기저기 퍼지게 되어 유지보수에 상당한 어려움을 겪게 된다.
그랬었는데 신규 서비스 개발을 하던 지난 21년 10월 ~ 22년 1월엔 백엔드에서 보내준 데이터를 class를 이용해 Model로 먼저 정의하고, 그 후에 UI개발에 들어가면서 위의 문제점들이 어느정도 해소됨을 느꼈다. 팀에서 이런 부분을 위해서 도입을 한 것인지는 잘 모르겠지만… 개인적으로 느끼기엔 그랬다. 물론 반드시 class만을 사용해야만 위의 문제를 해결할 수 있다고 생각하지는 않는다. 단지 우리 팀에서 class를 사용했었기에 글을 남긴다.
뭐가 좋았나?
모델을 정의하여 사용할 때 다음과 같은 이점이 있었다.
- 서버로부터 받아온 값과 (필드) 각 필드에 대한, 뷰 로직과는 독립적인 로직을 모아두어 한 곳에서 관리할 수 있다.
- 공통되는 필드, 로직의 경우 재활용할 수 있다.
- 뷰와 분리되어 있기에 테스트가 용이하다.
여기에 추가로, 지난 프로젝트에서는 활용하지 못했고 프로젝트가 다 끝나가서야 깨닫게 된 것이지만 백엔드에서 내려주는 데이터 스키마가 변경되었을 경우 모델만 수정하면 된다는 유지보수의 용이함도 있다. 모델 인스턴스를 만들 때에는 class를 썼지만, 위의 이점들이 꼭 class를 썼기 때문에 가능했다고 할 수는 없다. class가 아닌 일반 객체였어도 가능하지 않았을까?
예시
먼저, 백엔드로부터 받아온 다음과 같은 json 형태의 데이터가 있다.
{
"name": "",
"email": "",
"addressMain": "",
"addressDetail": "",
"submittedAt": ""
}
위의 데이터를 class로 만들어준다. 직접 만들어줄 수도 있지만 class-transformer
를 이용했다.
import { plainToclass } from 'class-transformer';
plainToclass(Model, data);
무엇보다도 class-transformer
의 도움을 받으면 3중으로 nested된 구조여도 엄청나게 쉽게 class 인스턴스로 만들 수 있다. 또한, class-validator
를 이용하면 데코레이터를 통해 손쉽게 유효성을 검증할 수도 있다. 이건 나중에 테스트 코드를 작성할 때에도 엄청나게 용이했다.
또 하나의 장점은 프로젝트에서 두 라이브러리를 백엔드에서도 쓰고 있기 때문에, 백엔드와 싱크를 맞추기 쉽다는 것이다. 어떤 기술적인 문제가 발생했을 때 프엔 파트끼리만 고민하는 것이 아니라 백엔드 파트에게 물어볼 수도 있고, 유효성 검증이 중요한 피쳐에서 싱크를 맞추기 특히 좋았다.
class의 구현은 대강 다음과 같다. 예제 코드에서 타입 인터페이스는 생략했다.
export default class Model {
@IsString() // 유효성 검증을 도와주는 데코레이터.
name?: string;
@IsEmail()
email?: string;
@Length(1, 20)
@IsString()
addressMain?: string;
@Length(1, 20)
@IsString()
addressDetail?: string;
// 날짜 데이터를 다루는 유틸성 class를 하나 만들고, 해당 class로 변환시켜주는 데코레이터를 사용했다.
@Type(() => DateVo)
@Transform(({ value }) => new DateVo(value))
@IsOptional() // 꽤 다양한 옵션의 데코레이터를 제공하는데, 이 데코레이터를 사용하면 해당 필드는 유효성 검증에서 제외된다.
submittedAt?: string;
// class transformer에서 다 지워버리기 때문에 constructor를 비웠다.
constructor() {}
// 필드별로 필요한 로직을 등록한다.
get isSeoulUser() {
if (this._addressMain === 'SEOUL') {
return true;
}
return false;
}
}
아쉬웠던 점
프로젝트 개발 때에는 정신없이 개발하느라 몰랐는데 끝나고 나니 보이는 점들이 있었다.
이 로직이 꼭 모델 안에 있어야 할까?
프로젝트가 오픈되고 이후 일부 피쳐를 리팩토링 하는 과정에서 모델이 너무 많은 일을 하는 것 같다는 생각을 했다. 팀원들과 함께 이 코드가 뭐가 문제지? 하고 파고들어가다보면 그 끝엔 항상 모델이 있었다. 사실은 역할과 책임이 여럿인 만큼 모델도 여럿으로 분리가 되어있어야 하는데 지금은 무조건 도메인 하나당 한 모델로 정리가 되어있다보니 복잡도를 높이는 원인이 되고 있었다.
더 자세히 이야기하면 현재 프로젝트는 Model을 DTO처럼 쓰고있고, 그러면서도 단순히 DTO의 역할만 해주는 것이 아니라 비즈니스로직에 가까운 유틸성 메서드들이 함께 들어있다. 게다가 이 모델은 레이어마다 각각 존재하는 것이 아니라 하나의 모델로 여러 레이어의 DTO 역할을 하고 있다보니 하는 일이 많아 문제가 많다. 가장 대표적인 문제는 A 레이어에서 하는 일과 B 레이어에서 하는 일이 충돌되는데, 모델은 하나라는 것이다.
이에 대해 팀원들과도 이야기를 해보았는데 문제가 된 모델의 경우 코드가 굉장히 방대하여, 레이어마다 DTO를 각각 만들어주면 엄청나게 많은 코드가 생긴다는 걱정점이 대두되었다. 현재 모델을 class로 관리하고 있기 때문에 상속으로 어떻게 해결할 방법이 없을까? 하는 생각은 들지만 아직 생각만 해본 단계라, 잘 모르겠다.
또한 프로젝트가 끝나고 객체지향 쪽으로 잘 알고 계시는 CTO님과 개인적으로 궁금했던 부분들에 대해 이야기를 하다보니 설계했던 모델의 메서드들이 사실은 모델 안에 있으면 안될 것 같다는 생각을 하게 되었다. 예를들어 지금은 각 필드에 관한 로직이고 여러 뷰에서 쓰인다면 모델안에 메서드로 그 로직을 들고 있게 되어있다.
가령, 이런식이다.
/**
* 억 단위
* 1억원 이하는 -로 표시
*/
get investmentVolume(): number | string {
if (this._investmentVolume <= 1) {
return '-';
}
return this._investmentVolume;
}
숫자가 1억원 이하일 때 ’-‘로 표시하고, 그렇지 않으면 숫자 그대로 표현한다. 이 부분이 우리 서비스에서만 사용되는 비즈니스 로직이라고 생각해서 메서드로 달아두었던 건데 사실은 뷰 로직이었다. 숫자를 3자리 씩 ’,‘를 찍는다와 같이 뷰에서 처리해줘야 하는 로직이었던 것이다. 그럼 재사용은? 지금은 모델안에 메서드로 로직이 존재하기 때문에 어떤 뷰에서든 재사용하기 쉽지만, 꼭 모델 안에 있지 않아도 아마도 훅으로 만들거나 유틸 함수로 만들거나 해서 재사용 할 수 있을 것이다.
private, getter 활용
위에서 설명한 구조로 모델을 만들고 뷰에서 이용하다보니, 백엔드에서 데이터 필드의 이름을 변경했을 때 프론트엔드에선 사용하는 모든 곳을 다 찾아다니며 이름을 변경해야 했다.
물론 IDE의 지원을 받아 리팩토링 기능으로 이름을 변경할 수도 있지만 (이 부분에서 vsc보다 웹스톰이 강력하다고 생각해 웹스톰을 요즘 써보고 있는 이유이기도 하다), 완전히 믿을 수는 없는 노릇이었고 실제로도 이걸 원인으로 버그도 자주 있었다.
때문에 프로젝트 막바지에 ‘이런 구조로 만들었더라면…’ 하는 생각이 들었었고, 그 구조는 다음과 같다.
export default class Model {
@IsString()
@Expose({ name: 'name' })
private _name?: string;
@IsEmail()
@Expose({ name: 'email' })
private _email?: string;
@Length(1, 20)
@IsString()
@Expose({ name: 'addressMain' })
private _addressMain?: string;
@Length(1, 20)
@IsString()
@Expose({ name: 'addressDetail' })
private _addressDetail?: string;
@Type(() => DateVo)
@Transform(({ value }) => new DateVo(value))
@IsOptional()
@Expose({ name: 'submittedAt' })
private _submittedAt?: string;
constructor() {}
get name() {
return this._name;
}
get isSeoulUser() {
if (this.addressMain === 'SEOUL') {
return true;
}
return false;
}
}
백엔드에서 넘어오는 필드를 모두 private로 감추고, getter를 거쳐 뷰에서 접근 가능하게 하는 것이다. 이러면 나중에 name
이 realName
으로 바뀌어도, class만 수정하면 된다.
그러나 만약 뷰에서 쓰이는 이름과 api 명세에서의 이름이 달라 혼란이 생길 것이 우려되어 뷰에서도 model.name
이 아니라 model.realName
으로 수정하고 싶다면, 그 땐 다시 IDE의 힘을 빌려 리팩토링 해야할 것 같다…🤔 다른 좋은 방법이 있을까요? 🥺
어쨌거나 이렇게 하기 위해 @Expose
데코레이터를 사용했다.
기본적으로 class-transformer
를 이용하여 매핑을 할 때에는 필드 이름이 같아야 한다. 따라서 위와 같이 private임을 나타내기 위해 prefix로 _ 를 붙이거나, 백엔드와 프엔의 컨벤션이 달라 snack_case와 camelCase 사이를 변환해야 하는 경우엔 @Expose
데코레이터를 사용하면 된다. 물론 다 달아주어야 한다는 것이 귀찮긴 하지만…ㅎㅎ.. private으로 감추고 getter로만 접근하게 하려면 어쩔 수 없는 듯 하다.
인스턴스를 풀었다가 만들었다가, 왔다갔다 하는 불편함
리덕스 스토어에 저장시 인스턴스는 저장할 수 없다. 때문에 매번 classToPlain
으로 class를 json객체로 풀어서 넣어주고, 리덕스에서 나온 데이터는 다시 class로 만들어주어야 했다. 그 뿐인가? json으로 필요로 하는 곳인데 classToPlain
을 쓸 수 없는 상황에서는 대비하기 위해 toJson
이라는 메서드를 모든 모델 안에 넣어주어야 했다. 또, react의 state로 데이터를 두고 유저 인터랙션에 의해 업데이트 하기 위해서는 class를 json으로 변환해서 state에 넣어주어야 했다.
이게 너무 번거롭고, 왜 이렇게 해야 하는지 스스로도 납득이 잘 되지 않았다. 나중에는 급기야 ‘음… 왜 class로 만들어야 하는거지? 어차피 다시 풀건데…’ 하는 생각도 들었다가 다시 정신 차리고 그러는 헤프닝도 있었다 ㅋㅋㅋ
이와 관련해서는 최근 개발바닥 유튜브 채널에서 프론트엔드에서의 class 활용 및 더 나아가 oop 적용에 대한 영상에 달린 댓글에서도 확인할 수 있었다.
여러모로 많은 사람들이 고민하고 있는 지점인 것 같다. 결국엔 꼭 class여야만 할까? 그냥 function 안에서 객체를 리턴하게 하는 것만으로는 대체가 불가능할까? 타입스크립트로 인터페이스를 선언하면 겉보기에는 똑같이 dot notation이고 타입지원도 되니 별 차이도 없을 거 같은데..? 라는 생각도 들었다.
실제 프로덕트 적용 후 알게된 것들
위에서 이야기한 private, getter 활용
를 22년 4월, 실제 프로덕트에 적용해보았다.
과정에서 새롭게 알게된 점은 @Expose
데코레이터를 달면 기본값이 안 들어간다는 것이다.. 그래서 getter 안에서 기본값 처리를 해주었어야 했는데 어차피 다 private으로 숨기고 getter로 접근해야만 하는 게 의도된 부분이긴 하니까 크게 어긋나진 않았는데 뭔가 아쉽긴 하다.