개발블로그

[인덱스] MySQL 쿼리에서 인덱스가 활용되는 원리: WHERE, ORDER BY, GROUP BY, JOIN 본문

STUDY/SQL

[인덱스] MySQL 쿼리에서 인덱스가 활용되는 원리: WHERE, ORDER BY, GROUP BY, JOIN

devmel 2026. 7. 27. 19:04
Contents 접기
 

인덱스 활용의 기본 원리

 

쿼리에서 인덱스가 효과적으로 사용되려면 다음 두 가지를 확인해야 함.

  1. 읽어야 하는 데이터 범위를 줄일 수 있는가
  2. 인덱스에 정렬된 순서를 그대로 활용할 수 있는가

 

WHERE 조건과 인덱스 활용

 

동등 조건

컬럼값이 특정 값과 정확히 일치하는 행을 찾는 조건

WHERE 인덱스_컬럼 = 값

 

일반적으로 인덱스의 검색 범위를 명확하게 좁힐 수 있음.

 

EX]

--  아래와 같은 인덱스가 있음
CREATE INDEX idx_articles_status ON articles (status);

/**
인덱스에는 status값이 정렬된 상태로 저장됨

draft
draft
published
published
published
reserved
*/

SELECT * FROM articles WHERE status='published';
/**
인덱스에서 published가 시작되는 위치를 찾는다. 
1. published 검색
2. published 범위의 시작 위치 확인
3. 같은 값이 이어지는 범위 읽기
4. 연결된 실제 행 조회

draft
draft
[ published
  published
  published ]
reserved
*/

 

범위 조건

특정 값보다 크거나 작거나, 일정 구간 안에 포함되는 행을 찾는 조건

범위의 시작 위치를 인덱스에서 찾고, 이후 정렬된 인덱스를 순서대로 읽는다. 

WHERE 인덱스_컬럼 > 값
WHERE 인덱스_컬럼 >= 값
WHERE 인덱스_컬럼 < 값
WHERE 인덱스_컬럼 <= 값
WHERE 인덱스_컬럼 BETWEEN 시작값 AND 끝값

 

EX]

-- 아래와 같은 인덱스
CREATE INDEX idx_articles_created_at
ON articles (created_at);

SELECT *
FROM articles
WHERE created_at >= '2026-07-10';
/**
인덱스는 날짜순으로 정렬되어 있음
2026-07-01
2026-07-05
2026-07-10
2026-07-15
2026-07-20

먼저 범위의 시작값인 2026-07-10을 찾는다. 
그 다음 조건에 맞는 방향으로 인덱스를 순서대로 읽는다.
2026-07-01  제외
2026-07-05  제외

2026-07-10  ← 시작 위치
2026-07-15
2026-07-20

1. 인덱스에서 2026-07-10 위치 탐색
2. 해당 위치부터 인덱스를 순서대로 읽기
3. 조건 범위가 끝날 때까지 조회 

*/

 

BETWEEN 조건

시작값과 끝값이 정해진 범위 조건

시작점 탐색

→ 정렬된 인덱스를 순서대로 읽음

→ 종료값을 벗어나면 탐색 종료 

 

SELECT *
FROM articles
WHERE created_at BETWEEN '2026-07-05' AND '2026-07-15';

-- (동일)  WHERE created_at >= '2026-07-05' AND created_at <= '2026-07-15'
/**
2026-07-01  제외

2026-07-05  ← 시작
2026-07-10
2026-07-15  ← 종료

2026-07-20  제외
*/

 

복합 인덱스에서의 차이

동등조건과 범위 조건의 차이는 복합 인덱스에서 더 중요함. 

[ 동등조건 ]

 

특정 값의 범위를 고정

→ 검색 범위를 명확하게 좁힘

→ 복합 인덱스의 뒤쪽 컬럼을 이어서 활용하기 좋음

 

[ 범위조건 ]

 

범위의 시작점을 찾고 여러 값을 순서대로 읽음

→ 복합 인덱스는 범위 조건 뒤의 컬럼으로 

검색 범위를 계속 좁히는 효과가 제한될 수 있음

 

-- 앞쪽 컬럼이 동등 조건인 경우

