Pagination 적용하는 법(Ft. Cursor vs No-offset)

2025. 1. 9. 17:52·기술 도입기

안녕하세요! Meerket서비스 백엔드 팀원으로 활동중인 문성현입니다 ^_^

안녕하세욥

저는 이 프로젝트에서 상품, 댓글 도메인 개발을 맡았고 현재는 CI/CD 파이프라인을 구축하고 있습니다.

(저의 글의 전체적인 흐름은 [글을 쓴 배경(이유)] -> [주제에 대한 내용] -> [마무리(느낀점)] 순서로 이루어져 있습니다.)


[글을 쓴 배경]

웹의 역할은 데이터 CRUD이며 웹 개발은 결국 CRUD를 얼마나 효율적이고 사용자 친화적으로 제공하냐라고 생각합니다.

개발을 해보시면 쉽게 느끼시겠지만 CRUD 중에서도 특히 READ 조회에 대한 리소스가 가장 많이 발생합니다.

조회가 작동하지 않는다면 데이터 생성 수정을해도 사용자 입장에서는 확인할 수 없을테니까요..!

이렇듯 이 글은 제가 맡았던 상품 도메인에서 Pagination을 이용하여 조회를 최적화하는 방법에대해 글을 써보려 합니다!

 


[Pagination 이란?]

Pagination이란, 많은 데이터를 부분적으로 불러오는 기술입니다.

서비스 초반인 지금 데이터가 뭐 50개..정도 있네요😂

하지만 저희 서비스가 당근마켓처럼 엄청난 성장을 하였다고 가정해 봅시다!!

당근마켓에는 몇억개 이상의 상품 데이터가 저장되어 있습니다.
그중 내 동네에 맞는 데이터가 몇만개 라고 생각해보면.. 이를 사용자가 어플에 접속했을 때 몇천개의 데이터들을 한번에 조회해 온다면 매번 엄청난 시간과 서버 부하기 발생할 것입니다.
이를 해결하기위해 전체 데이터 중 부분적으로 끊어서 조회해 오는 기술이 바로 Pagination입니다!

Pagination을 구현하는 방법에도 대표적으로 offset-based 방식과 no-offset인 Cursor 페이징 방식이 있습니다.


[Offset-based 페이지네이션]

 

Jpa pageable 객체를 이용해 구현

@Repository
public interface CommentRepository extends JpaRepository<CommentEntity,Long> {

    @Query("SELECT c FROM CommentEntity c WHERE c.performance.performanceId = :performanceId AND c.parentComment IS NULL")
    List<CommentEntity> findParentCommentsByPerformanceId(@Param("performanceId") Long performanceId, Pageable pageable);

실제 sql query는 이런식으로 나간다

select *from comment 
order by create_at desc
limit [페이지사이즈] 
offset [페이지번호];
  • limit : 쿼리에서 반환되는 최대 행(row) 수를 지정(한페이지 10개만 보고싶으면 10으로 지정)
  • offset : 몇 번째 행부터 데이터를 반환할 것인지 지정. -> 건널뛸 개수
    ex) OFFSET 10은 처음 10개의 행을 건너뛰고 그 다음 행부터 데이터를 반환

장점

  • 구현하기 쉽다( Jpa에서 pageable로 쉽게 구현)
  • 특정 페이지로 바로 이동 가능

단점

  • 중복 데이터 조회: 데이터가 지속적으로 추가되거나 삭제되는 환경에서는 페이지네이션 중 데이터의 중복이나 누락이 발생할 수 있습니다. 예를 들어, 첫 번째 페이지를 조회한 후 데이터가 5개 추가되면, 두 번째 페이지를 조회할 때 첫 페이지에서 이미 조회했던 데이터가5게 다시 조회될 수 있습니다. 
  • offset이 커질수록 성능 저하 유발

[no-offset(Cursor) 페이지네이션]

Meerket 서비스에서 선택한 페이지네이션 방법은 바로 no-offset 즉, 커서기반 페이지네이션입니다.

 

