범위 쿼리를 이용한 키셋 페이지 매김
학습자는 _id 또는 타임스탬프 필드에 범위 필터를 적용해 커서 기반 페이지 매김을 구축하고, 일관된 O(log n) 페이지 성능을 달성합니다.
범위 쿼리를 이용한 키셋 페이지 매김은(는) CoddyKit의 무료 MongoDB Academy 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 MongoDB Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. MongoDB Academy 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
What Is Keyset Pagination?
Keyset pagination—also called cursor pagination—avoids skip() entirely by using a range query on the sort key. Instead of telling MongoDB 'jump over the first N documents', you tell it 'give me documents where the sort key is greater than the last value I saw'. This is always O(log n) because it uses an index range scan, regardless of how far into the result set you are.
The Core Concept: A Range Filter as a Cursor
After fetching the first page, you remember the sort key value of the last document returned. For the next page, you filter documents where the sort key is strictly greater than (or less than, for descending) that remembered value. This filter combined with an index gives MongoDB an exact starting point—no skipping needed.
// First page — no cursor needed
const page1 = await db.collection('posts')
.find({ isPublished: true })
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.toArray();
// Remember the last document's sort keys
const lastCreatedAt = page1[page1.length - 1].createdAt;
const lastId = page1[page1.length - 1]._id;Fetching the Next Page With a Range Query
Use the remembered sort key values in a $lt (or $gt for ascending) condition for the next page query. No skip() is needed—the range condition navigates the index directly to the right starting position. MongoDB fetches limit documents starting from that point.
// Next page: posts older than the last one seen
// Descending by createdAt means 'older' = $lt
const page2 = await db.collection('posts')
.find({
isPublished: true,
$or: [
{ createdAt: { $lt: lastCreatedAt } },
{ createdAt: lastCreatedAt, _id: { $lt: lastId } } // tiebreaker
]
})
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.toArray();Why Include _id as a Tiebreaker?
Multiple documents may have the same createdAt timestamp (e.g., many items inserted in the same second). Without a tiebreaker, the range boundary is ambiguous and you might skip or duplicate documents at the boundary. Adding _id as a secondary sort field and including it in the range condition makes the cursor uniquely deterministic—no document can have the same (createdAt, _id) pair.
// Compound index to support the keyset query
db.posts.createIndex({ createdAt: -1, _id: -1 });
// This index covers both the sort and the range filterKeyset Pagination on _id Alone
If you sort purely by _id (default insertion order), keyset pagination is the simplest possible form. _id is always unique and already indexed. Each page returns documents where _id is greater than the last seen value. This works perfectly for feed-style queries where insertion order is the natural sort.
// First page
const page1 = await db.collection('events')
.find({})
.sort({ _id: 1 })
.limit(50)
.toArray();
const lastId = page1[page1.length - 1]._id;
// Next page — range filter on _id
const page2 = await db.collection('events')
.find({ _id: { $gt: lastId } })
.sort({ _id: 1 })
.limit(50)
.toArray();Encoding the Cursor for API Responses
API clients should not need to know the internal cursor format. Encode the cursor as a Base64 or JWT string that the server can decode on the next request. This hides the implementation detail (whether you use createdAt, _id, or a composite key) from clients and lets you change the cursor format without breaking the API contract.
// Encode cursor
function encodeCursor(doc) {
return Buffer.from(JSON.stringify({ createdAt: doc.createdAt, _id: doc._id })).toString('base64');
}
// Decode cursor
function decodeCursor(token) {
return JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
}
// API response
const nextCursor = page.length === PAGE_SIZE ? encodeCursor(page[page.length - 1]) : null;
res.json({ data: page, nextCursor });Keyset Pagination in an Express Handler
A complete keyset pagination handler decodes the incoming cursor (if provided), builds the range filter, runs the query, encodes the next cursor, and returns the response. If there is no next cursor to return (the page is smaller than the page size), the client knows it has reached the last page.
async function listPosts(req, res) {
const limit = 20;
let filter = { isPublished: true };
if (req.query.cursor) {
const { createdAt, _id } = decodeCursor(req.query.cursor);
filter['$or'] = [
{ createdAt: { $lt: new Date(createdAt) } },
{ createdAt: new Date(createdAt), _id: { $lt: _id } }
];
}
const posts = await Post.find(filter).sort({ createdAt: -1, _id: -1 }).limit(limit).lean();
const nextCursor = posts.length === limit ? encodeCursor(posts[posts.length - 1]) : null;
res.json({ data: posts, nextCursor });
}Keyset vs Offset: Performance Comparison
Imagine a collection with 1,000,000 posts. Offset pagination to page 1000 (20 items/page) executes skip(19980)—MongoDB walks 19,980 index entries. Keyset pagination uses a range filter: { createdAt: { $lt: someDate } }—MongoDB does a binary search on the index to find the starting point and scans exactly 20 entries. The difference at scale: milliseconds vs seconds.
Limitations of Keyset Pagination
Keyset pagination has two notable limitations: (1) you cannot jump to an arbitrary page number—you can only go forward or backward one page at a time; (2) the sort field must be part of the cursor, so sorting by non-unique, non-indexed fields requires careful tiebreaker selection. These trade-offs make keyset pagination unsuitable for applications that require page-number navigation, but it is the correct choice for infinite scroll and API cursor patterns.
Bidirectional Keyset Pagination
To support both 'next page' and 'previous page' navigation, store both the cursor for the first document and the cursor for the last document on each page. Use $gt with the first document's cursor to go backward. Reverse the sort direction for the backward query, then re-reverse the results before returning them.
// Previous page — documents newer than the first item on the current page
const prevPage = await db.collection('posts')
.find({
isPublished: true,
createdAt: { $gt: firstDocCreatedAt }
})
.sort({ createdAt: 1, _id: 1 }) // reverse sort for previous page
.limit(20)
.toArray();
prevPage.reverse(); // flip back to descending display orderIndex Design for Keyset Pagination
The compound index for a keyset pagination query should include: filter fields first (equality conditions), then the sort fields. For example, if you filter by isPublished and sort by createdAt DESC, _id DESC, the ideal index is { isPublished: 1, createdAt: -1, _id: -1 }. This index covers the equality filter and the range sort without any in-memory operations.
// Ideal covering index for keyset pagination on posts
db.posts.createIndex({ isPublished: 1, createdAt: -1, _id: -1 });
// Verify with explain — expect IXSCAN, no SORT stage
db.posts.find({ isPublished: true, createdAt: { $lt: new Date() } })
.sort({ createdAt: -1, _id: -1 })
.limit(20)
.explain('executionStats');Quick Check
Test your understanding of MongoDB & NoSQL Databases concepts from this lesson.
Lesson Recap
In this lesson you learned: keyset pagination uses a range filter on the last seen sort key instead of skip(), this achieves O(log n) performance regardless of page depth, and including _id as a tiebreaker prevents duplicate or missing documents at sort boundaries. Next up we practice combining sort, skip, limit, and projections into a complete query chain.
자주 묻는 질문
“범위 쿼리를 이용한 키셋 페이지 매김” 강의는 무료인가요?
네 — “범위 쿼리를 이용한 키셋 페이지 매김” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 MongoDB Academy 강의 전체를 잠금 해제할 수 있습니다. MongoDB Academy 강의에는 총 4개의 강의가 포함되어 있습니다.
“범위 쿼리를 이용한 키셋 페이지 매김”에서 뭘 배우나요?
학습자는 _id 또는 타임스탬프 필드에 범위 필터를 적용해 커서 기반 페이지 매김을 구축하고, 일관된 O(log n) 페이지 성능을 달성합니다. 브라우저에서 직접 실행하는 실습 코드로 MongoDB Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
MongoDB Academy을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 MongoDB Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.
“범위 쿼리를 이용한 키셋 페이지 매김” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 MongoDB Academy 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 MongoDB Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- sort()와 여러 키로 정렬하기
- Skip과 Limit: 오프셋 페이지 매김
- 범위 쿼리를 이용한 키셋 페이지 매김
- 정렬, Skip, Limit, 프로젝션 조합하기