제육's 휘발성 코딩
반응형

주문 도메인 개발

구현 기능

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

순서

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

주문 엔티티 개발

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;

    @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 , 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;
    }

    //== 비즈니스 로직==//

    // 주문 취소
    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;
    }
}
  • 주문을 생성할 때 엔티티에서 모두 필요한 모든 연관관계를 받아 생성 메서드를 만들어 처리한다. 주문 취소, 전체 주문 가격 조회 등의 비즈니스로직도 추가로 구현하자.

주문 상품 엔티티 개발

package jpabook.jpashop.domain;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
@Table(name = "order_item")
@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; // 주문 수량

    //== 생성 메서드 ==//
    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();
    }
}
  • 주문 상품 엔티티의 경우도 마찬가지로 생성 메서드를 통해 상품 정보를 받아와 처리 한다.
  • cancel() 의 경우 주문이 취소되었을 때 상품의 수량을 되돌리기 위한 메서드이다. addStock 은 이전에 상품 엔티티에서 개발한 비즈니스 로직으로 호출될 때 마다 현재 stock의 값이 1씩 증가하도록 되어있다.
  • 다음과 같이 생성메서드를 만들면 외부에서 처리를 못하게 하는 것이 바람직하다. JPA 기본 생성자 전략은 public, protected 두 가지를 제공한다. @NoArgsConstructor(access = AccessLevel.PROTECTED) 를 사용하여 외부 객체 생성을 차단하자.

주문 리포지토리 개발

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.aspectj.weaver.ast.Or;
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(OrderSearch orderSearch) {

    }*/
}
  • findAll() 의 경우 추후 검색 기능을 적용시키기 위한 내용으로 이후에 다뤄보자.

주문 서비스 개발

package jpabook.jpashop.service;

import jpabook.jpashop.domain.*;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

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

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

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

        // 주문 저장 - CascadeType.ALL 설정으로 연관된 모든 연관관계들도 persist 된다.
        orderRepository.save(order);
        return order.getId();
    }

    /**
     * 주문 취소
     */
    @Transactional
    public void cancelOrder(Long orderId) {

        // 주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);

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

    /**
     * 주문 검색
     */
   /* public List<Order> findOrders(OrderSearch orderSearch) {
        return orderRepository.findAll(orderSearch);
    }*/
}
  • 생성 메서드를 처리하고나니 비즈니스 로직에서 구현 단계가 가독성이 좋아진 것을 볼 수 있다.
  • 주문 생성을 보면 CascadeType.ALL 설정으로 Order 하나만으로 모든 연관관계들이 영속화되는 것을 볼 수 있다. 현재는 Order 에서만 OrderItem, Delivery 등을 사용하기 때문에 문제가 발생하지 않지만, 복잡해지는 경우 모든 관계를 별도로 영속화할지 여부를 고민해야 한다.

엔티티가 비즈니스 로직을 가진 패턴을 도메인 모델 패턴이라고 한다. (ORM을 사용하면 해당 방식 권장 - 정답은 없다.)

https://martinfowler.com/eaaCatalog/domainModel.html

서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.

https://martinfowler.com/eaaCatalog/transactionScript.html

주문 기능 테스트

package jpabook.jpashop.service;


import jpabook.jpashop.domain.*;
import jpabook.jpashop.domain.item.Book;
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 javax.persistence.EntityManager;

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

@SpringBootTest
@Transactional
class OrderServiceTest {

    private final EntityManager em;
    private final OrderService orderService;
    private final OrderRepository orderRepository;

    @Autowired
    public OrderServiceTest(EntityManager em, OrderService orderService, OrderRepository orderRepository) {
        this.em = em;
        this.orderService = orderService;
        this.orderRepository = orderRepository;
    }

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

    @Test
    public 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);

        assertEquals(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCEL 이다.");
        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;
    }

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

        // when
        int orderCount = 11;

        // then
        assertThrows(NotEnoughStockException.class, () -> {
            orderService.order(member.getId(), item.getId(), orderCount);
        });
    }
}
  • 상품 주문, 주문 취소, 재고수량초과의 경우에 대해서 통합테스트를 해보자.

주문 검색 기능 개발

JPA에서 동적 쿼리를 어떻게 해결하는 지 다음 기능을 개발해보면서 알아보자.

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class OrderSearch {

    private String memberName;
    private OrderStatus orderStatus;
}
  • 검색 기능을 넣기 위해 OrderSearch를 만든다.
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("status", orderSearch.getOrderStatus())
    .setParameter("name", orderSearch.getMemberName())
    .setMaxResults(1000)
    .getResultList();
}
  • OrderRepository에서 다음과 같이 구현할 수 있다. 다만 지금은 null의 여부를 모르기 때문에 동적 쿼리가 적용되지 않는다.

동적 쿼리 - JPQL

public List<Order> findAll(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를 지원한다.

동적 쿼리 - Criteria

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 표준 스펙이지만 아직까지도 너무 복잡하여 실무에서 사용하지 않는다. 가장 효율적인 해결책은 Querydsl이다.

동적 쿼리 - Querydsl

public List<Order> findAll(OrderSearch orderSearch) {
  QOrder order = QOrder.order;
  QMember member = QMember.member;

  return query
    .select(order)
    .from(order)
    .join(order.member, member)
    .where(statusEq(orderSearch.getOrderStatus()),
          nameLike(orderSearch.getMemberName()))
    .limit(1000)
    .fetch();
}

private BooleanExpression statusEq(OrderStatus statusCond) {
  if (statusCond == null) {
    return null;
  }
  return order.status.eq(statusCond);
}

private BooleanExpression nameLike(String nameCond) {
  if (!StringUtils.hasTest(nameCond)) {
    return null;
  }
  return member.name.like(nameCond);
}
  • 다음과 같이 Querydsl을 사용하여 동적쿼리를 해결하는 것이 바람직하다. 간결하고, 전부 자바코드로 이루어지기 때문에 컴파일 타임에서 오류를 잡을 수 있다는 강력한 장점이 있다. 해당 내용에 대해서는 추후 포스팅에서 다뤄보자.

본 포스팅은 '인프런 - 김영한님 강의(실전! 스프링 부트와 JPA 활용1)'를 토대로 정리한 내용입니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^