저희 서비스에서 커서기반 페이지네이션을 선택한 이유는 다음과  같습니다.

 

1.실시간 동적 데이터 처리에 유리하다

사실 커서 페이징의 가장 큰 장점은 실시간 데이터를 효율적으로 다룰수 있다는 점입니다.
왜냐하면 앞서 소개해 드렸던 offset 페이지네이션은 우리가 원하는 데이터가 "몇 번째" 있는지 집중하는 반면
커서기반 페이지네이션은 우리가 원하는 데이터가 "어떤 데이터 다음 순서"인지 집중하고 조회하는 방식이기 때문입니다.

즉 클라이언트가 요청해서 가져간 마지막 row의 순서상 다음 row부터 데이터 조회해주는 방식입니다.

 

하지만 offset기반 조회를 하게된다면 데이터 누락, 데이터 중복 문제가 반드시 발생합니다.
아래에서 두가지 문제에 대하여 더 자세히 알아보겠습니다.

 

2. 누락되지 않는 데이터

 

페이스북이 스크롤페이징을 커서페이징이 아닌, 오프셋페이징으로 구현했을 경우 데이터가 누락될 수 있습니다.

실생활에 예를 들어서 설명해보겠습니다. 페이스북에서 친구들이 가장 최근에 올린 5개의 피드를 바로 받은 경우 입니다.(offset paging)
(이 예시는 참고자료에 적은 "어제의 최선"님의 예시를 인용하였습니다. 너무 잘 정리해놓셔서..)

  • Game of Thrones meme(2)
  • Sam Drunk Photo(2)
  • Political Rant

그리고 다음 페이지로 스크롤하면 아래의 데이터를 볼 수 있습니다.

  • Cat Photo(2)
  • Olivia's Ausralia Trip(2)
  • Oprah Wisdom

사진의 2페이지를 볼 수 있을 것이라고 예상할 수 있습니다. 그런데 1페이지를 보고있는 동안 숙취를 앓고 있는 친구 Sam이 일어나서 술에 취한 부끄러운 사진을 삭제하는 경우에는 2페이지로 스크롤하면 아래 그림과 같은 피드가 조회될 것 입니다.

 

이와 같이 우리는 고양이 사진을 볼 수 없습니다.  왜냐하면 오프셋 페이징은 데이터가 수정되는 것을 신경쓰지 않고 그저 단순히 쿼리 결과문의 다음 5개의 피드를 받을뿐이기 때문입니다. 데이터베이스는 아마 이런식으로 수행되겠죠.

 

SELECT * FROM table ORDER BY timestamp OFFSET  0 LIMIT 5
SELECT * FROM table ORDER BY timestamp OFFSET  5 LIMIT 5
SELECT * FROM table ORDER BY timestamp OFFSET 10 LIMIT 5
SELECT * FROM table ORDER BY timestamp OFFSET 15 LIMIT 5
...

 

 

 

즉 아마 페이스북을 재접속하지 않는 이상, 고양이 사진을 절대 볼 수 없습니다.
게다가 누락된 피드가 있다는 것을 알 방법이 전혀 없습니다.

 

그럼 커서페이징은 어떻게 수행될까? OFFSET 파라미터를 인덱스로 전달하는 오프셋 페이징과 달리 커서 페이징은 조회가 중단된 마지막 페이지를 담은 커서라는 포인터(DataSet의 특정 레코드, 로우)를 파라미터로 전달합니다.  timestamp 열을 기준으로 하는 커서가 있다고 가정했을 때, 커서는 마지막으로 조회된 피드의 타임스탬프가 9월 12일 16시 2분임을 알려주고 이 시간 전의 5개의 피드를 불러오게 됩니다.( 물론 모든 데이터는 soft delete된다는 가정)

 

SELECT * FROM table WHERE cursor > timestamp ORDER BY timestamp LIMIT 5

 

