본문 바로가기

스프링 (인프런)/스프링부트

주문 도메인 개발

1. 주문 엔티티 개발

// 생성 메서드
                                                            // 주문 생성과 관련된 모든 로직을 응집해놓은 메서드
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() {                                // 전체 주문 가격 반환
    return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}

- Order.class의 필드 아래에 작성했다.

 

1) 생성 메서드

- Order 인스턴스를 생성하는 정적 메서드 createOrder()이다.

- Order와 연관되어 있는 객체까지 한꺼번에 담기 위한 메서드다.

- 생성자와 유사해보이지만 메서드이기 때문에 좀 더 유연하게 사용하고 수정할 수 있을 것이다.

 

 

 

2) 비즈니스 로직

- 주문을 취소하는 cancel()이다.

- 만약 이미 배송이 완료된 상태라면 주문을 취소할 수 없으므로 예외를 발생시킨다.

- 주문 상태를 변경한다.

- 각 주문 상품에 대해서도 cancel()을 호출해야 한다.

    - OrderItem에 상품의 가격뿐만 아니라 주문 수량에 대한 정보가 있기 때문에 거기서 재고를 원상 복구 해줘야 한다

 

 

 

3) 조회 로직

- 하나의 주문에 엮인 모든 상품의 가격을 반환하는 getTotalPrice()이다.

- orderItems에 들어 있는 각각의 orderItem 인스턴스의 상품 가격과 주문 수량을 곱한 것을 모두 합쳐 반환한다.

- 자바의 stream을 활용해서 나타냈다.

    - 직접 한 것은 아니고, 인텔리제이의 힘을 빌렸다.

 

 

 

2. 주문상품 엔티티 개발

// 생성 메서드
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
    OrderItem orderItem = new OrderItem();
    orderItem.setItem(item);
    orderItem.setOrderPrice(orderPrice);
    orderItem.setCount(count);

    // 재고 변화
    item.removeStockQuantity(count);

    return orderItem;
}

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

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

1) 생성 메서드

- 주문상품을 생성하고 연관된 상품, 가격, 수량 등을 모두 set해서 돌려주는 createOrderItem()이다.

 

 

 

2) 비즈니스 로직

- Order에서 취소할 때 수량을 수정해주는 cancel()이다.

 

 

 

3) 조회 로직

- Order에서 각 주문 상품의 가격을 반환받을 때 사용하는 getTotalPrice()이다.

 

 

 

3. 주문 리포지토리 개발

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    private final EntityManager em;
    
    public void save(Order order) {
        em.persist(order);
    }
    
    public Order findOne(Long orderId) {
        return em.find(Order.class, orderId);
    }
    
    // 회원명으로 관련된 주문 검색
    // public List<Order> findAll(OrderSearch orderSearch) { }
}

- MemberRepository와 별반 차이가 없다.

- 다만 주석 처리한 부분을 보면 알 수 있듯 회원명을 가지고 주문을 검색하는 기능은 동적 쿼리를 사용해야 하므로 이후 강의로 밀린 상태다.

 

 

 

4. 주문 서비스 개발

@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);                        // 원래는 Delivery, OrderItem도 각 리포지토리의 save()를 호출해줘야
                                                            // 그러나 Order.class를 보면 두 객체에 cascade가 걸려 있어서 자동으로 같이 save()됨
        return order.getId();                               // cascade 범위의 기준은 한쪽이 다른쪽을 exclusively 참조할 때
    }

- 클래스에 달리는 어노테이션은 Member에서와 동일하다.

 

- 그런데 스프링에서 주입 받는 Repository 객체가 Order뿐만 아니라, Member와 Item의 리포지토리까지 포함한다.

- order()를 보면 알겠지만, 이는 DB에서 id를 가지고 Member와 Item 객체를 조회하기 위해서 필요하다.

 

- order()에서 Delivery와 OrderItem 객체를 직접 생성하고 있다.

- 다른 객체가 이 둘을 참조하지 않기 때문에 Order가 이 둘의 라이프 사이클을 관리하는 것이 꽤나 합리적이다.

 

- 그런데 이 두 객체를 직접 persist()하는 로직은 찾을 수 없다.

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

@OneToOne( fetch = FetchType.LAZY, cascade = CascadeType.ALL)   // 일대일은 둘 다 연관관계의 주인이 될 수 있다
@JoinColumn( name = "delivery_id" )                             // order에서 delivery를 찾을 경우가 많으므로 연관관계의 주인으로
private Delivery delivery;

