구현 기능
1. 상품 주문
2. 주문 내역 조회
3. 주문 취소
주문, 주문상품 엔티티 개발
주문 엔티티 코드(생성메서드, 비즈니스 로직)
@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(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@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;
// orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}
}
기능 설명
- 생성 메서드( createOrder() ): 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
- 주문 취소( cancel() ): 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
- 전체 주문 가격 조회: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)
주문상품 엔티티 코드
@Entity
@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();
}
}
기능 설명
- 생성 메서드( createOrderItem() ): 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 그리고 item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
- 주문 취소( cancel() ): getItem().addStock(count) 를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
- 주문 가격 조회( getTotalPrice() ): 주문 가격에 수량을 곱한 값을 반환한다
주문 리포지토리 개발
@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);
}
// public List<Order> findAll(OrderSearch orderSearch) {}
}
주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있다. 마지막의 findAll(OrderSearch orderSearch) 메서드는 조금 뒤에 있는 주문 검색 기능에서 자세히 알아보자.
주문 서비스 개발
@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 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다.
※ 참고
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴(http://martinfowler.com/eaaCatalog/ domainModel.html)이라 한다.
반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴(http://martinfowler.com/ eaaCatalog/transactionScript.html)이라 한다.
주문 기능 테스트
테스트 요구사항
- 상품 주문이 성공해야 한다.
- 상품을 주문할 때 재고 수량을 초과하면 안 된다.
- 주문 취소가 성공해야 한다.
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
public void 상품주문 () {
//given
Member member = createMember();
Item book = createBook("시골 JPA", 10000, 10);
int orderCount = 2;
//when
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 상품주문_재고수량초과 () {
//given
Member member = createMember();
Item book = createBook("시골 JPA", 10000, 10);
int orderCount = 11;
//when
orderService.order(member.getId(), book.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 Item createBook(String name, int price, int stockQuantity) {
Item 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에서 동적 쿼리를 어떻게 해결해야 하는가?
JPA로 동적쿼리를 해결하는 방법은 크게 세가지가 있다.
- 문자열 조합 → 조건에 따라 문자열을 결합하면서 query문을 만들고 parameter를 세팅해주는 방법
- JPA Criteria → JPA 표준 스펙에서 제공하는 기능.
- queryDSL → 오픈소스를 통해 제공되는 기능으로 쿼리구현을 method로 한다.
문자열 조합
public List<Order> findAllByString(OrderSearch orderSearch) {
//language=JPAQL
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();
}
정리
- 문자열 조합 방식은 코드 작성중 쿼리에 오타가 나도 컴파일 시점에 발견하기 힘들다는 단점과 코드가 너무 스파게티처럼 복잡해진다는 단점이 있다.
- JPA Criteria는 사용법이 너무 어렵고 유지보수 난이도가 너무 높아 사실상 실무에서 적용이 힘들다.
- queryDSL은 오픈소스라 다음 Lecture에서 따로 다루도록 한다.
실무에서 위 세가지 방법이 다 어렵다면 NativeQuery를 사용하거나, case when 문법을 사용하여 해결할 수 있다. 하지만 문자열 조합 방식과 같이 오타를 컴파일 시점에서 잡아내기 힘들고 코드 검증이 힘들기 때문에 추천하는 방식은 아니다.