티스토리 뷰

반응형

✏️ PageableExecutionUtils로 카운트 쿼리 최적화하기

스프링 환경에서 QueryDSL로 페이징을 구현할 때 일반적으로 다음과 같은 패턴을 사용한다.

  1. 데이터 조회 쿼리 1번
  2. 전체 개수를 세는 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 비용이 크므로 최적화 필요.

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
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
글 보관함