3과목 · SQL 고급활용 및 튜닝 › 3-3. 인덱스 튜닝

인덱스 스캔 효율화와 설계

테이블 액세스 최소화가 "테이블에서 버려지는 건수"를 줄이는 일이라면, 인덱스 스캔 효율화는 "인덱스에서 읽고 버리는 건수"를 줄이는 일이다. 둘 다 결합 인덱스의 컬럼 구성과 순서에 크게 좌우된다.

인덱스 선행 컬럼과 스캔 범위

결합 인덱스는 선두 컬럼부터 차례로 정렬된다. 선두 컬럼이 같을 때만 두 번째 컬럼으로 정렬되고, 두 번째까지 같을 때만 세 번째 컬럼으로 정렬된다.

CREATE INDEX 주문_X1 ON 주문 (주문일자, 고객번호, 상품코드);
주문일자 고객번호 상품코드
20240101 C01 P10
20240101 C01 P20
20240101 C05 P10
20240102 C01 P30
20240102 C03 P10

이 구조 때문에 다음 원칙이 성립한다.

스캔 범위 결정 원칙

  • 선행 컬럼이 모두 = 조건일 때, 첫 번째로 나타나는 범위 검색 조건까지만 스캔 범위를 줄이는 데 기여한다.
  • 첫 번째 범위 검색 조건 이후의 컬럼 조건은 스캔 범위를 줄이지 못하고 필터 역할만 한다.
  • 선행 컬럼에 조건이 없으면(빠져 있으면) 그 뒤 컬럼 조건들은 모두 스캔 범위를 줄이지 못한다.

인덱스 (주문일자, 고객번호, 상품코드)

  • 주문일자 = :a AND 고객번호 = :b AND 상품코드 = :c → 세 조건 모두 스캔 범위 결정
  • 주문일자 = :a AND 고객번호 > :b AND 상품코드 = :c → 주문일자, 고객번호까지 범위 결정. 상품코드는 필터
  • 주문일자 BETWEEN :a AND :b AND 고객번호 = :c → 주문일자만 범위 결정. 고객번호는 필터 (매일 고객번호 조건을 확인)
  • 고객번호 = :b AND 상품코드 = :c → 선두 컬럼 조건 없음. Range Scan 불가 (Skip Scan 검토)

인덱스 액세스 조건과 필터 조건

실행계획의 Predicate Information에서 조건이 어떻게 쓰였는지 확인할 수 있다.

  • 인덱스 액세스 조건 인덱스 스캔 범위를 결정하는 조건. 수직적 탐색의 시작점과 수평적 탐색의 끝점을 정한다. access(...)로 표시.
  • 인덱스 필터 조건 인덱스를 스캔하면서 테이블로 갈지 말지 결정하는 조건. 스캔량은 줄이지 못하지만 테이블 액세스는 줄인다. 인덱스 단계의 filter(...).
  • 테이블 필터 조건 테이블을 액세스한 후 최종 결과 포함 여부를 결정하는 조건. 테이블 단계의 filter(...).
-- 인덱스: 주문_X1 (주문일자, 고객번호, 상품코드)
SELECT * FROM 주문
WHERE  주문일자 BETWEEN '20240101' AND '20240131'
AND    고객번호 = 'C01'
AND    주문금액 >= 10000;
| Id | Operation                           | Name   |
|* 1 |  TABLE ACCESS BY INDEX ROWID BATCHED| 주문    |
|* 2 |   INDEX RANGE SCAN                  | 주문_X1 |
Predicate Information
   1 - filter("주문금액">=10000)                          ← 테이블 필터
   2 - access("주문일자">='20240101' AND "고객번호"='C01'
              AND "주문일자"<='20240131')
       filter("고객번호"='C01')                           ← 인덱스 필터

access에 나와도 필터일 수 있다

위처럼 범위 조건 뒤의 고객번호='C01'은 access와 filter에 동시에 나타난다. 수직적 탐색 시작점을 정할 때는 사용되지만(첫 주문일자의 C01 위치), 수평적 탐색 범위를 줄이지는 못하므로 실질적으로는 인덱스 필터 조건이다.

