본문 바로가기

QueryDSL

[QueryDSL] 스프링 데이터 페이징와 Querydsl 페이징 연동

스프링 데이터 페이징 활용1 - Querydsl 페이징 연동

스프링 데이터의 Page, Pageable을 활용해보자.

  1. 전체 카운트를 한번에 조회하는 단순한 방법
  2. 데이터 내용과 전체 카운트를 별도로 조회하는 방법

사용자 정의 인터페이스에 페이징 2가지 추가

public interface MemberRepositoryCustom {
    List<MemberTeamDto> search(MemberSearchCondition condition);
    Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable);
    Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);

}

 

1. 전체 카운트를 한번에 조회하는 단순한 방법

/**
 * 단순한 페이징, fetchResults() 사용
 **/
@Override
public Page<MemberTeamDto> searchPageSimple(MemberSearchCondition condition, Pageable pageable) {
    QueryResults<MemberTeamDto> results = queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            // .orderby(member.username.asc()) total 쿼리시 orderBy는 제거해서 최적화
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetchResults();

    List<MemberTeamDto> content = results.getResults();
    long total = results.getTotal();
    return new PageImpl<>(content, pageable, total);
}
  • Querydsl이 제공하는 fetchResults() 를 사용하면 내용과 전체 카운트를 한번에 조회할 수 있다.(실제 쿼리는 2번 호출)
  • fetchResult() 는 카운트 쿼리 실행시 필요없는 order by 는 제거한다

2. 데이터 내용과 전체 카운트를 별도로 조회하는 방법

	/**
     * 복잡한 페이징
     * 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리 
     */
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .orderBy(member.username.asc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = queryFactory
                .selectFrom(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .fetchCount();
        return new PageImpl<>(content, pageable, total);
    }
  • 전체 카운트를 조회 하는 방법을 최적화 할 수 있으면 이렇게 분리하면 된다.
  • (예를 들어서 전체 카운트를 조회할 때 조인 쿼리를 줄일 수 있다면 상당한 효과가 있다.)
  • 코드를 리펙토링해서 내용 쿼리과 전체 카운트 쿼리를 읽기 좋게 분리하면 좋다.

스프링 데이터 페이징 활용2 - CountQuery 최적화

count 쿼리가 생략 가능한 경우 생략해서 처리 - 스프링 데이터 라이브러리가 제공

  1. 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
  2. 마지막 페이지 일 때 (offset + 컨텐츠 사이즈를 더해서 전체 사이즈 구함, 더 정확히는 마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때)
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
    List<MemberTeamDto> content = queryFactory
            .select(new QMemberTeamDto(
                    member.id,
                    member.username,
                    member.age,
                    team.id,
                    team.name))
            .from(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            )
            .orderBy(member.username.asc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    JPAQuery<Member> countQuery = queryFactory
            .selectFrom(member)
            .leftJoin(member.team, team)
            .where(
                    usernameEq(condition.getUsername()),
                    teamNameEq(condition.getTeamName()),
                    ageGoe(condition.getAgeGoe()),
                    ageLoe(condition.getAgeLoe())
            );
    
    return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchCount());
    // return new PageImpl<>(content, pageable, total);
}

스프링 데이터 정렬(Sort)

스프링 데이터 JPA는 자신의 정렬(Sort)Querydsl의 정렬(OrderSpecifier)로 편리하게 변경하는 기능을 제공한다.

 

스프링 데이터 SortQuerydslOrderSpecifier로 변환

JPAQuery<Member> query = queryFactory
         .selectFrom(member);
 for (Sort.Order o : pageable.getSort()) {
     PathBuilder pathBuilder = new PathBuilder(member.getType(),
 member.getMetadata());
     query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC : Order.DESC,
             pathBuilder.get(o.getProperty())));
  }
 List<Member> result = query.fetch();
