반응형
Ant_U
DBA 개미
Ant_U
전체 방문자
오늘
어제
  • 분류 전체보기 (309) N
    • AWS (3)
    • C# (1)
    • SQL (283) N
      • MYSQL (228) N
      • MSSQL (55) N
    • 자격증 (20)
      • SQLD (12)
      • SQLP (8)

인기 글

최근 글

250x250
hELLO · Designed By 정상우.
Ant_U

DBA 개미

[MySQL] 복합 인덱스 컬럼 순서: 등가 조건을 앞에, 범위 조건을 뒤에 배치하는 원칙
SQL/MYSQL

[MySQL] 복합 인덱스 컬럼 순서: 등가 조건을 앞에, 범위 조건을 뒤에 배치하는 원칙

2026. 10. 5. 09:00
728x90
반응형

[MySQL] 복합 인덱스 컬럼 순서: 등가 조건을 앞에, 범위 조건을 뒤에 배치하는 원칙

복합 인덱스 컬럼 순서: 실무적 정의와 운영상 중요성

MySQL에서 복합 인덱스(Composite Index)는 둘 이상의 컬럼 값을 지정된 순서로 정렬해 저장하는 B-Tree 인덱스이다. WHERE 절에 등가 조건과 범위 조건이 함께 있다면 일반적인 배치 원칙은 등가 조건 컬럼을 앞에 두고, 실제 탐색 범위를 결정할 범위 조건 컬럼을 그 뒤에 두는 것이다.

예를 들어 tenant_id = ?, status = ?, created_at >= ?가 함께 사용된다면 기본 후보는 (tenant_id, status, created_at)이다. 선행 등가 조건으로 탐색할 인덱스 구간을 고정한 다음, 해당 구간 안에서 날짜 범위를 연속적으로 읽을 수 있기 때문이다.

다만 이를 모든 쿼리에 적용되는 절대 규칙으로 이해하면 안 된다. 실제 순서는 쿼리 패턴의 빈도, 컬럼 누락 여부, 범위 조건의 선택도, 정렬 요구, 커버링 가능성까지 포함해 결정해야 한다. DBA는 단순한 카디널리티 수치가 아니라 인덱스가 어디까지 탐색 범위를 축소하는지 확인해야 한다.

Part 1. 복합 인덱스 기본 문법 및 사용법 (Basic Syntax)

다음은 멀티 테넌트 주문 테이블에서 특정 테넌트와 상태를 고정하고 생성 시각 범위를 조회하는 형태이다. 두 등가 조건 뒤에 범위 조건을 배치하면 좌측 선행 컬럼(Leftmost Prefix)이 끊기지 않는다.

SELECT order_id, tenant_id, status, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-08-01 00:00:00'
  AND created_at <  '2026-09-01 00:00:00';

CREATE INDEX ix_orders_tenant_status_created
    ON orders (tenant_id, status, created_at);

위 인덱스에서 MySQL은 먼저 tenant_id 값을 고정하고, 그 내부에서 status 값을 고정한다. 이후 created_at의 시작 지점을 찾고 종료 경계까지 순차적으로 읽는다. 전체 인덱스를 훑은 뒤 필터링하는 방식보다 읽어야 할 리프 페이지와 레코드 수를 줄일 가능성이 높다.

WHERE 절에 조건을 작성한 순서는 인덱스 컬럼 순서를 결정하지 않는다. 옵티마이저는 조건식을 분석하므로 created_at 조건을 SQL 문장 앞에 작성해도 인덱스 정의가 자동으로 바뀌지 않는다. 중요한 것은 CREATE INDEX에 선언된 컬럼의 물리적 순서이다.

조건 유형 대표 연산자 인덱스에서의 역할 일반적인 위치
등가 조건 =, <=> 선행 키 값을 하나의 구간으로 고정한다. 앞쪽
목록 조건 IN (...) 여러 등가 구간으로 분리될 수 있다. 범위 컬럼보다 앞쪽을 우선 검토한다.
범위 조건 >, >=, <, <=, BETWEEN 연속된 탐색 구간의 시작 또는 종료를 결정한다. 등가 조건 뒤
비등가 조건 <>, NOT IN 넓은 구간을 읽고 제외 필터를 적용할 가능성이 높다. 접근 경로 효과를 별도로 검증한다.

LIKE 'ABC%'는 고정 접두어를 이용할 수 있어 범위 탐색 후보가 된다. 반면 LIKE '%ABC'는 시작 위치를 특정할 수 없으므로 일반적인 B-Tree 인덱스의 효율적인 범위 탐색에 적합하지 않다. 문자열 패턴의 위치까지 조건 유형에 포함해 판단해야 한다.

