10장 실행 계획
MySQL 서버에서 보여주는 실행 계획을 읽고 이해하려면 서버가 데이터를 처리하는 로직을 이해해야한다.
10.1 통계 정보
테이블과 인덱스, 데이터 분포도를 수집해서 저장하는 히스토그램 정보를 통해 실행 계획을 수립한다.
10.1.1 테이블 및 인덱스 통계 정보
비용 기반 최적화에서 가장 중요한 것이 통계 정보이다.
서버의 통계 정보
InnoDB 스토리지 엔진을 사용하는 테이블에 대한 통계 정보를 영구적으로 관리할 수 있다. 이전에는 메모리에서만 관리되었는데, 서버가 재시작되면 모든 수집 정보가 사라진다. 따라서 통계 정보를 테이블로 관리할 수 있게 되었다.
10.1.2 히스토그램
이전에는 통계 정보가 단순히 인덱스된 칼럼의 유니크한 값 개수 정도만 가지고 있었다. 그래서 실제 인덱스의 일부 페이지를 랜덤으로 가져와 참조하는 방식을 사용하였고, 업그레이드되어 칼럼의 데이터 분포도를 참조할 수 있는 히스토그램 정보를 활용할 수 있다.
히스토그램은 버킷 단위로 구분되어 레코드 건수나 칼럼값의 범위가 관리된다.
히스토그램이 없으면 옵티마이저는 데이터가 균등하다 예측한다. 하지만 히스토그램을 통해 실제 특정 범우의 데이터가 많고 적음을 식별할 수 있다. 이를 통해 조인 순서를 바꾸는 등으로 쿼리 성능에 상당한 영향을 미칠 수 있다.
히스토그램과 인덱스는 완전히 다른 객체이지만 통계 정보 수집에서 어느 정도 공통점을 가진다. 인덱스를 통한 레코드 건수 예측을 위해 옵티마이저는 실제 인덱스의 B-Tree 를 샘플링해 살펴보며 이를 인덱스 다이브
라 표현한다.
10.1.3 코스트 모델
쿼리를 처리할 때 다양한 작업을 필요로 한다.
- 디스크로부터 데이터 페이지 읽기
- 메모리로부터 데이터 페이지 읽기
- 인덱스 키 비교
- 레코드 평가
- 메모리 임시 테이블 작업
- 디스크 임시 테이블 작업
MySQL 서버는 쿼리에 대해 다양한 작업이 얼마나 필요한지 예측하고 비용을 계산한 결과를 바탕으로 최적의 실행 계획을 찾는다. 이렇게 전체 쿼리 비용을 계산하는 데 필요한 단위 작업들의 비용을 코스트 모델이라 한다.
10.2 실행 계획 확인
실행 계획은 DESC
혹은 EXPLAIN
명령으로 확인할 수 있다.
쿼리의 실행 시간 정보를 확인할 수 있는 EXPLAIN ANALYZE
기능도 추가되었다.
10.3 실행 계획 분석
실행 계획이 어떤 접근 방법을 사용해서 어떤 최적화를 수행하는지 그리고 어떤 인덱스를 사용하는지 등을 이해하는 것이 중요하다.
id 칼럼
하나의 SELECT 문장은 다시 1개 이상의 하위 SELECT 문장을 포함할 수 있다.
mysql> SELECT ...
FROM (SELECT ... FROM tb_test1) tb1, tb_test2 tb2
WHERE tb1.id = tb2.id;
위 쿼리 문장에 있는 SELECT 쿼리를 다음과 같이 분리해서 생각해볼 수 있다.
mysql> SELECT ... FROM tb1_test1;
mysql> SELECT ... FROM tb1, tb_test2 tb2 WHERE tb1.id = tb2.id;
- 실행 계획에서 가장 왼쪽에 표시되는 id 컬럼은 단위 SELECT 쿼리별로 부여되는 식별자 값이다.
- 이 예제 쿼리의 경우 실행 계획에서 최소 2개의 id 값이 표시될 것이다.
- 하나의 SELECT 문장 안에서 여러 개의 테이블을 조인하면 조인되는 테이블의 개수만큼 실행 계획 레코드가 출력되지만 같은 id 값이 부여된다.
다음 예제는 SELECT 문장은 하나인데 여러 개의 테이블이 조인되는 경우에는 id 값이 증가하지 않고 같은 id 값이 부여된다.
mysql> EXPLAIN
SELECT e.emp_no, e.first_name, s.from_date, s.salary
FROM employees e, salaries s
WHERE e.emp_no=s.emp_no LIMIT 10;
반대로 다음 쿼리의 실행 계획에서는 쿼리 문장이 3개의 단위 SELECT 쿼리로 구성돼 있으므로 실행 계획의 각 레코드가 각기 다른 id 값을 지닌 것을 확인할 수 있다.
mysql> EXPLAIN
SELECT
( (SELECT COUNT(*) FROM employees) + (SELECT COUNT(*) FORM departments) ) AS total_count;
여기서 주의해야 할 것은 id 컬럼이 테이블의 접근 순서를 의미하지는 않는다는 것이다.
EXPLAIN FORMAT=TREE 명령으로 확인해보면 순서를 알 수 있다.
select_type 칼럼
각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시되는 컬럼이다. select_type 컬럼에 표시될 수 있는 값은 다음과 같다.
- SIMPLE: UNION이나 서브쿼리를 사용하지 않는 단순한 SELECT 쿼리
- PRIMARY: UNION이나 서브쿼리를 가지는 SELECT 쿼리의 실행 계획에서 가장 바깥쪽에 있는 단위 쿼리
- UNION: UNION으로 결합하는 단위 SELECT 쿼리 가운데 첫 번째를 제외한 두 번째 이후 단위 SELECT 쿼리
- DEPENDENT UNION: UNION 이나 UNION ALL로 결합된 쿼리가 외부 쿼리에 의해 영향을 받는 쿼리
- UNION RESULT: UNION 결과를 담아두는 테이블
MySQL 8.0 이전 버전에서는 UNION ALL이나 UNION 쿼리는 모두 임시 테이블을 생성했는데 MySQL 8.0 버전부터는 UNION ALL의 경우 임시 테이블을 사용하지 않도록 기능이 개선됐다.
따라서 UNION ALL을 하면 실행계획에 보이지 않는다. 하지만 UNION은 여전히 임시 테이블을 사용해 결과를 버퍼링한다. UNION RESULT는 실제 쿼리에서 단위 쿼리가 아니기 때문에 별도의 id 값은 부여되지 않는다.
- SUBQUERY: select_type의 SUBQUERY는 FROM 절 이외에서 사용되는 서브쿼리만을 의미한다.
mysql> EXPLAIN
SELECT e.first_name,
(SELECT COUNT(*)
FROM dept_emp de, dept_manager dm
WHERE dm.dept_no=de.dept_no) AS cnt
FROM employees e WHERE e.emp_no=10001;
- DEPENDENT SUBQUERY: 서브쿼리가 바깥쪽 SELECT 쿼리에서 정의된 칼럼을 사용하는 경우
mysql> EXPLAIN
SELECT e.first_name,
(SELECT COUNT(*)
FROM dept_emp de, dept_manager dm
WHERE dm.dept_no=de.dept_no) AS cnt
FROM employees e
WHERE e.first_name='Matt';
그래서 위의 예시를 살펴보면 FROM 절에 사용되는 쿼리는 DERIVED로 표기되고 IN 절에 사용된 쿼리는 DEPENDENT SUBQUERY로 표기된다.
- DERIVED: 단위 SELECT 쿼리의 실행 결과로 메모리나 디스크에 임시 테이블을 생성하는 것을 의미한다. select_type이 DERIVED인 경우에 생성되는 임시 테이블을 파생 테이블이라고도 한다.
- DEPENDENT DERIVED: 서브 쿼리 중 외부에 영향을 받는 첫 번째 쿼리 MySQL 8.0 버전부터 LATERAL JOIN 기능이 추가되면서 FROM 절의 서브쿼리에서도 외부 컬럼을 참조할 수 있게 됐다.
mysql> SELECT *
FROM employees e
LEFT JOIN LATERAL
(SELECT *
FROM salaries s
WHERE s.emp_no=e.emp_no
ORDER BY s.from_date DESC LIMIT 2) AS s2 ON s2.emp_no=e.emp_no
래터럴 조인의 경우에는 LATERAL 키워드를 사용해야 하며 LATERAL 키워드가 없는 서브쿼리에서 외부 컬럼을 참조하면 오류가 발생한다.
하나의 쿼리 문장에 서브쿼리가 하나만 있더라도 실제 그 쿼리가 한 번만 실행되는 것은 아니다. 그런데 조건이 똑같은 서브쿼리가 실행될 때는 다시 실행하지 않고 이전의 실행 결과를 그대로 사용할 수 있게 서브쿼리의 결과를 내부적인 캐시 공간에 담아둔다. 여기서 언급하는 서브쿼리 캐시는 쿼리 캐시나 파생 테이블(DERIVED)과는 전혀 무관한 기능이다.
- UNCACHEABLE SUBQUERY: SUBQUERY는 바깥쪽 영향을 받지 않으므로 처음 한 번만 실행해서 그 결과를 캐시하고 필요할 때 캐시 된 결과를 이용한다. DEPENDENT SUBQUERY는 바깥쪽 쿼리 컬럼의 값 단위로 캐시해두고 사용한다.
- UNCACHEABLE UNION:UNCACHEABLE과 UNION이 혼합된 select_type을 의미한다.
- MATERIALIZED:
table 칼럼
MySQL 서버의 실행 계획은 단위 SELECT 쿼리 기준이 아니라 테이블 기준으로 표시된다. 테이블의 이름에 별칭이 부여된 경우에는 별칭이 표시된다. 만약 별도의 테이블을 사용하지 않는 SELECT 쿼리인 경우에는 table 컬럼에 NULL이 표시된다. table 컬럼에 "<>"로 둘러싸인 이름이 명시되는 경우 이는 임시 테이블을 의미한다.
partitions 칼럼
MySQL 8.0 버전부터 EXPLATIN 명령으로 파티션 관련 실행 계획까지 모두 확인할 수 있게 변경됐다. 실행 계획에서 조회 조건에 맞는 파티션만 접근하고 불필요한 나머지 파티션에 대해서는 분석을 실행하지 않는다. 이를 파티션 프루닝이라고 한다.
실행 계획을 보면 파티션 특정 파티션만 접근했다는 것을 알 수 있다.
이때 주의해야될 점은 type에 ALL로 표기된다는 점이다.
partition은 물리적으로 개별 테이블처럼 별도의 저장 공간을 가지기 때문에 type에 파티션 일부만 읽는 쿼리라도 테이블 풀 스캔처럼 ALL로 표기된다.
type 칼럼
type 이후 컬럼은 MySQL 서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 나타낸다. MySQL의 메뉴얼에서는 type 컬럼을 조인 타입으로 소개한다. 또한 MySQL에서는 하나의 테이블로부터 레코드를 읽는 작업도 조인처럼 처리한다. 그래서 SELECT 쿼리의 테이블 개수에 관계없이 실행 계획의 type 컬럼을 조인 타입이라고 명시하고 있다.
ALL을 제외한 나머지 모두 인덱스를 사용하는 접근 방법이다. 하나의 단위 SELECT 쿼리는 위의 접근 방법 중에서 단 하나만 사용할 수 있다. 또한 index_merge를 제외한 나머지 방법은 하나의 인덱스만 사용한다.
- system: 레코드가 1건만 존재하는 테이블 또는 한 건도 존재하지 않는 테이블을 참조하는 형태의 접근 방법을 system 이라고 한다. 이 접근 방법은 InnoDB 스토리지 엔진을 사용하는 테이블에서는 나타나지 않는다.
- const: 테이블의 레코드 건수와 관계없이 쿼리가 프라이머리 키나 유니크 키 칼럼을 이용하는 WHERE 조건절을 가지고 있으며 반드시 1건을 반환하는 쿼리의 처리 방식을 const라고 한다. 다른 DBMS에서는 이를 유니크 인덱스 스캔이라고 표현한다.
- eq_ref: 조인에서 첫 번째 읽은 테이블의 컬럼 값을 이용해 두 번째 테이블을 프라이머리 키나 유니크 키로 동등 조건 검색(두 번쨰 테이블은 반드시 1건의 레코드만 반환)
- ref: 조인의 순서와 인덱스 종류에 관계없이 동등 조건으로 검색 (1건의 레코드만 반환된다는 보장이 없어도 됨)
eq_ref와는 달리 조인의 순서와 관계없이 사용되며 또한 프라이머리키나 유니크키 등의 제약조건도 없다. 인덱스의 종류와 관계없이 동등 조건으로 검색할 때는 ref 접근 방법이 사용된다.
- fulltext: 전문 검색(Full-text Search) 인덱스를 사용해 레코드를 읽는 접근 방법이다.
전문 검색용 인덱스가 테이블에 정의돼 있어야 오류가 발생하지 않고 사용이 가능하다.
전문 검색 인덱스를 사용하는 fulltext보다 일반 인덱스를 이용하는 range 접근 방법 더 빨리 처리되는 경우가 잦아서 조건별로 성능을 확인해봐야 한다. - ref_or_null: ref 접근 방법과 같은데 NULL 비교가 추가된 형태이다. 접근 방법의 이름 그대로 ref 방식 또는 NULL 비교 (IS NULL) 접근 방법을 의미한다
- unique_subquery: WHERE 조건절에 사용될 수 있는 IN 형태의 쿼리를 위한 접근 방법이다.
서브쿼리에서 중복되지 않는 유니크한 값만 반환할 때 이 접근 방법을 사용한다. IN(subquery) 형태의 조건에서 subquery의 반환 값에 중복이 없으므로 별도의 중복 제거 작업이 필요하지 않음 - index_subquery: IN 연산자의 특성상 IN(subquery) 또는 IN(상수 나열) 형태의 조건은 괄호 안에 있는 값의 목록중에서 중복된 값이 먼저 제거돼야 한다. 이때 서브쿼리 결과의 중복된 값을 인덱스를 이용해서 제거할 수 있을 때 index_subquery 접근 방법이 사용된다. IN(subquery) 형태의 조건에서 subquery의 반환 값에 중복된 값이 있을 수 있지만 인덱스를 이용해 중복된 값을 제거할 수 있음
- range: 레인지 스캔 형태의 접근 방법이다. range는 인덱스를 하나의 값이 아니라 범위로 검색하는 경우를 의미하는데 주로 <, >, IS NULL, BETWEEN, IN, LIKE 등의 연산자를 이용해 인덱스를 검색할 때 사용된다.
- index_merge: 2개 이상의 인덱스를 이용해 각각의 검색 결과를 만들어낸 후 그 결과를 병합해서 처리하는 방식이다. index_merge는 효율이 좋지 않다. 여러 인덱스를 읽어야 하므로 일반적으로 range 접근 방법보다 효율성이 떨어진다. 전문 검색 인덱스를 사용하는 쿼리에서는 index_merge가 적용되지 않는다. index_merge 접근 방법으로 처리된 결과는 항상 2개 이상의 집합이 되기 때문에 그 두 집합의 교집합이나 합집합 또는 중복 제거와 같은 부가적인 작업이 더 필요하다.
mysql> EXPLAIN
SELECT * FROM employees
WHERE emp_no BETWEEN 1001 AND 11000
OR first_name='Smith';
- index: index 접근 방법은 인덱스를 처음부터 끝까지 읽는 인덱스 풀 스캔을 의미한다.
index 접근 방법은 다음과 같은 조건을 충족하는 경우 사용된다.- range나 const, ref 같은 접근 방법으로 인덱스를 사용하지 못하는 경우
- 인덱스에 포함된 컬럼만으로 처리할 수 있는 쿼리인 경우
- range나 const, ref 같은 접근 방법으로 인덱스를 사용하지 못하는 경우
- 인덱스를 이용한 정렬이나 그루핑 작업이 가능한 경우
- ALL: 우리가 흔히 알고 있는 풀 테이블 스캔을 의미하는 접근 방법이다. 대량의 디스크 I/O를 유발하는 작업을 한꺼번에 많은 페이지를 읽어 들이는 기능인 리드 어헤드를 제공한다. 쿼리를 튜닝한다는 것이 무조건 풀 테이블 스캔, 풀 인덱스 스캔을 사용하지 못하게 하는 것은 아니다. 온라인 트랜잭션 환경(웹 서비스)에서는 적합하지 않다.
possible_keys 컬럼
MySQL 옵티마이저는 쿼리를 처리하기 위해 여러 가지 처리 방법을 고려하고 그중에서 비용이 가장 낮을 것으로 예상하는 실행 계획을 선택해 쿼리를 실행한다. 그런데 possible_keys 컬럼에 있는 내용은 옵티마이저가 최적의 실행 계획을 만들기 위해 후보로 선정했던 접근 방법에서 사용되는 인덱스의 목록일 뿐이다. 즉 말 그대로 '사용될법했던 인덱스 목록'인 것이다.
이 컬럼은 무시해되 되는데 절대 possible_keys 컬럼에 인덱스 이름이 나열됐다고 해서 그 인덱스를 사용한다고 판단하지 말자.
key 컬럼
최종 선택된 실행 계획에서 사용하는 인덱스를 의미한다.
PRIMARY인 경우에는 프라이머리 키를 사용한다는 의미이며 그 이외의 값은 모두 테이블이나 인덱스를 생성할 때 부여했던 고유 이름이다.
실행 게획의 type이 ALL일 때와 같이 인덱스를 전혀 사용하지 못하면 key 컬럼은 NULL로 표시된다.
MySQL 서버에 프라이머리 키는 별도의 이름을 부여할 수 없으며 기본적으로 PRIMARY 라는 이름을 가진 그 밖의 나머지 인덱스는 모두 테이블을 생성하거나 인덱스를 생성할 때 이름을 부여할 수 있다. 실행 계획뿐만 아니라 쿼리의 힌트를 사용할 때도 프라이머리 키를 지칭하고 싶다면 "PRIMARY"라는 키워드를 사용하면 된다.
key_len 컬럼
쿼리를 처리하기 위해 다중 컬럼으로 구성된 인덱스에서 몇 개의 컬럼까지 사용했는지 알 수 있다.
인덱스의 각 레코드에서 몇 바이트까지 사용했는지 알려주는 값이다.
mysql> EXPLAIN
SELECT * FROM dept_emp WHERE dept_no='d005';
두 개의 컬럼 (dpet_no, emp_no)으로 구성된 프라이머리 키를 가지는 dept_emp 테이블을 조회하는 쿼리이다.
이 쿼리는 dept_emp 테이블의 프라이머리 키 중에서 dept_no만 비교에 사용된다.
그래서 key_len 컬럼의 값이 16으로 표시된 것이다.
dept_no 컬럼의 타입이 CHAR(4)이기 때문에 프라이머리 키에서 앞쪽 16바이트만 유효하게 사용했다는 의미다.
실제 utf8mb4 문자 집합에서는 문자 하나가 차지하는 공간이 1바이트에서 4바이트까지 가변적이다. 하지만 MySQL 서버가 utf8mb4 문자를 위해 메모리 공간을 할당해야 할 때는 문자와 관계없이 고정적으로 4바이트로 계산한다.
테이블의 컬럼이 NULLABLE 컬럼으로 정의된 경우 MySQL에서는 NOT NULL이 아닌 컬럼에서는 컬럼의 값이 NULL인지 아닌지를 저장하기 위해 1바이트를 추가로 사용한다.
ref 컬럼
접근 방법이 ref면 참조 조건(Equal 비교 조건)으로 어떤 값이 제공됐는지 보여준다.
사용자가 명시적으로 값을 변환하거나 내부적으로 값을 변활할 때 ref 칼럼에 func라고 출력된다.
예를 들어 문자 집합이 일치하지 않은 두 문자열 칼럼 조인, 숫자 타입의 컬럼과 문자 타입의 컬럼으로 조인할 때
가급적 조인 칼럼의 타입은 일치시키는 것이 좋다.
rows 컬럼
MySQL 옵티마이저는 각 조건에 대해 가능한 처리 방식을 나열하고 각 처리 방식의 비용을 비교해 최종적으로는 하나의 실행 계획을 수립한다. 이때 각 처리 방식이 얼마나 많은 레코드를 읽고 비교해야 하는지 예측해서 비용을 산정한다. 대상 테이블에 얼마나 많은 레코드가 포함돼 있는지 또는 각 인덱스 값의 분포도가 어떤지를 통계 정보를 기준으로 조사해서 예측한다.
MySQL 실행 계획의 rows 컬럼 값은 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여준다. 이 값은 각 스토리지 엔진별로 가지고 있는 통계 정보를 참조해 MySQL 옵티마이저가 산출해 낸 예상값이라서 정확하지는 않다. 또한 rows 컬럼에 표시되는 값은 반환하는 레코드의 예측치가 아니라 쿼리를 처리하기 위해 얼마나 많은 레코드를 읽고 체크해야 하는지를 의미한다. 그래서 실행 계획의 rows 컬럼에 출력되는 값과 실제 쿼리 결과 반환된 레코드 건수는 일치하지 않는 경우가 많다.
filtered 컬럼
조인이 사용되는 경우에는 where 절에서 인덱스를 사용할 수 있는 조건도 중요 하지만 인덱스를 사용하지 못하는 조건에 일치하는 레코드 건수를 파악하는 것도 매우 중요하다.
filtered 칼럼의 값은 필터링되어 버려지는 레코드의 비율이 아니라 필터링되고 남은 레코드의 비율을 의미한다.
rows * (filtered / 100) = 수행한 레코드의 건수
일치하는 레코드 건수가 적은 테이블이 드라이빙 테이블이 되는 것이 조인의 횟수를 줄일 수 있어서 좋다.
'etc > Book' 카테고리의 다른 글
[객체지향의 사실과 오해] 3장 타입 추상화 (2일차) (1) | 2024.11.07 |
---|---|
[객체지향의 사실과 오해] 1장 협력하는 객체들의 공동체, 2장 이상한 나라의 객체 (1일차) (9) | 2024.11.04 |
[Real MySQL 8.0] 9장 옵티마이저와 힌트 (2) (8일차) (1) | 2024.11.01 |
[Real MySQL 8.0] 9장 옵티마이저와 힌트 (1) (7일차) (0) | 2024.10.31 |
[Real MySQL 8.0] 8장 인덱스 (2) (6일차) (1) | 2024.10.30 |