개요
이 글에서 API 서버에서 페이징을 구현할 때 자주 사용되는 오프셋 기반, 커서 기반 페이징 방식에 대해서 알아보고 방식별 장단점과 구현시 고려해야할 점들에 대해서 정리해보겠습니다.
Pagination이란?
페이징(Pagination)은 대량의 데이터를 한 번에 보여주지 않고, 나누어 보여주는 방법입니다. 한정된 양의 데이터만 조회하여 서버 부하를 감소시킬 수 있고, 서버와 클라이언트간 통신에 있어 네트워크 트래픽을 줄일 수 있습니다.
페이징 기법들
Offset-based Pagination (오프셋 기반 페이징)
오프셋(Offset)은 데이터베이스가 레코드를 선택하기 전에 건너뛰어야 하는 레코드 수입니다.
클라이언트가 PR의 두번째 페이지를 보고 싶다면, Offset을 10(하나의 페이지의 크기가 10인 경우)으로 줘서 첫번째 페이지에 해당하는 데이터들을 건너뛰고 원하는 데이터를 얻어올 수 있습니다. offset은 아래와 같이 계산됩니다.
- offset = pageSize * (pageNum-1)

클라이언트와 서버간 통신 예시

클라이언트에서 page_size와 page_number를 통해 데이터를 요청하면 서버에서 offset을 계산해서 DB에 쿼리를 날리고 있습니다.
오프셋 기반 페이징의 특징

오프셋 기반 페이징은 구현하는데 있어 고려해야할 사항이 적어 다른 페이징기법에 비해 단순한 방식입니다. 커서 기반 페이징 방식에서는 할 수 없는 특정 페이지로 한번에 이동이 가능하며 총 페이지 수를 보여줄 수 있다는 장점이 있습니다.
하지만, 아래와 같은 경우 에는 조회 결과 일관성이 보장되지 못하는 단점이 있습니다.
- 데이터 조회 도중 이전 페이지의 항목이 삭제되면 데이터가 Skip
- 데이터 조회 도중 이전 페이지의 항목이 추가되면 데이터가 중복
구현시 주의해야할 Offset
오프셋 기반 페이징 방식을 구현할 때 유의해야할 점은 'OFFSET' 키워드에 있습니다.

DB에 쿼리 요청시 위와 같이 'OFFSET' 키워드를 사용하는 경우, DB 서버는 레코드를 건너뛸때 내부적으로 연산을 하게 됩니다. 모든 레코드를 메모리로 가져온 이후 20개의 레코드를 필터링하고 난 이후 10개의 레코드를 보여주게 됩니다. (postgreSQL의 경우)
The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient.

따라서, 위 쿼리의 시간 복잡도는 O(N), O(offset+limit)로 데이터의 수가 증가함에 따라 시간복잡도가 선형적으로 증가함을 알 수 있습니다.
Cursor-based Pagination (커서 기반 페이지네이션)

커서 기반 페이징은 일반적으로 cursor라는 특별한 키를 사용하여, 클라이언트에게 다음 페이지를 요청할 때 사용할 수 있는 값을 제공합니다.
{
"data": [
... Endpoint data is here
],
"paging": {
"cursors": { // 기준이 되는 커서
"after": "MTAxNTExOTQ1MjAwNzI5NDE=",
"before": "NDMyNzQyODI3OTQw"
},
"previous": "https://graph.facebook.com/{your-user-id}/albums?limit=25&before=NDMyNzQyODI3OTQw"
"next": "https://graph.facebook.com/{your-user-id}/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
}
}
클라이언트와 서버간 통신 예시

커서 기반 페이징의 특징
오프셋 기반 페이징과는 다르게 커서를 기준으로 데이터를 조회하기 때문에 일관성 있는 조회 결과를 제공해줄 수 있습니다. 또한, 커서는 고유하며 인덱싱을 해주게 되는데 이로 인해 대규모 데이터 조회에 적합합니다. 원치 않는 데이터를 건너 뛰고 필요한 레코드부터 조회가 가능해지게 됩니다.
OFFSET 대안 솔루션과 그로 인한 side effect

