의존성 리팩터링을 하면서 느낀점(JPA, DDD)

공병주(Chris)·2022년 11월 3일
8
post-thumbnail

우아한테크코스 마지막 리팩토링 미션의 step3를 진행하는 과정과 그 과정 속에서 느낀 점을 정리해보았다.

코드의 상태

@Entity(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_table_id")
    private OrderTable orderTable;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    private LocalDateTime orderedTime;

    @OneToMany(mappedBy = "order")
    private List<OrderLineItem> orderLineItems = new ArrayList<>();

    protected Order() {
    }
    // ...
}

기존에 jdbc로 이루어진 구조를 JPA로 이전 시키면서, 객체 간의 참조를 맺을 수 있었다. 물론 Join을 통해서 jdbc로 구현할 수 있지만, JPA가 더 쉽게 매핑할 수 있겠다고 생각이 들었다. 따라서, 객체 끼리 참조가 가능해졌고 로직을 도메인(엔티티)로 넣을 수 있었다.

1. 무분별한 참조로 인한 복잡한 의존관계

처음엔 아래와 같은 이유로 최대한 도메인으로 로직을 넣고 서비스를 얇게 만드는 것이 이상적이라는 생각에 집중했다.

  1. 비즈니스 로직의 응집도를 높인다.
  2. 최대한 도메인에 로직을 밀어넣으면 Service가 다른 프레임워크로 변경해도 도메인에 담긴 로직을 그대로 사용할 수 있다. 하지만, 과연 Spring에서 다른 framework로의 변경이 일어날까는 의문은 존재한다.
  3. 서비스에 로직이 들어가면 테스트에 DB가 관여하기 때문에 테스트 하기가 POJO에 비해 어려워진다. 테스트가 DB 상태에 종속적이기 때문이다.

따라서, 최대한 service에서 로직을 제거하려고 객체 간의 모든 간접 참조를 직접 참조로 바꾸었다. 따라서, 하나의 도메인이 로딩 될 때 다른 객체와의 참조가 일어나고, 서비스에서 객체를 조회할 일이 없었기 때문에 로직을 최대한 도메인에 위치시킬 수 있었다.

문제점

하지만, 모든 관계를 양방향 직접 참조로 형성해두었기 때문에 복잡한 연관 관계가 형성이 되었다. 복잡한 연관 관계는 결합도를 높히고 객체의 독립적인 관리를 힘들게 한다. 즉 하나의 객체를 수정할 때, 신경 써야 할 포인트들이 많아진다는 것이다.
또한, JPA 환경에서 많은 객체들이 서로를 참조한다면 탐색의 범위가 매우 크다. 이것이 잘 제어한다면 큰 장점이지만, 지연 로딩으로 인한 성능 이슈로 이어질 수도 있다.
그렇다고 모든 것을 간접 참조를 할 수는 없다. 적절한 의존 관계가 객체 지향에는 중요하기 때문이다.

어떤 기준으로?

따라서, 직접 참조와 간접 참조에 대한 기준이 필요했다. 조영호 님의 **우아한테크세미나**를 보면 아래와 같은 규칙을 제시하신다.

  1. 함께 생성되고 함께 삭제되는 객체들을 함께 묶어라

  2. 도메인 제약사항을 공유하는 객체들을 함께 묶어라

  3. 가능하면 분리하라

    여기서 가능하면 분리하라에 초점을 맞췄다. 최대한 느슨하게 만들어두고, 묶어도 괜찮을 것 같은 객체들을 묶으려고 했다. 그 결과 아래와 같이 분리를 하였다.

객체들 간의 양방향 의존성을 대부분 제거하고 단방향으로 의존성이 흐르도록 했다. Menu, MenuProduct 그리고 Order, OrderLineItem의 경우에는 함께 생성되고 함께 삭제된다. 또한 주로 1에서 N을 참조하기 때문에 양방향을 걸어주는데 1 : N 단방향의 단점으로 인해 N : 1 양방향 연관관계를 맺어주었다.

