페이지네이션 가이드라인
GitLab v19.4요약
이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하는 현재 기능의 개요와 모범 사례를 제공합니다. 페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다.
이 문서는 GitLab에서, 특히 PostgreSQL에서 데이터를 페이지네이션하는 현재 기능의 개요와 모범 사례를 제공합니다.
페이지네이션이 필요한 이유#
페이지네이션은 하나의 웹 요청에서 너무 많은 데이터를 로드하지 않기 위한 일반적인 기법입니다. 보통 레코드 목록을 렌더링할 때 발생합니다. 대표적인 시나리오는 UI에서 부모-자식 관계(has many)를 시각화하는 경우입니다.
예시: 프로젝트 내 이슈 목록 표시
프로젝트 내 이슈 수가 늘어날수록 목록이 길어집니다. 목록을 렌더링하기 위해 백엔드는 다음을 수행합니다.
- 데이터베이스에서 레코드를 로드합니다. 보통 특정 순서로 로드합니다.
- Ruby에서 레코드를 직렬화합니다. Ruby(ActiveRecord) 객체를 빌드한 다음 JSON 또는 HTML 문자열을 빌드합니다.
- 브라우저로 응답을 반환합니다.
- 브라우저가 콘텐츠를 렌더링합니다.
콘텐츠 렌더링 방식은 두 가지입니다.
- HTML: 백엔드가 렌더링을 처리합니다(HAML 템플릿).
- JSON: 클라이언트(클라이언트 측 JavaScript)가 페이로드를 HTML로 변환합니다.
긴 목록을 렌더링하면 프론트엔드와 백엔드 성능 모두에 큰 영향을 줄 수 있습니다.
- 데이터베이스가 디스크에서 많은 데이터를 읽습니다.
- 쿼리 결과(레코드)는 결국 Ruby 객체로 변환되어 메모리 할당이 늘어납니다.
- 응답이 크면 사용자 브라우저로 전송하는 데 시간이 더 걸립니다.
- 긴 목록을 렌더링하면 브라우저가 멈출 수 있습니다(나쁜 사용자 경험).
페이지네이션을 사용하면 데이터가 동일한 크기의 조각(페이지)으로 나뉩니다. 첫 방문 시 사용자는 제한된 수의 항목(페이지 크기)만 받습니다. 사용자는 앞으로 페이지를 이동해 더 많은 항목을 볼 수 있으며, 이때 새로운 HTTP 요청과 새로운 데이터베이스 쿼리가 발생합니다.

