728x90
주문, 주문 상품 엔티티 개발 (비즈니스 로직 추가)
📌 Order 엔티티 (주문)
package bookbook.shopshop.domain;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static javax.persistence.CascadeType.ALL;
import static javax.persistence.FetchType.LAZY;
import static lombok.AccessLevel.PROTECTED;
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate; // 주문 시간
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태 [ORDER / CANCEL]
// == 연관관계 메서드 ==//
public void changeMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
// == 연관관계 메서드 ==//
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
// == 연관관계 메서드 ==//
public void changeDelivery(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;
// stream 표현식
return orderItems.stream()
.mapToInt(OrderItem::getTotalPrice)
.sum();
}
}
- 엔티티는 기본 생성자가 필수로 있어야 하기 때문에 접근 권한을 protected로 설정하여 기본 생성자로 인스턴스를 만들지 못하게 만들었다.
- createOrder() : 생성 메서드다.
- 주문 엔티티를 생성할 때 사용한다.
- 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 인티티를 생성한다.
- cancel() : 주문 취소
- 주문 취소시 사용한다.
- 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다.
- 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
- 전체 주문 가격 조회
- 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.
📌 OrderItem 엔티티 (주문상품)
package bookbook.shopshop.domain;
import bookbook.shopshop.domain.item.Item;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import static javax.persistence.FetchType.LAZY;
import static lombok.AccessLevel.PROTECTED;
@Entity
@Getter @Setter
@NoArgsConstructor(access = PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@ManyToOne(fetch = 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 count * orderPrice;
}
}
- createOrderItem() : 생성 메서드
- 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다.
- 그리고 item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
- cancel() : 주문 취소
- getItem().addStock(count) 를 호출해서 쥐소한 주문 수량만큼 상품의 재고를 증가시킨다.
- getTotalPrice() : 주문 가격 조회
- 주문 가격에 수량을 곱한 값을 반환한다.
주문 리포지토리
package bookbook.shopshop.repository;
import bookbook.shopshop.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);
}
// TODO
/**
* 검색
*/
// public List<Order> findAll(OrderSearch orderSearch) {}
}
주문 서비스 개발
📌 OrderService
package bookbook.shopshop.service;
import bookbook.shopshop.domain.Delivery;
import bookbook.shopshop.domain.Member;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.domain.OrderItem;
import bookbook.shopshop.domain.item.Item;
import bookbook.shopshop.repository.ItemRepository;
import bookbook.shopshop.repository.MemberRepository;
import bookbook.shopshop.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 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();
}
// TODO
/**
* 검색
*/
// public List<Order> findOrders(OrderSearch orderSearch) {
// return orderRepository.findAll(orderSearch);
// }
}
- Order() : 주문
- 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한다.
- cancelOrder() : 주문 취소
- 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
- findOrders() : 주문 검색
- OrderSearch라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다.
- 나중에 개발할 예정
참고로 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 존재한다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴(DDD)이라고 한다.
반대로 엔티티에는 비즈니스 로직이 거의 없고, 서비스 계층에는 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.
주문 기능 테스트
package bookbook.shopshop.service;
import bookbook.shopshop.domain.Address;
import bookbook.shopshop.domain.Member;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.domain.OrderStatus;
import bookbook.shopshop.domain.item.Book;
import bookbook.shopshop.domain.item.Item;
import bookbook.shopshop.exception.NotEnoughStockException;
import bookbook.shopshop.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void 상품주문() {
// given
Member member = createMember();
Book book = createBook("시골 JPA", 10000, 10);
int orderCount = 2;
// when
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
// then
Order getOrder = orderRepository.findOne(orderId);
assertThat(OrderStatus.ORDER).isEqualTo(getOrder.getStatus());
assertThat(getOrder.getOrderItems().size()).isEqualTo(1);
assertThat(getOrder.getTotalPrice()).isEqualTo(10000 * orderCount);
assertThat(book.getStockQuantity()).isEqualTo(8);
}
@Test
void 상품주문_재고수량초과() {
// given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 11;
// when
// then
assertThrows(NotEnoughStockException.class,
() -> orderService.order(member.getId(), item.getId(), orderCount));
}
@Test
void 주문취소() {
// 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);
assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.CANCEL);
assertThat(item.getStockQuantity()).isEqualTo(10);
}
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;
}
}
해당 테스트는 좋은 테스트는 아니다. 좋은 테스트는 DB, 스프링에 의존하지 않고 단위 테스트하는 것이 좋다. 지금은 JPA를 공부하고 있기 때문에 JPA 기능이 제대로 작동하는지 확인하기 위해 테스트를 JPA 의존적으로 설계했다.
참고로 도메인 모델 패턴 사용시 리포지토리 관계없이 엔티티 내부에 있는 비즈니스 로직에 대해서 단위 테스트를 작성할 수 있다.
주문 검색 기능 개발
📌 OrderSearch
package bookbook.shopshop.repository;
import bookbook.shopshop.domain.OrderStatus;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class OrderSearch {
private String memberName;
private OrderStatus orderStatus; // 주문 상태 [ORDER, CANCEL]
}
📌 OrderRepository (검색 기능 추가)
package bookbook.shopshop.repository;
import bookbook.shopshop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
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 orderId) {
return em.find(Order.class, orderId);
}
// TODO
/**
* 검색
* 실무에서는 이렇게 jpql로 쓰지 않고 QueryDSL을 사용한다.
*/
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();
}
}
실무에서는 동적 쿼리를 QueryDSL로 사용한다.
📌 OrderService (검색 기능 추가)
package bookbook.shopshop.service;
import bookbook.shopshop.domain.Delivery;
import bookbook.shopshop.domain.Member;
import bookbook.shopshop.domain.Order;
import bookbook.shopshop.domain.OrderItem;
import bookbook.shopshop.domain.item.Item;
import bookbook.shopshop.repository.ItemRepository;
import bookbook.shopshop.repository.MemberRepository;
import bookbook.shopshop.repository.OrderRepository;
import bookbook.shopshop.repository.OrderSearch;
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.findAllByString(orderSearch);
}
}
👀 참고 자료
실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 질문 & 답변 - 인프런 | 강의
수강생이 남긴 질문과 지식공유자의 답변을 확인할 수 있어요. 질문 & 답변 | 인프런...
www.inflearn.com
728x90
'[JPA] > 스프링 부트와 JPA 활용 1 - 웹 애플리케이션 개발' 카테고리의 다른 글
[JPA] 웹 계층 개발 (0) | 2022.04.05 |
---|---|
[JPA] 상품 도메인 개발 (0) | 2022.04.04 |
[JPA] 회원 도메인 개발 (0) | 2022.04.04 |
[JPA] 도메인 분석 설계 (0) | 2022.04.01 |