CREATE INDEX idx_articles_status_created
ON articles (status, created_at);

/**
정렬순서)
1. status로 정렬
2. 같은 status 안에서 created_at으로 정렬

draft
├─ 2026-07-01
├─ 2026-07-05
└─ 2026-07-10

published
├─ 2026-07-02
├─ 2026-07-08
└─ 2026-07-15
*/


SELECT *
FROM articles
WHERE status = 'published'
  AND created_at >= '2026-07-08';
/**
먼저 status를 하나의 값으로 고정

status = published 범위

published
├─ 2026-07-02
├─ 2026-07-08
└─ 2026-07-15

그 범위 안에서 created_at이 정렬되어 있으므로, 날짜 범위도 이어서 탐색할 수 있음.

1. status = published 범위 탐색
2. 그 안에서 created_at = 2026-07-08 시작점 탐색
3. 이후 날짜를 순서대로 읽기

published
├─ 2026-07-02  제외
├─ 2026-07-08  ← 시작
└─ 2026-07-15
*/

--------------------------------------------------------------------------

-- 앞쪽 컬럼이 범위 조건인 경우

CREATE INDEX idx_orders_amount_status
ON orders (amount, status);

/**
정렬순서)

10000
├─ cancelled
└─ paid

20000
├─ pending
└─ paid

30000
├─ cancelled
└─ paid
*/

SELECT *
FROM orders
WHERE amount >= 10000
  AND status = 'paid';
/**
amount >= 10000 은 하나의 값이 아니라 여러 amount 범위를 포함
10000 그룹
20000 그룹
30000 그룹
...

status = 'paid'는 각 amount 그룹 안에 나뉘어 존재함
10000, paid
20000, paid
30000, paid

따라서, paid인 항목 전체가 하나의 연속된 범위로 모여 있지 않음.
즉, status를 이용해 읽어야 할 인덱스 범위 자체를 하나의 연속된 구간으로 더 좁히기는 어려움. 
*/

 

 

IN 조건과 인덱스 활용

IN 조건은 컬럼값이 여러 후보 중 하나와 일치하는 행을 찾는 조건

WHERE 인덱스_컬럼 IN (값1, 값2, 값3)

/**
인덱스에서 값1의 위치 탐색
→ 인덱스에서 값2의 위치 탐색
→ 인덱스에서 값3의 위치 탐색
→ 일치하는 행 반환
*/

 

IN에 지정된 각각의 값은 동등 조건처럼 비교할 수 있다. 

따라서 해당 컬럼에 인덱스가 있다면, 각 값이 저장된 위치를 찾아 필요한 범위를 읽을 수 있음.

 

값1의 인덱스 범위 탐색
→ 값2의 인덱스 범위 탐색
→ 값3의 인덱스 범위 탐색
→ 각 결과를 반환

 

복합 인덱스에서는 IN이 어느 컬럼에 사용되는지가 중요.

조건 인덱스 활용
단일 인덱스 컬럼에 IN 사용 각 후보 값의 범위를 탐색할 수 있음
복합 인덱스 첫 번째 컬럼에 IN 사용 각 후보 값의 범위 안에서 뒤쪽 컬럼도 활용 가능
복합 인덱스 두 번째 컬럼에만 IN 사용 앞쪽 컬럼이 없어 검색 범위를 좁히기 어려움
후보 값과 결과 행이 많음 인덱스 사용의 이점이 줄어들 수 있음

 

EX]

CREATE INDEX idx_articles_status_created
ON articles (status, created_at);

-- 잘 활용 할 수 있음
SELECT *
FROM articles
WHERE status IN ('draft', 'published')
  AND created_at >= '2026-07-08';
/**
각 status에서 범위를 찾고, 각 범위 안에서 created_at 조건에 맞는 부분을 읽을 수 있음.
draft 범위
└─ 2026-07-08 이후 탐색

published 범위
└─ 2026-07-08 이후 탐색
*/

-- 활용하기 어려움
SELECT *
FROM articles
WHERE created_at IN ('2026-07-08', '2026-07-15');

 

 

LIKE 조건과 인덱스 활용

