[Spring Boot2][2] 6. 주문 도메인 개발

sorzzzzy·2021년 10월 14일
0

Spring Boot - RoadMap 2

목록 보기
17/26
post-thumbnail

📌 구현 기능

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

📌 순서
1️⃣ 주문 엔티티, 주문상품 엔티티 개발
2️⃣ 주문 리포지토리 개발
3️⃣ 주문 서비스 개발
4️⃣ 주문 검색 기능 개발
5️⃣ 주문 기능 테스트



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

✔️ Order

.
.
.
    // ==생성 메서드== //
    // 밖에서 setter로 설정하는 것이 아닌, 생성할 때무터 무조건 createOrder가 호출됨
    // 생성 메소드에서 주문 생성에 대한 복잡한 비즈니스 로직을 완결시킴
    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;
    }

    // ==비즈니스 로직== //
    /**
     * 주문 취소
     */
    public void cancel() {
        // 이미 배송이 시작됐으면 취소가 불가능
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        // 주문 상태 바꾸기
        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

    // ==조회 로직== //
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
  • 생성 메서드(createOrder())
    • 주문 엔티티를 생성할 때 사용한다.
    • 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
  • 주문 취소(cancel())
    • 주문 취소시 사용한다.
    • 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다.
    • 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
  • 전체 주문 가격 조회
    • 주문 시 사용한 전체 주문 가격을 조회한다.
    • 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.

✔️ 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;
    }

    // ==비즈니스 로직== //
    public void cancel() {
        // 재고를 다시 주문 수량만큼 늘려줌
        getItem().addStock(count);
    }

    // ==조회 로직== //
    // 주문상품 전체 가격 조회
    public int getTotalPrice() {

        return getOrderPrice() * getCount();
    }
  • 생성 메서드(createOrderItem())
    • 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다.
    • item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
  • 주문 취소(cancel())
    • getItem().addStock(count) 를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
  • 주문 가격 조회(getTotalPrice())
    • 주문 가격에 수량을 곱한 값을 반환한다.


🏷 주문 리포지토리 개발

✔️ OrderRepository

@Repository
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
@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();
    }

    /**
     * 주문 취소
     */
    @Transactional
    // 취소는 Id 값만 넘어옴
    public void cancelOrder(Long orderId) {
        // 주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);
        // 주문 취소
        order.cancel();
    }
}
  • 주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검 색 기능을 제공!!

📌 참고
예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다고 가정했음!



🏷 주문 기능 테스트

📌 테스트 요구사항

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

✔️ OrderServiceTest

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.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import static org.junit.Assert.*;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

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

    @Test
    public void 상품주문() throws Exception {
        // given
        Member member = createMember();

        Book book = createBook("시골 JPA", 10000, 10);

        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 * orderCount, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 8, book.getStockQuantity());
    }

    @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();
        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.CANCEL, getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
    }

    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }
}

⬆️ 테스트 결과 👍🏻



🏷 주문 검색 기능 개발

🤔 : JPA에서 동적 쿼리를 어떻게 해결해야 할까❓


✔️ 검색 조건 파라미터 OrderSearch

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.OrderStatus;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class OrderSearch {
    // 회원 이름
    private String memberName;
    // 주문 상태[ORDER, CANCEL]
    private OrderStatus orderStatus;
}

✔️ OrderRepository 수정

1️⃣ JPQL로 처리

public List<Order> findAllByString(OrderSearch orderSearch) {

        String jpql = "select o from Order o join o.member m";
        // 2,3번째에 나오면 where 가 아닌 and 가 되어야하므로 구별하기 위함
        boolean isFirstCondition = true;

        // 주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " o.status = :status";
        }

        // 회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            if (isFirstCondition) {
                jpql += " where";
                 isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " m.name like :name";
        }

        TypedQuery<Order> query = em.createQuery(jpql, Order.class)
                .setMaxResults(1000);

        if (orderSearch.getOrderStatus() != null) {
            query = query.setParameter("status", orderSearch.getOrderStatus());
        }
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            query = query.setParameter("name", orderSearch.getMemberName());
        }

        return query.getResultList();
    }
  • JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 발생할 가능성이 높다!

2️⃣ JPA Criteria로 처리

public List<Order> findAllByCriteria(OrderSearch orderSearch) {
        // 엔티티에서 getCriteriaBuilder()를 얻음
        CriteriaBuilder cb = em.getCriteriaBuilder();
        // 응답타입 세팅
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);
        Root<Order> o = cq.from(Order.class);
        Join<Object, Object> m = o.join("member", JoinType.INNER);

        List<Predicate> criteria = new ArrayList<>();

        // 주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            criteria.add(status);
        }
        // 회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Predicate name =
                    cb.like(m.<String>get("name"), "%" + orderSearch.getMemberName() + "%");
            criteria.add(name);
        }

        cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
        TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000);
        return query.getResultList();
    }
  • JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다.
  • 많은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다!!!
  • Querydsl은 나중에 더 자세히 배울 예정 😊

profile
Backend Developer

0개의 댓글