Spring/JPA 심화

JPA 컬렉션 페치 조인의 페이징 문제와 해결 전략

JuNo_12 2025. 6. 27. 20:04

문제 상황: 컬렉션 페치 조인 + 페이징의 함정

안녕하세요! 오늘은 많은 개발자분들이 JPA를 사용하면서 한 번쯤은 겪어보셨을 컬렉션 페치 조인과 페이징의 문제에 대해 다뤄보겠습니다.

 

문제가 되는 코드 예시

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
    
    // ❌ 이런 코드를 작성하면 문제가 발생합니다
    @Query("SELECT t FROM Todo t JOIN FETCH t.comments WHERE t.user.id = :userId")
    Page<Todo> findTodosWithComments(@Param("userId") Long userId, Pageable pageable);
}

 

왜 문제가 될까요?

핵심 원인: 카테시안 곱(Cartesian Product)

-- 실제 실행되는 SQL
SELECT t.*, c.*
FROM todos t 
JOIN comments c ON t.id = c.todo_id 
WHERE t.user_id = 1;

 

결과셋 예시:

todo_id todo_title comment_id comment_content
1 "할일1" 101 "댓글1"
1 "할일1" 102 "댓글2"
1 "할일1" 103 "댓글3"
2 "할일2" 201 "댓글1"

Todo 2개이지만 결과셋은 4줄로 확장됩니다!

 

발생하는 문제들

  1. 부정확한 페이징: LIMIT 10을 걸어도 Todo 10개가 아닌 "조인된 로우" 10개를 가져옴
  2. 잘못된 totalElements: 실제 Todo 100개인데 300개로 표시 (댓글 수만큼 부풀려짐)
  3. 성능 저하: Hibernate가 메모리에서 페이징 처리

해결 전략 1: 별도 쿼리 분리 + @BatchSize

접근 방법

컬렉션 페치 조인을 포기하고, 2단계로 나누어 처리하는 방법입니다.

 

구현 코드

// 1단계: Todo만 페이징 조회
@Query("SELECT t FROM Todo t WHERE t.user.id = :userId")
Page<Todo> findTodosByUserId(@Param("userId") Long userId, Pageable pageable);

// 2단계: 엔티티에 @BatchSize 설정
@Entity
public class Todo {
    @OneToMany(mappedBy = "todo")
    @BatchSize(size = 50)  // 50개씩 배치로 로딩
    private List<Comment> comments = new ArrayList<>();
}

// 사용 예시
@Service
public class TodoService {
    
    public Page<TodoDto> getTodos(Long userId, Pageable pageable) {
        Page<Todo> todos = todoRepository.findTodosByUserId(userId, pageable);
        
        return todos.map(todo -> new TodoDto(
            todo.getId(),
            todo.getTitle(),
            todo.getComments().size()  // 여기서 배치 로딩 발생
        ));
    }
}

실행되는 SQL

-- 1단계: Todo 페이징 조회
SELECT t.* FROM todos t WHERE t.user_id = 1 
ORDER BY t.created_at DESC LIMIT 10 OFFSET 0;

-- 2단계: Comments 배치 로딩 (필요시)
SELECT c.* FROM comments c 
WHERE c.todo_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

장점

  • 정확한 페이징 처리
  • N+1 문제 해결 (@BatchSize로)
  • 간단한 구현

단점

  • 쿼리가 2번 실행됨 (네트워크 비용)
  • 지연 로딩 의존성 (트랜잭션 범위 주의)

해결 전략 2: Projection 활용

접근 방법

컬렉션 자체를 가져오지 않고 집계 함수를 사용하여 필요한 정보만 추출하는 방법입니다.

 

구현 코드

// DTO 정의
public class TodoSearchResponse {
    private final Long id;
    private final String title;
    private final Long commentCount;  // 댓글 개수만
    private final Long managerCount;  // 매니저 개수만
    
    // constructor...
}

// QueryDSL 구현
@Repository
public class TodoQueryRepositoryImpl {
    
    public Page<TodoSearchResponse> searchTodos(Pageable pageable) {
        QTodo t = QTodo.todo;
        QComment c = QComment.comment;
        QManager m = QManager.manager;
        
        var content = jpaQueryFactory
            .select(Projections.constructor(TodoSearchResponse.class,
                    t.id,
                    t.title,
                    c.id.count(),    // 집계 함수 사용
                    m.id.count()))   // 집계 함수 사용
            .from(t)
            .leftJoin(t.comments, c)
            .leftJoin(t.managers, m)
            .groupBy(t.id, t.title)  // GROUP BY로 중복 해결
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
            
        return new PageImpl<>(content, pageable, totalCount);
    }
}

실행되는 SQL

SELECT t.id, t.title, COUNT(c.id), COUNT(m.id)
FROM todos t 
LEFT JOIN comments c ON t.id = c.todo_id
LEFT JOIN managers m ON t.id = m.todo_id
GROUP BY t.id, t.title
ORDER BY t.created_at DESC
LIMIT 10 OFFSET 0;

 

장점

  • 단일 쿼리로 해결
  • 정확한 페이징
  • 높은 성능

단점

  • 컬렉션 엔티티 자체는 접근 불가
  • 집계 정보만 활용 가능
  • DTO 변환 필요

해결 전략 3: IN절 패턴 (추천)

접근 방법

2단계 쿼리를 사용하되, IN절로 효율적으로 처리하는 방법입니다. 글로벌 페치 전략을 활용할 수 있는 가장 실용적인 해결책입니다.

 