LIKE은 문자열의 일부가 특정 패턴과 일치하는 행을 찾는 조건

WHERE <문자열_컬럼> LIKE '<검색 패턴>'

 

앞부분이 고정된 검색

LIKE '검색어%'
→ 검색어로 시작하는 범위를 찾을 수 있음

 

앞부분이 고정되지 않은 검색

LIKE '%검색어'
LIKE '%검색어%'
→ 검색값이 인덱스의 어느 위치에 있는지 바로 알기 어려움

 

 

인덱스 컬럼에 함수나 연산을 적용한 조건

가능한 가공하지 않고 원래 값 그대로 비교

컬럼에는 저장된 원래 값을 기준으로 정렬되어 있어서, 

함수나 연산을 적용하면 실제 비교 기준이 달라져 검색 범위를 바로 찾기 어려울 수 있음

=> 여러 인덱스 항목이나 행에 직접 함수/연산을 적용하여 조건 검사

 

연산 적용

WHERE <인덱스_컬럼> + 10 = 40

-- 가능하면 다음처럼 변환
WHERE <인덱슽_컬럼> = 30

 

날짜 함수 적용

WHERE DATE(<날짜시간_컬럼>) = '<특정 날짜>'

-- 날짜시간 컬럼의 원래 값에 DATE()를 적용해야 하므로 일반 인덱스를 충분히 활용하기 어려울 수 있음 => 원래 컬럼을 그대로 두고 범위 조건으로 변경함 
WHERE <날짜시간_컬럼> >= '<특정 날짜 00:00:00>'
	AND <날짜시간_컬럼> < '<다음 날짜 00:00:00>'

 

연도 추출

WHERE YEAR(<날짜_컬럼>) = <연도>

-- 다음처럼 연도의 시작과 종료 범위로 변경할 수 있음
WHERE <날짜_컬럼> >= '<해당 연도 시작일>'
	AND <날짜_컬럼> > '<다음 연도 시작일>'

 

함수 기반 인덱스

함수를 적용한 조건을 자주 사용해야 한다면, 함수 결과 자체를 인덱싱 하는 방식을 사용

CREATE INDEX <인덱스명>
ON <테이블명> ((<함수>(<컬럼명>)));

-- 다음 조건에서 해당 인덱스 활용 가능
WHERE <함수>(<컬럼명>) = <값>

 

 

IS NULL 조건

인덱스 컬럼에서 NULL인 행을 찾는 조건도 인덱스를 활용할 수 있음.

인덱스 안에서 NULL값이 모여 있는 범위를 찾아 해당 부분만 읽는 방식

 

실제 NULL 값의 비율이 중요함

읽어야 하는 행이 많으면 효율이 낮아짐

 

부정 조건

특정 값을 찾는 게 아니라 해당 값을 제외한 나머지를 찾음

WHERE <인덱스_컬럼> != <값>
WHERE <인덱스_컬럼> <> <값>
WHERE <인덱스_컬럼> NOT IN (<값1>, <값2>)
WHERE <인덱스_컬럼> NOT LIKE '<패턴>'

 

인덱스를 사용할 수 있지만, 일반적으로 검색 범위를 크게 줄이기는 어려움.

 

인덱스 사용 가능 여부보다는 제외한 뒤 실제로 몇 개의 행이 남는지를 함께 확인해야 함.

 

OR 조건

연결된 조건 중 하나라도 참인 행을 찾음

WHERE <조건 A>
   OR <조건 B>
   
/**
조건 A의 인덱스 범위 탐색
+
조건 B의 인덱스 범위 탐색

→ 중복을 처리한 뒤 결과 반환
*/

 


 

ORDER BY와 인덱스 활용

 

ORDER BY는 조회한 결과를 지정한 컬럼 순서로 정렬

ORDER BY <컬럼 A>, <컬럼 B>

 

인덱스 순서와 ORDER BY 순서가 일치

→ 인덱스를 정방향 또는 역방향으로 읽음
→ 별도의 정렬 작업을 줄일 수 있음

 

컬럼 순서가 맞지 않거나 인덱스의 중간 컬럼을 건너뛰면, 

인덱스에 저장된 순서만으로 원하는 결과를 만들기 어렵다. 

 

