(Spring) MVC Layered Architecture - DTO와 Domain Model을 분리해야 하는 이유
God Class에 대한 용어 정리
god class는, 꼭 크기가 커야만(가지고 있는 필드가 많아야만) god class인 것은 아닙니다. 여러 layer에 걸쳐 사용되고 있다면, 또는 2개 이상의 책임을 가지고 있다면, 그 클래스를 사용(의존)하고 있는 클래스가 그 만큼 많다는 의미이고, 이는 곧 god class (또는 god class 유망주)입니다.
god class는 크게 2가지 유형으로 나누어 볼 수 있습니다.
- (왼쪽) domain 책임은 제대로 나눴으나 여러 layer에 걸쳐 사용되는 경우 = 유형 1
- (오른쪽) domain 책임을 제대로 나누지 못해 1개 이상의 책임을 가지는 경우 = 유형 2
- 유형 1 + 유형 2 = 유형3 이 글에서 주요하게 다루고자 하는 내용은 유형 1 God Class에 해당합니다.
DTO와 Domain Model을 분리해야 하는 이유
Q. 굳이 왜 DTO와 Domain Model을 분리해야 하나요?
A. Domain Model과 DTO를 분리하지 않는다면, 하나의 거대한 범용 class를 여러 layer에 걸쳐(presentation-domain-persistence) 사용하게 됩니다. (Domain Model의 역할과 DTO 역할을 모두 수행하는 god class)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* 여러 layer에 걸쳐 사용되면서 단일 책임 원칙을 위배한 범용 클래스 예시
*/
class Card {
/**
* domain layer에서 필요로 하는 필드
* presentation layer에서도 쓸거라, 요청/응답에 이 필드가 포함되지 않도록 해야 하니 @JsonIgnore
* 여러 layer의 애너테이션이 한데 섞임
*/
private final String name;
@JsonIgnore
private final String number;
/* presentation layer에서 필요로 하는 필드 (request) */
@NotBlank
private final String search1;
private final String search2;
/**
* presentation layer에서 필요로 하는 필드 (response)
* 이 필드는 Domain Model 'Company'에 들어가는게 맞지만, 응답으로 Card가 나가고 있으니 추가
* A 컨트롤러 통해 반환 할 때는 필드가 채워져있고, B 컨트롤러 통해 반환 할 때는 null (민감 정보 제외)
*/
@JsonProperty("cardCompanyAddress")
private final String companyAddress;
/**
* persistence layer에서 필요로 하는 필드
* Card와 Member는 별도의 Domain Model임에도
* 모종의 이유(성능) 때문에 query에서 member와 card 정보를 한 번에 가져와야 한다면
* member(의 일부)를 class Card에 추가해버리는 경우가 잦다.
* (약한 참조를 위한 key를 가지고 있는 정상적인 경우와는 다르므로 주의. 약한 참조 자체는 정상. AggregateReference)
*/
private final String memberId;
private final String memberName;
private final String memberAge;
/* method 생략 */
}
class Member {
private final String id;
private final String name;
private final String age;
private final String email;
/* method 생략 */
}
- 이렇게 구성된 클래스는 생각보다 흔히 보입니다.
- 예제는 조금 극단적인 케이스이기는 하지만, 어떤 필드가 어느 클래스에 위치해야 하는지가 조금만 덜 명확한 상황이어도 잘못된 길로 빠지기 쉽습니다. (클래스 분리하는 것 보다 필드 추가가 간단합니다. 강력한 유혹.)
- persistence에서 필요로 하는 필드의 경우
- 주로 n:1 관계에서, n에 해당하는 클래스에 이런 패턴이 많이 보입니다. (예제의 Card 처럼)
- 반면 1에 해당하는 클래스는 List n 형태의 필드를 가지도록 구성하는 경우가 잦아 이런 문제가 자주 보이지는 않습니다.
Q. god class는 왜 나쁜가요? 그냥 위와 같이 layer에 걸쳐 범용적으로 쓸 수 있는 클래스 하나를 공통으로 쓰는게 더 간단한거 아닌가요?
특정 상황에서만 값이 존재하는 필드
- persistence layer에서 필요로 하는 필드에 대하여, member 정보를 가져오지 않고 card 정보만 가져오는 쿼리가 별도로 존재한다면, Card라는 객체는 어떤 경우에는
member*
필드까지 다 초기화 되고, 어떤 경우에는member*
를 제외한 나머지 필드만 초기화됩니다.- 이렇게 특정 상황에서만 값이 존재하는 필드는 좋지 않습니다.
[리팩터링 2판] 3장 Bad Smells in Code - 3.16 임시 필드 참조
- 시스템에 항상 완벽하게 초기화 된 상태의 객체만 존재하도록 하는 것은 현실적으로 불가능합니다.
- 그러나 [use-case에 따라 일부 필드가 영영 초기화되지 않는 불완전한 상태의 객체]와 [초기화 되기 전에 잠시 불완전한 상태를 가지며 초기화 되면 완전해지는 객체] 그리고 [특정 필드에 값이 없는 경우가개념적으로 타당한객체]는 구분해야 합니다.
초기화 되는 시점과 사용되는 시점이 필드군 마다 제각각이라 유지보수 어려움
- 하나의 클래스를 다용도로 쓰다 보니 ➞ 도대체 이 필드는 언제 초기화 돼서 어디에 쓰이는 것인가?를 파악하기가 곤란하고 ➞ 수정 시 영향 범위 파악과 추적이 어렵다 ➞ 유지보수 비용 증가로 이어집니다.
- 전 layer에 걸쳐 사용되니, 수정 할 때 전 layer를 모두 고려해야 해서 집중력이 분산됩니다. layering의 큰 이점 중 하나는 관심 분리를 통한 작업 대상 layer로의 집중 인데, 이러한 이점을 상쇄해버립니다.
- TC라도 있으면 다행이지만… 없다면 이렇게 커져버린 god class를 분리하는 것 또한 쉬운 일이 아닙니다.
- 또한 이렇게 하나의 객체를 부분 부분 나눠서 조금씩 여러번 초기화 하는 것은 final을 제거하고 싶은 충동을 유발합니다.
- 여전히 final을 쓸 수 있지만, setter로 상태 바꿔 필요한 필드만 추가로 초기화하는게 뭔가 더 깔끔해 보이기 때문입니다.
- 그래서 보통 이런 god class 들은 대부분의 필드가 final이 아닌데, 이는 웬만하면 불변으로 다루자는 컨셉을 따를 수 없게 만듭니다.
유형 1 god class는 (유형 1 + 유형 2 = 유형3) god class를 유발
- 최초에는 적어도 도메인에서 만큼은 2가지 책임을 가지지 않도록 그럭저럭 잘 나뉘어진 상태에서 시작한 class라고 하더라도, 전 layer에 걸쳐 사용되고 있다면 필드가 추가되며 점차 다른 도메인 책임 까지 애매하게 넘보게 됩니다.
- e.g., “member.name이 필요한데 card.memberName이 있으니 Member 말고 이걸 쓰자”
- 이렇게 점차 필드를 땜질하듯 하나 씩 추가하게 되면서 수직, 수평으로 확장(악순환)이 일어납니다.
- 책임이 모호한 컴포넌트 들로 구성된 시스템은 개발자의 혼란을 가중시킵니다. “이 기능은 도대체 어디에 위치시켜야 하는가?”
anemic domain model을 유발
- 로직을 모두 Service에 넣고, Domain Model은 메서드 없이 필드만 가지고 있는 구조
- Domain Model에 대해서 - anemic domain model 참조
여러 layer의 애너테이션이 한데 섞임
- 한 눈에 이해하기 어렵습니다
해결책 - 유형 1 god class를 피하기 위해서는 DTO와 Domain Model을 분리해야 합니다.
- 유지보수가 쉬운 시스템의 기본은, 수정이 발생했을 때 필요한 부분만 수정 해도 문제가 해결되는 시스템 입니다. 즉, 불필요한 수정이 발생하지 않아야 합니다.
- 필요한 부분만 수정 해도 문제가 해결되는 시스템이 되려면, 개발에 사용되는 구성요소(객체지향에서는 Class)들이 적절한 책임과 단위로 나뉘어 있어야 합니다. ➞ 수직 방향의 책임 뿐만 아니라, 수평 방향의 책임도 분리 해야 합니다.
- 객체 지향 5대 원칙 : SOLID - 단일 책임 원칙 등이 이를 뒷받침 합니다.
- 클린 코드의 저자 Robert C. Martin, 리팩터링과 아키텍쳐의 대가 Martin Fowler 도 책임에 따라 작게 쪼개는 설계, layering 기반 관심 분리의 중요성에 대해 얘기하고 있습니다.
변경 대상 클래스 숫자가 늘어나면 나쁜 설계 아닌가요?
- 1개의 god class를 3개의 DTO와 Domain Model로 쪼갰다고 가정합시다. 이 때 3개 클래스 모두 변경을 유발하는 하나의 수정 케이스를 떠올리며, 변경 대상 클래스가 1개에서 3개로 늘어나니 이 것은 유지보수 포인트가 늘어나는 나쁜 설계가 아닌가 하는 생각이 들 수 있습니다. 그러나 중요한 것은, 클래스의 물리적인 개수가 아닙니다.
- 지향점은 수정 시 변경 대상 클래스의 수가 적어지는 설계 가 아니라, 필요한 부분만 집중해서 변경할 수 있게끔 변경의 범위를 제한해주는 설계, 책임 이외의 사유로 도메인 모델을 망치지 않는 설계 입니다.
- [리팩터링 2판] 3장 Bad Smells in Code - 뒤엉킨 변경, 산탄총 수술 참고
그렇다면 항상 DTO와 Domain Model을 분리하는 것이 좋은 설계인가요?
앞서 얘기한 것 처럼 DTO와 Domain Model을 분리하는 것은 유형 1 god class를 예방하는데는 도움이 됩니다. 하지만 ‘Good design is all about trade-offs’ 입니다. 실용주의적 관점에서도 생각해 볼 필요가 있습니다.
관점 1 - 무엇이 더 일반적인가?
- 꽤나 복잡한 애플리케이션 시스템이라면 일반적으로 4가지 케이스가 모두 존재할 것입니다.
- presentaion DTO와 domain model을 분리해야만 하는 케이스
- presentaion DTO와 domain model을 분리하지 않아도 되는 케이스
- persistence DTO와 domain model을 분리해야만 하는 케이스
- persistence DTO와 domain model을 분리하지 않아도 되는 케이스
- 경험적으로 ‘무엇이 더 일반적인가?’를 생각해보는 것이 도움이 됩니다.
- presentation DTO와 domain model은 분리해야 하는 경우가 많습니다.
- 분리하지 않아도 되는 케이스의 대부분은, 나중에 분리가 필요해지는 케이스이므로 결과적으로는 분리하게 됩니다.
- 표현과 도메인 모델은 불일치 가능성이 높습니다.
- persistence DTO와 domain model은 굳이 분리하지 않아도 되는 경우가 많습니다.
- 대부분은 쿼리 join 정도로도, Domain Model에 변형을 가하지 않고 잘 매핑 하도록 만들 수 있습니다.
- 이는 즉 persistence layer의 요구사항 때문에 Domain Model을 망치게 될 가능성이 적다는 것입니다.
- 분리해야만 하는 경우 예시)
- 두 도메인 모델에 필요한 데이터를 한 번에 가져와야 하는 경우 - [MyBatis] 객체 안의 객체 매핑하기 (ResultMap과 DTO)
- 하나의 도메인 모델을 두 테이블에 나누어 저장해야 하는 경우 - Repository layer & DTO
- presentation DTO와 domain model은 분리해야 하는 경우가 많습니다.
관점 2 - god class 유발 가능성 원천 차단
- 사실, 분리가 필요해지는 순간 그 즉시 DTO와 Domain Model을 분리 할 수 있다면, DTO와 Domain Model의 구조가 일치하는 경우 굳이 미리 분리 할 필요는 없습니다.
- 마틴파울러 - ‘presentation DTO 조차 표현과 도메인 간의 불일치가 있을 때만 만들 가치가 있다 ‘
- 그러나 현실적으로 이게 가능한 경우는 많지 않습니다. (모두가 분리가 필요한 시점에 잘 분리했다면 god class는 존재하지 않았을 겁니다)
- 우리는 혼자 개발하는 것이 아니라 다양한 동료와 함께 합니다. 구성원들 모두가 객체지향과 layering의 중요성을 인지하고 있으며 언제든 자신있게 아키텍쳐를 개선하고 수정 할 수 있는 사람들이라고 가정하는 것은 너무 낙관적입니다. 특히, 새로 팀에 합류한 인원은 보통은 구조가 잡혀 있는 대로 따라서 개발하게 됩니다.
- 코드리뷰에서 이런 부분을 보완 할 수 있겠습니다만, 아키텍쳐가 익숙한 기존 인원이라고 하더라도 매번 수정이 발생할 때 마다 이런 부분에 주의를 기울이는 것 보다는, 차라리 처음 코드를 작성하는 사람이 DTO, Domain Model를 분리해서 구조를 잡아두는 것이 DTO(또는 Domain Model)의 오남용(e.g., god class) 예방 차원에서는 더 나을 수도 있습니다.
이러한 관점들을 고려하여 느슨한 원칙을 세운다면 이런 식이 됩니다.
- presentation DTO와 domain model은 항상 분리합니다.
- 어차피 분리해야만 하는 케이스가 대부분 입니다. 일부 꼭 분리하지 않아도 되는 케이스를 분리하지 않음으로써 얻을 수 있는 이익 보다 설계 구조의 일관성 유지, Domain Model을 망칠 가능성에 대한 차단이 더 중요해 보입니다.
- 어떻게 표현할지는 비즈니스와 완전히 분리된 또 다른 영역으로 구분해서 생각하는 것이 좋습니다.
- persistence DTO와 domain model은 필요한 경우에만 분리합니다.
- 분리해야만 하는 케이스가 드물기 때문에,모두 분리하도록 강제한다면 이를 불편하고 불필요한 작업이라 느끼게 될 가능성이 큽니다.( 마틴파울러 - ‘presentation DTO 조차 표현과 도메인 간의 불일치가 있을 때만 만들 가치가 있다 ‘ )
- 개발은 혼자 하는 것이 아니기 때문에, 팀원들의 심리적 허들을 낮추는 것도 중요합니다.
- 하지만 구성원들 모두가 ‘필요한 경우 즉시 분리한다’는 원칙을 이해하고 있어야 하고, 코드리뷰 시 이 것이 잘 지켜지고 있는지 신경써서 검증해야 합니다.
- 분리해야만 하는 케이스가 드물기 때문에,모두 분리하도록 강제한다면 이를 불편하고 불필요한 작업이라 느끼게 될 가능성이 큽니다.( 마틴파울러 - ‘presentation DTO 조차 표현과 도메인 간의 불일치가 있을 때만 만들 가치가 있다 ‘ )
- external DTO와 domain model은 대상이 외부 서버인 경우 항상 분리합니다.
- 외부 서버 API인 경우, 변수명 불일치(용어 불일치, 축약어 사용, snake_case 등) 및 타입 불일치 때문에 어차피 분리해야만 하는 케이스가 대부분입니다.
- 외부 서버와의 통신이라면, 굳이 분리 하지 않아도 되는 케이스여도, presentation DTO와 같은 사유로 일관적으로 분리해주는 것이 좋아보입니다.
- 내부 서버 API인 경우(같은 팀 내에서 같은 코드베이스를 공유하는 MSA 서버 간 요청), 변수명이나 타입이 불일치 할 가능성이 없습니다. 이런 경우 굳이 분리 할 필요는 없습니다.
- 외부 서버 API인 경우, 변수명 불일치(용어 불일치, 축약어 사용, snake_case 등) 및 타입 불일치 때문에 어차피 분리해야만 하는 케이스가 대부분입니다.
[!info] 이러한 원칙은 ‘필요한 순간에 dto 분리가 제대로 이행되기 어려우므로, 원칙으로 제한한다 ’는 전제 하에 출발한 것이므로, 팀 내 dto 분리 필요성에 대한 이해도, 숙련도, 거부감 등 다양한 요소를 고려해서 각자 상황에 맞는 원칙과 방법을 택해야 합니다.
어떻게 분리하나요?
- DTO가 필요한 이유는 Domain Model을 Domain Model 답게 유지하기 위함 이므로, 목적에 부합하도록 만듭니다.
- 상기 <그림1> 참조그림1>
- Domain Model에 대해서 참조
- 보통은 DTO를 layer에 따라 패키지로 묶어서 관리하는 것이 편해보입니다.
- 현재 작업 중인 클래스가 어떤 layer의 DTO인지, Domain Model 인지 파악하기 쉽습니다.
- 현실적인 사유로 DTO를 분리하지 않는다고 하더라도, 패키지 만큼은 꼭 나누는 것이 좋아보입니다.
- layer 분리와 그에 따른 DTO 분리는 필요하지만, 예를 들어 presentation DTO를 반드시 presentation layer에서만 사용 할 수 있도록 제한하는 것은 부작용이 있을 수 있습니다.
package 관리
[마틴파울러] Layering 관련 글 모음 - ‘도메인을 최우선으로 나누고…’ 부분 참고
* 도메인을 너무 작게 나누면 두개 이상의 도메인에서 공통으로 사용하는 클래스를 어디에 위치시킬지 애매해질 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{domain}
ㄴ business
ㄴ service
ㄴ model
ㄴ presentation
ㄴ controller
ㄴ dto
ㄴ external
ㄴ client
ㄴ dto
ㄴ persistence
ㄴ repository
ㄴ dto
{domain2}
ㄴ ...
하지만 이미 시스템이 위와 같은 구조로 되어 있지 않은 경우가 많은데, 그렇다면 무리해서 바꿀 필요는 없어보입니다.
external과 persistence의 분리?
- 외부 API call을 data layer(persistence layer)에 속하는 것으로 보는 경우도 있습니다. (remote storage와 IO하는 ‘자원’이라는 성격에 초점을 맞추는 경우)
- 하지만 두 layer의 관심사가 명확히 다르기 때문에 분리하는 것이 좋아보입니다.
- data layer(persistence)의 관심사는 주로, [tx, DB connector, CRUD, query 관리]
- external layer(client)의 관심사는 주로, [API client, timeout, retry, remote 작업 요청, auth]
Business 외부와 상호작용 할 때 뿐만 아니라, Business 내부에서도 DTO를 사용하는 것은 어떤가요?
- https://martinfowler.com/bliki/LocalDTO.html
- DTO <> Model Mapping 작업에 드는 비용 때문에 불필요한 DTO는 권장하지 않습니다.
- 마틴파울러는 DTO를 [1. layer의 경계를 넘어 데이터를 전달하기 위한 데이터 뭉치, 2. Domain Model과 Mapping하게 되는 대상]으로 바라보고 있습니다.
- 하지만 메서드 파라미터 개수가 너무 많아져서(예를 들어, 8개) 메서드 시그니처가 너무 지저분한 경우 이를 묶어줄 필요가 있습니다. ([리팩터링 2판] 3장 Bad Smells in Code - 3.10 데이터 뭉치)
- 여기서는 용어의 정리가 필요합니다. 이렇게 business layer 내에서 같이 다니는 데이터를 묶어 준 것은, DTO가 아니라 VO라고 생각하는 것이 더 적합합니다.