하나의 Aggregate에 하나의 Repository

실질적으로 직접 접근해야 하는 AGGREGATE의 루트에 대해서만 REPOSITORY를 제공하고, 모든 객체 저장과 접근은 REPOSITORY에 위임해서 클라이언트가 모델에 집중하게 하라. -도메인 주도 설계-

이렇게 되면, Menu는 Aggregate 루트엔티티로써 MenuProduct의 생명주기를 관리하고 Order와 OrderLineItem의 경우에도 그렇다. 따라서, cascade 옵션을 ALL을 부여하였고 따라서 MenuProductRepository와 OrderLineItemRepository를 삭제할 수 있었다. DDD 개념을 보면 Aggregate 루트 엔티티에 대해서만 repository를 부여하라고 나와있는데, 생명주기를 함께 하는 것에 대해 객체 참조를 부여하고 생명주기를 부모 엔티티가 관리하도록 하니 자연스럽게 자식 엔티티에 대한 repository는 필요가 없어졌다.

이를 역으로 생각해본다면, 하나의 Aggregate으로 생각한 객체들에 다수의 repository가 존재한다면 이들이 정말 하나의 Aggregate인지 다시 생각해봐도 될 것 같다.

미션에서 OrderTable과 TableGroup이 존재하였다. 처음에는 이들이 하나의 Aggregate이라고 생각했지만, 곰곰히 생각해보면 아닐 수도 있다. 둘의 생성 시점은 명확히 다르다. OrderTable이 먼저 생성되고 TableGroup이 생성된다. 따라서, 하나의 Aggregate에는 하나의 repository가 존재해야 한다는 말에 어긋난다. 따라서, 이를 다른 Aggregate로 정의했다.

패키지 간의 순환 참조

이렇게 객체 간의 의존 관계를 정리하고 나서 패키지의 의존관계를 살펴보았는데, order 패키지와 ordertable 패키지 간의 순환 참조가 발생했다.

아래의 두가지 이유 때문이다.

  • Order는 생성될 때, orderTableId를 통해 존재하는 OrderTable이 존재하는지 확인 해야한다.
  • OrderTable을 empty 상태로 만들 때, 해당 테이블에 존재하는 Order가 있는지 체크하고 있다면 식사가 끝난 상태인지 확인해야한다.

이벤트 방식

중간 패키지를 통해 풀 수 있는지 고민을 해보았지만, order와 ordertable의 잇는 패키지를 생성하기에, 억지로 중간 패키지를 생성하는 것 같고 적절한 개념이 떠오르지 않아 오히려 더 혼란스러울 수 있을거라고 생각했다.

따라서, 이벤트 방식으로 이를 풀어보려했다.

@Service
public class TableService {

    private final ApplicationEventPublisher applicationEventPublisher;
    private final TableRepository tableRepository;

    public TableService(ApplicationEventPublisher applicationEventPublisher,
                        TableRepository tableRepository) {
        this.applicationEventPublisher = applicationEventPublisher;
        this.tableRepository = tableRepository;
    }

    @Transactional
    public OrderTableResponse changeEmpty(Long orderTableId,
                                          OrderTableEmptyChangeRequest orderTableEmptyChangeRequest) {
        OrderTable savedOrderTable = tableRepository.findById(orderTableId)
                .orElseThrow(OrderTableNotFoundException::new);
        applicationEventPublisher.publishEvent(new OrderTableValidateEvent(orderTableId));
        savedOrderTable.setEmpty(orderTableEmptyChangeRequest.isEmpty());
        return new OrderTableResponse(tableRepository.save(savedOrderTable));
    }
}
@Service
public class OrderService {

    private static final String SETTING_EMPTY_DISABLED_BY_ORDER_NOT_COMPLETE_EXCEPTION =
            "조리중이거나 식사중인 테이블의 empty를 변경할 수 없습니다.";

