Spring/Spring Boot

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

민돌v 2021. 12. 23. 20:56
728x90

 

 

 

 

페이지네이션을 구현해보자!!

spring boot 의 JPA를 이용해서 구현을 할 예정이다

 

페이지네이션을 구하기 위해서는 요롷게 3가지를 생각해 주어야한다.

  1. view size : 한페이지에 보여줄 개수
  2. total page : 전체 페이지 개수
  3. page : 현재 페이지 

 

 

1. 첫번째 아이디어

단순, findAll()로 List로 불러와 list.sublist 로 잘라주었다.

//게시글 목록
    @GetMapping("/post-list")
    public List<PostResponseDto> getPostList(@RequestParam("page") int page){
        List<Post> resultList = postService.getPostList();
        List<PostResponseDto> postList = Arrays.asList(modelMapper.map(resultList,PostResponseDto[].class));

        postList = postList.subList(5*(page-1),5*page);
        System.out.println(postList.get(0).getTitle());
        Post best;
        if(resultList.size()>0) {
            best = resultList.stream().sorted(Comparator.comparing(Post::getView)).collect(Collectors.toList()).get(resultList.size() - 1);
            PostResponseDto best1 = modelMapper.map(best, PostResponseDto.class);
            postList.add(0,best1);
        }

        return postList;
    }

구현은 되었지만, 생각해보니 이러면, 속도랑 성능상 아무런 의미 없는 행위인 것을 깨달았따...

 


 

2. 페이징 쿼리문

쿼리문으로 잘라서 불러오면 되지않을까..?

찾아본 결과,

 

페이징 기법이란,

수많은 자료 데이터(행, 레코드)를 일정 크기로 나누어서, 나누어진 하나하나의 집단에 페이지 번호를 부여하는 방식으로,

정해진 갯수와 원하는 영역의 게시판 데이터를 불러오고 출력하여 '가독성'의 문제와 '자원 낭비' 문제점을 보완해주는 기법이라는 것을 알았다.

 

이에 의하면, 첫번째 방법은 완전히 의미없는 방식이었다는걸 의미하지,, 또륵

 

 

페이징 쿼리 방법, 

 

페이징 쿼리 방법으로는 크게 3가지가 존재하는 것 같다.

  1. ROWNUM(ORACLE, Mari DB),
  2. LIMIT(MySQL, Maria DB),
  3. TOP(MSSQL) 

각각의 방식은 해당 기능을 사용할 수 있는 DB가 정해져있다고 한다.

구글이나 유튜브에 페이징 쿼리문을 검색했을 때, Rownum 방식이 굉장히 많이 나오길래 의아했는데, Rownum 방식은 거의 모든 DB에서사용이 가능하다고 합니다.

 

그래서 많이 사용하는건감...???

나는 남들이 사용한다고 쓰고싶지않으니,, Mysql을 사용하고 있으므로, LIMIT 방식과 Rownum방식에 대해서 알아보자!

 

LIMIT

1. Mysql Limit, 사용법

mysql limit 은 몆개의 데이터를 가져올지를 의미한다고 한다.

select * from post limit 3;

이렇게 하면 0번째 인덱스 부터 3개를 가져온다

select * from 테이블 -&amp;amp;amp;amp;nbsp; 모든 데이터

limit 3

아래의 결과처럼 3개만 가져온다 신기하다..

select * from table limit 3

 

페이지네이션 기능을 적용하기 위해서는,

시작 위치와, page size를 알면 limit을 이용해 구현할 수 있을 것 같다.

select * from post limit 5, 3;

아래처럼!!


2. Mysql Limit Offset 함께 사용하기

offset이란 키워드를 이용하여 limit 페이징을 구현할수도 있습니다.

LIMIT과 OFFSET의 의미는 다음과 같습니다.

  • LIMIT: 행을 얼마나 가져올지

  • OFFSET: 어디서 부터 가져올지

SELECT * FROM 테이블명 ORDERS LIMIT 숫자 offset 페이지 넘버;

결과와 성능은 limit만 사용하였을 때랑 똑같습니다.

 

이런 방법처럼 Mysql 에서 LIMIT은 사용자가 정해준 위치(Offset)으로부터 불러올 데이터의 개수를 지정하여 가져오기 때문에 오프셋(offset) 페이징이라고도 한다고 합니다.

 


3. Mysql Limit 성능 개선

하지만 Limit의 경우, 아래의 예시 쿼리문이 존재할 때

  1. select * from table_name limit 0,100;
  2. select *from table_name limit 100000000000,100;

 

  • 1번같은 경우, 매우 빠르지만
  • 2번은, 100000000000번까지 조회 후, 100개를 가져오기 때문에 굉장히 느리다고 합니다. (내부 인덱스를 타지 않는다고도 말함)

 

  • 그렇다면 limit의 성능을 개선시키기위해서는, 흔히들 인덱스를 타야한다... 라고 말하는데,
  • 이 인덱스가 지금까지, 인덱스 테이블을 임의로 생성을 해야 사용할 수 있다고 생각했는데, 내부 인덱스?가 따로 있는가 봅니다.(인터넷 사람들이 말하는 너낌으로는)

 

 

