Post

Real MySQL - SELECT

Real MySQL - SELECT

Real MySQL 8.0 : 11장 쿼리 작성 및 최적화 (2) - SELECT

들어가며

SELECT 문은 데이터베이스에서 가장 많이 사용되는 SQL 문이다. 단순히 데이터를 조회하는 것처럼 보이지만, 내부적으로는 여러 단계의 처리 과정을 거치며 인덱스 활용 여부에 따라 성능이 크게 달라진다. 이번 글에서는 Real MySQL 8.0 2권 11장의 SELECT 관련 내용 중 11.4.1 SELECT 절의 처리 순서 부터 11.4.9 ORDER BY 까지를 정리한다.


SELECT 절의 처리 순서

SELECT 문장을 작성할 때 각 절(FROM, WHERE, GROUP BY 등)은 특정 순서로 작성해야 하지만, 실제 처리 순서는 작성 순서와 다르다.

1
2
3
4
5
6
7
8
SELECT s.emp_no, COUNT(DISTINCT e.first_name) AS cnt
FROM salaries s
  INNER JOIN employees e ON e.emp_no = s.emp_no
WHERE s.emp_no IN (100001, 100002)
GROUP BY s.emp_no
HAVING AVG(s.salary) > 1000
ORDER BY AVG(s.salary)
LIMIT 10;

위 쿼리의 실제 처리 순서 는 다음과 같다.

1
2
3
4
5
6
7
1. FROM (및 JOIN)   - 테이블에서 데이터를 읽음
2. WHERE            - 조건에 맞는 데이터 필터링
3. GROUP BY         - 데이터를 그룹화
4. HAVING           - 그룹화된 결과 필터링
5. SELECT           - 출력할 칼럼 선택
6. ORDER BY         - 정렬
7. LIMIT            - 결과 건수 제한

이 순서를 이해하면 몇 가지 중요한 사실을 알 수 있다.

WHERE 절에서 SELECT의 별칭(Alias)을 사용할 수 없다. WHERE는 SELECT보다 먼저 처리되기 때문이다.

1
2
3
4
5
-- 에러 발생! cnt는 아직 정의되지 않음
SELECT emp_no, COUNT(*) AS cnt
FROM salaries
WHERE cnt > 5       -- 사용 불가
GROUP BY emp_no;

ORDER BY와 GROUP BY에서는 SELECT의 별칭을 사용할 수 있다. MySQL의 경우 이들 절은 SELECT 이후에 처리되므로 별칭 사용이 가능하다.

1
2
3
4
5
-- 정상 작동
SELECT emp_no, AVG(salary) AS avg_sal
FROM salaries
GROUP BY emp_no
ORDER BY avg_sal;   -- 별칭 사용 가능

예외: 서브쿼리가 포함된 경우

서브쿼리나 CTE(Common Table Expression)가 사용되면 처리 순서가 달라질 수 있다. 서브쿼리의 결과가 임시 테이블로 생성되고, 외부 쿼리에서 이를 참조하는 방식으로 처리된다.


WHERE 절과 GROUP BY 절, ORDER BY 절의 인덱스 사용

WHERE, GROUP BY, ORDER BY 절에서 인덱스를 사용하려면 특정 조건 을 만족해야 한다. 인덱스 사용 여부는 쿼리 성능에 결정적인 영향을 미친다.

인덱스를 사용하기 위한 기본 규칙

1. 인덱스 칼럼에 변형이 가해지면 인덱스를 사용할 수 없다.

1
2
3
4
5
6
-- 인덱스 사용 불가 (칼럼에 함수 적용)
SELECT * FROM salaries WHERE YEAR(from_date) = 2020;

-- 인덱스 사용 가능 (상수에 변형 적용)
SELECT * FROM salaries 
WHERE from_date >= '2020-01-01' AND from_date < '2021-01-01';

2. 비교 대상의 데이터 타입이 일치해야 한다.

