Post

(Spring) MVC Layered Architecture - DTO 전달/변환/파라미터 설계

(Spring) MVC Layered Architecture - DTO 전달/변환/파라미터 설계

그림1

Controller -> Service 호출 시, Service의 메서드 파라미터 설계

1
2
3
4
5
6
7
8
9
10
@PostMapping("/some-path")
public ResponseEntity<...> foo(@RequestBody @Valid FooControllerRequest request) {
    varService.doSomething(request.getGroupId(), request.getUserId()); ...
}

/* foo API에 변경이 발생했다 */
@DeleteMapping("/some-path/groups/{groupId}/users/{userId}")
public ResponseEntity<...> foo(@PathVariable String groupId, @PathVariable String userId) {
    varService.doSomething(groupId, userId); ...
}
  • Controller가 변경되었지만 Service 메서드 파라미터는 그대로. => Service는 전혀 변경 안해도 된다.
  • 만약 varService.doSomething(FooControllerRequest) 이었다면, doSomething 내부에서 파라미터 사용하는 부분을 수정해주어야 한다. (변경 부담이 크지 않을지라도, 가능하면 의존성은 줄이는 것이 낫다)

파라미터가 2-3개 정도라면 이렇게 풀어서 넘기면 되니 문제가 없다.

하지만 파라미터가 5개 이상, 더 많아진다면 어떻게 해야할까?

layer 분리와 그에 따른 DTO 분리는 필요하지만, 예를 들어 presentation DTO를 반드시 presentation layer에서만 사용 할 필요는 없습니다.

  • presentation DTO가 presentaion layer 바깥으로 나가지 못하게 엄격하게 제한하는 것은 DTO를 목적에 맞지 않게 사용할 가능성을 차단 할 수는 있습니다만, 오히려 시스템의 유연성을 떨어트리고 불필요한 매핑 작업을 초래합니다.
  • presentation DTO와 Domain Model이 잘 분리되어 있는 시스템 이라면, presentation DTO가 service layer로 전달되더라도 변경 부담이 크지 않습니다.
    • 중요한 것은 어느 layer로 전달되느냐 보다, 목적에 맞게 사용하느냐입니다.
    • 그래서 사용할 layer를 한정하지는 않되, 패키지 정도는 꼭 나눠주는 것이 좋아보입니다.
  • 하지만 그렇다고 presentation DTO가 business layer를 넘어 persistence layer 까지 도달하는 것도 좋아보이지 않습니다.
  • 적당한 수준을 그림으로 정리하면 아래와 같습니다.

객체 별 이동 가능 범위(영역)와 변환(화살표)

사례 1 ) 외부 데이터를 받아 별다른 작업 없이 그대로 외부 API 요청하는 경우

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
FooControllerRequest {
  @swagger
  @NotEmpty
  String clientIP
  @swagger
  boolean mobile
  ... 기타 8 필드 ...
}

FooRequest {  // 굳이 필요한가?
  String clientIP
  boolean mobile
  ... 기타 8 필드 ...
}

FooExternalRequest {
  /* 클라이언트에서 받아야 되는 파라미터 */
  @JsonProperty(clnt_ip)
  String clientIP
  @JsonProperty(mobile_yn)
  String mobile // Y or N
  ... 기타 8 필드 ...

  /* 서버에서 세팅해야 하는 파라미터 */
  String serverCode (비즈니스에서는 의미가 없고 단순히 외부 요청 파라미터에 포함해야만 하는)
}
1
2
presentation DTO -> external DTO (실용주의)
presentation DTO -> VO -> external DTO (엄격한 layering)
  • DTO를 분리하는 이유를 생각해보면, ‘수평 방향의 책임을 나눠 god class를 예방하자’ 인데, 위와 같은 경우 FooRequest가 단순히 ‘Business layer에서 사용되는 용도의 객체’ 이외의 다른 의미나 책임을 가지지 못한다. 즉 VO가 어느 한쪽으로 혼합되는 나쁜 케이스가 아니라 VO 자체가 필요가 없는 케이스다.

  • 이런 경우에도 VO를 만들면 구조적 통일성을 지킬 수는 있겠지만, 그 보다는 불필요한 Mapping 작업에서 오는 피로도가 더 클 것으로 보인다. 이런 케이스는 실용주의적으로 접근하는게 낫다.
  • presentation DTO와 external DTO가 패키지로 잘 나뉘어져 있다면, VO가 필요해지는 시점에 눈치채고 VO를 분리 할 수 있을 것으로 보인다.
    • 보통은 service 호출처가 Controller 하나라서 분리 하지 않는게 편하지만, 같은 service layer 내에서도 호출되어야 한다면 VO 분리를 고려해볼만 하다.
    • 파라미터를 넘기기 위해 service layer 내에서 FooControllerRequest를 만드는 것은 애매할 수 있기 때문.

