[OOP] 우아한 객체지향 리뷰 (2단계)

청포도봉봉이·2024년 5월 9일
1

우아한 객체지향

목록 보기
2/3

우아한테크세미나에서 조영호님께서 발표해주신 강의를 정리했습니다.

강의 링크

2단계 코드

우아한 객체지향 2단계 리뷰

설계 개선하기

설계를 개선할때는 Dependency를 한 번 그려보자. 의존 사이클이 생기는지 패키지 또는 클래스의 의존관계에 대한 그림을 한눈에 보자.

두 가지 문제

의존성 살펴보기

레이어(Service, Domain)라는 개념은 자바에선 패키지라는 것으로 표현한다.

의존성 사이클

무엇이 문제인가?

  1. OrderShop에 가게가 열렸는지 확인하기 위한(메세지) 의존관계가 존재한다. 관계가 강하게 때문에 연관관계로 구현을 해놨었다.
  2. OptionGroupSpecificationOptionSpecification이 각각
    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();
        }
    }
}

1. 성능 문제 - 어디까지 조회할 것인가?

객체들이 연결되어 있기 때문에 그림과 같이 객체를 통해 전부 연결할 수 있다.

이게 메모리 상에서는 크게 이슈가 없다. 하지만, ORM을 쓰거나 DB에서 직접 매핑을 한다면 큰 문제가 발생할 수 있다. (Lazy Loading issue)

객체 그룹의 조회 경계가 모호하다.

2. 수정 시 도메인 규칙을 함께 적용할 경계는?

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가지의 객체가 변경되는 주기가 다르게 된다.

사장님이 가게를 영업 중, 준비 중으로 바꾸는 상황,
관리자가 주문 상태를 변경한다든지,
배달도 상태가 바뀌는 주기가 존재할 것이다.

새로운 요구사항이 추가될 수록 트랜잭션이 적용되는 주기가 달라진다는 것이다.

트랜잭션 경합으로 인한 성능 저하

그렇다면 객체 참조가 꼭 필요할까?

객체 참조의 문제점은 다시 말하자면 모든 것을 연결한다는 것이다.

어떤 객체라도 접근 가능하다.

어떤 객체라도 함께 수정 가능하다.

객체 참조는 결합도가 가장 높은 의존성이다. 영구적인 결합도이기 때문이다.

따라서 우리는 필요한 경우 객체 참조를 끊어야 한다.

해결하는 방법

Repository를 통한 탐색 (약한 결합도)

OrderShop 객체를 가지고 있는 것이 아닌 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 {
	...
}

경계 밖의 객체는 ID를 이용해 접근

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 {
	...
}

그룹 간에 객체 참조를 통한 연관관계 제거

ID를 이용해서 연관관계 설정

Order에서 Shop을 탐색하고 싶다면?

ShopRepository를 이용한 연관관계를 구현한다.

Shop shop = shopRepository.findById(order.getShopId());

트랜잭션 단위 && 조회 경계

위 그림의 관계로 트랜잭션 단위, 조회 경계를 결정하면 된다.

profile
서버 백엔드 개발자

0개의 댓글