시험 포인트: 인덱스 스캔 효율은 인덱스 액세스 조건이 결정하고, 테이블 랜덤 액세스 횟수는 인덱스 액세스 + 인덱스 필터 조건이 결정한다. 테이블 필터 조건은 최종 결과 건수에만 영향을 준다.

비교 연산자와 컬럼 순서에 따른 효율

같은 조건이라도 인덱스 컬럼 순서에 따라 스캔량이 크게 달라진다.

WHERE 고객번호 = 'C01'
AND   주문일자 BETWEEN '20240101' AND '20240131'
  • 인덱스 (주문일자, 고객번호) 1월 전체 주문을 스캔하면서 고객번호를 필터링한다. 1월 주문이 100만 건이면 100만 건을 읽는다.
  • 인덱스 (고객번호, 주문일자) C01 고객의 1월 주문만 연속된 범위로 스캔한다. 읽는 건수 = 결과 건수.

시험 포인트: = 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 두면 스캔 범위가 최소화된다. 결합 인덱스 컬럼 순서 문제에서 가장 많이 나오는 원리다.

BETWEEN을 IN-List로 바꾸기

범위 조건이 선행 컬럼에 있어 후행 컬럼 조건이 필터 역할밖에 못 할 때, 선행 컬럼 값의 종류가 적다면 IN-List로 바꿔 각 값마다 = 조건으로 탐색하게 할 수 있다.

-- 인덱스: 고객_X1 (고객등급, 가입일자)
-- 고객등급 종류: 'A', 'B', 'C' 세 가지

-- 변경 전: 고객등급 범위 조건 → 가입일자는 필터
WHERE 고객등급 BETWEEN 'A' AND 'C'
AND   가입일자 BETWEEN '20240101' AND '20240131'

-- 변경 후: 각 등급별로 가입일자 범위만 스캔
WHERE 고객등급 IN ('A', 'B', 'C')
AND   가입일자 BETWEEN '20240101' AND '20240131'
|  1 |  INLIST ITERATOR                     |        |
|  2 |   TABLE ACCESS BY INDEX ROWID BATCHED| 고객    |
|* 3 |    INDEX RANGE SCAN                  | 고객_X1 |
Predicate Information
   3 - access(("고객등급"='A' OR "고객등급"='B' OR "고객등급"='C')
              AND "가입일자">='20240101' AND "가입일자"<='20240131')

IN-List 전환이 오히려 나쁜 경우

  • IN-List 개수만큼 수직적 탐색이 반복된다. 값 종류가 많으면 수직적 탐색 비용이 커져 오히려 느려진다.
  • 선행 컬럼 값 범위가 좁아 한두 개의 리프 블록에 모여 있다면 굳이 바꿀 필요가 없다.
  • IN-List 항목 값이 바뀔 수 있는(코드가 추가되는) 컬럼이면 유지보수 위험이 있다. 이런 경우 코드 테이블과 NL 조인하는 방식으로 대신할 수 있다.
  • IN 조건이 인덱스 액세스 조건으로 쓰이려면 IN-List Iterator 방식으로 풀려야 한다. 그렇지 않으면 IN 조건은 필터로 처리된다. 힌트 NUM_INDEX_KEYS(테이블 인덱스 n)으로 액세스 조건에 쓸 인덱스 컬럼 수를 조정할 수 있다.
  • 선행 컬럼에 조건이 아예 없다면 Index Skip Scan을 고려할 수 있다. 자세한 내용은 인덱스 스캔 방식을 참고한다.

기타 비효율 패턴

LIKE로 BETWEEN 대체하는 경우

-- 인덱스: (판매월, 판매구분)
WHERE 판매월 LIKE '2024%' AND 판매구분 = 'A'      -- 판매월이 범위 조건 → 판매구분 필터
WHERE 판매월 BETWEEN '202401' AND '202412' AND 판매구분 = 'A'

