본문 바로가기

Spring Data

[JPA] fetchJoin 과 Paging 처리의 한계 및 해결 방안

EntityGraph를 사용하여 fetch join을 할 경우, 쿼리에서 직접적으로 페이징 처리가 어려워집니다.

 

이유는 다음과 같습니다:

 

1. 복잡한 조인 구조
예를 들어, A, B, C 세 테이블을 조인하는 경우를 생각해 봅시다.

 

A와 B는 1:N 관계이고, B와 C도 1:N 관계라면, 예를 들어 N이 3이라 가정할 때

페이지 크기가 10이면, 실제로 10개의 A 엔티티를 가져오더라도 조인 결과로는 최대 10 × 3 × 3 = 90개의 행(row)이 반환될 수 있습니다.

 

2. RDBMS와 객체지향의 차이

관계형 데이터베이스에서는 위와 같이 다중 조인된 결과에 대해 LIMIT을 적용할 때, 원래의 A 엔티티 기준으로 제한하지 않고 전체 행에 대해 제한을 걸게 됩니다.

그래서 결과적으로 full scan을 하고, 반환된 결과들을 객체로 매핑한 후 Java 코드 내에서 subList() 같은 메서드로 페이징 처리를 하게 됩니다.

 

해결 방법은 두 가지로 나뉩니다:

1. paging이 필요한 하나의 테이블만 조회 후 나머지 테이블 fetchJoin 또는 entityGraph 조회

A 테이블만 조회하면 SQL 단계에서 LIMIT을 적용해 원하는 페이징 처리가 가능하며, 나머지 B, C 데이터는 별도의 fetch join이나 EntityGraph를 사용하여 조회할 수 있습니다.

 

2. paging이 필요한 하나의 테이블만 조회 후 Batch Size 옵션을 사용한 LazyLoading

성능상 fetch join이나 EntityGraph를 사용해야 하는 경우가 아니라면, @BatchSize 애너테이션이나 spring.jpa.properties.hibernate.default_batch_fetch_size 설정을 통해 연관 엔티티들을 한 번에 조회하도록 할 수 있습니다.

 

 

예시 코드

 

아래 코드는 2번째 방법인 Order 테이블에서 페이징 처리를 먼저 완료하고, 이후에 OrderItem과 그에 연관된 Product를 Lazy Loading으로 초기화하는 방법입니다:

@Transactional(readOnly = true)
public Page<Order> findOrdersWithOrdersItemsAndProductsByUserId(long userId, Pageable pageable) {
    // 1. 페이징된 Order 엔티티들을 조회
    Page<Order> findPagedOrders = orderRepository.findAllByBuyerId(userId, pageable);
    
    // 2. 각 Order에 포함된 OrderItem과 Product를 강제로 초기화 (Lazy Loading)
    List<Long> orderIds = findPagedOrders.map(Order::getId).toList();
    if (!orderIds.isEmpty()) {
        // 각 Order의 OrderItems 컬렉션을 초기화
        findPagedOrders.getContent().forEach(order -> 
            order.getOrderItems().forEach(orderItem -> 
                orderItem.getProduct().getId() // Product의 식별자 접근으로 초기화
            )
        );
    }
    
    return findPagedOrders;
}

 

이 방식은 Order 엔티티들을 먼저 페이징 처리한 후, 각 Order의 Lazy Loaded 컬렉션에 접근하여 배치로 초기화합니다.

만약 배치 사이즈 설정이 없다면 N+1 문제가 발생할 수 있으므로, 위에서 언급한 @BatchSizehibernate.default_batch_fetch_size 옵션을 통해 쿼리 수를 줄일 수 있습니다.

 

또한 메인 로직이 아님에도 .forEach 같은 코드들이 있으면 가독성이 떨어지는 문제도 있으므로 아래와 같이 forceLoad를 사용하여

가독성을 개선할 수 있습니다.

    public static <T> void forceLoad(Collection<T> list, Function<T, ?> loader) {
        list.forEach(item -> {
            Object lazyProperty = loader.apply(item);
            if (lazyProperty instanceof Collection) {
                int noUseValue = ((Collection<?>) lazyProperty).size();
            } else if (lazyProperty != null) {
                String noUseValue = lazyProperty.toString();
            }
        });
    }
    // 사용법 LazyLoadingUtils.forceLoad(orders, Order::getOrderItems);

    public static <T> void forceLoad(T item, Function<T, ?> loader) {
        Object lazyProperty = loader.apply(item);
        if (lazyProperty instanceof Collection) {
            int noUseValue = ((Collection<?>) lazyProperty).size();
        } else if (lazyProperty != null) {
            String noUseValue = lazyProperty.toString();
        }
    }
    // 사용법 LazyLoadingUtils.forceLoad(orderItems, OrderItem::getProduct);