Order Domain, Service, Repository(+ 도메일 모델 패턴)

KMS·2022년 4월 14일
0

SpringBoot + JPA

목록 보기
4/14

Domain 개발

Order 엔티티

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

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id; //PK

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member; //FK => 그러므로, Member와 Order의 연관 관계에서 Order를 주인으로 설정

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "order", cascade = CascadeType.ALL)
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문상태: [ORDER, CANCEL]

    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    /**
     * ==생성 메서드==
     * 생성자 대신 생성 메서드를 사용하는 이유는 정적 팩토리 메서드 패턴을 만족시키기 위함입니다.
     */
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        //OrderItem 생성 -> createOrderItem() 호출 -> Order 생성 -> createOrder() 호출
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());

        return order;
    }

    /**
     * ==비즈니스 로직==
     */
    // =주문 취소=
    public void cancelOrder() {
        //이미 배달 완료이면 취소가 불가능
        if (this.getDelivery().getStatus() == DeliveryStatus.COMPLETE) {
            throw new IllegalStateException("이미 배송이 완료된 상품은 취소가 불가능");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : this.getOrderItems()) {
            //주문한 상품의 주문을 취소하면, 취소한 만큼 재고 수량을 변화 시켜줘야됨
            orderItem.cancel();
        }
    }

    /**
     * ==조회 로직==
     */
    // =전체 주문 가격 조회=
    public int getTotalPrice() {
//        int totalPrice = 0;
//        for (OrderItem orderItem : this.getOrderItems()) {
//            totalPrice += orderItem.calculateTotalPrice();
//        }
//        return totalPrice;

        return this.getOrderItems().stream()
                .mapToInt((o) -> {
                    return o.calculateTotalPrice();
                }).sum();

    }
}

OrderItem 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice; //주문 가격
    private int count; //주문 수량


    /**
     * 생성 메서드
     * 해당 메서드를 통해서 필요한 엔티티들을 set 해줌
     */
    static public OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice); //상품에 대한 할인 등이 적용 될 수도 있기 때문에 item.getPrice() 대신 orderPrice를 인자로 받아서 set
        orderItem.setCount(count);

        item.decreaseStock(count);

        return orderItem;
    }

    /**
     * 비즈니스 로직
     */
    public void cancel() {
        //주문 수량만큼 상품의 재고 수량을 추가
        this.getItem().increaseStock(this.getCount());
    }

    public int calculateTotalPrice() {
        return this.getOrderPrice()*this.getCount();
    }
    /**
     * 직접 필드에 접근(ex: orderPrice*count)를 하지 않고, Getter로 필드를 접근하는 이유는 프록시 객체를 이용 했을때, 프록시 객체에서는 필드의 값이 null이 반환 됩니다.
     * 그러므로, Getter를 사용해서 프록시 객체가 아닌 원본 객체에서의 필드 값들을 가죠오게 합니다.
     */
}

1. 비즈니스 로직을 각 도메일(엔티티)에서 구현하는 도메인 모델 패턴으로 개발했습니다.(https://martinfowler.com/eaaCatalog/domainModel.html)
(vs. 트랜잭션 스크립트 패턴 https://martinfowler.com/eaaCatalog/transactionScript.html)
2. 생성 메서드를 이용해서 속성들을 Set 해줍니다.
(vs.

Order order = new Order();
order.setItem(item)
...

)
생성 메서드를 이용함으로써, Order 엔티티에 변화가 있을때, Order를 생성하는 부분은 생성 메서드만 업데이트 해주면 Order 생성하는 모든 부분들은 코드를 고치지 않아도 됩니다. 반면, 생성 메서드를 사용하지 않으면, Order 엔티티에 변화가 있을때 Order를 생성하는 모든 코드를 찾아서 고쳐야되기 때문에 번거롭습니다.
3. 각 엔티티의 속성 값을 바로 접근하지 않고, Getter로 접근합니다. 왜냐하면, 프록시 객체의 경우, 엔티티의 속성 값을 접근 시 NULL 값을 반환하기 때문에, 원본 객체의 값을 가져오기 위해서 Getter를 사용해야 되기 때문입니다.

OrderRepository

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }

}

