반응형
구현 기능
- 상품 주문
- 주문 내역 조회
- 주문 취소
순서
- 주문 엔티티, 주문상품 엔티티 개발
- 주문 리포지토리 개발
- 주문 서비스 개발
- 주문 검색 기능 개발
- 주문 기능 테스트
주문, 주문상품 엔티티 개발
domain/Order에 다음 코드 추가
//==생성 메서드==//
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) { //COMP = 배송완료
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
}
//위의 validation을 통과하면 아래 메서드 실행
this.setStatus(OrderStatus.CANCEL); //OrderItem에는 재고를 다시 늘려주는 비즈니스 로직 추가
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
//==조회 로직==//
/**
* 전체 주문 가격 조회
*/
public int getTotalPrice() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice(); //왜 orderItem에 있는 getTotalPrice()를 가져오냐
} // 주문가격 * 주문 수량을 해야하기 때문 / 따라서 OrderItem에는 이 둘을 곱하는 메서드 추가
return totalPrice;
}
- Order만 생성해서 될 게 아니라 orderItem, delivery 등등 여러 연관관계가 들어가서 복잡해지는데 이런 복잡한 생성은 별도의 생성 메서드가 있으면 좋다.
- 생성메서드를 쓰는 게 왜 중요할까?
- 앞으로 무언가 생성하는 시점에서 변경해야 하는 것이 생기면 막 연관관계 메서드들을 이것저것 찾을 필요없이 createOrder메서드만 변경하면 되기 때문이다.
- for (OrderItem orderItem : orderItems) : 원래는 this.orderItems인데 인텔리제이가 알아서 orderItems에 색을 넣어주기 때문에 this는 생략 / 정말 강조하거나 이름이 똑같을 때 외에는 잘 안씀(=김영한 강사님 스타일 → 그냥 따라해라)
기능 설명
- 생성 메서드 createOrder() : 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
- 주문 취소 cancel() : 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
- 전체 주문 가격 조회: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)
OrderItem에 다음 코드 추가
//==생성 메서드==//
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() : 주문 가격에 수량을 곱한 값을 반환한다.
주문 리포지토리 개발
package jpabook.jpashop.repository;
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);
}
// public List<Order> findAll(OrderSearch orderSearch) { ... }
}
- 주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있다. 마지막의 findAll(OrderSearch orderSearch) 메서드는 뒤의 주문 검색 기능에서 설명하겠다.
주문 서비스 개발
package jpabook.jpashop.service;
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 jpabook.jpashop.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) { //주문 취소할 때 id가 넘어옴
//주문 엔티티 조회
Order order = orderRepository.findOne(orderId);
//주문 취소
order.cancel();
}
//검색
public List<Order> findOrders(OrderSearch orderSearch) {
return orderRepository.findAllByString(orderSearch);
}
}
- orderRepository.save(order) : 이거 하나만 저장을 해주면 orderItem과 delivery는 어떻게 되는 걸까
- 아래 코드를 보면 이전에 Order클래스에서 CascadeType.All을 걸어준 적이 있다.
- 이 말이 무슨 말이냐면 Order를 persist하면 컬렉션으로 들어와있는 OrderItem도 persist 해준다.
- delivery도 CascadeType.All이 걸어져 있다. Order가 persist될 때 delivery 엔티티도 persist 해주는 것이다.
- 그러므로 orderRepository.save(order) 이렇게 하나만 저장을 해줘도 OrderItem과 delivery도 자동으로 persist가 되는 것이다.
- Cascade는 예제처럼 Order가 OrderItem과 delivery를 관리하는 경우처럼 참조하는 것의 주인이 private owner인 경우에만 써야한다. 무슨말이냐면 OrderItem과 delivery는 Order말고 아무데서도 쓰지 않는다. 이처럼 다른 곳에서 쓰지 않고 참조할 수 없는 경우에 Cascade를 사용하면 도움을 받을 수 있다.
- 예를 들어 delivery가 되게 중요해서 다른 곳에서도 delivery를 쓰면 이렇게 막 Cascade를 쓰면 안된다.
- 주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.
- 참고: 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다.
참고 : 내가 개발할때는 createOrderItem을 생성해서 불러와서 사용하는 방법을 쓴다고 해도 다른 사람과 같이 개발할 때 그 사람은 내가 이러한 방법을 쓴 줄 모르고 다시 OrederItem을 생성해서 예를 들어 setCount() 이런식으로 개발할 수도 있다. 그렇게 되면 서로 로직이 달라서 나중에 유지보수하기가 상당히 어려워진다. 그래서 애초에 OrderItem과 Order를 다른곳에서 새롭게 생성해서 로직을 만들지 말게끔 protected OrderItem() { } 이런식으로 제약을 걸어두는게 좋다. 근데 이것 또한 롬복으로 줄일 수 있다. @NoArgsConstructor(access = AccessLevel.PROTECTED) 를 사용햐면 된다. OrderItem과 Order에 추가해준다.
기능 설명
- 주문 order() : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
- 주문 취소 cancelOrder() : 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
- 주문 검색 findOrders() : OrderSearch 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.
참고
- 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 한다.
주문 기능 테스트
테스트 요구사항
- 상품 주문이 성공해야 한다.
- 상품을 주문할 때 재고 수량을 초과하면 안 된다.
- 주문 취소가 성공해야 한다.
package jpabook.jpashop.service;
import jakarta.persistence.EntityManager;
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 static org.junit.Assert.*;
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
public void 상품주문() throws Exception {
//given 테스트를 위한 회원과 상품을 만들고
Member member = createMember();
Book book = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고
int orderCount = 2;
//when 실제 상품을 주문하고
Long orderId = orderService.order(member.getId(), book.getId(), orderCount); //order의 식별자 값이 반환이되도록 설계를
//해놨으니 Long orderId
//then 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증한다. / DB에 있는 데이터를 가져와서 조회(orderId(order의 식별자 값))
Order getOrder = orderRepository.findOne(orderId);
//assertEquals(String message, @Nullable Object expected, Object actual(실제값))
assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
assertEquals("주문한 상품 종류 수가 정확해야 한다.",1, getOrder.getOrderItems().size());
assertEquals("주문 가격은 가격 * 수량이다.", 10000 * 2, getOrder.getTotalPrice());
assertEquals("주문 수량만큼 재고가 줄어야 한다.",8, book.getStockQuantity()); //위에서 재고 10개라 했으니 10-2=8
}
@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 주문취소() throws Exception { //주문을 취소하면 그만큼 재고가 증가해야 한다.
//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 주문상태가 주문 취소 상태인지( CANCEL ), 취소한 만큼 재고가 증가했는지 검증한다.
Order getOrder = orderRepository.findOne(orderId);
assertEquals("주문 취소시 상태는 CANCEL 이다.",OrderStatus.CANCEL, getOrder.getStatus());
assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
}
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;
}
}
- JPA와 엮어서 테스트가 잘 동작하는지 보려고 통합테스트를 쓰는거지 사실 좋은 테스트는 아니다. DB와 dependency 없고 스프링도 엮지 않은 단위 테스트를 많이 사용하도록 하자
주문 검색 기능 개발
- JPA에서 위와 같은 동적 쿼리를 어떻게 해결해야 하는가?
검색 조건 파라미터 OrderSearch
package jpabook.jpashop.repository;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class OrderSearch {
private String memberName; //회원 이름
private OrderStatus orderStatus;//주문 상태[ORDER, CANCEL]
}
- 이 파라미터 조건이 있어야 whrer문에서 검색이 되는 것이다.
검색을 추가한 주문 리포지토리 코드
package jpabook.jpashop.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
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);
}
public List<Order> findAllByString(OrderSearch orderSearch) {
String jpql = "select o from Order o join o.member m";
boolean isFirstCondition = true;
//주문 상태 검색
if (orderSearch.getOrderStatus() != null) { //JPQL로 처리
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();
}
/**
*
* JAP Criteria
*/
public List<Order> findAllByCriteria(OrderSearch orderSearch) { //JPA Criteria로 처리
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> o = cq.from(Order.class);
Join<Object, Object> 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();
}
}
- JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.
- JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하고 유지보수가 거의 안된다. 결국 다른 대안이 필요하다. 많은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간단히 언급하겠다. 지금은 이대로 진행하자
OrderRepository : Querydsl로 처리
public List<Order> findAll(OrderSearch orderSearch) {
QOrder order = Qorder.order;
QMember member = QMember.member;
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 order.status.eq(statusCond;)
}
private BooleanExpression nameLike(String nameCond) {
if (!StringUtils.hasText(nameCond)) {
return null;
}
return member.name.like(nameCond);
}
출처 : 인프런 - 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발(김영한 강사님)
'JPA' 카테고리의 다른 글
API 개발 기본 (0) | 2023.05.29 |
---|---|
웹 계층 개발 (0) | 2023.05.21 |
상품 도메인 개발 (0) | 2023.05.21 |
도메인 분석 설계 / 애플리케이션 구현 준비 / 회원 도메인 개발 (0) | 2023.05.10 |
JPA와 DB설정, 동작확인 (0) | 2023.05.10 |