실전!스프링 부트와 JPA 활용 섹션 5 상품 도메인 개발 by 김영한

공부한것 다 기록해·2023년 5월 31일
0
post-thumbnail

상품 도메인 개발

구현 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

순서

  • 상품 엔티티 개발(비즈니스 로직 추가)
  • 상품 리포지토리 개발
  • 상품 서비스 개발
  • 상품 기능 테스트

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

상품 엔티티 코드

 package jpabook.jpashop.domain.item;
 import jpabook.jpashop.exception.NotEnoughStockException;
import lombok.Getter;
import lombok.Setter;
import jpabook.jpashop.domain.Category;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TYPE)
@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<Category>();
    
    // == 비즈니스 로직 == //
    public void addStock(int quantity){
    	this.stockQuantity += quantity;
    }
    
    public void removeStock(int quantity){
    	int restStock = this.stockQuantity - quantity;
        if(restStock < 0){
        	throw new NotEnoughStockException("need more stock");
        }
        
        this.stockQuantity = restStock;
    }
}

예외 추가

package jpabook.jpashop.exception;

public class NotEnoughStock Exception extends RuntimeException {
	public NotEnoughStockException(){
    }
    
    public NotEnoughStockException(String message){
    	super(message);
    }
    
    public NotEnoughStockException(String message, Throwable cause){
    	super(message, cause);
    }
    
    public NotEnoughStockException(Throwable cause){
    	super(cause);
    }
}

비즈니스 로직 분석

  • addStock()메서드는 파라미터로 넘어온 수만큼 재고를 늘린다. 이 메서드는 재고가 증가하거나 상품 주문을 취소해서 재고를 다시 늘려야 할 때 사용한다.
  • removeStock()메서드는 파라미터로 넘어온 수만큼 재고를 줄인다. 만약 재고가 부족하면 예외가 발생한다. 주로 상품을 주문할 때 사용한다.

상품 리포지토리 개발

상품 리포지토리 코드

package jpabook.jpashop.repository;
import jpabook.jpashop.domain.item.Item;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
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()

  • id가 없으면 신규로 보고 persists()실행
  • id가 있으면 이미 데이터베이스에 저장된 엔티티를 수정한다 보고, 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(){
    	itemRepository.findAll();
    }
    
    public Item findOne(Long itemId){
    	return itemRepository.findOne(itemId);
    }
}

상품 서비스는 상품 리포지토리에 단순히 위임만 하는 클래스

상품 기능 테스트
상품 테스트는 회원 테스트와 비슷하므로 생략

주문 도메인 개발

구현 기능

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

순서

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

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

주문 엔티티 개발

주문 엔티티 코드

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<OrderIte> orderItems = new ArrayList<>();
    
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery; // 배송 정보
    
    private LocalDateTime orderDate; // 주문 시가
    
    @Enumrated(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);
        ordetItem.setOrder(this);
    }
    
    public void setDelivery(Delivery delivery){
    	this.delivery = delivery;
        delivery.setOrder(this);
    }
    
    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItem){
    	Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for(OrderItem orderItem : orderItem){
        	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 : orderItem){
        	totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

기능 설명

  • 생성 메서드(createOrder()) : 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
  • 주문 취소(cancel()) : 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 에외를 발생시킨다.
  • 전체 주문 가격 조회 : 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화한다.)

주문 리포지토리 개발

주문 리포지토리 코드

package jpabook.jpahsop.repository;

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

import javax.persistence.EntityManager;

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

주문 서비스 개발

주문 서비스 코드

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

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

  • 주문(order()) : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
  • 주문 취소(cancelOrder()): 주문 식별자를 받아서 주문 엔티티르 조회한 후 주문 엔티티에 주문 취소를 요청한다.
  • 주문 검색(findOrders()): orderSearch라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다.

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

주문 기능 테스트

테스트 요구사항

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

상품 주문 테스트 코드

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(SrpingRunner.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("주문 수량만큼 재고가 줄어야 한다.",0,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;
    }
}

상품주문이 정상 동작하는지 확인하는 테스트. Given 절에서 테스트를 위한 회원과 상품을 만들고 When 절에서 실제 상품을 주문하고 Then 절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증한다.

재고 수량을 초과해서 상품을 주문해보자. 이때는 NotEnoughStockException예외가 발생해야 한다.

재고 수량 초과 테스트

@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("재고 수량 부족 예외가 발생해야 한다.");
}

코드를 보면 재고는 10권인데 orderCount = 11로 재고보다 1권 더 많은 수량을 주문했다. 주문 초과로 다음 로직에서 예외가 발생한다.

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

주문 취소 테스트

주문 취소 테스트 코드를 작성하자. 주문을 취소하면 그만큼 재고가 증가해야 한다.

주문 취소 테스트 코드

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

주문을 취소하려면 먼저 주문을 해야 한다. Given 절에서 주문하고 When 절에서 해당 주문을 취소했다.
Then 절에서 주문상태가 주문 취소 상태인지(CANCEL), 취소한 만큼 재고가 증가했는지 검증한다.

주문 검색 기능 개발

JPA에서 동적 쿼리를 어떻게 해결해야 하는가?

검색 조건 파라미터 OrderSearch

package jpabook.jpashop.domain;

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

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

package jpabook.japshop.repository;

@Repository
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){
    	//... 검색 로직
    }
}

findAll(OrderSearch orderSearh)메서드는 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회한다.

JPQL로 처리

public List<Order> findAllByString(OrderSearch orderSearch){
	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";
    }
    
    TypeQuery<Order> query = em.createQuery(jpql, Order,class)
    	.setMaxResult(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.getResults();
}

JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.

JPA Criteria로 처리

public List<Order> findAllByCriteria(OrderSearch orderSearch){
	CriteriaBuilder cb = em.getCriterialBuilder();
    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()))));
    TypeQuery<Order> query = em.createQuery(cq).setMaxResults(1000); // 최대 1000건
    
    return query.getResultList();
}

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

0개의 댓글