Post

(Spring) MVC Layered Architecture - DTO와 Domain Model을 분리해야 하는 이유

그림1

God Class에 대한 용어 정리

god class는, 꼭 크기가 커야만(가지고 있는 필드가 많아야만) god class인 것은 아닙니다. 여러 layer에 걸쳐 사용되고 있다면, 또는 2개 이상의 책임을 가지고 있다면, 그 클래스를 사용(의존)하고 있는 클래스가 그 만큼 많다는 의미이고, 이는 곧 god class (또는 god class 유망주)입니다.

god class는 크게 2가지 유형으로 나누어 볼 수 있습니다.

  1. (왼쪽) domain 책임은 제대로 나눴으나 여러 layer에 걸쳐 사용되는 경우 = 유형 1
  2. (오른쪽) domain 책임을 제대로 나누지 못해 1개 이상의 책임을 가지는 경우 = 유형 2
  3. 유형 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
54
/**
 * 여러 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을 망치게 될 가능성이 적다는 것입니다.
      • 분리해야만 하는 경우 예시)

관점 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 조차 표현과 도메인 간의 불일치가 있을 때만 만들 가치가 있다 ‘ )
      • 개발은 혼자 하는 것이 아니기 때문에, 팀원들의 심리적 허들을 낮추는 것도 중요합니다.
    • 하지만 구성원들 모두가 ‘필요한 경우 즉시 분리한다’는 원칙을 이해하고 있어야 하고, 코드리뷰 시 이 것이 잘 지켜지고 있는지 신경써서 검증해야 합니다.
  • external DTO와 domain model은 대상이 외부 서버인 경우 항상 분리합니다.
    • 외부 서버 API인 경우, 변수명 불일치(용어 불일치, 축약어 사용, snake_case 등) 및 타입 불일치 때문에 어차피 분리해야만 하는 케이스가 대부분입니다.
      • 외부 서버와의 통신이라면, 굳이 분리 하지 않아도 되는 케이스여도, presentation DTO와 같은 사유로 일관적으로 분리해주는 것이 좋아보입니다.
    • 내부 서버 API인 경우(같은 팀 내에서 같은 코드베이스를 공유하는 MSA 서버 간 요청), 변수명이나 타입이 불일치 할 가능성이 없습니다. 이런 경우 굳이 분리 할 필요는 없습니다.

이러한 원칙은 ‘필요한 순간에 dto 분리가 제대로 이행되기 어려우므로, 원칙으로 제한한다 ’는 전제 하에 출발한 것이므로, 팀 내 dto 분리 필요성에 대한 이해도, 숙련도, 거부감 등 다양한 요소를 고려해서 각자 상황에 맞는 원칙과 방법을 택해야 합니다.

어떻게 분리하나요?

  • DTO가 필요한 이유는 Domain Model을 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}
  ㄴ ...

하지만 이미 시스템이 위와 같은 구조로 되어 있지 않은 경우가 많은데, 그렇다면 무리해서 바꿀 필요는 없어보입니다.

절충안

1
2
3
4
5
6
7
model
   ㄴ {domain}
   ㄴ presentationdto
   ㄴ persistencedto
   ㄴ externaldto
   ㄴ {domain2}
   ㄴ ...

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라고 생각하는 것이 더 적합합니다.
This post is licensed under CC BY 4.0 by the author.