RE-Heat 개발자 일지

[JPA 활용1] [4] 상품·주문 도메인 개발 본문

백엔드/JPA

[JPA 활용1] [4] 상품·주문 도메인 개발

RE-Heat 2023. 8. 25. 23:47

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-%ED%99%9C%EC%9A%A9-1/

 

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 인프런 | 강의

실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다., 스프

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[1] 상품 도메인 개발

■ 상품 엔티티 개발

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();

    //==비즈니스 로직==/
    /**
     * stock 증가
     * */
    public void addStock(int quantity){
        this.stockQuantity += quantity;
    }

    /**
     * stock 감소
     * */
    public void removeStock(int quantity){
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0){
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;

    }
}

① 비즈니스 로직

1. addStock() 메서드는 파라미터로 넘어온 수만큼 재고를 늘린다. 이 메서드는 재고가 증가하거나 상품 주 
문을 취소해서 재고를 다시 늘려야 할 때 사용한다.

2. removeStock() 메서드는 파라미터로 넘어온 수만큼 재고를 줄인다. 만약 재고가 부족하면 예외가 발생한 
다. 주로 상품을 주문할 때 사용한다.

② 엔티티에서 비즈니스 로직을 처리하는 방식을 도메인 패턴 모델이라 부름. 서비스 단에서 처리하는 것보다 엔티티에서 처리하는 게 응집도가 높은 설계라 볼 수 있다.

 

NotEnoughStockException

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }

    protected NotEnoughStockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

① RuntimeException을 상속받은 후 오버라이딩.

 

■ 상품 리포지토리 개발

@Repository
@RequiredArgsConstructor
public class ItemRepository {
    private final EntityManager em;

    public void save(Item item){
        if (item.getId() == null) { //idX 새로 생성한 객체 그러니 신규 등록
            em.persist(item);
        } else {
            em.merge(item); //update
        }
    }

    public Item findOne(Long id){
        return em.find(Item.class, id);
    }

    public List<Item> findAll(){
        return em.createQuery("select i from Item i", Item.class)
                .getResultList();
    }
}

① RequiredArgsConstructor로 생성자 주입 방식으로 처리

② findAll은 createQuery로 SQL을 날려 값을 가져온다.

참고] em.merge()는 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용한다. 향후 더 자세히 알아볼 예정

 

 

■ 상품 서비스 개발

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
    private final ItemRepository itemRepository;

    @Transactional(readOnly = false)//읽기가 아닌 쓰기다.
    public void saveItem(Item item){
        itemRepository.save(item);
    }

    public List<Item> findItems(){
        return itemRepository.findAll();
    }

    public Item findOne(Long itemId){
        return itemRepository.findOne(itemId);
    }

}

① 상품 서비스는 상품 리포지토리에 위임하는 역할만 한다. 비즈니스 로직은 엔티티에서 구체적으로 구현됨.

 

 

[2] 주문 도메인 개발

■ 주문·주문상품 엔티티 개발

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() == 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() ) : 주문 취소 시 사용한다. 주문 상태를 취소로 변경하고 주문 상품에 주문 취소를 알린 
다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.

③ 전체주문 가격조회 : 주문 시 사용한 전체주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 
가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. 

 

OrderItem

@Entity
@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); //order가 들어오면 재고를 빼줘야 한다.
        return orderItem;
    }


    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count); //재고수량을 원복해준다.
    }

    /**
     * 주문상품 전체 가격 조회
     */

    //==조회 로직==//
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

 ① 생성 메서드로 상품 정보(주문 상품·가격·수량)를 받아와서 처리한다. 추가로 item.removeStock(count)로 주문한 수량만큼 상품 재고를 줄인다.

② 주문취소( cancel() ) : getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.

 

③ 주문 가격 조회(getTotalPrice()) : 주문 가격 * 수량을 반환한다.

 

■ 주문 리포지토리 개발

@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(){
//        return em.createQuery("select o from Order o", Order.class)
//                .getResultList();
//    }
}
  • findAll 메서드는 주문 검색 기능 파트에서 다룰 예정

 

■ 주문 서비스 개발

@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()); //회원 주소로 입력하는 걸로 함

        //주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장
        orderRepository.save(order);//왜 Order 한 번만 저장하면 되냐면 Cascade 때문.

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

① 생성자 메서드를 써서 가독성이 UP

② Cascade를 지정해 order만 persist해도 orderItem과 delivery도 자동으로 persist된다.

    => OrderItem과 Delivery가 Order에서만 쓰이므로 cascade를 써도 문제가 없지만, 만일 Delivery 등이 다른 곳에서도 사용한다면 Cascade를 쓰면 안 된다.

