Server

[Spring] Spring boot와 JPA 활용 - 주문 도메인 개발

nahyeon 2022. 6. 24. 18:23

가장 중요한 주문 도메인을 개발해볼 것이다.

중요한 이유는 비지니스 로직들이 얽혀서 돌아가는 걸 JPA나 entity를 가지고 어떻게 풀어내는지 알 수 있기 때문이다.

 

구현 기능

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

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

domain/Order.java

package jpabook2.jpashop2.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter
@Setter
@Table(name = "orders")
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)
    @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 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);
    }
    
    //==조회 로직==//
    /**
    * 전체 주문 가격 조회
    */
    public int getTotalPrice() {
    	int totalPrice = 0;
        for(OrderItem orderItem : orderItems) {
        	totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
   

}

이번에는 핵심 비지니스 로직을 만들 차례로, 주문이기 때문에 생성 메서드가 중요하다.

 

주문 생성은 Order, OrderItem, Delivery 그리고 연관관계도 있어야 하므로 복잡해진다.

그래서 이런 복잡한 생성은 별도의 생성 메서드가 있으면 좋다.

 

앞으로 생성하는 지점을 변경해야 한다면 생성 메서드인 createOrder만 변경해주면 되기 때문에 좋다.

 

createOrder() : 새로운 Order 객체 order를 만들고 parameter로 받은 Member, Delivery, OrderItem 객체를 set 해준다. 

파라미터로 받은 OrderItem는 주문 아이템이 여러 개 일 수 있기 때문에 ...문법을 사용하여 OrderItem... 타입의 orderItems 인자를 받아준다.

 

order.setStatus(OrderStatus.ORDER) : 처음 생성됐을 때 order 상태를 ORDER로 set 해준다.

 

다음은 비지니스 로직이다.

 

주문취소인 cancel() 메서드를 만들어주었다.

 

이미 배송이 완료된 주문은 취소 불가능하다는 로직으로

delivery의 status가 DeliveryStatus.COMP 일 때는 exception 처리를 해주었다.

 

  • IllegalStateException: RuntimeException으로 메서드가 요구된 처리를 하기에 적합한 상태에 있지 않을 때 던지는 exception이다.

만약 delivery.getStatus()가 완료된 상태가 아니라면 status를 CANCEL로 바꿔주고, 

orderItems에 대해 cancel() 함으로써 재고를 원복 시켜준다.

 

다음은 조회 로직이다.

전체 주문 가격 조회하는 getTotalPrice() 메서드를 만들고, orderItems에 대해서 orderItem의 전체 가격을 가져오고(개수까지 고려한),

가져온 가격을 totalPrice에 더해준다.

 


domain/OrderItem.java

package jpabook2.jpashop2.domain;

import jpabook2.jpashop2.domain.item.Item;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

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

    // 비지니스 로직
    public void cancel() {
        getItem().removeStock(count);
    }

    // 조회 로직

    /**
     * 주문상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

 

 


주문 리포지토리 개발, 주문 서비스 개발

repository/OrderRepository.java

@Repository
@RequiredArgsConstructor
public class OrderReository {
	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() {
    // 	em.createQuery();
    // }
}

service/OrderService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {

	private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

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

 

주문: order() 메서드

 

  • 주문할 때 입력한 Member와 Item 전체가 넘어오는 것이 아닌, memberId(PK), itemId(PK) 값만 파라미터로 넘어오게 된다.
  • 파라미터로 받은 member, item 엔티티를 repository에서 조회한다. 이때 MemberRepository, ItemRepository 등 여러 개의 레포지토리에 의존하게 된다. private final로 선언함으로써 @RequiredArgsConstructor 덕분에 코드를 크게 수정할 바가 없다.
  • 주문 상품 orderItem을 생성할 때 해당 엔티티에 만들어준 생성 메서드 createOrderItem을 사용해서 생성한다. 누군가는 주문 상품을 생성할 때 
OrderItem orderItem = new OrderItem();
orderItem.setCount(1);

 

  • 이런 식으로 주문상품을 생성할 때 개발할 수도 있다. 여기 로직에서는 이렇게 개발하고, 다른 로직에서는 다른 방식으로 개발하게 되면 유지보수가 어려워진다. (필드를 추가한다거나 로직을 변경하는 등) 생성 로직을 변경할 때 분산되므로 여러 스타일로 생성하는 것을 막아야 한다.
  • 대처방법으로는 constructor를 만들 때 protected로 만들어주면 된다. (JPA는 접근 지정자를 public, protected까지 지원해준다.)

 

// 방법 1
protected OrderItem() {}

// 방법 2
@Entity @Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {}

 

  • 이렇게 protected로 access level을 지정해주면 new로 생성하게 될 때 컴파일 오류가 떠서 개발자가 주의할 수 있다.
  • 다른 개발자가 작성을 하다가 기본 생성자가 컴파일 오류가 떴고, 코드를 확인해보니 NoArgsConstructor(접근 지정자 protected)가 있다는 것을 확인할 수 있다. 코드를 더 살펴보니 다른 생성 메서드(e.g. createOrderItem())이 있고 이걸 사용해야 하는구나로 유도할 수 있다.
  • 이처럼 코드를 제약하는 스타일로 작성을 해야 좋은 설계와 유지보수를 끌어갈 수 있다.

 

주문을 저장할 때

Order 클래스의 orderItems와 delivery에 대해서 CascadeType.ALL로 설정되어 있다.

 

 

domain/Order.java

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();


@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;

CascadeType.ALL 설정은 Order가 persist 될 때 자동으로 orderItems와 delivery도 persist 된다는 뜻이다.

 

service/OrderService.java

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

orderRepository.save(order);

서비스 계층에서 repository에 save 해주게 되면, 

order만 save 해서 JPA의 entity manager에 persist 해주어도, delivery와 orderItems에 대한 정보도 persist 해주게 된다.

 

cascade의 범위를 어디까지 설정할지도 고민해야 하고, 위의 코드에서는 order가 delivery와 orderItems를 관리하게 된다.

참조할 때는 주인이 private owner인 경우에만 사용해야 한다.

 

delivery는 order만 참조하고, orderItems도 order만 참조한다. 다른 곳에서도 참조할 수 있지만, lifecycle에 대해서도 신경을 써야 한다. 즉, order를 지울 때 다 지워지므로 cascade를 함부로 남발해선 안된다. 

만약 private owner가 아니라면 별도의 repository를 관리해야 한다.

 

orderRepository에 save 할 때는(주문을 저장할 때는)

cascade 옵션이 있기 때문에 orderItem과 delivery가 자동으로 함께 persist 되면서 DB table에 insert가 된다. transaction이 commit 되는 시점에 flush 가 일어나면서 영속성 컨텍스트의 변경 내용이 DB에 반영된다.

 

  • JPA의 플러시(flush)
    • 영속성 컨텍스트의 변경 내용을 DB에 반영하는 것 
    • Transaction Commit이 일어날 때 flush 가 동작하는데, 이때 쓰기 지연 저장소에 쌓아 놨던 INSERT, UPDATE, DELETE SQL들이 DB에 날아간다. (cf. Transaction: 작업 단위)
    • 플러시는 영속성 컨텍스트를 비우는 것이 아니라, 영속성 컨텍스트의 변경 사항들과 DB의 상태를 맞추는 작업이다. (동기화)

 

  • 플러시의 동작 과정
    • 1. Dirty Checking(변경 감지)를 한다.
    • 2. 수정된 Entity를 쓰기 지연 SQL 저장소에 등록한다.
    • 3. 쓰기 지연 SQL 저장소의 Query를 DB에 전송한다. (등록, 수정, 삭제 Query)

 

  • flush가 발생한다고 해서 commit이 이루어지는 것은 아니고, flush 다음에 실제 commit이 일어난다.
  • 플러시가 동작할 수 있는 이유데이터베이스 트랜잭션(작업 단위)이란 개념이 있기 때문이다.
    • 트랜잭션이 시작되고 해당 트랜잭션이 commit 되는 시점 직전에만 동기화(변경 내용을 날림) 해주면 되기 때문에, 그 사이에서 플러시 메커니즘의 동작이 가능한 것이다.

 

주문을 취소할 때

마찬가지로 파라미터로 취소하는 주문에 대한 id(식별자)만 넘어오게 된다.

 

데이터의 변경이 이뤄지므로 @Transactional로 override 해준다.

orderId로 해당 주문 엔티티를 조회하고, 주문 취소를 엔티티에 작성해둔 핵심 비즈니스 로직인 cancel()를 사용해서 취소한다.

 

원래는(JPA가 없다면) 로직을 바꾸고 데이터를 끄집어내서 파라미터에 넣고 쿼리로 데이터를 변경해줘야 하는데(트랜잭션 스크립트 패턴)

이런 패턴은 서비스 계층에서 비지니스 로직을 다 쓸 수 밖에 없다. 

 

그러나 JPA를 활용하면 이렇게 entity에 있는 데이터만 바꾸면, JPA에서 바뀐 변경 포인트를 dirty checking(변경 내역 감지)가 일어나면서 변경된 내용들을 다 찾아서 데이터베이스에 update가 알아서 처리된다. (JPA의 큰 장점)

 

 

도메인 모델 패턴

  • 엔티티에 핵심 비지니스 로직을 몰아넣는 스타일
  • 서비스 계층에서는 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
  • 엔티티가 비지니스 로직을 가지고 객체지향의 특성을 적극 활용하는 것
  • JPA나 ORM 등을 사용할 때 많이 사용하는 방식

 

트랜잭션 스크립트 패턴

  • 엔티티에는 비지니스 로직이 거의 없고 getter, setter 정도만 가지고 있다.
  • 대부분의 비지니스 로직을 서비스 계층에서 처리한다.
  • 일반적으로 SQL을 다룰 때 많이 사용했던 방식

어떤 방식이 더 좋다기보단 상황에 따라 어떤 패턴이 유지보수에 더 좋은지 고민하고 사용하면 된다.

 

주문 기능 테스트

// 단위 테스트별로 트랜잭션이 돌기 때문에 테스트당 rollback 이 되게 된다.

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

    }

    @Test
    public void 상품주문() throws Exception {
        //given
        Member member = createMember(); // cmd + option + m : 기본 data setter를 만듦
        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();
        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 11;

        //when
        orderService.order(member.getId(), item.getId(), orderCount);

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

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

}

 

주문 검색 기능 개발

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

 

  • 동적 쿼리란?

같은 기능을 하지만 동적으로 들어오는 파라미터에 의해 조건이 바뀌게 설계되는 쿼리

e.g. 검색 기능 구현시 값이 없으면 전체 정보, 있으면 해당하는 정보를 반환

  • 방법
    • 순수 JPQL
      • jpql을 문자열로 만든 뒤 조건에 다라 jpql에 string을 추가하는 방식
      • jpql에 띄어쓰기 하나라도 틀리면 오류 발생
    • Criteria
      • JPA의 표준 스펙이지만 실무에서 사용 X
      • 사용성이 복잡해서 어떤 쿼리가 나가는지 파악이 어렵다.
    • QueryDSL
      • 자세한 QueryDSL은 뒤에서 다루겠지만 가시성과 사용성이 좋아보임
      • 동적쿼리를 해결하는데 가장 강력한 방식이고, 정적쿼리가 복잡해질 때 사용해도 좋은 방식

 

 

[ref]

https://gmlwjd9405.github.io/2019/08/07/what-is-flush.html

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/