본문 바로가기

spring boot/JPA

[ JPA ] 상품 / 주문 도메인 개발

 

 

[ 상품 도메인 개발 ]

구현 기능 :  상품 등록, 상품 목록 조회, 상품 수정

순서 : 상품 엔티티 개발(비지니스 로직 추가) > 상품 리포지토리 개발 > 상품 서비스 개발

 

 

 


 

 

 

 

상품 엔티티 개발 

 

상품 수량을 늘리고, 줄이는 기능을 개발할껀데,

엔티티 자체에 해결할 수 있는것은 엔티티 안에서 비지니스 로직을 만드는것이 응집도가 있다.

/* 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;
}

 setter를 통해 값을 변경하는게 아니고 add,remove 같은 로직을 설정해서 변경해야 한다. 

 

Exception을 관리하는 패키지를 하나 생성하고 NotEnoughStockException 파일을 만들어준다.

package jpabook.jpashop.exception;

public class NotEnoughStockException extends RuntimeException{

    // 오버라이드 (ctrl+o)
    public NotEnoughStockException() {
        super();
    }

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

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

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

}

 

 

 

 

 

상품 리포지토리 개발

 

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 메소드에서,

id가 으면 신규로 보고 persist() 실행, 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> findItem(){
        return itemRepository.findAll();
    }
    public Item findOne(Long itemId){
        return itemRepository.findOne(itemId);
    }
}

 

상품 서비스는 리포지토리에 위임만하는 메소드여서 간단하게 끝이났다

 


 

 

 

[ 주문 도메인 개발 ]

 

 

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

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

 

 

 

 

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

 

 

주문 생성은 복잡하다. 이렇게 복잡한 생성은 별도의 생성 메서드가 있으면 좋다.

 

order.java 파일에 생성메서드 작성

//==생성 메서드==//
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;
}

위의 order가 연관관계를 쫙 걸면서 셋팅이 되고, 주문 상태랑 시간까지 정리해준다.

나중에 뭔가 변경해야할게 있으면 createOrder만 변경해주면 되니까 간단!

 

비지니스 로직을 추가해주자

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

 

그럼 orderItem.java에도 취소에 대한 비지니스 로직이 필요하기 때문에 코드를 작성해줘야한다.

여기도 생성메서드 먼저 만들어주고, 

// == 생성 메서드 ==
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);
    }

 

다시 order.java로 돌아와서 

전체 주문 가격을 조회하는 로직을 만들어준다.

public int getTotalPrice() {
    int totalPrice = 0;
    for (OrderItem orderItem : orderItems) {
        totalPrice += orderItem.getTotalPrice();
    }
    return totalPrice;
}

 

 

 

 

주문 리포지토리 개발

 

package jpabook.jpashop.repository;

import jakarta.persistence.EntityManager;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import lombok.extern.java.Log;
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) { ... }
}

검색기능은 동적쿼리 내용을 담고있기때문에 나중에 내용을 추가해주겠다

 

 

 

주문 서비스 개발

 

주문, 취소, 검색 기능을 중심으로 만들어보자

여러개의 리포지토리를 가져와야 한다.

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

    //주문 상품 생성(static 생성 메서드로 생성함)
    OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

    //주문 생성(static 생성 메서드로 생성함)
    Order order = Order.createOrder(member, delivery, orderItem);

    //주문 저장
    orderRepository.save(order);

    /* cascade 해야하는 범위 ? 참조할때 주인이 private일 경우에만 */

    return order.getId();

}

 

/* 취소 */
@Transactional
public void cancelOrder(Long orderId){
    // 주문 엔티티 조회
    Order order = orderRepository.findOne(orderId);
    // 주문 취소
    order.cancel();

}

 

마이바티스같은걸 썼다면, 취소후 변경되는 상태를 update를 쿼리를 작성해서 날려서 변경해줘야하는데,

JPA는 데이터가 바뀌면 변경내역감지가 이루어져서 알아서 update쿼리가 날라가서 변경해준다.

// 검색
public List<Order> findOrders(OrderSearch orderSearch){
    return orderRepository.findAll(orderSearch);
}

검색은 나중에 사용할거여서 일단 만들어만 놨다

 

 

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

 

 

 

 

 

주문 검색 기능 개발

 

JPA에서 동적쿼리를 사용하는방법을 알아보자

 

리포지토리 패키지에 orderSearch.java 파일을 만들어준다.

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)

}

 

이제 orderRepository에서 구현해보자

위에 주석처리한 findAll 메서드에서 내용을 추가한다.

public List<Order> findAll(OrderSearch orderSearch){
	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) // 최대 1000건
        .getResultList();
}

이렇게 작성해주면 간단하게 join된 테이블에서 값을 가져올 수 있다. 하지만 위의 코드는 값이 존재한다는 전제가 있다.

어떤 값이있다면 이렇게 처리해, 저렇게 처리해 > 이런 동적쿼리를 해결하는것이 복잡하고 까다롭다.

 

여러개의 방법이 있는데, JPQL로 처리하는 방법 혹은 JPA Criteria로 처리하는 방법이 있다.

 

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

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기엔 너무 복잡하다..

 

해결책은 Querydsl ! 이후에 심화과정에서 배우고 일단은 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); //최대 1000건
    
    return query.getResultList();
}

정말 코드만 보고서는 어떤 쿼리문이 생길지 가늠이 안간다 ㅜ