JPA

[JPA] Fetch Join과 Pageable을 함께 사용할 수 없는 이유와 해결 방법 정리

코딩 못하는 감자 2025. 4. 4. 19:17

1. 문제 상황

서비스 개발 중 FETCH JOIN을 통한 쿼리에 Pageable을 적용할 필요가 생기게 되었다.

사용자의 알림 내역을 가지고 있는 Notification 테이블과, Notification에 대한 정보를 가지고 있는 Routine테이블이 N:1로 연관관계를 맺고 있는데, 네트워크 통신의 수를 줄이고자 기존에 FETCH JOIN을 통하여 쿼리하고 있었다.

하지만 알림 내역은 지속적으로 쌓이는 데이터이므로 Pagenation의 추가가 필요하다고 판단하였다.

기존코드

    /**
     * 사용자의 Notification Entity List 반환 FETCH JOIN
     * */
    @Query(value = "SELECT n from NotificationEntity n " +
            "JOIN FETCH n.routine r " +
            "WHERE r.user.id = :userId")
    List<NotificationEntity> findNotificationsByUserId(@Param("userId") Long userId);

Pagenation을 추가하려고 관련 정보를 찾아보니 JPA는 FETCH JOIN에 대해서 Pagenatiion 기능을 제공하지 않는다고 한다.

왜 제공하지 않는지, JPA와 데이터베이스의 구조 차이에 대해서 정리하게 되었다.

 


2. 구조 분석

위 예제로는 문제를 이해하기 힘들 것 같아. 상황을 아래와 같이 정의하겠다.

Routine테이블과 Notification테이블이 일대다 연관관계를 맺고 있고 RoutineEntity를 조회하려고 한다.

식별자 id=1에 해당하는 RoutineEntity는 id=1, 2, 3에 각각 해당하는 NotificationEntity 3개를 보유하고 있다.

이 상황에서 FETCH JOIN을 하게 되면 데이터베이스로부터 아래와 같은 데이터를 가져온다.

데이터 예시

routine.id  routine.name  notification.id  notification.contenet
1 아침 식사 전 1 아침 식사전 복용할 약이 존재합니다.
1 아침 식사 전 2 복용하지 않은 약이 있습니다.
1 아침 식사 전 3 한시간 전에 복용하지 않은 약이 있습니다.

 

Routine은 1개의 데이터가 존재하고 Notification은 3개의 데이터가 존재하기 때문에, 중복된 Routine 데이터가 발생하면서 총 3개의 데이터가 조회된다.

 

JPA는 하위 엔티티에 대해서 List로 관리하기 때문에, 중복된 결과를 제외하고 저장한다.

그렇다면 만약, 이 상황에서 limit을 2로 주어 데이터 수에 제한을 둔다고 해보자.

 

아래와 같이 routine에는 3개의 데이터가 존재하지만 제한으로 인해 2개의 데이터만 조회될 것이다.

routine.id  routine.name  notification.id  notification.contenet
1 아침 식사 전 1 아침 식사전 복용할 약이 존재합니다.
1 아침 식사 전 2 복용하지 않은 약이 있습니다.

 

이와 같이 불완전한 데이터를 가져오는 것은 객체 그래프의 무결성을 매우 중요하게 생각하는 JPA의 메커니즘과 다르다. 따라서 FETCH JOIN에서 Pagenation 기능을 제공하지 않는다.

 


3. 해결방법

FETCH JOIN 을 통하여 하위엔티티까지 값을 가져올 수 없기 때문에 EntityGraph를 통하여 값을 가져온다.

 /**
     * 사용자의 Notification Entity List 반환 
     * FETCH JOIN의 경우 Pageable 지원 x -> EntityGraph로 구현 
     * */
    @EntityGraph(attributePaths = {"routine"})
    @Query("SELECT n FROM NotificationEntity n WHERE n.routine.user.id = :userId")
    List<NotificationEntity> findNotificationsByUserIdWithPageable(@Param("userId") Long userId, Pageable pageable);

 

아래와 같이 온메모리에서 페이징 처리하는 것이 아닌 SQL 쿼리부터 잘 적용된 것을 확인할 수 있다.

Hibernate: select ne1_0.id,ne1_0.content,ne1_0.is_read,r1_0.id,r1_0.take_date,r1_0.user_id,r1_0.user_schedule_id,ne1_0.sent_at,ne1_0.title from public.notification ne1_0 left join public.routine r1_0 on r1_0.id=ne1_0.routine_id where r1_0.user_id=? order by ne1_0.sent_at desc offset ? rows fetch first ? rows only

4. 예외

4.1. 상황

위 예시의 경우는 다대일 관계에서 FETCH JOIN과 Pageable을 동시에 적용한 케이스이기 때문에, 정상적으로 SQL에 페이징을 추가할 수 있다.

하지만 일대다 관계에서는 어떨까???

 

일대다 관계에서는 1개의 Routine 3개의 Notification이 존재하는 경우 Notification수에 맞게 Row가 발생한다.

이 상황에 Routine에 대해서 페이징을 적용하는 경우 루틴 기준으로 페이징을 하는 것이 아닌 Notification 기준으로 페이징을 하게 되는 문제가 발생한다.

 

아래 표를 예시로 들자면, routine 테이블에 대해서 2개의 데이터를 가져오려고 할 때, routine.id=1, routine.id= 2 의 값을 가져오는 것이 아닌, routine.id=1 이면서 notification.id=1,2 즉 routine id가 1인 데이터의 일부만 가져오는 결과가 발생한다.

