--- id: 635fcedd-d402-4297-bcf3-9fcdf4200d28 kind: REFERENCE slug: feed-visibility-query-pattern title: Feed Visibility Query Pattern topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/635fcedd-d402-4297-bcf3-9fcdf4200d28/edit" sourceRevision: n+1liner-lab@2026-08 source: - final/document.md#15-1 - final/document.md#15-4 --- # Feed Visibility Query Pattern 조회 사용자에 따라 보이는 항목이 갈리는 피드는 공개, 멘션, 비공개 세 분기로 조건이 나뉜다. 세 분기를 하나의 OR로 묶는 방식, UNION으로 나누는 방식, 사용자별로 미리 펼쳐 두는 방식은 같은 항목 집합을 돌려주지만 실행계획과 읽는 블록 수가 다르다. ## 관계 - **Visibility OR이 Keyset Index를 깨뜨린 문제** keyset 쿼리에 이 세 분기를 얹자 정렬키 인덱스가 계획에서 빠지는 것을 확인한 기록이다. - **feed_visible을 Production CQRS로 승격할 것인가** 사전계산 테이블을 실험 밖에서 상시 유지할지는 이 질문이 다룬다. - **Keyset Pagination 설계 기준** 가시성 조건을 얹기 전에 커서와 정렬키 인덱스를 어떻게 맞출지 정한 기준이다. ## 목적 세 분기가 보는 것이 서로 다르다. 공개 분기는 visibility 값만 비교하고, 멘션 분기는 feed_item_mentions를 뒤져 조회자가 멘션됐는지 확인하며, 비공개 분기는 항목의 user_id가 조회자인지 본다. 하나의 인덱스는 선두 컬럼 순서 하나만 주기 때문에 셋을 한 접근 경로로 만족시킬 수 없다. 여기에 정렬과 페이징까지 요구하면 세 분기를 어떻게 합치느냐에 따라 Sort 노드가 붙기도 하고 사라지기도 한다. ## 규칙 ### 1. 단일 OR로 묶으면 Sort 노드가 다시 붙는다 세 분기를 하나의 조건으로 묶으면 플래너가 분기마다 인덱스를 스캔해 BitmapOr로 합치고 Bitmap Heap Scan으로 테이블을 훑는다. 이 과정에서 인덱스가 주던 순서가 사라지기 때문에 상위 20행을 만드는 top-N Sort가 다시 붙는다. 멘션 여부를 EXISTS로 확인하는 분기는 이 계획에서 hashed SubPlan으로 처리됐다. ### 2. UNION으로 나누면 전체 재정렬이 사라진다 세 분기를 각각 정렬이 보장되는 쿼리로 만들어 Merge Append로 병합하면 전체를 다시 정렬하지 않는다. 분기마다 자기 조건에 맞는 인덱스를 쓰고 멘션 조건도 SubPlan 대신 Hash Join으로 바뀐다. 대신 요청할 때마다 세 분기를 각각 스캔한다. 분기가 셋이면 스캔도 세 번 나가므로 읽는 블록이 단일 OR보다 많아질 수 있다. ### 3. 미리 펼쳐 두면 조회가 커버링 인덱스 하나로 끝난다 사용자별로 볼 수 있는 항목을 미리 펼쳐 두면 조회는 커버링 인덱스 하나를 Index Only Scan으로 읽는다. OR도 조인도 정렬도 없다. 대신 원본이 바뀔 때마다 이 투영을 갱신해야 하고 저장 공간은 조회 사용자 수만큼 늘어난다. ### 4. 세 방식이 같은 결과를 내는지 먼저 확인한다 실행계획을 비교하기 전에 같은 조회 사용자에게 같은 항목 집합이 나오는지 대조한다. 답이 다르면 비용 비교가 의미 없다. 피드에서는 FeedVisibilityIT의 l16ThreeApproachesReturnSameVisibleSet이 seed 2,000에서 조회 사용자 user008에게 세 방식이 같은 20개 항목을 돌려주는지 먼저 맞춰 봤다. ### 5. 분기마다 선택도가 다르니 인덱스도 분기별로 본다 비공개 분기는 조회자 자신의 항목만 남기므로 부분 인덱스가 맞고, 공개 분기는 선택도가 높아 UNION 안에서도 비트맵으로 훑은 뒤 top-N Sort로 상위를 냈다. 멘션 테이블은 조회 방향에 맞는 컬럼 순서가 필요하다. V7이 만든 feed_item_mentions 인덱스는 선두 컬럼이 feed_item_id라 항목에 달린 멘션을 찾는 데는 맞지만 조회자를 멘션한 항목을 찾는 쿼리에는 맞지 않아, (mentioned_user_id, feed_item_id) 순서인 ix_mentions_user를 따로 만들어 비교했다. ### 6. buffers 하나만 보고 방식을 고르지 않는다 단일 OR의 buffers는 122였는데 UNION 분해는 200으로 오히려 컸다. 분기를 나눈 만큼 스캔이 늘었기 때문이다. 훑는 후보로 보면 단일 OR의 1,500이 UNION 분해에서 60 이하로 줄었고, 사전계산은 20행만 읽으면서 buffers도 1이었다. buffers 하나로 줄 세우면 UNION 분해가 뒤로 밀리지만 훑은 행수와 재정렬 유무로는 앞선다. 무엇을 줄이려는지 정한 뒤 훑는 후보 수, 정렬 유무, buffers를 함께 본다. 피드에서는 셋을 그렇게 견주고 보통은 UNION 분해를, 고트래픽 읽기가 극단이면 사전계산을 고르는 것으로 정리했다. ### 7. 사전계산을 상시 유지하면 읽기 모델 설계로 넘어간다 미리 계산한 테이블을 실험 밖에서 계속 유지하려면 피드·멘션·가시성이 바뀔 때마다 투영에 반영해야 한다. ## 적용 조건 - 조회 사용자에 따라 보이는 항목이 달라지는 목록을 만들 때 - 가시성 조건과 정렬·페이징을 함께 요구할 때 - 고트래픽 읽기에서 조회 비용을 줄여야 할 때 ## 예외 - 분기가 하나뿐이면 단일 조건이 가장 단순하다. 이 기준은 분기가 셋 이상일 때 적용한다. - 쓰기가 잦고 읽기가 드물면 사전계산의 갱신 비용이 이득을 넘는다. - 조회 사용자 수가 매우 많으면 사용자별 투영의 저장 공간을 먼저 계산한다. ## 예시 - 단일 OR : 분기별 스캔을 BitmapOr로 합침, top-N Sort 재등장, 멘션 조건은 hashed SubPlan, buffers 122 - UNION 분해 : 분기별 정렬 스트림을 Merge Append로 병합, 전체 재정렬 없음, 멘션 조건은 Hash Join, buffers 200 - 사전계산 : 커버링 인덱스 하나를 Index Only Scan, OR·조인·정렬 없음, buffers 1 - 훑는 후보 : 단일 OR 1,500, UNION 분해 60 이하, 사전계산 20 - 갱신 비용 : 사전계산만 있음 - 저장 공간 : 사전계산은 조회 사용자 수만큼 늘어남