- 이는 Order에서 이 둘에 대한 cascade 옵션을 ALL로 걸었기 때문에 가능한 것이었다.

- 즉, Order가 persist()됨과 동시에 Order가 의존하는 orderItems와 delivery가 함께 persist()된다.

- 참고로, cascade를 거는 기준은 명시되어 있는 것은 아니다.

- 다만, 아까 언급했던 것처럼 Delivery와 OrderItems는 Order가 배제적으로 참조한다는 점이 곧 기준이다.

 

 

 

참고!

@NoArgsConstructor( access = AccessLevel.PROTECTED )
public class 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;
}

- Order에는 생성 메서드가 따로 존재한다.

- 따라서 생성자를 이용하는 것이 오히려 권장되지 않는다.

- 그러니까 Order의 기본 생성자의 접근 제한자를 protected로 둠으로써 다른 클래스에서 접근하지 못하도록 막아야 한다.

- 이 때 Lombok의 @NoArgsConstructor에서 access 옵션을 PROTECTED로 두면, 생성자를 작성하지 않아도 된다.

 

 

 

( OrderService 계속 )

// 취소
@Transactional
public void cancelOrder(Long orderId) {
    // 주문 엔티티 조회
    Order order = orderRepository.findOne(orderId);

    // 주문 취소
    order.cancel();
}

- 취소 서비스는 매우 간단하다.

- orderId로 DB에서 조회해 받아온 Order 객체에 cancel()을 호출하면 된다.

 

- 그런데 이러한 간단한 로직 뒤에 숨은 JPA 기능이 있다.

- Order의 cancel()은 OrderItem의 cancel()을 호출하는데, 여기서 Item의 stockQuantity를 건드린다.

 

public void cancel() {
    getItem().addStockQuantity(count);
}

- 그런데 이렇게 자바상에서 바뀐 재고 수량에 대해, OrderService 계층에서 수정 쿼리를 날려줄 필요가 없는 것이다.

- 즉 cancelOrder()에서 Item 꼬랑지도 보지 못했는데 알아서 쿼리가 날라간다는 뜻.

 

 

 

도메인 모델 패턴

@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);                        // 원래는 Delivery, OrderItem도 각 리포지토리의 save()를 호출해줘야
                                                        // 그러나 Order.class를 보면 두 객체에 cascade가 걸려 있어서 자동으로 같이 save()됨
    return order.getId();                               // cascade 범위의 기준은 한쪽이 다른쪽을 exclusively 참조할 때
}

- 다시 OrderService의 order()를 살펴 보자.

- 코드가 꽤 길어 보이지만 이는 Order에 엮여 있는 객체가 많아서 그렇지, 막상 Order와 관련한 연산은 전부 메서드 호출로 끝낸다.

- createOrder나 createOrderItem()이 그 예시다.

- 이렇듯 서비스 계층은 엔티티에서 이미 구현해놓은 기능들을 호출해서 연산을 그쪽에 위임하는 방식을 "도메인 모델 패턴"이라고 한다.

 

 

 

