우아한테크코스 테코톡

라젤의 무한 스크롤과 Pagination

https://youtu.be/uodIYHz4dus?si=N-6xThCjtKQXm0mn

라젤의 무한 스크롤과 Pagination


무한 스크롤과 페이지네이션: 안정적인 데이터 조회를 위한 커서 기반 페이징

무한 스크롤은 사용자가 스크롤을 내릴 때마다 다음 데이터를 자동으로 불러와, 페이지 이동 없이 콘텐츠를 계속 탐색할 수 있게 하는 방식이다. 블로그 게시글, 상품 목록, 피드, 댓글 목록처럼 계속 이어지는 데이터를 보여줄 때 자주 사용된다.

처음에는 단순히 pagesize를 받아서 데이터를 잘라 보내면 충분해 보인다. 하지만 실제 서비스에서는 데이터가 계속 추가되거나 삭제되기 때문에, 단순한 오프셋 기반 페이징만으로는 중복 조회데이터 누락 문제가 발생할 수 있다.


오프셋 기반 페이징이란

가장 흔한 방식은 pagesize를 사용하는 오프셋 기반 페이징이다.

offset = page * size

예를 들어 게시글이 100개 있고, 최신순으로 조회한다고 가정해보자.

page = 2
size = 10
offset = 20

그러면 이미 20개를 보냈다고 보고, 그다음 10개를 조회한다.

SELECT *
FROM posts
ORDER BY id DESC
LIMIT 10 OFFSET 20;

이 방식은 구현이 단순하고 이해하기 쉽다.


hasNext를 구하는 방법

무한 스크롤에서는 다음 페이지가 있는지 알려주는 값이 필요하다.

{
  "items": [],
  "hasNext": true
}

이를 위해 보통 요청한 size보다 1개 더 조회한다.

요청 size = 10
실제 조회 = 11

11개가 조회되면 다음 데이터가 있다는 뜻이므로 hasNext = true가 된다. 응답할 때는 10개만 잘라서 반환한다.


Pageable과 Page

Spring Data JPA에서는 PageablePage를 사용해 페이징 코드를 깔끔하게 만들 수 있다.

Pageable은 다음 정보를 담는다.

몇 번째 페이지를
몇 개씩
어떤 정렬 기준으로 가져올지

대표 구현체는 PageRequest다.

Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());

Page는 조회 결과와 함께 다음 정보를 제공한다.

현재 페이지 데이터
전체 데이터 수
전체 페이지 수
다음 페이지 존재 여부

하지만 여기에는 중요한 단점이 있다.


Page의 단점: count 쿼리

Page는 전체 데이터 수와 전체 페이지 수를 알아야 한다.

그래서 내부적으로 count 쿼리가 필요하다.

SELECT COUNT(*)
FROM posts;

무한 스크롤에서는 보통 전체 개수가 필요하지 않다. 사용자에게 필요한 것은 “다음 데이터가 있는가?” 정도다.

그런데도 Page를 사용하면 전체 개수를 세기 위한 count 쿼리가 실행된다. 데이터가 많아질수록 이 비용은 부담이 될 수 있다.


오프셋 기반 페이징의 치명적인 문제

오프셋 기반 페이징의 가장 큰 문제는 데이터가 변할 때 발생한다.

서비스의 데이터는 고정되어 있지 않다.

새 게시글이 추가될 수 있고
기존 게시글이 삭제될 수 있다

이때 오프셋은 “몇 개를 건너뛸지”만 기억하기 때문에 문제가 생긴다.


데이터 중복 문제

사용자가 첫 번째 요청으로 100번부터 91번 게시글을 받았다고 가정하자.

그다음 요청은 offset 10부터 시작해야 한다.

그런데 그 사이에 새 게시글 101번이 추가되면?

기존 데이터의 순서가 한 칸씩 밀린다.

결과적으로 두 번째 요청에서 이미 받았던 91번 게시글이 다시 내려올 수 있다.

첫 요청: 100 ~ 91
새 글 추가: 101
다음 요청: 91 ~ 82

즉, 중복 데이터가 발생한다.


데이터 누락 문제

