페이징(Pagination) 구현은 백엔드 개발의 일반적인 요구사항이다. Spring Data JPA와 QueryDSL은 Page 객체를 통해 이를 편리하게 처리하는 기능을 제공한다. 이 과정에서는 데이터 목록을 조회하는 메인 쿼리와 전체 데이터 개수를 조회하는 Count 쿼리를 함께 사용하게 된다.
이때 Count 쿼리를 작성하는 과정에서 groupBy를 잘못 사용하면, 페이징 처리에 오류가 발생할 수 있다.
문제 현상: 오류 없이 잘못된 totalCount가 반환되는 코드
다음은 '특정 키워드가 포함된 댓글이 달린 게시글(Post)' 목록을 조회하며, 각 게시글에 달린 댓글 수를 집계하여 페이징으로 반환하는 코드이다.
// 메인 쿼리
JPAQuery<PostDto> query = queryFactory
.select(
Projections.fields(PostDto.class,
post.id.as("postId"),
post.title,
comment.id.count().as("commentCount")
)
)
.from(post)
.leftJoin(comment).on(comment.post.id.eq(post.id))
.where(comment.content.contains("keyword")) // 댓글 내용으로 필터링
.groupBy(post.id); // 각 게시글별로 집계하기 위해 groupBy 사용
// 문제가 되는 Count 쿼리
JPAQuery<Long> countQuery = queryFactory
.select(post.id.count())
.from(post)
.leftJoin(comment).on(comment.post.id.eq(post.id)) // where절을 위해 조인
.where(comment.content.contains("keyword")) // 동일한 필터링 조건
.groupBy(post.id);
return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchOne);
이 코드를 실행하면 API는 정상적으로 응답하며 서버 로그에도 오류는 기록되지 않는다. 하지만 페이징 정보의 totalCount 값이 실제 전체 개수와 다른, 1과 같은 작은 값으로 반환되는 문제가 발생한다.
숨겨진 최적화: PageableExecutionUtils의 동작 방식
이 문제가 더 발견하기 어려운 이유는 PageableExecutionUtils가 가진 최적화 로직 때문이다. 이 유틸리티 클래스는 불필요한 Count 쿼리를 실행하지 않으려는 동작 방식을 포함하고 있다.
PageableExecutionUtils.getPage()는 Count 쿼리를 실행하기 전에, 먼저 메인 쿼리를 통해 현재 페이지의 데이터 목록을 가져온다. 그리고 가져온 데이터의 개수가 요청된 페이지 크기(pageSize)보다 작을 경우, 뒤에 더 이상 데이터가 없다고 판단한다. 이 경우, 굳이 전체 개수를 세는 Count 쿼리를 실행하지 않고, 현재까지 조회된 데이터 개수를 기반으로 totalCount를 계산해버린다.
이 최적화 덕분에 전체 데이터가 pageSize보다 적은 테스트 환경에서는 문제가 되는 Count 쿼리가 아예 실행되지 않아, 버그가 없는 것처럼 보일 수 있다. 문제는 데이터가 많아져 이 최적화 조건을 벗어나는 순간 발생한다.
예상 동작: NonUniqueResultException 발생
JPA 명세에 따르면, fetchOne() (내부적으로 getSingleResult())은 쿼리 결과가 2개 이상일 때 NonUniqueResultException을 발생시켜야 한다.
위 Count 쿼리가 생성하는 SQL은 다음과 같다.
select count(p1_0.id)
from post p1_0
left join comment c1_0 on c1_0.post_id = p1_0.id
where c1_0.content like '%keyword%'
group by p1_0.id
이 SQL은 groupBy 절로 인해 각 그룹별 count를 실행하므로, 결과는 [1], [1], [1], ... 처럼 여러 행의 데이터가 된다. 따라서 fetchOne()을 실행하면 NonUniqueResultException이 발생하는 것이 정상적인 기대 동작이다.
실제로 select 절에서 count()를 제거하고 일반 컬럼을 조회하도록 수정하면, 예상대로 NonUniqueResultException이 발생한다.
// 이 코드는 NonUniqueResultException을 발생시킨다.
JPAQuery<Long> errorQuery = queryFactory
.select(post.id) // count() 제거
.from(post)
.leftJoin(comment).on(comment.post.id.eq(post.id))
.where(comment.content.contains("keyword"))
.groupBy(post.id);
errorQuery.fetchOne(); // ERROR!
그렇다면 왜 count()가 포함된 쿼리는 예외를 발생시키지 않는 것일까?
실제 동작: JPA 구현체(Hibernate)의 동작 방식
이 현상은 JPA 표준 명세가 아닌, 그 구현체인 Hibernate의 내부 동작 방식과 관련이 있다.
Hibernate는 fetchOne()을 실행할 때, SELECT 절의 내용에 따라 동작을 다르게 처리하는 것으로 보인다.
- select(post.id): SELECT 절이 일반 컬럼인 경우, Hibernate는 쿼리 결과가 여러 개일 수 있다고 판단하고 fetchOne의 규칙을 엄격하게 적용하여 NonUniqueResultException을 발생시킨다.
- select(post.id.count()): SELECT 절에 count() 같은 집계 함수가 포함된 것을 특별하게 인지한다. Hibernate는 이 쿼리의 본질을 '개수'를 세는 것으로 해석하여, 결과가 여러 행이더라도 예외를 발생시키는 대신 첫 번째 행의 값을 반환하는 방식으로 동작한다.
이러한 Hibernate의 내부 처리 방식 때문에 예외가 발생하는 대신, totalCount가 1로 잘못 계산되는 현상이 발생한 것이다.
올바른 해결책
이 문제는 Count 쿼리의 본래 목적인 '전체 결과 행의 개수'를 조회하도록 수정하면 해결된다. Count 쿼리에서는 각 그룹의 개수가 아닌, 전체 그룹의 총개수를 조회해야 한다.
따라서 Count 쿼리에서는 groupBy를 제거하고, countDistinct를 사용해야 한다.
// 올바른 Count 쿼리
JPAQuery<Long> countQuery = queryFactory
.select(post.id.countDistinct()) // countDistinct로 변경
.from(post)
.leftJoin(comment).on(comment.post.id.eq(post.id))
.where(comment.content.contains("keyword")); // 필터링을 위해 메인 쿼리와 동일한 조인과 where절이 반드시 필요하다.
// groupBy 제거
'Spring' 카테고리의 다른 글
Spring Boot JPA 네이티브 쿼리로 환경별 스키마 동적 변경하기 (0) | 2025.07.20 |
---|---|
pc ↔ 모바일 전환 시 일회용 토큰을 이용하여 로그인 기능 유지하기 (1) | 2025.07.06 |
Prometheus + Grafana instance 별칭 설정하기 (0) | 2025.06.09 |
Spring Boot Actuator, Grafana & Prometheus 모니터링 구축 및 연결 (0) | 2025.05.19 |
JpaSystemException: Could not extract column [6] from JDBC ResultSet [MONTH] 오류 해결 (1) | 2025.02.18 |