한정된 네트워크 자원을 효율적으로 활용하기 위해 특정한 정렬 기준에 따라 데이터를 분할하여 가져오는 것
즉, 데이터베이스에 만개의 데이터가 있을 때, 한번에 만 개를 전달하는 대신 0번 부터 49번까지 50개씩 전달하는 것을 의미한다. 여기서 다음 요청이 들어오면 50번부터 99번까지, 또 다음 요청이 들어오면 100번부터 149번까지 돌려준다. 이렇게 함으로써 네트워크의 낭비를 막고, 빠른 응답을 기대할 수 있게 된다.
Pagination은 아래와 같은 두가지 방식으로 처리가 가능하다.
1. 오프셋 기반 페이지네이션(Offset-based Pagination)
- DB의 offset쿼리를 사용하여 ‘페이지’ 단위로 구분하여 요청/응답
2. 커서 기반 페이지네이션(Cursor-based Pagination)
- Cursor 개념을 사용하여 사용자에게 응답해준 마지막 데이터 기준으로 다음 n개 요청/응답
MySQL 에서라면 간단하게 OFFSET 쿼리와 LIMIT 쿼리에 콤마를 붙여 '건너 뛸' row 숫자를 지정하여 페이지네이션을 구현한다.
즉, 페이지 단위로 구분한다.
SELECT id FROM products ORDER BY id DESC LIMIT 20, 40
LIMIT 절 앞에 붙은 숫자가 바로 건너 뛸 갯수(offset) 이다!
(20개 데이터 다음부터 40개 가져오기)
예를 들어, 1부터 시작하여 매기는 page 매개변수, 리스트의 쿼리 단위는 take 매개변수를 통해 전달된다고 할 때 이를 통해 쿼리 스트링을 만든다고 하면 아래와 같다.
// page : 1부터 시작하는 페이지
// take : 한번에 불러올 row 수
const query = "SELECT id FROM products ORDER BY id DESC LIMIT " + (take *(page-1)) + ", " + take;
이렇게 사용하면 가장 쉽고 편리한 방식인데 두가지 문제가 있다.
전통적인 페이지네이션은 오랜 기간 잘 작동해왔다. 문제는 페이스북이나 인스타그램과 같은
잦은 수정, 생성, 삭제가 반복되는 서비스에서는 더 이상 효율적으로 작동하지
못하게 되었다.
예를 들어, 1페이지에서 20개의 row를 불러와서 유저에게 1페이지를 띄워주었다. 고객이 1페이지의 상품들을 보고 있는 사이, 상품 운영팀에서 5개의 상품을 새로 올렸다면?
유저가 1페이지 상품을 다 둘러보고 2페이지를 눌렀을때 1페이지에서 보았던 상품 20개중 마지막 5개를 다시 2페이지에서 만나게 된다. (등록일 기준 내림차순이므로)
반대로 5개 상품을 삭제했다면 2페이지로 넘어갔을때 고객은 5개의 상품을 보지 못하게된다!
극단적으로 10억번째 페이지에 있는 값을 찾고 싶다면 OFFSET 또는 skip에 매우 큰 숫자가 들어가게 된다.
즉, 정렬기준(order by)에 대해 해당 row가 몇 번째 순서인지 알지 못하므로 OFFSET 값을 지정하여 쿼리를 한다고 했을 때
지정된 OFFSET까지 모두 만들어 놓은 후 지정된 갯수를 순회하여 자르는 방식이다. 때문에 퍼포먼스는 이에 비례하여 떨어지게 되어 있다.
자세한 내용은 아래 글을 참조하자.
Faster Pagination in Mysql – Why Order By With Limit and Offset is Slow?
Offset 기반 페이지네이션은 우리가 원하는 데이터가 ‘몇 번째’에 있다는 데에 집중하고 있다면,
커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는데에 집중한다.
즉, n개의 row를 skip 한 다음 10개 주세요가 아니라, 이 row 다음꺼부터 10개 주세요를 요청한다!
위의 오프셋 기반 페이지네이션의 1번 예제에서 ID 역순 정렬되어 있는 post 테이블에서 첫번째 리스트를 가져오는 방법은 동일하다.
1000개의 데이터가 Id 기준 DESC정렬되어 있다고 가정하고 다음 리스트를 가져오려면 #996 아래에 있어야할 데이터 #995 ~ #991을 가져오면 된다. 아래 쿼리로 쓰면 된다.
SELECT id, title
FROM `post`
WHERE id < (Id Cursor : 996)
ORDER BY id DESC
LIMIT 5
위처럼 중복되지 않는 고유의 id를 정렬하여 커서로 페이지네이션 한다면 문제 될것이 없지만
중복될 수 있는 생성 날짜등으로 정렬하여 커서로 사용시 문제가 생길수 있다.
동일하게 유저에게 마지막으로 응답했던 데이터중에 마지막 데이터가 Cursor가 된다.
마지막 data의 Create Date와 Id가 각각의 cursor가 된다.
여기서 주의깊에 봐야할 OR절은 만약 정확히 같은 시간에 여러개의 게시글이 생겼을 때 1개의 게시글을
제외하고 나머지는 무시될 수 있기 때문에 OR절을 활용하여 표현한다.
아래 쿼리 대로 생성시간 6보다 작거나(생성시간이 6이랑 같고 id가 5보다 큰) 값만 검색하게 된다.
만약 OR절이 없었다면 id가 11, 12인 값은 무시하고 넘어 갔을 것이다. 그렇기 때문에 이 경우는 OR절이
꼭 필요하다.
/*MySQL*/
SELECT ID, CONTENT, CREATION_DATE
FROM POST
WHERE CREATION_DATE < (CreationDate Cursor)
OR (CREATION_DATE = (CreationDate Cursor) AND id>(Id Cursor))
ORDER BY CREATION_DATE DESC, ID ASC
LIMIT 5
/*MONGO*/
db.posts
.find(
'CREATION_DATE' : {'$lt' : (CreationDate Cursor)}
{ $or : [ { $and : [{'CREATION_DATE' : (CreationDate Cursor)},{'id' : {'$gt' : (Id Cursor)}}]}]}
).limit(5)
SELECT ID, CONTENT ,CREATION_DATE,
CONCAT(LPAD(CREATION_DATE, 3, '0'), LPAD(ID, 3, '0')) as `CURSOR`
FROM `POST`
ORDER BY CREATION_DATE DESC, ID DESC
LIMIT 5;
CONCAT : 문자열을 합치는 쿼리문
LPAD : 지정된 길이로 해당 문자열을 채움(왼쪽)
첫번째 페이지 이후
SELECT ID, CONTENT ,CREATION_DATE,
CONCAT(LPAD(CREATION_DATE, 3, '0'), LPAD(ID, 3, '0')) as CURSOR
FROM POST
WHERE CURSOR < CONCAT(LPAD(CREATION_DATE_CURSOR, 3, '0'), LPAD(ID_CURSOR, 3, '0'))
ORDER BY CREATION_DATE DESC, ID DESC
LIMIT 5;
전통적인 페이지네이션 방식은 오랫동안 네트워크 낭비를 줄여주는 기능을 담당해왔다. 하지만 실시간성을 띄는 SNS 서비스의 등장으로 리소스가 자주 수정/생성/삭제되는 상황이 늘어나자 중복 전송의 가능성이 켜졌다. 트위터는 커서 기반 페이지네이션을 통해 실시간으로 변화하는 타임라인 상에서 리소스의 중복 전송을 효과적으로 막아냈다.
Reference
https://ksshlee.github.io/spring/spring%20boot/backend/pagination/