과정을 즐기자

도메인과 DB 엔티티를 분리하는 이유 본문

아키텍쳐

도메인과 DB 엔티티를 분리하는 이유

320Hwany 2024. 2. 12. 17:00

그동안 도메인과 엔티티를 분리하는 것에 대한 장점이 크게 납득이 되지 않았습니다.

하지만 개인 프로젝트에서 여러 가지 테스트를 하며 고민해보고 인턴을 하며 회사 코드에 적용을 해보았는데

이것이 꽤 유의미한 작업이 될 것이라고 생각했습니다.

 

저는 DDD도 모르고 헥사고날 아키텍쳐도 모릅니다.

그냥 요구사항에 대한 구현을 더 잘하기 위해 도메인과 DB 엔티티를 분리하는 작업을 해보겠습니다.

엔티티와 Dto를 분리 

먼저 엔티티와 Dto 분리에 대해 이야기 해보겠습니다. 그동안 엔티티와 Dto를 분리하는 작업은 많이 해왔습니다.

 

요청과 응답에서 엔티티를 직접 사용하면 안되는 이유

이번 글에서는 요청과 응답에서 엔티티를 직접 사용하면 안되는 이유에 대해서 작성해보려고 합니다. 일단.. 너무 기계적으로 dto를 사용했기 때문에 왜 dto가 필요한지를 생각해보려고 합니다. @

320hwany.tistory.com

이전 글에서 정리한적이 있는데 간단하게 정리하면 엔티티와 Dto를 분리하여 엔티티 스펙을 노출하지 않으며

UI가 변한다고 엔티티 스펙이 변하지 않도록 즉, UI에 의존적인 개발을 피할 수 있게 됩니다.

도메인과 DB 엔티티를 분리 

도메인과 DB 엔티티를 분리하는 작업도 마찬가지의 이유라고 생각합니다.

비즈니스 로직을 처리하는 도메인DB 접근을 위한 DB 엔티티를 분리한다면 DB에 저장하는 데이터가

변경된다고 해서 도메인이 직접적으로 영향을 받지 않도록 즉, DB에 의존적인 개발을 피할 수 있게 됩니다.

분리하는 구체적인 기준

비즈니스 로직을 처리하는 것이 도메인이고 DB 접근을 위해 사용하는 것이 DB 엔티티라고 했을 때

이들의 역할을 나눌 수 있는 구체적인 기준은 무엇일까요?

 

제가 여러가지 테스트를 해보았을 때 가장 구현이 편하고 기준이 명확하다고 생각한 것은 PK의 유무입니다.

일반적으로 PK는 엔티티의 식별자로써 비즈니스 로직과 관련없는 대체키를 사용합니다.

 

DB 엔티티는 PK를 식별자로 가진 DB 접근과 관련된 로직을 담당하고 도메인은 PK와 관련없이

비즈니스 로직을 담당하게 할 수 있습니다.

PK와 관련된 로직이라면 DB에서 조회하고나 삽입, 삭제, 수정 등의 로직이 있습니다.

이러한 로직은 DB 엔티티가 담당하고 그외 나머지 비즈니스 로직들은 도메인이 담당하도록 분리한다는 것입니다.

직접 구현을 해보자

한 가지 예시를 바탕으로 위에서 설명한 개념들을 더 자세히 설명 해보겠습니다.

요구 사항은 다음과 같습니다.

 

핵심 요구 사항 

- 회원이 상품을 주문한다.

 

세부 요구 사항 

1. 주문시 상품의 할인 적용 여부에 따라 가격을 계산한다. 

2. 회원이 가진 돈이 주문 가격보다 많은지/적은지 확인한다.

3. 주문 상품의 수가 재고보다 많은지/적은지 확인한다.

4. 주문이 가능하면 회원의 돈, 상품 재고를 차감하고 주문을 완료한다.

 