5. 주문 기능 테스트

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
    @Autowired EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    private Item createBook(String name, int orderPrice, int stockQuantity) {
        Item book = new Book();
        book.setName(name);
        book.setPrice(orderPrice);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }
    private Member createMember(String name, Address address) {
        Member member = new Member();
        member.setName(name);
        member.setAddress(address);
        em.persist(member);
        return member;
    }

    @Test
    public void 상품주문() throws Exception {
        // given
        Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
        Item book = createBook("안나 카레니나", 10000, 100);

        int count = 2;

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

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

    @Test( expected = NotEnoughStockException.class )
    public void 상품주문_재고수량초과() throws Exception {
        // given
        Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
        Item book = createBook("안나 카레니나", 10000, 100);

        int count = 111;

        // when
        orderService.order(member.getId(), book.getId(), count);

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

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
        Item book = createBook("안나 카레니나", 10000, 100);

        int count = 2;

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

        // when
        orderService.cancelOrder(orderId);

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

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

- createMember()와 createBook()은 인텔리제이의 "메서드 추출" 기능을 활용했다.

    - 단축키는 "command + option + M"이다.

    - 여러 테스트에서 공통으로 호출할 수 있다.

    - 스프링의 @Before를 써도 되지 않을까 ...

 

- 상품주문()은 Order 객체를 persist()한다.

    - 주문 상태(자동으로 들어감)를 확인한다.

    - 상품 종류 수가 1개인지 확인한다.

    - 주문 가격이 정확해야 한다.

    - 주문 후 재고가 정확하게 변경되어야 한다.

 

- 상품주문_재고수량초과()는 재고보다 많은 주문 수량을 집어넣어서 예외를 발생시킨다.

    - @Test에 expected를 걸어서 예외가 발생할 것을 명시해준다.

    - 만약 order()에서 예외가 발생하지 않으면, fail()까지 내려와서 테스트가 실패하게 된다.

 

- 주문취소()는 cancel()을 호출하여 올바른 결과가 발생하는지 확인한다.

    - 주문 상태가 CANCEL로 변경되었는지 확인한다.

    - 일치하는 Item의 재고가 수정되었는지 확인한다.

 

 

 

6. 주문 검색 기능 개발

- 회원명과 주문상태 두 조건을 가지고 검색할 수 있어야 한다.

- 그러려면 조회 쿼리에서 회원명과 주문 상태가 있는지 없는지도 따져야 하고, 있다면 값이 어떤지도 따져야 한다.

- 따라서 "동적 쿼리"가 필요하다.

 

 

 

검색 조건 파라미터 OrderSearch

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

- OrderSearch는 주문 검색에 필요한 회원명과 주문 상태를 공급하는 역할을 한다.

 

 

 

public List<Order> findAll(OrderSearch orderSearch)

public List<Order> findAll(OrderSearch orderSearch) {
    orderSearch의 memberName 혹은 orderStatus가 null이라면? -> 동적 쿼리 필용
    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)        // 최대 1000건 조회
            .getResultList();
}

- 직접 EntityManager를 통해서 쿼리를 날리는 쪽은 OrderRepository다.

- 위와 같은 createQuery()는 뭔가 완벽해보이지만 한 가지 부족한 점이 있다.

 

- 만약 orderSearch의 name 혹은 status가 비어있다면 모든 name 혹은 모든 status에 대해 검색해야 한다.

    - 그러면 대충 생각해봐도 쿼리가 2(있거나 없거나) * 2 = 4가지 상황을 대비해야 하는 것이다.

- 따라서 위 쿼리는 정적쿼리라고 할 수 있다.

 

 

 

CASE 1 -> JPQL로 처리

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

- jpql 문자열 변수에 기본적인 쿼리 문자열을 먼저 넣는다.

- 1차 if문을 통해 회원명과 주문 상태 각각이 비어 있지 않으면 where 조건절을 넣어준다.

    - 엄밀히 말하면 둘 중 먼저 값이 있는 곳에선 where, 나중에 값이 있는 곳은 and로 엮인다.

- 2차 if문을 통해 회원명과 주문 상태 각각이 비어 있지 않으면 파라미터 바인딩을 해준다.

 

- 매우 복잡하고, 유지보수성이 떨어진다.

- 예를 들어, 주문을 검색하는 조건이 추가되거나 줄어들 경우 이에 맞도록 코드를 전부 바꿔줘야 한다.

 

 

 

CASE 2 -> 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();
}
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를 활용한 방법이다.

- Criteria를 따로 공부한 적이 없어서 문법은 전혀 모르겠고, 강의에서도 실무에서 사용하지 않는 방식이라 하나씩 설명을 듣진 못했다.

- 그래서 코드만 적고, 따로 설명을 넣진 않겠다.

 

 

 

CASE 3 -> QueryDSL

- QueryDSL을 쓰는 것이 솔루션이라고 알려줬으나, 단순히 검색 조건을 위해서 코드만 보여주기보다는 따로 강의를 할 예정이라고 한다.

- 그래서 코드도 제대로 알려주지 않았고, 어차피 Case 1과 Case 2 모두 목적에 맞게 작동하므로 그 둘을 우선 사용하면 될 것 같다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

'스프링 (인프런) > 스프링부트' 카테고리의 다른 글

API 개발 기본  (1) 2023.07.13
웹 계층 개발  (0) 2023.07.08
상품 도메인 개발  (0) 2023.07.06
애플리케이션 구현 준비  (0) 2023.07.05
엔티티 클래스 개발  (0) 2023.07.05