오프셋 기반 페이징에서 문제가 되었던 'OFFSET' 키워드는 인덱스(PK인 id값)를 활용하여 DB 서버가 모든 레코드를 조회하지 않게 수정 되었습니다.
하지만, 위 쿼리를 사용하려면 이전 페이지의 마지막 데이터의 id값을 알아야 하며, 이는 특정 페이지로 한번에 이동이 가능할 수 없음을 의미합니다.
Cursor에 적합한 데이터
커서 기반 페이징에서 커서의 조건은 아래와 같습니다.
고유하며 순차적이여야 한다.
가장 쉽게 생각해볼 수 있는 커서는 테이블의 숫자 타입의 Primary Key입니다. 사용할 수 만 있다면 가장 확실한 커서이지만 항상 연속적으로 증가하는 PK만을 활용할 수는 없습니다.
비연속적인 PK(UUID와 같은)를 커서로 사용하는 경우 데이터의 추가 및 삭제에 따른 조회 결과의 일관성이 보장되지 않기 때문에, 문제가 될 수 있습니다. ( 보다 자세한 내용은 링크를 참고해주세요. )
이외에도 커서로 활용 할 수 있는 여러 데이터 후보군이 있습니다.
- Time Stamp
- 레코드 값을 인코딩하여 표현
추가적인 고려사항
대개의 경우 페이징과 함께 필터링이 적용될 수 있습니다. 이러한 경우에 필터링 조건에 인덱싱 처리가 되어있는지 확인 해주어야합니다. 뿐만 아니라 커서를 활용하여 다음 페이지 결과를 찾는 쿼리를 작성하는데에도 주의를 기울여야 합니다.
1. ID와 같은 고유 식별자와 생년월일 필드를 조합하여 현재 페이지를 조회하려고 합니다.
SELECT * FROM user ORDER BY birthday, id LIMIT 5;

2. 마지막 레코드를 기준으로 "커서"를 계산합니다.

위의 경우 다음 페이지를 위한 커서는 "id=31766&birthday=1973-02-04"가 됩니다.
3. 결과와 인코딩된 커서를 사용자에게 다시 전송합니다.
4. 다음 요청에서는 커서를 추출하고, 커서에서 상태를 분해한 다음 쿼리를 구성합니다.
SELECT * FROM user WHERE id > 31766 and birthday >= '1973-02-04' ORDER BY birthday, id
LIMIT 5;
ID와 다르게 생년월일은 동일한 레코드가 추가적으로 존재할 수 있으니 gt이 아닌 ge를 사용해야합니다.
하지만, 위 쿼리 대로라면 생년월일은 이후이지만 id 값은 31766보다 작은 레코드를 조회할 수 없습니다.
알맞게 쿼리를 수정하면 아래와 같습니다.
SELECT * FROM user WHERE birthday > '1973-02-04' or (birthday >= '1973-02-04' and id > 31766) ORDER BY birthday, id
LIMIT 5;
위에서 설명한 2가지 방식 이외에도, 데이터의 타임스탬프나 날짜를 기준으로 정렬하여 페이징처리를 하는 Time-based Pagination (시간 기반 페이지네이션)과 HTTP Link 헤더를 활용한 방식등 다른 방식들도 많이 존재합니다.
Reference
https://betterprogramming.pub/understanding-the-offset-and-cursor-pagination-8ddc54d10d98
https://medium.com/swlh/how-to-implement-cursor-pagination-like-a-pro-513140b65f32
'개발' 카테고리의 다른 글
| Redis의 자료구조 (List, Sorted Set) (0) | 2025.01.19 |
|---|---|
| SOLID Principle 알아보기 - 두번째 (0) | 2025.01.05 |
| JVM 두번째 글 - Heap 메모리 구조에 대하여 (0) | 2024.12.22 |
| SOLID Principle 알아보기 - 첫번째 (0) | 2024.11.24 |
| JVM 첫번째 글 - JVM의 구조와 동작에 대하여 (0) | 2024.10.27 |