Super Kawaii Cute Cat Kaoani [스프링 부트와 JPA 활용 1] 5. 주문 도메인 개발

[스프링 부트와 JPA 활용 1] 5. 주문 도메인 개발

2024. 1. 24. 00:16
728x90
SMALL

📌 구현 기능 및 순서

✔ 구현 기능

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

✔ 순서

  • 주문 엔티티 , 주문 상품 엔티티 개발
  • 주문 리포지토리 개발
  • 주문 서비스 개발
  • 주문 검색 기능 개발
  • 주문 기능 테스트 개발

 

 

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

⚡ 주문 엔티티 개발

🔶 main/.../domain/Order

package jpabook.jpashop.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

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) //order <-> member 다대일 관계
    @JoinColumn(name="member_id")
    private Member member;

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


    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate; //주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status;//주문상태 [Order, Cancle]

    //==연관관계 메서드==//
    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;
    }

    //==비지니스로직==/
    /**
     * 주문 취소
     */
    public void cancle(){
        if(delivery.getStatus()==DeliveryStatus.COMP){
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.setStatus(OrderStatus.CANCLE);
        for(OrderItem orderItem : orderItems){
            orderItem.cancle();
        }
    }

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


}

createOrder

  • 주문 엔티티를 생성할 때 사용
  • 주문 회원, 배송 정보, 주문 상품의 정보를 받아서 실제 주문 엔티티를 생성

 

✔ 왜 OrderItem...이라고 하는가

  • 가변인자 사용
  • 여러 개의 OrderItem 객체 전달 가능
  • 배열로 취급됨

OrderStatus는 일단 기본으로 ORDER
LocalDateTime.now : 현재 시간

cancel

  • cancel()은 주문 취소시 사용
  • 주문 상태를 취소로 변경하고 주문 상품에 주문 취소를 알림
  • 만약 이미 배송을 완료한 상품이라면 주문 취소를 못하도록 예외 발생

getTotalPrice

  • 주문 시 사용한 전체 주문 가격 조회
  • 전체 주문 가격을 알려면 각각의 주문 상품 가격을 알아야 함
  • 로직을 보면 연관된 주문 상품들의 가격을 조회해서 더한 값을 반환
    • 실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화함

 

 

 

⚡ 주문 상품 엔티티 개발

🔶 main/.../domain/OrderItem

package jpabook.jpashop.domain;

import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter@Setter
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;//주문 수량

    //==생성 메서드==//
    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 cancle() {
        getItem().addStock(count);
    }

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

createOrderItem() : 생성 메서드

  • 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다.
  • item.removeStock(count)를 호출해서 주문한 수량만큼 상품의 재고 줄임

cancel

  • getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킴

getTotalPrice()

  • 주문 가격에 수량을 곱한 값 반환

 

 

 

📌 주문 리포지토리 개발

🔶 main/.../repository/OrderRepository 

package jpabook.jpashop.repository;

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

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(OrderSearch orderSearch){
//        ...
//    }
}
  • 주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있음
  • 마지막의 findAll(OrderSearch orderSearch)메서드는 조금 뒤에 있는 주문 검색 기능에서 자세히 설명

 

 

 

📌 주문 서비스 개발

🔶 main/.../service/OrderService 

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Delivery;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.aspectj.weaver.ast.Or;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
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
    public void cancelOrder(Long orderId){
        //주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);

        //주문 취소
        order.cancle();
    }


    /**
     * 검색
     */

    public List<Order> findOrders(OrderSearch orderSearch){
        return orderRepository.findAllByString(orderSearch);
    }

}

 

  • 주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공함

 

cf) 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있음
  • 주문(order()) : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장
    • 원래는 딜리버리, 주문 상품 리포지토리를 따로 만들어서 세팅해줬는데 코드에서는 order 리포지토리에 order 하나만 넣고 끝남
    • 앞에서 작성한CasCade 옵션에 의해서 해당 클래스를 persist하면 Cascade가 적힌 다른 객체들도 persist해줌
    • Order만 Delivery와 OrderItem을 사용하기에 가능 -> 아니라면 리포지토리 생성해서 해줘야 함
  • 주문 취소(cancelOrder()) : 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소 요청
  • 주문 검색(findOrders()) : OrderSearch라는 검색 조건을 가진 객체로 주문 엔티티 검색
    • 주문 검색 기능에서 자세히 설명

cf)
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.
서비스 계층 은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 
엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라 한다.
반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분 의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.

 

 

 

📌 주문 기능 테스트

✔ 테스트 요구사항

  • 상품 주문이 성공해야 함
  • 상품을 주문할 때 재고 수량을 초과하면 안됨
  • 주문 취소가 성공되어야 함

 

⚡ 상품 주문 테스트

