티스토리 뷰
✏️ PageableExecutionUtils로 카운트 쿼리 최적화하기
스프링 환경에서 QueryDSL로 페이징을 구현할 때 일반적으로 다음과 같은 패턴을 사용한다.
- 데이터 조회 쿼리 1번
- 전체 개수를 세는 count 쿼리 1번
즉, 페이지 하나를 조회할 때 쿼리가 두 번 실행된다.
데이터가 많거나 조인이 많은 경우 성능에 큰 영향을 미친다.
이번 글에서는 기존 방식의 문제점과 이를 개선하기 위한
PageableExecutionUtils 활용법을 설명한다.
✏️ 1. 기존 QueryDSL 페이징 방식의 문제점
아래는 일반적인 QueryDSL 페이징 코드이며 이 방식의 가장 큰 단점은 2가지가 있다.
package com.roome.roome.be.domain.inquiry.repository;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.roome.roome.be.domain.admin.dto.request.AdminInquirySearchCondition;
import com.roome.roome.be.domain.inquiry.dto.response.AdminInquiryDetailResponse;
import com.roome.roome.be.domain.inquiry.enums.InquiryStatus;
import com.roome.roome.be.domain.inquiry.enums.InquiryType;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.List;
import static com.roome.roome.be.domain.inquiry.entity.QInquiry.inquiry;
import static com.roome.roome.be.domain.inquiry.entity.QInquiryAnswer.inquiryAnswer;
import static com.roome.roome.be.domain.user.entity.QUser.user;
@RequiredArgsConstructor
public class InquiryCustomRepositoryImpl implements InquiryCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<AdminInquiryDetailResponse> findInquiryList(AdminInquirySearchCondition condition, Pageable pageable) {
List<AdminInquiryDetailResponse> content = jpaQueryFactory
.select(Projections.constructor(
AdminInquiryDetailResponse.class,
inquiry.id,
inquiry.type,
inquiry.status,
inquiry.user.id,
inquiry.user.nickname,
inquiry.content,
inquiry.createdAt,
inquiryAnswer.content,
inquiryAnswer.admin.id,
inquiryAnswer.admin.nickname,
inquiryAnswer.createdAt
))
.from(inquiry)
.join(inquiry.user, user)
.leftJoin(inquiry.answer, inquiryAnswer)
.where(
containKeyword(condition.keyword()),
statusEq(condition.status()),
typeEq(condition.type())
)
.orderBy(inquiry.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long count = jpaQueryFactory
.select(inquiry.count())
.from(inquiry)
.where(
containKeyword(condition.keyword()),
statusEq(condition.status()),
typeEq(condition.type())
)
.fetchOne();
return new PageImpl<>(content, pageable, count);
}
private BooleanExpression containKeyword(String keyword) {
return keyword != null ? inquiry.content.contains(keyword) : null;
}
private BooleanExpression statusEq(InquiryStatus status) {
return status != null ? inquiry.status.eq(status) : null;
}
private BooleanExpression typeEq(InquiryType type) {
return type != null ? inquiry.type.eq(type) : null;
}
}
1) 쿼리가 무조건 2번 실행됨
content 쿼리 + count 쿼리 = 총 2번
예를 들어 조인이 많은 문의 조회라면
- 유저 테이블
- 답변 테이블
- 관리자 테이블
등을 모두 조인하기 때문에 count()를 구하는 쿼리 부담도 커진다.
2) 마지막 페이지에서도 count 쿼리가 불필요하게 실행됨
예를 들어 페이지네이션에서 마지막 페이지는
이미 content size로 "전체 데이터 수가 부족함"을 알 수 있다.
하지만 기존 방식에서는 무조건 count() 쿼리를 날린다.
성능적으로 낭비가 발생한다.
✏️ 2. PageableExecutionUtils를 활용한 최적화
Spring Data에서 제공하는 PageableExecutionUtils는
count 쿼리를 필요할 때만 실행하도록 도와준다.
동작 방식
- content size가 페이지 사이즈보다 작으면 → count 쿼리 실행 안 함
- 이미 total count를 추정할 수 있는 경우 → count 쿼리 생략
- 필요할 때만 countQuery 실행
즉, 전체 카운트 쿼리를 조건적으로 수행하여 성능을 올린다.
✏️ 3. 개선된 코드: PageableExecutionUtils 적용
package com.roome.roome.be.domain.inquiry.repository;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.roome.roome.be.domain.admin.dto.request.AdminInquirySearchCondition;
import com.roome.roome.be.domain.inquiry.dto.response.AdminInquiryResponse;
import com.roome.roome.be.domain.inquiry.enums.InquiryStatus;
import com.roome.roome.be.domain.inquiry.enums.InquiryType;
import com.roome.roome.be.domain.user.dto.request.UserInquirySearchCondition;
import com.roome.roome.be.domain.user.dto.response.UserInquiryResponse;
import com.roome.roome.be.domain.user.entity.QUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.stereotype.Repository;
import java.util.List;
import static com.roome.roome.be.domain.inquiry.entity.QInquiry.inquiry;
import static com.roome.roome.be.domain.inquiry.entity.QInquiryAnswer.inquiryAnswer;
import static com.roome.roome.be.domain.user.entity.QUser.user;
@Repository
@RequiredArgsConstructor
public class InquiryCustomRepositoryImpl implements InquiryCustomRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<AdminInquiryResponse> findAdminInquiryList(AdminInquirySearchCondition condition, Pageable pageable) {
QUser admin = new QUser("admin");
List<AdminInquiryResponse> content = jpaQueryFactory
.select(Projections.constructor(
AdminInquiryResponse.class,
inquiry.id,
inquiry.type,
inquiry.status,
inquiry.user.id,
inquiry.user.nickname,
inquiry.content,
inquiry.createdAt,
inquiryAnswer.content,
inquiryAnswer.admin.id,
inquiryAnswer.admin.nickname,
inquiryAnswer.createdAt
))
.from(inquiry)
.join(inquiry.user, user)
.leftJoin(inquiry.answer, inquiryAnswer)
.leftJoin(inquiryAnswer.admin,admin)
.where(
containKeyword(condition.keyword()),
statusEq(condition.status()),
typeEq(condition.type())
)
.orderBy(inquiry.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = jpaQueryFactory
.select(inquiry.count())
.from(inquiry)
.where(
containKeyword(condition.keyword()),
statusEq(condition.status()),
typeEq(condition.type())
);
return PageableExecutionUtils.getPage(content,pageable, countQuery::fetchOne);
}
private BooleanExpression containKeyword(String keyword) {
if (keyword == null || keyword.isBlank()) {
return null;
}
return inquiry.content.containsIgnoreCase(keyword);
}
private BooleanExpression statusEq(InquiryStatus status) {
return status != null ? inquiry.status.eq(status) : null;
}
private BooleanExpression typeEq(InquiryType type) {
return type != null ? inquiry.type.eq(type) : null;
}
}
✏️ 4. PageableExecutionUtils 적용 시 동작 요약

