본문 바로가기

Spring/웹 애플리케이션 개발

06. 주문 도메인 개발

💡 본 게시글은 김영한님의 인프런(Inflearn) 강의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발에 대해 공부하고, 정리한 내용입니다.

1. 주문 도메인 개발

1) 구현 기능

  1. 상품 주문
  2. 주문 내역 조회
  3. 주문 취소

2) 구현 순서

  1. 주문 엔티티, 주문상품 엔티티 개발
  2. 주문 리포지토리 개발
  3. 주문 서비스 개발
  4. 주문 검색 기능 개발
  5. 주문 기능 테스트

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

(1) 주문 엔티티 코드

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(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @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;
    }
}

(2) 기능 설명

  • 생성 메서드(createOrder()): 주문 회원, 배송 정보, 주문 상품의 정보를 받아 주문 엔티티를 생성합니다.
  • 주문 취소(cancel()): 주문 상태를 CANCEL로 변경하고, 주문상품에 주문 취소를 알립니다. 이미 배송 완료된 상품은 주문을 취소할 수 없도록 예외가 발생합니다.
  • 전체 주문 가격 조회: 모든 주문상품의 가격을 합산하여 전체 주문 가격을 조회합니다. (실무에서는 전체 주문 가격 필드를 두고 역정규화합니다.)

(3) 주문상품 엔티티 코드

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import jpabook.jpashop.domain.item.Item;
import javax.persistence.*;

@Entity
@Table(name = "order_item")
@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();
    }
}

(4) 기능 설명

  • 생성 메서드(createOrderItem()): 주문 상품, 가격, 수량 정보를 사용하여 주문상품 엔티티를 생성하고, 주문한 수량만큼 상품의 재고를 줄입니다.
  • 주문 취소(cancel()): 취소된 주문 수량만큼 상품의 재고를 증가시킵니다.
  • 주문 가격 조회(getTotalPrice()): 주문 가격에 수량을 곱한 값을 반환합니다.

4) 주문 리포지토리 개발

(1) 주문 리포지토리 코드

package jpabook.jpashop.repository;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderSearch;
import jpabook.jpashop.domain.QMember;
import jpabook.jpashop.domain.QOrder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.*;
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();
    }

    /** QueryDSL로 동적 쿼리 처리 */
    public List<Order> findAll(OrderSearch orderSearch) {
        QOrder order = QOrder.order;
        QMember member = QMember.member;

        JPAQueryFactory query = new JPAQueryFactory(em);

        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 QOrder.order.status.eq(statusCond);
    }

    private BooleanExpression nameLike(String nameCond) {
        if (!StringUtils.hasText(nameCond)) {
            return null;
        }
        return QMember.member.name.like(nameCond);
    }
}

(2) 기능 설명

  • save(): 주문 엔티티를 저장합니다.
  • findOne(): 특정 주문을 ID로 조회합니다.
  • findAllByString(): JPQL을 사용하여 동적 쿼리를 생성해 주문을 조회합니다.
  • findAllByCriteria(): JPA Criteria API를 사용하여 동적 쿼리를 생성해 주문을 조회합니다.
  • findAll(OrderSearch orderSearch): QueryDSL을 사용하여 동적 쿼리를 생성해 주문을 조회합니다.

5) 주문 서비스 개발

(1) 주문 서비스 코드

package jpabook.jpashop.service;

import jpabook.jpashop.domain.*;
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 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);
    }
}

(2) 기능 설명

  • 주문(order()): 회원 식별자, 상품 식별자, 주문 수량 정보를 받아 주문 엔티티를 생성하고 저장합니다.
  • 주문 취소(cancelOrder()): 주문 식별자를 받아 주문 엔티티에 주문 취소를 요청합니다.
  • 주문 검색(findOrders()): OrderSearch 객체를 사용하여 주문 엔티티를 검색합니다.

주문 서비스의 주요 로직은 엔티티에 비즈니스 로직을 위임하며, 이를 통해 도메인 모델 패턴을 구현합니다.

6) 주문 기능 테스트

(1) 테스트 요구사항

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

(2) 상품 주문 테스트 코드

package jpabook.jpashop.service;

import jpabook.jpashop.domain.*;
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 {

        //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 주문취소() {

        //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, getOrder.getStatus());
        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;
    }
}