페이지네이션 일반 가이드라인#
올바른 접근 방식 선택#
페이지네이션, 필터링, 데이터 조회는 데이터베이스가 처리하도록 맡깁니다. 백엔드의 인메모리 페이지네이션(Kaminari의 paginate_array)이나 프론트엔드(JavaScript) 페이지네이션은 레코드가 수백 개 수준이면 동작할 수 있습니다. 애플리케이션 제한을 정의하지 않으면 상황이 빠르게 통제를 벗어납니다.
복잡성 줄이기#
페이지에 레코드를 나열할 때 추가 필터와 여러 정렬 옵션을 함께 제공하는 경우가 많습니다. 이는 백엔드 측 복잡성을 크게 높입니다.
MVC 버전에서는 다음을 고려합니다.
- 정렬 옵션 수를 최소한으로 줄입니다.
- 필터 수(드롭다운 목록, 검색창)를 최소한으로 줄입니다.
정렬과 페이지네이션을 효율적으로 만들려면 각 정렬 옵션마다 최소 두 개의 데이터베이스 인덱스(오름차순, 내림차순)가 필요합니다. 필터 옵션(상태별 또는 작성자별)을 추가하면 좋은 성능을 유지하기 위해 더 많은 인덱스가 필요할 수 있습니다. 인덱스는 공짜가 아니며 UPDATE 쿼리 시간에 큰 영향을 줄 수 있습니다.
모든 필터와 정렬 조합을 성능적으로 만족시킬 수는 없으므로, 사용 패턴을 기준으로 성능을 최적화합니다.
확장 대비#
오프셋 기반 페이지네이션은 레코드를 페이지네이션하는 가장 쉬운 방법입니다. 다만 대용량 데이터베이스 테이블에서는 확장성이 떨어집니다. 장기적인 해결책으로는 keyset 페이지네이션을 권장합니다. 오프셋과 keyset 페이지네이션 간 전환은 대체로 간단하며, 다음 조건을 충족하면 최종 사용자에게 영향을 주지 않고 수행할 수 있습니다.
- 전체 개수 표시를 피하고 제한 개수를 사용합니다.
- 예시: 최대 1001개의 레코드를 세고, 개수가 1001이면 UI에 1000+를 표시하고 그렇지 않으면 실제 숫자를 표시합니다.
- 자세한 내용은 배지 카운터 접근 방식을 참고합니다.
- 페이지 번호를 쓰지 않고 다음·이전 페이지 버튼을 사용합니다.
- Keyset 페이지네이션은 페이지 번호를 지원하지 않습니다.
- API에서는 다음 페이지 URL을 "직접" 조립하지 않도록 안내합니다.
- 백엔드가 다음·이전 페이지 URL을 제공하는
Link헤더 사용을 권장합니다. - 이 방식이면 이전 버전 호환성을 깨지 않고 URL 구조를 바꿀 수 있습니다.
- 백엔드가 다음·이전 페이지 URL을 제공하는
무한 스크롤은 페이지 번호가 노출되지 않으므로 사용자 경험에 영향을 주지 않고 keyset 페이지네이션을 사용할 수 있습니다.
페이지네이션 옵션#
오프셋 페이지네이션#
목록을 페이지네이션하는 가장 일반적인 방법은 오프셋 기반 페이지네이션입니다(UI 및 REST API). 이 방식은 ActiveRecord 쿼리에 페이지네이션을 구현하는 편리한 헬퍼 메서드를 제공하는 Kaminari Ruby gem을 기반으로 합니다.
오프셋 기반 페이지네이션은 LIMIT 및 OFFSET SQL 절을 활용해 테이블에서 특정 구간을 가져옵니다.
프로젝트 내 이슈의 두 번째 페이지를 조회할 때의 데이터베이스 쿼리 예시입니다.
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
- 테이블 행 위로 가상의 포인터를 옮겨 20개 행을 건너뜁니다.
- 다음 20개 행을 가져옵니다.
이 쿼리가 기본 키(id)로 행을 정렬한다는 점에 유의합니다. 데이터를 페이지네이션할 때 순서 지정은 매우 중요합니다. 순서가 없으면 반환되는 행이 비결정적이어서 최종 사용자에게 혼란을 줍니다.
페이지 번호#
페이지네이션 바 예시입니다.

