- 계약 채택 — 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>
5.5 KiB
id, kind, slug, title, topic, topicName, topicName, project, status, studio, questionStatus, sourceRevision, source
| id | kind | slug | title | topic | topicName | topicName | project | status | studio | questionStatus | sourceRevision | source | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| b099ca65-bf9f-4d61-814c-74722453fa3c | QUESTION | nullable-first-highlighted-at | Highlight 없는 FeedItem을 허용할 것인가 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit | OPEN | n+1liner-lab@2026-08 |
|
id: b099ca65-bf9f-4d61-814c-74722453fa3c kind: QUESTION slug: nullable-first-highlighted-at title: Highlight 없는 FeedItem을 허용할 것인가 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/b099ca65-bf9f-4d61-814c-74722453fa3c/edit" questionStatus: OPEN sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#3-1
- final/document.md#14-4
Highlight 없는 FeedItem을 허용할 것인가
정렬키 first_highlighted_at은 지금 null을 허용한다. 하이라이트가 하나도 없는 FeedItem이 스키마 위에서는 존재할 수 있다는 뜻이다. FeedSeedFixture.seed(N)이 하이라이트가 만든 FeedItem만 넣다 보니 지금까지 쓴 데이터셋에는 이 컬럼이 빈 행이 없었다. 허용할지 말지를 정하기 전에는 정렬에서 그 행을 어디에 둘지도, 커서 비교식에 무엇을 담을지도 정할 수 없다.
관계
- Keyset Pagination 설계 기준 정렬키에 null이 섞이면 여기서 세운 커서 설계가 그대로 성립하지 않는다.
- Visibility OR이 Keyset Index를 깨뜨린 문제 거기서 플래너가 쓰지 못한 인덱스가 이 질문의 정렬키 인덱스와 같은 ix_feed_items_keyset이다.
사실
- first_highlighted_at은 timestamptz이고 null을 허용한다. NOT NULL 제약이 붙어 있지 않다.
- FeedSeedFixture.seed(N)은 하이라이트가 만든 FeedItem만 생성하므로 이 컬럼을 항상 채운다. 그래서 지금까지의 측정에서는 null이 나타나지 않았다.
- FeedItem은 (user, page) 조합당 하나이고 UNIQUE(user_id, page_id) 제약이 있다.
- keyset 커서는 (first_highlighted_at, id)를 비교식에 담는다. 시각이 같은 행도 안정적으로 넘기려고 id까지 함께 넣었다.
- 정렬키 전용 인덱스 ix_feed_items_keyset은 (first_highlighted_at DESC, id DESC)로 만들었다.
가정
- 하이라이트가 하나도 없는 FeedItem이 실제로 만들어지는 경로가 도메인에 있을 수 있다.
- null이 섞이면 커서 비교식이 경계에서 행을 빠뜨리거나 중복시킬 수 있다.
- 정렬 위치를 정하지 않으면 페이지를 넘길 때 순서가 흔들릴 수 있다.
미지수
- 하이라이트 없는 FeedItem을 만드는 경로가 도메인에 있는가. 있다면 FeedItem을 만드는 코드 중 어느 것인가.
- 스키마를 NOT NULL로 좁히는가, null을 허용하고 정렬 위치를 정하는가.
- null을 허용한다면 정렬에서 맨 앞과 맨 뒤 중 어디에 두는가. 그 순서를 (first_highlighted_at DESC, id DESC) 인덱스가 그대로 주는가.
- 커서에 담긴 시각이 null인 페이지를 넘길 때 (first_highlighted_at, id) 비교식에 무엇을 넣는가.
- 부분 인덱스 조건을 쓰면 null 행이 인덱스에서 빠지는데, 그 행은 어느 쿼리로 읽는가.
- FeedItem을 만든 시각과 첫 하이라이트가 생긴 시각이 다를 수 있는가.
제약
- 커서 비교식과 정렬키 인덱스 정의가 이 결정에 달려 있어서, keyset 페이징을 프로덕션에 반영하기 전에 정해야 한다.
- 스키마를 NOT NULL로 좁히려면 기존 데이터에 null이 하나도 없어야 하므로, 마이그레이션을 돌리기 전에 세어 봐야 한다.
- 지금 데이터셋에는 null 행이 없어 이 경계가 재현되지 않는다. null이 섞인 데이터셋을 따로 만들어야 확인할 수 있다. 시더가 이 컬럼의 값을 시간에 흩어 놓은 것은 시간순 페이징과 정렬 인덱스를 실험할 바탕을 마련하려는 구성이다. 값이 비는 행을 만드는 것은 그 구성에 들어 있지 않다.
선택지
1. NOT NULL로 좁힌다
FeedItem이 항상 하이라이트와 함께 만들어진다면 정렬키에 NOT NULL을 건다. 커서는 지금처럼 (first_highlighted_at, id) 두 값만 비교하면 되고 인덱스도 정의를 바꾸지 않는다.
대신 하이라이트 없는 FeedItem을 만드는 경로가 나중에 필요해지면 스키마와 생성 흐름을 다시 바꿔야 한다.
2. null을 허용하고 정렬 위치를 정의한다
정렬에서 null을 맨 앞에 둘지 맨 뒤에 둘지 쿼리에 적고, 인덱스도 같은 순서로 만든다. 커서 비교식은 null 구간을 따로 다룬다.
FeedItem을 만드는 흐름은 하이라이트 없이도 되지만, 커서에 무엇을 담을지와 인덱스를 어느 순서로 만들지를 둘 다 따로 정해야 한다.
3. 부분 인덱스로 null 행을 제외한다
정렬키가 채워진 행만 인덱스에 담는다. 피드 목록에 하이라이트가 있는 항목만 내보낸다고 정하는 것과 같다.
인덱스는 작아지지만 null 행을 읽는 쿼리를 따로 둬야 한다.
다음 검증
-
도메인에서 하이라이트 없는 FeedItem이 생기는 경로가 있는지 확인한다.
-
기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다.
-
null이 섞인 데이터셋을 만들어 지금 커서 비교식이 경계에서 행을 빠뜨리는지 중복시키는지 재현한다.
-
세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 20행·같은 순서인지 FeedKeysetIT.l15KeysetWalkMatchesOffsetPages와 같은 방식으로 대조한다.
-
null 정렬 위치를 정한 경우 ix_feed_items_keyset이 그 순서를 그대로 주는지, 실행계획에 Sort가 다시 나오는지로 확인한다.