계속 커지는 뷰모델 해결사례

Bonjugi·2022년 9월 18일
0

흔히 도메인 모델을 Dto 뷰모델로 반환을 하게 된다.
그런데 뷰모델은 아주 큰 도메인 모델들을 클러스터링 해서 응답 해야 할때가 많다.
그러다 보면 뷰모델은 점점 커지게 된다.

책 마이크로 서비스 패턴의 API 게이트웨이 패턴 에서는 API 를 조합 패턴 이나 CQRS 를 소개 하고 있다.

우리 서비스에서 적합한 방식으로 API 를 조합 패턴 을 선택했다.
그러나 모놀리식에서는 GW 도 없거니와 IPC가 발생하지 않는다.
API 조합 패턴 의 본질은 뷰모델을 조합 하는 과정 이다.
모놀리식에서도 쓰기 좋고 확장과 분리가 용이한 뷰모델을 조합 패턴 을 소개 하고자 한다.

태초의 뷰모델, OrderDto 매핑 소개

태초의 뷰모델인 OrderDto (오더 상세정보) 를 매핑 하는 방식과 문제점을 소개한다.

@Service
public class OrderService() {
	private final OrderRepository orderRepository;
    private final OrderMapper orderMapper;
    
	public List<OrderDto> findOrderDetails() {
		List<Order> orders = orderRepository.findAll();
        return orderMapper.mapToOrderDtos(orders);  // 매핑
    }	
}

mapper 코드는 다음과 같다.
단순히 Dto로 매핑을 해주는 pojo 코드 이다.
흔히 많이 쓰는 매핑 방식이다.

public class OrderMapper() {
	public List<OrderDto> mapToOrderDtos(Collection<Order> orders) {
    	return orders.stream().map(x -> mapToOrderDto(x)).toList();
	}
    public OrderDto mapToOrderDto(Order order) {
    	return new OrderDto() {
        	order.getId(),
            order.getStatus()
            ..
        }
    }
}

뷰모델에 속성이 추가 될 때마다 문제 발생

OrderDto 뷰모델에 Customer 정보를 추가해야 한다면 다음과 같이 고칠수 있다.
Order 에 있는 Customer 를 꺼내면 될 것이다.

    public OrderDto mapToOrderDto(Order order) {
    	return new OrderDto() {
        	order.getId(),
            order.getStatus(),
            mapToCustomerDto(order.getCustomer())  // 추가됨.
        }
    }

여기서 문제는 Customer 가 다른 애그리게잇 일때 발생한다.
Order와 Customer 간에 릴레이션을 주면 아주 쉽게 할수는 있지만 이경우 애그리게잇간의 경계가 없어진다.
또한 선례가 하나 생긴다면 빠른속도로 릴레이션이 불어나게 된다.
Order 같은 핵심 도메인은 모든 도메인과 연관이 있기 때문이다. (Payment, Store, Agent..)
명확하게 Order 의 경계를 만들지 않으면 repository 에서 Order 하나 꺼내 오는데 200개가 넘는 컬럼과 수십개의 테이블 조인 쿼리가 발생 할 것이다.

비단 성능문제 뿐만 아니라, 경계가 모호하면 다음과 같은 복잡도가 발생한다.

  1. Order가 복잡해진다. 위에서 말한것 처럼 다른 하위 애그리게잇을 가지게 된다.
  2. 양방향 릴레이션 이라면 하위 애그리게잇도 복잡 해진다.
  3. 하위 애그리게잇의 변경에 Order 도 취약해 진다.
  4. Order 를 테스트하는데 힘들다. 꼭 필요한 값이 아니라 규모가 큰 애그리게잇을 참조하기 때문

구체적인 예시와 함께 설명하면 좋겠지만 이번 포스팅에선 생략한다.

문제 해결 방법

CQS (CQRS 또는 sql join) 를 적용하거나, 뷰모델 조합패턴 을 적용하여 해결할수 있다.

1. CQS 적용하기

애초에 findOrderDetail 가 Order 를 반환하고 이것을 dto 로 변환하지 않고, 뷰모델을 리턴하게 만들면 된다.
커맨드에 적합한 Order 객체와 OrderDto 를 완전히 다르게 취급 하는 것이다. (Order 를 OrderDto 로 변환하는게 아니다)

