Post

External Client class의 param/return 설계 - DomainModel VS DTO

공통 비즈니스 로직을 처리하려면 결과적으로 외부 API 응답(JSON)을 Domain Model로 변환해야 한다.
외부 API에서 받아온 데이터가 공통 비즈니스 로직까지 도달하기 전 거쳐가는 layer는 아래와 같다. 아래 과정 중 어디선가는 Domain Model로의 변환을 수행해 주어야 한다.

1
Business Layer(Service class) - External Layer(Client class) - JSON

JSON -> DTO 매핑과 DTO -> Domain Model 변환을 어디서 처리하는게 좋을까?

Client class에서 도메인 모델을 반환? DTO를 반환?

Client class의 책임은 HTTP API에 대한 java interface(JSON -> DTO)로 한정하는 것이 좋다.
DTO -> Domain Model 변환 및 응답 추상화 책임 까지 넣으면 Client가 너무 비대해진다.

아래와 같이 ClientService를 따로 두는 구조가 유지보수하기 편하고, 더 유연하고, 확장성도 있다.

1
2
3
4
5
6
7
8
9
ClientService {  // DTO->DomainModel 변환 책임
    val dto = callClient()
    return domainModel(dto)
    
}
Client {    // JSON->DTO 매핑 책임
    val dto = callApi(https...)
    return dto
}

결론적으로 추상화는 상위 layer(ClientService)에 맡기고, Client는 DTO를 반환하는게 낫다. (경험 상, 나중에 결국 이렇게 가게 된다.)
Client에서 DTO가 아니라 VO(Domain Model)을 반환하면 아래와 같은 문제들이 생긴다.

a. 과도한 추상화로 인해 잘 들어맞지 않는 부분이 생긴다.

API call이 낭비되는 사례

DTO는 Client 내부에서 요청/응답을 수행하는 국소적인 코드 영역에서만 사용하고, 리턴하기 전에 도메인 모델로 변환을 마쳐 DTO 노출을 최소화 하고 싶을 수도 있다.

1
2
BuyResult XExchangeClient.buy(BuyRequest)
BuyResult YExchangeClient.buy(BuyRequest)

external layer는 얇게 유지하려는 시도는 좋으나, 이렇게 과도한 추상화로 인해 잘 들어맞지 않는 부분이나, call 관점에서 비효율이 생긴다.

e.g., YExchange에서는 BuyResult를 만들기 위해서, 서로 다른 2개의 API call 해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class XExchangeClient {
    fun buy(BuyRequest): BuyResult {
        val dto = webClient.get("/api/buy")
        return BuyResult(dto)
    }
}
class YExchangeClient {
    fun buy(BuyRequest): BuyResult {
        val dto1 = webClient.get("/api/buy")
        val dto2 = webClient.get("/api/order-info")
        return BuyResult(dto1, dto2)
    }
}

어떤 사용처에서는 /api/buy의 결과만 필요한데, 항상 /api/order-info 도 같이 가져와야 하니 쓸데없이 call이 낭비된다.
특히 API 서버에 rate limit이 걸려있다면 이런 call이 상당히 아까울 수 있다.

=> Client DTO를 반환하고, DTO->Domain Model 변환은 ClientService에서 처리하게 하면 문제가 해결된다.

mangling되는 정보가 너무 많은 경우

스펙이 크게 달라서, JSON→Domain Model로 바로 변환하게 되면 mangling 되는 정보가 너무 많아져 좋지 않다. 일단은 JSON → DTO로 받고, DTO → Domain Model로의 변환을 별도로 수행해 주는 것이 낫다.

b. 어차피 Client 자체를 interface화 할 수 있는게 아니면, Client에서 바로 Domain Model을 반환하는게 이점이 크지 않다.

YExchangeClient에서 call을 낭비하더라도 Domain Model을 반환하기로 결정했다고 하자. 장점은 Business Layer로의 빠른 통합인데, 실제 use-case를 생각해보면 사용하는 관점에서 그다지 장점이 없다는 것을 알 수 있다.

