Coding/사이드 플젝

[뚝배기] 페이징과 페치조인

kangplay 2025. 4. 25. 11:32

프로젝트를 진행하다 보니 주문(Order)과 주문메뉴(OrderMenu) 그리고 메뉴(Menu) 관계를 기반으로 다음과 같은 API가 필요해졌다.

여러 개의 주문을 한 번에 페이징으로 조회하고, 각 주문에 포함된 메뉴까지 함께 조회하고 싶다!

테이블 구성 및 문제 상황

현재 나의 도메인은 다음과 같이 구성되어있다.

 

  • Order (주문)
  • OrderMenu (주문 상세 / 다대일 관계로 주문 참조)
  • Menu (메뉴)

이 중 OrderMenu가 Order와 Menu를 참조하는 단방향 구조이다.

 

처음에 내가 착각한 것: 페치 조인은 @OneToMany 전용이다?

@OneToMany(fetch = FetchType.LAZY)
private List<OrderMenu> orderMenus;

보통 위와 같이 @OneToMany로 선언된 리스트에만 JOIN FETCH를 사용하는 줄 알았다. 하지만 이건 오해였다.

페치 조인도 결국 조인

페치 조인(Fetch Join)은 SQL의 조인과 동일하게 외래 키(FK) 를 기준으로 연관된 데이터를 한 번에 가져오는 것일 뿐, @OneToMany, @ManyToOne 같은 방향성과는 무관하다.

 

예를 들어 다음 쿼리는 OrderMenu -> Order, Menu 단방향 관계에서 모두 페치 조인을 사용하고 있다.

SELECT om
FROM OrderMenu om
JOIN FETCH om.order o
JOIN FETCH om.menu m
WHERE o.user.id = :userId

단 하나의 쿼리로 연관 객체까지 한 번에 조회할 수 있다.  즉, 페치 조인은 JPA가 연관된 객체를 즉시 조회하는 것 뿐이었다.

페치 조인도 결국 조인! 데이터베이스엔 외래키를 기준으로 조인하는 것은 동일하다. JPA가 연관된 엔티티를 바로 가져오냐의 여부만 다를뿐!

 

문제 발생: 페이징

페치조인의 가장 큰 단점은 페이징과 호환이 어렵다는 것이다. 왜냐하면

SELECT * FROM orders o
LEFT JOIN order_menu om ON om.order_id = o.id

이런 식으로 조인을 할 경우, 주문 1개당 메뉴 N개로 인해 row가 중복되고 Pageable에서 개수를 세는 기준이 row가 되기 때문이다.

 

그 이유는 JPA가 페이징을 처리하는 방식이 다음과 같기 때문이다.
JPA에서 Paging을 사용하면 다음 두 쿼리를 실행한다.

-- 실제 데이터 가져오기
SELECT * FROM orders LIMIT ?, ?
--
전체 개수 세기 SELECT COUNT(*) FROM orders

해결: 2쿼리 전략

1. 먼저, Order만 페이징 쿼리

@Query("SELECT o FROM Order o WHERE o.user.id = :userId")
Page<Order> findByUserId(@Param("userId") Long userId, Pageable pageable);

2. 그 다음, orderIds로 OrderMenu + Menu 페치 조인

@Query("SELECT om FROM OrderMenu om JOIN FETCH om.menu WHERE om.order.id IN :orderIds")
List<OrderMenu> findByOrderIdInWithMenu(@Param("orderIds") List<Long> orderIds);

문제 발생2: 쿼리 4개 발생

페이징 쿼리와, 페치 조인 단 2개만 기대했는데, 4개의 쿼리가 발생했다.

살펴보니, 페이징할 때 가져오는 Order에서 연관된 User를 지연 로딩으로 참조하고 있어서 추가 쿼리가 발생했다.

해결2: 페이징에서 페치조인

페이징에서 페치조인을 할 때, @countQuery로 count는 어떤 쿼리를 기준으로 할 건지 명시해주면 의도한대로 Order를 기준으로 원하는 갯수만큼 조회할 수 있다. 

@Query(
    value = "SELECT o FROM Order o JOIN FETCH o.store WHERE o.user.id = :userId",
    countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user.id = :userId"
)
Page<Order> findAllByUserId(@Param("userId") long userId, Pageable pageable);
 

쿼리 결과: 단 2개

Hibernate: 
    select
        o1_0.`id`,
        o1_0.`created_at`,
        o1_0.`created_by`,
        o1_0.`modified_at`,
        o1_0.`modified_by`,
        o1_0.`order_status`,
        o1_0.`request_comment`,
        o1_0.`store_id`,
        o1_0.`user_id`,
        u1_0.`id`,
        u1_0.`created_at`,
        u1_0.`email`,
        u1_0.`is_deleted`,
        u1_0.`modified_at`,
        u1_0.`name`,
        u1_0.`password`,
        u1_0.`phone`,
        u1_0.`user_role` 
    from
        `orders` o1_0 
    join
        `users` u1_0 
            on u1_0.`id`=o1_0.`user_id` 
    where
        o1_0.`store_id`=? 
    limit
        ?
Hibernate: 
    select
        om1_0.`id`,
        om1_0.`count`,
        om1_0.`created_at`,
        om1_0.`created_by`,
        om1_0.`menu_id`,
        m1_0.`id`,
        m1_0.`category`,
        m1_0.`description`,
        m1_0.`is_deleted`,
        m1_0.`is_option`,
        m1_0.`name`,
        m1_0.`price`,
        m1_0.`store_id`,
        om1_0.`modified_at`,
        om1_0.`modified_by`,
        om1_0.`order_id` 
    from
        `orders_menus` om1_0 
    join
        `menu` m1_0 
            on m1_0.`id`=om1_0.`menu_id` 
    where
        om1_0.`order_id` in (?, ?, ?, ?, ?, ?, ?, ?)

최종 코드

public PageResponseDto<OrderHistoryUserResponseDto> getOrdersForUser(long userId, Pageable pageable) {
        // order만 페이징 조회
        Page<Order> orders = orderRepository.findAllByUserId(userId, pageable);
        List<Long> orderIds = orders.getContent().stream()
                .map(Order::getId)
                .toList();
		
        // orderIds로 orderMenus 조회(메뉴까지 페치조인)
        List<OrderMenu> orderMenus = orderMenuRepository.findByOrderIdInWithMenu(orderIds);
        Map<Long, List<OrderMenu>> orderMenuMap = orderMenus.stream()
                .collect(Collectors.groupingBy(om -> om.getOrder().getId()));

		// Dto에 저장
        Page<OrderHistoryUserResponseDto> result = orders.map(order ->
                OrderHistoryUserResponseDto.from(
                        order,
                        orderMenuMap.getOrDefault(order.getId(), List.of()),
                        false
                )
        );

        return PageResponseDto.toDto(result);
    }