의존성을 이용해 설계 진화시키기
개요
- 설계의 핵심은 의존성 이다.
- 좋은 설계는 의존성을 어떻게 하는지에 달려있다.
- 의존성에 따라 설계가 바뀐다.
의존성(Dependency)
- 설계란?
- 코드를 어떻게 배치할 것인가에 대한 의사결정
- 어떤 클래스에 어떤 코드? 어떤 패키지에 어떤 코드? 어떤 프로젝트에 어떤 코드?
- 변경에 초점을 맞춘다.
- 같이 변경되는 코드를 같이 모아둔다.
- 변경에 대한 것은 의존성이다.
- 의존성은 점선으로 나타낸다.
- B가 변경될 때 A도 변경될 수 있다.
- 의존성은 변경과 관련있다.
- 의존성은 변경에 의해서 영향받을 가능성을 말한다.
클래스 의존성의 종류
1. 연관관계
- A에서 B로 이동할 수 있다.
- 연관관계는 영구적인 관계를 맺는다.
- 구현방법 중 하나는 객체 참조가 있다.(위 코드)
2. 의존관계
- 파라미터, 리턴 타입에 타입이 나오거나 매서드 안에 타입을 생성한다면 의존관계가 성립된다.
- 의존관계는 일시적으로 관계를 맺는다.
3. 상속관계
- B가 바뀔 때 A가 바뀐다.
- 구현이 변경되면 영향을 받는다.
4. 실체화 관계(인터페이스)
패키지 의존성
- 간단히 import에 다른 객체가 존재하면 의존성을 가진다고 생각할 수 있다.
설계 가이드
양방향 의존성을 피하라.
- A가 바뀔 때 B가 바뀌고 A가 또 바뀐다.
- 이는 한 클래스를 억지로 분리한 것으로 생각할 수 있다.
- 성능 이슈 및 여러 문제점이 발생한다.
다중성이 적은 방향을 선택하라.
- 컬렉션을 인스턴스 변수로 가지지 않도록 한다.
- 성능 이슈 등 다양한 이슈가 생긴다.
의존성이 필요없다면 제거하라.
패키지 사이의 의존성 사이클을 제거하라.
- 양방향이면 같이 바뀌므로 하나의 패키지로 만드는 것이 낫다.
- 패키지 3개가 싸이클이라는 것은 하나의 패키지라고 보는 것이 맞다.
예제 살펴보기
- 배달앱 예제
- 실제 배달의민족 코드와 다름
- 재구성하고 매우 단순화함
주문플로우
- 가게 선택
- 메뉴 선택
- 장바구니담기
- 주문완료
- 설계하는 과정은 영화 예제 영상 참고
- 영상 링크
Domain Concept - 가게 & 메뉴
- 영업여부: 준비중인지 확인
- 최소주문금액: 얼마이상담아야 주문가능
- 1인 세트가 메뉴
- 한 메뉴에 여러 옵션이 있음
- 옵션이 그룹으로 묶여있음
- Specification은 뒤에 따로 설명함
Domain Object - 가게 & 메뉴
Domain Concept - 주문
Domain Object - 주문
- 옆에 있는 그림을 실제 주문하면 오른쪽 상태가 됨
Domain Object - 메뉴 & 주문
메뉴 선택 문제점
- 사용자는 메뉴 선택 후 장바구니에 담는다.
- 배달의민족 앱은 장바구니 정보를 사용자 로컬에 저장한다.(위 예제도 동일)
- 서버에 저장하는 것이 아닌 사용자 로컬에 저장함
- 스마트폰이 바뀌면 장바구니 데이터가 없어진다.
- 만약 사용자가 1인세트를 장바구니에 담은 후, 사장님이 메뉴를 바꾸면 문제가 발생한다.
- 예를 들어 1인세트를 0.5인세트로 바꾸면 사용자 장바구니의 메뉴와 불일치가 발생한다.
- 주문을 했을 때, 실제로 주문 데이터와 사장님 데이터가 같은지 검증해야한다.
주문 Validation
Validation에 대한 협력 설계하기
- 주문하기 메시지가 전송됨
- 가게가 영업중인지, 최소주문금액보다 큰지 확인
- 메뉴 이름과 주문항목 비교
- 옵션그룹 이름과 주문옵션그룹 이름과 비교
- 옵션의 이름과 가격이 같은지 비교
클래스 다이어그램
- 위 그림은 협력을 클래스 다이어그램으로 나타낸 것이다.
- 코드는 위 구조로 구현되어있다.
- 개발이 어려운 점은 런타임에 동적인 구조를 정적인 코드로 담는것이다.
- 변경되는 많은 것들을 정적인 것으로 만들어줘야한다.
- 정적인 무언가를 찾아내야함(관계)
- 관계에는 방향성이 필요하다.
- 협력을 정적인 코드로 나타내야한다.
- 의존성은 소스와 타겟이 필요하다.
- 데이터베이스는 방향성이 없다.
- Foreign Key를 설정해놓으면 양방향으로 움직임
- 코드는 방향성을 결정해야한다.
- 관계의 방향 = 협력의 방향 = 의존성의 방향
- 런타임의 협력 방향에 따라 설계해야 한다.
- 상속과 실체화는 매우 명확하다.
- 연관관계와 의존관계를 설정해야한다.
- 연관관계는 영구적
- 객체참조로 구현 가능
- 데이터의 흐름을 따라갈 수 밖에 없다.
Order
가 Shop
으로 빈번하게 협력한다면 연관관계로 하는 것이 좋다.
- 어떤 객체가 어떤 객체로 빈번하게 가야한다면 연관관계
- 연관관계에는 이유가 필요하다.
- 의존관계는 일시적
- 관계의 종류보다는 방향성이 중요하다.
연관관계 = 탐색가능성
- 연관관계는 어떤 객체가 있을 때, 이 객체를 알면 내가 원하는 다른 객체를 찾아갈수 있다.
- 연관관계의 협력
- 일반적으로 연관관계는 객체참조로 구현한다.
- 연관관계 개념과 객체참조 구현을 구분해야 한다.
- 개념과 이를 구현할 수 있는 방법을 구분해야한다.
- 일대일로 매칭하는 오류를 자주 범한다.
- 연관관계를 구현하는 방법 중 객체참조가 있다.
구현 시작하기
public class Order {
public void place() {
validate();
ordered();
}
private void validate() {
}
private void ordered() {
}
}
- 어떤 객체가 어떤 메시지를 받는다는 것은 public 메서드로 구현된다는 것이다.
- 순서는 메시지를 결정하고 그에 맞는 메서드를 만드는 것이다.
place()
validate()
: 주문이 올바른지
ordered()
: 주문을 변경하는
@Entity
@Table(name="ORDERS")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="ORDER_ID")
private Long id;
@ManyToOne
@JoinColumn(name="SHOP_ID")
private Shop shop;
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="ORDER_ID")
private List<OrderLineItem> orderLineItems = new ArrayList<>();
public void place() {
validate();
ordered();
}
private void validate() {
}
private void ordered() {
}
}
- 주문은 가게와 주문 항목과 연관관계
- 주문은 항상 가게의 주문이고, 항목이 있으므로 영구적으로 연결되어 있어야 한다고 판단함
private void validate() {
if (orderLineItems.isEmpty()) {
throw new IllegalStateException("주문 항목이 비어 있습니다.");
}
if (!shop.isOpen()) {
throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
}
if (!shop.isValidOrderAmount(calculateTotalPrice())) {
throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요.", shop.getMinOrderAmount()));
}
for (OrderLineItem orderLineItem : orderLineItems) {
orderLineItem.validate();
}
}
private void ordered() {
this.orderStatus = OrderStatus.ORDERED;
}
public class OrderLineItem {
public void validate() {
menu.validateOrder(name, convertToOptionGroups());
}
}
- 1) 가게가 영업중인지 확인
- 2) 최소주문금액보다 큰 지 확인
- 3) 주문한 메뉴와 가게 메뉴가 일치하는지 확인
Order
-> OrderLineItem
-> Menu
- 위 유효성 검사 코드는 레이어드 아키텍처에서 도메인 레이어에 속한다.
- 도메인을 구현하려면 데이터베이스에서 조회하는 등 다른 레이어 코드를 구현해야한다.
- 현재 예제 코드는 서비스와 인프라 레이어까지 구현되어 있음
- 문제가 발생하면 예외를 발생하고 종료시킴
- 정상적이면 데이터베이스에 저장함
설계 개선하기
- 설계를 개선하는 방법을 물어볼 때 클래스 하나만 보여주는 경우가 많다.
- 이 때는 말해줄 수 있는게 적다.
- 객체의 협력을 보여주어야한다.
- 설계를 개선하려면 의존성을 봐야한다.
- 조영호님은 반드시 의존성을 그려본다고 한다.
- 찝찝한 부분이 보인다.
- 초반에는 절차적으로 짤 수도 있다.(일단 구현해보는 것도 중요하다.)
- 구현한 후에 설계를 개선하는 것도 방법이다.
의존성 살펴보기
- 문제는 서비스레이어에서
Shop
과 Order
사이에서 사이클이 만들어진다.
Order
는 가게가 열려있는지와 최소주문금액 유효성을 검사해야하므로 가게와 깊게 의존하고 있다.
- 따라서 객체 참조로 구현하였다.
OptionGroupSpecification
과 OptionSpecification
은 OrderGroup
과 OrderOption
에서 데이터를 가져와야하는데, 이 때 사이클이 생성된다.
- 양방향인 경우 shop 패키지를 변경하면 order 패키지도 변경해야한다.
- 양쪽의 패키지를 같이 변경되야한다.
- 이는 잘못된 거임
- 양방향으로 의존하는 것을 보여준다.
- 지금부터 양방향으로 패키지가 의존하는 3가지 케이스가 나온다.
첫 번째 해결방법 - 중간 객체를 이용한 의존성 사이클 끊기
- 위와 같이 구현하면 의존성이 한쪽 방향으로 흐른다.
- 이는 조금 이상해 보일 수 있다.
- 추상화라고 하면 추상 클래스나 인터페이스를 떠올린다.
- 개발쪽에서 추상화는 잘 안변하는 것이다.
OptionGroup
과 Option
은 추상화를 한 것이다.
- DIP의 변형으로 볼 수 있다.
- 이를 통한 장점은 추후에 장바구니에서 같은 유효성 검사를 위의 두 객체를 재사용할 수 있다.
- 장바구니뿐아니라 주문과 다른 로직에서도 사용할 수 있다.
- 의존성을 보며 개선할 부분이 있지 않을까 생각할 수 있는 것이 중요하다.
객체 참조의 문제점
성능 문제 - 어디까지 조회할 것인가?
- 단순히 메모리를 사용할 때는 문제가 되지 않는다.
- ORM을 사용하는 순간 큰 문제점이 생긴다.
- 대표적으로 Lazy 로딩문제가 있다.
- Open Session In View도 연관관계 문제이다.
- 객체 그룹의 조회 경계가 모호하다.
- 모든 객체가 연결되어 있는 것이 근본적인 문제이다.
수정 시 도메인 규칙을 함께 적용할 경계는?
Order
의 상태를 변경할 때 연관된 도메인 규칙을 함께 적용해야하는 객체의 범위는?
- 위 문제는 트랜잭션의 경계는 어디까지인가? 문제로 이어진다.
- 어떤 테이블에서 어떤 테이블까지 하나의 단위로 잠금할 것인가?
- A 객체에 대한 객체참조가 없는 문제가 생길 때 A 객체를 인스턴스 변수로 넣어버리는 방법으로 쉽게 해결하려고 한다.
결제 완료
public class OrderService {
@Transactional
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow(IllegalArgumentException::new);
order.payed();
Delivery delivery = Delivery.started(order);
deliveryRepository.save(delivery);
}
}
public class Order {
public enum OrderStatus { ORDERED, PAYED, DELIVERED }
@Enumerated(EnumType.STRING)
@Column(name="STATUS")
private OrderStatus orderStatus;
public void payed() {
this.orderStatus = OrderStatus.PAYED;
}
}
@Entity
@Table(name="DELIVERIES")
public class Delivery {
enum DeliveryStatus { DELIVERING, DELIVERED }
@OneToOne
@JoinColumn(name="ORDER_ID")
private Order order;
@Enumerated(EnumType.STRING)
@Column(name="STATUS")
private DeliveryStatus deliveryStatus;
public static Delivery started(Order order) {
return new Delivery(order, DeliveryStatus.DELIVERING);
}
}
- 1) 주문이 완료되면 결제를 위해
Order
의 상태를 결제중으로 변경한다.
- 2) 배송을 위해
Delivery
객체를 만들고 배송을 시작한다.
배달 완료
public class OrderService {
@Transactional
public void deliverOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow(IllegalArgumentException::new);
order.delivered();
Delivery delivery = deliveryRepository.findById(orderId).orElseThrow(IllegalArgumentException::new);
delivery.complete();
}
}
public class Order {
public enum OrderStatus { ORDERED, PAYED, DELIVERED }
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
this.shop.billCommissionFee(calculateTotalPrice());
}
}
@Entity
@Table(name="SHOPS")
public class Shop {
@Column(name="COMMISSION_RATE")
private Ratio commissionRate;
@Column(name = "COMMISSION")
private Money commission = Money.ZERO;
public void billCommissionFee(Money price) {
commission = commission.plus(commissionRate.of(price));
}
}
public class Delivery {
public void complete() {
this.deliveryStatus = DeliveryStatus.DELIVERED;
}
}
- 1)
Order
상태를 배달 완료로 변경한다.
- 2) 배달 수수료 추가를
Shop
객체에 요청한다.
- 3)
Delivery
상태를 배달 완료로 변경한다.
트랜잭션 범위
- 이 트랜잭션의 문제점은 객체 3개 모두 변경의 빈도가 다르다.
- Long 트랜잭션으로 묶여있는 것은 새로운 것이 추가될 수록 트랜잭션 주기가 달라진다.
- Long 트랜잭션 안의 lock이 걸린 것들로 큰 문제가 발생할 수 있다.
- 성능이 저하된다.
객체참조가 꼭 필요한가?
- 객체참조의 문제점은 모든 것을 연결시킨다.
- 객체참조는 결합도가 가장 높은 의존성
- 필요한 경우 객체참조는 모두 끊어버려야한다.
객체참조를 통한 탐색(강한 결합도)
Repository를 통한 탐색(약한 결합도)
- 가장 흔히 해결하는 방법 중 하나이다.
- Repository라는 인터페이스를 일반적으로 중구난방으로 만든다.
- Repository는 파라미터로 받은 타입으로 이 객체를 찾을 수 있다는 오퍼레이션을 기본적으로 가지고 있어야한다.
- 하지만 조회가 섞이면서 이가 깨진다.
- 사용자에게 보여줘야할 정보가 많아지면서 복잡해진다.
- 조회 로직때문에 양방향관계가 늘어난다.
어떤 객체들을 묶고 어떤 객체들을 분리할 것인가?
- 간단한 규칙
- 함께 생성되고 삭제되는 객체끼리 묶는다.
- 도메인 제약사항을 공유하는 객체끼리 묶는다.
- 가능하면 분리한다.
- 트랜잭션 안에 있는 것은 같이 변경되는 것들이 있어야 한다.
- 각 객체들의 라이프사이클을 생각해야한다.
- 주문과 배달의 라이프사이클은 완전히 다르므로 독립적으로 묶어야 한다.
- 경계 안의 객체는 참조를 이용해 접근한다.
- FetchType, Cascade 옵션을 준다.
- 경계 밖의 객체는 ID를 이용해 접근한다.
- 객체참조는 객체지향을 설명하는데 많이 사용된다. 간단하고 객체지향을 잘 나타낼 수 있기 때문이다.
- 현업으로 가면 객체참조는 최대한 자제하고 객체를 어떻게 분리하고 합치는지가 중요하다.
- 개념과 현업의 괴리가 있다.
- 객체를 분리한 단위가 트랜잭션 단위가 되고 조회 단위가 된다.
- 같은 경계안에 있는 객체끼리는 한 번에 조회가 되고 Lazy 로딩을 할 수 있다.
- 객체 분리 단위가 Lazy와 Eager의 경계를 구분할 수도 있다.
컴파일에러 1
- 경계밖의 객체를 id로 참조하므로 기존 코드에서 직접 객체를 사용한 부분에서 컴파일 에러가 발생한다.
해결방법1 - 객체를 직접 참조하는 로직을 다른 객체로 옮긴다.
@Component
public class OrderValidator {
public OrderValidator(ShopRepository shopRepository,
MenuRepository menuRepository) {
this.shopRepository = shopRepository;
this.menuRepository = menuRepository;
}
public void validate(Order order) {
validate(order, getShop(order), getMenus(order));
}
void validate(Order order, Shop shop, Map<Long, Menu> menus) {
if (!shop.isOpen()) {
throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
}
if (order.getOrderLineItems().isEmpty()) {
throw new IllegalStateException("주문 항목이 비어 있습니다.");
}
if (!shop.isValidOrderAmount(order.calculateTotalPrice())) {
throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요.", shop.getMinOrderAmount()));
}
for (OrderLineItem item : order.getOrderLineItems()) {
validateOrderLineItem(item, menus.get(item.getMenuId()));
}
}
private void validateOrderLineItem(OrderLineItem item, Menu menu) {
if (!menu.getName().equals(item.getName())) {
throw new IllegalArgumentException("기본 상품이 변경됐습니다.");
}
for(OrderOptionGroup group : item.getGroups()) {
validateOrderOptionGroup(group, menu);
}
}
private void validateOrderOptionGroup(OrderOptionGroup group, Menu menu) {
for(OptionGroupSpecification spec : menu.getOptionGroupSpecs()) {
if (spec.isSatisfiedBy(group.convertToOptionGroup())) {
return;
}
}
throw new IllegalArgumentException("메뉴가 변경됐습니다.");
}
}
@Service
public class OrderService {
private OrderRepository orderRepository;
private OrderValidator orderValidator;
private OrderMapper orderMapper;
@Transactional
public void placeOrder(Cart cart) {
Order order = orderMapper.mapFrom(cart);
order.place(orderValidator);
orderRepository.save(order);
}
}
public class Order {
public void place(OrderValidator orderValidator) {
orderValidator.validate(this);
ordered();
}
}
- 조영호님은 이가 괜찮은 설계라고 생각한다고 함.
- 기존 로직은 유효성 로직을 찾으러 가야하지만 변경된 위 코드는 한 번에 유효성 검사 로직을 볼 수 있다.
- 응집도가 높은 코드는 변경되는 코드가 같이 있는 것이다.
- 기존의 코드는 validation 로직과 주문 처리 로직이 같이 있고 이는 같이 변경되는 코드가 아니다.(응집도가 낮은 코드)
- 때로는 절차지향이 객체지향보다 좋을 때가 있다.
- 절차지향:
OrderValidator()
- 객체 결합도는 높아질 수 있지만 응집도가 낮아지는 경우가 있다.
컴파일 에러 2
- 위 로직은 A의 상태 변경에 따라 B의 상태가 변하는 순차적인 로직으로 구성되어 있다.
- 이렇게 도메인 제약사항으로 도메인 로직이 순차적으로 진행되는 경우가 있다.
- 도메인이 순차적으로 진행될 때 어떻게 끊어야 할까?
해결방법 1 - 절차지향 로직(OrderValidator
와 동일)
public class OrderService {
private OrderDeliveredService orderDeliveredService;
@Transactional
public void deliverOrder(Long orderId) {
orderDeliveredService.deliverOrder(orderId);
}
}
@Component
public class OrderDeliveredService {
public void deliverOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow(IllegalArgumentException::new);
Shop shop = shopRepository.findById(order.getShopId()).orElseThrow(IllegalArgumentException::new);
Delivery delivery = deliveryRepository.findById(orderId).orElseThrow(IllegalArgumentException::new);
order.delivered();
shop.billCommissionFee(order.calculateTotalPrice());
delivery.complete();
}
}
- 간단하게 해결할 수 있는 장점이 있다.
- 기존의 코드는 클래스 2개로 분리되어 있어 비지니스 플로우가 한 눈에 보이지 않는다.
- 설계를 개선한 후에 다시 의존성을 그려보면서 정말로 잘된 설계인지 확인해야한다.
- 설계 이후 또 다시 의존성 사이클이 생성되었다.
인터페이스를 이용해서 의존성을 역전시킨다.
- DIP를 적용한 모습이다.
- 패키지 의존성 싸이클 해결방법
해결방법 2 - 도메인 이벤트(Domain Event) 퍼블리싱
- 전체 예제 코드
- 우아한형제들에서는 도메인 이벤트를 매우 자주 사용한다.(모듈이 잘게 나눠져 있기 때문)
- 1번 방법과 달리 잘게 나누는 방법이다.(결합을 느슨하게 만드는 방법)
- 이벤트를 만들어 발행하고 이벤트 핸들러에서 받아 처리한다.
- 이벤트를 직접 만드는 것이 좋지만, 예제는 간단히 하기 위해 스프링에서 제공하는 추상 클래스를 사용함
- 스프링에서 제공하는 것은 많이 사용하지 않는 방법임
- 이벤트 핸들러는 스프링에서 제공하는 것을 사용하면 된다.
- 여기서도 의존성 사이클이 생긴다.
Billing
이라는 패키지로 분리한다.
- 주문과 정산은 서로 다른 도메인 로직으로 볼 수 있다.
- 패키지를 분리할 때는 도메인에 대한 관점이 바뀔 때가 많다
- 의존성 사이클을 제거하다가 보면 도메인이 새로 만들어져야할 때가 빈번히 있고, 이 때 새로운 패키지로 분리해낸다.
(정리) 패키지 의존성 사이클을 제거하는 3가지 방법
1. 새로운 객체로 변환
2. 의존성 역전
- 의존성을 추상 클래스나 인터페이스로 역전시킨다.
3. 새로운 패키지 추가
- 위 세 가지 방법의 선택은 상황에 따라 다르다.
의존성과 시스템 분리
- 의존성을 관리하다보면 시스템을 쉽게 분리할 수 있다.
- Domain Event를 사용하기 전에는 레이어별로 도메인을 나누었다.
- 도메인 단위로 분리하면 의존성에 문제가 생긴다.
- 의존성을 관리하지 않으면 레이어별로 패키지를 나누고 그에 맞는 도메인로직을 담으면 된다.
- 패키지별로 의존성 관리를 해주면 되므로 간단하다.
- 의존성을 관리하면 패키지를 분리해낼 수 있다.
- 도메인 이벤트 퍼블리싱을 적용한 코드가 이와 같은 구조로 되어있다.(코드 링크)
- 각각은 도메인 이벤트로 협력을 한다.
Order
, Delivery
, Billing
이 비즈니스 로직으로는 한 번에 돌아가지만 분리해낼 수 있다.
- 도메인 이벤트(내부), 인터널 이벤트, 시스템 이벤트(외부) 등 용어가 있다.
- 시스템끼리 비동기적인 메시지를 가지고 통신하는 경우가 많다.
- 특히, 배달의민족이 이렇게 많이 되어 있음
- 동기적으로 하면 시스템이 한 번에 다 터질 수 있어서 위험하다.
결론
- 시스템을 볼 때는 의존성을 봐라.
- 의존성에 따라 시스템을 진화시켜라!
잘 읽었습니다 감사합니당 : )