b-1. Client class에서 Domain Model(BuyResult)를 반환하는 경우의 use-case

1
2
3
4
5
6
7
8
9
class Service {
  fun buy(exchange, params): BuyResult {
    if (exchange == X) {
      return XExchangeClient.buy(XExchangeBuyRequest(params)) 
    } else if (exchange == Y) {   
      return YExchangeClient.buy(YExchangeBuyRequest(params)) 
    }
  }
}

b-2. Client class에서 DTO를 반환하는 경우의 use-case

(Client에서는 DTO를 반환. DTO → Domain Model 변환은 Client 바깥 영역 (e.g., Service)에서 담당)

1
2
XExchangeClient.buy(XExchangeBuyRequest): XExchangeBuyResponse
YExchangeClient.buy(YExchangeBuyRequest): YExchangeBuyResponse
1
2
3
4
5
6
7
8
9
10
class Service {
    fun buy(exchange, params): BuyResult {
        if (exchange == X) {
            return XExchangeClient.buy(XExchangeBuyRequest(params)).toEntity() 
        } else if (exchange == Y) {
            return YExchangeClient.buy(YExchangeBuyRequest(params)).toEntity() 
        }
    }
}

caller가 사용하는 관점에서는 거의 차이가 없는데, 어차피 ExchangeService에서는 XExchangeClient와 YExchangeClient를 동일하게 다루지 않고 구분해서 다루고 있기 때문이다.
Domain Model 반환의 이점을 제대로 취하려면, 어떤 메서드 1개가 Domain Model을 반환하는 것 만으로는 부족하고, Client 자체가 interface화 되어 다형성을 사용 할 수 있어야 한다.

Client 자체를 interface화 한다는 것은 곧, ExchangeClient의 모든 메서드가 같은 형태의 Domain Model을 반환해야 한다는 말이고, 이는 DTO->Domain Model 변환을 Client에서 처리하겠다는 의미다.
같은 개념의 결과를 반환하는 API 이더라도 제공처 마다 스펙이 크게 다른게 일반적이라(a 참고), DTO->Domain Model 변환을 Client에서 처리하면 로직이 매우 지저분해진다.

=> Client 자체를 interface화 하는 것 보다, Client들의 공통화를 담당하는 ClientService를 따로 두는게 낫다.

b-3. ClientService를 사용하는 경우의 use-case

1
2
3
4
5
class Service {
    fun buy(clientService, params): BuyResult {
        return clientService.buy(params)
    }
}

c. Client class의 책임 관점에서도 client에서는 DTO를 반환하는 것이 심플하다.

1
2
3
4
5
6
7
8
9
class YExchangeClient {
    fun buy(BuyRequest): BuyResult {
        val dto1 = requestBuyApi()
        val dto2 = requestOrderInfoApi()
        return BuyResult(dto1, dto2)
    }
    private fun requestBuyApi()
    private fun requestOrderInfoApi()
}

Client class에서 Domain Model BuyResult를 반환하는 경우, Client 클래스 내에서 메서드의 역할이 2개로 갈리게 되는데,

  • 1번 유형) 단일 API 요청 응답용 private (dto 반환)
  • 2번 유형) API 요청 메서드들을 묶어서 model을 반환 (model 반환)

이렇게 메서드의 타입이 2개로 나뉘어야 한다는 것 자체가 Client의 책임이 너무 많다는 신호일 수 있다.

특히 동적타입언어로 개발된 API 서버와 통신 할 때, 타입이 뒤죽박죽이고 심지어 정적타입언어 DTO로 제대로된 응답 스펙을 표현 할 수 없을 때도 있는데… (Map<String, Any>가 되어야 하는)
이렇게 client에서 처리해야 하는 JSON->DTO 매핑 로직 자체만으로도 Client가 꽤나 복잡해질 수도 있다.

=> 2번 유형 메서드인 DTO->Domain Model 변환은 ClientService라는 별도 클래스에 두어 관심분리 하는게 개발 스트레스를 완화해준다.

