본문 바로가기
혼자 공부하는 것들/JPA

주문조회 페이징 처리 API 설계하면서 고민했던 점 및 트러블 슈팅

by applepick 2022. 9. 24.
반응형

JPA에서 페이징 처리를 공부하면서 적용해봤습니다. 배경을 설명해드리자면, 주문 조회 시 시작 날짜와 종료 날짜를 입력받아 페이징 처리하여 보여주는 기능을 구현했습니다.

package assignment.shop.order.repository;

import assignment.shop.order.Order;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select distinct o from Order o " +
            " join fetch o.orderItems oi " +
            " join fetch oi.item i " +
            "where o.memberId = :memberId " +
            "and o.createdDate between :startDate and :endDate")
    Page<Order> findByUserIdAndDateCondition(Long memberId, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);
}

우선 repository를 살펴보면 유저의ID와 시작 날짜 ~ 종료 날짜를 기준으로 쿼리를 가져오고 있습니다. 혹시 잘못된 부분이 보이시나요?

위 상태로 was를 올리게 되면 아래와같은 오류가 납니다.

Caused by: java.lang.IllegalArgumentException: org.hibernate.QueryException: query specified join fetching, but the owner of the fetched association was not present in the select list [FromElement{explicit,not a collection join,fetch join,fetch non-lazy properties,classAlias=oi,role=assignment.shop.order.Order.orderItems,tableName=order_item,tableAlias=orderitems1_,origin=orders order0_,columns={order0_.order_id,className=assignment.shop.order.OrderItem}}] [select count(distinct o) from assignment.shop.order.Order o  join fetch o.orderItems oi  join fetch oi.item i where o.memberId = :memberId and o.createdDate between :startDate and :endDate]
   at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:138) ~[hibernate-core-5.6.10.Final.jar:5.6.10.Final]
   at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:181) ~[hibernate-core-5.6.10.Final.jar:5.6.10.Final]
   at org.hibernate.internal.ExceptionConverterImpl.convert(ExceptionConverterImpl.java:188) ~[hibernate-core-5.6.10.Final.jar:5.6.10.Final]
   at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:757) ~[hibernate-core-5.6.10.Final.jar:5.6.10.Final]
   at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:114) ~[hibernate-core-5.6.10.Final.jar:5.6.10.Final]
   at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
   at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
   at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
   at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
   at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:362) ~[spring-orm-5.3.22.jar:5.3.22]
   at jdk.proxy3/jdk.proxy3.$Proxy153.createQuery(Unknown Source) ~[na:na]
   at org.springframework.data.jpa.repository.query.SimpleJpaQuery.validateQuery(SimpleJpaQuery.java:90) ~[spring-data-jpa-2.7.2.jar:2.7.2]
   ... 77 common frames omitted

 

러한 오류가 나는 이유는 이 부분 과 같습니다.

우선 페이징을 하기 위해서는 전체 카운트가 꼭 있어야 합니다. 그래야 몇 page까지 있는지 알 수 있습니다.

그래서 countQuery가 없으면 스프링 데이터 JPA가 임의로 원본 쿼리를 보고 countQuery를 생성합니다.

그런데 이 임의로 생성하는 쿼리가 그렇게 똑똑하지는 못해서 다음과 같이 만들어냅니다.

원본쿼리: select u from User u join fetch u.store s

임의로 생성한 count쿼리: select count(u) from User u join fetch u.store s

그런데 여기서 문제는 fetch join은 객체 그래프를 조회하는 기능이기 때문에 연관된 부모가 꼭 있어야 합니다. 그런데 수를 뽑는 count(u)로 조회 결과가 변경되어버렸기 때문에, 오류가 발생한 것이지요.

그래서 fetch join이나 복잡한 쿼리의 경우 꼭 countQuery를 분리해서 사용해주세요^^

이러한 이유로 아래와 같이 countQuery를 분리해보았습니다.

package assignment.shop.order.repository;

import assignment.shop.order.Order;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query(value = "select distinct o from Order o " +
            " join fetch o.orderItems oi " +
            " join fetch oi.item i " +
            "where o.memberId = :memberId " +
            "and o.createdDate between :startDate and :endDate", countQuery = "select count(o) from Order o")
    Page<Order> findByUserIdAndDateCondition(Long memberId, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable);
}

service 쪽을 확인해보겠습니다.

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

    private final OrderRepository orderRepository;
    private final ItemRepository itemRepository;

    /**
     *  유저의 주문내역 조회 페이징 처리
     */
    public Page<OrderHistoryResponse> getOrderHistory(Long memberId, LocalDateTime startDate, LocalDateTime endDate, Pageable pageable) {
        Page<Order> orders = orderRepository.findByUserIdAndDateCondition(memberId, startDate, endDate, pageable);
        return orders.map(OrderHistoryResponse::from);
    }
}

유저의 ID, 시작 날짜, 종료 날짜와 페이징 사이즈를 받아서 findByUserIdAndDateCondition를 실행시킨 값을 service 단에서 response로 변환하여 반환해줍니다.

 

controller 단을 확인해보면

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private static final String USER_ID_HEADER = "x-user-id";

    private final OrderService orderService;
    
    /**
     * 유저 주문내역 조회[페이징] API
     * @param memberId
     * @param startDate
     * @param endDate
     * @param pageable
     * @return Page<OrderHistoryResponse>
     */
    @GetMapping("/history")
    public Page<OrderHistoryResponse> getOrderHistory(@RequestHeader(USER_ID_HEADER) Long memberId,
                                                      @RequestParam("start_date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
                                                      @RequestParam("end_date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate,
                                                      @PageableDefault(size = 5) Pageable pageable) {
        if (startDate.isAfter(endDate)) {
            throw new OrderException(ErrorCode.INVALID_INPUT_VALUE);
        }
        return orderService.getOrderHistory(memberId, startDate, endDate, pageable);
    }

}

시작날짜와 종료 날짜 페이지 사이즈를 받아와서 처리해줍니다. 결과를 확인해볼까요?

페이징을 처리한것을 볼 수 있습니다. 인수 테스트 중 하나 정도 확인해보겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OrderAcceptanceTest {
    @LocalServerPort
    int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @Test
    public void 주문_내역을_조회시_기간으로_조건에_맞는_주문이_있으면_주문_페이징_목록이_반환() {
        given()
                .accept(MediaType.APPLICATION_JSON_VALUE)
                .header("x-user-id", "1")
        .when()
                .get("/api/orders/history?start_date=2022-06-20T00:00&end_date=2023-08-21T00:00")
        .then()
                .statusCode(HttpStatus.OK.value())
                .body("first", equalTo(true))
                .body("size", equalTo(5));
    }
    ...
}

확인 결과 정상적으로 작동하는 것을 볼 수 있습니다. SpringDataJPA를 사용하면서 Page을 손쉽게 할 수 있도록 지원해줘서 학습하여 적용해봤습니다. 

반응형

댓글