본문 바로가기
BackEnd/JPA

[JPA] 주문 도메인 개발

by 개발 Blog 2024. 10. 3.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

주문 도메인은 JPA를 활용하여 상품 주문, 주문 내역 조회, 주문 취소 등의 기능을 구현하는 데 중요한 역할을 한다. 이번 글에서는 주문 엔티티와 주문상품 엔티티의 변경 사항과 추가된 기능에 대해 설명한다.

 

주문, 주문상품 엔티티

order

package jpabook.jpashop.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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;

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

기능 설명

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

생성자 접근 수준

주문 엔티티에는 @NoArgsConstructor(access = AccessLevel.PROTECTED) 어노테이션이 적용되어 있다. 이는 기본 생성자의 접근 수준을 PROTECTED로 설정하여, 외부에서 직접 객체를 생성하는 것을 방지한다. 이를 통해 도메인 모델의 무결성을 보장하고, 객체 생성은 오직 createOrder 메서드를 통해서만 이루어지도록 하여, 잘못된 상태의 객체가 생성되는 것을 방지한다.

 

orderItem

package jpabook.jpashop.domain;

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

@Entity
@Getter
@Setter
@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();
    }
}

기능 설명

  • 생성 메서드 (createOrderItem()): 주문 상품, 가격, 수량 정보를 사용하여 주문 상품 엔티티를 생성한다. 또한, item.removeStock(count)를 호출하여 주문한 수량만큼 상품의 재고를 감소시킨다.
  • 주문 취소 (cancel()): getItem().addStock(count)를 호출하여 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
  • 주문 가격 조회 (getTotalPrice()): 주문 가격에 수량을 곱한 값을 반환한다.

생성자 접근 수준

주문상품 엔티티에도 @NoArgsConstructor(access = AccessLevel.PROTECTED) 어노테이션이 적용되어 있다. 이 역시 기본 생성자의 접근 수준을 PROTECTED로 설정하여, 외부에서의 직접적인 객체 생성을 차단한다. 이를 통해 주문상품 객체는 오직 정의된 생성 메서드를 통해서만 생성될 수 있으며, 이로 인해 비즈니스 로직의 안전성을 높인다.

 

주문 리포지토리

주문 리포지토리에서는 동적 쿼리를 JPQL과 Criteria로 작성하였지만, 실제 업무에서는 사용하지 않는 방식이다. 이 부분을 개선하기 위해 Querydsl을 사용하여 리팩토링할 계획이다.

package jpabook.jpashop.repository;

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

import java.util.ArrayList;
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);
    }

    // 첫 번째 방법 (실무에서 안씀)

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

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

    // 두 번째 방법 (실무에서 안씀)
    /**
     * 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();
    }
}

 

주문 서비스

주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용하여 주문, 주문 취소, 주문 내역 검색 기능을 제공한다. 이 예제에서는 단순화를 위해 한 번에 하나의 상품만 주문할 수 있도록 설정하였다.

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;

import java.util.List;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
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.cancel();
    }

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

기능 설명

  • 주문 (order()): 주문하는 회원의 식별자, 상품의 식별자, 주문 수량 정보를 받아 실제 주문 엔티티를 생성한 후 저장한다.
  • 주문 취소 (cancelOrder()): 주문 식별자를 받아 주문 엔티티를 조회한 후, 해당 주문 엔티티에 주문 취소 요청을 한다.
  • 주문 검색 (findOrders()): OrderSearch라는 검색 조건을 가진 객체를 통해 주문 엔티티를 검색한다. 이 기능은 다음 섹션에서 더 자세히 설명하겠다.

참고 사항

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

 

주문 기능 테스트

주문 서비스의 기능을 테스트하기 위한 단위 테스트를 작성하였다. 테스트는 상품 주문, 재고 수량 초과 시 예외 발생, 주문 취소 시 재고 증가 여부 등을 검증한다.

 

테스트 요구사항

  • 상품 주문이 성공해야 한다: 주문 기능이 정상적으로 작동하는지 확인한다.
  • 재고 수량 초과 방지: 상품을 주문할 때 재고 수량을 초과하지 않도록 검증한다.
  • 주문 취소가 성공해야 한다: 주문 취소 기능이 올바르게 작동하는지 테스트한다.
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.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 static org.junit.Assert.*;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

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

    @Test
    public void 상품주문() throws Exception {
        // given
        Member member = createMember();

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

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

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = createMember();
        Book 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, getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
    }

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

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }
}

 

이와 같이 주문 도메인은 JPA를 활용하여 상품 주문 및 관련 비즈니스 로직을 효과적으로 관리하며, 전체 시스템의 안정성과 유지보수성을 높인다. 도메인 모델 패턴을 통해 엔티티가 비즈니스 로직을 관리함으로써 코드의 가독성과 재사용성을 향상시킬 수 있다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 웹 계층 개발(2)  (0) 2024.10.05
[JPA] 웹 계층 개발(1)  (2) 2024.10.04
[JPA] 상품 도메인 개발  (0) 2024.10.03
[JPA] 회원 도메인 개발  (0) 2024.10.02
[JPA] 도메인 분석 설계(3)  (2) 2024.10.01