Order, OrderService, OrderRepository 구현 및 테스트

guswls·2023년 2월 16일

JPA 활용 1편

목록 보기
9/9
post-thumbnail

이번 포스팅은 김영한 이사님의 스프링 부트 JPA 활용 1편을 듣고 제작되었습니다.

Order에 대한 구현 기능은 다음과 같다.

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

Order에도 전에 했던 Item과 같이 엔티티 내부에서 해결할 수 있는 핵심 비지니스 로직은 엔티티 내부에서 구현할 예정이다.

1. OrderEntity 추가 구현

1-1. 생성 메서드 추가

먼저 Order를 생성하는 메서드를 구현하고자 한다. Order는 전에 구현한 MemberItem과는 달리 생성될 떄 연관된 엔티티도 같이 지정을 해주어야 하기 때문에 신경써주어야 할 것이 더 많다.

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

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name="member_id")
    private Member member;

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

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name="delivery_id")
    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){
        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;
    }
}
  • Order를 생성하는 로직을 createOrder로 엔티티 내부에 생성하였다.
  • 연관관계가 있는 Member, Delivery에 대한 초기화를 해주었다.
  • OrderItem에 대해서는 연관관계 편의 메서드인 addOrderItem으로 값을 추가해주었다.
  • 그 밖에 주문 상태와 시간을 초기화 해주었다.
  • 이렇게 할 경우 실제 주문을 생성할 때 발생하는 복잡한 비지니스 로직을 외부에서 구현할 필요 없이 createOrder에서 완결이 된다.

1-2-1. 주문 취소 로직

@Entity
@Table(name="orders")
@Getter @Setter
public class Order {
			.
            .
            .
    //==비지니스 로직
    /**
     * 주문 취소
     */
    public void cancel(){
        if(delivery.getStatus() == DeliveryStatus.COMP){
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems){
            orderItem.cancel();
        }
    }
}
  • 만약 배송이 완료된 경우엔 주문 취소를 할 수 없기 때문에 이 경우엔 예외를 내보낸다.
  • 위의 상황이 아니라면 주문상태를 CANCEL로 변경한다.
  • 주문 상품들, 즉 OrderItem 에 대해서도 주문이 취소 되어야 한다. 한 주문에 여러 개의 상품을 주문할 수 있기 때문에 루프문을 통해 모든 주문 상품을 조회하여 취소를 해주어야 한다..

1-2-2. 주문 취소 로직 In OrderItem

@Entity
@Getter @Setter
public class OrderItem {
			.
            .
            .
    //==비지니스 로직==
    public void cancel(){
        getItem().addStock(count);
    }
}
  • OrderItem에서의 취소는 해당 상품의 재고 수량을 원상복구 하는데에 목적이 있다.
  • Order에서 OrderItem에서의 cancel을 호출하면 각 주문에 대한 수량이 복구된다.

1-3-1. 전체 주문 가격 조회

@Entity
@Table(name="orders")
@Getter @Setter
public class Order {
			.
            .
            .
    //==조회 로직
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice(){
//        int totalPrice=0;
//        for (OrderItem orderItem : orderItems){
//            totalPrice += orderItem.getTotalPrice();
//        }
//        return totalPrice;
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }
}
  • Order와 같이 조회가 복잡한 경우 엔티티 내부에 조회 메소드를 만들어 사용해도 된다.
  • 전체 주문 가격은 주문 상품들의 합이므로 각 orderItem의 합, TotalPrice를 구해서 반환하면 된다.
  • 자바8에 나온 문법인 stream을 활용하여 for문을 사용하지 않고 OrderItem을 조회하여 메소드를 호출할 수 있다.

1-3-2. 주문 가격 총합 조회 In OrderItem

@Entity
@Getter @Setter
public class OrderItem {
			.
            .
            .
    //==비지니스 로직==
    public void cancel(){
        getItem().addStock(count);
    }