EX]

인덱스 순서
(A, B, C)

활용하기 쉬운 정렬
ORDER BY A
ORDER BY A, B
ORDER BY A, B, C

활용하기 어려운 정렬
ORDER BY B
ORDER BY A, C

 


 

GROUP BY와 인덱스 활용

 

같은 값을 가진 행을 하나의 그룹으로 묶는다

GROUP BY <컬럼 A>, <컬럼 B>

 

인덱스 순서와 GROUP BY 순서가 일치

→ 같은 값이 연속해서 저장됨

→ 인덱스를 순서대로 읽으며 그룹 경계를 판단

→ 별도의 정렬이나 임시 작업을 줄일 수 있음.

 

EX]

인덱스
(A, B, C)

활용하기 쉬운 그룹화
GROUP BY A
GROUP BY A, B
GROUP BY A, B, C

활용하기 어려운 그룹화
GROUP BY B
GROUP BY A, C

 


 

JOIN과 인덱스 활용

 

JOIN은 두 테이블의 연결 컬럼값을 비교해 서로 관련된 행을 찾는 작업

SELECT <조회할 컬럼>
FROM <테이블 A>
JOIN <테이블 B>
  ON <테이블 A.연결_컬럼> = <테이블 B.연결_컬럼>;

 

[고려사항]

 

양쪽 연결 컬럼의 자료형을 동일하게 설정
+
반복해서 검색되는 쪽의 컬럼에 인덱스 생성

 

상황 인덱스 설계
기본 키로 다른 테이블과 연결 기본 키에는 일반적으로 이미 인덱스가 있음
외래 키 값으로 관련 행 검색 외래 키 컬럼의 인덱스 검토


인덱스가 없는 경우
: 연결값 하나 확인 → 상대 테이블의 여러 행을 확인 → 일치하는 행 탐색

인덱스가 있는 경우
: 연결값 하나 확인 → 인덱스에서 같은 값 탐색 → 해당하는 행만 조회

단순히 양쪽 컬럼에 모두 인덱스를 만드는 것보다, 
실행 과정에서 반복해서 검색되는 쪽의 연결 컬럼에 적절한 인덱스가 있는지가 중요


조인과 추가 조건을 함께 사용 복합 인덱스 검토
조인 컬럼 자료형이 다름 동일한 자료형과 속성으로 맞춤
복합 인덱스 컬럼 순서 결정 실제 조회 조건과 실행 계획을 기준으로 결정

 

 

조인에서는 한쪽 테이블에서 확인한 연결값을 이용해 다른 테이블에서 일치하는 행을 찾아야 함.

이때 검색되는 쪽의 연결 컬럼에 인덱스가 있으면, 테이블 전체를 확인하지 않고 일치하는 값의 위치를 바로 탐색할 수 있음.

인덱스가 없는 경우

: 연결값 하나 확인 → 상대 테이블의 여러 행을 확인 → 일치하는 행 탐색

 

인덱스가 있는 경우

: 연결값 하나 확인 → 인덱스에서 같은 값 탐색 → 해당하는 행만 조회

 

 

/**
[테이블]

users
- id
- name

orders
- id
- user_id = 주문을 생성한 사용자의 users.id를 저장함
- amount
*/

-- 일반적으로 users.id는 기본키이므로 이미 인덱스가 있음 
-- 사용자별 주문을 빨리 찾으려면 orders.user_id에도 인덱스가 필요
CREATE INDEX idx_orders_user_id
ON orders (user_id);

SELECT users.name, orders.amount
FROM users
JOIN orders
  ON users.id = orders.user_id;


-- 조인 조건과 함께 추가 검색 조건을 자주 사용한다면 복합 인덱스도 검토
/**
[조회흐름]
1. users에서 사용자의 id확인
2. orders.user_id 인덱스에서 같은 값 탐색
3. 해당 사용자의 주문만 조회
4. 사용자와 주문 데이터 연결
*/

SELECT users.name, orders.amount
FROM users
JOIN orders
  ON users.id = orders.user_id;
  