1
2
3
4
5
6
-- string_column이 문자열인데 숫자와 비교하면
-- 모든 문자열을 숫자로 변환해야 하므로 인덱스 사용 불가
SELECT * FROM tab WHERE string_column = 10001;

-- 타입을 맞춰주면 인덱스 사용 가능
SELECT * FROM tab WHERE string_column = '10001';

WHERE 절의 인덱스 사용

WHERE 절에서 인덱스를 사용하려면 동등 비교(=)나 범위 비교(>, <, BETWEEN, IN) 조건이어야 한다.

복합 인덱스의 경우 인덱스 칼럼 순서가 중요하다. 인덱스가 (col1, col2, col3) 순서로 구성되어 있다면:

1
2
3
4
5
6
7
8
-- 인덱스 완전 활용
WHERE col1 = 'A' AND col2 = 'B' AND col3 = 'C'

-- col1, col2까지만 인덱스 활용 (col3은 인덱스 사용 불가)
WHERE col1 = 'A' AND col2 > 'B' AND col3 = 'C'

-- 인덱스 사용 불가 (선행 칼럼 col1이 없음)
WHERE col2 = 'B' AND col3 = 'C'

핵심 원칙: 복합 인덱스에서 범위 조건(>, <, BETWEEN) 이후의 칼럼은 인덱스를 타지 못한다.

GROUP BY 절의 인덱스 사용

GROUP BY 절에서 인덱스를 사용하려면 인덱스 칼럼 순서와 GROUP BY 칼럼 순서가 일치 해야 한다.

1
2
3
4
5
6
7
8
9
10
-- 인덱스: (col1, col2, col3)

-- 인덱스 사용 가능
GROUP BY col1
GROUP BY col1, col2
GROUP BY col1, col2, col3

-- 인덱스 사용 불가 (순서가 다름)
GROUP BY col2, col1
GROUP BY col1, col3  -- col2를 건너뜀

WHERE 절에서 동등 비교로 상수가 제공되면 GROUP BY에서 해당 칼럼을 생략할 수 있다.

1
2
3
-- 인덱스: (col1, col2, col3)
-- col1이 상수로 고정되었으므로 col2부터 GROUP BY 가능
WHERE col1 = 'A' GROUP BY col2, col3  -- 인덱스 사용 가능

ORDER BY 절의 인덱스 사용

ORDER BY도 GROUP BY와 유사한 규칙을 따르지만, 추가로 정렬 방향(ASC/DESC)이 인덱스와 일치 해야 한다.

1
2
3
4
5
6
7
8
-- 인덱스: (col1 ASC, col2 ASC, col3 ASC)

-- 인덱스 사용 가능
ORDER BY col1, col2, col3
ORDER BY col1 DESC, col2 DESC, col3 DESC  -- 역순도 OK

-- 인덱스 사용 불가 (정렬 방향 혼합)
ORDER BY col1 ASC, col2 DESC, col3 ASC

참고: MySQL 8.0부터는 혼합 정렬 방향 인덱스를 지원한다. 인덱스 생성 시 각 칼럼의 정렬 방향을 지정할 수 있다.

1
2
3
4
5
-- MySQL 8.0: 혼합 정렬 방향 인덱스 생성 가능
CREATE INDEX idx_mixed ON table_name (col1 ASC, col2 DESC, col3 ASC);

-- 위 인덱스가 있으면 아래 ORDER BY도 인덱스 사용 가능
ORDER BY col1 ASC, col2 DESC, col3 ASC

WHERE + ORDER BY 조합

WHERE 절과 ORDER BY 절이 같이 사용될 때 인덱스 활용 방법은 세 가지다.

  1. WHERE 절과 ORDER BY 절이 같은 인덱스 사용 : 가장 좋은 경우
  2. WHERE 절만 인덱스 사용 : ORDER BY는 Filesort로 처리
  3. ORDER BY 절만 인덱스 사용 : WHERE 조건은 인덱스 레코드를 읽으면서 필터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 인덱스: (emp_no, from_date)

