springboot

[springboot] 실전! 스프링부트와 JPA 활용 - 4. 주문 도메인(Order)

힝뿌 2023. 11. 14. 23:00
반응형

상품 도메인 개발에 이어 이번에는 주문 도메인을 구현하려고 한다.

주문 도메인 개발은 내용이 많으니 차근차근 정리를 해보자!

 

 

 

주문 도메인 개발

구현 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

 

순서

  • 주문 엔티티, 주문상품 엔티티 개발
  • 주문 Repository 개발
  • 주문 서비스 개발
  • 주문 검색 기능 개발
  • 주문 기능 테스트

 

 

 

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

주문 엔티티 개발

주문 엔티티 코드

@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(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @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;
    }
}
  • 생성 메서드(createOrder())
    • 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송 정보, 주문 상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
  • 주문 취소(cancel())
    • 주문 취소 시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
  • 전체 주문 가격 조회
    • 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문 상품들의 가격을 조회해서 더한 값을 반환한다.
    • 실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)

 

... 자바의 가변인자(varargs)
메서드가 동적으로 변하는 개수의 인자를 받을 수 있도록 하는 문법이다.
가변인자를 사용하면 메서드를 호출할 때 인자의 개수를 미리 정하지 않고도 여러 개의 인자를 전달할 수 있다.

가변인자의 주요 특징
1. 가변인자는 매개변수 리스트의 마지막에 위치해야 한다.
    - 즉, 가변인자는 한 번만 사용되고, 그 뒤에 다른 매개변수가 올 수 없다.
2. 가변인자는 배열로 처리된다.
    - 가변인자를 사용하면 메서드 내에서는 해당 타입의 배열로 처리된다.
3. 가변인자를 사용한 메서드는 인자를 전달할 때 배열이나 인자의 개수를 직접 지정할 필요가 없다.
    - 가변인자를 사용한 메서드를 호출할 때 인자를 여러 개 전달하면 그 인자들이 배열로 자동으로 묶인다.

 

가변인자 예시 코드)

public class VarargsExample {
    public static void printNumbers(String label, int... numbers) {
        System.out.println(label);
        for (int num : numbers) {
            System.out.println(num);
        }
    }

    public static void main(String[] args) {
        printNumbers("Numbers:", 1, 2, 3, 4, 5);
    }
}

 

 

 

주문 상품 엔티티 개발

 

주문 상품 엔티티 코드

@Entity
@Table(name = "order_item")
@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 개발

주문 repository 코드

주문 repository에는 주문 엔티티를 저장하고 검색하는 기능이 있다.

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

 

 

 

 

 

주문 서비스 개발

주문 서비스 코드

참고사항
예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다고 가정한다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
    
    private final MemberRepository memberRepository;
    private final OrderRepository orderRepository;
    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());
        delivery.setStatus(DeliveryStatus.READY);
        
        // 주문상품 생성
        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();
    }
    
}

 

참고사항

주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직이 대부분 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.

 

 

 

 

주문 기능 테스트

테스트 요구사항

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다.
  • 주문 취소가 성공해야 한다.

 

 

상품 주문 테스트 코드

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @PersistenceContext
    EntityManager em;

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {

        // Given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10);        // 이름, 가격, 재고
        int orderCount = 2;

        // When
        Long orderId = orderService.order(member.getId(), item.getId(), orderCount);

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

        assertEquals("상품 주문 시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
        assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다.", 10000*2, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 8, item.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.setStockQuantity(stockQuantity);
        book.setPrice(price);
        em.persist(book);
        return book;
    }


}

 

Given 절에서 테스트를 위한 회원과 상품을 만들고, When 절에서 실제 상품을 주문하고, Then 절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증한다.

 

 

 

 

재고 수량 초과 테스트

재고 수량을 초과해서 상품을 주문해 보자. 이때는 NotEnoughStockException 예외가 발생해야 한다.

 

재고 수량 초과 테스트 코드

    @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("재고 수량 부족 예외가 발생해야 한다.");

    }

 

재고는 10권인데 재고보다 1권 더 많은 수량을 주문했기 때문에 주문 초과로 Item 엔티티의 removeStock 메서드에서 예외가 발생한다.

 

 

 

