[spring] 주문 도메인 개발

주문, 주문상품 엔티티 개발


생성지점이 되면 이 메소드만 바꾸면 돼서 중요하다.
이 메소드에서 주문에 대한 완성을 시킨다. (set set 하는게 아니라)

//==생성 메서드==//
    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;
    }

//한번 주문할 때 상품 두개 주문할수있음, 취소할때 각각에 cancel날림
for(OrderItem orderItem : orderItems) {
orderItem.cancel();
}

//아이템 가져와서 주문수량만큼 stock늘려야함
public void cancel() {
getItem().addStock(count);
}

//orderItem의 totalPrice 주문할 때 주문가격과 수량 곱해야하기 때문
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}

public int getTotalPrice() {
return getOrderPrice() * getCount();
}

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;  //연관관계 주인

    //(fetch = FetchType.EAGER) 로 하면
    //JPQL select o From order o; -> SQL로 다 번역된다. select * from order : n + 1(order)

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

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")   //연관관계 주인
    private Delivery delivery;

    private LocalDateTime orderDate; //주문시간

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


    //==연관관계 메서드==//    <- 핵심적 연관관계 주인 쪽에 넣어주는 게 좋음
    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;
    }

    //==비즈니스 로직 ==//
    //1. 주문취소
    public void cancel() {
        if(delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소 불가");
        }

        this.setStatus(OrderStatus.CANCLE);
        for(OrderItem orderItem : orderItems) {
            orderItem.cancel(); //한번 주문할 때 상품 두개 주문할수있음, 취소할때 각각에 cancel날림
        }
    }

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

    }

}

생성 메서드 createOrderItem() : 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다.
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;
}

주문 리포지토리 개발


package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@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(OrerSearch orderSearch) {}
}

주문 서비스 개발


//주문
    @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();

    }

//주문 저장
orderRepository.save(order);

하나만 한 이유는 Cascade옵션때문에 그렇다. Order를 persist하면 OrderItem도 하나씩 강제로 persist 해준다.
Order가 persist할 때 Delivery item도 자동으로 persist된다.

어디까지 cascade 해야하나 고민 : 명확히 칼로 자르긴 애매
Order같은 경우에 Order가 delivery, orderitem관리하는데, 이 그림정도에서 사용해야 함.
참조하는 게 딱 주인이 private Owner 인경우만 써야함.

orderitem와 delivery는 Order에서만 참조해서 쓴다. (다른곳에서 참조를 안한다.)
라이프사이클에서 동일하게 관리할 때, 다른곳에서 참조안하는 private Owner 인경우에 cascade사용. 아닌 경우에는 cascade쓰면 안된다.


/setter로 OrderItem생성하는거 막고 createOrderItem으로 생성하도록 유도 (제약)
/

protected OrderItem() {
}

이도 Lombok으로 줄일 수 있음
@NoArgsConstructor(access = AccessLevel.PROTECTED)


//주문 취소
@Transactional
public void cancelOrder(Long orderId) {
//주문 조회
Order order = orderRepository.findOne(orderId);
//주문 취소
order.cancel();
}

이렇게 되면 Order의 비즈니스 로직인 cancel()로 간다.

JPA에서는 엔티티 안에 있는 데이터만 바꿔주면 jpa가 알아서 바뀐 변경 포인트 (더티 체크) 찾아서 db에 update 쿼리가 날라간다.
주문 취소의 경우 Order의 상태바꿨기 때문에 Order update쿼리 날라가고

//==비즈니스 로직 ==//
//1. 주문취소
public void cancel() {
if(delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소 불가");
}

    this.setStatus(OrderStatus.CANCLE);
    for(OrderItem orderItem : orderItems) {
        orderItem.cancel(); //한번 주문할 때 상품 두개 주문할수있음, 취소할때 각각에 cancel날림
    }
}

OrderItem에서 Item도 update쿼리가 날라간다.

//아이템 가져와서 주문수량만큼 stock늘려야함
public void cancel() {
getItem().addStock(count);
}

주문 기능 테스트