-- 1. WHERE + ORDER BY 모두 인덱스 사용
SELECT * FROM salaries 
WHERE emp_no = 10001 
ORDER BY from_date;

-- 2. WHERE만 인덱스 사용 (ORDER BY는 Filesort)
SELECT * FROM salaries 
WHERE emp_no = 10001 
ORDER BY salary;

-- 3. ORDER BY만 인덱스 사용 (WHERE은 필터링)
SELECT * FROM salaries 
WHERE salary > 50000 
ORDER BY emp_no, from_date;


WHERE 절의 비교 조건 사용 시 주의사항

NULL 비교

MySQL에서 NULL은 “값이 없음” 을 의미하며, 일반 비교 연산자로 비교할 수 없다.

1
2
3
4
5
6
7
8
9
-- 잘못된 사용 (항상 결과 없음)
SELECT * FROM employees WHERE comm = NULL;

-- 올바른 사용
SELECT * FROM employees WHERE comm IS NULL;
SELECT * FROM employees WHERE comm IS NOT NULL;

-- NULL-safe 비교 연산자
SELECT * FROM employees WHERE comm <=> NULL;  -- TRUE 반환

문자열이나 숫자 비교

문자열과 숫자를 비교할 때 MySQL은 문자열을 숫자로 변환 한다. 이는 심각한 성능 문제를 야기할 수 있다.

1
2
3
4
5
6
-- char_column에 인덱스가 있어도 사용 불가
-- 모든 문자열을 숫자로 변환해야 하기 때문
SELECT * FROM tab WHERE char_column = 10;

-- 인덱스 정상 사용
SELECT * FROM tab WHERE char_column = '10';

숫자 칼럼에 문자열을 비교하는 것은 괜찮다. 문자열 상수가 숫자로 변환되므로 인덱스 사용에 문제가 없다.

1
2
-- int_column에 인덱스가 있으면 정상 사용
SELECT * FROM tab WHERE int_column = '10';

DATE와 DATETIME 비교

DATE와 DATETIME을 비교할 때 DATE는 자동으로 DATETIME으로 변환 된다(‘2024-01-01’ → ‘2024-01-01 00:00:00’).

1
2
3
4
5
6
7
8
-- datetime_column이 '2024-01-01 12:30:00'이면
SELECT * FROM tab WHERE datetime_column = '2024-01-01';
-- 결과 없음! ('2024-01-01 00:00:00'과 비교되기 때문)

-- 올바른 방법
SELECT * FROM tab 
WHERE datetime_column >= '2024-01-01' 
  AND datetime_column < '2024-01-02';

DATETIME과 TIMESTAMP 비교

DATETIME은 타임존 독립적 이고, TIMESTAMP는 UTC 기준으로 저장 된다. 두 타입을 비교할 때는 타임존 변환이 발생할 수 있으므로 주의해야 한다.

Short-Circuit Evaluation

MySQL은 단축 평가(Short-Circuit Evaluation)를 보장하지 않는다. WHERE 절에서 AND로 연결된 조건들의 평가 순서는 옵티마이저가 결정하며, 작성 순서와 다를 수 있다.

1
2
3
4
5
6
-- 두 조건의 평가 순서는 옵티마이저가 결정
SELECT * FROM employees 
WHERE MONTH(birth_date) = 1       -- 함수 연산
  AND first_name = 'Smith';       -- 인덱스 사용 가능

-- 옵티마이저는 인덱스를 사용할 수 있는 first_name 조건을 먼저 평가할 수 있음


DISTINCT

DISTINCT는 SELECT 절에서 조회하는 모든 칼럼의 조합이 유니크한 레코드 를 반환한다.

흔한 오해

1
2
3
-- 두 쿼리는 완전히 동일하다
SELECT DISTINCT first_name, last_name FROM employees;
SELECT DISTINCT(first_name), last_name FROM employees;

DISTINCT 뒤의 괄호는 함수가 아니라 그냥 무의미한 괄호 다. DISTINCT는 first_name만 유니크하게 만드는 것이 아니라 (first_name, last_name) 조합 이 유니크한 레코드를 반환한다.