이렇게하면 Sam이 사진을 삭제한 것은 영향이 없고, timestamp를 기준으로 하기 때문에 고양이 사진을 포함해 정상적으로 데이터를 얻을 수 있습니다.

 


3. 중복되지 않는 데이터 

총 10개의 게시물( 10 9 8 7 6 5 4 3 2 1 )이 내림차순으로 조회되고 있을 가정해보겠습니다.

 

1페이지를 보는 동안, 새로운 피드가 2개 추가되어 2페이지는 OFFSET 7번부터 5개를(1페이지에 12번~8번이 있으므로) 보여줘야하지만 여전히 5번부터 5개를 보여주기 때문입니다.-> 5개 건너뛰고 5개 조회에 이렇게 명령이 들어가기 때문(1페이지 : 12~8 / 2페이지 : 7~3) 즉 7,6번 게시물이 중복된다.

 

사용자는 뭔가 잘못된 것을 즉시 알 수 있고 사이트에 좋지 못한 경험을 하지만 웹사이트는 정상적으로 작동하고 있는 상태이므로 최악의 상황이라고 할 수 있습니다.

 

반면 커서 페이징이라면 위에서 설명한 대로 커서를 기준으로 하기 때문에 정상적으로 id 5번부터 조회할 것 입니다.


저희 앱은 중고물품 거래가 주 서비스로 실시간 상품에대한 데이터들이 동적으로 생겨나며, 무한스크롤로 조회되어야 합니다.

그러므로 커서기반 페이지네이션으로 페이징 처리를 적용해야 한다고 판단하였습니다.

아래는 저희 앱 상품조회 화면입니다.

 

Meerket의 홈 화면

 

이렇듯 몇 페이지를 선택해서 넘어가는 방식이아닌 무한 스크롤로 데이터를 조회하는 방식으로 구현하였습니다.
무한스크롤로 구현하니 뭔가 더 트렌디하고 예쁜느낌..!

 

[코드]

커서에대한 어노테이션 직접 커스텀(Jpa Pageable같은거)

package org.j1p5.common.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CursorDefault {
    long cursor() default Long.MAX_VALUE;

    int size() default 10;
}

Querydsl을 이용한 cursor 페이지네이션 조회 구현

    @Override
    public List<ProductEntity> findProductsByCursor(List<Long> blockUserIds, Point coordinate, Long cursor, Integer size) {
        // QueryDSL을 사용한 커서 기반 조회
        return queryFactory
                .selectFrom(qProduct)
                .where(
                        qUserEntity.id.notIn(blockUserIds),
                        withinDistance(coordinate, MAX_DISTANCE), // 거리 조건 (100km 이내)
                        cursorCondition(cursor), // 커서 조건
                        isNotDeleted(),
                        isNotWithdrawalUser())
                .orderBy(qProduct.id.desc()) // 내림차순 정렬
                .limit(size) // 페이지 크기 제한
                .fetch();
    }

    /**
     * 커서를 기준으로 특정 상품 ID보다 작은 상품만 조회하는 조건 생성
     *
     * @param cursor 마지막으로 조회한 상품 ID
     * @return BooleanExpression (QueryDSL 조건)
     */
    private BooleanExpression cursorCondition(Long cursor) {
        if (cursor == null) {
            return null; // 커서가 없으면 조건 생략
        }
        return qProduct.id.lt(cursor);
    }

 

여기서 cursor가 되는 컬럼은 Unique하거나 , 정렬가능(Orderable), 불변(Immutable)한 컬럼을 선택해야 한다. 
나는 PK인 id값을 선택하였고 보통은 createdat 생성시간으로도 많이 사용한다고 한다.

 

장점>

  • 데이터의 추가 삭제에 안정적이다.
    이전 offset Pagination 에서와는 다르게 데이터의 추가, 삭제에도 중복된 데이터를 만들거나 데이터를 보지 못하는 케이스가 발생하지 않는다.
    즉, 커서는 특정 데이터 요소를 기준으로 하기 때문에, 조회 중 데이터 변경이 발생해도 영향을 받지 않는다.
  • 대규모 데이터에 적합한 확장성을 가지고 있다.
    cursor의 데이터가 인덱싱 되어 있다는 전제하에, database는 cursor를 바로 찾을 수 있고, 원하는 갯수만큼의 데이터를 효율적으로 불러 올 수 있다.

