-
#Spring boot-10 주문 도메인 개발SPRING-BOOT 2021. 3. 4. 15:09
[출처] 인프런 김영한 강사님 -실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
주문 도메인 개발
구현 기능
· 상품 주문
· 주문 내역
· 조회
· 주문 취소
순서
· 주문 엔티티, 주문상품 엔티티 개발(핵심 비지니스로직 추가)
· 주문 리포지토리 개발
· 주문 서비스 개발
· 주문 검색 기능 개발
· 주문 기능 테스트
주문, 주문상품 엔티티 개발
주문 엔티티 개발
주문 엔티티 코드
Order class
package jpabook.jpashop.domain; import lombok.Getter; import lombok.Setter; import org.aspectj.weaver.ast.Or; 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) //em.find 해서 한건 들고올때는 가능 @JoinColumn(name = "member_id")//외래키 이름이 member_id가 된다. private Member member; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) //casecade orderitems에다가 데이터를 넣어두고 저장하면 order까지 같이 저장된다. 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); //validation 통과하면 order의 상태를 cancel로 변경 //this.orderItems 으로 써야하지만 여기선 색깔을 표시해주기도하고 this는 이름이 똑같을 때랑 강조할때만 쓴다. for(OrderItem orderItem : orderItems){//루프를 돌면서 orderItem에대해 cancel을 하면 orderItem.cancel(); //재고 증가 } } //====조회 로직 ===// 계산이 필요할 경우 /* * 전체 주문 가격 조회 * */ public int getTotalPrice(){ // int totalPrice = 0; // for (OrderItem orderItem : orderItems){ // totalPrice += orderItem.getTotalPrice(); //가격 * 수량 // } //람다식으로 변환 alt+enter int totalPrice = orderItems.stream() .mapToInt(OrderItem::getTotalPrice) .sum(); return totalPrice; } }기능 설명
· 생성 메서드( createOrder() ):
주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보 를 받아서 실제 주문 엔티티를 생성한다.
· 주문 취소( cancel() ):
주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린 다.
만약 이미 배송을 완료한 상품 이면 주문을 취소하지 못하도록 예외를 발생시킨다.
· 전체 주문 가격 조회:
주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다.
로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.
(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)
주문상품 엔티티 개발
주문상품 엔티티 코드
OrderItem class
package jpabook.jpashop.domain; import jpabook.jpashop.domain.item.Item; import lombok.Getter; import lombok.Setter; import javax.persistence.*; @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() ):
주문 가격에 수량을 곱한 값을 반환한다.
주문 리포지토리 개발
주문 리포지토리 코드
OrderRepository class
package jpabook.jpashop.Repository; import jpabook.jpashop.domain.Order; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import javax.persistence.EntityManager; 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); } //public List<Order> findAll(OrderSearch orderSearch){} }주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있다. 마지막의 findAll(OrderSearch orderSearch) 메서드는 조금 뒤에 있는 주문 검색 기능에서 자세히 알아보자
주문 서비스 개발
주문 서비스 코드
OrderService class
package jpabook.jpashop.service; import jpabook.jpashop.Repository.ItemRepository; import jpabook.jpashop.Repository.MemberRepository; import jpabook.jpashop.Repository.OrderRepository; 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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @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); //원래는 delivery save 따로 orderItem save따로 해줘야하는데 // 여기서는 order만 저장했다 이유는?? orderItems이랑, delivery에 걸려있는 Casecade옵션때문에 그렇다. //order를 persist하게되면 order의 orderItems에 들어와있는 애들과 delivery를 같이 persist해주게 된다. orderRepository.save(order); return order.getId(); } //취소 @Transactional public void cancelOrder(Long orderId){ //주문 엔티티 조회 Order order = orderRepository.findOne(orderId); //주문 취소 order.cancel(); //평상시같은 경우에는 이런식으로 변경할 경우 변경하는 sql을 또짜야한다. //JPA를 활용하면 DATA만 바꾸면 바뀐 변경점을 더티 체킹(변경 감지)으로 찾아서 DB에 UPDATE QUERY를 날림 } //검색 // public List<Order> findOrders(OrderSearch, orderSearch){ // return orderRepository.findAll(orderSearch); // } }주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검 색 기능을 제공한다.
참고: 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다.
주문( order() ):
주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
참고 :
원래는 delivery save 따로 orderItem save따로 해줘야하는데
여기서는 order만 저장했다 이유는?? orderItems이랑, delivery에 걸려있는 Casecade옵션때문에 그렇다.
order를 persist하게되면 order의 orderItems에 들어와있는 애들과 delivery를 같이 persist해주게 된다.
근데 문제가 casecade의 범위이다. 어디까지 casecade를 해야할까?
명확하진 않지만 개념은 order같은 경우 order가 orderItem, delivery를 관리한다. 이런경우에서만 써야한다.
즉 , delivery가 order말고 다른곳에서안쓰고 orderItem도 order말고 다른데서 안쓰는 이런경우에서만 사용해야한다.정리 :
1. life cycle에 대해서 동일하게 관리를 할때 의미가 있다.
2. 다른데서 참조할 수 없는 private owner인 경우에 도움을 받을 수 있다.
3. 다른엔티티에서 참조해서 쓰고 그러면 쓰면 안된다.· 만약 생성 메소드를 안만들고 order할때 set으로 다 정의 할 경우 생긴느 문제점.
여기 로직에서는 이렇게 만들고 , 저기로직에서는 또 다르게 만들면 유지보수할때 힘들어진다.
생성 로직에서 필드를 추가한다거나 로직을 더 넣는다거나 이게 분산되니깐 생성자 이외에
다른 스타일은 다막아놔야한다.
· 생성자 메소드 이외 다른 스타일 막는 방법 protected를 걸어주거나
@NoArgsConstructor(access = AccessLevel.PROTECTED)를 해준다.
protected OrderItem(){ }@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Order {그럼 아 ~이건 쓰지 말라는거구나 JPA쓰면서 이것은 쓰지말라는 거라고 인식을 한다.
주문 취소( cancelOrder() ):
주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
참고 :
평상시같은 경우에는 이런식으로 변경할 경우 변경하는 sql을 또짜야한다.
JPA를 활용하면 DATA만 바꾸면 바뀐 변경점을 더티 체킹(변경 감지)으로 찾아서 DB에 UPDATE QUERY를 날림주문 검색( findOrders() ):
OrderSearch 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.
서비스 로직 참고: 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.
즉, 서비스 계층 은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
이처럼 엔티티가 비즈니스 로직을 가지고 객체 지 향의 특성을 적극 활용하는 것을
도메인 모델 패턴(http://martinfowler.com/eaaCatalog/domainModel.html)이라 한다.
P of EAA: Domain Model
martinfowler.com
도메인 모델 패턴의 장점은 Repository이런거 관계없이 order Entity에 대해서 그냥 테스트를 작성해버리는 것이다.
단위 테스트 핵심 로직자체가 Entity에 있기 때문이다.
반대로 엔티티에는 비즈니스 로직이 거의 없고(getter,setter뿐이고)
서비스 계층에서 대부분 의 비즈니스 로직을 처리하는 것을
트랜잭션 스크립트 패턴(http://martinfowler.com/eaaCatalog/transactionScript.html)이라 한다.
P of EAA: Transaction Script
| P of EAA Catalog | Transaction Script Organizes business logic by procedures where each procedure handles a single request from the presentation. For a full description see P of EAA page 110 Most business applications can be thought of as a series of tra
martinfowler.com
주문 기능 테스트
테스트 요구사항
· 상품 주문이 성공해야 한다.
· 상품을 주문할 때 재고 수량을 초과하면 안 된다.
· 주문 취소가 성공해야 한다.
상품 주문 테스트 코드
OrderServiceTest class
//원래는 통태보다 단위테스트가 좋다. @Test public void 상품주문() throws Exception{ //given Member member =new Member(); member.setName("회원1"); member.setAddress(new Address("서울","강가","123-123")); em.persist(member); Item book = new Book(); book.setName("시골 JPA"); book.setPrice(10000); book.setStockQuantity(10); em.persist(book); 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()); //10 - 2 }상품주문이 정상 동작하는지 확인하는 테스트다. Given 절에서 테스트를 위한 회원과 상품을 만들고 When 절에서 실제 상품을 주문하고 Then 절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었 는지 검증한다.
재고 수량 초과 테스트
재고 수량을 초과해서 상품을 주문해보자. 이때는 NotEnoughStockException 예외가 발생해야 한다
재고 수량 초과 테스트 코드
@Test(expected = NotEnoughStockException.class) public void 상품주문_재고수량초과() throws Exception{ //given Member member = createMember(); Item item = createBook(10000, "시골 JPA", 10); int orderCount =11;//11로해줘서 아래에서 exception이 터져야한다. 초과예외가 터져서 끝남. //10으로 하게되면 정상적으로 돌고 fail까지 내려가고 에러 발생. //when orderService.order(member.getId(),item.getId(),orderCount); // //then fail("재고 수량 부족 예외가 발생해야 한다."); System.out.println("11이면 안넘오오지~ 예외가 터져서"); } private Member createMember() { Member member =new Member(); member.setName("회원1"); member.setAddress(new Address("서울","강가","123-123")); em.persist(member); return member; } private Item createBook(int price, String name, int stockQuantity) { Item book = new Book(); book.setName(name); book.setPrice(price); book.setStockQuantity(stockQuantity); em.persist(book); return book; }주문 취소 테스트
주문 취소 테스트 코드를 작성하자. 주문을 취소하면 그만큼 재고가 증가해야 한다.
주문 취소 테스트 코드
@Test public void 주문취소() throws Exception{ //given Member member = createMember(); Item item = createBook(10000, "시골 JPA", 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()); //cancel해서 다시 10개로 복구가 되야한다. 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 Item createBook(int price, String name, int stockQuantity) { Item book = new Book(); book.setName(name); book.setPrice(price); book.setStockQuantity(stockQuantity); em.persist(book); return book; }주문을 취소하려면 먼저 주문을 해야 한다. Given 절에서 주문하고 When 절에서 해당 주문을 취소했다. Then 절에서 주문상태가 주문 취소 상태인지( CANCEL ), 취소한 만큼 재고가 증가했는지 검증한다.
주문 검색 기능 개발
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] }검색을 추가한 주문 리포지토리 코드
public List<Order> findAll(OrderSearch orderSearch){ return em.createQuery("select o from Order o join o.member m" + "where o.status = :status" + "and m.name like :name" , Order.class) .setParameter("status",orderSearch.getOrderStatus()) .setParameter("name",orderSearch.getMemberName()) .getResultList(); }findAll(OrderSearch orderSearch) 메서드는 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회한다.
여기서 문제점이 값이 없으면 setParameter자체가 필요없어진다.
조회쿼리 같은 경우 사용자의 요구에따라 동적 쿼리가 되야한다.
아니면 name이 null이면 주문이던 취소던 상태 체크하지말고 다 들고와야하는 상황은 어떻게 처리하면 될까?JPQL로 처리( X )
public List<Order> findAll(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())) {//text에 이값이 있으면 어떻게 합니까? 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(); }JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.
결론 이렇게 짜면 안된다.. mybatis,ibatis를 쓰는 이유가 이런 동적쿼리 작성에 용이하기 때문이다.
JPA Criteria로 처리( X ) - JPA표준 스팩
/* * * JPA Criteria * */ //JPA제공하는 동적쿼리를 만들기위한 표준이다. 이것도 권장하는 방법은 아니다. why? 실무에서 쓰라고 만든게 아니다. 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); //최대1000건 return query.getResultList(); }JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 그리고 유지보수 측면에서도 힘들다. 다른 사람들이 알아보기 힘들기 때문,
결국 다른 대안이 필요하다. 많 은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간 단히 언급하겠다. 지금은 이대로 진행하자.
참고: JPA Criteria에 대한 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 책을 참고하자
'SPRING-BOOT' 카테고리의 다른 글
#실전2 -1 postman 설치 및 RESTAPI (0) 2021.03.07 #Spring boot-11 웹 계층 개발 (0) 2021.03.04 #Spring boot-9 상품 도메인 개발 (0) 2021.03.04 #Spring boot-8_1 테스트 케이스 작성 중 에러 (0) 2021.03.04 #Spring boot-8 회원 기능 테스트 (0) 2021.03.04