Spring/Spring Boot

[Spring] 스프링 부트 JPA 페이징 성능 개선 - querydsl 페이지네이션(오프셋 페이징, 커서 페이징, querydsl 정렬)

민돌v 2022. 4. 10. 02:27
728x90

JPA N+1 문제를 해결했으니, 이제 페이지네이션 성능개선을 해볼려고 한다.

https://thalals.tistory.com/246

 

[Spring] 스프링 부트 페이지네이션 (Query, JPA, offset / cursor 페이지네이션)

페이지네이션을 구현해보자!! spring boot 의 JPA를 이용해서 구현을 할 예정이다 페이지네이션을 구하기 위해서는 요롷게 3가지를 생각해 주어야한다. view size : 한페이지에 보여줄 개수 total page :

thalals.tistory.com

 

이전에, 페이지네이션에 대해 공부해 보았을 떄

offset 과 cursor 2가지 방식의 페이지네이션 구현 방법이 존재하고

JPA 페이징 API는 오프셋 기반 방식이라는 것을 확인한 적이 있다.

 

오늘은, JPA 페이징 API로 간단하게 구현했던 페이지네이션을, QueryDSL을 이용한 방법으로 바꾸워서 구현하고,

오프셋 보다, 효율이 좋은 커서 기반 페이지네이션을 구현해 보고자 한다.

 

querydsl을 설정하는 방법은 이 글에선 다루지 않을거다..!


 

1. queryDSL로 Offset 기반 페이지네이션 구현하기

 

1) 인터페이스 정의하기

  • 먼저, 구현할 메소드의 인터페이스를 정의해줍니다.
public interface PostRepositoryCustom {
    Page<Post> findByCustom_offsetPaging(Pageable pageable);
    Page<Post> findByCustom_cursorPaging(Pageable pageable, String sorting, Long idx);
}

 

2) Impl 클래스 구현하기

정의된, 인터페이스를 기준으로 메소드를 구현해봅시다

public class PostRepositoryCustomImpl implements PostRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public PostRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    
    @Override
    public Page<Post> findByCustom_offsetPaging(Pageable pageable) {
       
    }
}

JPAQueryFactory

  • QueryDSL 을 사용해서 빌드하기 위해서는 JPAQueryFactory가 필요합니다.
  • JPAQueryFactory를 사용하면 EntityManager를 통해서 질의가 처리되고, JPQL을 생성해서 처리합니다.
  • 참고로, SQLQueryFactory라는 것을 사용하면,  JDBC를 이용하여 질의가 처리되고 SQL을 사용합니다.

 

Q-type class

  • Q-type 클래스는 QueryDSL 설정을 성공적으로 마치면 @Entity가 붙은 클래스를 찾아 자동으로 생성됩니다.
  • Q-type class들은 QueryDSL을 사용하여 메소드 기반으로 쿼리를 작성할 때 우리가 만든 도메인 클래스의 구조를 설명해주는 메타데이터 역할을 하며, 쿼리의 조건을 설정할 때 사용됩니다.

 

 

3) Offset 기반 페이지네이션 QueryDSL 작성

  • querydsl을 사용하면, fetch join할 엔티티들을 join을 불러온 후, 뒤에 fetchjoin()만 붙혀서 간단하게 처리해줄 수 있습니다.
  • querydsl이 제공하는 fetchResult()를 사용하면 내용과 전체 카운트를 한번에 조회할 수 있습니다.(실제 쿼리는 2번 호출)
  • 단, querydsl 정렬의 경우, Sort 객체가 적용이 안되기 때문에 정렬을 할 컬럼명을 명시해주어야 합니다.
  • 따라서, querydsl을 이용하여 동적 정렬을 하기 위해선, OrderSepecifier<> 로 정렬을 해주어야합니다.
public class PostRepositoryCustomImpl implements PostRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    public PostRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    
    @Override
    public Page<Post> findByCustom_offsetPaging(Pageable pageable) {
        QPost post = QPost.post;

        QueryResults<Post> results = queryFactory
                .select(post)
                .from(post)
                .join(post.user)
                .fetchJoin()
                .orderBy(PostSort(pageable))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<Post> content = results.getResults();
        long total = results.getTotal();

        return new PageImpl<>(content,pageable,total);
    }
}

[fetchResult 결과 - 실제 쿼리 2개 호출]

fetchresult 2개의 쿼리

 

4) queryDSL offset 기반 페이징 + order by 동적 쿼리

  • orderby에 들어갈, Sort() 메서드 입니다.
public class PostRepositoryCustomImpl implements PostRepositoryCustom {


    // offset 코드 생략

    /**
     * OrderSpecifier 를 쿼리로 반환하여 정렬조건을 맞춰준다.
     * 리스트 정렬
     * @param page
     * @return
     */
    private OrderSpecifier<?> PostSort(Pageable page) {
        QPost post = QPost.post;

        //서비스에서 보내준 Pageable 객체에 정렬조건 null 값 체크
        if (!page.getSort().isEmpty()) {
            //정렬값이 들어 있으면 for 사용하여 값을 가져온다
            for (Sort.Order order : page.getSort()) {
                // 서비스에서 넣어준 DESC or ASC 를 가져온다.
                Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
                // 서비스에서 넣어준 정렬 조건을 스위치 케이스 문을 활용하여 셋팅하여 준다.
                switch (order.getProperty()){
                    case "createdAt" :
                    case "descending":
                        return new OrderSpecifier(direction, post.createdAt);
                    case "countOfLikes":
                        return new OrderSpecifier(direction, post.countOfLikes);
                    case "view":
                        return new OrderSpecifier(direction, post.view);

                }
            }
        }
        return null;
    }

}

조건에 따른 queryDsl null 핸들링 정렬