    private final MenuRepository menuRepository;
    private final OrderRepository orderRepository;
    private final OrderLineItemRepository orderLineItemRepository;
    private final TableRepository tableRepository;

    public OrderService(MenuRepository menuRepository, OrderRepository orderRepository,
                        OrderLineItemRepository orderLineItemRepository, TableRepository tableRepository) {
        this.menuRepository = menuRepository;
        this.orderRepository = orderRepository;
        this.orderLineItemRepository = orderLineItemRepository;
        this.tableRepository = tableRepository;
    }

    // ...

    @EventListener
    public void validateOrderStatusIfExists(OrderTableValidateEvent orderTableValidateEvent) {
        Long orderTableId = orderTableValidateEvent.getOrderTableId();
        Optional<Order> order = orderRepository.findByOrderTableId(orderTableId);
        if (order.isPresent() &&
                order.get().isNotCompletionOrderStatus()) {
            throw new TableEmptyDisabledException(SETTING_EMPTY_DISABLED_BY_ORDER_NOT_COMPLETE_EXCEPTION);
        }
    }

order에서는 기존 구조대로 Order를 생성시킬 때 orderTableId를 통해 OrderTable이 존재하는지 확인하도록 하였다. 반면, ordertable 패키지에서 OrderTable을 empty 상태로 만들 때는 orderTableI를 넣어서 위처럼 event를 발행하고 이를 order가 Listen 하여 OrderTable에 대한 Order를 검증하도록 하였다.

인터페이스(수정)

후에 생각해보니 이벤트로 검증하는 방식이 별로라는 판단이 들었다. OrderTable에서 자신의 id를 order 쪽으로 던져서 Order가 예외를 발생시키는게 어색했다. order 쪽에서 OrderTable에 대한 order의 상태를 받아서 OrderTable에서 예외를 발생시키는게 좋다고 생각했다. 기존의 어색함의 증거도 있다. 위의 OrderService를 보면 는데 order라는 도메인에 Table을 empty로 만들지 못한다는 내용의 예외 메시지가 존재한다.

따라서, interface 방식으로 이를 풀었다.

//ordertable 패키지
public interface OrderChecker {

    boolean isNotCompletionOrder(Long orderTableId);
}

위와 같이 ordertable 패키지에 OrderChecker라는 interface를 두고
이를 구현하는 쪽은 아래와 같이 order에 두는 방식으로 해결했다.

public class TableEmptyOrderChecker implements OrderChecker {

    private final OrderRepository orderRepository;

    public TableEmptyOrderChecker(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public boolean isNotCompletionOrder(Long orderTableId) {
        Optional<Order> order = orderRepository.findByOrderTableId(orderTableId);
        return order.isPresent() && order.get().isNotCompletionOrderStatus();
    }
}

최종적으로 아래와 같은 패키지 참조 관계가 만들어졌다.

다시 두꺼워진 Service 로직

@Service
public class MenuService {

    private final MenuRepository menuRepository;
    private final MenuGroupRepository menuGroupRepository;
    private final ProductRepository productRepository;

    public MenuService(MenuRepository menuRepository, MenuGroupRepository menuGroupRepository,
                       ProductRepository productRepository) {
        this.menuRepository = menuRepository;
        this.menuGroupRepository = menuGroupRepository;
        this.productRepository = productRepository;
    }

    @Transactional
    public MenuResponse create(MenuCreateRequest menuCreateRequest) {
        Price menuPrice = new Price(menuCreateRequest.getPrice());
        validateMenuGroup(menuCreateRequest.getMenuGroupId());
        validateMenuPrice(menuCreateRequest.getMenuProducts(), menuPrice);
        List<MenuProduct> menuProducts = toMenuProducts(menuCreateRequest.getMenuProducts());
        Menu menu = new Menu(menuCreateRequest.getName(), menuPrice, menuCreateRequest.getMenuGroupId(), menuProducts);
        return new MenuResponse(menuRepository.save(menu));
    }

