본문 바로가기
Dev/JPA

[JPA] JPA 활용 I - 주문 도메인 개발

by dev_jsk 2021. 9. 23.
728x90
반응형

상품 주문, 주문내역 조회, 주문 취소 기능을 개발해보자

주문, 주문상품 엔티티 개발

엔티티 내에 핵심 비즈니스 로직을 구현

파일 경로

  • 주문 엔티티 : main/java/jpabook/jpashop/domain/Order.java
  • 주문상품 엔티티 : main/java/jpabook/jpashop/domain/OrderItem.java

소스 구현

주문 엔티티

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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)
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)  // ENUM 클래스 값 형식 설정
    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 : this.orderItems) {
            orderItem.cancel(); // 재고 복구
        }
    }

    /**
     * 전체 주문 가격 조회
     * @return
     */
    public int getTotalPrice() {
        int totalPrice = 0;

        for(OrderItem orderItem : this.orderItems) {
            totalPrice += orderItem.getTotalPrice();    // 상품 가격 누적
        }

        return totalPrice;
    }

}
  • 주문 생성 메소드 createOrder()
    - 주문을 생성할 때 사용하는 메소드로 주문 회원, 배송정보, 주문상품 정보를 받아 주문 엔티티를 생성한다.
  • 주문 취소 cancel()
    - 주문 취소 시 사용하는 메소드로 주문 상태를 취소로 변경하고 주문상품의 취소 함수를 호출하여 재고를 복구한다. 주문 상태가 배송완료일 경우 주문 취소가 불가능하도록 예외를 발생시킨다.
  • 전체 주문 가격 조회 getTotalPrice()
    - 주문 상품의 가격을 누적하여 주문의 총 가격을 조회하는 메소드이다.
  • @NoArgsConstructor
    - 기본 생성자를 생성해주는 어노테이션으로 접근 옵션을 설정할 수 있다. protected옵션으로 설정할 경우 기본 생성자를 해당 클래스 이외에선 사용할 수 없어 생성 메소드를 강제화할 수 있는 장점이 있다.

주문상품 엔티티

@Entity
@Table(name = "order_item")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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(this.count);
    }

    /**
     * 주문 상품 전체 가격 조회
     * @return
     */
    public int getTotalPrice() {
        // 주문금액 X 수량
        return getOrderPrice() * getCount();
    }
}
  • 주문상품 생성 메소드 createOrderItem()
    - 주문 상품 엔티티를 생성하는 생성 메소드, 주문 상품 생성 시 해당 상품의 재고를 감소시킨다.
  • 주문 취소 cancel()
    - 취소 요청된 주문에 포함된 상품의 재고수량을 복구하는 메소드이다.
  • 주문 상품 총 가격 조회 getTotalPrice()
    - 주문에 포함된 상품의 총 가격을 계산하는 메소드이다. 주문금액 X 수량

생성 메소드

Setter를 통해 값을 설정하여 생성하는 것이 아닌 엔티티 내에 생성 메소드를 구성하여 연관관계를 갖는 엔티티까지 한번에 생성하는 메소드Setter를 사용하여 엔티티를 생성, 설정하게 되면 여기저기서 사용하기 때문에 유지보수 측면에서 굉장히 복잡하다. 그렇기 때문에 기본 생성자의 접근 권한을 protected로 설정하여 사용하지 못하도록 설정하여 생성 메소드를 강제한다.

주문 레포지토리 개발

파일 경로

main/java/jpabook/jpashop/repository/OrderRepository.java

소스 구현

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    /**
     * save Order
     * 
     * @param order
     */
    public void save(Order order) {
        em.persist(order);
    }

    /**
     * find one Order
     * 
     * @param id
     * @return
     */
    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }
}
  • 주문 저장 메소드와 특정 1개 주문 조회 메소드 구현
  • 특정 조건을 이용하여 여러 주문 조회 메소드는 이후 주문 검색 기능 개발 부분에서 상세히 작성

주문 서비스 개발

파일 경로

main/java/jpabook/jpashop/service/OrderService.java

소스 구현

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    /**
     * save Order
     * @param memberId
     * @param itemId
     * @param count
     * @return
     */
    @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, OrderItem이 cascade 설정이 되어있기 때문에 주문만 저장해도 다 저장된다.)
        orderRepository.save(order);

        return order.getId();
    }

    /**
     * cancel Order
     * @param orderId
     */
    @Transactional
    public void cancelOrder(Long orderId) {
        // 취소할 주문 정보 조회
        Order order = orderRepository.findOne(orderId);
        // 주문 취소
        order.cancel();
    }
}
  • 주문 order()
    - 주문하려는 회원의 ID, 상품의 ID, 주문 수량을 받아 주문을 생성하는 메소드이다. 추가적으로 해당 회원의 배송정보를 조회하여 배송정보도 셋팅하여 주문을 생성한다.
  • 주문 취소 cancelOrder()
    - 취소하려는 주문 ID를 받아 주문을 취소하는 메소드이다.
  • 주문 엔티티에 배송, 주문상품 엔티티가 Cascade ALL 설정이 되어있기 때문에 주문 엔티티만 저장해도 전부 저장된다.
  • Cascade 설정은 참조하는 것의 주인이 Private Owner(참조하는 곳이 한 곳)일 경우에만 사용한다. 추가적으로 라이프사이클 상 같이 persist가 필요할 경우에도 사용한다.

