--- 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 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/e6715e81-6dbd-4287-8e19-946c334f38fb/edit" 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 ## 재현 조건 1. 정렬키 전용 인덱스를 만들고 keyset 쿼리가 Index Only Scan으로 20행만 읽는 것을 확인한다. 2. 같은 keyset 쿼리에 가시성 세 분기를 OR로 추가한다. public, MENTIONED이면서 EXISTS로 멘션 확인, PRIVATE이면서 user_id가 조회자. 3. EXPLAIN (ANALYZE, BUFFERS)로 정렬키 인덱스 사용 여부와 Sort 노드 유무를 확인한다. 4. 깊은 페이지에서 OFFSET과 keyset의 훑은 행을 대조한다. 훑은 행은 Limit 하위의 actual rows로 계산한다. ## 본문 ## 가시성을 얹기 전 — 커서 이후 20행만 읽기 seed 2,000에서 100번째 페이지(offset 1980)를 요청하고 두 방식의 실행계획을 대조했다. ```text label="keyset + 정렬키 인덱스, 깊은 페이지" 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`으로 테이블을 한 번 훑는다. 비트맵에는 행의 순서가 남지 않는다. ```text label="keyset + 가시성 OR/EXISTS" 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`가 다시 붙었다. ## 왜 하나의 쿼리로는 순서를 유지하지 못하나 ```sql label="세 분기를 하나의 OR로 묶은 형태" 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`으로 바꾸는 대신, 피드·멘션·가시성이 바뀔 때마다 읽기 모델을 갱신해야 하고 조회 사용자 수만큼 저장 공간도 늘어난다.