    private List<MenuProduct> toMenuProducts(List<MenuProductCreateRequest> menuProductCreateRequests) {
        List<MenuProduct> menuProducts = new ArrayList<>();
        for (MenuProductCreateRequest menuProductResponse : menuProductCreateRequests) {
            Product product = productRepository.findById(menuProductResponse.getProductId())
                    .orElseThrow(ProductNotFoundException::new);
            MenuProduct menuProduct = new MenuProduct(product.getId(), new Quantity(menuProductResponse.getQuantity()));
            menuProducts.add(menuProduct);
        }
        return menuProducts;
    }

    private void validateMenuGroup(Long menuGroupId) {
        if (!menuGroupRepository.existsById(menuGroupId)) {
            throw new MenuGroupNotFoundException();
        }
    }

    private void validateMenuPrice(List<MenuProductCreateRequest> menuProductCreateRequests, Price menuPrice) {
        BigDecimal sum = BigDecimal.ZERO;
        for (MenuProductCreateRequest menuProductCreateRequest : menuProductCreateRequests) {
            Product product = productRepository.findById(menuProductCreateRequest.getProductId())
                    .orElseThrow(MenuNotFoundException::new);
            sum = sum.add(product.getPrice()
                    .multiply(BigDecimal.valueOf(menuProductCreateRequest.getQuantity())));
        }
        if (menuPrice.isHigher(sum)) {
            throw new InvalidMenuPriceException();
        }
    }
    // ...
}

기준에 따라 객체 간의 연관관계를 끊었다. 끊다보니, Service에서 간접 참조 값으로 객체를 조회하고 유효성 검사를 할 일이 많이 생겼고 그에 따라, Service에 로직이 다시 두꺼워졌다. 그래서, 다시 도메인 쪽으로 로직을 넣을 방법을 생각했다. 그렇게 하려면 도메인이 repository를 의존하도록 해야했다. 하지만, 나는 도메인이 repository를 알면 안된다고 생각하고 있었다.

repository에 대한 재정의

도메인이 repository를 참조하면 안된다고 생각했던 이유는 repository는 DB 조회라고 생각했기 때문이다. 하지만, 이프와 베루스와 이야기를 나누면서 repository에 대해 다시 정의를 내릴 수 있었다.

repository는 단순히 엔티티를 관리하는 개념이라고 생각한다. repository는 인터페이스로 분리되기 때문에 java의 컬렉션이어도 되고 db여도 되고 무었이어도 상관없다. 데이터는 DB에 저장된다는 고정 관념이 불러일으킨 착각이었다. 따라서, repository는 엔티티(도메인)의 개념적인 컬렉션이라고 생각한다.

따라서, 도메인은 충분히 repository를 의존해도 된다고 생각한다.

따라서, 아래와 같이 MenuValidator라는 도메인을 만들어서, 여기에 repository를 의존시키고 예외 처리 로직을 넣었다. 그리고 Menu가 생성될 때 이를 사용해서 예외 검증을 했다.

@Component
public class MenuValidator {

    private final ProductRepository productRepository;
    private final MenuGroupRepository menuGroupRepository;

    public MenuValidator(ProductRepository productRepository,
                         MenuGroupRepository menuGroupRepository) {
        this.productRepository = productRepository;
        this.menuGroupRepository = menuGroupRepository;
    }

    public void validateCreation(Price price, Long menuGroupId, List<MenuProduct> menuProducts) {
        validateMenuGroup(menuGroupId);
        validateMenuPrice(price, menuProducts);
    }

    private void validateMenuGroup(Long menuGroupId) {
        if (!menuGroupRepository.existsById(menuGroupId)) {
            throw new MenuGroupNotFoundException();
        }
    }