여기서 도메인이 담당해야 할 부분과 DB 엔티티가 담당해야 할 부분을 분리 해보겠습니다.

 

도메인이 담당해야 할 부분

- 상품의 가격, 할인 적용 여부로 할인 가격을 계산한다.

- 회원이 가진 돈, 주문 가격으로 주문이 가능한지 확인한다.

- 주문 상품의 수, 재고 수량으로 부터 주문이 가능한지 확인한다.

 

DB 엔티티가 담당해야 할 부분

- 상품의 가격, 할인 적용 여부를 DB로부터 조회한다.

- 회원이 가진 돈을 DB로부터 조회한다.

- 상품 재고를 DB로부터 조회한다.

- 회원의 돈, 상품 재고를 DB에서 차감한다.

 

위의 요구사항을 구현한 OrderCreator 코드는 아래와 같습니다.

OrderCreator

@Service
public class OrderCreator {

    private final OrderFinder orderFinder;
    private final ItemFinder itemFinder;
    private final MemberFinder memberFinder;

    public OrderCreator(final OrderFinder orderFinder,
                        final ItemFinder itemFinder,
                        final MemberFinder memberFinder) {
        this.orderFinder = orderFinder;
        this.itemFinder = itemFinder;
        this.memberFinder = memberFinder;
    }

    @Transactional
    public void completeOrder(final long memberId, final long orderId) {
        MemberJpaEntity memberJpaEntity =
                memberFinder.getByIdWithPessimisticLock(memberId);
        OrderJpaEntity orderJpaEntity = updateOrderStatus(orderId);
        ItemJpaEntity itemJpaEntity = 
                itemFinder.getByIdWithPessimisticLock(orderJpaEntity.getItemId());

        Order order = toDomain(itemJpaEntity, orderJpaEntity);
        calculatePrice(order, memberJpaEntity, itemJpaEntity);
    }

    private OrderJpaEntity updateOrderStatus(final long orderId) {
        OrderJpaEntity orderJpaEntity = orderFinder.getById(orderId);
        orderJpaEntity.updateOrderStatus(ORDER_COMPLETE);
        return orderJpaEntity;
    }

    private Order toDomain(final ItemJpaEntity itemJpaEntity,
                           final OrderJpaEntity orderJpaEntity) {
        Item item = Item.toDomain(itemJpaEntity);
        return Order.toDomain(item, orderJpaEntity);
    }

    private void calculatePrice(final Order order, final MemberJpaEntity memberJpaEntity,
                                final ItemJpaEntity itemJpaEntity) {
        long orderPrice = order.calculatePrice(memberJpaEntity.getMoney(), LocalDate.now());

        memberJpaEntity.subtractOrderPrice(orderPrice);
        itemJpaEntity.subtractTotalQuantity(order.itemQuantity());
    }
}

 

위 코드에서는 DB 엔티티를 조회하고 수정하는 코드가 주로 보입니다.

회원, 상품, 주문 엔티티를 DB로부터 가져오고 회원이 가진 돈, 재고 수량을 차감하는 것입니다.

 

그렇다면 할인 가격을 계산하고 주문이 가능한지를 판단하는 로직은 어디에 있을까요?

Service 레이어에서 보이는 도메인과 관련된 코드는 아래와 같은 코드 한 부분입니다.

long orderPrice = order.calculatePrice(memberJpaEntity.getMoney(), LocalDate.now());

 

외부에서 보았을 때는 "회원의 돈, 현재 날짜를 입력 받아 주문 가격을 계산한다" 이정도의 의미로 파악할 수 있습니다.

내부에서 어떤 식으로 돌아가는지는 Service 계층에서의 관심사가 아닙니다.

Service 계층에서는 Order 도메인에게 요청을 해서 계산한 주문 가격 정보를 받아오기만 하면 되는 것입니다.

Order