private OrderSpecifier<?> goodsSort(final Pageable pageable){
    for (Sort.Order order : pageable.getSort()) {
        Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
        
        if(order.getProperty().equals("outSequence")){
            return new OrderSpecifier<>(direction, GOODS.outSequence, NullHandling.NullsLast);
        }
    }
    return new OrderSpecifier<>(Order.ASC, GOODS.id);
}

 

2. queryDSL 카운트 쿼리 분리하기

  • 위에서도 봤다시피, fetchResult()를 사용하면, 별도의 카운트쿼리가 호출되는 것을 볼 수있다.
  • 이 카운트쿼리는 자동으로 생성되는 것이기 때문에, 오직, 전체 total만 필요하다면, 다소 필요한 질의가 처리 될 수 도있다.
  • 따라서, 이 카운트 쿼리를 분리해 보기로 했다..!

fetchresult 2개의 쿼리

 

  • 이렇게 전체 카운트를 조회하는 방법을 최적하여 분리할 수 있다.
  • 결과값은 fetch()로 받아오고, 전체 카운트는, fetchcount()로 받아올 수 있다.
  • 이런식으로 코드를 분리하면, 전체 카운트를 할 때 불필요한 조인 쿼리를 줄일 수 있다.
@Override
public Page<Post> findByCustom_offsetPaging(Pageable pageable) {
    QPost post = QPost.post;

    List<Post> content = queryFactory
            .select(post)
            .from(post)
            .join(post.user)
            .fetchJoin()
            .orderBy(PostSort(pageable))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

    long total = queryFactory
            .select(post)
            .from(post)
            .fetchCount();

    return new PageImpl<>(content,pageable,total);
}

 

contents를 먼저 조회하고 난 후, 날라가는 카운트쿼리, 상당히 짧아졌, 불필요한 조인을 분리했다.

 

 


 

3. QueryDSL 커서 페이징 구현하기

 

  • 전 포스팅에서 알아본, index값을 조건절로 넘겨주어, 전체 스캔을 하지않고
  • 앞에 부분을 스킵하는 페이지네이션 방식을 커서 페이징 방식이라고 한다.
  • 오프셋 기반에 비해서 커서페이징의 성능이 상당히 좋다..!
  • 물론 무조건 그런것은 아니고, 만약 페이징 조건이 2개 이상의 쿼리 질의문이 필요하다던가 하면, 초반에는 오프셋 기반이 더좋다
  • 이런식으로 각각의 상황에 따라 효율이 달라지지만, 나는 공부 차원에서 둘다 해보기로했다..!

 

커서 페이징의 핵심은, 조건절에, index값을 넘겨준다는 것이다.

@Override
public Page<Post> findByCustom_cursorPaging(Pageable pageable, String sorting, Long cursorIdx) {
    QPost post = QPost.post;

    List<Post> content = queryFactory
            .select(post)
            .from(post)
            .join(post.user)
            .fetchJoin()
            .orderBy(PostSort(pageable))
            .where(cursorId(sorting,cursorIdx))
            .limit(pageable.getPageSize())
            .fetch();

    //카운트 쿼리 따로
    long total = queryFactory
            .select(post.idx)
            .from(post)
            .join(post.user)
            .fetchCount();

    return new PageImpl<>(content,pageable,total);
}

 

  • querydsl에서, 동적 조건으로 where절을 사용하기 위해서 cursorId라는 메소드를 정의해주었다.
  • BooleanExpression을 이용해서 querydsl 조건절을 동적 쿼리로 사용할 수 있다.
  • 맨 첫, 페이지의 경우, cursorId가 날라가지 않으므로, null로 처리해주고,  예외처리를 해주었다.

 

나는 정렬기능 또한, 구현하고 있으므로, 정렬 기준또한 넘겨주어, 조건절을 처리하도록 하였다.

/**
 * 커서페이징 조건절
 * @param sorting
 * @param cursorId
 * @return
 */
private BooleanExpression cursorId(String sorting, Long cursorId) {
    QPost post = QPost.post;
    
    // id < 파라미터를 첫 페이지에선 사용하지 않기 위한 동적 쿼리
    if (cursorId==null) {
        return null; // // BooleanExpression 자리에 null 이 반환되면 조건문에서 자동으로 제거
    }
    else if(sorting.equals("createdAt"))
        return post.idx.lt(cursorId);
    else if(sorting.equals("view"))
        return post.view.lt(cursorId);
    else if(sorting.equals("countOfLikes"))
        return post.countOfLikes.lt(cursorId);
    else
        return post.idx.lt(cursorId);   //최신순
}

 

커서 페이징의 경우, 다음과 같이 조건절에서 offset과 차이가 발생함을 알 수 있다.

커서페이징 결과값,,!

 


 

4. 커서페이징 한계

사실 본래의 목표는, 커서페이징의 적용이었다.

하지만, 커서페이징의 특징 상, 넘겨주어야하는 index값이 정렬되어있어야하고, 중복되는 값이 있을경우도 생각을 해주어야했다.

또, 넘겨주는 값이 유일해야해서 이미 엔티티가 설계가 된 내 프로젝트에 적용하기가 맞지 않았다..

커서페이징은 무한 스크롤 방식에서 굉장히 유용하다.

내 프로젝트는, 페이징 방식의 게시판 형식이기 때문에, 뒤로갈때나, 2페이지 이상 넘어갈때 등을 고려하면, 커서페이징은 맞지 않다고 여겨진다..ㅠㅠ

 

 

 


*참고

orderby 동적쿼리 : https://itmoon.tistory.com/73

querydsl 입문 : https://velog.io/@mu1616/Querydsl-%EC%9E%85%EB%AC%B8

김연한 jpa 강의 1,2편

반응형