어 나 갱수.

[Spring] pagination 구현하기 본문

Spring

[Spring] pagination 구현하기

김경수 2024. 8. 14. 14:16
728x90

Pagination이란

Pagination이란 검색 결과를 조회할 때 데이터를 한 번에 다 가져오는 것이 아니라 나눠서 일부만 조회하는 방법입니다.

 

Pagination은 게시판이나 상품 목록 조회등을 요청할 때 결과값이 총 1억 개가 있다고 가정하면, 사용자는 이 데이터 1억 개를 모두 보지도 않는데 1억 개의 데이터를 조회해야 합니다. 그렇게 매번 전체의 리스트를 조회하게 되면 조회 속도도 느려지고 성능도 안 좋아지게 될 수 있습니다. 하지만 데이터를 일정한 크기로 나눠서 규칙적으로 조회한다면 조회 속도도 빠르고 성능적으로도 좋은 애플리케이션이 될 수 있습니다.

Pagination을 구현하는 방법은 크게 2가지가 있습니다.

offset-based pagination

말 그대로 페이지를 기반으로 데이터를 나눠서 요청하는 방식이다. 요청을 보낼 때 몇 번째 페이지에서 몇 개의 데이터를 조회할지 명시합니다. 아래 사진과 같이 클라이언트에서 페이지를 선택하면 그 페이지를 통해 데이터 조회 요청을 보냅니다. 

이 Page Based 기반의 페이지네이션 기법은 구현이 간단하다는 장접이 있습니다. 조회하려는 데이터의 개수와 페이지 수만 요청하면 쉽게 구현할 수 있습니다.

 

offset 이란 sql에서 조회를 시작할 기준점을 의미합니다. limit은 조회할 결과의 개수를 의미합니다.

예를 들어 아래와 같은 쿼리가 있을 경우 5000번째 행부터 10개의 행을 읽겠다는 의미입니다.

SELECT *
FROM board
LIMIT 10
OFFSET 5000;

 

offset 기반 페이지네이션

아래와 같이 12건의 데이터가 있다고 가정했을 때 Offset을 4로 설정하고 요청하게 되면 조회 대상을 3Page로 나누고 4건씩 조회하도록 구현할 수 있습니다.

중복 문제 발생

그러나 단점으로는 페이지네이션으로 데이터를 조회하던 도중 새로운 데이터가 추가되거나, 기존의 데이터가 삭제되는 경우에 데이터가 중복될 가능성이 있습니다. 만약 조회하는 과정에서 7과 8 사이에 7.5라는 새로운 값이 들어오게 된다면 Page 2에서 조회한 8이라는 값을 Page 3에서 다시 중복 조회하게 될 경우가 발생합니다.

누락 문제 발생

또 다른 발생할 수 있는 문제점으로는 데이터 누락입니다. 만약에 위와 같은 상황에서 사용자는 Page 1에서 Page 2로 페이지를 넘기려고 합니다. 그때, 3번 4번 데이터를 삭제하게 되면 5번 6번 데이터가 자연스럽게 Page 1로 이동하게 될 거고 그러면 Page 2로 넘기면 5번 6번 데이터는 볼 수 없게 되는 것입니다. 이렇게 데이터 누락 문제도 발생할 수 있습니다.

느린 속도 문제 발생

offset 방식의 페이징 처리는 조금 느릴 수 있습니다. 테이블의 크기가 커지면서 느려질 수도 있고, API 로직이 복잡해지면서 느려질 수도 있습니다. 하지만 offset 방식으로 데이터를 조회하게 되면 뒤로 갈수록 앞에서 읽었던 행을 다시 읽어야 하기 때문에 페이지가 뒤로 갈수록 느립니다.

 

이는 전체 페이지 수를 계산할 때, 전체 데이터 count() 로직이 시간이 많이 소요되는 작업이기 때문입니다. count() 로직에는 full-scan을 하면서 몇 개의 데이터가 존재하는지 카운트합니다.

SELECT *
FROM board
LIMIT 10
OFFSET 5000;

위와 같은 명령어로 offset 페이지네이션을 구현한다고 가정해 보겠습니다.

 

5000번째 데이터부터 10개를 조회하기 위해서는 5010개의 데이터를 모두 읽은 뒤, 앞의 필요하지 않은 5000개는 버려야 합니다.