-- order테이블에서 JOIN 조건인 user_id, WHERE 조건인 status
-- 조회 패턴에 따라 다음과 같은 복합 인덱스 고려
CREATE INDEX idx_orders_user_status
ON orders (user_id, status);

 

 


 

여러 조건을 함께 사용할 때의 인덱스 활용

 

인덱스는 각 절을 따로 처리하는 게 아니라, 

하나의 실행 과정에서 읽어야 하는 범위와 추가 작업을 얼마나 줄일 수 있는지를 기준으로 활용함

 

고려해야 할 것

확인할 부분 질문
JOIN 상대 테이블에서 어떤 컬럼으로 행을 반복 검색하는가
WHERE 어떤 조건이 조회 범위를 가장 많이 줄이는가
복합 인덱스 앞쪽 컬럼부터 조건이 이어지는가
범위 조건 범위 조건 뒤쪽 컬럼의 활용이 제한되지 않는가
ORDER BY 조건 적용 후 인덱스의 정렬 순서를 사용할 수 있는가
LIMIT 필요한 앞부분만 읽고 일찍 종료할 수 있는가

 

일반적인 처리 흐름

  1. JOIN이나 WHERE 조건으로 검색 범위를 좁힘
  2. 조건에 맞는 인덱스 범위를 읽음
  3. 가능하면 인덱스의 정렬 순서를 ORDER BY나 GROUP BY에 활용
  4. 필요한 실제 행이나 컬럼을 조회
 -- 특정 사용자의 주문을 반복해서 찾음
SELECT
    users.name,
    orders.amount,
    orders.created_at
FROM users
JOIN orders
  ON users.id = orders.user_id
WHERE orders.status = 'paid'
ORDER BY orders.created_at DESC
LIMIT 20;

-- [사용되는 컬럼]
-- user_id (join 조건), status(where 조건), created_at(order by 조건)

-- 고려하면 좋을 인덱스
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);

--------------------------------------------------------------------------------

-- 결제 완료 주문을 최신순으로 조회하는 형태
SELECT *
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

-- 고려하면 좋을 인덱스
CREATE INDEX idx_orders_status_created
ON orders (status, created_at DESC);

--------------------------------------------------------------------------------

 

 


 

EXPLAIN으로 인덱스 활용 확인

 

실제로 어떤 인덱스를 사용하는지는 데이터 분포, 조회 예상 행 수, 정렬 비용 등을 고려해 옵티마이저가 결정함.

 

개념

MySQL이 쿼리를 어떤 순서와 방식으로 처리할 예정인지 보여줌.

 

문법

EXPLAIN
SELECT ...

 

다음 항목을 중심으로 본다

각 항목은 따로 판단하지 않고 함께 확인해야 함

항목 의미 확인할 내용
type 테이블 접근 방식 전체 스캔인지, 인덱스 범위 조회인지
possible_keys 사용할 수 있다고 판단한 인덱스 후보 현재 조건에 맞는 인덱스가 후보에 포함됐는지
key 실제로 선택한 인덱스 예상한 인덱스를 사용했는지
key_len 실제 사용한 인덱스 키의 길이 (바이트 단위) 복합 인덱스의 어느 부분까지 사용했는지
ref 인덱스와 비교한 값 상수 또는 앞에서 읽은 테이블의 어떤 컬럼과 비교했는지
rows 확인할 것으로 예상한 행 수

- 작을수록 유리한 경우가 많지만, 결과 자체에 많은 쿼리라면 큰 값이 정상일 수 있음.
- InnoDB의 rows는 통계에 기반한 추정치이므로 실제 행 수와 다를 수 있음.
검색 범위가 충분히 줄어들었는지
filtered 읽은 행 중 조건을 통과할 것으로 예상한 비율 인덱스 탐색 후 추가로 얼마나 걸러지는지
Extra 인덱스 외에 어떤 처리가 발생하는가
(정렬·필터링 등 추가 처리 정보)
커버링, 별도 정렬, 임시 테이블 등이 발생했는지

 

EX]

-- 인덱스
CREATE INDEX idx_articles_status_created
ON articles (status, created_at);

-- 쿼리 실행계획 확인
EXPLAIN
SELECT id, title, created_at
FROM articles
WHERE status = 'published'
  AND created_at >= '2026-07-01'
