들어가며: 병목은 찾았는데, 왜 인덱스를 안 탈까

지난 글에서 126ms짜리 리뷰 목록 쿼리의 병목이 review 테이블 Seq Scan(순차 스캔)이라는 걸 짚었습니다. 읽어 들인 14,556행 중 81%를 필터로 버리고 있었죠.

그럼 자연스러운 다음 질문. 인덱스를 걸면 되는 거 아냐? 그런데 조건이 멀쩡히 붙어 있는데도 플래너는 인덱스를 안 쓰고 굳이 테이블을 통째로 읽었습니다. 여기서 처음 만난 단어가 SARGable이었고, 이걸 원리까지 파고 나서야 “아, 인덱스는 이래서 못 타는구나”가 몸으로 이해됐습니다.

이 글은 그 동작 원리에 대한 정리입니다. (스키마명은 리뷰 플랫폼 도메인으로 익명화했습니다.)

1. 인덱스는 “찾아가기”, 필터는 “다 읽고 버리기”

지난 글에서 잠깐 봤던 두 조건을 다시 봅니다.

  • Index Cond: — 인덱스를 타고 필요한 행으로 바로 찾아간다. 안 읽어도 될 행은 아예 건드리지 않는다.
  • Filter: — 일단 행을 다 읽은 뒤 조건에 안 맞는 걸 버린다.

내 플랜의 Seq Scan on review에는 Index Cond가 하나도 없고 조건이 전부 Filter였습니다. 즉 “찾아가기”를 못 하고 “다 읽고 버리기”만 하는 상태. 그럼 인덱스가 있어도 왜 못 찾아갈까요? 그걸 알려면 인덱스가 물리적으로 뭔지부터 봐야 합니다.

2. 인덱스의 실체: 미리 계산해서 정렬해둔 (키, 위치)

B-tree 인덱스를 한 줄로 말하면 이렇습니다.

컬럼 값을 미리 뽑아, 정렬해서 저장해둔 (key, ctid) 목록.

여기서 key는 인덱스 건 컬럼의 값이고, ctid는 그 값이 실제 테이블(힙)의 몇 번째 위치에 있는지를 가리키는 포인터입니다. 전화번호부를 떠올리면 정확합니다. 이름이 가나다순으로 미리 정렬돼 있으니까, “김”으로 시작하는 사람을 찾을 때 첫 장부터 넘기지 않고 중간을 펼쳐 이진 탐색으로 바로 좁혀 들어갈 수 있죠.

인덱스 조회(= seek)가 빠른 이유가 여기 있습니다. 키가 이미 정렬된 형태로 저장돼 있어서, 찾는 값이 어디쯤 있는지 이진 탐색이 가능한 겁니다.

바로 이 지점에서 결정적인 통찰이 나옵니다. (이건 제가 학습하다가 스스로 도달했다가 확인받은 부분이라 특히 기억에 남습니다.)

인덱스에 저장된 건 컬럼의 원래 값이다. 그런데 쿼리에서 그 컬럼을 함수나 연산으로 감싸서 지금 계산해버리면, 그 계산 결과값은 인덱스에 저장돼 있지 않다. 저장된 적 없는 값으로는 이진 탐색을 할 수 없다. → 인덱스를 못 탄다.

이게 SARGability의 전부입니다.

3. SARGability — 규칙은 딱 하나

SARGable = Search ARGument able. IBM System R 시절부터 쓰던 말로, “인덱스로 찾아가기(seek)가 가능한 조건인가”를 뜻합니다. 규칙은 놀랍게도 하나뿐입니다.

인덱스 건 컬럼이 조건 한쪽에 “벌거벗은 채” 홀로 있어야 한다. 컬럼을 함수·연산·캐스트로 감싸는 순간 non-SARGable이 된다.

예를 들어보면 감이 옵니다.

-- ✅ SARGable : 컬럼이 그대로 있음 → 인덱스 seek 가능
WHERE created_at >= '2026-07-01'
WHERE lower_email = 'a@b.com'          -- 컬럼 자체가 이미 소문자로 저장돼 있다면

-- ❌ non-SARGable : 컬럼을 감쌈 → 인덱스에 그 계산 결과가 없음 → Seq Scan + Filter
WHERE date(created_at) = '2026-07-01'  -- date()로 감쌈
WHERE lower(email) = 'a@b.com'         -- lower()로 감쌈
WHERE email || '' = 'a@b.com'          -- 연산으로 감쌈
WHERE phone LIKE '%1234'               -- 앞이 와일드카드 → 정렬이 소용없음

포인트는 “함수를 쓰면 안 된다”가 아니라 “컬럼 쪽을 감싸면 안 된다”입니다. 함수가 상수 쪽에 있으면 아무 문제 없습니다.

-- ✅ 컬럼은 그대로, 계산은 상수 쪽에서 → SARGable
WHERE created_at >= '2026-07-01'::timestamptz

4. 내 플랜의 두 범인

이 렌즈로 Seq Scan on reviewFilter를 다시 보니 non-SARGable 범인이 정확히 둘이었습니다.

① 커서 페이지네이션의 md5(...) 비교

Sort Key: (md5(((review_id)::text || '<salt>'::text))), review_id
Filter:   ... ROW(md5(((review_id)::text || '<salt>'::text)), review_id) > ROW(...) ...