집합 함수와 함께 사용된 DISTINCT

집합 함수 내에서 DISTINCT는 해당 칼럼의 유니크한 값 만 집계한다.

1
2
3
4
-- 서로 다른 의미
SELECT COUNT(DISTINCT first_name) FROM employees;  -- first_name 종류 수
SELECT COUNT(first_name) FROM employees;           -- NULL이 아닌 first_name 수
SELECT DISTINCT COUNT(first_name) FROM employees;  -- 전체 COUNT 결과 (DISTINCT 무의미)

집합 함수 내 DISTINCT는 임시 테이블을 사용 한다. COUNT(DISTINCT col1), COUNT(DISTINCT col2)처럼 두 번 사용하면 두 개의 임시 테이블이 생성되므로 성능에 주의해야 한다.

DISTINCT와 인덱스

DISTINCT는 정렬을 필요로 하므로 인덱스가 있으면 별도의 정렬 없이 처리 할 수 있다. 인덱스가 없으면 임시 테이블을 사용해 중복을 제거한다.


LIMIT n

LIMIT은 쿼리 결과에서 지정된 순서에 위치한 레코드만 가져오도록 한다.

LIMIT의 동작 방식

1
2
3
4
5
6
-- 10건만 필요하면 10건을 찾는 즉시 쿼리 실행 종료
SELECT * FROM employees LIMIT 10;

-- 하지만 GROUP BY나 ORDER BY가 있으면 다르다
SELECT * FROM employees ORDER BY last_name LIMIT 10;
-- 모든 레코드를 정렬한 후 10건 반환

중요: ORDER BY와 함께 사용할 때 LIMIT이 있어도 WHERE 조건에 맞는 모든 레코드를 먼저 가져와서 정렬해야 할 수 있다. 다만 MySQL 옵티마이저는 LIMIT이 있으면 필요한 만큼만 정렬하려고 최적화 한다.

LIMIT과 페이징의 성능 문제

1
2
3
4
5
-- 앞쪽 페이지는 빠름
SELECT * FROM employees LIMIT 0, 10;    -- 10건만 읽음

-- 뒤쪽 페이지는 느림
SELECT * FROM employees LIMIT 100000, 10;  -- 100,010건을 읽고 100,000건을 버림

OFFSET이 커질수록 읽고 버리는 레코드가 많아져 성능이 저하된다.

해결 방법은 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 비효율적인 방식
SELECT * FROM employees 
ORDER BY emp_no LIMIT 100000, 10;

-- 효율적인 방식 (커버링 인덱스 + 조인)
SELECT e.* 
FROM employees e
  INNER JOIN (
    SELECT emp_no FROM employees ORDER BY emp_no LIMIT 100000, 10
  ) t ON t.emp_no = e.emp_no;

-- 더 효율적인 방식 (No Offset)
SELECT * FROM employees 
WHERE emp_no > 100000 
ORDER BY emp_no LIMIT 10;


COUNT()

COUNT() 함수는 레코드의 건수를 반환 한다.

COUNT(*)와 COUNT(column)의 차이

1
2
3
4
5
6
7
8
-- 전체 행 수 (NULL 포함)
SELECT COUNT(*) FROM employees;

-- column이 NULL이 아닌 행 수
SELECT COUNT(column) FROM employees;

-- 유니크한 column 값의 수
SELECT COUNT(DISTINCT column) FROM employees;

*COUNT()는 레코드 자체를 세고, COUNT(column)은 NULL이 아닌 값을 센다.** 대부분의 경우 COUNT(*)를 사용하는 것이 의도에 맞다.

COUNT(*)의 최적화

MyISAM 스토리지 엔진은 테이블의 전체 레코드 수를 메타데이터로 관리하므로 WHERE 조건 없는 COUNT(*)가 매우 빠르다. 하지만 InnoDB는 이런 메타데이터가 없어서 항상 테이블이나 인덱스를 스캔해야 한다.