반대로 중간 데이터가 삭제되면 어떻게 될까?

첫 요청 이후 어떤 게시글이 삭제되면 전체 순서가 당겨진다.

그런데 클라이언트는 여전히 “10개를 건너뛰어라”라고 요청한다.

결과적으로 아직 받지 못한 데이터 하나를 건너뛰게 된다.

첫 요청: 100 ~ 91
중간 데이터 삭제
다음 요청: 89 ~ 80

90번 게시글이 빠지는 식의 데이터 누락이 발생할 수 있다.


해결책: 커서 기반 페이징

이 문제를 해결하는 가장 좋은 방법은 커서 기반 페이징(Cursor-based Pagination) 이다.

오프셋 기반 페이징은 “몇 개를 건너뛸지”를 기준으로 한다.

offset = 20

반면 커서 기반 페이징은 “마지막으로 받은 데이터의 고유값”을 기준으로 한다.

lastId = 91

즉, 다음 요청에서는 이렇게 조회한다.

SELECT *
FROM posts
WHERE id < 91
ORDER BY id DESC
LIMIT 10;

이 방식은 중간에 새 데이터가 추가되거나 삭제되어도 안정적으로 동작한다.


왜 커서 기반 페이징은 안정적일까

커서 기반 페이징은 순서의 개수가 아니라 데이터 자체의 위치를 기준으로 한다.

마지막으로 받은 게시글 ID가 91이다
그러면 다음에는 91보다 작은 게시글만 가져온다

따라서 중간에 101번 게시글이 새로 추가되어도 영향을 받지 않는다.

새 글 101 추가
그래도 다음 조회 기준은 id < 91

삭제가 발생해도 마찬가지다.

중간 데이터 삭제
그래도 다음 조회 기준은 id < 91

그래서 무한 스크롤처럼 데이터가 실시간으로 변할 수 있는 환경에서는 커서 기반 페이징이 훨씬 안정적이다.


Slice란 무엇인가

무한 스크롤에서는 전체 데이터 개수보다 다음 데이터 존재 여부가 중요하다.

이때 Page 대신 Slice를 사용할 수 있다.

Slice는 다음 정보를 제공한다.

현재 데이터 목록
다음 페이지 존재 여부

하지만 Page와 달리 다음 정보는 제공하지 않는다.

전체 데이터 수
전체 페이지 수

그래서 count 쿼리가 필요 없다.


Page와 Slice의 차이

구분PageSlice
현재 데이터제공제공
다음 페이지 여부제공제공
전체 데이터 수제공제공하지 않음
전체 페이지 수제공제공하지 않음
count 쿼리필요불필요
적합한 사용처게시판 페이지 이동무한 스크롤

무한 스크롤에서는 보통 Slice가 더 적합하다.


실무 구현 방향

무한 스크롤 API는 보통 다음 구조가 적합하다.

GET /posts?lastId=91&size=10

응답은 다음처럼 구성할 수 있다.

{
  "items": [
    {
      "id": 90,
      "title": "게시글 제목"
    }
  ],
  "hasNext": true,
  "nextCursor": 81
}

핵심은 다음 요청에 사용할 커서를 같이 내려주는 것이다.

nextCursor = 마지막 요소의 id

정리

오프셋 기반 페이징은 구현이 쉽지만, 데이터가 추가되거나 삭제되면 중복과 누락이 발생할 수 있다.

Page는 전체 페이지 수와 전체 데이터 수를 제공하지만, count 쿼리가 필요하다.

무한 스크롤에서는 전체 개수보다 다음 데이터 존재 여부가 더 중요하므로 Slice가 더 적합하다.

가장 안정적인 방식은 커서 기반 페이징이다.

이전 응답의 마지막 요소를 기준으로
다음 데이터를 조회한다

이 방식은 데이터가 추가되거나 삭제되어도 일관된 결과를 제공한다.

결국 무한 스크롤 구현의 핵심은 이것이다.

페이지 번호가 아니라
마지막으로 본 데이터의 위치를 기억하자

그래야 사용자는 중복이나 누락 없이 자연스럽게 데이터를 이어서 볼 수 있다.


© 2020. All rights reserved.

SIKSIK