커서 키를 md5(review_id || salt)매 조회 시점에 계산해서 비교하고 있었습니다. review_idmd5()와 문자열 결합으로 겹겹이 감싸져 있으니 완벽한 non-SARGable. 이 계산 결과는 어떤 인덱스에도 저장돼 있지 않으니, 전 행을 읽어 하나하나 md5를 돌려보고 비교하는 수밖에 없습니다.

② 카테고리 매칭 jsonb + EXISTS 상관 서브쿼리

Filter: ... AND EXISTS(SubPlan 4)
  SubPlan 4
    ->  Hash Semi Join  (... loops=10676)
          ->  Function Scan on jsonb_array_elements sc_1  (... loops=10676)

jsonb 배열을 펼쳐서(jsonb_array_elements) 매칭하는 상관 서브쿼리입니다. loops=10676바깥 행 하나하나마다 서브쿼리를 다시 실행하고 있죠. 이런 형태도 일반 B-tree로는 못 찾아갑니다.

5. 고치는 두 갈래: 재작성 vs 표현식 인덱스

non-SARGable을 SARGable로 되돌리는 길은 둘입니다.

(a) 조건을 재작성해 컬럼을 벌거벗긴다. 예를 들어 date(created_at) = '2026-07-01'은 범위 조건으로 바꾸면 컬럼이 그대로 노출됩니다.

WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02'

jsonb EXISTS도 같은 발상으로, 상관 서브쿼리 대신 JOIN으로 펼치거나 jsonbGIN 인덱스를 거는 방향으로 접근합니다.

(b) 계산 결과 자체를 미리 저장한다 — 표현식 인덱스. “지금 계산해서 non-SARGable이 문제”라면, 그 계산 결과를 인덱스에 미리 넣어두면 다시 SARGable이 됩니다.

-- md5 커서라면, 그 표현식에 그대로 인덱스를 건다
CREATE INDEX idx_review_cursor
  ON review (md5(review_id::text || '<salt>'), review_id);

이러면 md5(...) 값이 INSERT 시점에 계산돼 정렬 저장되므로, 조회 때는 그 저장된 값으로 이진 탐색이 됩니다.

함정: 표현식 인덱스의 함수는 IMMUTABLE이어야 한다

여기서 걸려 넘어졌습니다. 아무 함수나 표현식 인덱스에 넣을 수 있는 게 아닙니다. 함수가 IMMUTABLE(같은 입력이면 항상 같은 출력)이어야 합니다.

이유는 방금 그림에서 바로 나옵니다. 표현식 인덱스는 f(col)INSERT 시점에 계산해서 키로 저장해둡니다. 그런데 나중에 조회할 때 같은 입력인데도 f다른 값을 뱉는다면, 저장해둔 키가 거짓이 되고 인덱스가 깨지겠죠. 그래서 입력만으로 결과가 확정되는 함수여야만 합니다.

  • md5(text)IMMUTABLE. 표현식 인덱스 OK.
  • date(timestamptz)STABLE (결과가 세션 타임존에 의존). 그래서 표현식 인덱스에 못 넣습니다. 이게 앞서 date(created_at)을 인덱스로 못 살리고 범위 조건으로 재작성해야 하는 이유이기도 합니다.

함수의 변동성(IMMUTABLE / STABLE / VOLATILE)이 단순 성능 힌트가 아니라 표현식 인덱스를 걸 수 있냐 없냐를 가르는 문법적 조건이라는 걸 여기서 처음 실감했습니다.

6. SARGable은 ‘자격’일 뿐, 인덱스가 이길지는 별개다

마지막으로 하나 더. non-SARGable을 다 고쳤다고 무조건 인덱스를 타는 건 아닙니다.

  • SARGability = 자격. 인덱스로 찾아갈 수 있느냐.
  • 선택도(selectivity) = 가치. 찾아가는 게 이득이냐.

조건이 SARGable이어도, 전체의 상당 비율이 매칭되면 플래너는 일부러 Seq Scan을 고릅니다. 여기저기 흩어진 행을 인덱스로 하나씩 찾아 힙을 들쑤시는 것보다, 그냥 순차로 쭉 읽는 게 더 싸기 때문이죠. (경험칙으로 대략 5~10% 넘게 매칭되면 순차 스캔이 유리해지는 구간이 옵니다.)

즉 인덱스가 이기려면 자격(SARGable)과 가치(낮은 선택도)가 둘 다 있어야 합니다.

마치며

인덱스가 안 타는 이유를 겉으로만 알던 데서, 이제는 이렇게 한 줄로 정리됩니다.

인덱스는 키를 미리 계산해 정렬 저장한 것이다. 저장된 키 형태 그대로 비교하면 seek (SARGable), 컬럼을 감싸 지금 계산하면 scan + filter (non-SARGable). 표현식 인덱스는 그 계산 결과를 미리 저장해 다시 SARGable로 만들되, 함수가 IMMUTABLE이어야 한다.

플랜에서 Filter에 컬럼이 함수로 감싸여 있는 걸 보면, 이제는 반사적으로 “아, 저장된 값이 아니라 지금 계산하고 있구나”가 보입니다. EXPLAIN을 읽는 눈이 노드 이름 수준에서 동작 원리 수준으로 한 칸 내려간 느낌입니다.