1
2
3
-- InnoDB에서도 커버링 인덱스를 활용하면 빠르게 처리 가능
SELECT COUNT(*) FROM employees WHERE dept_no = 'd001';
-- dept_no 인덱스만 스캔 (테이블 데이터 접근 없음)

COUNT()와 페이징

페이징에서 전체 건수를 구하기 위해 COUNT(*)를 실행하면 매번 전체 테이블을 스캔 해야 할 수 있다. 대안:

  1. 전체 건수를 별도 테이블에 캐싱 (INSERT/DELETE 시 업데이트)
  2. 대략적인 건수로 대체 (SHOW TABLE STATUS의 Rows)
  3. “더 보기” 방식 UI로 전체 건수 필요 없애기


JOIN

MySQL 8.0에서는 조인 조건에 따라 Nested Loop Join 또는 Hash Join 을 사용한다.

  • 조인 조건 칼럼에 인덱스가 있는 경우 : Nested Loop Join
  • 조인 조건 칼럼에 인덱스가 없는 경우 : Hash Join (MySQL 8.0.18부터 도입)

MySQL 8.0.20 이후로는 기존의 Block Nested Loop(BNL) 조인이 완전히 Hash Join으로 대체되었다.

드라이빙 테이블과 드리븐 테이블

1
2
3
SELECT * 
FROM employees e
  INNER JOIN salaries s ON s.emp_no = e.emp_no;

조인에서 먼저 읽는 테이블을 드라이빙(Driving) 테이블 , 나중에 읽는 테이블을 드리븐(Driven) 테이블 이라고 한다. Nested Loop Join은 다음과 같이 동작한다:

1
2
3
4
for each row in driving_table:      # 드라이빙 테이블의 각 행에 대해
    for each row in driven_table:   # 드리븐 테이블에서 조건에 맞는 행 검색
        if join_condition:
            output row

Nested Loop Join에서는 드리븐 테이블의 조인 조건 칼럼에 인덱스가 있어야 효율적이다. 인덱스가 없으면 옵티마이저는 Hash Join을 선택한다.

Hash Join

Hash Join은 조인 조건 칼럼에 인덱스가 없을 때 사용되며, 빌드(Build) 단계와 프로브(Probe) 단계로 나뉜다.

  1. 빌드 단계 : 작은 테이블(빌드 테이블)을 읽어 메모리에 해시 테이블 생성
  2. 프로브 단계 : 큰 테이블(프로브 테이블)을 읽으면서 해시 테이블과 매칭
1
2
3
4
5
6
7
-- emp_no에 인덱스가 있으면 Nested Loop Join
SELECT * FROM employees e 
  INNER JOIN salaries s ON s.emp_no = e.emp_no;

-- 인덱스가 없는 칼럼으로 조인하면 Hash Join
SELECT * FROM table_a a 
  INNER JOIN table_b b ON a.no_index_col = b.no_index_col;

Hash Join은 대량 데이터 처리에 효율적이지만, 조인 버퍼(join_buffer_size) 메모리를 사용한다.

INNER JOIN vs OUTER JOIN

INNER JOIN 은 양쪽 테이블 모두에 존재하는 레코드만 반환한다.

1
2
3
4
SELECT e.*, d.dept_name
FROM employees e
  INNER JOIN departments d ON e.dept_no = d.dept_no;
-- 부서가 없는 직원은 결과에서 제외됨

LEFT (OUTER) JOIN 은 왼쪽 테이블의 모든 레코드를 포함하고, 오른쪽 테이블에 매칭되는 레코드가 없으면 NULL로 채운다.

1
2
3
4
SELECT e.*, d.dept_name
FROM employees e
  LEFT JOIN departments d ON e.dept_no = d.dept_no;
-- 부서가 없는 직원도 포함 (dept_name은 NULL)

OUTER JOIN에서는 조인 순서가 고정 된다. LEFT JOIN에서는 왼쪽 테이블이 항상 드라이빙 테이블이다. 따라서 INNER JOIN이 옵티마이저가 최적화할 여지가 더 많다.

