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

jada·2024년 2월 13일
0

Spring 스터디

목록 보기
18/35

상품 도메인 개발

상품 엔티티 개발(비즈니스 로직 추가)

  • 도메인 주도 설계) 엔티티 자체가 해결할 수 있는 것들은 주로 엔티티 안에 비즈니스 로직을 넣는 것이 좋다. -> 객체지향적인 설계
    • stockQuantity라는 재고 수량 변수가 Item 엔티티 안에 있고, 데이터를 가지고 있는 곳에 비즈니스 로직이 있는 것이 가장 응집도가 있다고 볼 수 있다.

Item 클래스

package jpabook.jpashop.domain.item;

import jakarta.persistence.*;
import jpabook.jpashop.domain.Category;
import jpabook.jpashop.exception.NotEnoughStockException;
import lombok.Getter;
import lombok.Setter;

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

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

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

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();

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

    /**
     * stock 증가
     */
    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    /**
     * stock 감소
     */
    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

NotEnoughStockException 클래스

package jpabook.jpashop.exception;

public class NotEnoughStockException extends RuntimeException {

    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

  • 정리:
    • 객체지향적으로 생각해보면, 데이터를 가지고있는 쪽에 비즈니스 메서드가 있는 것이 응집력이 있으므로, 가장 관리하기 좋다.
    • 데이터를 변경해야 한다면, Setter를 가지고 외부에서 계산해 넣는 것이 아니라,가 아닌 핵심 비즈니스 메서드를 통해 변경해야 한다.

상품 리포지토리 개발


package jpabook.jpashop.repository;

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

import java.util.List;

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item);
        }
        else {
            em.merge(item);
        }
    }

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

    public List<Item> findAll() {
        return em.createQuery("select i from Item i", Item.class)
                .getResultList();
    }
}
  • save메서드 내부) Item.getId()의 값이 null이면 DB에 저장되지 않은 것이므로 em.persist를 해주고, null이 아니라면 이미 JPA를 통해 DB에 저장되었던 것으로 가정하고 강제로 update해주는 개념의 em.merge를 해준다.

상품 서비스 개발

package jpabook.jpashop.service;

import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.ItemRepository;
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 ItemService {
    
    private final ItemRepository itemRepository;
    
    @Transactional
    public void saveItem(Item item) {
        itemRepository.save(item);
    }
    
    public List<Item> findItems() {
        return itemRepository.findAll();
    }
    
    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }
}

상품 서비스는 단순히 상품 리포지토리에 위임만 하는 클래스이다.
경우에 따라 이렇게 정말 위임만 하는 클래스를 만들 필요가 있을까 고민해볼 필요가 있다. (컨트롤러에서 아이템 리포지토리에 바로 접근해서 그냥 써도 크게 문제가 없기 때문에..)


주문 도메인 개발

이 섹션에서는 핵심 부분인 주문 도메인을 개발한다. (가장 중요!)

  • 비즈니스 로직들이 얽혀서 돌아가는 것을 JPA나 엔티티를 가지고 어떻게 풀어내는지 배울 수 있기 때문
  • Transaction script 패턴과 Domain Model 패턴(엔티티에 실제 비즈니스 로직이 있고, 더 많은 것을 엔티티로 위임하는 스타일)을 이해할 수 있다.

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

  • 구현기능: 상품 주문, 주문 내역 조회, 주문 취소

주문(Order) 생성은 Order뿐만 아니라 OrderItem, Delivery도 있어야 한다. 여러 연관관계가 얽혀 복잡해진다. 그래서 이러한 복잡한 생성은 별도의 생성 메서드가 있으면 좋다.

Order class

//==생성 메서드==//
    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() {
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }

이렇게 createOrder메서드를 작성하면, return되는 이 order가 연관관계를 쭉 걸면서 세팅이 되고, 상태와 주문 시간 정보까지 다 세팅을 해서 완전히 정리가 된다.