적은 양의 데이터를 조회할 때는 성능적인 문제가 발생하지 않지만 전체 데이터가 많아질수록 앞에 읽어야 하는 데이터의 양이 많아져서 성능적으로 문제가 발생할 수 있습니다. 실제로 필요한 건 마지막 10개뿐이니 데이터베이스에 부하가 올 수 있습니다.
뒤페이지로 갈수록 버리지만 읽어야 할 행의 개수가 많아 점점 뒤로 갈수록 느려지는 것입니다.

 

이때, 커서 기반 페이지네이션 또는 no-offset 페이징과 같은 방법으로 페이징처리를 할 수 있습니다.

cursor-based pagination

"커서 기반 페이징이 가장 효율적인 방법이며, 가능한 항상 사용되어야 한다."라고 타임라인 기능을 무한 스크롤 페이징으로 만든 페이스북 개발자는 말했습니다. 그만큼 커서 기반의 페이징 처리는 효율로 따지면 가장 최고의 페이징 처리 방법이라고 할 수 있습니다.

 

페이지의 값이 커짐에 따라 읽어야 하는 데이터가 많아지는 Offset 기반 방식과 달리, Cursor 기반은 해당 페이지에서 조회하는 데이터만 조회합니다. 커서가 뒤로 많이 간 상황에도 항상 일정한 성능을 보장하는 특성을 가지고 있습니다. 

데이터 중복이 발생하지 않고 바로 다음 cursor에 대한 정보를 주며 되므로 대량의 데이터를 다룰 때 성능상 이점이 있습니다.

 

offset 기반 페이지네이션과의 차이

마지막으로 읽은 데이터(1억번)의 데이터 다음 데이터부터 10개를 조회

  • offset 기반
    • 1억10개의 데이터를 조회하고 앞의 1억 개의 데이터는 버리고 나머지 10개의 데이터만 읽음 -> 결과적으로 1억 10개의 데이터를 읽음
  • cursor 기반
    •  1억부터 1억 10번 까지의 데이터만 읽음 -> 결과적으로 10개의 데이터만 읽음

cursor 기반의 페이지네이션은 n개의 row를 skip 한 다음에 10개를 조회해 주세요가 아니라, n개의 row 다음 거부터 10개를 조회해 주세요 라는 요청입니다.

 

Cursor 페이지네이션은 현재 보고 있는 데이터의 마지막 번호인 cursor와 몇 개의 데이터를 불러올 것인지에 대한 page_size를 통해 서버가 데이터를 조회할 수 있습니다. cursor는 데이터베이스의 특정 고유 값을 참조합니다.

API: GET /api/board

request: {
 cursor: 123,
 page_size: 10
}

만약에 cursor를 제공하지 않는다면 첫 번째 페이지에서 page_size 만큼의 데이터를 조회합니다.

 

반환할 때는 아래와 같이 다음 cursor를 함께 반환하고 다음 요청을 할 때 해당 cursor를 사용해서 조회 요청을 합니다.

response: {
    "boards": [...],
    "next_cursor": "12345",
}

 

이렇게 cursor가 boardId라는 auto-increment 기능을 통해 1씩 증가되는 long 타입의 값이라면 마지막 데이터의 id보다 1 높거나 1 낮은 데이터를 next_cursor로 설정하면 간단하게 다음 데이터를 조회할 수 있습니다.

 

그러나 boardId가 UUID로 설정돼 있다면 위와 같은 방법으로는 데이터를 조회하기 힘듭니다. UUID는 고유하도록 설계되어서 유일성이라는 장점을 가지고는 있지만 순차적이지는 않습니다. UUID만 커서로 사용한다면 페이지 매김에 필수적인 예측 가능한 다음 데이터의 UUID를 결정하기가 어렵습니다.

이렇게 데이터 순서의 대한 정보를 전달하기 힘들기 때문에 UUID를 단독으로 커서로 사용하는 경우는 많지 않습니다.

 

공통 커서 필드

데이터베이스는 일반적으로 자동 증가 ID 또는 타임스탬프와 같은 순차 필드를 사용하여 페이지를 매기도록 최적화되어 있습니다. UUID를 사용한 페이지 매김은 비순차적 순서로 "다음" 데이터를 결정하는 데 관련된 복잡성으로 인해 효율성이 떨어질 수 있습니다.
UUID를 커서로 사용하려면 UUID를 명확한 순서(예: 타임스탬프)를 제공하는 다른 필드와 쌍을 이루는 추가 로직이 필요한 경우가 많습니다. 이는 조금 복잡한 쿼리를 요구합니다.

 

케이스:: id DESC 정렬

