💡 본 게시글은 김영한님의 인프런(Inflearn) 강의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발에 대해 공부하고, 정리한 내용입니다.
1. 주문 도메인 개발
1) 구현 기능
- 상품 주문
- 주문 내역 조회
- 주문 취소
2) 구현 순서
- 주문 엔티티, 주문상품 엔티티 개발
- 주문 리포지토리 개발
- 주문 서비스 개발
- 주문 검색 기능 개발
- 주문 기능 테스트
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) 테스트 요구사항
- 상품 주문이 성공해야 한다.
- 상품을 주문할 때 재고 수량을 초과하면 안 된다.
- 주문 취소가 성공해야 한다.
(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. 총 정리
- 주문 및 주문상품 엔티티 개발
Order
엔티티는 주문 상태 관리, 주문 생성, 취소, 조회 등의 비즈니스 로직을 포함하며,OrderItem
엔티티는 주문 상품의 가격 조회, 주문 취소 로직을 포함합니다.
- 주문 리포지토리 개발
OrderRepository
는@Repository
와EntityManager
를 사용해 주문 데이터를 관리하며, 주문 저장 및 조회 기능을 제공합니다. 동적 쿼리를 사용한 주문 검색 기능도 포함됩니다.
- 주문 서비스 개발
OrderService
는@Service
와@Transactional
을 사용해 주문과 관련된 비즈니스 로직을 관리합니다. 주문 생성(order()
), 주문 취소(cancelOrder()
), 주문 검색(findOrders()
) 기능을 제공하며, 대부분의 비즈니스 로직을 엔티티에 위임합니다.
- 주문 기능 테스트
- 주문과 관련된 기능들이 올바르게 동작하는지 테스트 코드를 통해 검증합니다. 테스트 요구사항은 상품 주문 성공, 재고 수량 초과 방지, 주문 취소 성공 등입니다.
- 동적 쿼리와 검색 기능 구현
- 주문 검색 기능은 동적 쿼리를 필요로 하며, 이를 JPQL, JPA Criteria, Querydsl 등 다양한 방법으로 구현할 수 있습니다. 실무에서는 가독성과 유지보수성을 고려해 Querydsl을 많이 사용합니다.
- 도메인 모델 패턴의 이해
- 서비스 계층은 비즈니스 로직을 엔티티에 위임하며, 엔티티가 비즈니스 로직을 직접 관리하는 구조를 통해 객체 지향적 설계를 강조합니다. 이를 도메인 모델 패턴이라 하며, 엔티티가 핵심 로직을 담도록 설계합니다.
'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 |