Zettelkasten

OR 연산은 인덱스의 연속 스캔을 방해해 비효율을 유발한다.

·수정 2026.05.14·수정 3

요약

  • OR 연산은 인덱스의 연속 스캔(range scan)을 방해하여 비효율을 유발한다
  • 특히 서로 다른 컬럼 간의 OR 조건이 문제가 된다

본문

연속 스캔이란

B+Tree 인덱스의 leaf node들은 키 정렬 순서로 linked list로 연결돼 있다. 시작점만 트리 탐색으로 찾으면, 이후는 한 방향으로 leaf를 따라가며 읽으면 된다.

[루트] → [중간] → [leaf₁] ↔ [leaf₂] ↔ [leaf₃] ↔ ...
                     └─ 여기부터 →→→→ 쭉 읽기

빠른 이유:

  • 랜덤 I/O 1회 + 시퀀셜 I/O 다회: 시작 위치 탐색(트리 하강)만 랜덤 I/O, 이후는 인접 페이지 순차 read
  • OS/디스크의 read-ahead 활용: 순차 접근 패턴이면 prefetch가 잘 먹어 throughput이 올라감
  • 정렬 결과 무료: leaf가 이미 키 순서대로 정렬돼 있어 ORDER BY 별도 비용 없음
  • clustered index(PK) 기반이면 row data 자체도 인접 위치라 추가 룩업까지 시퀀셜

연속 스캔이 잘 되는 쿼리:

WHERE age BETWEEN 20 AND 30                       -- 시작~끝 범위
WHERE name LIKE 'kim%'                            -- prefix가 정해진 범위
WHERE created_at > '2024-01-01' ORDER BY created_at  -- 정렬까지 공짜

연속 스캔이 깨지는 패턴

매칭 행이 leaf 상에서 흩어져 있으면 여러 시작점을 각각 따로 찾아야 해서 시퀀셜 I/O 이점이 사라진다:

  • OR (서로 다른 컬럼) — 두 위치를 따로 찾아 합쳐야 함 → Index Merge
  • IN (대량 값) — 값마다 leaf 위치가 다름
  • LIKE '%kim' — leading wildcard라 시작점을 못 잡음
  • 함수 적용된 컬럼: WHERE LOWER(name) = ... — 인덱스 정렬 순서와 무관
  • <> (부등호) — 매칭 영역이 "전체 - 일부"라 거의 풀 스캔
  • 타입 캐스팅: WHERE id = '123' (id가 int인데 문자열) — 인덱스 미사용 가능

옵티마이저가 "어차피 흩어진 행을 랜덤 I/O로 읽을 거면 차라리 풀 테이블 스캔하자"라고 판단해 인덱스를 포기하기도 한다. EXPLAIN의 type: ALL 또는 Using where + rows 큰 값이 신호.

OR이 연속 스캔을 어떻게 방해하는가

A OR B는 의미상 "A를 만족하는 행들의 leaf 영역" + "B를 만족하는 영역"의 합집합이다. 두 영역이 인덱스 상 인접하지 않으면:

  1. A 조건으로 leaf를 한 번 스캔
  2. B 조건으로 leaf를 다시 한 번 스캔(또는 풀 스캔)
  3. 두 결과 합치고 중복 제거 (Index Merge)

각 단계가 시퀀셜이긴 해도, 두 번의 랜덤 I/O 시작점 + 합치는 비용이 누적된다. 동일 컬럼 OR(=IN)은 옵티마이저가 단일 leaf 영역으로 정리할 수 있지만, 다른 컬럼 OR은 그게 안 된다.

문제가 되는 케이스: 서로 다른 컬럼 간 OR

SELECT * FROM users
WHERE name = 'kim' OR email = 'test@example.com'
  • 두 컬럼 모두 인덱스가 있어도 각각 스캔 후 합집합(Index Merge) 연산 필요
  • Index Merge 비용이 높아 Full Scan이 더 나을 수 있음

괜찮은 케이스: 동일 컬럼의 OR

SELECT * FROM orders
WHERE status = 'PENDING' OR status = 'PROCESSING'
  • 옵티마이저가 IN ('PENDING', 'PROCESSING')으로 자동 변환
  • 인덱스 활용 가능

우회 방법

1. UNION ALL로 분리

SELECT * FROM users WHERE name = 'kim'
UNION ALL
SELECT * FROM users WHERE email = 'test@example.com' AND name != 'kim'
  • 주의: UNION ALL은 중복 허용 → 조건이 상호 배타적인지 확인 필요

2. 동일 컬럼이면 IN 사용

WHERE status IN ('active', 'pending', 'review')

실무 팁

  • EXPLAIN으로 실제 실행 계획 확인 필수
  • OR 조건이 있는 느린 쿼리 발견 시 위 우회 방법 검토

참고