routine.id  routine.name  notification.id  notification.contenet
1 아침 식사 전 1 아침 식사전 복용할 약이 존재합니다.
1 아침 식사 전 2 복용하지 않은 약이 있습니다.
1 아침 식사 전 3 한시간 전에 복용하지 않은 약이 있습니다.
2 점심 식사 전 4 점심 식사전 복용할 약이 존재합니다.
2 점심 식사 전 5 복용하지 않은 약이 있습니다.
2 점심 식사 전 6 한시간 전에 복용하지 않은 약이 있습니다.

 

따라서 JPA는 일대다 관계에서 페이징 요청이 들어오면, SQL에 반영하지 않고 모든 데이터를 가져온 후, 온메모리에서 페이징 처리를한다.

데이터의 총량이 많은 경우 메모리가 오버플로우하여 시스템이 중단되는 치명적인 문제가 발생할 수 있다.

 

4.2. 해결 방법

하나의 쿼리를 두개의 쿼리로 나누어 문제를 해결한다.

  1. Routine에 대해서 페이징
  2. FETCH JOIN 방식

일대다 관계에서 EntityGraph를 통하여 값을 가져올경우 하위엔티티의 row 개수로 인해, 페이징이 불가능해져, 온메모리에서 직접 페이징을 하는 문제가 발생했었다.

따라서 Routine에 대해서만 먼저 페이징을 진행하고 IN절을 통하여 FETCH JOIN하는 방식으로 문제를 해결할 수 있다.

// Step 1: ID만 페이징
@Query("SELECT r.id FROM RoutineEntity r WHERE r.user.id = :userId")
Page<Long> findRoutineIdsByUserId(Long userId, Pageable pageable);

// Step 2: ID 리스트로 fetch join
@Query("SELECT r FROM RoutineEntity r JOIN FETCH r.fileEntitySet WHERE r.id IN :ids")
List<RoutineEntity> findWithFilesByIds(@Param("ids") List<Long> ids);

쿼리의 개수는 1 → 2개로 늘어나게 되지만, SQL단에서 페이징을 적용시켜 네트워크 비용과, 메모리 안정성을 챙길 수 있다.

 


5. Batch Size

FETCH JOIN에서의 페이징을 해결하는 직접적인 방법은 아니지만, JPQL FETCH JOIN, EntityGraph와 더불어 1:N 지연로딩 관계에서 값을 미리가져올 수 있는 방법이라 같이 정리하였다.

미리 지정한 값에 대하여 상위 엔티티를 조회할 때 BatchSize에 명시한 크기만큼 하위엔티티를 조회한다.

 

글로벌 설정

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 10

 

엔티티 별로 설정

@OneToMany(...)
@BatchSize(size = 10)
private Set<NotificationEntity> notificationSet=new LinkedHashSet<>();

 

RoutineEntity를 조회하면 아래와 같은 IN절 쿼리가 발생한다.

SELECT
    n.id,
    n.title,
    n.content,
    n.routine_id
FROM
    notification n
WHERE
    n.routine_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

 

하지만 실제로는 batch_size대로 적용되지 않는다. 하이버네이트 내부적으로 최적화 작업을 거치기 때문이다.

하이버네이트는 사용자가 이 데이터값을 전부 사용할 것이라고 생각하지 않기 때문에, batch_size 내부적으로 데이터를 나누게 된다. 이를 점진적 배치 로딩이라고 부른다.

 

조회 대상 수 나누는 방식 (예시)

batch size = 100 100, 50, 25, 12, 6, 3, 1 ...
예: 14개 ID 조회 IN (12), IN (2) 쿼리 2번 발생

 

실제로 일어나는 쿼리는 아래와 같다.

-- 첫 번째 IN 쿼리 (12개)
SELECT ... FROM notification WHERE id IN (?, ?, ..., ?)

-- 두 번째 IN 쿼리 (2개)
SELECT ... FROM notification WHERE id IN (?, ?)

 

따라서 BatchSize를 사용하면 상위엔티티 쿼리 1개 + 하위엔티티 쿼리 (1~K)개가 발생한다.

한번에 하위 엔티티 값을 전부 사용한다면, 하이버네이트의 점진적 배치 로딩을 끌 수 있다.

spring:
  jpa:
    properties:
      hibernate:
        batch_fetch_style: dynamic

6. 결론

JPA는 자바 애플리케이션에서 데이터를 효율적으로 사용할 수 있도록 많은 이점을 주지만, JPA와 데이터베이스의 구조 차이로 인해 N+1 문제와 같이 다양한 문제가 발생한다.

매커니즘적 차이를 극복하기 위해 다양한 기능을 제공하기 때문에, 문제를 이해하고 기능을 잘 활용한다면, 많은 성능 개선을 이룰 수 있을 것이라 생각이 들었다.

  1. FETCH JOIN의 경우 객체 그래프의 무결성을 위해 페이징 기능을 제공하지 않는다.
  2. 다대일 관계에서는 EntityGraph를 통하여 N+1, Paging 문제를 해결할 수 있다.
  3. 일대다 관계에서는 EntityGrapth를 사용할 경우 온메모리 페이징 문제가 발생한다.
  4. 페이징+Fetch Join 두가지의 쿼리로 나누어 문제를 해결할 수 있다.
  5. Batch Size를 지정하여, Fetch Join을 사용하지 않더라도 하위엔티티를 같이 조회할 수 있다.