클릭하면 더 크게 볼 수 있습니다.

③ 다른 사람이 @Setter메서드를 써서 정보를 입력하는 걸 방지하려면 기본 생성자에 protected를 써주면 된다. 

  • 롬복으로 기본생성자의 AccessLevel을 지정해 왼쪽 방식을 대체할 수도 있다.

 

참고]

1. 엔티티가 비즈니스 로직을 가진 패턴을 도메인 모델 패턴이라고 부른다. (JPA, ORM 쓸 땐 이 패턴을 많이 쓴다.)

2. 서비스 계층에서 비즈니스 로직 대부분을 처리하는 방식을 트랜잭션 스크립트 패턴이라고 부른다.

 

■ 주문 기능 테스트

OrderServiceTest

@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("주문 가격은 가격 * 수량이다.", 10000 * orderCount, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 8, book.getStockQuantity());
    }

    @Test(expected = NotEnoughStockException.class)
    public void 상품주문_재고수량초과() throws Exception {
        //given
        Member member = createMember();
        Book book = createBook("도시 JPA", 10000, 10);

        int orderCount = 11;
        //when
        orderService.order(member.getId(), book.getId(), orderCount);

        //then
        fail("재고 수량 부족 예외가 발생해야 한다.");
    }


    @Test
    public void 주문취소() throws Exception {
        //given
        Member member = createMember();
        Book 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());
    }


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


}

① 상품주문 테스트

given : 테스트를 위한 Member, 상품 생성

    생성자로 값을 넘기기 위해 Address에 @AllArgsConstructor 추가

when : 실제 상품 주문

then : 주문 후 주문 상태, 주문 상품 종류 수 , 주문가격, 재고수량 줄었는지 검증

 

② 상품주문 재고수량 초과 테스트

    • NotEnoughStockException 발생해야 한다. 따라서 @Test(expected = NotEnoughStockException.class)로 지정.
    • 순서
      • orderservice.order() => orderItem.createOrderItem() => item.removeStock
      • 재고수량이 10인데, 주문수량은 11이므로 NotEnoughStockException이 발생

 

참고] removeStock에 대한 단위테스트가 있는 게 더 좋은 테스트다.

 

③ 주문취소 테스트

given : 테스트를 위한 회원, 상품 생성 후 주문까지 완료

when : 주문을 취소

then : 주문 상태는 CANCEL, 재고는 복구됐는지 확인

 

  • 순서
    • orderService.cancelOrder() => order.cancel() => setStatus CANCEL로 처리
    • => orderItem.cancel() => addStock()으로 재고수량 복구

 

■ 주문 검색 기능 개발

JPA에서 동적 쿼리는 어떻게 해결해야 할까?

원하는 최종 화면

 

① 검색에 필요한 객체 작성

OrderSerarch

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

}

 

② 동적 쿼리 작성

1. JPQL로 처리 - MemberRepository 일부 발췌

//검색
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())){
        if (isFirstCondition){
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " m.name like :name";
    }

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

① isFirstCondition으로 이게 첫 번째 where 조건인지 파악

② 그때그때 각조건에 맞는 JPQL 추가

③ createQuery().setmaxResult()로 조회할 데이터 수 설정(페이징)

④ orderStatus, memberName이 있는지 확인 후 있으면 setParameter를 써서 파라미터 값으로 넘김

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

 

2. JPA Criteria로 처리

/**
 * 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<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();
}

단점 : 문자열보단 낫지만, 이 코드를 보면 어떤 쿼리가 만들어질지 생각해 내기 어렵다. 따라서 유지보수 측면에서 좋지 않은 코드다. 그래서 실무에선 JPA Criteria보다 QueryDSL을 쓴다.

 

③ QueryDSL로 처리

/**
 * Querydsl
 * */
public List<Order> findAll(OrderSearch orderSearch){
    QOrder order = QOrder.order;
    QMember member = QMember.member;

    return query
            .select(order)
            .from(order)
            .join(order.member, member)
            .where(statusEq(orderSearch.getOrderStatus()),
                    nameLike(orderSearch.getMemberName()))
            .limit(1000)
            .fetch();
}

private BooleanExpression statusEq(OrderStatus statusCond){
    if (statusCond == null){
        return null;
    }
    return order.status.eq(statusCond);
}

private BooleanExpression nameLike(String nameCond){
    if (!StringUtils.hasText(nameCond)){
        return null;
    }
    return member.name.like(nameCond);
}

SQL문과 비슷한 QueryDSL. 자세한 내용은 향후 다룰 예정이다.