-
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 의 인터페이스를 구현한 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
https://vladmihalcea.com/sql-seek-keyset-pagination/
Thanks To ChatGPT!!
반응형'웹 개발' 카테고리의 다른 글
WebClient 와 ChatClient 의 관계 (1) 2024.12.19 JWT 토큰 암호화 방식 및 키 저장 방식 (0) 2024.06.26 자바 HashTable 과 HashMap (0) 2024.03.28 User-Agent 정보 가져오기 (0) 2024.03.20 정적 코드 분석 도구 Sonarqube 도입 제안 (2) 2024.02.24