조인 최적화의 핵심

  1. 드리븐 테이블의 조인 조건 칼럼에 인덱스 가 있으면 Nested Loop Join으로 효율적 처리
  2. 인덱스가 없으면 Hash Join 이 사용되며, 조인 버퍼 크기가 성능에 영향
  3. WHERE 조건으로 읽는 레코드 수를 줄이는 것 이 중요하다
  4. 가능하면 OUTER JOIN보다 INNER JOIN 을 사용한다 (옵티마이저 최적화 여지가 더 많음)

지연된 조인(Delayed Join)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 비효율적: 조인 후 정렬
SELECT e.*, s.salary
FROM employees e
  INNER JOIN salaries s ON s.emp_no = e.emp_no
ORDER BY e.emp_no
LIMIT 10;

-- 효율적: 정렬 후 조인 (지연된 조인)
SELECT e.*, s.salary
FROM (
  SELECT emp_no FROM employees ORDER BY emp_no LIMIT 10
) t
  INNER JOIN employees e ON e.emp_no = t.emp_no
  INNER JOIN salaries s ON s.emp_no = t.emp_no;

지연된 조인은 필요한 최소한의 데이터로 정렬/필터링 후 조인 하여 성능을 개선한다.


GROUP BY

GROUP BY는 특정 칼럼의 값으로 레코드를 그룹화 하고, 각 그룹에 집합 함수(COUNT, SUM, AVG 등)를 적용한다.

GROUP BY의 인덱스 사용

GROUP BY 절의 칼럼이 인덱스 칼럼과 순서/구성이 일치 하면 인덱스를 사용할 수 있다.

1
2
3
4
5
6
7
-- 인덱스: (dept_no, emp_no)

-- 인덱스 사용 (인덱스 순서와 일치)
SELECT dept_no, COUNT(*) FROM dept_emp GROUP BY dept_no;

-- 인덱스 사용 불가 (인덱스 순서와 불일치)
SELECT emp_no, COUNT(*) FROM dept_emp GROUP BY emp_no;

GROUP BY와 정렬

MySQL 8.0부터 GROUP BY는 더 이상 암묵적인 정렬을 수행하지 않는다. 정렬이 필요하면 명시적으로 ORDER BY를 추가해야 한다.

1
2
3
4
5
-- MySQL 8.0: 정렬 순서 보장 안됨
SELECT dept_no, COUNT(*) FROM dept_emp GROUP BY dept_no;

-- 정렬이 필요하면 명시적으로 추가
SELECT dept_no, COUNT(*) FROM dept_emp GROUP BY dept_no ORDER BY dept_no;

HAVING vs WHERE

WHERE 는 GROUP BY 이전에 레코드를 필터링하고, HAVING 은 GROUP BY 이후에 그룹을 필터링한다.

1
2
3
4
5
6
7
8
9
10
11
-- WHERE: 개별 레코드 필터링 (GROUP BY 전)
SELECT dept_no, AVG(salary) 
FROM salaries 
WHERE salary > 50000       -- 개별 레코드 조건
GROUP BY dept_no;

-- HAVING: 그룹 필터링 (GROUP BY 후)
SELECT dept_no, AVG(salary) as avg_sal
FROM salaries 
GROUP BY dept_no
HAVING avg_sal > 60000;    -- 그룹 조건

가능하면 WHERE 절로 필터링하는 것이 좋다. WHERE로 데이터를 먼저 줄이면 GROUP BY가 처리할 데이터가 줄어든다.

WITH ROLLUP

WITH ROLLUP은 그룹별 합계와 전체 합계를 한 번에 조회 할 수 있게 한다.

1
2
3
4
5
6
7
8
SELECT dept_no, COUNT(*) 
FROM dept_emp 
GROUP BY dept_no WITH ROLLUP;

-- 결과:
-- d001, 20000
-- d002, 15000
-- NULL, 35000  <- 전체 합계


ORDER BY

ORDER BY는 쿼리 결과를 특정 칼럼 기준으로 정렬 한다.