이렇게 별도의 생성 메서드를 만들면, 생성하는 지점을 변경해야 할 때, 이것저것 찾아다닐 필요 없이 이 생성 메서드만 바꾸면 된다는 장점이 있다!

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 cancel() {
        getItem().addStock(count);
    }

	//==조회 로직==//
    /**
     * 주문상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

orderItem의 cancel 메서드는 재고 수량은 원상복구해주는 역할이다.

주문 리포지토리 개발

package jpabook.jpashop.repository;

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

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

주문 서비스 개발

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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
 private final MemberRepository memberRepository;
 private final OrderRepository orderRepository;
 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());
 delivery.setStatus(DeliveryStatus.READY);
 //주문상품 생성
 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.cancel();
 }
 /** 주문 검색 */
/*
 public List<Order> findOrders(OrderSearch orderSearch) {
 return orderRepository.findAll(orderSearch);
 }
*/
}
  • 주문 생성 코드를 보면, Delivery객체와 OrderItem객체를 따로 persist하지 않았다. (JPA에 넣어주지 않았다.) 그리고 orderRepository.save(order); 하나만 실행했다.

    • ❔ 이유는? : Order 클래스의 필드 orderItems, delivery 에 적용된 Cascasde 옵션 때문이다!
    @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;
  • cascade 옵션 때문에 order를 persist 하면 order 안에 들어와 있는 delivery 엔티티와 collection 안의 orderItem 엔티티도 다 강제로 persist를 날려준다.

  • 🤔 cascade 의 범위 ? 위 두 가지 조건을 만족시킬 때 쓰는 것이 좋다.

      1. Order가 Delivery와 OrderItem을 관리한다. 참조하는 주인이 private 오너인 경우에만 써야 한다!! - 즉 Delivery와 OrderItem을 참조하는 클래스가 Order 말고 없다.
      • 만약 Delivery가 매우 중요해서 다른 엔티티에서도 Delivery를 참조하고 갖다 쓸 경우에는, 이렇게 cascade를 쓰면 안된다. 별도의 repository를 생성해서 persist를 별도로 해야 한다.
      1. persist 해야 하는 Life Cycle도 완전히 똑같다.
  • 생성 로직 메서드 말고, 기본 생성자와 set메서드를 이용해 생성하면, 생성 로직을 변경할 때 생성 로직에서 어떤필드를 더 추가하거나 로직을 더 넣는다거나 할 때 분산되기 때문에 유지보수가 어려워진다. -> 따라서 생성 로직 메서드 외의 다른 스타일의 생성을 다 막아야 한다!

    • 이를 위해 기본 생성자를 protected로 만들어두면, 외부에서 기본 생성자를 사용할 때 컴파일 오류가 뜨기 때문에 이 방법을 사용하는 것이 좋다. (@NoArgsConstructor(access = AccessLevel.PROTECTED) 사용해도 됨.)
  • 주문 취소 코드 - 이렇게 짧게 쓸 수 있는 이유는?
/**
     * 주문 취소
     */
    public void cancelOrder(Long orderId) {
        //주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);
        //주문 취소
        order.cancel();
    }

: JPA를 사용했기 때문 !
만약 일반적인 데이터베이스 SQL을 직접 다루는 라이브러리를 사용할 경우, 데이터를 변경했을 때 리포지토리에 update 쿼리를 직접 짜서 날려야 한다.
하지만, JPA를 활용하면 엔티티 안에 있는 데이터들만 바꿔주면, JPA가 알아서 바뀐 변경 포인트들을 찾아서 데이터베잇에 update 쿼리를 날려준다.(Dirty Checking, 변경 내역 감지)

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

-> 두 패턴 중 뭐가 더 옳다, 그르다 라는 개념은 없고, 현재 문맥에 더 맞는 것을 쓰면 된다.

주문 기능 테스트

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 javax.persistence.PersistenceContext;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
 @PersistenceContext
 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(expected = NotEnoughStockException.class)
 public void 상품주문_재고수량초과() throws Exception {
 //...
 }
 @Test
 public void 주문취소() {
 //...
 }
 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에서 동적 쿼리를 어떻게 해결하는가

JPQL로 처리

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로 처리


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); //최대 1000return query.getResultList();
}

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 결국 다른 대안이 필요하다. 많은 개발자가
비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간단히 언급하겠다. 지금은 이대로 진행하자.

profile
꾸준히 발전하는 개발자가 되자 !

0개의 댓글