사례 2 ) SearchCondition

  • [컨트롤러 검색 조건 파라미터 받아서 ➞ DB에서 꺼내서 ➞ Domain Model 반환] 하는 케이스를 생각해보자.
  • selectOne이라면 보통 pk로 검색하고, 조건이 추가로 있다고 해도 많지 않아서 파라미터를 쪼개서 전달해도 되며, find* 메서드의 개수도 부담되지 않는 수준이다.
  • 반면, selectMany의 경우 조건이 많아진다. 예를 들어 파라미터가 5개 이상이라면, 필요한 findAllBy* 메서드의 개수는 \[{}_5 \mathrm{ C }_1 + {}_5 \mathrm{ C }_2 + … + {}_5 \mathrm{ C }_5 = 2^5 - 1\]

  • 이런 상황 때문에 보통 파라미터 조건을 if로 처리하는 select 쿼리 1개를 범용적으로 사용하게 되고, 이 때 파라미터로 넘겨 받을 검색 조건을 명시용 클래스가 필요하다. (SearchCondition)
  • 참고) JPA 에서도 SearchCondition 받아서 QueryDSL 사용하는 경우 있다. (link ) (또는 Criteria)
  • SearchCondition 객체는 DTO가 아니라 VO로 보는 것이 적절하다.
    • persistence 저장 구조가 변경된다고 검색 조건 자체가 바뀌지는 않는다. 쿼리는 변경될 수 있어도 “어떤 속성으로 찾을 것인가”는 도메인 모델의 속성에 의존적인 정보다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Controller(searchRequest) {
    service(searchRequest.param1, searchRequest.param2)
}

Service(param1, param2) {
    searchCondition = param1 + param2 + getTheOthers()
    repository(searchCondition)
}

/* 위와 동일한 개념으로 가려면 */
Controller(searchRequest) {
    service(searchRequest)
}

Service(searchRequest) {
    searchCondition = searchRequest + getTheOthers()
    repository(searchCondition)
}

searchCondition 전달/변환 예시


타입별 presentation DTO / domain model 설계 사례

글고 presentation DTO / domain model 나눠야 되는거 사례 추가하자.

검증파라미터 받을 때, 공통 필드가 있고 신홍보, 홍보확인서 이런 타입에 따라서 받아야 되는 필드가 차이가 나는데

이럴 때 domain model은 신홍보, 홍보확인서에 따라서 무조건 분리되어 있는게 맞고.

그러면 공통 필드는 상위 클래스에서 가지고 있는거고. 이런 hierarch를 가지게끔 하는게 맞고. (객체지향이자나…)

그래야 공통 로직을 부모 클래스를 참조해서 처리 할 수 있으니까.

상속을 안쓰면 공통 필드에 대한 로직도 다 따로따로 만들어져야 해서… 쫌 그렇다.

(공통 필드는 컴포지션으로 처리하는 방법도 있긴 함 ㅇㅇ)

아무튼 도메인 모델을 god class로 쓰는거는 꼭 피해야 한다. 유지보수하기 매우 어렵다.

1
2
3
4
5
6
7
8
9
10
11
12
13
홍보1요청VO
{
    홍보1 단독 필드들
    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
    공통 필드 객체
}

홍보2요청VO
{
    홍보2 단독 필드들
    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
    공통 필드 객체
}

반면 presentation DTO는? map으로 받아서 objectMapper 변환 하는 방법도 있긴 한데… 이러면 스웨거를 못타고.

그래서 이거는 god class로 쓰는 경우가 많이 보인다.

그래도 상관은 없고. domain으로 넘기면서 변환만 잘 해준다면…

이런 형태가 좋을 것 같은데?

1
2
3
4
5
6
{
    공통필드,
    신홍보단독필드?,
    홍보1단독필드?,
    홍보2단독필드?,
}

근데 이런 구조의 문제점은, 홍보1,2에서만 쓰이는거를 공통필드에 넣어야 하는가? 의 문제점이 있다.