OrderService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    //Order
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        //배송 정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());
        delivery.setStatus(DeliveryStatus.READY);

        //주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장
        orderRepository.save(order);
        /**
         * Order에서 orderItems, delivery는 cascade = CascadeType.ALL로 설정했기 때문에,
         * orderItem이나 delivery를 따로 repository를 만들어서 persist() 해주지 않아도 연관 관계의 order가 persis() 되면,
         * 같이 연관되어 있는 orderItem과 delivery도 persist() 됩니다
         * Cascade를 당하는 엔티티가 하나의 엔티티만 참조를 할때 사용합니다.
         */

        return order.getId();
    }

    //Cancel
    @Transactional
    public void cancel(Long orderId) {
        Order order = orderRepository.findOne(orderId);
        order.cancelOrder();
        /**
         * cancelOrder를 통해 주문을 취소
         * 엔티티의 속성 값을 바꾸면 Dirty Checking 을 통해 변경된 값을 감지
         * 값의 변경이 감지되면 자동으로 DB에 update 쿼리문을 실행합니다
         * => JPA를 사용하면 값의 변경이 있을때마다 추가로 update 쿼리문을 개발자가 실행 할 필요가 없어집니다
         */
    }



}

1. Cascade를 이용해서 Order만 persist해도, 연관된 Delivery와 orderItems도 자동으로 persist 해줍니다. (Cascade에 대한 자세한 내용은 JPA Basic 시리즈에서 Cascade 포스팅 참고)
2. 엔티티의 값을 변경해주면, JPA는 Dirty Checking을 통해 값이 변경된 것을 감지합니다. 값이 변경된 것을 감지하면, 자동으로 update 쿼리문을 실행 시켜서 DB의 값을 변경된 값으로 업데이트 해줍니다. (Dirty Checking은 JPA Basics에서 다뤘습니다)

OrderServiceTest

@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired
    EntityManager em;

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void 상품주문() {
        //given
        Member member = createMember();
        Book book = createBook();

        //when
        int orderCnt = 2;
        Long orderId = orderService.order(member.getId(), book.getId(), orderCnt);

        //then
        Order findOrder = orderRepository.findOne(orderId);

        Assertions.assertEquals(OrderStatus.ORDER, findOrder.getStatus(), "상품 주문시 상태는 ORDER");
        Assertions.assertEquals(1, findOrder.getOrderItems().size(), "주문한 상품 종류 수가 정확해야 한다.");
        Assertions.assertEquals(10000 * 2, findOrder.getTotalPrice(), "주문 가격은 가격 * 수량이다.");
        Assertions.assertEquals(8, book.getQuantity(), "주문 수량만큼 재고가 줄어야 한다.");

    }



    @Test
    void 주문취소() {
        //given
        Member member = createMember();
        Book book = createBook();

        int orderCnt = 2;

        Long orderId = orderService.order(member.getId(), book.getId(), orderCnt);

        //when
        Assertions.assertEquals(8, book.getQuantity(), "주문 취소 전에는 수량이 8");
        orderService.cancel(orderId);

        //then
        Order findOrder = orderRepository.findOne(orderId);
        Assertions.assertEquals(OrderStatus.CANCEL, findOrder.getStatus(), "주문 취소시 상태는 CANCEL");
        Assertions.assertEquals(10, book.getQuantity(), "주문 취소시 수량은 10");
    }

    @Test
    void 재고수량초과() {
        //given
        Member member = createMember();
        Book book = createBook();

        //when
        int orderCnt = 11;

        //then
        Assertions.assertThrows(NotEnoughStockException.class, () -> {
            orderService.order(member.getId(), book.getId(), orderCnt);
            // OrderService.order() -> OrderItem.createOrderItem() -> Item.decreaseStock() -> NotEnoughStockException
        });
    }

    private Book createBook() {
        Book book = new Book();
        book.setName("JPA");
        book.setPrice(10000);
        book.setQuantity(10);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setUsername("MemberA");
        member.setAddress(new Address("seoul", "blvd", "12345"));
        em.persist(member);
        return member;
    }

}

재고 수량 초과 테스트:
OrderService.order() 호출 -> OrderItem.createOrderItem() 호출 -> Item.decreaseStock() 호출 -> 주문 수량이 재고를 초과하는지 확인 -> 주문 수량이 재고량을 초과하기 때문에 자체 개발한 NotEnoughStockException을 반환함

profile
Student at Sejong University Department of Software

0개의 댓글