단점>

  • 사용자가 원하는 페이지로 바로 이동 할 수 없다.
    cursor기반 페이지네이션은 총 데이터의 개수를 알 수 없어, 원하는 페이지로 바로 이동하는 것이 불가능합니다.
  • 정렬 기능이 제한된다.
    커서 기반 페이지네이션에서 데이터는 커서 값에 따라 정렬되어야 합니다. 하지만 where절에서 다른 필드를 기준으로 데이터를 필터링 하고자 할 경우, 그 필터 조건과 커서 정렬 조건이 일치하지 않으면 정확하고 예상된 결과를 얻기 힘듭니다.

[Result 도입 이후 결과]

사실 cursor pagination과 offset pagination 성능비교는 아직 못해봤습니다.
조만간 Jmeter를 이용해 대용량 데이터 상황에서 성능비교 테스트를 해볼 예정입니다!

테스트 후 이부분은 다시 글을 작성해보겠습니다.

 


[마치며]

개발자로서 기록하는 습관은 정말 중요하다고 생각합니다.

글로 정리하면서 부족했던 부분에 대한 회고와 피드백이 가능하며 또 알았던 부분도 까먹지 않고 더 확실히 내 것이 된다고 생각합니다.

앞으로도 개발을 하면서 겪었던 여러가지 고민이나 생각들을 기록하고 소통하는 개발자가 되어보겠습니다!

 

 

 

※참고 레퍼런스※

https://bbbicb.tistory.com/40

 

왜 오프셋 페이징보다 커서 페이징일까?

Is offset pagination dead? Why cursor pagination is taking over Facebook’s developer page said it best: uxdesign.cc ※ 이 글은 위 글을 의역한 글입니다. ※ 제가 이해한 것을 토대로 약간 수정했습니다. 커서 기반 페이징

bbbicb.tistory.com

 

'기술 도입기' 카테고리의 다른 글

API Wrapper  (0) 2025.01.19
Github Actions로 CI/CD 구현하기  (1) 2025.01.17
Atomic Design Pattern 도입기⚛️  (1) 2025.01.09
RouterGuard 개발기🛡️  (0) 2025.01.08
Quartz 사용하여 경매 마감 관리하기  (0) 2025.01.07
'기술 도입기' 카테고리의 다른 글
  • API Wrapper
  • Github Actions로 CI/CD 구현하기
  • Atomic Design Pattern 도입기⚛️
  • RouterGuard 개발기🛡️
meerket
meerket
Meerket의 기술 블로그 입니다.
  • meerket
    Meerket Tech Blog
    meerket
  • 전체
    오늘
    어제
    • 분류 전체보기 (12)
      • About Meerket🦦 (1)
      • 기술 도입기 (9)
      • 이슈 & 버그 해결 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 미어켓
    • 백엔드 레포지토리
    • 프론트엔드 레포지토리
  • 공지사항

  • 인기 글

  • 태그

    routerguard
    frontend
    axios
    Quartz
    api 호출
    아토믹 디자인 패턴
    리액트
    issue관리
    미어켓
    미어켓팀소개
    프론트엔드
    protectrouter
    AuthGUard
    swr
    미어켓서비스소개
    j1p5
    github project
    router guard 관련 버그 해결
    스케줄러
    reactquery swr
    컴포넌트 설계 원칙
    미어켓이슈관리
    githubproject이슈관리
    블라인드 입찰
    axios instance
    atomic design pattern
    깃허브로이슈관리
    meerket
    react
    디자인 시스템
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
meerket
Pagination 적용하는 법(Ft. Cursor vs No-offset)
상단으로

티스토리툴바