DTO와 VO 역할
오늘의 주제는 dto와 vo에 대한 이야기입니다.
"기본타입에 대한 강박관념" primitive obsession
우선 원시값에 대한 이야기로 시작하려고 합니다. 아마 현우님도 알고 계실 것 같아요!
원시값 Primitive Value 원시값의 정의는 다음과 같다고 합니다.
자바스크립트 등 프로그래밍에서 원시 값(Primitive Value)이란 객체가 아니며 메서드나 속성을 가지지 않는 가장 기본이 되는 데이터입니다.자바스크립트 기준 원시 값의 종류는 문자열(string), 숫자(number), 큰 정수(bigint), 불리언(boolean), undefined, 심벌(symbol), null의 7가지가 있습니다 오늘은 이들 중 최악의 문제아 문자열(string)에 대한 이야기입니다. 다른 친구들과 다르게 문자열은 굉장히 넓은 범위와 제약이 없는 자료형입니다. 크기의 제약도 없고 영문, 한글, 특수문자, 숫자 등등 여러 형태를 스트링에 담을 수 있습니다.
그래서 email도 string이고 mobile도 string 타입입니다. 하지만 중요한 점은 모든 string이 email 혹은 mobile이 될 수 없다는 점입니다. 집합론으로 따지면 email, mobile은 string의 부분집합이고(밴다이어 그램을 그려보시면 바로 이해하실거에요!) 이로 인해서 생기는 문제와 유지보수성에 대해 이어가볼게요.
Request Body feat(JSON) 서버에서 외부로부터 데이터를 받아올때 데이터를 Request Body에 담고 Content-Type 헤더를 대부분 application/json으로 받아오게 되는데요. json에 별도의 많은 타입이나 클래스를 지정해서 보낼 수 없기때문에 이런 외부에서 받아오는 데이터는 순수한 원시 값이 전혀 이상하지 않습니다. 이를 표현하는 계약이 dto 객체의 역할이 됩니다. 그래서 dto객체에 email 혹은 mobile 필드가 string 타입일 수 있습니다. 반대로 데이터를 DB에 저장할 때 역시 컬럼이 varchar 혹은 text 형태이고 이를 string으로 서버에서 다루는 부분 역시 이상하지 않습니다.
하지만 서비스 레이어의 코드를 보면 email, mobile은 명백하게 일반적인 string은 아닙니다. 만약 email, mobile이 일반적인 string과 같은 범위라면 하기 코드는 필요없을 것 같습니다.
email = dto.email.trim().toLowerCase() mobile = dto.mobile.replaceAll('-', '') VO (value object / 값 객체) 이를 해결하기 위해 등장한 개념이 VO(값 객체)라는 것입니다. VO는 이름 그대로 단순히 값을 표현하기 위한 객체입니다. 코드로 보시면 바로 이해 가능 합니다.
//전화번호를 표현하기 위한 값객체 export class MobileVo { //전화번호를 담고 있을 변수 //외부에서 맘대로 수정못함 -> 캡슐화 private readonly _value: string;
//[개인습관] 다중 생성자를 지원하지 않아서 저는 습관적으로 생성자를 private로 그리고 다중 생성자를 static 형태로 작성합니다.! private constructor(value: string) { this.validate(value); this._value = this.normalize(value); }
// 전달받은 값의 검증 책임! private validate(value: string) { //regex 010-\d{4}-\d{4} 테스트 포맷 만족 못할 시 Error Throw //전체 서비스에서 mobile 데이터 검증 책임은 여기 한줄! }
private normalize(value: string): string { //replaceAll('-') 제거작업 전체 서비스에서 mobile 형태의 포맷을 결정하는 책임은 여기 한줄! return normalizedValue; }
//값을 꺼내가기 위한 getter, setter는 존재하지 않기 때문에 값의 변환 걱정은 없음 get value(): string { return this._value; }
//static 생성자 static of(mobile: string) { return new MobileVo(mobile); } } 사실 이번 pr에 하고싶은 말은 하나입니다. 너무 자유로운 우리의 망나니 string은 적절한 시점에 어떤 형태로 제약을 해야 한다.
어디서 그 책임을 가져갈 것인가?
서비스레이어에서 처리하기 장점: 읽을 코드가 줄어든다. 개발속도가 빠르다. 머리 아픈 고민 안해도 된다. 단점: 사용자 핸드폰번호 업데이트 기능이 추가 될 시 누군가 똑같이 replaceAll을 실수하면 DB에 여러 포맷의 데이터가 저장되기 시작한다.
VO에서 처리하기 장점: 어떤 값의 제약과 검증의 책임이 딱 1개의 객체에 집중된다. 유지보수 시 제약에 변경이 생기면 VO코드만 수정하면 된다. 서비스 로직은 서비스 로직에 집중한다. 단점: 실제 값을 꺼내려면 mobileVo.value 같은 형태로 꺼내써야한다. 코드베이스에 객체들과 파일들이 늘어나기 시작한다.
VO를 쓴다면 책임이라는 것이 나뉘기 시작합니다. 재밌는 점은 책임이라는 질문에서 여러 생각들이 파생됩니다.
dto는 어디까지의 책임을 가질 것인가? 외부와의 계약을 표현하기 위한 객체라면 타입을 string으로 둘 것인가? 내부적으로 VO로 변환할 것인가? Controller의 책임은 무엇인가? 외부에서 받은 데이터를 서비스레이어에 전달 하려면 dto -> vo 변환 책임은 컨트롤러 레이어가 처리할 것인가? Service의 책임은 무엇인가? 파라매터로 vo를 받았지만 repository 레이어에 vo를 전달 할 것인가? string으로 꺼내서 전달 할 것인가? 저 같은 경우는 상기 질문들이 생기기 시작하는데요. 이는 정답이 없는 경우가 많습니다. 각각의 장단이 존재하는 영역이구요. 현우님도 현우님만의 생각을 정리해가시면 좋겠습니다.
이번 PR에서 반드시 수정되어야 하는 문제가 아님을 명백히 밝힙니다!
한줄요약 도메인 규칙이 있는 string은 단순 문자열로 흩어두지 말고, VO로 의미·검증·정규화 책임을 한곳에 모을 수 있습니다.