그래서 직접 실험을 해봤습니다.

 

아래의 그림처럼 3개의 쿼리문을 실행해서 시간을 측정해 보았습니다.

인덱스는 따로 만들어주지 않았습니다.

3개의 쿼리문
결과

결과는, 실제로 3번 쿼리가 상당히 빠름을 보여주었습니다.

  • 1,2 번 쿼리는 full scan을 한 것 같고,
  • 3번 쿼리는 name의  지정한 컬럼으로 이동해 limit을 수행한 것으로 보입니다. (이게 인덱스를 탔다고 하는거겠죠?)
  • 3번 같은 경우를 이용하기 위해서는 정렬화된 idx값이 필요하겠지요..??

 

 

이러한 차이가 나는 이유는 3번 쿼리문이 커서 페이징 방식이기 때문입니다.

페이지네이션은 보통 2가지 방식으로 구현이 가능한데

 

1. 오프셋 기반 페이지네이션(Offset-based Pagination)

  • 페이지 단위로 구분

2. 커서 기반 페이지네이션(Cursor-based Pagination)

  • 현재 row 순서상의 다음 row들의 n개를 응답

 

 

3번 쿼리문이 커서 기반 페이지네이션이기 때문에 더 빠른 성능을 보여주는 것입니다.

1️⃣오프셋 기반 페이지네이션은 1번 row 부터 순서대로 조회하는 반면에,

2️⃣ 커서 기반 페이지네이션은, 현재 row 위치 부터 다음 n개를 조회하기 때문에 대용량 데이터에서 월등한 성능을 발휘한다고 합니다.

(row 위치를 먼저 찾기때문에 인덱스를 태운다고 하는 것 같군요)

 

결론은!! 간단하고 대용량이 아닐땐 오프셋으로!, 대용량 데이터를 가질 때 커서기반으로 구현하자!!

 

2 가지 방식에 대한 자세한 내용은 아래의 블로그를 참고!

https://velog.io/@minsangk/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Cursor-based-Pagination-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

조금 더 개선된 페이징 쿼리문을 사용할려면 아래의 블로그를 참조해보자

1) 커버링 페이징 : https://jojoldu.tistory.com/529

2) 인덱스과, join의 사용 : https://blog.lulab.net/database/optimize-pagination-sql-by-join-instead-of-limit/


ROW Number

Rownumber 방식은, 쿼리문의 추가로 구현한다.

Rownumber라는 임의의 row들의 수를 뜻하는 컬럼을 생성한 후, 이 Rownumber를 이용해 페이징을 구현하는 것이다.

 

예시)

select * from 
        (select @rownum := @rownum + 1 as rn, bno, title, writer, regdate, updatedate 
        from vam_board, (select @rownum := 0)  as rowcolumn order by bno desc) as rownum_table  
where rn > 10 and rn <=20;
-- where rn between 10 and 20;

출처 :&amp;amp;amp;nbsp;https://kimvampa.tistory.com/170

 

rownumber 는 서브쿼리문 등, 쿼리문을 이용하여 구현하는 방식이다.

 


 

3. JPA ORM 페이지네이션

사실은 JPA를 사용하기전에 쿼리문이 어떻게 작성되는지 알고 싶었다...ㅎ

 

Spring boot의 꽃은 JPA라고 생각한다,,, 그러니까 써야지!!

이번에는 JPA를 이용하여 페이지네이션을 구현하는 방법에 대해서 알아보자

 

 상속받아 사용하는 JpaRepository는 많은 부모 클래스들을 상속받는데, 그 중에

PagingAndSortingRepository 클래스가 페이징과 정렬 기능의 ORM을 제공합니다.

 

PagingAndSortingRepository  내부 클래스는 이렇게 구현이 되어있다.

 

Page 도메인과, Pageable

  • org.springframework.data.domain.Pageable
    • 페이징을 제공하는 중요한 인터페이스이다.
  • org.springframework.data.domain.Page
    • 페이징의 findAll() 의 기본적인 반환 메서드로 여러 반환 타입 중 하나이다.

뭐가뭔지 모르겠으니까 일단 사용해보자!

 

 

 Controller

//게시글 목록
    @GetMapping("/post-list")
    public Page<Post> getPostList(@RequestParam("page") int page){
        Page<Post> resultList = postService.getPostList(page, 5);

        return resultList;
    }

 

Service

//게시글 리스트
    public Page<Post> getPostList(int page, int size){
        Pageable pageable = PageRequest.of(page, size);
        Page<Post> postList = postrepository.findAll(pageable);

        return postList;
    }

Repository

레포지토리는  따로 추가하지 않았습니다.

단순 pagealbe 을 매개변수로 받는 findAll() 은 상속받은 메소드이기 때문에 그냥 사용하니까 작동이 됬습니다.

public interface PostRepository extends JpaRepository<Post,Long> {
    List<Post> findByUserId(Long userId);
    List<Post> findAllByUser(User user);
    List<Post> findAllByTitleContainingIgnoreCase(String keyword);
    List<Post> findAllByContentContainingIgnoreCase(String keyword);
}

 