    public int getTotalPrice(){
        return getOrderPrice() * getCount();
    }
}
  • getTotalPrice는 한 주문 상품의 가격 총합이다.

  • 가격 총합은 물품 한개의 가격 * 수량이다.

  • Order에서 이 메서드를 호출하여 주문 상품들에 대한 총합을 구하게 된다.

    1-4. OrderItem 생성 메서드

    @Entity
    @Getter @Setter
    public class OrderItem {
    			.
               .
               .
       //==생성 메서드--//
       public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
           OrderItem orderItem = new OrderItem();
           orderItem.setItem(item);
           orderItem.setOrderPrice(orderPrice);
           orderItem.setCount(count);
    
           item.removeStock(count);
           return orderItem;
       }
    }
  • OrderItem역시 복잡하기 때문에 엔티티에서 생성 메서드를 제공해주면 편하다.

  • item을 통해서 orderPrice를 가져올 수도 있지만 이 값은 변경 가능성이 있기 때문에 따로 가져오는 것이 좋다.

  • 주문 상품이 생성되면 그 갯수만큼 재고에서 사라쳐야되기 때문에 removeStock을 통해 재고를 까준다.

  • Order를 만들기 위해서 OrderItem을 가져올 때 이 생성메서드에서 재고 수량을 미리 까서 가져오게 하는 것이라 생각하면 된다.

    2. 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);
       }
    
       //public List<Order> findAll(OrderSearch orderSearch){}
    }
  • 주문 리포지토리는 간단하지만 추가적으로 검색기능이 들어가있기 때문에 이부분에 대해서는 따로 구현을 해주어야 한다.

    3. OrderService 구현

    OrderService의 경우 주문을 생성, 취소, 검색 3가지를 할 수 있어야 한다.

    3-1. order 구현

    @Service
    @Transactional(readOnly = true)
    @RequiredArgsConstructor
    public class OrderService {
       private final OrderRepository orderRepository;
       private final MemberRepository memberRepository;
       private final ItemRepository itemRepository;
    
       //주문
       @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());
    
           //주문상품 생성
           OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
    
           //주문 생성
           Order order = Order.createOrder(member, delivery, orderItem );
    
           //주문 저장
           orderRepository.save(order);
           return order.getId();
       }
    
       //취소
    
       //검색
    }
  • Order는 기본적으로 Member, Item과 연관관계가 있기 떄문에 이들 리포지토리를 가져와 사용해야 한다.

  • 우선 MemberItem에 대한 조회를 통해 주문 고객과 주문 상품을 선택하도록 해야한다.

  • 배송 정보는 주문과 함꼐 생성되기 때문에 OrderServiceorder를 통해 생성하여준다.

  • 선택한 상품에 대한 가격과 수량을 이용해서 OrderItem, 주문 상품 엔티티로 생성하여준다.

  • 위에서 선택한 Member와 앞에서 생성된 deliveryorderItem엔티티를 사용하여 주문을 생성한다.

  • 생성된 ordersave하여 persist를 진행한다.

  • orderItemorder는 도메인에서 생성 메서드를 따로 만들었기 떄문에 Service에서 따로 값을 가져와서 세팅할 필요 없이 메서드 호출만으로 손쉽게 엔티티를 생성할 수 있다.

    orderItem과 delivery 엔티티는 persist를 안하나??

    위에서 보면 order에 대해서만 orderRepositorysave를 통해서 persist만 하는 것을 확인할 수 있다. 이 이유는 Order도메인에서 우리가 deliveryorderItem에 대해서 cascade = CascadeType.ALL옵션을 주었기 떄문이다.
    이 옵션이 들어가있으면 해당 엔티티, 즉 Orderpersist될 때 같이 강제로 persist를 날려주게 된다.
    CascadeType.All옵션은 Order에서만 OrderItemDelivery를 사용하기 때문에 사용할 수 있는 것이다. 만약 OrderItem을 다른 엔티티에서도 갖다 사용하는 경우가 생긴다면 이 옵션이 있는 경우 동작이 꼬일 수 있기 떄문에 사용하면 안된다.
    그렇기 때문에 확실한 경우가 아니고선 리포지토리를 따로 생성하여 persist를 호출하여 프로그램을 만든 다음에 리팩토링 할 때 이 옵션을 적용시키는 것도 좋은 방법이다.


    정리하자면 CascadeType.ALL옵션을 사용해도 되는 경우는 다음과 같다.

    1. 라이프 사이클이 같으며
    2. 다른 엔티티에서 참조를 하지 않을 떄

참고. new 연산을 통한 엔티티 생성 막기

위와 같이 createOrdercreateOrderItem로 생성 메서드를 따로 지정해서 엔티티를 생성할 경우 외부에서 new연산을 통한 엔티티 생성은 막아주는 것이 좋다. 이때 모든 필드들에 대해 생성자를 protected로 만들어주면 된다.

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

이렇게 Lombok을 사용하여 인자가 없는 protected생성자를 만들어주는 어노테이션을 사용해도 된다.

3-2. 주문 취소

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
			.
            .
            .
    //취소
    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findOne(orderId);
        order.cancel();
    }
}
  • 주문을 취소하는 경우에도 단순하다. 왜냐하면 이미 도메인 레벨에서 비지니스 로직이 다 들어가있기 때문에 호출만 해주면 끝나기 때문이다.
  • JPA의 강점이 바로 이런 것들이다. SQL을 이용해서 짜면 데이터의 변경에 대해서 또 UPDATE쿼리를 외부에서 작성을 해서 DB에 반영시켜 주어야 하는데 JPA를 사용하면 더티 체킹, 변경 감지를 통해 데이터만 변경해도 자동적으로 UPDATE쿼리가 나가게 된다.

참고. 도메인 모델 패턴

