- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 · KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d (문서가 인용한 IT 7종이 그 커밋에 있다) - 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를 통과하지 못해 Case 로 빼지 않았다 - 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision - 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은 절반이 있는 문장은 평가만 뺐다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9.7 KiB
id, kind, slug, title, topic, topicName, topicName, project, status, studio, assets, evidence, sourceRevision, source
| id | kind | slug | title | topic | topicName | topicName | project | status | studio | assets | evidence | sourceRevision | source | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| e6715e81-6dbd-4287-8e19-946c334f38fb | CASE | visibility-or-breaks-keyset-index | Visibility OR이 Keyset Index를 깨뜨린 문제 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/e6715e81-6dbd-4287-8e19-946c334f38fb/edit |
|
|
n+1liner-lab@2026-08 |
|
id: e6715e81-6dbd-4287-8e19-946c334f38fb kind: CASE slug: visibility-or-breaks-keyset-index title: Visibility OR이 Keyset Index를 깨뜨린 문제 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/e6715e81-6dbd-4287-8e19-946c334f38fb/edit" assets:
- key: keyset-vs-offset file: ../../../final/assets/tech-log-studio/keyset-vs-offset.svg evidence:
- ../../../final/evidence/raw/explain/l15-keyset-no-index.txt
- ../../../final/evidence/raw/explain/l15-offset-deep-page.txt sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#14-2
- final/document.md#14-3
- final/document.md#14-5
Visibility OR이 Keyset Index를 깨뜨린 문제
keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 가시성 세 분기를 OR로 얹자 플래너가 그 인덱스를 쓰지 못하고 BitmapOr로 떨어졌고, 사라졌던 Sort 노드가 다시 나타났다.
관계
- Feed Visibility Query Pattern 가시성 세 분기를 단일 OR, UNION 분해, 사전계산으로 처리했을 때 실행계획이 어떻게 갈리는지 정리했다.
- Keyset Pagination 설계 기준 커서와 정렬키 인덱스의 컬럼과 방향을 어떻게 맞추는지 앞 단계에서 정했다.
- feed_visible을 Production CQRS로 승격할 것인가 사전계산을 프로덕션에서 상시 유지할지는 아직 답하지 않았다.
문제
keyset 페이징으로 페이지 깊이 문제를 풀었다. 깊은 페이지에서 OFFSET은 2,000행을 훑고 20행만 남겼지만 keyset은 Index Only Scan으로 20행만 읽었고 buffers는 1이었다.
실서비스 피드는 조회하는 사용자마다 보이는 아이템이 다르다. 이 가시성은 public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기로 갈린다. 세 분기를 keyset과 같은 쿼리에 얹었다.
결론
가시성 조건을 추가하자 플래너는 정렬키 인덱스 ix_feed_items_keyset을 더 이상 쓰지 못했다. 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스가 주던 정렬 순서를 잃어 Sort 노드가 다시 나타났다.
하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 visibility 값 비교, 멘션 테이블 조인, user_id 비교로 조건이 다르기 때문에 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.
멘션 조건의 EXISTS는 hashed SubPlan으로 처리됐다. keyset 문법만으로 비용이 줄어든 것이 아니라 커서와 같은 순서의 정렬키 인덱스가 있어야 했는데, 가시성 OR이 그 전제를 깨뜨렸다.
검증 환경
Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers)
쿼리 : 통합 테스트 안의 native SQL 정렬키 인덱스 : 테스트 안에서 CREATE / DROP ix_feed_items_keyset : feed_items (first_highlighted_at DESC, id DESC)
기존 인덱스의 한계 ix_feed_items_visibility_sort : (visibility, first_highlighted_at DESC, id) 선두 컬럼이 visibility라 가시성 필터가 없는 keyset 쿼리에는 맞지 않는다
시드 : seed 2,000
재현 조건
-
정렬키 전용 인덱스를 만들고 keyset 쿼리가 Index Only Scan으로 20행만 읽는 것을 확인한다.
-
같은 keyset 쿼리에 가시성 세 분기를 OR로 추가한다. public, MENTIONED이면서 EXISTS로 멘션 확인, PRIVATE이면서 user_id가 조회자.
-
EXPLAIN (ANALYZE, BUFFERS)로 정렬키 인덱스 사용 여부와 Sort 노드 유무를 확인한다.
-
깊은 페이지에서 OFFSET과 keyset의 훑은 행을 대조한다. 훑은 행은 Limit 하위의 actual rows로 계산한다.
본문
가시성을 얹기 전 — 커서 이후 20행만 읽기
:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉜다. 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉜다. 페이지가 깊어질수록 위쪽 빗금 구간만 길어진다." caption=" " zoom="true" :::
seed 2,000에서 100번째 페이지(offset 1980)를 요청하고 두 방식의 실행계획을 대조했다.
Limit (rows=20) Buffers: shared hit=1 read=2
-> Index Only Scan using ix_feed_items_keyset on feed_items fi (actual rows=20)
Index Cond: (ROW(first_highlighted_at, id) < ROW('...'::timestamptz, '...'::uuid))
Heap Fetches: 20
| 변형 | 플랜 | 훑은 행 | buffers | exec |
|---|---|---|---|---|
| OFFSET | Limit←Sort←Seq Scan(2,000) |
2,000 | 141 | 0.996 ms |
| keyset + 인덱스 | Limit←Index Only Scan |
20 | 1 | 0.076 ms |
| keyset − 인덱스 | Limit←Sort←Seq Scan(filter) |
20 | 141 | 0.373 ms |
깊은 페이지에서 OFFSET은 Seq Scan과 Sort로 2,000행을 만든 뒤 20행만 남겼다. 같은 페이지를 커서로 요청한 keyset은 정렬키 인덱스에서 20행만 읽었고 buffers는 1이었다.
커서로 넘긴 두 번째 페이지는 OFFSET의 두 번째 페이지와 같은 20개 식별자를 같은 순서로 반환했다.
인덱스를 제거하자 keyset도 Seq Scan으로 떨어졌다. 결과는 그대로 20행이었지만 테이블 2,000행을 읽고 1,980행을 필터로 걸러냈기 때문에 buffers는 OFFSET과 같은 141이 됐다. 비용을 줄인 것은 keyset 문법이 아니라 커서와 같은 순서의 정렬키 인덱스였다.
다만 이 정렬키 인덱스는 통합 테스트 안에서 만들고 지웠다. 운영 스키마에는 아직 들어가 있지 않다.
가시성 OR을 얹은 뒤의 실행계획
같은 커서에 가시성 세 분기를 얹고 실행계획을 다시 받았다. 플래너는 조건이 여러 분기로 갈릴 때 분기마다 인덱스에서 비트맵을 만들어 BitmapOr로 합치고 Bitmap Heap Scan으로 테이블을 한 번 훑는다. 비트맵에는 행의 순서가 남지 않는다.
Limit -> Sort (Sort Key: first_highlighted_at DESC, id DESC) ← Sort 재등장
-> Bitmap Heap Scan on feed_items
-> BitmapOr
-> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='PUBLIC' AND ROW(...) < cursor)
-> Bitmap Index Scan on ix_feed_items_visibility_sort (visibility='MENTIONED' AND ...)
-> BitmapAnd (visibility='PRIVATE' ∩ user_id = me)
SubPlan 1 -> Index Only Scan on uq_feed_item_mentions (EXISTS)
정렬키 인덱스 ix_feed_items_keyset이 계획에서 사라지고 ix_feed_items_visibility_sort를 분기별로 스캔한 BitmapOr가 대신 들어왔다. 세 분기를 비트맵으로 합치면서 인덱스가 주던 정렬 순서를 잃었기 때문에, 상위 20행을 만드는 Sort가 다시 붙었다.
왜 하나의 쿼리로는 순서를 유지하지 못하나
SELECT fi.id, fi.first_highlighted_at FROM feed_items fi
WHERE (fi.visibility='PUBLIC'
OR (fi.visibility='MENTIONED' AND EXISTS(SELECT 1 FROM feed_item_mentions m
WHERE m.feed_item_id=fi.id AND m.mentioned_user_id=:me))
OR (fi.visibility='PRIVATE' AND fi.user_id=:me))
ORDER BY fi.first_highlighted_at DESC, fi.id DESC LIMIT 20;
세 분기는 visibility 값 비교, 멘션 테이블 조인, user_id 비교로 조건이 서로 다르다. 하나의 인덱스는 선두 컬럼 순서 하나만 주므로 셋을 동시에 만족하는 접근 경로가 없다.
keyset이 Index Only Scan으로 성립하려면 정렬키·커서·인덱스의 컬럼과 방향이 모두 일치해야 한다. first_highlighted_at이 같은 행까지 안정적으로 넘기려면 커서에 id를 함께 넣어야 하고, 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다. 가시성 OR은 이 일치를 깨뜨린다.
다음 단계 — UNION 분해와 사전계산
세 분기를 UNION ALL로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 처음에는 단일 OR이 Seq Scan으로 떨어지고 UNION 분해가 읽는 블록을 줄일 것으로 예상했다.
실제 단일 OR은 Seq Scan이 아니라 BitmapOr와 top-N Sort, hashed SubPlan을 썼다. UNION 분해는 요청할 때마다 세 분기를 각각 스캔했고, 그래서 읽은 블록은 단일 OR보다 많았다. 읽은 블록을 가장 줄인 방식은 UNION 분해가 아니라 사전계산이었다. 사전계산은 조회를 단일 Index Only Scan으로 바꾸는 대신, 피드·멘션·가시성이 바뀔 때마다 읽기 모델을 갱신해야 하고 조회 사용자 수만큼 저장 공간도 늘어난다.
로컬 미리보기
본문 「가시성을 얹기 전 — 커서 이후 20행만 읽기」 아래 :::evidence key="keyset-vs-offset" 자리에 들어갈 그림이다.