--- 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 행을 읽는 쿼리를 따로 둬야 한다. ## 다음 검증 1. 도메인에서 하이라이트 없는 FeedItem이 생기는 경로가 있는지 확인한다. 2. 기존 데이터에 first_highlighted_at이 null인 행이 있는지 센다. 3. null이 섞인 데이터셋을 만들어 지금 커서 비교식이 경계에서 행을 빠뜨리는지 중복시키는지 재현한다. 4. 세 선택지 각각에서 커서로 넘긴 페이지가 OFFSET 페이지와 같은 20행·같은 순서인지 FeedKeysetIT.l15KeysetWalkMatchesOffsetPages와 같은 방식으로 대조한다. 5. null 정렬 위치를 정한 경우 ix_feed_items_keyset이 그 순서를 그대로 주는지, 실행계획에 Sort가 다시 나오는지로 확인한다.