관리 메뉴

나구리의 개발공부기록

주문 도메인 개발, 주문 및 주문상품 엔터티 개발, 주문 리포지토리 개발, 주문 서비스 개발, 주문 기능 테스트, 주문 검색 기능 개발 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발

주문 도메인 개발, 주문 및 주문상품 엔터티 개발, 주문 리포지토리 개발, 주문 서비스 개발, 주문 기능 테스트, 주문 검색 기능 개발

소소한나구리 2024. 9. 30. 11:56

출처 : 인프런 - 실전! 스프링 부트와 JPA활용1 - 웹 애플리케이션 개발(유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  


1. 주문 및 주문상품 엔터티 개발

1) Order -  메서드 추가

(1) 생성 메서드, createOrder()

  • 주문 엔터티를 생성할 때 사용하며, 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔터티를 생성
  • 주문 생성에 대한 복잡한 비즈니스 로직을 엔터티안에 만듦으로써 밖에서 Order를 생성하여 값을 set하는 방식이 아니라 주문 생성이 되면 createOrder를 생성에 필요한 정보 및 설정을함
  • 이렇게 생성 메서드를 만들면 생성에 관련한 변경 점이 있을 때 해당 엔터티에서 이 생성메서드만 고치면 되므로 유지 보수성이 좋아지고 코드도 호출해서 사용할 수 있으므로 재사용성이 높아지며 생성 시 항상 동일한 로직으로 생성하게 되므로 일관성을 유지할 수 있는 등의 다양한 장점이 존재함
  • 생성메서드는 실무에서는 훨씬더 복잡함

(2) 비즈니스 로직 - 주문 취소, cancel()

  • 주문 취소시 사용하며 주문 상태를 취소로 변경하고 주문 상품에 취소를 알림 -> oderItem.candel() 호출
  • 배송상태가 COMP(완료)면 주문을 취소하지 못하도록 예외를 발생시킴

(3) 비즈니스 로직 - 전체 주문 가격 조회, getTotalPrice()

  • 주문 시 사용한 전체 주문 가격을 조회
  • 전체 주문 가격을 알려면 각각의 주문 상품 가격을 알아야 하기 때문에 연관된 주문 상품들의 가격들 조회해서 더한 값을 반환함
  • 반복문을 사용해서 주문 상품에 대한 가격을 꺼낼 수도 있고 Java8에서 지원하는 스트림을 이용하면 훨씬 깔끔하게 연관된 주문 상품의 값을 꺼내서 반환할 수 있음
  • 실무에서는 주로 주문에 전체 주문 가격필드를 두고 역정규화를 함
// 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() == DeliverStatus.COMP) {
        throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다");
    }
    // 취소 상태일 때는 재고를 원복
    // OrderItem의 cancel()를 호출해서 재고를 증가
    this.setStatus(OrderStatus.CANCEL);
    for (OrderItem orderItem : orderItems) {
        orderItem.cancel();
    }
}

// 전체 주문 가격 조회
public int getTotalPrice() {
    // stream 문법으로 작성
    // mapToInt(): 스트림의 각 요소를 int 타입으로 변환
    // OrderItem.getTotalPrice의 값을 모두 더해서 반환
    return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();

// 기본 문법으로 작성
//    int totalPrice = 0;
//    for (OrderItem orderItem : orderItems) {
//        totalPrice += orderItem.getTotalPrice();
//    }
//    return totalPrice;
}

2) OrderItem - 메서드 추가

(1) 생성 메서드, createOrderItem()

  • 주문 상품, 가격, 수량 정보를 사용해서 주문 상품 엔터티를 생성
  • item.removeStock(count)을 호출해서 주문 수량만큼 상품의 재고를 줄임

(2) 비즈니스 메서드, cancel(), getTotalPrice()

  • 주문취소, cancel(): 주문이 취소되면 getItem().addStock(count)을 호출해서 취소한 주문 수량 만큼 상품의 재고를 증가시킴(재고 원복)
  • 주문 가격 조회, getTotalPrice(): 주문 가격에 수량을 곱한 값을 반환