    private void validateMenuPrice(Price menuPrice, List<MenuProduct> menuProducts) {
        BigDecimal sum = calculateSumOfMenuProduct(menuProducts);
        if (menuPrice.isHigher(sum)) {
            throw new InvalidMenuPriceException();
        }
    }

    private BigDecimal calculateSumOfMenuProduct(List<MenuProduct> menuProducts) {
        BigDecimal sum = BigDecimal.ZERO;
        for (MenuProduct menuProduct : menuProducts) {
            Product product = productRepository.findById(menuProduct.getProductId())
                    .orElseThrow(MenuNotFoundException::new);
            sum = sum.add(product.getPrice()
                    .multiply(BigDecimal.valueOf(menuProduct.getQuantity())));
        }
        return sum;
    }
}

위의 방식으로, service 레이어에 존재하던 유효성 검증 로직을 domain으로 이전할 수 있었다.

Validator에 대한 테스트

class MenuValidatorTest {

    private final ProductRepository productRepository = Mockito.mock(ProductRepository.class);
    private final MenuGroupRepository menuGroupRepository = Mockito.mock(MenuGroupRepository.class);
    private final MenuValidator menuValidator = new MenuValidator(productRepository, menuGroupRepository);

    @DisplayName("MenuGroup이 존재하지 않으면 예외를 발생시킨다.")
    @Test
    void validate_Exception_MenuGroupNotFound() {
        Product product1 = new Product("상품1", new Price(new BigDecimal(5000)));
        Price price = new Price(new BigDecimal(10000));
        MenuProduct menuProduct = new MenuProduct(1L, new Quantity(2L));
        Long notFoundMenuGroupId = 2L;
        when(menuGroupRepository.existsById(notFoundMenuGroupId))
                .thenReturn(false);
        when(productRepository.findById(1L))
                .thenReturn(Optional.of(product1));

        assertThatThrownBy(() -> menuValidator.validateCreation(price, notFoundMenuGroupId, List.of(menuProduct)))
                .isInstanceOf(MenuGroupNotFoundException.class);
    }
    //... test cases
}

Validator에 대한 테스트는 repository를 Mocking을 통해 작성했다. 이유는 아래와 같다.

  1. 위에서 설명했던 것처럼 service에서 로직을 뺐을 때의 장점은 테스트에 DB가 관여되기 때문이었다. 예외 검정을 domain 레이어로 넣고 이를 테스트할 때 DB가 관여되게 하고 싶지 않았다.
    Mocking 하지 않고 java의 collection을 가지는 Fake repository를 만들려고 해봤지만, 현재 repository가 DataJpaRepository를 상속하고 있어서 오버라이드하기가 버거웠다. 리소스가 너무 많이 들어서 불가능하다고 판단했다.
  2. 실제 repository를 사용한다면 SpringBootTest와 같은 방식으로 repository를 AutoWired 받아야 하는데, 이렇게 되면 도메인 테스트가 Spring에 의존적인 구조가 된다.

위의 이유로 repository를 mocking 해서 테스트를 작성했지만, when~thenReturn을 적어주는 것도 일이기 때문에, 더 나은 방법이 있는지 고민해봐야겠다.

끗.

참고자료

https://www.youtube.com/watch?v=dJ5C4qRqAgA&t=4858s
https://www.youtube.com/watch?v=kmUneexSxk0
https://bperhaps.tistory.com/entry/Repository%EC%99%80-Dao%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90
https://medium.com/@SlackBeck/%EC%95%A0%EA%B7%B8%EB%A6%AC%EA%B2%8C%EC%9E%87-%ED%95%98%EB%82%98%EC%97%90-%EB%A6%AC%ED%8C%8C%EC%A7%80%ED%86%A0%EB%A6%AC-%ED%95%98%EB%82%98-f97a69662f63

2개의 댓글

comment-user-thumbnail
2022년 11월 3일

공주 멋지다 ..

1개의 답글