ORDER BY의 인덱스 사용

ORDER BY가 인덱스를 사용하려면:

  1. 인덱스 칼럼 순서와 ORDER BY 칼럼 순서가 일치 해야 함
  2. 정렬 방향(ASC/DESC)이 인덱스와 일치 하거나 완전히 반대여야 함
  3. WHERE 절의 조건이 동등 비교 이면 ORDER BY에서 해당 칼럼 생략 가능
1
2
3
4
5
6
7
8
9
10
-- 인덱스: (col1, col2, col3) - 모두 ASC 기본값

-- 인덱스 사용
ORDER BY col1, col2, col3
ORDER BY col1 DESC, col2 DESC, col3 DESC  -- 완전 역순 OK

-- 인덱스 사용 불가 (기본 ASC 인덱스에서 방향 혼합)
ORDER BY col2, col1          -- 순서 불일치
ORDER BY col1 ASC, col2 DESC -- 방향 혼합 (혼합 방향 인덱스 없으면 불가)
ORDER BY col1, col3          -- col2 누락

Filesort

인덱스를 사용하지 못하면 MySQL은 Filesort 라는 별도의 정렬 처리를 수행한다. Filesort는 sort_buffer_size 메모리를 사용하고, 데이터가 많으면 디스크를 사용하므로 성능이 저하된다.

실행 계획의 Extra 칼럼에 “Using filesort”가 표시되면 인덱스를 사용하지 못한 것이다.

정렬 처리 방법

MySQL의 정렬 처리 방법은 세 가지다 (빠른 순서대로):

  1. 인덱스 사용 : 인덱스의 정렬 순서 그대로 사용 (가장 빠름)
  2. 조인의 드라이빙 테이블만 정렬 : 조인 전에 드라이빙 테이블을 먼저 정렬
  3. 조인 결과를 임시 테이블로 저장 후 정렬 : 가장 느림

세 번째 방법은 “Using temporary; Using filesort”가 함께 표시된다.

ORDER BY 최적화 팁

1. 정렬할 데이터 양을 줄인다 : WHERE 조건으로 레코드를 최대한 필터링

2. 인덱스를 활용할 수 있도록 ORDER BY 설계 : 자주 사용하는 정렬 순서를 인덱스로 생성

3. LIMIT과 함께 사용하면 최적화 가능 : MySQL 옵티마이저가 필요한 만큼만 정렬하도록 최적화

1
2
3
-- ORDER BY + LIMIT 조합은 성능에 유리
SELECT * FROM employees ORDER BY hire_date LIMIT 10;
-- 전체 정렬 대신 Top 10만 찾는 최적화 가능


마치며

이번 글에서는 SELECT 문의 처리 순서부터 ORDER BY까지 살펴보았다. 핵심 원칙을 정리하면 다음과 같다.

  • SELECT 절의 실제 처리 순서 는 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT 순이다
  • 인덱스를 효율적으로 사용하려면 복합 인덱스의 칼럼 순서를 고려하고, 칼럼에 함수를 적용하지 않아야 한다
  • 데이터 타입을 일치 시켜야 인덱스를 사용할 수 있다 (문자열 칼럼에 숫자 비교 주의)
  • DISTINCT는 SELECT의 모든 칼럼 조합 에 대해 유니크를 보장한다
  • LIMIT의 OFFSET이 커지면 성능 저하 가 발생하므로, 커버링 인덱스나 No Offset 방식을 고려한다
  • COUNT(*) 는 전체 행 수를, COUNT(column) 은 NULL이 아닌 행 수를 반환한다
  • 조인에서 드리븐 테이블의 조인 조건 칼럼에 인덱스 가 있으면 Nested Loop Join, 없으면 Hash Join이 사용된다
  • GROUP BY와 ORDER BY 가 인덱스를 활용하려면 인덱스 칼럼 순서와 일치해야 한다

References

  • Real MySQL 8.0 2권
This post is licensed under CC BY 4.0 by the author.