--- id: 06788903-3dfa-4f70-b159-f1224384fd0b kind: REFERENCE slug: keyset-pagination-design title: Keyset Pagination 설계 기준 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/06788903-3dfa-4f70-b159-f1224384fd0b/edit" sourceRevision: n+1liner-lab@2026-08 source: - final/document.md#14-1 - final/document.md#14-4 --- # Keyset Pagination 설계 기준 OFFSET은 정렬 순서에서 앞의 offset행을 만든 뒤 버리기 때문에 페이지가 깊어질수록 훑는 행이 늘어난다. keyset은 이전 페이지의 마지막 행을 커서로 삼아 그 지점 이후만 읽으므로 깊이와 상관없이 페이지 크기만큼만 훑는다. 다만 커서와 같은 순서의 정렬키 인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다. ## 관계 - **Visibility OR이 Keyset Index를 깨뜨린 문제** 가시성 조건을 같은 쿼리에 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌다. - **Feed Pagination은 Keyset을 사용한다** 이 기준을 따라 부모 피드 페이징을 OFFSET에서 커서로 바꿨다. - **PostgreSQL Query Plan 측정 기준** 깊이별로 훑은 행과 읽은 블록 수를 EXPLAIN으로 잴 때 그 기준을 따랐다. ## 목적 무한 스크롤에서는 뒤쪽 페이지일수록 OFFSET이 커진다. 정렬키 인덱스가 있어도 건너뛸 튜플을 훑어야 하고, 깊어지면 아예 Seq Scan과 Sort로 전량을 훑는다. 페이지 깊이와 무관하게 읽는 행수를 페이지 크기에 고정하려면 이전 페이지의 마지막 행을 커서로 넘겨야 한다. ## 규칙 ### 1. 커서에 정렬키를 모두 담는다 정렬이 여러 컬럼이면 커서도 같은 컬럼을 모두 가진다. 앞 컬럼만 커서로 쓰면 값이 같은 행이 있을 때 경계에서 행이 빠지거나 중복될 수 있다. ### 2. tie-break 컬럼을 정렬과 커서에 넣는다 정렬키에 중복이 있을 수 있으면 값이 유일한 컬럼을 마지막 정렬키로 더하고 커서에도 같이 담는다. 피드의 커서는 first_highlighted_at과 id 두 컬럼이었는데, 시각이 같은 행도 안정적으로 넘기려면 tie-break인 id까지 커서에 포함해야 한다. ### 3. 정렬키, 커서, 인덱스의 컬럼과 방향을 일치시킨다 셋 중 하나라도 어긋나면 인덱스가 순서를 주지 못해 Sort 노드가 다시 나타난다. 컬럼 목록뿐 아니라 내림차순·오름차순 방향까지 같아야 한다. ### 4. 정렬키 전용 인덱스를 확인한다 선두 컬럼이 다른 인덱스는 이 쿼리에 쓰이지 않는다. 피드에서도 V6가 만든 ix_feed_items_visibility_sort는 선두 컬럼이 visibility라 가시성 필터가 없는 keyset 쿼리에 맞지 않았고, first_highlighted_at DESC와 id DESC만으로 된 ix_feed_items_keyset을 따로 만들어 썼다. 스키마에 인덱스가 있어도 선두 컬럼이 맞물리지 않으면 정렬에는 쓰이지 못한다. 인덱스가 없으면 keyset도 Seq Scan으로 전량을 훑는다. offset 1980의 깊은 페이지에서 정렬키 인덱스가 있을 때는 Index Only Scan으로 20행만 읽었고 읽은 블록은 1개였지만, 인덱스를 지우자 같은 쿼리가 Seq Scan으로 떨어지면서 141개가 됐다. ### 5. 깊이별로 훑은 행을 측정한다 OFFSET은 offset에 페이지 크기를 더한 만큼 훑고 keyset은 페이지 크기만큼 훑는다. 훑은 행은 Limit 하위의 actual rows로 읽는다. 한 페이지만 재면 차이가 드러나지 않으니 깊이를 바꿔 가며 곡선으로 확인한다. seed 2,000에 페이지 크기 20으로 재면 1페이지에서는 둘 다 20행이다. OFFSET이 훑은 행은 50페이지(offset 980)에서 1,000행, 100페이지(offset 1980)에서 2,000행까지 늘었고 keyset은 세 페이지 모두 20행이었다. ### 6. 결과가 OFFSET과 같은지 검증한다 커서로 넘긴 페이지가 OFFSET으로 넘긴 페이지와 같은 행을 같은 순서로 반환하는지 대조한다. 페이지 크기, 순서, 식별자를 모두 확인한다. 피드에서는 FeedKeysetIT의 l15KeysetWalkMatchesOffsetPages가 두 방식의 두 번째 페이지를 맞춰 보고 같은 20개 id, 같은 순서인지 확인했다. ### 7. 필터를 얹으면 정렬키 인덱스를 못 쓸 수 있다 선택 조건이 여러 분기로 갈리면 플래너가 분기별로 스캔한 뒤 BitmapOr로 합치면서 인덱스의 정렬 순서를 잃고, 사라졌던 Sort 노드가 다시 나타난다. 피드에서도 가시성 3분기를 keyset과 같은 쿼리에 얹자 플래너가 ix_feed_items_keyset을 더 이상 쓰지 못했다. 필터가 있는 keyset은 필터까지 포함한 인덱스를 설계하거나, 분기를 UNION ALL로 나누거나, 뷰어별 결과를 미리 계산하는 방법을 함께 본다. ### 8. 정렬키에 null이 있을 수 있는지 먼저 정한다 정렬키에 null이 들어갈 수 있으면 null을 어디에 정렬할지와 커서에 어떻게 담을지를 정의해야 한다. 이 판단을 미루면 커서 비교식이 경계에서 어긋난다. 피드의 first_highlighted_at은 아직 NOT NULL이 아니다. NOT NULL로 좁힐지 NULLS LAST 같은 null 정렬 위치를 정의할지는 keyset 페이징 단계 이전에 결론 내기로 하고 미결정으로 뒀다. ## 적용 조건 - 무한 스크롤이나 깊은 페이지를 지원할 때 - 정렬 순서가 고정돼 있고 인덱스를 만들 수 있을 때 - 전체 페이지 수가 필요하지 않을 때 ## 예외 - 임의 페이지 점프가 필요하면 커서만으로는 부족하다. OFFSET을 함께 두거나 다른 탐색을 설계한다. - 전체 건수를 화면에 표시해야 하면 count 쿼리를 따로 실행한다. 커서 결과에는 전체 건수가 없다. - 정렬 기준이 자주 바뀌면 기준마다 인덱스가 필요하다. 인덱스 수와 쓰기 비용을 함께 본다. ## 예시 - OFFSET 훑은 행 : offset + 페이지 크기 (offset 1980에서 2,000행) - keyset 훑은 행 : 페이지 크기 (offset 1980에서도 20행) - 커서 : 정렬키와 tie-break 조합 (first_highlighted_at, id) - 전제 인덱스 : 정렬키와 같은 컬럼·같은 방향 (ix_feed_items_keyset) - 인덱스 없는 keyset : Seq Scan으로 전량 훑음, 읽은 블록 141개 (인덱스가 있으면 1개) - 필터 추가 : 분기가 갈리면 BitmapOr로 떨어지고 Sort 재등장