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줄로 확장됩니다!
발생하는 문제들
- 부정확한 페이징: LIMIT 10을 걸어도 Todo 10개가 아닌 "조인된 로우" 10개를 가져옴
- 잘못된 totalElements: 실제 Todo 100개인데 300개로 표시 (댓글 수만큼 부풀려짐)
- 성능 저하: 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만 | 구현 단순성 |
결론 및 추천사항
핵심 포인트
- 컬렉션 페치 조인 + 페이징은 피하세요
- 카테시안 곱으로 인한 부정확한 페이징
- 메모리에서의 페이징 처리로 성능 저하
- IN절 패턴을 기본 전략으로 채택하세요
- DB 레벨 정확한 페이징
- 글로벌 페치 전략과 @BatchSize 활용 가능
- 복잡한 조건에도 적용 가능
- 상황에 맞는 전략을 선택하세요
- 집계 정보만 필요하면 Projection
- 성능이 중요하면 QueryHints 활용
- 단순함이 중요하면 @BatchSize만 사용
실무 체크리스트
- 컬렉션 페치 조인 + 페이징 코드 점검
- @BatchSize 설정 확인
- IN절 패턴 적용 검토
- 성능 테스트 실시
- 트랜잭션 범위 관리 확인
이상으로 JPA 컬렉션 페치 조인의 페이징 문제와 해결 전략에 대해 알아보았습니다. 각 전략의 장단점을 이해하시고, 프로젝트 상황에 맞는 최적의 방법을 선택하시기 바랍니다!