ORDER BY created_at;

/** [결과]
type          : range
	=> 인덱스에서 조건에 맞는 범위를 읽음
possible_keys : idx_articles_status_created
	=> 현재 조건에서 사용할 수 있는 인덱스 후보
key           : idx_articles_status_created
	=> 해당 인덱스를 실제 실행 계획에서 선택함
key_len       : ...
rows          : 120
	=> 약 120개의 행을 확인할 것으로 예상함
Extra         : Using index condition
	=> 인덱스 항목에서 조건을 먼저 검사한 뒤, 필요한 실제 행을 조회함 
*/

 

type

값 의미 일반적인 해석
system 테이블에 행이 하나만 있음 매우 제한적인 특수 상황
const 기본 키나 유니크 인덱스로 최대 한 행 조회

ex) select * from users where id=10;
매우 좁은 검색
eq_ref 조인할 때 기본 키·유니크 인덱스로 한 행 조회 효율적인 조인
ref 일반 인덱스의 일치값에 해당하는 여러 행 조회

ex) select * from orders where user_id=10;
동등 조건에서 자주 발생
range 인덱스의 특정 범위 조회

ex) select * from orders where created_at >= '2026-07-01';
범위·IN·BETWEEN 등에서 발생
index_merge 여러 인덱스의 결과를 결합 여러 개별 인덱스를 함께 사용
index 인덱스 전체를 순서대로 읽음 전체 인덱스 스캔
ALL 테이블 전체를 읽음 전체 테이블 스캔

 

ref

값의 형태 의미
const 쿼리에 직접 지정한 상수와 비교
데이터베이스.테이블.컬럼 앞에서 읽은 테이블의 컬럼값과 비교
func 함수나 연산 결과와 비교
NULL 직접 비교값이 없는 범위 조회 등에 나타날 수 있음

 

ex)

-- 다음 조건에서는 상수와 비교 => ref=const
WHERE user_id = 10

-- 조인에서는 앞에서 읽은 테이블의 컬럼값과 비교할 수 있음 => ref = database.users.id
SELECT *
FROM users
JOIN orders
  ON users.id = orders.user_id;

 

Extra

값 의미 확인할 내용
Using index 인덱스만으로 필요한 결과를 조회 커버링 인덱스 사용
Using index condition 인덱스 항목에서 조건을 먼저 검사 실제 행 조회 전에 일부 조건 필터링
Using where 읽은 행에 조건을 적용해 추가 필터링 그 자체로 문제는 아님
Using filesort 인덱스 순서만으로 정렬하지 못해 별도 정렬 ORDER BY와 인덱스 순서 확인
Using temporary 쿼리 처리 중 임시 테이블 생성

- 특히, GROUP BY와 ORDER BY의 컬럼 구성이 서로 다를 때 나타날 수 있음.
GROUP BY, ORDER BY 조합 확인
Using index for group-by 인덱스를 효율적으로 이용해 그룹화 그룹별 일부 인덱스 항목만 읽을 수 있음
Backward index scan 인덱스를 역방향으로 읽음 내림차순 정렬 등에 사용
Using join buffer 조인 버퍼를 이용해 조인 처리 조인 컬럼의 인덱스와 실행 방식 확인
Range checked for each record 앞 테이블의 행마다 사용할 인덱스를 다시 검토 적절한 고정 인덱스가 없는지 점검

 

MySQL 공식문서에서는 특히 Using filesort와 Using temporary를 확인하도록 안내함. 

다만, 이 값이 있다고 무조건 느린 쿼리는 아니며, 처리할 행 수와 실제 실행 시간을 함께 판단해야 함.

 

확인 순서

  1. type → 테이블 전체를 읽는지, 특정 값이나 범위를 탐색하는지 확인
  2. possible_keys / key → 후보 인덱스와 실제 선택한 인덱스 확인
  3. key_len → 복합 인덱스의 어느 부분까지 탐색에 사용했는지 확인
  4.  rows / filtered → 예상 조회 범위와 추가 필터링 비율 확인
  5. Extra → 커버링, 별도 정렬, 임시 테이블 등 추가 작업 확인