외주 작업 중 피드 게시글을 보여주기 위한 무한 스크롤을 구현할 일이 생겼다.
처음에는 부트캠프 때 배운 오프셋 페이지네이션을 이용해서 작업하려고 했는데, 그건 블로그와 같은 서비스에 적합하고 SNS와 같은 무한 스크롤에는 비효율 적이라는 것을 보았다.
이것저것 무한 스크롤을 구현하는 방법을 찾아본 결과 커서 기반 페이지네이션이라는 것을 알게됐고, 그 내용을 기록한다.
구현은 간단한데 치명적인 단점이 있다. 만약에 사용자가 데이터를 조회하는 중에 데이터가 추가, 삭제되면 데이터를 조회할 때 중복으로 조회될 수 있다.
예로 들어서 현재 페이지(1번)에서 10개의 데이터를 조회했는데, 다음 페이지(2번)를 조회하기 전에 새로 10개의 데이터가 추가됐다면?
다음 페이지(2번)를 조회할 때 현재 페이지(1번)와 똑같은 데이터가 날라올 것이다. 왜냐면 이미 DB에 10개의 데이터가 추가됐기 때문에, 현재 페이지의 정보들이 다음 페이지(2번)으로 되는 것이다.
데이터 중복 문제 이외에도 테이블의 row수가 많아질수록 RDBMS의 Offset 쿼리 속도가 급격히 떨어지는 문제가 있다.
DB의 문법을 이용해서 데이터를 구분한다. MySQL같은 경우 Offest 문법을 통해 페이지네이션을 할 수 있다.
SELECT id FROM Users
ORDER BY age
LIMIT 10
OFFSET (page * 10); # 페이지당 10개의 데이터
결론은! 오프셋 기반 페이지네이션은 row수가 적은 데이터나, 데이터의 추가, 삭제가 많지 않은 서비스에서 간단하게 구현하기 좋은 페이지네이션이다.
오프셋 기반 페이지네이션은 n개의 row를 스킵하고 데이터를 요청하는 방법에 반해, 커서 기반 페이지네이션은 이 row 다음 n개의 row를 요청하는 방식이다.
무슨 차이냐면 오프셋 기반은 100개의 데이터를 구한 뒤에 10개씩 쪼개서 응답받는 반면, 커서 기반은 10개의 데이터를 바로 구하고 그 다음 10개의 데이터를 계속 요청해서 응답받는 방식이다.
커서 기반 페이지네이션을 구현하기 위해서는 고유값을 가진 특정 컬럼을 이용해야 한다. 문제는 그 컬럼이 내가 원하는 조건에 맞게 정렬이 되는지를 봐야 한다.
id | name | age | updatedAt |
---|---|---|---|
100 | user100 | 26 | 2022-10-08 |
99 | user99 | 32 | 2022-10-18 |
98 | user98 | 21 | 2022-09-22 |
97 | user97 | 25 | 2022-10-18 |
96 | user96 | 29 | 2022-10-19 |
위와 같은 테이블에서 사용자를 불러오려고 한다. 총 100개의 데이터가 있는데 편의상 5개만 작성했다.
고유값인 컬럼으로 커서를 설정하려면 현재 테이블에서는 id 컬럼이 가장 적절해 보인다.
그럼 간단하게 아래와 같이 구현해볼 수 있다.
SELECT name FROM Users
WHERE id < ?
ORDER BY id DESC
LIMIT 5;
이렇게 하면 id 값에 따라서 10개씩 데이터를 받을 수 있고, 10개 이후에 데이터에 대해서도 쉽게 구할 수 있다.
문제는 이렇게 구현했을 때 원하는 조건으로 데이터가 불러와지지 않을 때가 있다. 예로 들어서 사용자가 정보가 업데이트 된 순서대로 데이터를 받고 싶다면 id 컬럼 만으로 데이터를 불러올 수 없다.
두 개 이상의 컬럼을 합치거나, 임의의 컬럼을 만들어서 커서를 지정하면 위 문제를 해결할 수 있다!
많이 쓰이는 방법으로 CONCAT과 LPAD 문법을 사용해서 하나의 문자열 컬럼을 만들고 이를 커서로 지정하는 것이다.
- CONCAT : 문자열을 합치는 MySQL 내장함수
- LPAD : 문자열이나 숫자를 지정된 길이의 문자열로 채움
처음에 클라이언트에서 데이터를 조회하면 커스텀 커서를 포함한 값을 응답으로 준다.
SELECT id, name, age, updatedAt,
CONCAT(LPAD(DATE_FORMAT(updatedAt, '%Y%m%d'), 10, '0'),
LPAD(id, 5, '0') AS cursor
FROM Users
ORDER BY id DESC
LIMIT 5;
위의 쿼리를 실행하면 아래와 같은 결과를 얻을 수 있다.
id | name | age | updatedAt | cursor |
---|---|---|---|---|
100 | user100 | 26 | 2022-10-08 | 202210080000100 |
99 | user99 | 32 | 2022-10-18 | 202210180000099 |
98 | user98 | 21 | 2022-09-22 | 202209220000098 |
97 | user97 | 25 | 2022-10-18 | 202210180000097 |
96 | user96 | 29 | 2022-10-19 | 202210190000096 |
이렇게 생성된 커스텀 커서를 통해 다음에 조회할 데이터에 대한 조건으로 넣어주면 된다.
SELECT id, name, age, updatedAt,
CONCAT(LPAD(DATE_FORMAT(updatedAt, '%Y%m%d'), 10, '0'),
LPAD(id, 5, '0') AS cursor
FROM Users
WHERE CONCAT(LPAD(DATE_FORMAT(updatedAt, '%Y%m%d'), 10, '0'),
LPAD(id, 5, '0') < ? # 커서값을 입력으로 받음
ORDER BY id DESC
LIMIT 5;
그럼 커스텀 커서 이후의 값으로 조회가 되면서 클라이언트는 현재 받은 데이터 이후의 데이터에 대해 조회할 수 있다.
커스텀 커서는 무한 스크로를 구현하는데 주로 쓰이는데, 꼭 무한 스크롤이 아니어도 데이터의 입출력이 많은 게시판에서도 사용할 수 있다.
오프셋 기반의 페이지네이션이 단순하고 비효율적이기 때문에 그보다 조금 복잡한 서비스를 생각한다면 커서 기반 페이지네이션을 고려해보자!
작업을 하다가 한 가지 이상한(?) 점이 있었다. 문자열로 커스텀 커서를 만들어 조회하다 보니, 문자열이 어느정도 일치하는 경우에는 커서를 건너뛰는 경우가 발생했다.
1. 20221008000099 (1번째 데이터)
2. 20221019000098 (2번째 데이터)
3. 20221019000097 (3번째 데이터)
4. 20221019000096 (4번째 데이터)
5. 20221017000095 (5번째 데이터)
무슨 말이냐면 위의 예시와 같이 날짜 + 고유 컬럼으로 커스텀 커서를 만든 경우, 날짜가 같은 커서(2번이나 3번 커서)로 다음 데이터를 조회하면 같은 날짜의 데이터를 모두 건너뛰고 5번째 데이터만 조회된다.
심각한 이슈사항은 아니지만 문자열을 비교하는데 어떤 메커니즘으로 비교가 되는건지 다시 살펴봐야겠다.