🔶 test/.../service/OrderService

 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.aspectj.weaver.ast.Or;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

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

    @Test
    public void 상품주문() throws Exception{
        //given
        Member member = createMember("회원1", new Address("서울", "강가", "123-123"));

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

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


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

    @Test
    public void 상품주문_재고수량초과() throws Exception{
        //given
        Member member = createMember("회원1", new Address("서울", "강가", "123-123"));

        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 11;

        //when
        assertThrows(NotEnoughStockException.class, ()->{
        orderService.order(member.getId(), item.getId(), orderCount);
        });

      //  orderService.order(member.getId(), item.getId(), orderCount);
        //then
        //fail("재고 수량 부족 예외가 발생해야 한다");
    }

    @Test
    public void 주문취소() throws Exception{
        //given
        Member member = createMember("회원1", new Address("서울", "강가", "123-123"));

        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);

        assertEquals(OrderStatus.CANCLE, getOrder.getStatus(), "주문 취소 시 상태는 CANCEL이다");
        assertEquals(10, item.getStockQuantity(), "주문이 취소된 상품은 그만큼 재고가 증가해야 한다.");
    }

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

    private Member createMember(String name, Address address) {
        Member member = new Member();
        member.setName(name);
        member.setAddress(address);
        em.persist(member);
        return member;
    }


}
  • 상품 주문이 정상 동작하는지 확인하는 테스트
  • Given 절에서 테스트를 위한 회원과 상품 만듦
  • When 절에서 실제 상품을 주문
  • Then 절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증
  • Member, Book을 생성하는 메서드는 자주 사용되므로 단축키 command + option + M으로 꺼냄

 

 

✔ 재고 수량 초과 테스트

  • NotEnoughStockException 예외 발생
@Test
public void 상품주문_재고수량초과() throws Exception{
    //given
    Member member = createMember("회원1", new Address("서울", "강가", "123-123"));

    Item item = createBook("시골 JPA", 10000, 10);

    int orderCount = 11;

    //when
    assertThrows(NotEnoughStockException.class, ()->{
    orderService.order(member.getId(), item.getId(), orderCount);
    });
    //then
    //fail("재고 수량 부족 예외가 발생해야 한다");
}
  • 재고는 10권인데 orderCount=11로 재고보다 1 권 많은 수량을 주문함
  • 주문 초과로 예외 발생

 

 

 

✔ 예외가 발생되는 로직

public abstract class Item {
    //...
    public void removeStock(int orderQuantity) {
        int restStock = this.stockQuantity - orderQuantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

 

 

 

 

✔ 주문 취소 테스트 코드

@Test
public void 주문취소() throws Exception{
    //given
    Member member = createMember("회원1", new Address("서울", "강가", "123-123"));

    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);

    assertEquals(OrderStatus.CANCLE, getOrder.getStatus(), "주문 취소 시 상태는 CANCEL이다");
    assertEquals(10, item.getStockQuantity(), "주문이 취소된 상품은 그만큼 재고가 증가해야 한다.");
}
  • 주문을 취소하려면 먼저 주문을 해야함
  • Given 절에서 주문하고
  • When 절에서 주문 취소
  • Then 절에서 주문 상태가 주문 취소 상태인지, 취소한 만큼 재고가 증가했는지 검토

 

반응형

 

 

📌 주문 검색 기능 개발

 

 

 

⚡ 검색 조건 파라미터 OrderSearch

🔶 main/.../repository/OrderSearch

package jpabook.jpashop.repository;

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

@Getter@Setter
public class OrderSearch {

    private String memberName; //회원 이번
    private OrderStatus orderStatus ;//주문 상태[ORDER, CANCEL]
}

 

 

 

⚡ 검색을 추가한 주문 리포지토리

🔶 main/.../repository/OrderRepository

package jpabook.jpashop.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    @PersistenceContext
    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){
        List resultList = em.createQuery("select o from Order o join o.member m "
                + "where o.status = :status " + "and m.member like :name" + Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setMaxResults(1000) //최대 1000번
                .getResultList();
        return resultList;
    }
}
  • findAll(OrderSearch orderSearch) 메서드는 검색 조건에 동적으로 쿼리를 생성해주는 주문 엔티티를 조회

 

 

 

⚡ JPQL로 처리

package jpabook.jpashop.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    @PersistenceContext
    EntityManager em;

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

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

    public List<Order> findAllByString(OrderSearch orderSearch) {
        //language = JPAQL
        String jpql = "select o From Order o join o.member m";
        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);//최대 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 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있음

 

 

 

⚡ JPA Criteria로 처리

package jpabook.jpashop.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.aspectj.weaver.ast.Or;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    @PersistenceContext
    EntityManager em;

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

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

    public List<Order> findAllByCriteria(OrderSearch orderSearch) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);
        Root<Order> o = cq.from(Order.class);
        Join<Order, Member> 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);//최대 1000건


        return query.getResultList();
    }
}
  • JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기엔 너무 복잡함
    • 다른 대안 필요(Qureydsl)

 

 

<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발_김영한>을 수강하고 작성한 글입니다

 

 

 

 

 


PREV

 

[스프링 부트와 JPA 활용 1] 4. 상품 도메인 개발

📌 상품 도메인 개발 ✔ 구현 기능 상품 등록 상품 목록 조회 상품 수정 ✔ 순서 상품 엔티티 개발(비즈니스 로직 추가) 상품 리포지토리 개발 상품 서비스 개발 상품 기능 테스트 📌 상품 엔티

nyeroni.tistory.com

NEXT

 

[스프링 부트와 JPA 활용 1] 6. 웹 계층 개발

📌 홈 화면과 레이아웃 홈화면 회원 기능 회원 등록 회원 조회 상품 기능 상품 등록 상품 수정 상품조회 주문 기능 상품 주문 주문 내역 조회 주문 취소 ⚡ 홈 컨트롤러 등록 🔶 main/.../jpashop/cont

nyeroni.tistory.com

 

728x90
LIST

BELATED ARTICLES

more