Kaminari gem은 UI에 페이지 번호와 함께 선택적으로 다음·이전·첫 페이지·마지막 페이지 버튼 단축 링크를 포함한 페이지네이션 바를 렌더링합니다. 이 버튼을 렌더링하려면 Kaminari가 행 수를 알아야 하므로 count 쿼리가 실행됩니다.
SELECT COUNT(*) FROM issues WHERE project_id = 1
성능#
인덱스 커버리지#
좋은 성능을 얻으려면 ORDER BY 절이 인덱스로 커버되어야 합니다.
다음 인덱스가 있다고 가정합니다.
CREATE INDEX index_on_issues_project_id ON issues (project_id);
첫 번째 페이지를 요청해 봅니다.
SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20;
Rails에서 동일한 쿼리를 만들 수 있습니다.
Issue.where(project_id: 1).page(1).per(20)
이 SQL 쿼리는 데이터베이스에서 최대 20개 행을 반환합니다. 다만 데이터베이스가 결과를 만들기 위해 디스크에서 20개 행만 읽는다는 뜻은 아닙니다.
실제로는 다음과 같이 동작합니다.
- 데이터베이스는 테이블 통계와 사용 가능한 인덱스를 기반으로 가장 효율적인 실행 계획을 세우려 합니다.
- 플래너는
project_id칼럼을 커버하는 인덱스가 있음을 알고 있습니다. - 데이터베이스는
project_id인덱스를 사용해 모든 행을 읽습니다. - 이 시점의 행은 정렬되어 있지 않으므로 데이터베이스가 행을 정렬합니다.
- 데이터베이스가 처음 20개 행을 반환합니다.
프로젝트에 10,000개 행이 있으면 데이터베이스는 10,000개 행을 읽어 메모리(또는 디스크)에서 정렬합니다. 이는 장기적으로 확장성이 떨어집니다.
이를 해결하려면 다음 인덱스가 필요합니다.
CREATE INDEX index_on_issues_project_id ON issues (project_id, id);
id 칼럼을 인덱스에 포함하면 앞의 쿼리는 최대 20개 행만 읽습니다. 프로젝트 내 이슈 수와 무관하게 쿼리 성능이 유지됩니다. 따라서 이 변경으로 최초 페이지 로드(사용자가 이슈 페이지를 여는 시점)도 개선됩니다.
여기서는 b-tree 데이터베이스 인덱스의 정렬 속성을 활용합니다. 인덱스의 값이 정렬되어 있으므로 20개 행을 읽는 데 추가 정렬이 필요하지 않습니다.
알려진 문제#
대용량 데이터셋에서의 COUNT(*)#
Kaminari는 기본적으로 페이지 링크를 렌더링할 페이지 수를 파악하기 위해 count 쿼리를 실행합니다. count 쿼리는 대용량 테이블에서 상당히 비쌀 수 있습니다. 최악의 경우 쿼리가 타임아웃됩니다.
이를 우회하려면 count SQL 쿼리를 호출하지 않고 Kaminari를 실행할 수 있습니다.
Issue.where(project_id: 1).page(1).per(20).without_count
이 경우 count 쿼리는 실행되지 않으며 페이지네이션이 페이지 번호를 렌더링하지 않습니다. 다음·이전 링크만 표시됩니다.
대용량 데이터셋에서의 OFFSET#
대용량 데이터셋을 페이지네이션하면 응답 시간이 점점 느려지는 현상이 나타납니다. 행을 훑으며 N개 행을 건너뛰는 OFFSET 절 때문입니다.
사용자 관점에서는 항상 체감되지는 않습니다. 사용자가 앞으로 페이지를 이동할 때는 이전 행이 데이터베이스 버퍼 캐시에 남아 있을 수 있습니다. 사용자가 그 링크를 다른 사람과 공유하고 몇 분 또는 몇 시간 뒤에 열면 응답 시간이 크게 늘어나거나 타임아웃될 수 있습니다.
큰 페이지 번호를 요청하면 데이터베이스는 PAGE * PAGE_SIZE개 행을 읽어야 합니다. 이 때문에 오프셋 페이지네이션은 대용량 데이터베이스 테이블에 적합하지 않습니다. 다만 최적화 기법을 적용하면 데이터베이스 쿼리의 전반적인 성능을 다소 개선할 수 있습니다.
예시: Admin area에서 사용자 목록 표시
매우 단순한 SQL 쿼리로 사용자 목록을 표시합니다.
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 0
쿼리 실행 계획을 보면 이 쿼리는 효율적이며 데이터베이스가 20개 행만 읽었습니다(rows=20).
Limit (cost=0.43..3.19 rows=20 width=1309) (actual time=0.098..2.093 rows=20 loops=1)
Buffers: shared hit=103
-> Index Scan Backward using users_pkey on users (cost=0.43..X rows=X width=1309) (actual time=0.097..2.087 rows=20 loops=1)
Buffers: shared hit=103
Planning Time: 0.333 ms
Execution Time: 2.145 ms
(6 rows)
실행 계획 읽는 법은 EXPLAIN 계획 이해하기를 참고합니다.
50,000번째 페이지를 방문해 봅니다.
SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 999980;
실행 계획을 보면 데이터베이스가 20개 행을 반환하기 위해 1,000,000개 행을 읽었고 실행 시간이 매우 길었습니다(5.5초).
Limit (cost=137878.89..137881.65 rows=20 width=1309) (actual time=5523.588..5523.667 rows=20 loops=1)
Buffers: shared hit=1007901 read=14774 written=609
I/O Timings: read=420.591 write=57.344
-> Index Scan Backward using users_pkey on users (cost=0.43..X rows=X width=1309) (actual time=0.060..5459.353 rows=1000000 loops=1)
Buffers: shared hit=1007901 read=14774 written=609
I/O Timings: read=420.591 write=57.344
Planning Time: 0.821 ms
Execution Time: 5523.745 ms
(8 rows)
일반적인 사용자는 이런 페이지를 방문하지 않는다고 볼 수도 있습니다. 다만 API 사용자는 매우 높은 페이지 번호까지 이동할 수 있습니다(스크래핑, 데이터 수집).
Keyset 페이지네이션#
Keyset 페이지네이션은 큰 페이지를 요청할 때 이전 행을 "건너뛰는" 데서 오는 성능 문제를 해결합니다. 다만 오프셋 기반 페이지네이션을 그대로 대체하지는 못합니다. API 엔드포인트를 오프셋 기반에서 keyset 기반 페이지네이션으로 옮길 때는 두 방식을 모두 지원해야 합니다. 한쪽 페이지네이션 유형을 완전히 제거하는 것은 브레이킹 체인지입니다.
Keyset 페이지네이션은 GraphQL API와 REST API 양쪽에서 사용됩니다.
다음 issues 테이블을 살펴봅니다.
id |
project_id |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 3 | 2 |
| 4 | 1 |
| 5 | 1 |
| 6 | 2 |
| 7 | 2 |
| 8 | 1 |
| 9 | 1 |
| 10 | 2 |
기본 키(id)로 정렬해 테이블 전체를 페이지네이션해 봅니다. 첫 페이지 쿼리는 오프셋 페이지네이션 쿼리와 동일하며, 단순화를 위해 페이지 크기로 5를 사용합니다.
SELECT "issues".* FROM "issues" ORDER BY "issues"."id" ASC LIMIT 5
OFFSET 절을 추가하지 않았다는 점에 유의합니다.
다음 페이지로 이동하려면 마지막 행에서 ORDER BY 절에 포함된 값을 추출해야 합니다. 이 경우 필요한 값은 id인 5뿐입니다. 이제 다음 페이지 쿼리를 구성합니다.
SELECT "issues".* FROM "issues" WHERE "issues"."id" > 5 ORDER BY "issues"."id" ASC LIMIT 5
쿼리 실행 계획을 보면 이 쿼리는 5개 행만 읽었습니다(오프셋 기반 페이지네이션이라면 10개 행을 읽습니다).
Limit (cost=0.56..2.08 rows=5 width=1301) (actual time=0.093..0.137 rows=5 loops=1)
-> Index Scan using issues_pkey on issues (cost=0.56..X rows=X width=1301) (actual time=0.092..0.136 rows=5 loops=1)
Index Cond: (id > 5)
Planning Time: 7.710 ms
Execution Time: 0.224 ms
(5 rows)
알려진 문제#
페이지 번호 미지원#
오프셋 페이지네이션은 특정 페이지를 간단히 요청할 수 있습니다. URL을 편집해 page= URL 파라미터를 수정하면 됩니다. Keyset 페이지네이션은 페이징 로직이 서로 다른 칼럼에 의존할 수 있으므로 페이지 번호를 제공하지 못합니다.
앞의 예시에서 칼럼은 id이므로 URL에서 다음과 같은 형태를 볼 수 있습니다.
id_after=5
GraphQL에서는 파라미터가 JSON으로 직렬화된 다음 인코딩됩니다.
eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
페이지네이션 파라미터는 사용자에게 노출되므로 어떤 칼럼으로 정렬할지 신중하게 정합니다.
Keyset 페이지네이션은 다음·이전·첫 페이지·마지막 페이지만 제공할 수 있습니다.
복잡성#
단일 칼럼으로 정렬할 때는 쿼리 작성이 매우 쉽습니다. 다만 타이 브레이커나 다중 칼럼 정렬을 쓰면 더 복잡해집니다. 칼럼이 nullable이면 복잡성이 한층 커집니다.
예시: created_at이 nullable인 상태에서 id와 created_at으로 정렬할 때, 두 번째 페이지를 가져오는 쿼리입니다.
SELECT "issues".*
FROM "issues"
WHERE (("issues"."id" > 99
AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" IS NULL))
ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
LIMIT 20
도구#
GitLab 프로젝트에는 범용 keyset 페이지네이션 라이브러리가 있으며, 대부분의 경우 기존 Kaminari 기반 페이지네이션을 손쉽게 대체해 대용량 데이터셋을 다룰 때 성능을 크게 개선합니다.
예시:
# first page
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records
# next page
cursor = paginator.cursor_for_next_page
paginator = Project.order(:created_at, :id).keyset_paginate(cursor: cursor, per_page: 20)
puts paginator.to_a # records
전체적인 개요는 keyset 페이지네이션 가이드 페이지를 참고합니다.
성능#
Keyset 페이지네이션은 앞으로 몇 페이지를 이동했든 일정한 성능을 제공합니다. 이 성능을 얻으려면 오프셋 페이지네이션과 마찬가지로 페이지네이션 쿼리에 ORDER BY 절의 모든 칼럼을 커버하는 인덱스가 필요합니다.
일반 성능 가이드라인#
페이지네이션 일반 성능 가이드라인 페이지를 참고합니다.