boards테이블에서 리스트를 가져오는 SQL 쿼리문입니다.

SELECT id FROM `boards` ORDER BY id DESC LIMIT 5

그럼 아래와 같은 리스트가 조회됩니다.

id title
100 상품#100
99 상품#99
98 상품#98
97 상품#97
96 상품#96

이제 여기서 커서 페이지네이션을 사용해서 다음 값을 조회해보겠습니다. 가장 마지막 데이터의 board_id가 96이기 때문에 다음 데이터의 board_id는 96보다 작은 수에서 내림차순 해서 데이터를 조회하면 됩니다.

SELECT id, title
  FROM `boards`
  WHERE id < 996
  ORDER BY id DESC
  LIMIT 5

여기서는 cursor 값이 board_id입니다. 996이 cursor 값이 됩니다.

 

케이스:: price ASC 정렬

boards 테이블에서 likes(좋아요)가 적은 순 대로 조회하는 SQL 쿼리문입니다.

SELECT id, title, likes
	FROM `boards`
	ORDER BY likes ASC
	LIMIT 5

아래와 같은 리스트를 조회할 수 있습니다.

Id title likes
102 상품#102 11
34 상품#34 21
11 상품#11 45
7 상품#7 102
92 상품#92 203

 

여기서 cursor 값은 어떤 값일까요? 92라고 생각하셨겠지만 아닙니다. cursor값은 마지막 데이터의 likes인 203입니다.
이제 여기서 다음 리스트를 조회하는 쿼리문을 작성해 보겠습니다.

SELECT id, title, likes
	FROM `boards`
	WHERE likes > 203
	ORDER BY likes ASC
	LIMIT 5

오름차순이니 부등호 방향도 >로 해서 좋아요 수가 203보다 높은 게시글을 5개 오름차순으로 조회하도록 쿼리문을 작성하였습니다.
만약 여기서 203보다 큰 값 중에 좋아요가 300인 게시글이 두 개 있다면 중복값이 발생하게 됩니다.

 

커서 기반 페이지네이션을 구현할 때 정렬 기준이 되는 필드 중 하나는 고유값이 있어야 합니다. 위의 예시에서는 likes라는 중복이 발생할 수 있는 고유성이 없는 필드이기 때문에 고유성을 지니고 있는 id값도 함께 두 번째 정렬 기준으로 설정해줘야 합니다.

SELECT id FROM `boards` ORDER BY likes ASC, id ASC LIMIT 5

이렇게 likes(좋아요)가 같은 상황에서는 id로 정렬하도록 정렬 기준을 추가하였습니다.

 

커서 기반의 페이지네이션은 목록 전체를 조회하지 않고 조회된 마지막 부분을 기준으로 일정량의 데이터만 가져오는 방식으로 기존 방식의 단점을 보완했습니다. 따라서 이전 조회 목록과 중복되지 않는 데이터 검색을 보장하며 훨씬 빠르고 효율적으로 데이터를 가져올 수 있다는 장점을 가지고 있습니다. 그러면 무조건 커서 기반의 페이지네이션을 사용해야 하는 거 아닌가 라는 생각을 할 수 있습니다. 항상 그런 것만은 아닙니다. 커서 페이지네이션을 구현할 때 사용되는 커서에 중복이 발생한다면 출력되는 데이터의 일관성이 깨질 수 있습니다. 커서 값으로 사용되는 데이터는 항상 유일성이 보장되어야 합니다. 그리고 한 번에 여러 페이지를 넘기고 싶은 상황에서도 좋은 선택지가 아닙니다.

 

페이지 기반 페이지네이션 예시

네이버웹툰은 페이지 기반 페이지네이션 방법을 사용합니다. 사용자 입장에서 정렬된 웹툰에서 자신이 보고 싶은 웹툰이 어디 있는지 정확히 알고 싶을 수 있습니다. 특정 조건으로 정렬했을 때 3번째 페이지에 자신이 보고 싶은 웹툰이 있으면 바로 3페이지로 가서 웹툰을 볼 수 있습니다. 이렇게 빠르게 원하는 페이지로 이동할 수 있는 UI를 사용한 리스트 조회에서는 페이지 기반 페이지네이션을 채택할 수 있습니다.

 

커서 기반 페이지네이션 예시

대표적인 커서 페이지네이션 예시는 인스타그램 피드가 있습니다. 아래로 스크롤하다 보면 계속해서 새로운 포스트가 나오는 것을 확인할 수 있습니다. 

728x90