방법들

  1. 만약 공통필드에 넣게 되면, 공통필드 처리 로직 안에, if (신홍보제외) 라는 로직이 들어가줘야 하기 때문에… 이건 반드시 피해야 한다. (단점 if 분기)
  2. 홍보1,2에 각각 추가하고, 해당 필드 처리 로직도 홍보1,2 로직에서 각각 다룬다. (단점 로직 중복)
  3. 차라리 아예 데이터 구조를 다르게 간다. (단점 swagger)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// domain model 구조와 유사

홍보1요청DTO
{
    홍보1 단독 필드들
    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
    공통 필드 객체
}

홍보2요청DTO
{
    홍보2 단독 필드들
    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
    공통 필드 객체
}

이렇게 되면 데이터를 VO로 묶어주고 컴포지션을 썼기 때문에 필드, 로직 중복이 없어지나

type 별로 다른 request를 받아야 해서 swagger에서 req spec 확인하는게 난감해진다. (일단 map으로 받아서 object mapping 해야하나? 라는 고민.)

이 문제는 이렇게 해결 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
    신홍보요청DTO?,
    홍보1요청DTO? : 
	    {
			    홍보1 단독 필드들
			    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
			    공통 필드 객체
			},
	  홍보2요청DTO? :
		  {
			    홍보2 단독 필드들
			    홍보1,2 겹치는 필드 객체(주제에 맞는 네이밍)
			    공통 필드 객체
			}
}

이런 식으로, 받으면 swagger에서 표시안 되는 문제도 해결 가능하고

어떤 타입에서 어떤 요청을 해야 하는지도 명확하고

로직이나 필드 중복도 없다.

단점이 있다고 한다면, 요청을 보내는 쪽에서 전체 타입에서 필요한 공통 필드가 무엇인지 판단하려면 직접 식별해야 한다는 것.

근데 굳이… presentation 까지 이렇게 깔끔하게 갈 필요는 없구…

받는 쪽은 그냥 보내는 쪽이 편한 대로 해서 받은 다음에 domain 쪽으로 넘길 때 mapping 하는 방법 사용해도 충분해보인다.

DTO-Model 변환 책임

Controller<>Service 구조에서 presentation DTO-Model 변환 책임은?

 ProsCons
Controller에서presentation DTO는 UI가 달라지면 변경될 수 있는 부분이므로 controller에 두는게 맞다. e.g., web 용 DTO와 native GUI 용 DTOdomain model을 controller에서 만들고, controller로 반환하게 되니 controller에서 domain model에 접근이 가능하다. controller에서 DTO 변환 이외의 다른 작업을 수행할 여지가 있다. (실수로)
Service에서service input/output이 애초에 DTO이면, controller에서 domain model에 접근할 여지가 없다. controller가 domain model을 조작하는 실수를 막을 수 있다.service layer에서 presentation DTO에 대해 알고 있어야 한다.
  • 애초에, SearchCondition 계열이나, presentation DTO -> external DTO로 바로 매핑해야 하는 케이스는 presentation DTO가 service layer 파라미터로 전달 되는 것이 자연스럽다.
  • 따라서 변환에 대한 책임을 강제하기 보다는, 상황에 맞게 유연하게 선택하는 것이 좋아보인다.

Service<>DAO(Repository) 구조에서 Model-persistence DTO 변환 책임은?

  • 가능하면 DAO(Repository)에서 한다. (시스템 내에서 데이터를 DTO로 다루는 구간은 최소한으로 유지하는 것이 좋다.)

Model <> DTO 변환은 별도의 Data Mapper 생성 도구를 이용하는 것이 좋다.

  • DTO<>Domain Model 변환 메서드를 직접 작성하게 되면, 1. 매핑 작업이 너무 많아 개발 시점에도 비효율적이고, 2. 필드/추가 삭제 시 매핑 함수가 곧 변경 포인트가 되어 유지보수하기 나쁘다.
  • DataMapper와 MapStruct 같은 Data Mapper 자동 생성 도구를 이용하는 것이 좋다.

이 이상의 너무 세부적인 원칙은 별로 의미가 없다.

원칙이 세부적일 수록, 잘 들어맞지 않는 케이스가 많아진다. 일반화 할 수 있는 몇 가지 중요한 원칙을 기반으로, 세부적인 원칙은 각자가 처한 상황과 시스템에 맞게 세워야 한다.

This post is licensed under CC BY 4.0 by the author.