Article
Offset 페이지네이션의 성능 문제와 커서 기반 솔루션
페이지네이션: 개발자가 가장 자주 사용하는 기능
게시판, 검색 결과, 뉴스피드 등 대량의 데이터를 화면에 표시할 때 페이지네이션이 필수입니다. 하지만 가장 흔한 구현 방식인 Offset 기반 페이지네이션에는 심각한 문제가 있습니다.
Offset 기반 페이지네이션의 문제점
문제 1: 데이터가 많아질수록 쿼리가 느려진다
동작 원리
SELECT * FROM posts LIMIT 10 OFFSET 1000000;
Offset은 건너뛸 행의 개수를 명시합니다. 위 쿼리는:
- 데이터베이스가 처음부터 시작해서
- 100만 개의 행을 모두 읽은 다음
- 마지막 10개만 반환합니다
실제 성능 비교
| Offset 크기 | 조회 시간 |
|---|---|
| 10 | 1ms |
| 1,000 | 2ms |
| 10,000 | 5ms |
| 100,000 | 50ms |
| 1,000,000 | 500ms+ |
데이터가 적을 때는 문제가 없지만, 뒤로 갈수록 점점 느려집니다.
문제 2: 데이터 변경 중의 중복 표시
실제 시나리오
게시판에서 사용자가 1페이지를 보고 있는 상황입니다:
시간 1: 사용자가 1페이지(게시물 1-10번) 조회 완료
시간 2: 다른 사용자가 새로운 게시물 1개 추가 → 기존 게시물들의 ID가 한 칸씩 뒤로 밀림
시간 3: 사용자가 2페이지를 조회
이 경우:
1페이지에서 본 게시물: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
2페이지에서 본 게시물: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 (같은 게시물!)
같은 게시물을 두 번 보는 문제가 발생합니다.
더 심각한 경우
삭제가 일어나면:
1페이지에서 본 게시물: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
2페이지로 가려는데, 그사이 게시물 5번이 삭제됨
2페이지에서 본 게시물: 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 (1번과 5번이 중복!)
데이터의 일관성이 깨집니다.
해결 방안: 커서 기반 페이지네이션
동작 원리
커서(Cursor)는 마지막으로 조회한 데이터의 고유 식별자입니다.
SELECT * FROM posts WHERE id > ? LIMIT 10;
예를 들어:
- 첫 페이지:
SELECT * FROM posts LIMIT 10→ ID 1-10 반환 - 두 번째 페이지:
SELECT * FROM posts WHERE id > 10 LIMIT 10→ ID 11-20 반환 - 세 번째 페이지:
SELECT * FROM posts WHERE id > 20 LIMIT 10→ ID 21-30 반환
구현 예제
PHP/Laravel 구현
// 첫 페이지
$posts = Post::orderBy('id')->limit(10)->get();
$nextCursor = $posts->last()->id;
// 다음 페이지 (nextCursor를 클라이언트가 전달)
$posts = Post::where('id', '>', $cursor)->orderBy('id')->limit(10)->get();
JSON 응답 형식
{
"data": [
{"id": 11, "title": "게시물 11"},
{"id": 12, "title": "게시물 12"}
],
"nextCursor": 20,
"hasMore": true
}
클라이언트는 nextCursor를 저장했다가 다음 요청 시 전달합니다.
커서 기반 방식의 장점
1. 성능: 데이터 크기와 관계없이 일정
- 100만 번째 페이지도 10ms 이내로 완료
- 데이터가 증가해도 성능 저하 없음
- 인덱스를 효과적으로 활용 (ID는 일반적으로 Primary Key)
2. 일관성: 데이터 변경에 강함
1페이지: ID > 0 LIMIT 10 → ID 1-10 반환, cursor=10
2페이지: ID > 10 LIMIT 10 → ID 11-20 반환 (그사이 새 데이터 추가해도 OK)
새로운 데이터가 추가되거나 삭제되어도 중복이나 누락이 없습니다.
3. 확장성: API와 모바일에 최적
// 모바일 앱에서
fetch('/api/posts?cursor=' + lastPostId)
.then(response => response.json())
.then(data => {
setPosts([...posts, ...data.data]);
setLastCursor(data.nextCursor);
});
“무한 스크롤” 구현이 자연스럽습니다.
커서 기반과 Offset의 비교표
| 특성 | Offset | 커서 기반 |
|---|---|---|
| 구현 복잡도 | 낮음 | 중간 |
| 성능 | 데이터가 많으면 저하 | 일정 |
| 데이터 일관성 | 나쁨 | 우수 |
| 뒤로가기 | 가능 | 불가능* |
| 적합한 서비스 | 관리자 패널 | 서비스 UI |
*커서 기반에서 이전 페이지로 가려면 별도 구현 필요
언제 무엇을 사용할 것인가?
Offset 사용 권장
- 관리자 패널: 데이터 크기 관리 가능
- 검색 결과 필터링: 페이지 번호 명시 필요
- 작은 데이터셋: 1000개 이하
커서 기반 권장
- 게시판/뉴스피드: 실시간 데이터 추가/삭제 빈번
- 모바일 앱: 무한 스크롤 구현
- 대규모 데이터셋: 수백만 개 이상의 행
- API 서비스: 일관성 중요
결론
Offset 기반 페이지네이션은 간단하지만, 성능과 데이터 일관성이 중요한 서비스에서는 반드시 커서 기반으로 전환해야 합니다.
특히 다음과 같은 서비스에서는 필수입니다:
- 게시판 및 커뮤니티: 게시물이 계속 추가되고 삭제됨
- 소셜 네트워크: 피드 업데이트가 실시간
- 검색 엔진: 검색 결과 중복 표시는 사용자 신뢰 저하
- 모바일 앱: 무한 스크롤로 UX 개선
초기 개발할 때는 Offset으로 시작하되, 서비스가 성장하면서 데이터가 증가할 것을 예상한다면 처음부터 커서 기반 아키텍처를 고려하는 것이 좋습니다.
이미 Offset으로 구축한 서비스라면, 성능이나 일관성 문제가 발생하기 전에 미리 커서 기반으로 마이그레이션 하는 것을 추천합니다.
댓글