// 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();    // 주문 가격 * 주문 수량 = 상품 주문 가격
}

2. 주문 리포지토리 개발

1) OrderRepository

  • 주문 엔터티 저장, 검색 기능이 있음
  • findAll(): 전체 조회 메서드는 주문 검색 기능 개발 시 자세히 설명(동적 쿼리 필요)
@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) {}
}

3. 주문 서비스 개발

  • 주문 엔터티와 주문 상품 엔터티의 비즈니스 로직을 활용해서 주문, 주문취소, 주문내역검색 기능을 제공
  • 예제를 단순화 하려고 한 번에 하나의 상품만 주문할 수 있음

(1) order(), 주문 생성

  • 주문하는 회원 식별자와 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔터티를 생성한 후 저장
  • OrderItem과 Order의 생성을 만들어둔 생성 메서드가 아닌 new를 사용해서 생성할 수 없도록 OderItem과 Order에 @NoArgsConstructor(assess = AccessLevel.PROTECTED)를 적용
  • 엔터티 개발시 cascade를 적용하여 orderRepository(save)하나로도 OrderItem, Delivery에서도 persist가 일어남
  • 지금과 같이 Order에서만 OrderItem, Delivery가 참조가 일어나는 구조에서는 적용해도 되지만 cascade를 적용한 대상이 여러 곳에서 참조가 일어나는 경우에는 별도의 Repository를 만들어서 persist를 따로 하는 것이 좋음
  • 이러한 개념이 잡혀져 있지 않다면 cascade를 개발시에는 적용하지 말고 추후 리펙토링 때 필요시에 변경하는 것을 권장

(2) cancelOrder(), 주문 취소

  • 주문 식별자를 받아서 주문 엔터티를 조회한 후 주문 엔터티에 주문 취소를 요청
  • 보통은 SQL을 직접 다루는 MyBatis나 JDBC template을 사용하는 경우는 데이터 변경시 업데이트에 일어나는 상세한 변경에 대한 쿼리를 파라티러를 입력해서 전부 작성해야 해야 해서 Service 계층에 비즈니스 로직이 많이 몰리게 되었음
  • JPA를 확용하면 엔터티 안에서 데이터들만 바꿔주면 변경 내역 감지가 일어나면서 변경된 내역들을 찾아 데이터베이스에 업데이트쿼리들을 알아서 적용해줌

(3) findOrder(), 주문 검색 - 주문 검색 기능에서 개발

  •  껍데기만 만들어 놓고 이후에 개발, orderSearch라는 검색 조건을 가진 객체로 주문 엔터리를 검색

** 참고

  • 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔터티에 있으며 서비스 계층은 단순히 엔터티에 필요한 요청을 위힘하는 역할을 하고 있음
  • 엔터티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 함 -> JPA(ORM 적용시) 사용 할 때 많이 적용하는 방식
  • 엔터티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 함 -> SQL을 다룰 때 사용하는 방식
  • 어떤 방식이 더 좋다고 말하기는 애매하며, 유지보수 측면에서 어떤 방식이 더 좋을지 생각해보고 적용하는 것이 좋으며 실무에서 한 프로젝트 안에서도 두가지의 패턴이 양립을 함
package jpabook.jpashop.service;

@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());
        delivery.setStatus(DeliveryStatus.READY);

        // 주문상품 생성 - new OrderItem으로 직접 생성하는 것을 막기위해 OrderItem에 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 적용
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        // 주문 생성 - 위와 동일
        Order order = Order.createOrder(member, delivery, orderItem);

        // 주문 저장
        /*
        cascade 적용으로 order만 저장되면 OrderItem, Delivery도 같이 저장됨
        cascade의 적용 범위에 대해서는 고민이 필요한데, cascade를 적용할 대상이 다른곳에서 참조하지 않을 때(private owner) 적용하면 됨
        만약 해당 객체가 중요해서 여러곳에서 참조가 일어나면 별도의 repository를 생성해서 별도의 persist를 하는것이 좋음
        이러한 상황이 개발시에 떠오르지 않거나 개념이 잘 잡혀있지 않다면 바로 적용하는 것이 아니라 추후에 리펙토링할때 적용을 해보는 것이 좋음
        */
        orderRepository.save(order);
        return order.getId();        // 주문 식별자 값 반환
    }

    /**
     * 주문 취소
     */
    @Transactional
    public void cancelOrder(Long OrderId) {
        // 주문 엔터티를 조회 후 주문이 취소됨
        Order order = orderRepository.findOne(OrderId);
        order.cancel();
    }

    /**
     * 주문 검색 - 껍데기만 생성, 주문 검색 기능 파트에서 구현
     */
