페이징 쿼리 Offset, Cursor 방식 - f-lab-edu/jshop GitHub Wiki

대부분 웹서비스에서 데이터를 한번에 보여줄 수 없는경우 페이징을 사용한다.

스크린샷 2024-06-18 15 11 21

덕분에 수천건, 수만건의 데이터라도 페이지단위로 쉽게 조회할 수 있다.

DB에 이런 페이징 쿼리를 날리는 기법에는 크게 두가지가 있다.

  1. 오프셋 방식
  2. 커서 방식

두 페이지 방식의 차이

오프셋 방식

오프셋 방식은 정렬된 전체 데이터에서 오프셋을 주고 일정한 개수만큼을 제공하는 방식이다.

id가 1 ~ 100 까지 내림차순으로 정렬된 100 개의 데이터가 있을때

오프셋 50, 크기 10 으로 설정한다면 [51 ~ 60] 번째 데이터(id : 41 ~ 50)를 얻을 수 있다.

하지만 오프셋은 페이지번호와 다른 개념이다. 위의 예시에서 오프셋 50, 크기 10 일때 실제 페이지 번호는 6 이다.

그리고 웹서비스는 데이터를 표현할때 페이지번호를 사용하지 오프셋으로 표현하지 않는다.

즉 서버단에서 사용자의 요청으로 넘어온 페이지번호와 페이지크기를 사용해 오프셋을 계산하는 로직이 필요하단 것이다.

커서 방식

커서 방식은 정렬된 데이터에서 커서를 기준으로 일정한 개수만큼 제공하는 방식이다.

id가 1 ~ 100 까지 내림차순으로 정렬된 100 개의 데이터가 있을때

id를 커서로잡고 커서 51, 크기 10 으로 설정한다면 위의 오프셋 방식과 동일한 결과(id : 41 ~ 50)를 얻을 수 있다.

커서 방식은 페이지 번호를 사용해 데이터를 표현하는 전통적인 페이징 방식에서 사용되지 않고 무한 스크롤과 같이 페이지와 상관없이 계속해서 데이터를 제공해주는 곳에서 사용한다.

커서 방식은 보통 제공된 데이터의 마지막 데이터를 커서로 잡고 다음 쿼리시 해당 커서를 던지는 식으로 구현한다.

페이징 구현

오프셋 방식 구현

SQL을 직접 사용한다면 위에서 설명했듯, 페이지번호를 오프셋으로 변환하는 작업이 필요하다.

상당히 귀찮고 실수가 날 부분도 많은 작업이다. 하지만 Spring Data JPA 에서는 이러한 페이징 계산을 편하게 해주는 인터페이스를 제공한다.

레포지토리 인터페이스에서 추상 메서드 선언시 파라미터로 Pageable 을 넘겨주고, 리턴 타입으로 Page, Slice, List 등을 사용한다면 페이징 쿼리를 쉽게 사용할 수 있다.

// MemberRepository.java

Page<Member> findAll(Pageable pageable);

위와같이 작성하고 파라미터로 Pageable 객체를 넘겨주기만 하면 끝난다. Pageable 객체는 다음과 같이 생성한다.

PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "id"));

페이지번호는 0 (0부터 시작), 페이지 크기는 3, 정렬은 id 내림차순으로 설정했다. (정렬은 선택)

이 요청으로 쿼리를 날리면 아래와 같은 SQL로 번역되어 날라간다.

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.username      
    from
        member m1_0      
    order by
        m1_0.member_id desc      
    offset
        0 rows      
    fetch
        first 3 rows only

사용할때는 추상 메서드에서 선언한 리턴타입으로 받아 사용할 수 있다. 위의 예시인 Page

  • 쿼리결과 (List)
  • 토탈 멤버 수
  • 토탈 페이지 수
  • 현재 페이지 수 등 다양한 기능들을 제공한다.

커서 방식 구현

커서 방식은 Spring Data JPA에서 따로 지원하지는 않는다. 하지만 원리 자체는 어렵지 않아 어렵지 않게 구현할 수 있다.

커서 방식에서는 커서를 추가로 받아 구현한다. 위의 예시에서와 마찬가지로 id 를 커서로 잡고 구현했다.

// MemberRepository.java
@Query(value = "select m from Member m where m.id < :cursor")
Page<Member> findMemberCursor(@Param("cursor") Long cursor, Pageable pageable);

페이지 요청을 날리는 Pageable 에는 페이지 번호를 항상 0 으로 주고 페이지 크기를 같이 넘겨준다.

PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Direction.DESC, "id"));

커서 방식에서 최초 요청시 기준이 되는 커서값이 없기 때문에 비즈니스 로직에 따라 최초 커서를 잡아준다.

위 예시에선 Long 타입인 id 로 내림차순 정렬이 되어있기에 Long.MAX_VALUE 로 최초 커서를 잡아줬다.

요청은 다음과 같은 SQL로 번역되어 날라간다.

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.username      
    from
        member m1_0      
    where
        m1_0.member_id<9223372036854775807      
    order by
        m1_0.member_id desc      
    offset
        0 rows      
    fetch
        first 3 rows only

이후 요청부터는 커서값을 요청 결과의 마지막 Member.id 로 설정해 요청하면 된다.

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.username      
    from
        member m1_0      
    where
        m1_0.member_id<108      
    order by
        m1_0.member_id desc      
    offset
        0 rows      
    fetch
        first 3 rows only

두 방식 장단점

오프셋 방식 장단점

장점

  • 쿼리 구조가 단순하다.
  • 원하는 페이지로 바로 이동이 가능하다

단점

  • 일관성이 깨질 수 있다.
    • 예를들어 페이지크기가 10일때 최신순으로 어떤 페이지를 보고있다고 해보자.

      그때 10개의 새로운 데이터가 추가된다면 같은 페이지로 쿼리를 날리더라도 새로운 데이터가 오게 된다.

      또한 같은 원리로 다음 페이지로 넘어가더라도 이전 페이지와 동일한 데이터를 보게되거나, 경우에 따라서 데이터가 누락될수도 있다.

  • 성능문제

커서 방식 장단점

장점

  • 오프셋 없이 동작하기 때문에 성능면에서 효율적이다.
  • 커서만 동일하다면 조회 일관성이 보장된다.

단점

  • 오프셋 방식에 비해 복잡성이 증가한다.
  • 특정 페이지로 이동이 힘들다.

offset 주의사항

offset 은 결과에서 특정 행만큼 건너뛰는 작업을 수행한다.

데이터가 몇건 없을때야 상관없지만 데이터가 몇억건정도로 많아진다면 성능상 문제를 야기한다.

결과 데이터가 1e8 건인데, 9e7 의 오프셋이 잡혀있다고 해보자. 이럴땐 9천만개의 행을 읽고 건너뛰는 작업이 필요하고 이 과정에서 큰 성능 저하가 발생할 수 있다.

⚠️ **GitHub.com Fallback** ⚠️