구현 코드

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
    
    // 1단계: ID만 페이징 조회
    @Query("SELECT t.id FROM Todo t WHERE t.user.id = :userId ORDER BY t.createdAt DESC")
    List<Long> findTodoIdsByUserId(@Param("userId") Long userId, Pageable pageable);
    
    // 2단계: IN절로 엔티티 조회
    @Query("SELECT t FROM Todo t LEFT JOIN FETCH t.user WHERE t.id IN :ids ORDER BY t.createdAt DESC")
    List<Todo> findByIdInWithUser(@Param("ids") List<Long> ids);
    
    // 전체 개수 조회
    @Query("SELECT COUNT(t) FROM Todo t WHERE t.user.id = :userId")
    Long countTodosByUserId(@Param("userId") Long userId);
}

@Service
@Transactional(readOnly = true)
public class TodoService {
    
    public Page<Todo> getTodosWithComments(Long userId, Pageable pageable) {
        // 1단계: ID만 페이징 조회 (DB 레벨 페이징)
        List<Long> todoIds = todoRepository.findTodoIdsByUserId(userId, pageable);
        
        if (todoIds.isEmpty()) {
            return Page.empty(pageable);
        }
        
        // 2단계: IN절로 Todo + User 조회
        List<Todo> todos = todoRepository.findByIdInWithUser(todoIds);
        
        // 3단계: comments는 글로벌 페치 전략에 따라 처리
        // @BatchSize(size = 50)에 의해 배치 로딩
        
        Long total = todoRepository.countTodosByUserId(userId);
        
        return new PageImpl<>(todos, pageable, total);
    }
}

실행되는 SQL

-- 1단계: ID만 페이징 조회 (정확한 DB 레벨 페이징)
SELECT t.id FROM todos t WHERE t.user_id = 1 
ORDER BY t.created_at DESC LIMIT 10 OFFSET 0;

-- 2단계: Todo + User 조회
SELECT t.*, u.* FROM todos t 
LEFT JOIN users u ON t.user_id = u.id 
WHERE t.id IN (1,2,3,4,5,6,7,8,9,10)
ORDER BY t.created_at DESC;

-- 3단계: Comments 배치 로딩 (필요시, @BatchSize에 의해)
SELECT c.* FROM comments c WHERE c.todo_id IN (1,2,3,4,5,6,7,8,9,10);

 

장점

  • 정확한 DB 레벨 페이징
  • 글로벌 페치 전략 활용 가능
  • @BatchSize와 완벽한 조합
  • N+1 문제 해결
  • 재사용 가능한 메서드 구조
  • 복잡한 조건에도 적용 가능

단점

  • 쿼리가 여러 번 실행됨
  • 코드 복잡성 증가
  • 트랜잭션 범위 관리 필요

고급 전략: @EntityGraph + QueryHints

접근 방법

EntityGraph와 커스텀 힌트를 조합하여 쿼리 실행 계획을 세밀하게 제어하는 방법입니다.

 

구현 코드

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
    
    @EntityGraph(attributePaths = {"user", "comments.user"})
    @QueryHints({
        @QueryHint(name = "javax.persistence.fetchgraph", value = "true"),  // 정확히 명시된 것만 로딩
        @QueryHint(name = "org.hibernate.readOnly", value = "true"),        // 변경 감지 비활성화
        @QueryHint(name = "org.hibernate.cacheable", value = "true")        // 쿼리 결과 캐싱
    })
    @Query("SELECT t FROM Todo t WHERE t.id IN :ids")
    List<Todo> findByIdInOptimized(@Param("ids") List<Long> ids);
}

 

QueryHint 옵션들

힌트 설명 효과
javax.persistence.fetchgraph EntityGraph에 명시된 것만 로딩 예측 가능한 쿼리
org.hibernate.readOnly 변경 감지 비활성화 조회 성능 향상
org.hibernate.cacheable 쿼리 결과 캐싱 반복 조회 성능 향상
org.hibernate.fetchSize 배치 페치 사이즈 조정 네트워크 최적화

 

장점

  • 세밀한 쿼리 제어
  • 성능 최적화 옵션 활용
  • 캐시 전략 제어 가능

단점

  • 복잡한 설정
  • 벤더 종속적인 힌트들
  • 여전히 컬렉션 페치 조인 문제 존재

실무 적용 가이드

상황별 최적 전략 선택

상황추천 전략 이유
단순 조회 + 페이징 IN절 패턴 정확한 페이징 + 글로벌 페치 전략 활용
집계 정보만 필요 Projection 최고 성능 + 단일 쿼리
읽기 전용 대용량 조회 @EntityGraph + Hints 캐시 + 최적화
간단한 1:N 관계 @BatchSize만 구현 단순성

결론 및 추천사항

핵심 포인트

  1. 컬렉션 페치 조인 + 페이징은 피하세요
    • 카테시안 곱으로 인한 부정확한 페이징
    • 메모리에서의 페이징 처리로 성능 저하
  2. IN절 패턴을 기본 전략으로 채택하세요
    • DB 레벨 정확한 페이징
    • 글로벌 페치 전략과 @BatchSize 활용 가능
    • 복잡한 조건에도 적용 가능
  3. 상황에 맞는 전략을 선택하세요
    • 집계 정보만 필요하면 Projection
    • 성능이 중요하면 QueryHints 활용
    • 단순함이 중요하면 @BatchSize만 사용

실무 체크리스트

  • 컬렉션 페치 조인 + 페이징 코드 점검
  • @BatchSize 설정 확인
  • IN절 패턴 적용 검토
  • 성능 테스트 실시
  • 트랜잭션 범위 관리 확인

이상으로 JPA 컬렉션 페치 조인의 페이징 문제와 해결 전략에 대해 알아보았습니다. 각 전략의 장단점을 이해하시고, 프로젝트 상황에 맞는 최적의 방법을 선택하시기 바랍니다!