(3) 테스트 설명

  • 상품 주문 테스트: 주문 상태, 주문 상품 종류 수, 주문 가격, 재고 수량 감소 여부를 검증합니다.
  • 재고 수량 초과 테스트: 재고보다 많은 수량을 주문하면 NotEnoughStockException 예외가 발생하는지 확인합니다.
  • 주문 취소 테스트: 주문을 취소하면 주문 상태가 CANCEL로 변경되고, 재고가 증가하는지 확인합니다.

7) 주문 검색 기능 개발

(1) 주문 검색 기능 코드

  • 검색 조건 파라미터(OrderSearch)
package jpabook.jpashop.domain;

public class OrderSearch {

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

    // Getter, Setter
    public String getMemberName() {
        return memberName;
    }

    public void setMemberName(String memberName) {
        this.memberName = memberName;
    }

    public OrderStatus getOrderStatus() {
        return orderStatus;
    }

    public void setOrderStatus(OrderStatus orderStatus) {
        this.orderStatus = orderStatus;
    }
}

(2) 검색 기능 구현 방법

  • JPQL로 처리: 문자열로 JPQL 쿼리를 생성하여 처리합니다.
  • JPA Criteria로 처리: JPA 표준 스펙을
  • 사용하지만, 복잡하고 가독성이 떨어집니다.
  • Querydsl로 처리: 자바 코드로 작성하여 가독성이 좋고, 오타를 컴파일 시점에서 잡을 수 있습니다.

(3) Querydsl 코드

public List<Order> findAll(OrderSearch orderSearch) {

    QOrder order = QOrder.order;
    QMember member = QMember.member;

    JPAQueryFactory query = new JPAQueryFactory(em);

    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 QOrder.order.status.eq(statusCond);
}

private BooleanExpression nameLike(String nameCond) {
    if (!StringUtils.hasText(nameCond)) {
        return null;
    }
    return QMember.member.name.like(nameCond);
}

8. 총 정리

  1. 주문 및 주문상품 엔티티 개발
    • Order 엔티티는 주문 상태 관리, 주문 생성, 취소, 조회 등의 비즈니스 로직을 포함하며, OrderItem 엔티티는 주문 상품의 가격 조회, 주문 취소 로직을 포함합니다.
  2. 주문 리포지토리 개발
    • OrderRepository@RepositoryEntityManager를 사용해 주문 데이터를 관리하며, 주문 저장 및 조회 기능을 제공합니다. 동적 쿼리를 사용한 주문 검색 기능도 포함됩니다.
  3. 주문 서비스 개발
    • OrderService@Service@Transactional을 사용해 주문과 관련된 비즈니스 로직을 관리합니다. 주문 생성(order()), 주문 취소(cancelOrder()), 주문 검색(findOrders()) 기능을 제공하며, 대부분의 비즈니스 로직을 엔티티에 위임합니다.
  4. 주문 기능 테스트
    • 주문과 관련된 기능들이 올바르게 동작하는지 테스트 코드를 통해 검증합니다. 테스트 요구사항은 상품 주문 성공, 재고 수량 초과 방지, 주문 취소 성공 등입니다.
  5. 동적 쿼리와 검색 기능 구현
    • 주문 검색 기능은 동적 쿼리를 필요로 하며, 이를 JPQL, JPA Criteria, Querydsl 등 다양한 방법으로 구현할 수 있습니다. 실무에서는 가독성과 유지보수성을 고려해 Querydsl을 많이 사용합니다.
  6. 도메인 모델 패턴의 이해
    • 서비스 계층은 비즈니스 로직을 엔티티에 위임하며, 엔티티가 비즈니스 로직을 직접 관리하는 구조를 통해 객체 지향적 설계를 강조합니다. 이를 도메인 모델 패턴이라 하며, 엔티티가 핵심 로직을 담도록 설계합니다.

'Spring > 웹 애플리케이션 개발' 카테고리의 다른 글

05. 상품 도메인 개발  (0) 2024.09.08
04. 회원 도메인 개발  (0) 2024.09.08
03. 애플리케이션 구현 준비  (2) 2024.09.08
02. 도메인 분석 설계  (2) 2024.09.08
01. 프로젝트 환경설정  (0) 2024.09.08