LIKE와 BETWEEN 모두 범위 검색 조건이지만, LIKE '2024%'는 '2024'로 시작하는 모든 값('2024', '2024X' 등)을 포함하므로 BETWEEN보다 스캔 범위가 같거나 넓다. 범위 시작·끝이 명확하면 BETWEEN이 유리하다.

범위 조건을 남용하는 경우

옵션 조건 처리를 위해 = 대신 LIKE :val || '%'나 BETWEEN을 쓰면, 원래 =로 처리될 수 있던 컬럼이 범위 조건이 되어 뒤 컬럼의 스캔 범위 축소 효과를 잃는다.

-- 인덱스: (회사, 지역, 상품명)
-- 지역을 선택 입력으로 처리하려고 LIKE 사용 → 지역이 범위 조건이 되어 상품명이 필터화
WHERE 회사 = :com AND 지역 LIKE :reg || '%' AND 상품명 LIKE :prod || '%'

이 경우 지역 조건이 입력된 경우와 아닌 경우를 UNION ALL로 분기하거나, 필수 조건은 =로 처리하는 것이 좋다.

옵션 조건 처리의 함정

  • 컬럼 LIKE :val || '%'는 숫자형 컬럼에 쓰면 묵시적 형변환으로 인덱스를 못 쓴다. 또한 NULL 허용 컬럼이면 :val이 NULL일 때 NULL 행이 결과에서 빠진다.
  • NVL(:val, 컬럼) = 컬럼 패턴은 Oracle이 OR-Expansion으로 분기해 줄 수 있지만, 컬럼이 NULL 허용이면 결과가 달라진다.

결합 인덱스 컬럼 순서 결정 기준

  1. 조건절에 항상 사용되는가 자주 쓰이는(가능하면 항상 쓰이는) 컬럼을 선두로 둔다. 선두 컬럼이 빠지면 Range Scan 자체가 불가능하다.
  2. = 조건인가 항상 = 조건으로 쓰이는 컬럼을 앞쪽에, 범위 조건 컬럼을 뒤쪽에 둔다.
  3. 소트 연산을 생략할 수 있는가 ORDER BY·GROUP BY 컬럼 순서와 맞춰 소트를 생략할 수 있도록 배치한다.
  4. 선택도 위 기준이 같다면 선택도가 낮은(변별력이 좋은) 컬럼을 고려한다.

선택도가 낮은 컬럼을 앞에 둬야 할까?

흔한 오해다. 결합 인덱스의 모든 컬럼이 = 조건으로 쓰이면, 컬럼 순서와 무관하게 스캔 범위는 같다. 선택도는 "어느 컬럼을 인덱스에 포함할지"를 정할 때 중요하지, 컬럼 순서를 정하는 1순위 기준은 아니다. 오히려 선두 컬럼의 선택도가 높아야(값 종류가 적어야) Index Skip Scan을 활용할 수 있다는 점도 고려한다.

시험 포인트: 결합 인덱스 컬럼 순서는 "선택도가 낮은 컬럼 우선"이 아니라 "항상 사용되는 컬럼, = 조건 컬럼 우선, 범위 조건은 뒤로"가 핵심이다. 모든 조건이 =면 순서는 스캔 효율에 영향이 없다.

소트 연산을 생략할 수 있는 인덱스

인덱스는 정렬되어 있으므로 ORDER BY, GROUP BY, MIN/MAX를 별도 소트 없이 처리할 수 있다. 부분범위 처리가 가능해져 Top-N 쿼리 응답이 빨라진다.

CREATE INDEX 주문_X2 ON 주문 (고객번호, 주문일자);

SELECT * FROM 주문
WHERE  고객번호 = 'C01'
AND    주문일자 >= '20240101'
ORDER BY 주문일자 DESC;
| Id | Operation                    | Name   |
|  1 |  TABLE ACCESS BY INDEX ROWID | 주문    |
|* 2 |   INDEX RANGE SCAN DESCENDING| 주문_X2 |

