이전 상품 엔티티에 비즈니스 로직 붙인 것 처럼 주문에도 적용하도록 한다. Order 엔티티 클래스에 Order를 생성하기 위해 생성자가 아닌 정적 팩토리 메서드를 고려한다.("생성자 대신 정적 팩터리 메서드를 고려하라"-이펙티브 자바 ) Order 엔티티 객체는 생성에 대해 까다로운 과정을 가진 객체이다. 연관된 필드에 Cascade까지 적용되어있어 Order 객체만 잘 구성해서 persist 해준다면 Cascade가 선언된 필드에 해당하는 객체들도 persist된다.
주문 생성, 주문 취소, 주문에 들어있는 모든 주문 상품에 대한 전체 가격 조회 비즈니스 로직을 엔티티 클래스에 담아낼 것이다.
//== 생성 메서드==//
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;
}
생성자로 해결하는 것이 아니기에 Order에 정보들을 넣는 순간과 생성자로 부여되는 시점이 구분된다.
외부에서 주입받은 member와 delivery, orderItem... 객체들을 활용하여 Order를 세팅한다. order를 리턴 및 반환받아 이를 persist해주면 cascade 정책에 따라 delivery, orderItems또한 persist된다.
//== 비즈니스 로직==//
/**
* 주문 취소
*/
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
}
this.setStatus(OrderStatus.CANCEL);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
만약 주문에 담긴 배달 객체의 상태가 COMP(배송 완료)라면 예외를 터트린다. 그렇지 않다면 Status를 CANCEL로 잡아주고 주문에 포함된 각각의 orderItem들에 대해 orderItem.cancel()를 해준다.
orderItem.cancel()은 단순히 Item 엔티티의 메서드인 addStock()을 통해 stock 수를 올려준다.
//==조회 로직==//
/**
* 전체 주문 가격 조회
*/
public int getTotalPrice() {
return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}
// OrderItem::getTotalPrice -> return orderPrice * count;
설명 생략
@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);
}
/**
* JPA Criteria (권장X) -> QuertDsl이 짱이다...
*/
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<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);
return query.getResultList();
}
}
findOne까지는 매우 빠르게 작성이 가능하다. 다만 findAllByCriteria()는 동적 쿼리에 관련하여 JPACriteria를 사용한 것이다. 사실 그 이전에 순수한 자바로 하여금 jpql을 동적으로 짤 수도 있지만 그나마 개선한 느낌이 Criteria를 활용한 것이다. 실제론 Criteria를 절대 쓰지 않는다곤 한다. 후에 제대로 배울 Querydsl로 JPA의 대부분의 동적 쿼리 문제를 해결할 수 있다.
그러므로 위의 조건부 전체 조회 기능은 "그냥 이런게 있구나 하고 넘어가도록 하자."(코드를 보면 이걸 쓰라고 만든건지 매우 난해하다.)
보다시피 querydsl은 공부를 하지 않고 코드를 보더라도 굉장히 sql이 어떻게 작동될지 생각이 나면서도 동적인 문제를 쉽게 처리하는 느낌이 든다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
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); // Cascade로 하여금 Delivery, OrderItem 자동 persist 된다.
return order.getId();
}
/**
* 주문 취소
*/
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findOne(orderId);
order.cancel();
}
/**
* 전체 조건부 조회
*/
public List<Order> findOrders(OrderSearch orderSearch) {
return orderRepository.findAllByCriteria(orderSearch);
}
}
엔티티에 비즈니스 로직을 작성하는 방식으로 코딩했더니 서비스 계층이 굉장히 깔끔해진 느낌이다. 여러 메서드들을 단지 위임하는 느낌이 든다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 한다. 프로그램이 커지면 서비스에 적히는 비즈니스 로직들이 굉장히 많을 것이라 생각한다. 이에 대해 좀 더 엔티티쪽에서 다뤄도 될 부분들을 엔티티에 붙여 서비스 계층에서 코드를 분산시켜 유지보수에 대해 복잡성을 flatten하게 만든다는 생각이 든다. 실제로 Autowired로 주입받아 처리할 필요 없는 저수준의 엔티티 관련 로직들은 엔티티쪽에서 메서드를 짜서 활용하는 것이 옳다고 생각이 들었다.
@SpringBootTest
@Transactional
@ExtendWith(SpringExtension.class)
class OrderServiceTest {
@Autowired
EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void 상품주문() {
// given
Member member = createMember("회원1", "서울", "인계동", "7890-12");
Book book = createBook("GOD JPA", 10000, 10);
// when
int orderCount = 2;
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
Order getOrder = orderRepository.findOne(orderId);
// then
assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.ORDER);
assertThat(getOrder.getOrderItems().size()).isEqualTo(1);
assertThat(getOrder.getTotalPrice()).isEqualTo(10000 * orderCount);
assertThat(book.getStockQuantity()).isEqualTo(10 - orderCount);
}
@Test
void 상품주문_재고수량초과() {
// given
Member member = createMember("회원1", "서울", "인계동", "7890-12");
Item item = createBook("GOD JPA", 10000, 10);
// when
int orderCount = 11;
// then
Assertions.assertThatThrownBy(() -> orderService.order(member.getId(), item.getId(), orderCount))
.isInstanceOf(NotEnoughStockException.class);
}
@Test
void 주문취소() {
// given
Member member = createMember("회원1", "서울", "인계동", "7890-12");
Item item = createBook("GOD JPA", 10000, 10);
int orderCount = 2;
// when
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
orderService.cancelOrder(orderId);
Order orderAfterCancel = orderRepository.findOne(orderId);
// then
assertThat(orderAfterCancel.getStatus()).isEqualTo(OrderStatus.CANCEL);
}
private Member createMember(String name, String city, String street, String zipcode) {
Member member = new Member();
member.setName(name);
member.setAddress(new Address(city, street, zipcode));
em.persist(member);
return member;
}
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;
}
}
Junit4에서 5로 넘어오면서 @RunWith이 아닌@ExtendWith(SpringExtension.class)를 써야 한다는 것을 알게되었다.
사실 이렇게 큰 테스트를 만드는 것보다는 메서드 하나하나 단위 테스트를 만들어 스프링에 최대한 독립적으로 테스트를 구성하는 것이 좋다고 한다.