Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-visibility-or-breaks-keyset-index.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

7.7 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, assets, evidence
id kind slug title topic topicName project status studio assets evidence
e6715e81-6dbd-4287-8e19-946c334f38fb CASE visibility-or-breaks-keyset-index Visibility OR이 Keyset Index를 깨뜨린 문제 jpa-feed-query-performance JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/e6715e81-6dbd-4287-8e19-946c334f38fb/edit
key file
keyset-vs-offset ../../../final/assets/tech-log-studio/keyset-vs-offset.svg
../../../final/evidence/raw/explain/l15-keyset-no-index.txt
../../../final/evidence/raw/explain/l15-offset-deep-page.txt

Visibility OR이 Keyset Index를 깨뜨린 문제

keyset 페이징은 정렬키 인덱스로 커서 이후 20행만 읽었다. 여기에 공개 범위 세 분기를 OR로 얹자 플래너가 정렬키 인덱스를 쓰지 못하고 BitmapOr로 떨어졌으며, 사라졌던 Sort 노드가 다시 나타났다.

관계

  • Feed Visibility Query Pattern 이 문제를 세 가지 방식으로 비교한 기준이다.
  • Keyset Pagination 설계 기준 이 기록이 이어받은 앞 단계의 기준이다.
  • feed_visible을 Production CQRS로 승격할 것인가 이 문제의 해법 중 하나가 남긴 판단이다.

문제

keyset 페이징으로 페이지 깊이 문제를 풀었다. 깊은 페이지에서 OFFSET은 2,000행을 훑고 20행만 남겼지만 keyset은 Index Only Scan으로 20행만 읽었고 buffers는 1이었다.

실서비스 피드는 조회 사용자에 따라 공개 범위를 판정해야 한다. public 아이템, 내가 멘션된 아이템, 내 비공개 아이템 세 분기다. 이 필터를 keyset과 같은 쿼리에 얹었다.

결론

가시성 조건을 추가하자 정렬키 인덱스를 더 이상 사용하지 못했다. 플래너는 세 분기를 각각 인덱스로 스캔한 뒤 BitmapOr로 합쳤고, 그 과정에서 인덱스의 정렬 순서를 잃어 Sort 노드가 다시 나타났다.

하나의 인덱스는 하나의 선두 컬럼 순서만 준다. 세 분기는 각각 다른 조건이라 하나의 쿼리로 묶으면 각 분기를 따로 스캔한 뒤 합쳐서 다시 정렬해야 한다.

멘션 조건의 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로 계산한다.

본문

가시성을 얹기 전 — 인덱스로 커서 이후만

:::evidence key="keyset-vs-offset" alt="위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림." caption=" " zoom="true" :::

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 LimitSortSeq Scan(2,000) 2,000 141 0.996 ms
keyset + 인덱스 LimitIndex Only Scan 20 1 0.076 ms
keyset 인덱스 LimitSortSeq Scan(filter) 20 141 0.373 ms

인덱스를 제거하면 keyset도 Seq Scan으로 전량을 훑는다. keyset 문법이 아니라 정렬키 인덱스가 비용을 줄인다.

가시성 OR을 얹은 뒤

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가 대신 들어왔다. bitmap으로 합치는 과정에서 인덱스가 주던 정렬 순서를 잃어 상위 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 비교다. 하나의 인덱스는 하나의 선두 컬럼 순서만 주므로 셋을 동시에 만족하는 단일 접근 경로가 없다.

커서에 tie-break가 필요한 이유

first_highlighted_at이 같은 행도 안정적으로 넘기려면 커서에 id까지 포함해야 한다. 시각만 커서로 쓰면 경계에서 행이 빠지거나 중복될 수 있다.

정렬키, 커서, 인덱스의 컬럼과 방향이 모두 일치해야 Index Only Scan이 성립한다. 가시성 OR은 이 일치를 깨뜨린다.

다음 선택

세 분기를 UNION ALL로 나눠 각각 정렬 스트림으로 만든 뒤 병합하는 방식과, 조회 사용자별 가시성을 미리 계산해 두는 방식을 비교했다. 앞의 것은 요청할 때마다 세 분기를 스캔하고, 뒤의 것은 조회를 단일 Index Only Scan으로 바꾸는 대신 읽기 모델 갱신 비용을 만든다.

로컬 미리보기

본문 「가시성을 얹기 전 — 인덱스로 커서 이후만」 아래 :::evidence key="keyset-vs-offset" 자리에 들어갈 그림이다.

위쪽 OFFSET 막대는 정렬 순서상 앞에 있어 만들어졌다가 버려지는 빗금 구간과 실제 반환되는 진한 구간으로 나뉘고, 아래쪽 keyset 막대는 아예 읽지 않는 빈 구간과 커서 표시 뒤의 페이지 구간으로 나뉘어, 페이지가 깊어질수록 위쪽 빗금만 길어지는 것을 보여 주는 대조 그림.