Part 2. 내부 동작 메커니즘과 안티 패턴

복합 인덱스 (tenant_id, status, created_at)는 세 개의 독립 인덱스를 합친 구조가 아니다. 전체 키가 tenant_id → status → created_at 순서로 정렬된다. 따라서 첫 번째 컬럼이 고정되지 않으면 두 번째와 세 번째 컬럼의 정렬 구간도 전역적으로 연속되지 않는다.

가장 흔한 안티 패턴은 범위 조건 컬럼을 선두에 두고, 자주 사용되는 등가 조건 컬럼을 뒤에 배치하는 것이다.

CREATE INDEX ix_orders_created_tenant_status
    ON orders (created_at, tenant_id, status);

SELECT order_id
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-08-01 00:00:00'
  AND created_at <  '2026-09-01 00:00:00';

이 인덱스는 먼저 전체 테넌트와 전체 상태가 섞인 날짜 구간을 읽게 될 수 있다. 후행 tenant_id와 status는 스토리지 엔진의 Index Condition Pushdown 조건이나 서버 계층의 필터 조건으로 평가될 수 있지만, 선행 등가 조건으로 탐색 구간을 먼저 좁히는 구조와 동일하지 않다. 결과적으로 스캔 레코드와 디스크 I/O가 증가할 수 있다.

첫 번째 범위 조건 뒤의 컬럼이 항상 완전히 무시된다고 단정하는 것도 정확하지 않다. MySQL 버전, 연산자 조합, 통계, Index Condition Pushdown, 커버링 여부에 따라 후행 컬럼이 필터링이나 인덱스 내부 판정에 활용될 수 있다. 핵심은 후행 컬럼이 표시되었는지가 아니라 실제 접근 범위와 읽은 행 수를 충분히 줄였는지이다.

등가 조건 컬럼끼리의 순서는 어떻게 결정하는가

모든 등가 컬럼이 항상 함께 사용된다면 (tenant_id, status, created_at)와 (status, tenant_id, created_at)는 해당 쿼리 하나에 대해 모두 세 컬럼을 연속 탐색에 사용할 수 있다. 이때 카디널리티가 높은 컬럼을 무조건 첫 번째로 두어야 한다는 규칙은 성립하지 않는다.

등가 컬럼끼리의 순서는 다른 쿼리가 어떤 선행 컬럼만 사용하는지에 따라 결정한다. 테넌트 단위 조회가 많고 status가 생략되는 쿼리가 존재한다면 tenant_id를 선두에 두는 편이 재사용성이 높다. 반대로 상태별 전역 집계가 핵심이라면 별도의 인덱스가 필요할 수 있으며, 하나의 복합 인덱스로 상충하는 접근 경로를 모두 해결하려 하면 쓰기 비용만 증가한다.

범위 조건이 두 개라면 어떤 컬럼을 먼저 두는가

created_at과 amount에 모두 범위 조건이 있다면 두 컬럼을 동시에 이상적인 단일 연속 구간으로 축소하기 어렵다. 등가 접두어 다음에는 더 많은 행을 안정적으로 제거하는 범위 컬럼, 핵심 조회에서 항상 존재하는 범위 컬럼, 또는 정렬과 페이징까지 지원해야 하는 컬럼을 우선 검토한다.

WHERE tenant_id = 42
  AND created_at >= :from_time
  AND created_at <  :to_time
  AND amount BETWEEN :min_amount AND :max_amount

(tenant_id, created_at, amount)를 선택하면 날짜 구간이 주된 접근 범위가 되고 금액은 후속 판정에 사용될 수 있다. 반대로 금액 구간이 훨씬 좁고 대부분의 요청에서 필수라면 (tenant_id, amount, created_at)도 후보가 된다. 데이터 분포와 실제 바인드 값에 따라 결과가 달라지므로 추정만으로 결정하면 안 된다.

쿼리 특성 우선 검토할 인덱스 판단 이유
테넌트와 상태는 등가, 생성 시각은 범위 (tenant_id, status, created_at) 두 값을 고정한 구간에서 시간 범위를 읽는다.
테넌트만 항상 존재하고 상태는 선택 사항 (tenant_id, created_at) 또는 쿼리별 별도 인덱스 상태가 누락될 때 발생하는 선행 컬럼 공백을 피한다.
테넌트 등가, 날짜와 금액이 모두 범위 더 선택적이고 필수적인 범위 컬럼을 두 번째에 배치 첫 핵심 범위 이후 컬럼의 접근 범위 축소 효과가 제한될 수 있다.
최근 데이터 정렬과 LIMIT이 핵심 (tenant_id, status, created_at) 동일 등가 접두어 안에서 시간순 접근을 지원할 수 있다.
날짜 컬럼에 함수를 적용 인덱스 순서보다 조건식 재작성 우선 비-SARGable 조건은 정상적인 범위 탐색을 방해한다.