@Test
   public void 상품주문() throws Exception {
       //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);
       assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
       assertEquals("주문한 상품 종류 수가 정확해야한다.", 1, getOrder.getOrderItems().size());
       assertEquals("주문 가격은 가격 * 수량이다.", 10000 * 2,
               getOrder.getTotalPrice());
       assertEquals("주문 수량만큼 재고가 줄어야 한다.",8, book.getStockQuantity());

JUnit5에서는

재고 수량 초과 테스트 코드
@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception {
//Given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고
int orderCount = 11; //재고보다 많은 수량
//When
orderService.order(member.getId(), item.getId(), orderCount);
//Then
fail("재고 수량 부족 예외가 발생해야 한다.");
}

대신에

@Test
   public void 상품주문_재고수량초과() throws Exception {
       //given
       Member member = createMember();
       Item item = createBook("시골 JPA", 10000, 10);

       int orderCount = 11;

       //when

       //then
       final NotEnoughStockException notEnoughStockException = assertThrows(NotEnoughStockException.class,
               () -> orderService.order(member.getId(), item.getId(), orderCount),
               "재고수량이 부족 하면 예외가 발생 한다."
       );
   }

사용

최종 테스트 코드

package jpabook.jpashop.service;

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.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.util.AssertionErrors.assertEquals;
import javax.persistence.EntityManager;

@SpringBootTest
@Transactional
public class OrderServiceTest {
   @Autowired EntityManager em;
   @Autowired OrderService orderService;
   @Autowired OrderRepository orderRepository;


   @Test
   public void 상품주문() throws Exception {
       //given
       Member member = createMember();
       Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고

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

       //then
       Order getOrder = orderRepository.findOne(orderId);
       assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
       assertEquals("주문한 상품 종류 수가 정확해야한다.", 1, getOrder.getOrderItems().size());
       assertEquals("주문 가격은 가격 * 수량이다.", 10000 * 2,
               getOrder.getTotalPrice());
       assertEquals("주문 수량만큼 재고가 줄어야 한다.",8, item.getStockQuantity());

   }

   @Test
   public void 상품주문_재고수량초과() throws Exception {
       //given
       Member member = createMember();
       Item item = createBook("시골 JPA", 10000, 10);

       int orderCount = 11;

       //when

       //then
       final NotEnoughStockException notEnoughStockException = assertThrows(NotEnoughStockException.class,
               () -> orderService.order(member.getId(), item.getId(), orderCount),
               "재고수량이 부족 하면 예외가 발생 한다."
       );
   }

   @Test
   public void 주문취소() throws Exception {
       //given
       Member member = createMember();
       Book 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);
       assertEquals("주문 취소시 상태는 CANCEL이다.", OrderStatus.CANCLE, getOrder.getStatus());
       assertEquals("주문 취소된 상품은 그만큼 재고 증가해야한다.", 10, item.getStockQuantity());


   }
   private Member createMember() {
       Member member = new Member();
       member.setName("회원1");
       member.setAddress(new Address("서울", "강가", "123-123"));
       em.persist(member);
       return member;
   }
   private Book createBook(String name, int price, int stockQuantity) {
       Book book = new Book();
       book.setName(name);
       book.setStockQuantity(stockQuantity);
       book.setPrice(price);
       em.persist(book);
       return book;
   }


}

테스트 성공

주문 검색 기능 개발


JPA에서 동적 쿼리 해결

값이 다 있다는 가정에선

public List<Order> findAll(OrderSearch orderSearch) {
       return em.createQuery("select o from Order o join o.member m" +
               "where o.status = :status" +
               "and m.name like :name", Order.class)
               .setParameter("staus", orderSearch.getOrderStatus())
               .setParameter("name", orderSearch.getMemberName())
               .setMaxResults(1000)
               .getResultList();
       
   }

값이 없으면 :status이런 문장 있으면 안된다.
null이면 동적쿼리를 해야한다.
동적 쿼리 해결이 만만치않다.

MyBatis는 xml로 동적쿼리 해결할 수있는 장점 있지만 여긴 좀 어려움
첫번째 해결법 jpql -> 실무에서 안씀
JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있기 때문.

좀 나은 방법은
JPA Criteria로 처리

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡, 권장하는 방법이 아니다.
가장 멋진 해결책은 Querydsl. Querydsl 소개장에서 간
단히 언급할 예정

좋은 웹페이지 즐겨찾기