결과

page 객체의 response가 어떻게 오는지 확인해 보니

content - 안에 domain이 담기고

pageable - 안에 페이징에 관련된 정보가 담겨왔다.

 

14개 데이터에 size = 5, page=2로 했더니 마지막 4개 데이터만 딸려왔다.

page는 0부터 시작하나 보다... 진짜 편리하다 ㄷㄷ

 

post db&amp;amp;nbsp;
page response result

 


Pageable sort (PageRequest)

 pageanable response를 봐도 sort 에 관한 정보가 담겨있는걸 볼 수있다.

 

Service를 보면, Pageable 인터페이스의 값을, PageRequest.of()를 이용해서 받아오는 걸 볼 수 있다.

PageRequest가 Pageable 인터페이스를 상속받기 때문인데, 

쉽게 Paging을 위한 정보를 넘길 수 있게, 정렬, 페이지 offset, page에 대한 정보를 담을 수 있다.

 

Service

//게시글 리스트
    public Page<Post> getPostList(int page, int size){
        Pageable pageable = PageRequest.of(page, size);
        Page<Post> postList = postrepository.findAll(pageable);

        return postList;
    }

 

PageRequest

 

 

PageRequest의 내부 구조를 조금 보면 

아래의 코드  처럼, 각각의 매소드가 정의되어있음을 확인할 수 있다.

한번 보고 자기한테 필요한걸 쓰자!

 

그럼이제 정렬도 한번 해보자!

이런식으로 정렬 객체를 넘겨주니까 정렬이 됬다. descending()은 역순을 의미한다( 내림차순?)

 

//게시글 리스트
    public Page<Post> getPostList(int page, int size){
        Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
        Page<Post> postList = postrepository.findAll(pageable);

        return postList;
    }

 

이외에 정렬 예시

Pageable sortedByName = 
    PageRequest.of(0, 3, Sort.by("name")); 
Pageable sortedByPriceDesc = 
    PageRequest.of(0, 3, Sort.by("price").descending()); 
Pageable sortedByPriceDescNameAsc =
    PageRequest.of(0, 5, Sort.by("price").descending().and(Sort.by("name")));

 


반환 타입에 따른 페이징 결과

Spring Data JPA 에는 반환 타입에 따라서 각기 다른 결과를 제공한다.

  1. Page<T> 타입
  2. Slice<T> 타입
  3. List<T> 타입

 

1) page<T> 타입

Page<T> 는 일반적인 게시판 형태의 페이징에서 사용된다.

https://wonit.tistory.com/483

 

2) Slice<T> 타입

Slice<T> 타입을 반환 타입으로 받게 된다면 더보기 형태의 페이징에서 사용된다

Slice<T> 타입은 추가 count 쿼리 없이 다음 페이지 확인 가능하다. 내부적으로 limit + 1 조회를 해서 totalCount 쿼리가 나가지 않아서 성능상 조금 이점을 볼 수도 있다.

https://wonit.tistory.com/483

3) List<T> 타입

List 반환 타입은 가장 기본적인 방법으로 count 쿼리 없이 결과만 반환한다.

 


JPA Paging 성능

마무리로 JPA Paging의 성능이 궁금했다.

하지만 아무리 찾아봐도 명확한 수치로된 결과값이 나오지 않았다.

 

JPA가 실행하는 Hibernate를 query로 확인해 보았다.

 

 

Spring boot에서 JPA 쿼리 파리미터를 확인하는 방법은,

application.properties 에 아래 2줄을 추가해주면 터미널에서 확인할 수 있다.

logging.level.org.hibernate.SQL=debug

spring.jpa.properties.hibernate.format_sql=true

Full Scan 방식의 Offset 페이지네이션 쿼리를 이용하는 JPA

JPA 페이징 쿼리문

 

 

JPA Paging은 매우매우 편리한 기능이지만, 

1) Page<T> 타입의 경우 모든 쿼리를 카운트해서 가져온다고 하기 때문에  Offset 페이지네이션에 가까운 것 같다.

2) Slice<T> 는, 카운트 쿼리를 별도로 날리지 않기때문에 성능상 아주 조금의 이점이 있을것같다.

 

 

JPA 는 ORM이기때문에 편리하다는 이점이 있는데, 성능상의 쿼리문이 나을때가 있다는 말을 오늘 페이지네이션을 공부하면서 조금은 이해가되는 것 같다.


그럼 끝!!

 

 

 

 

 

🔥🔥 무한스크롤에 커서 페이지네이션 ( = 노 오프셋 페이지네이션) 을 적용한 글은 여기!! 를 봐주세요 🔥🔥

 

[Spring] 무한스크롤 구현 및 성능 개선 하기 - No Offset 페이지네이션

안녕하세요 오늘은 페이지네이션의 성능을 개선 시키는 방법 중 하나인 no-offset 페이지네이션에 대해 기록을 남겨보고자 합니다! 예전에 스프링 페이지네이션에 대해 포시팅한적이 있는데 이번

thalals.tistory.com

 

 

 

 

 

 

 


*참고

 

반응형