---
title: "검색엔진 후보를 DB 로 재인가할 때, 엔진 문서의 인덱스 키로 펜스 치기"
description: "검색엔진이 돌려준 후보를 DB 에서 다시 권한 검사하는데 인덱스 선두 키가 없어 느렸습니다. 엔진 문서가 이미 가진 키 쌍을 서브쿼리 출처로 끼워 비용을 채널 크기에서 후보 수로 바꾼 과정입니다."
date: 2026-11-02
updated: 2026-11-02T12:00:56.825Z
tags: [postgresql, query-plan, elasticsearch, performance]
canonical: https://blog.wooncloud.com/posts/es-candidates-db-reauth-index-fence
---

파일 검색은 검색엔진(ES)에 맡기고, 돌려받은 후보 수십 건을 DB 에서 다시 권한 검사하는 구조입니다. 엔진 결과는 노출 후보일 뿐이고 최종 판정은 DB 가 합니다.

![채널 조건만 걸면 채널 크기만큼 읽지만, 후보 쌍으로 먼저 펜스 치면 후보 수만큼만 읽는다.](/images/posts/es-candidates-db-reauth-index-fence/7f364e1b-98ec-45a0-b3d3-b1137e8b1661.webp)

후보의 키는 `file_id` 인데, 1억 행이 넘는 링크 테이블에는 `file_id` 로 시작하는 인덱스가 없었습니다. 인덱스는 `(channel_id, post_id, ...)` 뿐입니다.

## 인덱스가 있는데 왜 느릴까요?

`file_id = ANY(...)` 만으로 거르면 플래너가 테이블 전체를 순차 스캔합니다. 수십 초가 걸려 타임아웃이 났습니다.

그래서 후보가 나온 채널을 `channel_id = ANY(...)` 로 같이 걸었습니다. 인덱스는 타는데, 그 채널의 링크 행을 **전 기간 전부** heap 에서 읽고 나서 `file_id` 를 사후 필터합니다. 비용이 결과 수가 아니라 채널 크기에 비례합니다. 큰 채널이 섞이면 콜드 캐시에서 수십 초가 됩니다.

게다가 멤버십 `EXISTS` 를 플래너가 1행으로 잘못 추정해서, 사용자가 속한 채널 수천 개를 구동 측으로 펼치고 채널마다 인덱스를 탐색했습니다.

![채널 조건만 걸면 채널 크기만큼 행을 읽고 사후 필터하지만, 후보의 채널과 글 쌍으로 먼저 행을 뽑으면 후보 수만큼만 읽는다.](/images/posts/es-candidates-db-reauth-index-fence/d4065ce8-db61-488a-890c-fe78ad8775d4.svg)

## 엔진 문서가 이미 키를 들고 있습니다

그래서 엔진 문서가 인덱스 선두 키를 이미 갖고 있는지 확인했습니다. 색인기가 링크 행 하나당 문서 하나를 만들고, `channel_id` 와 `post_id` 를 그 행에서 그대로 싣고 있었습니다. 그렇다면 후보의 `(channel_id, post_id)` 쌍이 곧 인덱스 선두 두 컬럼입니다.

그 쌍으로 먼저 행을 뽑는 서브쿼리를 출처(FROM)로 끼웁니다. 추가 조건(AND)으로 넣으면 안 됩니다. 여기에 `OFFSET 0`(또는 `DISTINCT ON`)을 붙여 서브쿼리가 풀리지 않게 하면, 바깥 권한 조인보다 먼저 작은 집합이 확정됩니다.

권한 게이트(멤버십, 가시성)는 바깥에 그대로 둡니다. 바뀐 건 출처뿐이라 인가가 약해지지 않습니다.

```sql
SELECT l.*
  FROM (SELECT DISTINCT ON (x.link_id) x.*
          FROM (
            SELECT e.*
              FROM unnest($1::bigint[], $2::bigint[]) AS p(chan, post)
              JOIN file_links e ON e.channel_id = p.chan AND e.post_id = p.post
             WHERE e.file_id = ANY($3::bigint[])
            UNION ALL   -- 엔진이 안 준 행 중 꼭 필요한 것(예: 즐겨찾기)은 PK 로
            SELECT e.*
              FROM file_links e
             WHERE e.link_id = ANY($4::bigint[]) AND e.file_id = ANY($3::bigint[])
          ) x
         ORDER BY x.link_id
         OFFSET 0) l
 WHERE EXISTS (SELECT 1 FROM memberships m
                WHERE m.channel_id = l.channel_id AND m.user_id = $5);
```

같은 결과를 내는 요청 기준으로 이렇게 줄었습니다.

| 항목 | 이전 | 이후 |
|---|---|---|
| heap 방문 행 | 8,895 | 44 |
| 공유 버퍼 | 5,365 | 1,413 |

채널 크기에 비례하던 비용이 후보 수에 비례하게 바뀐 겁니다. 이미 있는 인덱스를 타게 하는 쪽으로 풀었습니다.

## 쓰기 전에 확인할 것

- 전제는 **엔진 문서와 테이블 행이 같은 키를 쓴다**는 점입니다. 색인기 코드로 직접 확인해야 합니다. 엔진이 첨부당 문서 하나로 접거나 다른 번호를 주면, 펜스는 그 행을 조용히 놓칩니다. 확인되지 않은 백엔드는 이전 방식으로 내려 둡니다.
- 쌍 중 하나라도 키가 없으면 펜스를 쓰지 않습니다. 쌍만 골라내고 진행하면 그 후보가 결과에서 사라집니다.
- 의미가 조금 달라집니다. 이전 방식은 같은 채널의 아무 글에 걸린 같은 파일을 대체 후보로 삼았지만, 펜스는 엔진이 돌려준 글만 봅니다. 차이는 엔진 결과 창 밖으로 밀린 글에서만 납니다.

## 참고

- [PostgreSQL 서브쿼리 (평탄화와 OFFSET 0)](https://www.postgresql.org/docs/current/queries-subqueries.html)
- [PostgreSQL 다중 컬럼 인덱스](https://www.postgresql.org/docs/current/indexes-multicolumn.html)
- [PostgreSQL EXPLAIN 사용법](https://www.postgresql.org/docs/current/using-explain.html)
