ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Pageable 을 파헤치자.
    웹 개발 2024. 4. 29. 19:07
    반응형

    1. 스프링 페이징의 기본 개념

    • 페이징 처리의 원리, 페이징 처리의 과정
    public class PagingVO {
    	         // 현재페이지, 시작페이지, 끝페이지, 
    	private int nowPage, startPage, endPage, 
    	         // 게시글 총 갯수, 페이지당 글 갯수, 마지막페이지, 
    	            total, cntPerPage, lastPage, 
    	         // SQL쿼리에 쓸 start, end
    	            start, end;
    	private int cntPage = 5;
    	public PagingVO() {
    	}
    	public PagingVO(int total, int nowPage, int cntPerPage) {
    		setNowPage(nowPage);
    		setCntPerPage(cntPerPage);
    		setTotal(total);
    		calcLastPage(getTotal(), getCntPerPage());
    		calcStartEndPage(getNowPage(), cntPage);
    		calcStartEnd(getNowPage(), getCntPerPage());
    	}
    	// 제일 마지막 페이지 계산
    	public void calcLastPage(int total, int cntPerPage) {
    		setLastPage((int) Math.ceil((double)total / (double)cntPerPage));
    	}
    	// 시작, 끝 페이지 계산
    	public void calcStartEndPage(int nowPage, int cntPage) {
    		setEndPage(((int)Math.ceil((double)nowPage / (double)cntPage)) * cntPage);
    		if (getLastPage() < getEndPage()) {
    			setEndPage(getLastPage());
    		}
    		setStartPage(getEndPage() - cntPage + 1);
    		if (getStartPage() < 1) {
    			setStartPage(1);
    		}
    	}
    	// DB 쿼리에서 사용할 start, end값 계산
    	public void calcStartEnd(int nowPage, int cntPerPage) {
    		setEnd(nowPage * cntPerPage);
    		setStart(getEnd() - cntPerPage + 1);
    	}
        ... 
    }
    
    
    ===============================
    
    
    <select id="countBoard" resultType="int">
    	SELECT COUNT(*) FROM BOARD
    </select>
    <select id="selectBoard" resultType="com.my.spring.domain.BoardVO">
    	SELECT * 
    		FROM (
    			SELECT ROWNUM RN, A.* 
    				FROM (
    						SELECT * 
    						FROM BOARD 
    						ORDER BY SEQ DESC 
    						) A
    				)
    	WHERE RN BETWEEN #{start} AND #{end}
    </select>

    위의 코드가 예전 제가 초기 스프링 사용 시에 페이지를 구현해야할 때 구현해야했던 내용입니다. (실제 제가 사용한 코드는 아닙니당..)

    현재페이지, 시작페이지, 끝페이지, 게시글 총 갯수, 페이지당 글 갯수, 등등의 내용을 가지고 계산식을 사용해 쿼리 를 직접 날려야했던 내용입니다..

    신경써야할게 많았습니다.. ㅜㅜ

     

    2. Pageable 과 @PageableQueryParam

    • Pagebale

    Pageable 내용

     

    • Pageable 의 인터페이스를 구현한 PageRequest 가 있고, PageRequest 를 통해 Pageable 을 구현할 수 있다.

    AbstractPageRequest 는 Pageable 을 implements 하고 있고, PageRequest 가 AbstractPageRequest 를 상속받고 있습니다.

    • 이를 통해 알 수 있듯이 페이징을 간단하게 구현해보면
    ...
    @GetMapping
    @Operation(summary = "리스트 조회")
    public BoardResponse.ResultPage findBoards(
              @ParameterObject BoardRequest.Search search,
              @RequestParam(defaultValue = "0") int page, 
              @RequestParam(defaultValue = "10") int size,
              @RequestParam(defaultValue = "id,asc") String sort) 
              {
                Pageable pagebale = PageRequest.of(page, size, Sort.by(sort.split(",")))
                return boardService.findBoards(search, pageable);
              }
    ...

    이렇게 page 와 페이지 당 게시글 갯수인 size 를 받아서 PageRequest.of 를 사용해 Pageable 을 구현하고 전달할 수 있습니다. 

    아주 간단!!

     

    • 우린 @PageableQueryParam 을 쓰고 있는데 뭘까..
    ...
    @GetMapping
    @PageableAsQueryParam
    @Operation(summary = "리스트 조회")
    public BoardResponse.ResultPage finBoards(@ParameterObject BoardRequest.Search search,
                                           @Parameter(hidden = true) Pageable pageable) {
        return boardService.findBoards(search, pageable);
    }
    ...

    현재 우리는 Page 의 각 요소를 파라미터로 받지 않고, Pageable 을 바로 파라미터로 받으면서, @PageableAsQueryParam 을 사용하고 있다. 해당 어노테이션의 위치는 명시적으로 하기 위해 컨트롤러 위에 올려놓았다... ㅎ

    @PageableAsQueryParam 을 보면 실제 파라미터로 필요했떤, page 와, szie, sort 를 가지고 있다는 것을 볼 수 있다.

    그런걸로 봐선 내부적으로 무언가 해당 파라미터를 받아서 Pageable 객체를 만들어 주는 것 같다는 생각이 든다..

     

    어떻게 그럴까..보면

    궁금증1, @PageableAsQueryParam 을 사용하는데 어떻게 Pageable 인터페이스 객체로 요청을 바로 받을 수 있을까?…

    답은 HandlerMethodArgumentResolver 이다.

    HandlerMethodArgumentReslover 는 컨트롤러 메서드의 매개변수를 해석하고 값을 제공하는 인터페이스이다.

    (@Requestbody 설명에서도 등장했던.. ㅎㅎ 궁금하면. https://shs2810.tistory.com/77)

    HandlerMethodArgumentResolver 를 상속받고 있는 PageableArgumentResolver 가 있고, 이를 implements 되는 PageableHandlerMethodArgumentResolver 에 구현되어있는 메소드인 resolverArgument 를 보면..

    page 관련 파라미터가 들어오게 되면, 해당 resolver 가 PageRequest.of 를 구현해서 return 해주는 것을 볼 수 있다.

    그래서 우린 @PageableAsQueryParam 으로 간단하게 쓸 수 있다.

     

     

    3. Pageable 를 이용한 페이징 구현과 원리

    • JPA 사용시 JpaRepository 를 사용하게 되고, JpaRepository 메소드를 이용할 때 Pageable 을 전달하면 된다.
    • JPA 는 PagingAndSortingRepository 를 상속받고 있고,

    ...
    Page<Board> boards = boardRepository.findAll(pageable);
    List<Board> content = boards.getContent()
    ...

    의 코드로 간단하게 구현할 수 있다.

    • SimpleJpaRepository 란
      JpaRepository 는 JPA Repository 를 정의하기 위한 인터페이스
      JpaRepositoryImplementation 은 JpaRepository의 구현을 나타내기 위한 인터페이스
      SimpleJpaRepository 는 JpaRepositoryimplementation 을 구현한 클래스이다.
      SimpleJpaRepository 가 실제로 데이터베이스에 접근되고 실제로 사용되는 클래스 중 하나이다.

     

    • SimpleJpaRepository 에서 findAll의 내용은 아래와 같다.
    ...
    @Repository
    @Transactional(
        readOnly = true
    )
    public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
      ...
        public Page<T> findAll(Pageable pageable) {
            return (Page)(isUnpaged(pageable) ? 
                      new PageImpl(this.findAll()) : this.findAll((Specification)null, pageable));
        }
        public Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable) {
            TypedQuery<T> query = this.getQuery(spec, pageable);
            return (Page)(isUnpaged(pageable) ? new PageImpl(query.getResultList()) : this.readPage(query, this.getDomainClass(), pageable, spec));
        }
      ...
    }

    PageImpl 은 Page 인터페이스를 구현한 클래스이고, 페이징 되는 데이터, 페이지 번호, 페이지 당 데이터 갯수, 총 항목 수 등의 페이지 메타 데이터를 제공한다.

    Specification 은 검색 조건을 나타내고, null이 전달된 것을 확인할 수 있고, TypedQuery 객체는 실제 데이터베이스 쿼리를 나타낸다.

    isUnpaged 로 페이징의 여부를 확인한 다음에 페이징되어있지 않다면 단순한 결과 리스트를 가져와서 PageImpl 객체로 반환하고, 페이징이 되어있다면 readPage 메서드를 호출해서 데이터베이스에서 페이징된 결과를 가져온다.

    ...
    @Repository
    @Transactional(
        readOnly = true
    )
    public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
      ...
        protected <S extends T> Page<S> readPage(TypedQuery<S> query, final Class<S> domainClass, Pageable pageable, @Nullable Specification<S> spec) {
            if (pageable.isPaged()) {
                query.setFirstResult((int)pageable.getOffset());
                query.setMaxResults(pageable.getPageSize());
            }
            return PageableExecutionUtils.getPage(query.getResultList(), pageable, () -> {
                return executeCountQuery(this.getCountQuery(spec, domainClass));
            });
        }
      ...
    }

    이어서 readPage 메소드를 살펴보면, pageable.isPaged() 를 사용해서 페이지가 있는지 확인하고, 있으면 TypedQuery 에 페이지 설정을 한다.

    query.setFirstResult((int)pageable.getOffset()); 로 시작 인덱스를 설정하고, query.setMaxResults(pageable.getPageSize()); 로 최대 결과 수를 설정한다.

    executeCountQuery 메서드를 사용하여 전체 항목 수를 가져오고, getCountQuery를 호출하여 전체 항목 수를 세는 쿼리를 실행한 후, 결과를 반환합니다.

    PageableExecutionUtils 는 아래와 같다.

    public abstract class PageableExecutionUtils {
        private PageableExecutionUtils() {
        }
        public static <T> Page<T> getPage(List<T> content, Pageable pageable, LongSupplier totalSupplier) {
            Assert.notNull(content, "Content must not be null!");
            Assert.notNull(pageable, "Pageable must not be null!");
            Assert.notNull(totalSupplier, "TotalSupplier must not be null!");
            if (!pageable.isUnpaged() && pageable.getOffset() != 0L) {
                return content.size() != 0 && pageable.getPageSize() > content.size() ? new PageImpl(content, pageable, pageable.getOffset() + (long)content.size()) : new PageImpl(content, pageable, totalSupplier.getAsLong());
            } else {
                return !pageable.isUnpaged() && pageable.getPageSize() <= content.size() ? new PageImpl(content, pageable, totalSupplier.getAsLong()) : new PageImpl(content, pageable, (long)content.size());
            }
        }
    }

    getPage 마찬가지로 PageImple 로 구현하여 return 해주는 것을 볼 수 있다.

     

    4.  우리가 사용하고 있는 Querydsl5RepositorySupport

    • QuerydslPredicateExecutor, QuerydslRepositorySupport 등이 스프링 데이터 JPA 에서 제공하는 라이브러리이다.
      해당 라이브러리의 한계가 있다.
    • QuerydslPredicateExecutor 는 조인이 안되거나, 조건을 커스텀하는 기능이 복잡하고 명시적이지 않다.
    • QuerydslRepositorySupport 는 Querydsl 3.x 버전을 대상으로 만들어졌고, select 로 시작할 수 없으며, 정렬이 정상적이지 않다.
    • 그래서 스프링 데이터 JPA 에서 제공하는 인터페이스를 사용하지 않거나, 현재 Querydsl5RepositorySupport 를 만들어서 사용하고 있다.

    위의 내용은 갓영한님께서 알려주신 내용이고 Querydsl5RepositorySupport 를 우리도 만들어서 사용하고 있다.

    ... 
    @Repository
    public abstract class Querydsl5RepositorySupport {
      ...
      	protected <T> Page<T> applyPagination(Pageable pageable,
    										  Function<JPAQueryFactory, JPAQuery> contentQuery, 
    										  Function<JPAQueryFactory, JPAQuery> countQuery) {
    		JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
    		List<T> content = getQuerydsl().applyPagination(pageable,
    				jpaContentQuery).fetch();
    		JPAQuery<Long> countResult = countQuery.apply(getQueryFactory());
    		return PageableExecutionUtils.getPage(content, pageable, countResult::fetchOne);
    	}
    }
    ...

    Querydsl5RepoistorySupport 내용 중 applyPagination 함수에서 PageableExecutionUtils.getPage 를 사용해서 페이징 처리 할 수 있는 걸 볼 수 있다.

     

    5. JPA 페이징 사용 시 우리가 고려해야할 점은 무엇이 있을까.

    1. Page 객체의 count 쿼리의 발생

    반환하는 Page 객체에서 전체 갯수를 받아서 반환하는 로직이 있는데, 이는 countQuery 를 한번 발생시킨다.

    사실 페이징 처리 후에 반환하는 로직에서 전체 갯수는 필요없을 수 있다. 그래서 전체 갯수를 반환해야하는게 아니면 사용하지 않고, Slice 클래스를 사용해도 무방하다. (무한 스크롤 등..)

     

    Page 대신 Slice 클래스를 사용하여 대략적으로 아래와 같이 구현할 수 있을 것 같다.

    ...
        @Override
        public Slice<BoardResponse.Result> findSliceAllByConditionAndPageable(BoardRequest.Search search, Pageable pageable) {
            final List<BoardResponse.Result> boards = selectBoard()
                    .from(board)
                    .where(searchCondition(search))
                    .offset(pageable.getOffset())
                    .orderBy(OrderSpecifierUtil.getSortedColumn(pageable.getSort(), Board.class, member))
                    .limit(pageable.getPageSize())
                    .fetch();
            final boolean hasNext = boards.size() >= pageable.getPageSize();
            return new SliceImpl<>(boards, pageable, hasNext);
        } 
        ...

     

    그래서 정리하면

    - Count 쿼리가 Content 조회 쿼리보다 시간이 더 걸린다. 조인이 들어가면 더 심해질 수 있기 때문에 이는 성능의 부담을 줄 수 있다.   
     >> AbstarctJPAQuery 에서의 fecthCount 가 Deprecated 된 이유

    - Total Count 가 반드시 필요하지 않은 경우에는 Slice 가 효율적일 수 있다.

    - 혹은 Total Count 가 필요한 경우에는 Count 쿼리의 최적화를 하여 개선해보자.  
     >> 병렬 처리 및 쿼리 수정 등

     

    2. offset 의 한계

    • offset 은 데이터를 다 읽어와서 필요한 부분만 잘라내는 방식이다.
    • 만약 offset 의 값이 10,000,000 이고, limit 이 10이라면, 10,000,10 개의 row 를 읽어와서 10,000,00 개를 잘라내고 10,000,01 번 데이터부터 10,000,10 까지의 데이터를 결과로 반환해준다.
    • 또한 데이터가 많아서 잘라내는 과정에서 데이터가 추가되면 중복된 데이터가 조회될 수 있다.

    데이터가 많아질 때 성능상의 문제가 생길 수 있어 offset 을 사용하지 않고 쿼리를 만들어 볼 수 있다.
    > 이는 특정페이지로 이동하지 않는 경우에 사용해볼 수 있다.(ex. 무한 스크롤)

    select * 
    from board 
    where board_status='ACTIVATE' 
    order by create_date_time desc 
    limit 20 offset 100
    ////
    select *
    from board
    where board_status = 'ACTIVATE'
    where board.id < {마지막 조회 id}
    ordery by id desc
    limit 20

    pk 값을 기준으로 조회하기 때문에 빠르고, 이전 데이터를 읽을 필요가 없어 건너뛸 수 있다. 또한 데이터가 추가되더라도 영향없이 중복 조회될 일이 없다.

    하지만, 최근에 조회한 row 이후의 데이터들만 가져오기 때문에, 임의의 특정 페이지로 바로 이동이 불가하다.

     

     

    6. 마무리

    페이징에 대해 잘 알 수 있고, 언급한 우려되면 좋은 사항에 대해서는 만약 임의의 페이지 이동이 필요한 경우가 아니라면 pagination 구현 시 offset을 사용하지 말고 keyset pagination을 사용하자. 이 점이 offset을 사용함으로써 발생하는 여러 문제를 해결할 수 있고 성능 또한 더 우수해 보일 수 있다.

    다만, 지금의 규모에서는 문제가 크게 없고, 오히려 지금 방식이 더 편하기까지하다. 문제가 생긴다면 커버링 인덱스를 이용하고, 그 이후에 no offset을 이용해도 될 것 같다.

     

     

    * 참고

    https://www.slideshare.net/MarkusWinand/p2d2-pagination-done-the-postgresql-way

     

    Pagination Done the Right Way

    Pagination Done the Right Way - Download as a PDF or view online for free

    www.slideshare.net

    https://vladmihalcea.com/sql-seek-keyset-pagination/

     

    SQL Seek Method or Keyset Pagination - Vlad Mihalcea

    Learn what the SQL Seek Method or Keyset Pagination is and why you should consider it when navigating over large results sets.

    vladmihalcea.com

    https://binux.tistory.com/148

     

    Pagination(Paging), offset을 사용하지 맙시다

    들어가기 전에 이 글은 use-the-index-luke 사이트의 no-offset 글을 번역한 글입니다. 원 글이 좀 딱딱한 것 같아서 이해하기 쉽게 번역해보았습니다. 참고부탁드립니다. 왜 offset을 사용하면 안돼? SQL로

    binux.tistory.com

    Thanks To ChatGPT!!

    반응형
Designed by Tistory.