우아한테크세미나에서 조영호님께서 발표해주신 강의를 정리했습니다.
설계를 개선할때는 Dependency를 한 번 그려보자. 의존 사이클이 생기는지 패키지 또는 클래스의 의존관계에 대한 그림을 한눈에 보자.
레이어(Service, Domain)라는 개념은 자바에선 패키지라는 것으로 표현한다.
Order
는 Shop
에 가게가 열렸는지 확인하기 위한(메세지) 의존관계가 존재한다. 관계가 강하게 때문에 연관관계로 구현을 해놨었다.OptionGroupSpecification
과 OptionSpecification
이 각각OrderOptionGroup
, OrderOption
의 이름과 가격 데이터를 가지고 와 본인 것과 비교한다.따라서 양방향 연관관계가 발생한다. 문제가 발생하는 코드는 아래와 같다.
class OptionGroupSpecification {
public boolean isSatisfiedBy(OrderOptionGroup group) {
...
}
public boolean isSatisfiedBy(OrderOption option) {
...
}
}
class Order {
private void validate() {
if (!shop.isOpen()) { ... }
}
}
위와 같이 3가지의 양방향 연관관계가 발생한다.
위 그림의 중간 객체를 코드로 표현하면 아래와 같다.
class OptionGroup {
public OptionGroup convertToOptionGroup() {
return new OptionGroup(name, ...);
}
}
class Option {
public Option converToOption() {
return new Option(name, ...);
}
}
위와 같은 중간 객체를 사용하게 된다면
class OptionGroupSpecification {
public boolean isSatisfiedBy(OptionGroup group) {
...
}
public boolean isSatisfiedBy(Option option) {
...
}
}
위와 같이 바꿔서 단방향 의존관계를 만들 수 있다.
보통은 의존관계 역전의 원칙(DIP)에 따라 추상적인 인터페이스에 의존하게 되는데 그건 무조건적인게 아니다. 위의 예시는 클래스에 의존한 예시이다.
실 사례에서의 장점이 드러난 부분은
원래 장바구니는 서버에 저장하도록 구현되었는데 서비스가 커짐에 따라 문제가 발생하면서 앱 내부에 저장하도록 변경하였다. 장바구니도 마찬가지로 옵션 그룹, 옵션을 지니고 있는데 검증 로직을 OptionGroup
, Option
을 이용하여 변환 로직을 구현해주었다.
왜냐하면 장바구니의 옵션 그룹과 옵션, 주문에서의 옵션 그룹과 옵션은 동일하기 때문이다.
협력을 위해 필요하지만 두 객체 사이의 결합도가 높아진다.
class Order {
private List<OrderLineItem> orderLineItems;
public void place() {
validate();
ordered();
}
private void validate() {
for (OrderLineItem orderLineItem : orderLineItems) {
orderLineItem.validate();
}
}
}
객체들이 연결되어 있기 때문에 그림과 같이 객체를 통해 전부 연결할 수 있다.
이게 메모리 상에서는 크게 이슈가 없다. 하지만, ORM을 쓰거나 DB에서 직접 매핑을 한다면 큰 문제가 발생할 수 있다. (Lazy Loading issue)
객체 그룹의 조회 경계가 모호하다.
Order
의 상태를 변경할 때 연관된 도메인 규칙을 함께 적용해야하는 객체의 범위는?
만약 Order
을 바꿀때 Shop
, OrderOption
, OrderSpecification
의 상태도 바꿔야 한다면 Order
를 뒤져가면서 객체의 상태를 변경해야 할 것이다.
즉 트랜잭션 경계는 어디까지인지 모호하다.
어떤 테이블에서 어떤 테이블까지 하나의 단위로 잠금(Lock)을 설정할 것인지 생각해야 한다.
이거와 관련된 예제를 한번 살펴보자.
주문이 완료되었다면 결제를 해야하고 배달 완료 처리를 해줘야 한다.
public class OrderService {
@Transactional
public void payOrder(Long orderId) {
Order order = orderRepository.findById(orderId) ...
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 = PAYED;
}
}
@Entity
@Table(name = "DELIVERIES")
public class Delivery {
enum DeliveryStatus { DELIVERING, DELIVERED }
@OneToOne
@JoinColumn(name = "ORDER_ID")
private Order order;
@Enumrated(EnumType.STRING)
@Column(name = "STATUS")
private DeliveryStatus deliveryStatus;
public static Delivery started(Order order) {
return new Delivery(order, DELIVERING);
}
}
public class OrderService {
@Transactional
public void deliverOrder(Long orderId) {
Order order = orderRepository.findById(orderId) ...
order.delivered();
Delivery delivery = deliveryRepository.findById(orderId) ...
delivery.complete();
}
}
public class Order {
public void delivered() {
this.orderStatuus = DELIVERED;
this.shop.billCommissionFee(calculateTotalPrice());
}
}
public class Shop {
private Ratio commissionRate;
private Money commission = Money.ZERO;
public void billCommissionFee(Money price) {
commission = commission.plus(commissionRate.of(price));
}
}
public class Delivery {
public void complete() {
this.deliverStatus = DELIVERED;
this.order.completed();
}
}
admin이나 배치나 다른 로직들이 추가된다면 위의 3가지의 객체가 변경되는 주기가 다르게 된다.
사장님이 가게를 영업 중, 준비 중으로 바꾸는 상황,
관리자가 주문 상태를 변경한다든지,
배달도 상태가 바뀌는 주기가 존재할 것이다.
새로운 요구사항이 추가될 수록 트랜잭션이 적용되는 주기가 달라진다는 것이다.
객체 참조의 문제점은 다시 말하자면 모든 것을 연결한다는 것이다.
어떤 객체라도 접근 가능하다.
어떤 객체라도 함께 수정 가능하다.
객체 참조는 결합도가 가장 높은 의존성이다. 영구적인 결합도이기 때문이다.
따라서 우리는 필요한 경우 객체 참조를 끊어야 한다.
Order
에 Shop
객체를 가지고 있는 것이 아닌 shopId
을 가지고 있게 한다.
Shop
객체가 필요할때는 Order
에서 shopId
를 통해 Shop
객체를 가져온다.
우리는 Repository
를 중구난방으로 만드는 경우가 많다. 실제로 Repository
에 들어갈 인터페이스는 연관관계를 구현할 오퍼레이션이 기본적으로 들어가 있어야 한다. 하지만 이런 경우는 조회 로직이 들어가면서 깨진다.
간단한 규칙
1. 함께 생성되고 함께 삭제되는 객체들을 함께 묶어라
2. 도메인 제약사항을 공유하는 객체들을 함께 묶어라
3. 가능하면 분리하라
본질적으로 객체간의 결합도가 높은게 있고 연결하지 않더라도 Repository
를 통해 탐색 가능한 것들이 있다. 이걸 구분했을 때 가장 중요한 것은 도메인 룰
이다. 어떤 데이터들을 함께 처리해야 하는 것인지, 따로 처리해야 하는 것인지 등을 정해야 한다.
예를 들자면 보통 장바구니와 장바구니 항목들은 같이 생성되야 한다고 생각한다. 하지만 장바구니는 미리 생성해 놓은 것이고 장바구니 항목들은 나중에 추가되거나 삭제되는 것이다. 즉 둘의 라이프 사이클은 다르다. 그리고 장바구니와 장바구니 항목의 공통된 제약사항이 없다면 분리시켜야 한다.
하지만 배달의 민족 서비스 같은 경우는 장바구니와 장바구니 항목이 같이 생성되고 같이 소멸된다. 왜냐하면 장바구니에 동일한 업소의 장바구니 항목만 추가할 수 있기 때문이다. 가게가 바뀌면 장바구니에 항목을 넣을 수 없다. 즉 A라는 가게에는 a, b, c라는 항목이 들어갔다면 A, a, b, c는 동일한 제약사항을 공유하는 것이라고 볼 수 있다.
이런 예시를 드는 이유는 어떤 객체를 어떻게 묶을 것인가?
라는 질문엔 실제로 룰이 없다. 즉 비즈니스 룰에 따라서 이게 결정된다.
위 그림에 따라 경계 안의 객체는 참조를 이용해 접근하게 세팅해주면 된다.
public class Order {
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="ORDER_ID")
private List<OrderLineItem> orderLineItems = new ArrayList<>();
...
}
public class OrderLineItem {
...
}
public class Shop {
...
}
public class Order {
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name="ORDER_ID")
private List<OrderLineItem> orderLineItems = new ArrayList<>();
@Column(name = "SHOP_ID")
private Long shopId;
...
}
public class OrderLineItem {
...
}
ShopRepository를 이용한 연관관계를 구현한다.
Shop shop = shopRepository.findById(order.getShopId());
위 그림의 관계로 트랜잭션 단위, 조회 경계를 결정하면 된다.