Part 3. 성능 최적화 및 튜닝 전략

인덱스 후보는 동일한 테이블과 데이터 분포에서 비교해야 한다. 서로 다른 컬럼 순서의 인덱스를 생성한 뒤 EXPLAIN과 EXPLAIN ANALYZE를 사용해 선택된 키, 접근 방식, 예상 행 수, 실제 행 수를 확인한다.

CREATE INDEX ix_eq_range
    ON orders (tenant_id, status, created_at);

CREATE INDEX ix_range_eq
    ON orders (created_at, tenant_id, status);

EXPLAIN
SELECT order_id
FROM orders FORCE INDEX (ix_eq_range)
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-08-01 00:00:00'
  AND created_at <  '2026-09-01 00:00:00';

EXPLAIN
SELECT order_id
FROM orders FORCE INDEX (ix_range_eq)
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-08-01 00:00:00'
  AND created_at <  '2026-09-01 00:00:00';

FORCE INDEX는 두 후보를 통제된 조건에서 비교하기 위한 진단 용도로만 사용한다. 운영 쿼리에 상시 고정하면 데이터 분포와 통계가 변한 뒤 더 나은 실행 계획을 옵티마이저가 선택하지 못할 수 있다.

검증 항목 확인 내용 해석 시 주의점
key 실제로 선택한 인덱스 인덱스가 선택되었다는 사실만으로 효율을 판단할 수 없다.
key_len 접근에 사용된 키 길이의 단서 자료형, NULL 허용 여부, 문자셋의 영향을 받으므로 스키마와 함께 해석한다.
rows 옵티마이저가 예상한 검사 행 수 추정치이므로 실제 실행 행 수와 비교해야 한다.
filtered 조건 통과 비율의 추정치 낮은 값은 많은 행을 읽고 제거하는 접근일 수 있다.
type ref, range, index, ALL 등 접근 방식 range라도 탐색 구간이 넓으면 병목이 발생한다.
실제 rows와 loops EXPLAIN ANALYZE의 실행 결과 운영 트래픽에 영향을 주지 않는 복제본이나 검증 환경에서 수행한다.

key_len이 길다는 이유만으로 더 좋은 인덱스라고 판단하면 안 된다. 후행 키가 조건 판정에 포함되어 길이가 증가해도 넓은 선행 범위를 읽을 수 있다. rows, 실제 반환 전 검사 행 수, 실행 시간, 버퍼 접근량을 함께 비교해야 한다.

콜드 캐시와 웜 캐시 측정 절차

웜 캐시 측정은 동일 쿼리를 준비 실행한 뒤 여러 차례 반복하고 각 실행 시간을 개별 기록한다. 평균만 제시하면 일시적인 지연에 왜곡될 수 있으므로 중앙값과 상위 지연 구간도 함께 계산한다. 비교 대상 쿼리는 같은 파라미터, 같은 결과 건수, 같은 서버 부하 조건을 사용해야 한다.

콜드 캐시 측정은 버퍼 풀 상태가 성능에 직접 영향을 주므로 전용 검증 환경에서 수행해야 한다. 운영 서버에서 캐시를 비우거나 MySQL을 재시작하면 다른 세션의 디스크 I/O와 응답 시간까지 악화되므로 금지해야 한다. 별도 인스턴스를 동일한 데이터 스냅샷으로 초기화하거나 재현 가능한 테스트 환경을 매회 새로 구성하는 방식이 안전하다.

측정 표에는 두 인덱스의 정확한 정의, EXPLAIN key_len, 예상 rows, EXPLAIN ANALYZE의 실제 처리 행 수, 콜드 캐시 시간, 웜 캐시 시간을 기록한다. 본문에 게시할 값은 작성자가 동일 조건에서 직접 측정한 결과로 교체해야 한다.

비교 항목 ix_eq_range ix_range_eq
인덱스 정의 (tenant_id, status, created_at) (created_at, tenant_id, status)
EXPLAIN key_len EXPLAIN 결과의 key_len 값을 기록한다. EXPLAIN 결과의 key_len 값을 기록한다.
EXPLAIN rows EXPLAIN 결과의 rows 추정치를 기록한다. EXPLAIN 결과의 rows 추정치를 기록한다.
실제 처리 행 수 EXPLAIN ANALYZE의 실제 rows와 loops를 기록한다. EXPLAIN ANALYZE의 실제 rows와 loops를 기록한다.
콜드 캐시 실행 시간 동일한 데이터 스냅샷으로 초기화한 전용 검증 환경의 실행 시간을 기록한다. 동일한 데이터 스냅샷으로 초기화한 전용 검증 환경의 실행 시간을 기록한다.
웜 캐시 실행 시간 준비 실행 후 반복 측정한 개별 시간과 중앙값, 상위 지연 구간을 기록한다. 준비 실행 후 반복 측정한 개별 시간과 중앙값, 상위 지연 구간을 기록한다.