//    public List<Order> findOrders(OrderSearch orderSearch)
//        return orderRepository.findAll(orderSearch);
//    }
}

 

(4) Order, OrderItem에 @NoArgsConstructor 적용

@NoArgsConstructor(access = AccessLevel.PROTECTED) // 다른곳에서 new로 직접 생성하는 것을 방지
public class Order { // 코드 생략 }

@NoArgsConstructor(access = AccessLevel.PROTECTED)  // 다른곳에서 new로 직점 생성하는 것을 방지
public class OrderItem {

4. 주문 기능 테스트

1) OrderServiceTest - Junit5 사용

  • junit4 사용시에는 메서드에 관행적으로 throws Exception을 입력했지만 junit5에서는 더 많은 기능과 유연성을 제공하기 때문에 해당 관습을 덜 사용하는 경향으로 변했고, Assertion.assertThrows나 Assertions.assertThatThrownBy를 통해 명시적으로 예외 테스트를 함
더보기

JUnit4에서 throws Exception을 사용했던 이유 - ChatGpt-4.o 답변

  1. 예외 발생을 처리하기 위해:
    JUnit 4의 테스트 메서드는 특정 예외가 발생할 가능성이 있는 코드를 포함할 수 있습니다. 테스트 중에 예외가 발생할 수 있는 다양한 상황을 처리할 때, throws Exception을 붙이면 개별적으로 try-catch 블록을 쓰지 않고 간단히 예외를 상위로 던질 수 있었습니다.
  2. 테스트 코드 단순화:
    모든 예외를 명시적으로 처리하지 않고 테스트 메서드에서 한 번에 처리할 수 있게 해주므로, 코드가 더 깔끔하게 보이는 장점이 있었습니다.
  3. 레거시 코드와의 호환성:
    초기에는 레거시 코드를 테스트할 때 예외를 많이 던지던 시절이 있었고, 많은 테스트 코드에서 일관되게 throws Exception을 사용하면서 예외를 묵시적으로 처리할 수 있게 하려는 관습이 생겼습니다.

(1) createBook(), createMember()

  • 테스트를 위한 객체를 생성하는 메서드를 별도로 생성하여 각 테스트마다 재사용

(2) 통합 테스트

  • 상품주문() : 정상 주문을 실행하여 주문상태와 주문 수량, 총 가격, 줄어든 재고 등을 검증
  • 재고수량초과() : 재고 수량보다 더 많은 수량을 주문했을 시 만들어둔 NotEnoughStockException이 터지는지 검증
  • 주문취소() : 주문 후 취소하여 주문상태와 재고가 다시 원복이 되었는지를 검증

** 참고사항

  • 실무에서는 이것보다 훨씬 더 꼼꼼하게 테스트를 하며 단위 테스트를 하는 것이 좋음
  • 재고수량초과에서 터지는 예외를 검증하는 것보다 실제 수량을 감소시키는 removeStock에 대한 단위테스트를 시행하는 등의 별도의 비즈니스 메서드에 대한 꼼꼼한 검증을 하는 것이 좋음
  • domain 모델을 사용하면 엔터티에 비즈니스 로직이 들어있어 db와 관계없이 순수하게 로직의 단위테스트 작성을 할 수 있음
package jpabook.jpashop.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void 상품주문() {
        // given
        Member member = createMember();
        Book book = createBook("테스트 북", 10000, 10);

        int orderCount = 2;

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

        // then
        Order getOrder = orderRepository.findOne(orderId);
        assertEquals(OrderStatus.ORDER, getOrder.getStatus(), "상품 주문시 상태는 ORDER");
        assertEquals(1, getOrder.getOrderItems().size(), "주문한 상품 종류 수가 정확해야 함");
        assertEquals(10000 * orderCount, getOrder.getTotalPrice(), "주문 가격은 가격 * 수량임");
        assertEquals(8, book.getStockQuantity(), "주문 수량만큼 재고가 줄어야 함");

    }