✏️ 5. BooleanExpression 팁 (조건문 최적화)
아래처럼 null을 반환하는 방식은 QueryDSL에서 자동으로 무시된다. -> AND / OR 조건을 동적으로 붙일 때 가장 깔끔
private BooleanExpression containKeyword(String keyword) {
if (keyword == null || keyword.isBlank()) {
return null;
}
return inquiry.content.containsIgnoreCase(keyword);
}
✏️ 6. 성능 비교
Before (기존 방식)
- 항상 쿼리 2번
- count 쿼리도 join이 걸려 있어 비싸다
- 큰 데이터 테이블에서는 성능 저하
After (PageableExecutionUtils)
- 필요할 때만 count 쿼리
- content 결과로 판단 가능한 경우 count 생략
- 조인 비용의 절약 → DB 부하 감소
✏️ 7. 실제 서비스에서 유용한 이유
✔️ 동적 검색 조건과 함께 쓰기 좋음
검색 조건이 많을수록 count 쿼리가 복잡해지기 때문에
조건적 실행이 큰 이점이 된다.
✔️ 관리자 페이지나 대시보드에 적합
관리자가 조회하는 리스트들은 보통 필터링이 많고
응답 속도가 빠를수록 UX가 좋아진다.
✔️ 조인 테이블이 많을 때 효과 극대화
e.g. 문의 → 유저 → 답변 → 관리자
처럼 여러 테이블을 순회할 때 count 비용이 크므로 최적화 필요.
'Back-End' 카테고리의 다른 글
| [Spring/스프링] Spring AI 연동하기(Spring Boot + OpenAI) (0) | 2025.12.29 |
|---|---|
| [Spring/스프링] - Duplicate identification variable user error (0) | 2025.11.21 |
| [UMC/시니어 미션#4] (0) | 2025.09.27 |
| [UMC/시니어 미션#3] (1) | 2025.09.20 |
| [UMC/시니어 미션 #2] (3) | 2025.09.18 |
- Total
- Today
- Yesterday
- 자바스크립트
- DP
- DFS
- 카운팅 정렬
- 알고리즘
- 세그먼트 트리
- java
- 반복문
- CSS
- HTML5
- 이분 매칭
- 투 포인터
- 스프링 부트 crud 게시판 구현
- 백준 풀이
- 자료구조
- c++ string
- Do it!
- C++ Stack
- C++
- js
- 자바
- 스택
- 우선순위 큐
- 유니온 파인드
- 백준
- 알고리즘 공부
- html
- 에라토스테네스의 체
- BFS
- 유클리드 호제법
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
