Intro
안녕하세요. 환이s입니다👋
이전 포스팅에서 상품 도메인 개발까지 알아봤습니다.
이어서 주문 도메인을 개발해 보겠습니다🙂
✅ 구현 기능
1️⃣ 상품 주문
2️⃣ 주문 내역 조회
3️⃣ 주문 취소
✅ 순서
1️⃣ 주문 엔티티, 주문상품 엔티티 개발
2️⃣ 주문 리포지토리 개발
3️⃣ 주문 서비스 개발
4️⃣ 주문 검색 기능 개발
5️⃣ 주문 기능 테스트
주문, 주문상품 엔티티 개발
먼저 엔티티 내에 핵심 비즈니스 로직을 구현합니다.
✅ Order
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;
}
}
주문 엔티티에 추가된 기능을 정리해 보면 다음과 같습니다.
1️⃣ 생성 메서드(createOrder()) : 주문 엔티티를 생성할 대 사용합니다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성합니다.
2️⃣ 주문 취소(cancel()) : 주문 취소 시 사용합니다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알립니다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킵니다.
3️⃣ 전체 주문 가격 조회 : 주문 시 사용한 전체 주문 가격을 조회합니다. 전체 주문 가격을 알리면 각각의 주문상품 가격을 알아야 합니다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환합니다.
(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 합니다.)
이어서 주문상품 엔티티에도 핵심 로직을 추가합니다.
✅ OrderItem
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, intcount) {
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();
}
}
주문상품 엔티티에 추가된 기능을 정리해 보면 다음과 같습니다.
1️⃣ 생성 메서드(createOrderItem()) : 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성합니다. 그리고 item.removeStock(count)를 호출해서 주문한 수량만큼 상품의 재고를 줄입니다.
2️⃣ 주문 취소(cancel()) : getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킵니다.
3️⃣ 주문 가격 조회(getTotalPrice()) : 주문 가격에 수량을 곱한 값을 반환합니다.
주문 리포지토리 개발
핵심 로직을 추가했다면 이제 리포지토리를 생성합니다.
✅ OrderRepository
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);
} }
주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능을 추가했습니다.
주문 서비스 개발
주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공합니다.
이번 포스팅에서의 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있게 로직을 작성했습니다.
✅ OrderService
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();
}
}
주문 서비스 기능을 정리해 보면 다음과 같습니다.
1️⃣ 주문(order()) : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장합니다.
2️⃣ 주문 취소(cancelOrder()) : 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청합니다.
TIP)
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.
서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 합니다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라 합니다.
반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 합니다.
다음으로 지금까지 구현된 로직을 테스트해보겠습니다.
주문 기능 테스트
✅ 테스트 요구사항
1️⃣ 상품 주문이 성공해야 한다.
2️⃣ 상품을 주문할 때 재고 수량을 초과하면 안 된다.
3️⃣ 주문 취소가 성공해야 한다.
✅ OrderServiceTest - 상품 주문이 성공해야 한다.
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;
}
}
위 코드는 상품 주문이 정상 동작하는지 확인하는 테스트입니다.
Given 절에서 테스트를 위한 회원과 상품을 만들고
When 절에서 실제 상품을 주문하고
Then 절에서 주문 가격이 올바른지,
주문 후 재고 수량이 정확히 줄었는지 검증합니다.
✅ OrderServiceTest - 상품을 주문할 때 재고 수량을 초과하면 안 된다.
재고 수량을 초과해서 상품을 주문해 보겠습니다.
이때, 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 restStock = this.stockQuantity - orderQuantity;
if (restStock < 0) {
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity = restStock;
}
}
✅ OrderServiceTest - 주문 취소가 성공해야 한다.
마지막으로 주문 취소 테스트 코드를 작성해 보겠습니다.
주문을 취소하면 그만큼 재고가 증가해야 합니다.
@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());
}
주문을 취소하려면 먼저 주문을 해야 합니다.
Given 절에서 주문하고
When 절에서 해당 주문을 취소했습니다.
Then 절에서 주문상태가 주문 취소 상태인지(CANCEL), 취소한 만큼 재고가 증가했는지 검증합니다.
마치며
오늘은 주문 도메인 개발에 대해 알아봤습니다.
주문 파트에서 핵심 기능인 검색 로직은 중요한 만큼 별도로 포스팅을 진행하려고 합니다.
다음 포스팅에서 뵙겠습니다👋
위 포스팅 글은 김영한님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발을 참고했습니다.
'[ ORM ] > JPA' 카테고리의 다른 글
[ JPA ] 변경 감지(Dirty Checking)와 병합(Merge) 알아보기 (1) | 2025.01.18 |
---|---|
[ JPA ] E-commerce 프로젝트 - 주문 검색 기능 개발 (JPQL,Criteria,Querydsl) (2) | 2025.01.13 |
[ JPA ] E-commerce 프로젝트 - 상품 도메인 개발 (1) | 2025.01.06 |
[ JPA ] E-commerce 프로젝트 - 회원 도메인 개발 (3) | 2024.12.27 |
[ JPA ] E-commerce 프로젝트 - 도메인 분석 설계 (4) | 2024.12.23 |