참고: 정렬(Sort)은 조건이 조금만 복잡해져도 Pageable 의 Sort 기능을 사용하기 어렵다. 루트 엔티티 범위를 넘어가는 동적 정렬 기능이 필요하면 스프링 데이터 페이징이 제공하는 Sort`를 사용하기 보다는 파라미터 를 받아서 직접 처리하는 것을 권장한다.

fetchCount()와 fetchResults()는 QueryDSL 5.0 이상에서 deprecated(사용 중단 예정) 되었다.

 

🚨 fetchCount(), fetchResults()가 deprecated 된 이유

  1. 성능 문제
    • fetchCount()는 SELECT COUNT(*)와 동일한 쿼리를 실행하지만, 복잡한 서브쿼리나 조인을 포함할 경우 성능 저하가 발생할 수 있다.
    • fetchResults()는 두 개의 쿼리(COUNT 쿼리 + 데이터 조회 쿼리)를 실행하므로 불필요한 성능 부담이 있다.
  2. JPA와의 비호환성
    • fetchResults()는 LIMIT과 OFFSET을 사용하는데, 이는 JPA의 TypedQuery에서 제대로 동작하지 않는 경우가 있었다.

✅ 대체 방법

1. fetchCount() 대신 count() 사용

long count = queryFactory
    .select(member.count()) // 변경된 방식
    .from(member)
    .fetchOne();  // 단일 결과 반환

 

2. fetchResults() 대신 fetch()와 count()를 따로 사용

List<Member> members = queryFactory
    .selectFrom(member)
    .offset(0)
    .limit(10)
    .fetch(); // 데이터 리스트 조회

long totalCount = queryFactory
    .select(member.count())
    .from(member)
    .fetchOne(); // 전체 개수 조회
  • ➡️ fetchResults()가 자동으로 해주던 일을 직접 두 번의 쿼리로 나눠서 수행하면 된다.

페이징 처리 방법 (QueryDSL + Spring Data JPA)

1. Page를 반환하는 방식

@Override
public Page<Member> findAllWithPaging(Pageable pageable) {
    // 데이터 리스트 조회
    List<Member> members = queryFactory
        .selectFrom(member)
        .offset(pageable.getOffset()) // offset 설정
        .limit(pageable.getPageSize()) // limit 설정
        .fetch();

    // 전체 개수 조회
    long totalCount = queryFactory
        .select(member.count()) // count 쿼리
        .from(member)
        .fetchOne();

    return new PageImpl<>(members, pageable, totalCount);
}
  • 위 방식은 전체 개수 조회 쿼리(count query) + 데이터 조회 쿼리(fetch query) 두 개를 실행하는 방식이다.

📌 설명

  • offset(pageable.getOffset()) → 몇 번째부터 가져올지 지정
  • limit(pageable.getPageSize()) → 한 페이지에 가져올 개수 지정
  • fetch() → 조회한 결과 리스트 반환
  • count() → 전체 개수 조회
  • PageImpl<>(조회결과, 페이지정보, 전체 개수)를 사용해서 Spring Data JPA의 Page 객체로 변환

 

페이징 최적화 방법

위 방식은 항상 두 개의 쿼리(count + fetch) 를 실행하는데, 첫 페이지나 마지막 페이지에서는 전체 개수 쿼리를 생략하는 방법도 가능하다. 위 CountQuery 최적화에서 하는 방식과 동일하다.

 

Spring Data JPA의 PageableExecutionUtils 사용

/**
     * 복잡한 페이징
     * 데이터 조회 쿼리와, 전체 카운트 쿼리를 분리
     */
    @Override
    public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
        List<MemberTeamDto> content = queryFactory
                .select(new QMemberTeamDto(
                        member.id,
                        member.username,
                        member.age,
                        team.id,
                        team.name))
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                )
                .orderBy(member.username.asc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = queryFactory
                .select(member.count())
                .from(member)
                .leftJoin(member.team, team)
                .where(
                        usernameEq(condition.getUsername()),
                        teamNameEq(condition.getTeamName()),
                        ageGoe(condition.getAgeGoe()),
                        ageLoe(condition.getAgeLoe())
                );

        return PageableExecutionUtils.getPage(content, pageable, () -> countQuery.fetchOne());
        // return new PageImpl<>(content, pageable, total);
    }

 

📌 설명

  • PageableExecutionUtils.getPage()를 사용하면
    • 마지막 페이지에서 자동으로 count() 생략 가능
    • 데이터 개수가 페이지 크기보다 작을 경우 자동으로 count 쿼리를 실행하지 않음
  • countQuery::fetchOne → count() 쿼리를 필요할 때만 실행

페이징 성능 최적화가 필요하면 PageableExecutionUtils를 적극 활용하는 것이 좋다! 🚀