SORT ORDER BY 오퍼레이션이 없다. 소트를 생략하려면 다음 조건을 만족해야 한다.

  • = 조건 컬럼 + ORDER BY 컬럼 = 조건인 선행 컬럼 다음에 ORDER BY 컬럼이 순서대로 오면 소트 생략 가능
  • ORDER BY 순서 일치 ORDER BY 컬럼 순서와 인덱스 컬럼 순서가 같아야 하고, 정렬 방향(ASC/DESC)이 모두 같거나 모두 반대여야 한다
  • 범위 조건 주의 인덱스 (A, B)에 A IN (1, 2) 조건 + ORDER BY B는 IN-List Iterator가 A값별로 따로 스캔하므로 B 순 정렬이 보장되지 않는다
  • 가공 금지 ORDER BY 컬럼을 가공하거나 SELECT 절에서 가공한 별칭으로 정렬하면 소트 생략 불가

인덱스 (지역, 등록일자, 고객명)

  • WHERE 지역 = :r ORDER BY 등록일자, 고객명 → 소트 생략 가능
  • WHERE 지역 = :r ORDER BY 고객명 → 등록일자가 중간에 있어 소트 생략 불가
  • ORDER BY 지역, 등록일자 DESC → 정렬 방향이 섞여 소트 생략 불가 (내림차순 인덱스가 아니라면)
  • WHERE 지역 IN ('서울','부산') ORDER BY 등록일자 → IN-List Iterator로 풀리면 소트 생략 불가

소트 튜닝 전반은 소트 튜닝, Top-N 처리는 Top N 쿼리를 참고한다.

인덱스 설계 시 고려사항

  • SQL 수행 패턴 분석 조건절에 쓰이는 컬럼, 조인 컬럼, 정렬 컬럼과 각 SQL의 수행 빈도를 조사한다. 자주 수행되는 SQL을 우선한다.
  • 인덱스 개수 최소화 인덱스가 늘수록 DML 성능이 떨어지고, 저장 공간과 관리 비용이 커진다. 여러 SQL을 하나의 인덱스로 커버하도록 설계한다.
  • NL 조인 내부 테이블 NL 조인의 Inner 쪽 조인 컬럼에는 반드시 인덱스가 필요하다. 상세는 NL 조인
  • 데이터 분포와 손익분기점 조건 컬럼의 선택도, 클러스터링 팩터, 대량 조회 여부를 함께 고려한다.

인덱스 설계 체크리스트

  • 조건절에 항상 쓰이는 컬럼이 선두에 있는가
  • = 조건 컬럼이 범위 조건 컬럼보다 앞에 있는가
  • ORDER BY·GROUP BY 소트를 생략할 수 있는 순서인가
  • 테이블에서 필터링되는 컬럼을 인덱스 뒤에 추가해 랜덤 액세스를 줄일 수 있는가
  • 기존 인덱스를 확장(컬럼 추가)하여 새 인덱스 생성을 피할 수 있는가
  • DML이 많은 테이블이라면 인덱스 수가 적정한가

핵심 요약

  • 결합 인덱스는 선두 컬럼부터 차례로 정렬되며, 첫 범위 조건까지만 스캔 범위를 줄이고 그 뒤 조건은 필터 역할만 한다.
  • 인덱스 액세스 조건은 스캔 범위를, 인덱스 필터 조건은 테이블 액세스 여부를, 테이블 필터 조건은 최종 결과를 결정한다.
  • = 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 두면 스캔 범위가 최소화된다.
  • 선행 컬럼 범위 조건을 IN-List로 바꾸면 후행 컬럼도 액세스 조건이 되지만, IN 값이 많으면 수직적 탐색 반복으로 역효과가 난다.
  • 범위가 명확하면 LIKE보다 BETWEEN이 유리하고, 옵션 조건에 LIKE·BETWEEN을 남용하면 후행 컬럼이 필터화된다.
  • 결합 인덱스 컬럼 순서는 항상 사용되는 컬럼, = 조건 컬럼, 소트 생략, 선택도 순으로 고려한다. 모든 조건이 =면 순서는 스캔 효율과 무관하다.
  • = 조건 선행 컬럼 다음에 ORDER BY 컬럼이 같은 순서·방향으로 오면 소트 연산을 생략할 수 있다.

이 절 음성 강의 듣기