SQL을 직접 다루는 방식 vs JPA

주문 취소의 경우 주문의 상태, 주문상품의 수량 변경에 따른 데이터 수정이 필요하다. 이때 SQL을 직접 다룰경우 해당 데이터 수정을 위한 쿼리를 실행하기 위해 데이터 조회 후 파라미터 바인딩을 하는 로직이 필요하지만 JPA를 사용할 경우 데이터 변경이 일어났을 때 자동으로 수정 쿼리가 실행된다.

도메인 모델 패턴 vs 트랜잭션 스크립트 패턴

도메인 모델 패턴이란 비즈니스 로직이 엔티티 내부에 존재하는 것으로 엔티티가 비즈니스 로직을 갖고 객체 지향의 특성을 적극 활용하고 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 하는 것이다. 반대로 트랜잭션 스크립트 패턴이란 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것이다. 두 패턴은 옳고 나쁘다는 것이 없고 유지보수 측면으로 봤을 때 더 좋은 방법을 사용하면 되는 것이고, 한 프로젝트 내에서도 두가지 패턴을 모두 사용하는 경우도 있기 때문에 현재 문맥에서 어떤 패턴이 더 나은지 판단하여 사용하면 된다.

주문 기능 테스트

테스트 요구사항

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안되고 초과할 경우 예외가 발생해야 한다.
  • 주문 취소가 성공해야 한다.

테스트 파일 경로

test/java/jpabook/jpashop/service/OrderServiceTest.java

테스트 구현

@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);

        // then
        Order getOrder = orderRepository.findOne(orderId);

        assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
        assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 X 수량이다.", 10000 * orderCount, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 8, book.getStockQuantity());
    }
    
    @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();
        Book book = createBook("JPA", 10000, 10);

        int orderCount = 2;

        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        // when
        orderService.cancelOrder(orderId);

        // then
        Order getOrder = orderRepository.findOne(orderId);

        assertEquals("주문 취소시 상태는 CANCEL이다.", OrderStatus.CANCEL, getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, book.getStockQuantity());
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        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;
    }
}

테스트 결과

상품 주문 테스트

상품 주문에 따른 주문 상태, 상품 종류 수, 주문 가격, 재고 결과가 정상적으로 동작한것을 좌측 마커를 통해 알 수 있다.

상품 주문 테스트 실패

재고 예측을 18개로 했을 경우 테스트 실패 내역

상품 주문시 재고 확인 테스트

상품 주문 시 재고보다 많은 수량을 주문할 경우 예상한 NotEnoughStockException이 발생한다.

주문 취소 테스트

주문 취소에 따른 주문 상태, 재고 결과가 정상적으로 동작한 것을 좌측 마커를 통해 알 수 있다.

주문 검색 기능 개발

주문 서비스와 레포지토리에 검색 메소드를 추가한다.

주문 서비스

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    // order(), cancelOrder()

    /**
     * find Orders
     * @param orderSearch
     * @return
     */
    public List<Order> findOrders(OrderSearch orderSearch) {
        return orderRepository.findAll(orderSearch);
    }
}

주문 검색조건

package jpabook.jpashop.repository;

@Getter @Setter
public class OrderSearch {
    
    private String memberName;  // 회원 이름
    private OrderStatus orderStatus;  // 주문 상태
}

주문 레포지토리

package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    // save(), findOne()

    /**
     * find All Orders
     * @param orderSearch
     * @return List<Order>
     */
    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())
                .setMaxResults(1000).getResultList();
    }

    /**
     * JPQL 동적으로 문자열 구성
     * @param orderSearch
     * @return List<Order>
     */
    public List<Order> findAllByString(OrderSearch orderSearch) {
        // language=JPQL
        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();
    }

    /**
     * Criteria 사용
     * @param orderSearch
     * @return List<Order>
     */
    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();
    }
}
  • 검색조건이 항상 있다면 findAll() 메소드가 가장 간단한 방법이지만 검색조건이 항상 있을 수 없고 두 조건 모두 있을 경우가 항상 있는 것은 아니기 때문에 동적으로 쿼리를 구현해야 한다.
  • Query String을 동적으로 구성하는 findAllByString() 메소드의 경우 구성 자체가 매우 어렵고 번거로우며 실수로 인한 버그가 발생할 수 있다.
  • JPA 표준 스펙인 Criteria 객체를 사용한 findAllByCriteria() 메소드의 경우도 구성이 어렵기 때문에 실무에선 사용하지 않는다.

위 3가지 방법 모두 동적 쿼리 구성에 어려움이 있어 많은 개발자들이 고민을 하였고 QueryDSL이 가장 좋은 해결책을 제공하면서 뒤에서 QueryDSL을 이용하여 재구성할 예정이다.

728x90
반응형

댓글