@Service
public class OrderService() {
	// ..
	public List<OrderDto> findOrderDetails() {
		// List<OrderDto> orders = orderRepository.find();
        // List<Customer> customers = customerRepository.findCustomers();
        List<OrderDto> orders = orderDetailRepository.findAll();
        return orders;
    }	
}

findOrderDetails 의 구현은 2가지가 있다

  • CQRS
  • SQL join 을 하는방법이 있다.

1-1. CQRS

CQRS 는 커맨드 용애그리게잇과 분리된 뷰모델을 위한 데이터를 유지하는 방식이다.
데이터소스는 이벤트소싱을 통해 다른 서비스에 레플리카를 둬도 되고, 같은 서비스 내에 있어도 된다.
다만 구현 복잡도가 있으므로, 조합방식을 먼저 고민해보는것이 좋다.
검색폼이 복잡 하다던지, 페이지네이션과 소팅 등이 들어가는 등 조합 히가가 까다로운 경우가 있다.
이런 경우에 비로소 CQRS 를 검토 하면 된다.

2-1. SQL join

sql 이 성능도 빠르고 구현도 쉽다.
밑에서 소개할 뷰모델 조합 패턴 이나 cqrs 패턴에 비해 복제 지연에 의한 정합성 차이가 발생 하지도 않는다.

다만 sql join은 아주 강력한 커플링 이다.
때문에 모놀리식을 분리하고자 한다던지, 또는 향후에 라도 계획이 있다면 가장 마지막 으로 고려할 옵션이라고 생각 한다.

CQRS 는 알야 할것도 많고, 실제로 구현이 복잡한 편이다.
sql join 도 커플링이 생기는 단점이 있다.
아래에서 소개할 뷰모델 조합 패턴 을 먼저 고민 해 볼 것을 추천한다.

2. 뷰모델 조합 패턴

msa 에서는 sql join 자체가 불가능 하기 때문에 여러 서비스로부터 받은 값을 합쳐 하나의 뷰모델을 만들기 위한 패턴이 이미 존재한다.
API 조합기 패턴 이라고 하는데, Customer나 Partner 등의 다른 애그리게잇들이 다른 서비스에 있기 때문에 IPC를 통해서 가져와 조합기에서 조합하는 패턴이다.
조합기는 GW 에 있어도 되고, 백엔드에 있어도 된다.
좋은 방법은 아니지만 클라이언트에 있을수도 있다.

클라이언트 조합은 모바일 이라던지, 대역폭이 낮고 레이턴시가 높은 망 외에 있기 때문이다.
이를 해결하기 위해 api gateway 패턴을 고려해야 한다.

모놀리식 에서는 API 조합기 패턴 대신 뷰모델 조합 패턴을 적용 해 볼수 있다.
rest api 응답을 조합하는 대신에 repository 결과를를 조합하는 차이가 있다.
아래는 OrderDto 를 조립하는 mapToOrderDtos 메소드 호출시 인자로 Cusotmers 를 전달 해 주는 코드 이다.

@Service
public class OrderService() {
	// ..
	public List<OrderDto> findOrderDetails() {
		List<Order> orders = orderRepository.findOrderDetails();
        Map<Long, Customer> customers = customerRepository.findCustomers();
        return orderMapper.mapToOrderDtos(orders, customers);
    }	
}

매퍼는 전달받은 customers 를 다음과 같이 order와 조합 할수 있다.

public class OrderMapper() {
	public List<OrderDto> mapToOrderDtos(Collection<Order> orders, Map<Long, Customer> customers) {
    	return orders.stream().map(x -> mapToOrderDto(x, customers.get(x.getId))).toList();
	}
    public OrderDto mapToOrderDto(Order order, Customer customer) {
    	return new OrderDto() {
        	order.getId(),
            order.getStatus(),
            mapToCustomerDto(customer)
        }
    }
}

뷰모델 조합 패턴 결론