주문 취소 테스트 코드

주문을 취소하면 그만큼 재고가 증가해야 한다.

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

    }

 

주문을 취소하려면 먼저 주문을 해야 한다.

Given 절에서 주문하고, When 절에서 해당 주문을 취소했다. Then 절에서 주문 상태가 취소 상태인지, 취소한 만큼 재고가 증가했는지 검토한다.

 

 

 

작성한 모든 테스트 코드가 성공하는 것을 확인할 수 있다.

 

 

 

 

주문 검색 기능 개발

검색 조건 파라미터 OrderSearch

@Getter
public class OrderSearch {
    
    private String memberName;      // 회원 이름
    
    private OrderStatus orderStatus;        // 주문 상태 [ORDER, CANCEL]
    
}

 

 

 

검색을 추가한 주문 Repository 코드

@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) 메서드는 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회한다.

 

 

 

 

 

JPQL로 처리

 public List<Order> findAll(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();
    }

 

  • isFirstCondition
    • JPQL 쿼리 문자열을 동적으로 생성할 때 첫 번째 조건인지 여부를 나타내는 플래그 변수이다. JPQL은 동적으로 생성되는 쿼리에서 조건을 추가할 때 WHERE 키워드와 AND 또는 OR를 사용하여 조건들을 연결하는데, isFirstCondition을 사용하여 첫 번째 조건을 추가할 때는 WHERE을 추가하고, 그 이후의 조건들은 AND로 추가하는 로직이다.
  • .setMaxResults
    • JPQL 쿼리에서 반환되는 결과의 최대 수를 제한하는 데 사용한다. 특정 결과 집합만 필요한 경우 대량의 레코드를 데이터베이스에서 가져오는 것을 피하기 위해 일반적으로 사용된다.
  • TypedQuery
    • JPA에서 제공하는 인터페이스 중 하나로, 타입 안정성을 갖는 쿼리를 작성하고 실행할 수 있도록 도와주는 인터페이스다.
    • 제네릭 타입을 사용하며, 쿼리가 변환하는 결과의 타입을 명시적으로 지정할 수 있다. 이는 런타임 시에 타입 안정성을 제공하여 컴파일러가 유용한 경고나 오류를 통해 코드의 안정성을 높인다.
    • 즉, 기본 Query 인터페이스를 사용하는 경우에는 쿼리의 결과를 Object로 받게 되므로 컴파일러는 결과에 대한 타입 체크를 할 수 없다. 하지만 TypedQuery를 사용하면 명시적으로 결과의 타입을 지정하므로 컴파일러가 타입 안정성을 확보할 수 있는 것이다.

 

JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.

 

JPQL이란?

JPQL(Java Persistence Query Language)은 객체지향 데이터베이스의 엔티티 대상으로 쿼리를 작성하기 위한 자바 기반 쿼리이다. JPQL은 JPA(Java Persistence API)의 일부로서, 엔티티 객체를 대상으로 쿼리를 작성하고 실행할 수 있도록 도와준다. 이 언어는 SQL과는 다르게 데이터베이스 특정 용어나 테이블 구조에 의존하지 않고 엔티티 객체 모델을 기반으로 쿼리를 수행한다.

 

 

 

 

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는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 결국 다른 대안이 필요하다는 의미로, 많은 개발자 분들이 비슷한 고민을 한 결과, 가장 멋진 해결책은 Querydsl이 제시했다고 한다. 지금은 이대로 진행한다.

다음번에 Querydsl에 대해서 더 자세하게 공부해서 기록해 봐야겠다.

참고: JPA Criteria에 대한 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 책을 참고하자!

 

 

 

 

많은 내용들이 있는 주문 관련 부분 개발 단계였다.

처음 들어보는 JPA Criteria와 Querydsl, JPQL들이 나와 당황스러웠다.

내용도 복잡해 보이고 코드도 길어 공부하기 막막하다는 생각이 들었다..^^

실전! 스프링부트와 JPA 활용 1, 2 전부 완주하면 그 뒤에는 ORM 표준 JPA 프로그래밍 책으로 JPA도 공부해 봐야겠다!

사실 책은 올해 2월부터 있었던 것 같은데... 열어보질 않았다는....

반응형