@Builder
public record Order(
        Item item,
        long itemQuantity,
        OrderStatus orderStatus
) {

    public static Order toDomain(final Item item, final OrderJpaEntity orderJpaEntity) {
        return Order.builder()
                .item(item)
                .itemQuantity(orderJpaEntity.getItemQuantity())
                .orderStatus(orderJpaEntity.getOrderStatus())
                .build();
    }

    public long calculatePrice(final long money, final LocalDate now) {
        validateQuantity();
        long itemPrice = item.applyDiscount(now);
        long orderPrice = itemPrice * itemQuantity;
        validateMoney(money, orderPrice);

        return orderPrice;
    }

    private void validateQuantity() {
        if (item.totalQuantity() < itemQuantity) {
            throw new IllegalStateException("재고 수량이 부족합니다.");
        }
    }

    private void validateMoney(final long money, final long totalPrice) {
        if (money < totalPrice) {
            throw new IllegalArgumentException("금액이 부족합니다.");
        }
    }
}

Item

@Builder
public record Item(
        String itemName,
        long itemPrice,
        long discountPrice,
        long totalQuantity,
        LocalDate discountDate
) {

    public static Item toDomain(final ItemJpaEntity itemJpaEntity) {
        return Item.builder()
                .itemName(itemJpaEntity.getItemName())
                .itemPrice(itemJpaEntity.getItemPrice())
                .discountPrice(itemJpaEntity.getDiscountPrice())
                .totalQuantity(itemJpaEntity.getTotalQuantity())
                .discountDate(itemJpaEntity.getDiscountDate())
                .build();
    }

    public long applyDiscount(final LocalDate now) {
        if (isDiscount(now)) {
            return itemPrice - discountPrice;
        }

        return itemPrice;
    }

    private boolean isDiscount(final LocalDate now) {
        return discountDate.isAfter(now);
    }
}

분리했을 때의 장단점

직접 이렇게 구현을 해보고 느낀점에 대해 작성 해보겠습니다.

구현을 해보면서 가장 좋았던 점은 JPA와 같은 ORM에 의존하지 않아도 된다는 것이었습니다.

JPA와 같은 ORM을 사용하는 이유 중 하나는 객체와 RDB 사이의 패러다임 불일치 문제를 해결해준다는 것입니다.

 

하지만 트레이드 오프는 항상 있습니다. 패러다임 불일치 문제를 해결하는 대신 객체 매핑을 했을 때의 단점은

분명합니다. 특히 양방향 매핑을 했을 때는 하나의 객체로 묶이는 것과 같이 유연성이 매우 떨어집니다.

객체와 RDB 사이의 불일치 문제를 해결하기 보다는 각각을 따로 두면 해결될 문제라고 생각합니다.

 

1. RDB에 의존하는 DB 엔티티를 두고 연관 관계가 있다면 객체 매핑이 아닌 id 참조를 한다.

2. RDB에 의존하지 않는 도메인에서의 관계는 객체로 참조한다.

 

이렇게 분리를 했을 때의 또 다른 장점은 테스트를 작성하기가 쉬워진다는 점입니다.

도메인에 로직이 많아질 수록 Spring과 JPA에 의존하지 않는 순수한 자바 코드로 테스트 할 수 있는 부분이 많아집니다. 

 

단점이라고 한다면 개발하는 코드가 더 많아져서 개발 시간이 더 길어진다는 것이 있을 수 있지만

장기적으로 보았을 때 유지 보수가 더 쉬운 코드가 되어 오히려 시간이 단축될 수 있다고 생각합니다.

하지만 이 부분은 직접 느끼고 작성한 부분은 아니기 때문에 다른 팀원들과 프로젝트를 해보고 다시 정리 해보겠습니다.

 



예시 코드는 아래 링크를 참고 해주세요

 

GitHub - 320Hwany/Domain-Entity-Separation: 도메인과 DB 엔티티 분리

도메인과 DB 엔티티 분리 . Contribute to 320Hwany/Domain-Entity-Separation development by creating an account on GitHub.

github.com