정렬, 커버링 인덱스와 쓰기 비용

등가 접두어 뒤의 범위 컬럼이 ORDER BY와 같은 순서라면 별도 정렬을 피할 가능성이 있다. 그러나 정렬 방향 혼합, 선행 컬럼 누락, 다른 범위 조건, 조인 순서에 따라 filesort가 발생할 수 있으므로 실행 계획의 정렬 단계까지 확인해야 한다.

SELECT 목록의 컬럼을 무조건 복합 인덱스 뒤에 추가하는 것도 위험하다. 커버링 인덱스는 테이블 레코드 접근을 줄일 수 있지만 인덱스 크기, 버퍼 풀 점유, INSERT·UPDATE 비용, 페이지 분할을 증가시킨다. 대용량 트랜잭션 테이블에서는 읽기 이득과 쓰기 증폭을 함께 측정해야 한다.

날짜 범위 조건에서 컬럼을 함수로 감싸면 올바른 순서의 인덱스도 무력화될 수 있다. DATE(created_at) = '2026-08-01' 대신 반개방 구간인 created_at >= '2026-08-01 00:00:00' AND created_at < '2026-08-02 00:00:00' 형태를 사용해야 한다. 관련 원리는 MySQL DATEDIFF 성능 저하와 인덱스 무력화 해결 방법과 MySQL DATE_FORMAT이 유발하는 Full Table Scan과 쿼리 튜닝 가이드에서 추가로 확인할 수 있다.

경계값을 계산해야 한다면 인덱스 컬럼이 아니라 상수 측을 가공해야 한다. MySQL DATE_ADD/DATE_SUB 사용 시 인덱스가 무너지는 이유와 튜닝 기법처럼 SARGable 조건을 유지해야 복합 인덱스의 컬럼 순서도 실제 효과를 낸다.

성능 최적화의 핵심은 등가 조건을 이용해 인덱스의 선행 구간을 먼저 고정하고, 첫 핵심 범위 조건으로 읽을 구간을 제한하는 것이다. 이후 EXPLAIN의 key_len과 rows, 실제 처리 행 수, 콜드·웜 캐시 결과를 함께 검증해야 한다. 컬럼 가공 회피, 좌측 선행 컬럼 유지, 범위 검색, 실행 계획 확인이 운영 환경의 디스크 I/O 병목과 불필요한 전체 테이블 스캔을 방지한다.

728x90
반응형

'SQL > MYSQL' 카테고리의 다른 글

[MySQL] 낮은 선택도 인덱스에서 Full Table Scan을 선택하는 기준과 검증 방법  (0) 2026.10.11
[MySQL] Invisible Index로 중복 인덱스 삭제 전 영향 범위를 검증하는 절차  (0) 2026.10.09
[MySQL] 커버링 인덱스 설계: 테이블 접근 제거 조건과 과도한 인덱스 비용  (0) 2026.10.07
[MySQL] EXPLAIN ANALYZE 카디널리티 오차 진단과 실행 계획 튜닝 순서  (0) 2026.10.03
[MySQL] 갭 락과 넥스트키 락 재현: 없는 값을 조회해도 INSERT가 막히는 이유  (0) 2026.10.02
[MySQL] 메타데이터 락 때문에 멈춘 DDL 진단과 안전한 해소 절차  (0) 2026.10.01
[MySQL] NOWAIT와 SKIP LOCKED 활용: 대기 없는 작업 큐 구현과 한계  (0) 2026.09.30
[MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계  (0) 2026.09.29
    'SQL/MYSQL' 카테고리의 다른 글
    • [MySQL] Invisible Index로 중복 인덱스 삭제 전 영향 범위를 검증하는 절차
    • [MySQL] 커버링 인덱스 설계: 테이블 접근 제거 조건과 과도한 인덱스 비용
    • [MySQL] EXPLAIN ANALYZE 카디널리티 오차 진단과 실행 계획 튜닝 순서
    • [MySQL] 갭 락과 넥스트키 락 재현: 없는 값을 조회해도 INSERT가 막히는 이유
    Ant_U
    Ant_U

    티스토리툴바