이렇게 Order와 같이 핵심 비지니스 로직을 도메인에 몰아놓고 설계를 진행하는 것을 도메인 모델 패턴이라고 한다. 반면에 비지니스 로직을 서비스에서 실행하는 것을 트랜잭션 스크립트 패턴이라고 한다. 일반적으로 ORM을 사용하면 도메인 모델 패턴을 사용하는 것이 유지보수 관점에서 유용하나 상황에 맞게 사용을 하면 된다.

4. OrderServiceTest

테스트의 요구사항은 다음과 같다.

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안된다.
  • 주문 취소가 성공해야 한다.

4-1. 상품 주문 테스트

class OrderServiceTest {

    @Autowired  EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void 삼품주문()  {
        // given
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);

        Book book = new Book();
        book.setName("시골 JPA");
        book.setPrice(10000);
        book.setStockQuantity(10);
        em.persist(book);
        int orderCount=2;

        // when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

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

        assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.ORDER); //주문 상태 확인
        assertThat(getOrder.getOrderItems().size()).isEqualTo(1); //주문 상품 종류 수 확인
        assertThat(getOrder.getTotalPrice()).isEqualTo(10000 * orderCount); //주문 가격 확인 (가격 * 수량) 인지
        assertThat(book.getStockQuantity()).isEqualTo(8); //주문 수량만큼 재고가 줄었는지
    }
}
  • 먼저 MemberBook 엔티티를 생성하고 그것을 order의 안자로 넘겨주어 주문을 생성하였다.
  • 그 후 반환값의 주문 상태, 주문 상품 종류 수, 주문 가격, 주문 수량에 대해 정상적으로 동작 하였는지 확인을 진행하였다.
  • AssertJassertThat을 사용하여 값을 비교하였다.
    정상적으로 잘 실행된 모습을 확인할 수 있다.

4-2. 상품주문_재고수량 초과

@SpringBootTest
@Transactional
class OrderServiceTest {
			.
            .
            .
    @Test
    void 상품주문_재고수량초과() {
        // given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10);
        int orderCount = 11;

        // when. then
        assertThatThrownBy(() -> orderService.order(member.getId(), item.getId(), orderCount))
                .isInstanceOf(NotEnoughStockException.class);
    }

}
  • MemberItem을 생성하는 부분은 Extract Method를 통해 메소드로 만들었다.
    -위의 예외는 만약 상품의 재고보다 많은 수량을 주문했을 때 발생하는 예외를 테스트로 진행한 것이다.
  • 예외가 터지는 이유는 createOrderItem을 하였을 떄 Item에 있는 removeStock을 진행하며 재고 수량보다 적은 경우 예외가 터지도록 조건문을 넣어놨기 때문이다.
  • 예외에 대한 테스트로 assertThatThrownBy()메서드를 사용하여 비교하였다.

예외가 정상적으로 발생한 것을 확인할 수 있다.
만약 예외가 터지지 않도록 정상적으로 수량을 넣어서 주문을 생성한다면 위와 같은 에러메시지가 출력된다.

참고. 파라미터 지정 단축키

이 그림처럼 파라미터를 외부에서 입력받도록 해주는 단축키는 crtl + Alt +p이다.

4-3. 주문 취소 test

package jpabook.jpashop.Service;

import jakarta.persistence.EntityManager;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;

@SpringBootTest
@Transactional
class OrderServiceTest {
			.
            .
            .
    @Test
    void 주문취소() {
        // given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 2;
        Long orderId = orderService.order(member.getId(), item.getId(), orderCount);

        // when
        orderService.cancelOrder(orderId);

        // then
        Order getOrder = orderRepository.findOne(orderId);
        assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.CANCEL);
        assertThat(item.getStockQuantity()).isEqualTo(10);
    }
}
  • 주문을 취소할 경우 주문 상태가 ORDER에서 CANCEL로 변하고 수량이 원래대로 돌아온다.
  • 이것에 대해서 테스트를 진행하였다.

테스트는 정상적으로 실행된 것을 확인할 수 있다.

총정리

이렇게 해서 Order에 대한 Domain, Repository, Service를 구현하고 마지막으로 테스트까지 진행해보았다. 코드의 양도 지금까지 진행했던 실습중에 제일 많았던 강의지 않았나 싶다. 아무래도 Order가 연관관계가 제일 많아서 이렇게 많았던 것 같다. 비지니스 로직도 많은 것을 고려해야했기 때문에 만약 혼자서 진행했으면 많은 어려움을 겪었을 수도 있을 것이라 생각한다.

우선 도메인 모델 패턴에 대해서 바로 실습을 통해 이해를 할 수 있었고 확실히 비지니스 로직이 도메인에 있는 것이 서비스나 테스트를 쉽게 할 수 있도록 도와주는 것 같았다. 이것에 대해서도 확실히 체감을 할 수 있었다.

Order가 양이 많아 정리하면서 공부하기 시간이 조금 걸렸었는데 다음시간엔 바로 미처 만들지 못한 검색 기능에 대해서 구현을 해볼 예정이다.

profile
안녕하세요

0개의 댓글