d. 외부 API 서버 요청/응답 스펙은 언제든 변경될 위험을 가지고 있다.

현재는 interface화가 가능하고, Domain Model 반환하는게 가능하다 하더라도, 갑자기 어떤 외부 API 요청/응답 스펙이 변경되어서 이를 반영해야 할 때, Client에서 Domain Model을 반환하기 위해 억지로 끼워맞춰야 하는 부분이 너무 많이 생길 수도 있다.

e. 생성자 인터페이스가 business layer에서 사용하기 적절치 않다면, DTO.of를 활용하면 해결된다.

External Client class의 param/return 설계 - flatten VS DTO.of
즉, 단순히 DTO 인터페이스, 생성자 파라미터가 맞지 않는다는 이유로 VO를 만드는 것은 타당성이 떨어진다.

f. external IO module을 분리 하고 싶은 경우, VO를 param/return으로 사용하면 VO도 external module에 들어가야 한다.

도메인 모듈 -> external IO module 방향으로 참조하기 때문에 external IO module에서는 VO를 참조 할 수 없다.
따라서 VO를 param/return으로 사용하려면 VO를 external module로 옮기거나 모듈 계층 구조를 다시 설계해야 하는데 둘 다 좋지 않다.

=> DTO를 param/return으로 사용하면 해결된다.

g. Spring6 @HttpExchange, Retrofit, OpenFeign 같은 선언형 HTTP client를 사용한다면 DTO 반환 할 수 밖에 없다.

  • Client가 인터페이스+애너테이션으로 이루어지기 때문에, 메서드 반환 타입은 DTO가 될 수 밖에 없다.
  • 이 경우 ClientService를 반드시 두는 것이 좋다.

DTO → DomainModel 변환은 ClientService에서

ClientService는 항상 필요한가? 시스템 규모 확장에 따라 아래와 같이 진행된다.

같은 기능의 Client가 2개 이상일 때.

  1. 1개 일 때는 Client로 둔다.
  2. 2개 이상이 되면 ClientService로 추상화한다.

같은 기능의 Client가 2개 이상이라는 것은 Domain Model 공통화가 필요하다는 얘기다.
공통 비즈니스 로직을 처리하려면 결과적으로 외부 API 응답(JSON)을 Domain Model로 변환해야 한다.

같은 기능의 Client가 1개일 때.

비즈니스 복잡도에 따라 가장 적절한 설계가 달라진다. 아래 과정이 시스템 성장에 따라 단계별로 진행된다는 점을 반드시 기억해야 한다.

  1. Client만 둔다. DTO를 그냥 business에서 가져다 쓴다.
  2. DTO로 최대한 처리한다.
  3. DTO로 커버가 안되면 ClientService를 만들고, Client를 delegation한다. (전체 Client DTO 반환 인터페이스 + VO 반환 인터페이스를 둘 다 노출)
  4. VO 반환 메서드가 점차 많아지면, composition으로 전환한다. (DTO 반환 인터페이스 제거, VO 반환 인터페이스만 노출)

추상화 수준은 1->4 진행 될 수록 증가한다.
처음부터 Client의 모든 기능을 ClientService의 메서드로 wrapping(4단계)하게 되면, 이 과정이 중복으로 느껴질 수도 있다.
delegation(3단계)이 이런 중복과 심리적 허들을 낮추는데 큰 도움이 된다.

그림에서 Extends 부분을 delegation으로 변경해서 생각하면된다

기타 ClientService 사례1

그림1

그림2

그림1도 비즈니스 형태에 따라 가능하지만, 그림2 구조로 가는 것이 일반적이다.
그림2 구조로 가면, Pair가 생길 때 마다 BetweenService class를 작성하지 않아도 된다. (각 Pair ArbitService에서 2개의 ClientService를 composition)

공통화를 할거라면, business layer의 중심으로부터 가장 먼쪽에서 부터 순서대로 검토해보는 것이 좋다.

기타 ClientService 사례2

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