뷰모델 조합 패턴 의 핵심은 여기까지만 봐도 된다.

  • api 조합 방식의 패턴을 차용 했다는 점
  • sql join 없음
  • cqrs 없음

각자의 시스템에 맞게 디자인을 수정해서 적용하면 된다.
우리 시스템 에서는 customer 이외에도 partner, agent 같이 속성이 계속적으로 추가 되는것에 취약했다.
mapToOrderDtos 를 쓰고 있는 클라이언트가 매우 많았는데 매번 대응하기 번거로웠다.

	// mapToOrderDtos 호출하는 코드가 20군데 있다면? 20군데를 고쳐써야 한다.
	orderMapper.mapToOrderDtos(orders, customers /*, partner, agent, .. */);

아래 MappingProxy 도입 부분에서 자세히 설명하도록 하겠다.

3. 뷰모델 조합 패턴 디자인 변경 (MappingProxy)

뷰모델 조합 패턴에서 재료가 많아질수록 문제가 발생 되는것을 해결하기 위해 proxy 객체를 추가할수 있다.
오더는 연관된 모델을 매핑하기 위해 Proxy 객체로부터 가져 올수 있다.

public List<OrderDto> mapToOrderDtos(Collection<Order> orders) {
	OrderMappingProxy proxy = OrderMappingProxy.create(orderIds);
	return orders.stream().map(x -> mapToOrderDto(x,proxy)).toList();
}

public OrderDto mapToOrderDto(Order order, OrderMappingProxy proxy) {
	CustomerInfo customer = proxy.getCustomerInfo(order.getId());
	PartnerInfo partner = proxy.getPartnerInfo(order.getId());
	OrderDto.create(
		order.getId(),
		order.getStatus(),
		customer,    // 기존에는 order.getCusotmer() 였다.
		partner
	);
}

프록시 객체는 다음과 같은 구성을 하고 있다.
미리 customerInfo, partnerInfo 를 로드된 상태로 갖고있다.
getCustomerInfo 같은 orderId 를 하나 받으면 map 에서 꺼내 주면 된다.
미리 로드 되어 있기 때문에 1+n 이 발생하지도 않는다.

public class OrderMappingProxy {
	private final Map<Long, CustomerInfo> customerInfoMap;
	private final Map<Long, PartnerInfo> partnerInfoMap;

	public CustomerInfo getCustomerInfo(Long orderId) {
		return custoemrInfoMap.get(orderId);
	}
    
    // 생성자, 팩토리 메소드 ..
}

아래는 프록시 객체를 생성하는 팩토리다.
여러 repository나 도메인 서비스의 협력을 필요로한다.

@Service
public class OrderMappingProxyFactory() {
	private final CustomerRepository customerRepository;
	private final PartnerRepository partnerRepository;

	public OrderMappingProxy create(List<Long> orderIds) {
		return OrderMappingProxy.create(
			cusotmerRepository.findMap(orderIds);
			partnerRepository.findMap(orderIds);
		);
	}
}

결론

뷰모델을 만드는 방법들을 알아봤다.
추천하는 검토 우선순위는 뷰모델 조합패턴 -> CQRS -> sql join 순 이다.
설계적으로 완성도가 높은것은 CQRS 겠지만 구현 복잡도 가 있는 등 각 방법마다 트레이드오프가 있다.

뷰모델 조합 방식은 각 시스템 마다 다를수 있다.
orderMapper.mapToOrderDtos(orders) 보다는 orderDetailRepository.find(orders) 같이 좀더 추상화 한 repository 를 두는 방식이 나을수도 있다.

프록시 객체를 둬서 1+N 이 발생하지 않으면서 조립 할때도 깔끔한 방식으로 리팩토링 할수 있다.
내 경우 mapToOrderDto 를 너무 많은곳에서 쓰고 있어서 변경에 최소화 하기 위한 고민 이었다.
GraphQL 의 resolver 디자인에서 영감을 받았다.

뷰모델 조합방식은 IPC 보다는 비용이 적지만, xxRepository.findMap(orderIds) 가 많아질수록 CompletableFuture 와 같은 병렬로 조회 하는것이 좋다.
전체적으로 본질은 API 조합 패턴 과 다르지 않다.

0개의 댓글