    @Test
    void 재고수량초과() {
        // given
        Member member = createMember();
        Book book = createBook("테스트 북", 10000, 10);

        // when
        int orderCount = 11;    // 실제 재고보다 주문 수량이 많음

        // then
        assertThatThrownBy(() -> orderService.order(member.getId(), book.getId(), orderCount))
                 .isInstanceOf(NotEnoughStockException.class);
    }

    @Test
    void 주문취소() {
        // given
        Member member = createMember();
        Book book = createBook("테스트북", 10000, 10);

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

        // when
        orderService.cancelOrder(orderId);

        // then
        Order getOrder = orderRepository.findOne(orderId);
        // junit - Assertions 사용
        assertEquals(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCLE");
        // assertj - Assertions 사용
        assertThat(book.getStockQuantity()).isEqualTo(10).as("주문이 취소된 상품은 그만큼 재고가 증가해야 함");
    }

    // Book 재고 생성
    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;
    }

}

5. 주문 검색 기능 개발

  • 전체를 조회할 때 조건에 따라 검색이 되도록 JPA 동적쿼리를 해결

(1) OrderSearch 추가

  • 동적쿼리를 구현하기위해서 검색조건을 파라미터로 넘기기 위한 클래스 생성
package jpabook.jpashop.repository;

@Getter @Setter
public class OrderSearch {

    private String memberName;          // 회원 이름
    private OrderStatus orderStatus;    // 주문 상태
}

 

(2) JPQL로 해결 - 동적쿼리를 JPQL로 해결하는 것은 번거로움

  • 번거롭고 복잡함
  • 실수로 인한 버그가 충분히 발생할 수 있음(특히 공백)
  • JPQL을 직접 동적으로 만드는 것은 JDBC에서 SQL을 직접 작성하는 것과 다를바가 없음
// jpql로 해결 - 실무에서 사용안함, 너무 복잡하고 버그가 발생할 확률이 너무 높음
public List<Order> findAllByString(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())) {
        if (isFirstCondition) {
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " m.name like :name";
    }
    // 최대 1000건
    TypedQuery<Order> query = em.createQuery(jpql, Order.class).setMaxResults(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();
}

 

(3) JPA Criteria로 처리 - 실무에서 사용하라고 만든것이 아닌것같은 JPA 표준.. 안씀

    • JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡함
    • 동적 쿼리를 자바코드로 표현하여 타입체크도 되는 부분이 조금더 수월하긴 하지만, 유지보수에서 최악의 모습을 보여주는데 이 코드를 보고 어떤 쿼리가 진행되는지 떠오르기가 매우 어려움
// JPA Criteria - JPA 표준으로 동적쿼리를 작성, 하지만 이마저도 번거로워서 Querydsl을 활용하는것이 좋음
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> cq = cb.createQuery(Order.class);
    Root<Order> orderRoot = cq.from(Order.class);
    Join<Order, Member> m = orderRoot.join("member", JoinType.INNER);   // 회원과 조인

    List<Predicate> criteria = new ArrayList<>();

    // 주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
        Predicate status = cb.equal(orderRoot.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();
}

 

(4) 실질적인 대안은 Querydsl - JPA 동적 쿼리의 해결책

  • JPA에서 동적쿼리에 대한 부분은 Querydsl로 사용함 - 이후 강의에서 코드 리펙토링으로 Querydsl로 변경하고, Querydsl에 대한 자세한 강의도 따로 다룰 예정
  • 실무에서 JPA를 사용할 때 스프링 데이터 JPA, Qeurydsl은 세트로 사용한다고 봐도 무방함
  • 자바코드로 구성하기 때문에 타입체크도 되고, 체인형식으로 작성하기에 가독성도 매우 좋음
  • querydsl의 간단한 사용법 및 소개