DBMS의 쿼리 실행에 같은 결과를 만들어 내는 데 한가지 방법만 있는 것은 아닙니다. 아주 많은 방법이 있지만 그중에서 어떤 방법이 최적이고 최소의 비용이 소모될지 결정해야 합니다. DBMS에서는 쿼리를 최적으로 실행하기 위해 각 테이블의 데이터가 어떤 분포로 저장돼 있는지 통계 정보를 참조하며, 그러한 기본 데이터를 비교해 최적의 실행 계획을 수립하는 작업이 필요합니다. DBMS에서는 옵티마이저가 이러한 기능을 담당합니다.
MySQL에서는 EXPLAIN이라는 명령으로 쿼리의 실행 계획을 확인할 수 있으며, 여기에는 많은 정보가 출력됩니다. 실행 계획에 표시되는 내용이 무엇을 의미하고 MySQL 서버가 내부적으로 어떤 작업을 하는지 자세히 살펴보겠습니다. 그리고 어떤 실행 계획이 좋고 나쁜지도 간단히 살펴보겠습니다.
실행 계획 개요
어떤 DBMS든지 쿼리의 실행 계획을 수립하는 옵티마이저는 가장 복잡한 부분으로 알려져 있으며, 옵티마이저가 만들어 내는 실행 계획을 이해하는 것 또한 상당히 어려운 부분입니다. 하지만 그 실행 계획을 이해할 수 있어야만 실행 계획의 불합리한 부분을 찾아내고, 더욱 최적화된 방법으로 실행 계획을 수립하도록 유도할 수 있습니다. 실행 계획을 살펴보기 전에, 먼저 알고 있어야 할 몇 가지 부분을 살펴 보겠습니다.
쿼리 실행 절차
MySQL 서버에서 쿼리가 실행되는 과정은 크게 3가지로 나눌 수 있습니다.
1. 사용자로부터 요청된 SQL 문장을 잘게 쪼개서 MySQL 서버가 이해할 수 있는 수준으로 분리한다.
2. SQL의 파싱 정보(파스 트리)를 확인하면서 어떤 테이블부터 읽고 어떤 인덱스를 이용해 테이블을 읽을지 선택한다.
3. 두번째 단계에서 결정된 테이블의 읽기 순서나 선택된 인덱스를 이용해 스토리지 엔진으로부터 데이터를 가져온다.
첫 번째 단계를 "SQL 파싱(Parsing)"이라고 하며, MySQL 서버의 "SQL 파서"라는 모듈로 처리합니다. 만약 SQL 문장이 문법적으로 잘못됐다면 이 단계에서 걸러집니다. 또한 이 단계에서 "SQL 파스 트리"가 만들어집니다. MySQL 서버는 SQL 문장 그 자체가 아니라 SQL 파스 트리를 이용해 쿼리를 실행합니다.
두 번째 단계는 첫 번째 단계에서 만들어진 SQL 파스 트리를 참조하면서, 다음과 같은 내용을 처리합니다.
불필요한 조건의 제거 및 복잡한 연산의 단순화
여러 테이블의 조인이 있는 경우 어떤 순서로 테이블을 읽을지 결정
각 테이블에 사용된 조건과 인덱스 통계 정보를 이용해 사용할 인덱스 결정
가져온 레코드들을 임시 테이블에 넣고 다시 한번 가공해야 하는지 결정
물론 이 밖에도 수많은 처리를 하지만, 대표적으로 이런 작업을 들 수 있습니다. 두 번째 단계는 "최적화 및 실행 계획 수립" 단계이며, MySQL 서버의 "옵티마이저"에서 처리합니다. 또한 두 번째 단계가 오나료되면 쿼리의 "실행 계획"이 만들어집니다.
세 번째 단계는 수립된 실행 계획대로 스토리지 엔진에 레코드를 읽어오도록 요청하고, MySQL 엔진에서는 스토리지 엔진으로부터 받은 레코드를 조인하거나 정렬하는 작업을 수행합니다.
첫 번째 단계와 두 번째 단계는 거의 MySQL 엔진에서 처리하며, 세 번째 단계는 MySQL 엔진과 스토리지 엔진이 동시에 참여해서 처리합니다. 아래 그림은 "SQL 파서"와 "옵티마이저"가 MySQL 전체적인 아키텍처에서 어느 위치에 있는지 보여줍니다.
옵티마이저의 종류
옵티마이저는 데이터베이스 서버에 두뇌와 같은 역할을 담당하고 있습니다. 옵티마이저는 현재 대부분의 DBMS가 선택하고 있는 비용 기반 최적화(Cost-based optimizer, CBO) 방법과 예전 오라클에서 많이 사용됐던 규칙 기반 최적화 방법(Rule-based optimizer, RBO)으로 크게 나눠 볼 수 있습니다.
규칙 기반 최적화는 기본적으로 대상 테이블의 레코드 건수나 선택도 등을 고려하지 않고 옵티마이저에 내장된 우선순위에 따라 실행 계획을 수립하는 방식을 의미합니다. 이 방식에서는 통계 정보(테이블의 레코드 건수나 칼럼 값의 분포도)를 조사하지 않고 실행 계획이 수립되기 때문에 같은 쿼리에 대해서는 거의 항상 같은 실행 방법을 만들어냅니다. 하지만 규칙 기반의 최적화는 이미 오래 전부터 많은 DBMS에서 거의 지원되지 않거나 업데이트되지 않은 상태로 그대로 남아 있는 것이 현실입니다.
비용 기반 최적화는 쿼리를 처리하기 위한 여러 가지 가능한 방법을 만들고, 각 단위 작업의 비용(부하) 정보와 대상 테이블의 예측된 통계 정보를 이용해 각 실행 계획별 비용을 산출합니다. 이렇게 산출된 각 실행 방법별로 최소 비용이 소요되는 처리 방식을 선택해 최종 쿼리를 실행하게 됩니다.
규칙 기반 최적화는 각 테이블이나 인덱스의 통계 정보가 거의 없고, 상대적으로 느린 CPU 연산 탓에 비용 계산 과정이 부담스러웠기 때문에 사용되던 최적화 방법입니다. 현재는 거의 대부분의 RDBMS가 비용 기반의 옵티마이저를 채택하고 있으며, MySQL 역시 마찬가지입니다.
통계 정보
비용 기반 최적화에서 가장 중요한 것은 통계 정보입니다. 통계 정보가 정확하지 않다면 전혀 엉뚱한 방향으로 쿼리를 실행해 버릴 수 있기 때문입니다. 예를 들어 1억 건의 레코드가 저장된 테이블의 통계 정보가 갱신되지 않아서 레코드가 10건 미만인 것처럼 돼 있다면 옵티마이저는 실제 쿼리 실행 시에 인덱스 레인지 스캔이 아니라 테이블을 처음부터 끝까지 읽는 방식(풀 테이블 스캔)으로 실행해 버릴 수도 있습니다. 부정확한 통계 정보 탓에 0.1초에 끝날 쿼리가 1시간이 소요될 수도 있습니다.
MySQL 또한 다른 DBMS와 같이 비용 기반의 최적화를 사용하지만 다른 DBMS보다 통계 정보는 그리 다양하지 않습니다. 기본적으로 MySQL에서 관리되는 통계 정보는 대략의 레코드 건수와 인덱스의 유니크한 값의 개수 정도가 전부입니다. 오라클과 같은 DBMS에서는 통계 정보가 상당히 정적이고 수집에 많은 시간이 소요되기 때문에 통계 정보만 따로 백업하기도 합니다. 하지만 MySQL에서 통계 정보는 사용자가 알아채지 못하는 순간순간 자동으로 변경되기 때문에 상당히 동적인 편입니다. 하지만 레코드 건수가 많지 않으면 통계 정보가 상당히 부정확한 경우가 많으므로 "ANALYZE" 명령을 이용해 강제적으로 통계 정보를 갱신해야 할 때도 있습니다. 특히 이런 현상은 레코드 건수가 얼마 되지 않는 개발용 MySQL 서버에서 자주 발생합니다.
MEMORY 테이블은 별도로 통계 정보가 없으며, MyISAM과 InnoDB의 테이블과 인덱스 통계 정보는 다음과 같이 확인할 수 있습니다. ANALYZE 명령은 인덱스 키값의 분포도(선택도)만 업데이트하며, 전체 테이블의 건수는 테이블의 전체 페이지 수를 이용해 예측합니다.
SHOW TABLE STATUS LIKE 'tb_test'\G SHOW INDEX FROM tb_test;
통계 정보를 갱신하려면 다음과 같이 ANALYZE를 실행하면 됩니다.
-- // 파티션을 사용하지 않는 일반 테이블의 통계 정보 수집 ANALYZE TABLE tb_test; -- // 파티션을 사용하는 테이블에서 특정 파티션의 통계 정보 수집 ANALYZE TABLE tb_test ANALYZE PARTITION p3;
ANALYZE를 실행하는 동안 MyISAM 테이블은 읽기는 가능하지만 쓰기는 안됩니다. 하지만 InnoDB 테이블은 읽기와 쓰기 모두 불가능하므로 서비스 도중에는 ANALYZE을 실행하지 않는 것이 좋습니다. MyISAM 테이블의 ANALYZE는 정확한 키값 분포도를 위해 인덱스 전체를 스캔하므로 많은 시간이 소요됩니다. 이와는 달리 InnoDB 테이블은 인덱스 페이지 중에서 8개 정도만 랜덤하게 선택해서 분석하고 그 결과를 인덱스의 통계 정보로 갱신합니다.
MySQL 5.1.38 미만 버전에서는 항상 랜덤하게 인덱스 페이지 8개만 읽어서 통계 정보를 수집하지만 MySQL 5.1.38 이상의 InnoDB 플러그인 버전에서는 분석할 인덱스 페이지의 개수를 "innodb_stats_sample_pages" 파라미터로 지정할 수 있습니다. 분석할 페이지 개수를 늘릴수록 더 정확한 통계 정보를 수집할 수 있겠지만 InnoDB의 통계정보는 다른 DBMS보다 훨씬 자주 수집되며 서비스 도중에도 통계 정보가 수집될 수 있습니다. InnoDB의 통계 수집을 위한 인덱스 페이지 개수는 기본값 8개에서 2~3배 이상을 벗어나지 않도록 설정하는 좋습니다.
실행 계획 분석
MySQL에서 쿼리의 실행 계획을 확인하려면 EXPLAIN 명령을 사용하면 됩니다. 아무런 옵션 없이 EXPLAIN 명령만 사용하면 기본적인 쿼리 실행 계획만 보입니다. 하지만 EXPLAIN EXTENDED나 EXPLAIN PARTITIONS 명령을 이용해 더 상세한 실행 계획을 확인할 수도 있습니다. 추가 옵션을 사용하는 경우에는 기본적인 실행 계획에 추가로 정보가 1개씩 더 표시됩니다.
우선 기본 실행 계획을 제대로 이해할 수 있어야 하므로 옵션이 없는 "EXPLAIN" 명령으로 조회하는 실행 계획을 자세히 살펴보겠습니다. 그리고 마지막에 PARTITIONS나 EXTENDED 옵션의 실행 계획을 확인하는 방법을 설명하겠습니다.
EXPLAIN 명령은 다음과 같이 EXPLAIN 키워드 뒤에 확인하고 싶은 SELECT 쿼리 문장을 적으면 됩니다. 실행 계획의 결과로 여러 가지 정보가 표 형태로 표시됩니다. 실행계획 중에는 possible_keys 항목과 같이 내용은 길지만 거의 쓸모가 없는 항목도 있습니다. 실행 계획의 여러 결과 중 꼭 필요한 경우를 제외하고는 모두 생략하고 표시합니다. 또한 실행 계획에서 NULL 값이 출력되는 부분은 모두 공백으로 표시합니다.
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;
EXPLAIN을 실행하면 쿼리 문장의 특성에 따라 표 형태로 된 1줄 이상의 결과가 표시됩니다. 표의 각 라인(레코드)은 쿼리 문장에서 사용된 테이블(서브 쿼리로 임시 테이블을 생성한 경우 그 임시 테이블까지 포함)의 개수만큼 출력됩니다. 실행 순서는 위에서 아래로 순서대로 표시된다(UNION이나 상관 서브쿼리와 같은 경우 순서대로 표시되지 않을 수 있습니다.) 출력된 실행 계획에서 위쪽에 출력된 결과일수록(id 칼럼의 값이 작을수록) 쿼리의 바깥(Outer) 부분이거나 먼저 접근한 테이블이고, 아래쪽에 출력된 결과일수록(id 칼럼의 값이 클수록) 쿼리의 안쪽(Inner) 부분 또는 나중에 접근한 테이블에 해당됩니다. 하지만 쿼리 문장과 직접 비교해 가면서 실행 계획의 위쪽부터 테이블과 매칭해서 비교하는 편이 더 쉽게 이해될 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e | index | ix_firstname | 44 |
| 300584 | Using index |
2 | SIMPLE | s | ref | PRIMARY | 4 | employees. | 4 |
|
다른 DBMS와는 달리 MySQL에서는 필요에 따라 실행 계획을 산출하기 위해 쿼리의 일부분을 직접 실행할 때도 있습니다. 때문에 쿼리 자체가 상당히 복잡하고 무거운 쿼리인 경우에는 실행 계획의 조회 또한 느려질 가능성이 있습니다. 그리고 UPDATE나 INSERT, DELETE 문장에 대해서는 실행 계획을 확인할 방법이 없습니다. UPDATE나 INSERT, DELETE 문장의 실행 계획을 확인하려면 WHERE 조건절만 같은 SELECT 문장을 만들어서 대략적으로 계획을 확인해 볼 수 있습니다.
이제부터는 실행 계획에 표시된 각 칼럼이 어떤 것을 의미하는지, 그리고 각 컬럼에 어떤 값들이 출력될 수 있는지 하나씩 자세히 살펴보겠습니다.
id 칼럼
하나의 SELECT 문장은 다시 1개 이상의 하위(SUB) SELECT 문장을 포함할 수 있습니다. 다음 쿼리를 살펴보겠습니다.
SELECT ... FROM (SELECT ... FROM tb_test1) tb1, tb_test2 tb2 WHERE tb1.id = tb2.id;
위의 쿼리 문장에 있는 각 SELECT를 다음과 같이 분리해서 생각해볼 수 있습니다. 이렇게 SELECT 키워드 단위로 구분한 것을 "단위 (SELECT) 쿼리"라고 표현하겠습니다.
SELECT ... FROM tb_test1; SELECT ... FROM tb1, tb_test2 tb WHERE tb1.id = tb2.d;
실행 계획에서 가장 왼쪽에 표시되는 id 칼럼은 단위 select 쿼리별로 부여되는 식별자 값입니다. 이 예제 쿼리의 경우, 실행 계획에서 최소 2개의 id 값이 표시될 것입니다.
만약 하나의 SELECT 문장 안에서 여러 개의 테이블을 조인하면 조인되는 테이블의 개수만큼 실행 계획 레코드가 출력되지만 같은 id가 부여됩니다. 다음 예제에서처럼 SELECT 문장은 하나인데 여러 개의 테이블이 조인되는 경우에는 id 값이 증가하지 않고 같은 id가 부여됩니다.
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;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e | index | ix_firstname | 44 |
| 300584 | Using index |
1 | SIMPLE | s | ref | PRIMARY | 4 | employees. | 4 |
|
반대로 다음 쿼리의 실행 계획에서는 쿼리 문장이 3개의 단위 SELECT 쿼리로 구성돼 있으므로 실행 계획의 각 레코드가 각기 다른 id를 지닌 것을 확인할 수 있습니다.
EXPLAIN SELECT ( (SELECT COUNT(*) FROM employees) + (SELECT COUNT(*) FROM departments) ) AS total_count;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | No tables used | ||||||
2 | SUBQUERY | departments | index | ux_deptname | 123 | 9 | Using index | |
3 | SUBQUERY | employees | index | ix_hiredate | 3 | 300584 | Using index |
select_type 칼럼
각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시되는 칼럼입니다. select_type 칼럼에 표시될 수 있는 값은 다음과 같습니다.
SIMPLE
UNION이나 서브 쿼리를 사용하지 않는 단순한 SELECT 쿼리인 경우, 해당 쿼리 문장의 select_type은 SIMPLE로 표시됩니다.(쿼리에 조인이 포함된 경우에도 마찬가지) 쿼리 문장이 아무리 복잡하더라도 실행 계획에서 select_type이 SIMPLE인 단위 쿼리는 반드시 하나만 존재합니다. 일반적으로 제일 바깥 SELECT 쿼리의 select_type이 SIMPLE로 표시됩니다.
PRIMARY
UNION이나 서브 쿼리가 포함된 SELECT 쿼리의 실행 계획에서 가장 바깥쪽(Outer)에 있는 단위 쿼리는 select_type이 PRIMARY로 표시됩니다. SIMPLE과 마찬자기로 select_type이 PRIMARY인 단위 SELECT 쿼리는 하나만 존재하며, 쿼리의 제일 바깥 쪽에 있는 SELECT 단위 쿼리가 PRIMARY로 표시됩니다.
UNION
UNION으로 결합하는 단위 SELECT 쿼리 가운데 첫 번째를 제외한 두 번째 이후 단위 SELECT 쿼리의 select_type은 UNION으로 표시됩니다. UNION의 첫 번째 단위 SELECT는 select_type이 UNION이 아니라 UNION 쿼리로 결합된 전체 집합의 select_type이 표시됩니다.
EXPLAIN SELECT * FROM ( (SELECT emp_no FROM employees e1 LIMIT 10) UNION ALL (SELECT emp_no FROM employees e2 LIMIT 10) UNION ALL (SELECT emp_no FROM employees e3 LIMIT 10) ) tb;
위 쿼리의 실행 계획은 다음과 같습니다. UNION이 되는 단위 SELECT 쿼리 3개 중에서 첫 번째(e 테이블)만 UNION이 아니고, 나머지 2개는 모두 UNION으로 표시돼 있습니다. 대신 UNION의 첫 번째 쿼리는 전체 UNION의 결과를 대표하는 select_type으로 설정됐습니다. 여기서는 세 개의 서브 쿼리로 조회된 결과를 UNION ALL로 결합해 임시 테이블을 만들어서 사용하고 있으므로 UNION ALL의 첫번째 쿼리는 DERIVED라는 select_type을 갖는 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 30 | ||||
2 | DERIVED | e1 | index | ix_hiredate | 3 |
| 300584 | Using index |
3 | UNION | e2 | index | ix_hiredate | 3 | 300584 | Using index | |
4 | UNION | e3 | index | ix_hiredate | 3 | 300584 | Using index | |
UNION SELECT | <union2,3,4> | ALL |
DEPENDENT UNION
DEPENDENT UNION select_type 또한 UNION select_type과 같이 쿼리에 UNION이나 UNION ALL로 집합을 결합하는 쿼리에서 표시됩니다. 그리고 여기서 DEPENDENT는 UNION이나 UNION ALL로 결합된 단위 쿼리가 외부의 영향에 의해 영향을 받는 것을 의미합니다. 다음의 예제 쿼리를 보면 두 개의 SELECT 쿼리가 UNION으로 결합됐으므로 select_type에 UNION이 표시된 것입니다. 그런데 UNION으로 결합되는 각 쿼리를 보면 이 서브 쿼리의 외부(Outer)에서 정의된 employees 테이블의 emp_no 칼럼을 사용하고 있음을 알 수 있습니다. 이렇게 내부 쿼리가 외부의 값을 참조해서 처리될 때 DEPENDENT 키워드가 select_type에 표시됩니다.
EXPLAIN SELECT e.first_name, ( SELECT CONCAT('Salary change count : ', COUNT(*)) AS message FROM salaries s WHERE s.emp_no = e.emp_no UNION SELECT CONCAT('Department change count : ', COUNT(*)) AS message FROM dept_emp de WHERE de.emp_no = e.emp_no ) AS message FROM employees e WHERE e.emp_no = 10001;
위 예제는 조금 억지스럽긴 하지만 UNION에 사용된 SELECT 쿼리에 아우터에 정의된 employees 테이블의 emp_no 칼럼이 사용됐기 때문에 DEPENDENT UNION이라 select_type에 표시된 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 1 | |
2 | DEPENDENT | s | ref | PRIMARY | 4 | const | 17 | Using index |
3 | DEPENDENT | de | ref | ix_empno | 3 | 1 | Using where; | |
UNION RESULT | <union2,3,> | ALL |
하나의 단위 SELECT 쿼리가 다른 단위 SELECT를 포함하고 있으면 이를 서브 쿼리라고 표현합니다. 이처럼 서브 쿼리가 사용된 경우에는 외부(Outer) 쿼리보다 서브 쿼리가 먼저 실행되는 것이 일반적이며, 대부분이 이 방식의 반대의 경우보다 빠르게 처리됩니다. 하지만 select_type에 "DEPENDENT" 키워드를 포함하는 서브 쿼리는 외부 쿼리에 의존적이므로 절대 외부 쿼리보다 먼저 실행될 수가 없습니다. 그래서 select_type에 "DEPENDENT" 키워드가 포함된 서버 쿼리는 비효율적인 경우가 많습니다.
UNION RESULT
UNION 결과를 담아두는 테이블을 의미합니다. MySQL에서 UNION ALL이나 UNION (DISTINCT) 쿼리는 모두 UNION의 결과를 임시 테이블로 생성하게 되는데, 실행 계획상에서 이 임시 테이블을 가리키는 라인의 select_type이 UNION RESULT입니다. UNION RESULT는 실제 쿼리에서 단위 쿼리가 아니기 때문에 별도로 id 값은 부여되지 않습니다.
EXPLAIN SELECT emp_no FROM salaries WHERE salary>100000 UNION ALL SELECT emp_no FROM dept_emp WHERE from_date > '2001-01-01';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | salaries | Range | ix_salary | 4 | 171094 | Using where; | |
2 | UNION | dept_emp | Range | ix_fromdate | 3 |
| 10458 | Using where; |
UNION SELECT | <union1,2> | ALL |
위 실행 계획의 마지막 "UNION RESULT" 라인의 table 칼럼은 "<union1,2>"로 표시돼 있는데, 이것은 id가 1번인 단위 쿼리의 조회 결과와 id가 2번인 단위 쿼리의 조회 결과를 UNION했다는 것을 의미합니다.
SUBQUERY
일반적으로 서브 쿼리라고 하면 여러 가지를 통틀어서 이야기할 때가 많은데, 여기서 SUBQUERY라고 하는 것은 FROM 절 이외에서 사용되는 서브 쿼리만을 의미합니다.
EXPLAIN SELECT e.first_name, (SELECT COUNT(*) FROM dept_name, dept_manager dm WHERE dm.dept_no=de.dept_no) AS cnt FROM employees e WHERE e.emp_no = 10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 171094 |
|
2 | SUBQUERY | dm | index | PRIMARY | 16 |
| 24 | Using index |
2 | SUBQUERY | de | ref | PRIMARY | 12 | employees.dm | 18603 | Using index |
MySQL의 실행 계획에서 FROM 절에 사용된 서브 쿼리는 select_type이 DERIVED라고 표시되고, 그 밖의 위치에서 사용된 서브 쿼리는 전부 SUBQUERY라고 표시됩니다. 그리고 이 책이나 MySQL 매뉴얼에서 사용하는 "파생 테이블"이라는 단어는 DERIVED와 같은 의미로 이해하면 됩니다.
서브 쿼리는 사용되는 위치에 따라 각각 다른 이름을 지니고 있습니다.
중첩된 쿼리(Nested Query)
SELECT 되는 칼럼에 사용된 서브 쿼리를 네스티드 쿼리라고 합니다.
서브 쿼리(Sub Query)
WHERE 절에 사용된 경우에는 일반적으로 그냥 서브 쿼리라고 합니다.
파생 테이블(Derived)
FROM 절에 사용된 서브 쿼리를 MySQL에서는 파생 테이블이라고 하며, 일반적으로 RDBMS 전체적으로 인라인 뷰(Inline View) 또는 서브 셀렉트(Sub Select)라고 부르기도 합니다.
또한 서브 쿼리가 반환하는 값의 특성에 따라 다음과 같이 구분하기도 합니다.
스칼라 서브 쿼리(Scalar SubQuery)
하나의 값만(칼럼이 단 하나인 레코드 1건만) 반환하는 쿼리
로우 서브 쿼리(Row Sub Query)
칼럼의 개수에 관계없이 하나의 레코드만 반환하는 쿼리
DEPENDENT SUBQUERY
서브 쿼리가 바깥쪽(Outer) SELECT 쿼리에서 정의된 칼럼을 사용하는 경우를 DEPENDENT SUBQEURY라고 표현합니다. 다음의 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT e.first_name, (SELECT COUNT(*) FROM dept_emp de, dept_manager dm WHERE dm.dept_no=de.dept_no AND de.emp_no=e.emp_no) AS cnt FROM employees e WHERE e.emp_no=10001;
이럴 때는 안쪽(Inner)의 서브 쿼리 결과가 바깥쪽(Outer) SELECT 쿼리의 칼럼에 의존적이라서 DEPENDENT라는 키워드가 붙습니다. 또한 DEPENDENT UNION과 같이 DEPENDENT SUBQUERY 또한 외부 쿼리가 먼저 수행된 후 내부 쿼리(서브 쿼리)가 실행돼야 하므로 (DEPENDENT 키워드가 없는) 일반 서브 쿼리보다는 처리 속도가 느릴 때가 많습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 1 |
|
2 | DEPENDENT | de | ref | ix_empno | 4 |
| 1 | Using index |
2 | DEPENDENT | dm | ref | PRIMARY | 12 | de.dept_no | 18603 | Using index |
DERIVED
서브 쿼리가 FROM 절에 사용된 경우 MySQL은 항상 select_type이 DERIVED인 실행 계획을 만듭니다. DERIVED는 단위 SELECT 쿼리의 실행 결과를 메모리나 디스크에 임시 테이블을 생성하는 것을 의미합니다. select_type이 DERIVED인 경우에 생성되는 임시 테이블을 파생 테이블이라고도 합니다. 안타깝게도 MySQL은 FROM 절에 사용된 서브 쿼리를 제대로 최적화하지 못할 때가 대부분입니다. 파생 테이블에는 인덱스가 전혀 없으므로 다른 테이블과 조인할 때 성능상 불리할 때가 많습니다.
EXPLAIN SELECT * FROM (SELECT de.emp_no FROM dept_demp de) tb, employees e WHERE e.emp_no=tb.emp_no;
사실 위의 쿼리는 FROM 절의 서브 쿼리를 간단히 제거하고 조인으로 처리할 수 있는 형태입니다. 실제로 다른 DBMS에서는 이렇게 쿼리를 재작성하는 형태의 최적화 기능도 제공합니다. 하지만 다음 실행 계획을 보면 알 수 있듯이 MySQL에서는 FROM 절의 서브 쿼리를 임시 테이블로 만들어서 처리합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 331603 | ||||
2 | PRIMARY | e | eq_ref | PRIMARY | 4 | tb.emp_no | 1 | |
2 | DERIVED | de | index | ix_fromdate | 3 | 334868 | Using index |
MySQL 6.0 이상 버전부터는 FROM 절의 서브 쿼리에 대한 최적화 부분이 많이 개선될 것으로 알려졌으며 다들 많이 기대하는 상태입니다. 하지만 그전까지는 FROM 절의 서브 쿼리는 상당히 신경 써서 개발하고 튜닝해야 합니다. 현재 많이 사용되는 MySQL 5.0 5.1 버전에서는 조인이 상당히 최적화돼 있는 편입니다. 가능하다면 DERIVED 형태의 실행 계획을 조인으로 해결할 수 있게 바꿔주는 것이 좋습니다.
쿼리를 튜닝하기 위해 실행 계획을 확인할 때 가장 먼저 select_type 칼럼의 값이 DERIVED인 것이 있는지 확인해야 합니다. 다른 방법이 없어서 서브 쿼리를 사용하는 것은 피할 수 없습니다. 하지만 조인으로 해결할 수 있는 경우라면 서브 쿼리보다는 조인을 사용할 것을 강력히 권장합니다. 실제로 기능을 조금씩 단계적으로 추가하는 형태로 쿼리를 개발합니다. 이러한 개발 과정 때문에 대부분의 쿼리가 조인이 아니라 서브 쿼리 형태로 작성되는 것입니다. 물론 이런 절차로 개발하는 것이 생산성은 높겠지만 쿼리의 성능은 더 떨어집니다. 쿼리를 서브 쿼리 형태로 작성하는 것이 편하다면 반드시 마지막에는 서브쿼리를 조인으로 풀어서 고쳐 쓰는 습관을 들이는 것이 좋습니다. 그러면 어느 순간에는 서브 쿼리로 작성하는 단계 없이 바로 조인으로 복잡한 쿼리를 개발할 수 있을 것입니다.
UNCACHEABLE SUBQUERY
하나의 쿼리 문장에서 서브 쿼리가 하나만 있더라도 실제 그 서브 쿼리가 한 번만 실행되는 것은 아닙니다. 그런데 조건이 똑같은 서브 쿼리가 실행될 때는 다시 실행하지 않고 이전의 실행 결과를 그대로 사용할 수 있게 서브 쿼리의 결과를 내부적인 캐시 공간에 담아둡니다. 여기서 언급하는 서브 쿼리 캐시는 쿼리 캐시나 파생 테이블(DERIVED)와는 전혀 무관한 기능이므로 혼동하지 않도록 주의해야 합니다.
간단히 SUBQUERY와 DEPENDENT SUBQUERY가 캐시를 사용하는 방법을 비교해 보겠습니다.
SUBQUERY는 바깥쪽(Outer)의 영향을 받지 않으므로 처음 한 번만 실행해서 그 결과를 캐시하고 필요할 때 캐시된 결과를 이용합니다.
DEPENDENT SUBQUERY는 의존하는 바깥쪽(Outer) 쿼리의 칼럼의 값 단위로 캐시해두고 사용합니다.
위 그림은 select_type이 SUBQUERY인 경우 캐시를 사용하는 방법을 표현한 것입니다. 캐시가 처음 한 번만 생성됩니다. 하지만 DEPENDENT SUBQUERY는 서브 쿼리 결과가 딱 한 번만 캐시되는 것이 아니라 외부(Outer) 쿼리의 값 단위로 캐시가 만들어지는(즉, 위의 그림이 차례대로 반복되는 구조) 방식으로 처리됩니다.
select_type이 SUBQUERY인 경우와 "UNCACHEABLE SUBQUERY"는 이 캐시를 사용할 수 있느냐 없느냐에 차이가 있습니다. 서브 쿼리에 포함된 요소에 의해 캐시 자체가 불가능할 수가 있는데, 이 경우 select_type이 UNCACHEABLE SUBQUERY로 표시됩니다. 캐시를 사용하지 못하도록 하는 요소로는 대표적으로 다음과 같은 것들이 있습니다.
사용자 변수가 서브 쿼리에 사용된 경우
NOT-DETERMINISTIC 속성의 스토어드 루틴이 서브 쿼리 내에 사용된 경우
UUID()나 RAND()와 같이 결과값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우
다음은 사용자 변수(@status)가 사용된 쿼리 예제입니다. 이 경우 WHERE 절에 사용된 단위 쿼리의 select_type은 UNCACHEABLE SUBQUERY로 표시되는 것을 확인할 수 있습니다.
EXPLAIN SELECT * FROM employees e WHERE e.emp_no = ( SELECT @status FROM dept_emp de WHERE de.dept_no='d005' );
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | ALL | 300584 | Using where | |||
2 | UNCACHEABLE SUBQUERY | de | ref | PRIMARY | 12 | const | 53288 | Using where; Using index |
UNCACHEABLE UNION
이미 UNION과 UNCACHEABLE에 대해서는 충분히 설명했으므로 기본적인 의미는 쉽게 이해했을 것입니다. UNCACHEABLE UNION이란 이 두 개의 키워드의 속성이 혼합된 select_type을 의미합니다. UNCACHEABLE UNION은 MySQL 5.0에서는 표시되지 않으며, MySQL 5.1부터 추가된 select_type입니다.
table 칼럼
MySQL의 실행 계획은 단위 SELECT 쿼리 기준이 아니라 테이블 기준으로 표시됩니다. 만약 테이블의 이름에 별칭이 부여된 경우에는 별칭이 표시됩니다. 다음 예제 쿼리와 같이 별도의 테이블을 사용하지 않는 SELECT 쿼리인 경우에는 table 칼럼에 NULL이 표시됩니다.
EXPLAIN SELECT NOW(); EXPLAIN SELECT NOW() FROM DUAL;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | (NULL) | No tables used |
일부 DBMS에서는 SELECT 문장이 반드시 FROM 절을 가져야 하는 제약이 있습니다. 이를 위해 DUAL이라는 스칼라(칼럼 1개짜리 레코드 1개를 가지는) 값을 가지는 테이블을 사용하곤 하는데, MySQL에서는 FROM 절이 없이도 쿼리 실행에 특별히 문제가 되지는 않습니다. 하지만 다른 DBMS와의 호환을 위해 "FROM DUAL"로 사용해도 문제없이 실행됩니다. 위 예제에서 두 번째 쿼리의 "FROM DUAL"은 없어도 무방합니다. 즉, 첫번째 쿼리와 두번째 쿼리는 같은 쿼리입니다.
Table 칼럼에 <derived> 또는 <union>과 같이 "<>"로 둘러싸인 이름이 명시되는 경우가 많은데, 이 테이블은 임시 테이블을 의미합니다. 또한 "<>" 안에 항상 표시되는 숫자는 단위 SELECT 쿼리의 id를 지칭합니다. 다음 실행 계획을 한번 살펴 보겠습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 10420 | ||||
1 | PRIMARY | e | eq_ref | PRIMARY | 4 | de1.emp_no | 1 | |
2 | DERIVED | dept_emp | range | ix_fromdate | 3 | 20550 |
위의 예에서 첫번째 라인의 table 칼럼의 값이 <derived 2>인데, 이것은 단위 SELECT 쿼리의 아이디가 2번인 실행 계획으로부터 만들어진 파생 테이블을 가리킵니다. 단위 SELECT 쿼리의 id 2번(실행 계획의 최하위 라인)은 dept_emp 테이블로부터 SELECT된 결과가 저장된 파생 테이블이라는 점을 알 수 있습니다.
실행계획의 id 칼럼과 select_type 그리고 table 칼럼 총 3개의 칼럼은 실행 계획의 각 라인에 명시된 테이블이 어떤 순서로 실행되는지를 판단하는 근거를 표시해줍니다. 그러면 이 3개의 칼럼만으로 위의 실행 계획을 분석해보겠습니다.
1. 첫번째 라인의 테이블이 <derived2>라는 것을 보아 이 라인보다 쿼리의 id가 2번인 라인이 먼저 실행되고 그 결과가 파생 테이블로 준비돼야 한다는 것을 알 수 있다.
2. 세 번째 라인의 쿼리 id 2번을 보면, select_type 칼럼의 값이 DERIVED로 표시돼 있다. 즉, 이 라인은 table 칼럼에 표시된 dept_emp 테이블을 읽어서 파생 테이블을 생성하는 것을 알 수 있다.
3. 세 번째 라인의 분석이 끝났으므로 다시 실행 계획의 첫 번째 라인으로 돌아가자.
4. 첫 번째 라인과 두 번째 라인은 같은 id 값을 가지고 있는 것으로 봐서 2개 테이블(첫번째 라인의 <derived2>와 두번째 라인의 e테이블)이 조인되는 쿼리라는 사실을 알 수 있습니다. 그런데 <derived2> 테이블이 e 테이블보다 먼저 (윗 라인에) 표시됐기 때문에 <derived2>가 드라이빙 테이블이 되고, e 테이블이 드리븐 테이블이 된다는 것을 알 수 있다. 즉, <derived2> 테이블을 먼저 읽어서 e 테이블로 조인을 실행했다는 것을 알 수 있다.
이제 MySQL에서 쿼리의 실행 계획을 어떤 순서로 읽는지 대략 파악됐을 것입니다. 방금 분석해 본 실행 계획의 실제 쿼리를 한번 살펴보겠습니다.
SELECT * FROM (SELECT de.emp_no FROM dept_emp de) tb, employees e WHERE e.emp_no = tb.emp_no;
MySQL은 다른 DBMS와 달리 FROM 절에 사용된 서브 쿼리(Derived, 파생 테이블)는 반드시 별칭을 가져야 합니다. 그렇지 않으면 별칭이 부여되지 않았다는 에러 메시지가 출력되고 쿼리는 실행되지 않을 것입니다. 쿼리를 작성하거나 실행 계획을 확인할 때는 임시 테이블의 별칭을 잊지말고 반드시 명시해야 합니다.
mysql> SELECT dttm FROM (SELECT NOW() AS dttm);
ERROR 1248 (42000): Every derived table must have its own alias
mysql> SELECT dttm FROM (SELECT NOW() AS dttm) derived_table_alias;
type 칼럼
쿼리의 실행 계획에서 type 이후의 칼럼은 MySQL 서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 의미합니다. 여기서 방식이라 함은 인덱스를 사용해 레코드를 읽었는지 아니면 테이블을 처음부터 끝까지 읽는 풀 테이블 스캔으로 레코드를 읽었는지 등을 의미합니다. 일반적으로 쿼리를 튜닝할 때 인덱스를 효율적으로 사용하는지 확인하는 것이 중요하므로 실행 계획에서 type 칼럼은 반드시 체크해야할 중요한 정보입니다.
MySQL의 매뉴얼에서는 type 칼럼을 "조인 타입"으로 소개합니다. 또한 MySQL에서는 하나의 테이블로부터 레코드를 읽는 작업도 조인처럼 처리합니다. 그래서 SELECT 쿼리의 테이블 개수에 관계없이 실행계획의 type 칼럼을 "조인 타입"이라고 명시하고 있습니다. 하지만 크게 조인과 연관 지어 생각하지 말고, 각 테이블의 접근 방식(Access type)으로 해석하면 됩니다.
실행 계획의 type 칼럼에 표시될 수 있는 값은 버전에 따라 조금씩 차이가 있을 수 있지만, 현재 많이 사용되는 MySQL 5.0과 5.1 버전에서는 다음과 같은 값이 표시됩니다.
system const eq_ref
ref fulltext ref_or_null
unique_subquery index_subquery range
index_merge index ALL
위의 12가지 접근 방법 중에서 하단의 ALL을 제외한 나머지는 모두 인덱스를 사용하는 접근 방법입니다. ALL은 인덱스를 사용하지 않고, 테이블을 처음부터 끝까지 읽어서 레코드를 가져오는 풀 테이블 스캔 접근 방식을 의미합니다.
하나의 단위 SELECT 쿼리는 위의 접근 방법 중에서 단 하나만 사용할 수 있습니다. 또한 index_merge를 제외한 나머지 접근 방법은 반드시 하나의 인덱스만 사용합니다. 그러므로 실행 계획의 각 라인에 접근 방법이 2개 이상 표시되지 않으며, index_merge 이외의 type에서는 인덱스 항목에도 단 하나의 인덱스 이름만 표시됩니다.
이제 실행 계획의 type 칼럼에 표시될 수 있는 값을 위의 순서대로 하나씩 살펴보겠습니다. 참고로 위에 표시된 각 접근 방식은 성능이 빠른 순서대로 나열된 것(MySQL에서 부여한 우선순위임)이며, 각 type의 설명도 이 순서대로 진행할 것입니다. MySQL 옵티마이저는 이런 접근 방식과 비용을 함께 계산해서 최소의 비용이 필요한 접근 방식을 선택합니다.
system
레코드가 1건만 존재하는 테이블 또는 한건도 존재하지 않는 테이블을 참조하는 형태의 접근 방법을 system이라고 합니다. 이 접근 방식은 InnoDB 테이블에서는 나타나지 않고, MyISAM이나 MEMORY 테이블에서만 사용되는 접근 방법입니다.
EXPLAIN SELECT * FROM tb_dual;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | tb_dual | system | 1 |
위 예제에서 tb_dual 테이블은 레코드가 1건만 들어있는 MyISAM 테이블입니다. 만약 이 테이블을 InnoDB로 변환하면 결과는 어떻게 될까요?
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | tb_dual | index | PRIMARY | 1 | 1 | Using index |
쿼리의 모양에 따라 조금은 다르겠지만 접근 방법(type 칼럼)이 ALL 또는 index로 표시될 가능성이 큽니다. system은 테이블에 레코드가 1건 이하인 경우에만 사용할 수 있는 접근 방법이므로 실제 애플리케이션에서 사용되는 쿼리의 실행 계획에서는 거의 보이지 않습니다.
const
테이블의 레코드의 건수에 관계없이 쿼리가 프라이머리 키나 유니크 키 칼럼을 이용하는 WHERE 조건절을 가지고 있으며, 반드시 1건을 반환하는 처리 방식을 const라고 합니다. 다른 DBMS에서는 이를 유니크 인덱스 스캔(UNIQUE INDEX SCAN)이라고도 표현합니다.
EXPLAIN SELECT * FROM employees WHERE emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employees | const | PRIMARY | 4 | const | 1 |
|
다음 예제와 같이 다중 칼럼으로 구성된 프라이머리 키나 유니크 키 중에서 인덱스의 일부 칼럼만 조건으로 사용할 때는 const 타입의 접근 방법을 사용할 수 없습니다. 이 경우에는 실제 레코드가 1건만 저장돼 있더라도 MySQL 엔진이 데이터를 읽어보지 않고서는 레코드가 1건이라는 것을 확신할 수 없기 때문입니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
프라이머리 키의 일부만 조건으로 사용할 때는 접근 방식이 const가 아닌 ref로 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53288 | Using where |
하지만 프라이머리 키나 유니크 인덱스의 모든 칼럼을 동등 조건으로 WHERE 절에 명시하면 다음 예제와 같이 const 접근 방법을 사용합니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | const | PRIMARY | 16 | const, const | 1 |
|
실행 계획의 type 칼럼이 const인 실행 계획은 MySQL의 옵티마이저가 쿼리를 최적화하는 단계에서 모두 상수화합니다. 그래서 실행 계획의 type 칼럼의 값이 "상수(const)"라고 표시되는 것입니다. 다음의 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT COUNT(*) FROM employees e1 WHERE first_name = (SELECT first_name FROM employees e2 WHERE emp_no=100001);
위의 예제 쿼리에서 WHERE 절에 사용된 서브 쿼리는 employees(e2) 테이블의 프라이머리 키를 검색해서 first_name을 일고 있습니다. 이 쿼리의 실행 계획은 다음과 같은데, 예상대로 e2 테이블은 프라이머리 키를 const 타입으로 접근한다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e1 | ref | ix_firstname | 44 | 247 | Using where | |
2 | SUBQUERY | e2 | const | PRIMARY | 4 | const | 1 |
|
여기서 설명하는 것은 실제 이 쿼리는 옵티마이저에 의해 최적화되는 시점에 다음과 같은 쿼리로 변환된다는 것입니다. 즉, 옵티마이저에 의해 상수화된 다음 쿼리 실행기로 전달되기 때문에 접근 방식이 const인 것입니다.
SELECT COUNT(*) FROM employees e1 WHERE first_name='Jasminko'; -- // Jasminko는 사번이 100001인 사원의 first_name 값임
eq_ref
eq_ref 접근 방법은 여러 테이블이 조인되는 쿼리의 실행 계획에서만 표시됩니다. 조인에서 처음 읽은 테이블의 칼럼 값을, 그다음 읽어야 할 테이블의 프라이머리 키나 유니크 키 칼럼의 검색 조건에 사용할 때를 eq_ref라고 합니다. 이때 두번째 이후에 읽는 테이블의 type 칼럼에 eq_ref가 표시됩니다. 또한 두번째 이후에 읽히는 테이블을 유니크 키로 검색할 때 그 유니크 인덱스는 NOT NULL이어야 하며, 다중 칼럼으로 만들어진 프라이머리 키나 유니크 인덱스라면 인덱스의 모든 칼럼이 비교 조건에 사용돼야만 eq_ref 접근 방법이 사용될 수 있습니다. 즉, 조인에서 두번째 이후에 읽는 테이블에서 반드시 1건만 존재한다는 보장이 있어야 사용할 수 있는 접근 방법입니다.
다음 예제 쿼리의 실행 계획을 살펴보겠습니다. 우선 첫번째 라인과 두번째 라인의 id가 1로 같으므로 두 개의 테이블이 조인으로 실행된다는 것을 알 수 있습니다. 그리고 dept_emp 테이블이 실행 계획의 위쪽에 있으므로 dept_emp 테이블을 먼저 읽고 "e.emp_no=de.emp_no" 조건을 이용해 employees 테이블을 검색하고 있습니다. employees 테이블의 emp_no는 프라이머리 키라서 실행 계획의 두 번째 라인은 type 칼럼이 eq_ref로 표시된 것입니다.
EXPLAIN SELECT * FROM dept_emp de, employees e WHERE e.emp_no=de.emp_no AND de.dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ref | PRIMARY | 12 | const | 53288 | Using where |
2 | SIMPLE | e | eq_ref | PRIMARY | 4 | employees | 1 |
|
ref
ref 접근 방법은 eq_ref와는 달리 조인의 순서와 관계없이 사용되며, 또한 프라이머리 키나 유니크 키 등의 제약 조건도 없습니다. 인덱스의 종류와 관계없이 동등(Equal) 조건으로 검색할 때는 ref 접근 방법이 사용됩니다. ref 타입은 반환되는 레코드가 반드시 1건이라는 보장이 없으므로 const나 eq_ref보다는 빠르지 않습니다. 하지만 동등한 조건으로만 비교되므로 매우 빠른 레코드 조회 방법의 하나입니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | ref | PRIMARY | 16 | const | 53288 | Using where |
위의 예에서는 dept_emp 테이블의 프라이머리 키를 구성하는 칼럼(dept_no+emp_no) 중에서 일부만 동등(Equal) 조건으로 WHERE 절에 명시됐기 때문에 조건에 일치하는 레코드가 1건이라는 보장이 없습니다. 그래서 const가 아닌 ref 접근 방법이 사용됐으며 실행 계획의 ref 칼럼 값에는 const가 명시됐습니다. 이 const는 접근 방식이 아니라, ref 비교 방식으로 사용된 입력 값이 상수('d005')였음을 의미합니다. ref 칼럼의 내용은 밑에서 다시 한번 살펴보겠습니다.
지금까지 배운 실행 계획의 type에 대해 간단히 비교하면서 다시 한 번 정리해보겠습니다.
const
조인의 순서에 관계없이 프라이머리 키나 유니크 키의 모든 칼럼에 대해 동등(Equal) 조건으로 검색(반드시 1건의 레코드만 반환)
eq_req
조인에서 첫 번째 읽은 테이블의 칼럼값을 이용해 두 번째 테이블을 프라이머리 키나 유니크 키로 동등(Equal) 조건 검색(두번째 테이블은 반드시 1건의 레코드만 반환)
ref
조인의 순서와 인덱스의 종류에 관계없이 동등(Equal) 조건으로 검색(1건의 레코드만 반환된다는 보장이 없어도 됨)
이 세 가지 접근 방식 모두 WHERE 조건절에 사용되는 비교 연산자는 동등 비교 연산자이어야 한다는 공통점이 있습니다. 동등 비교 연산자는 "=" 또는 "<=>"을 의미합니다. "<=>" 연산자는 NULL에 대한 비교 방식만 조금 다를 뿐 "=" 연산자와 같은 연산자입니다.
또한 세 가지 모두 매우 좋은 접근 방법으로 인덱스의 분포도가 나쁘지 않다면 성능상의 문제를 일으키지 않는 접근 방법입니다. 쿼리를 튜닝할 때도 이 세 가지 접근 방법에 대해서는 크게 신경쓰지 않고 넘어가도 무방합니다.
fulltext
fulltext 접근 방법은 MySQL의 전문 검색(Fulltext) 인덱스를 사용해 레코드를 읽는 접근 방법을 의미합니다. 지금 살펴보는 type의 순서가 일반적으로 처리 성능의 순서이긴 하지만 실제로 데이터의 분포나 레코드의 건수에 따라 빠른 순서는 달라질 수 있습니다. 이는 비용 기반의 옵티마이저에서 통계 정보를 이용해 비용을 계산하는 이유이기도 합니다. 하지만 전문 검색 인덱스는 통계 정보가 관리되지 않으며, 전문 검색 인덱스를 사용하려면 전혀 다른 SQL 문법을 사용해야 합니다. 그래서 MySQL 옵티마이저는 전문 인덱스를 사용할 수 있는 SQL에서는 쿼리의 비용과는 관계없이 거의 매번 fulltext 접근 방법을 사용합니다. 물론, fulltext 접근 방법보다 명백히 빠른 const나 eq_ref 또는 ref 접근 방법을 사용할 수 있는 쿼리에서는 억지로 fulltext 접근 방법을 선택하지는 않습니다.
MySQL의 전문 검색 조건은 우선순위가 상당히 높습니다. 쿼리에서 전문 인덱스를 사용하는 조건과 그 이외의 일반 인덱스를 사용하는 조건을 함께 사용하면 일반 인덱스의 접근 방법이 const나 eq_ref, 그리고 ref가 아니면 일반적으로 MySQL은 전문 인덱스를 사용하는 조건을 선택해서 처리합니다.
전문 검색은 "MATCH ... AGAINST ..." 구문을 사용해서 실행하는데, 반드시 해당 테이블에 전문 검색용 인덱스가 준비돼 있어야만 합니다. 만약 테이블에 전문 인덱스가 없다면 쿼리는 오류가 발생하고 중지될 것입니다. 다음의 "MATCH ... AGAINST ..." 예제 쿼리를 한 번 살펴보겠습니다.
EXPLAIN SELECT * FROM employee_name WHERE emp_no = 10001 AND emp_no BETWEEN 10001 AND 10005 AND MATCH(first_name, last_name) AGAINST('Facello' IN BOOLEAN MODE);
위 쿼리 문장은 3개의 조건을 가지고 있습니다. 첫번째 조건은 employee_name 테이블의 프라이머리 키를 1건만 조회하는 const 타입의 조건이며, 두번째 조건은 밑에서 설명할 range 타입의 조건입니다. 그리고 마지막으로 세 번째 조건은 전문 검색(Fulltext) 조건입니다. 이 문장의 실행 계획을 보면 다음과 같습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employee_name | fulltext | fx_name | 0 |
| 1 | Using where |
이번에는 range 타입의 두 번째 조건이 아니라 전문 검색(Fulltext) 조건인 세번째 조건을 선택했습니다. 일반적으로 쿼리에 전문 검색 조건(MATCH ... AGAINST ...)을 사용하면 MySQL은 아무런 주저 없이 fulltext 접근 방식을 사용하는 경향이 있습니다. 하지만 지금까지의 경험으로 보면 전문 검색 인덱스를 이용하는 fulltext보다 일반 인덱스를 이용하는 range 접근 방법이 더 빨리 처리되는 경우가 더 많았씁니다. 전문 검색 쿼리를 사용할 때는 각 조건별로 성능을 확인해 보는 편이 좋습니다.
ref_or_null
이 접근 방법은 ref 접근 같은데, NULL 비교가 추가된 형태입니다. 접근 방식의 이름 그대로 ref 방식 또는 NULL 비교(IS NULL) 접근 방식을 의미합니다. 실제 업무에서 많이 보이지도 않고, 별로 존재감이 없는 접근 방법이므로 대략의 의미만 기억해두어도 충분합니다.
EXPLAIN SELECT * FROM titles WHERE to_date='1985-03-01' OR to_date IS NULL;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | titles | ref_or_null | ix_todate | 4 | const | 2 | Using where; |
unique_subquery
WHERE 조건절에서 사용될 수 있는 IN (subquery) 형태의 쿼리를 위한 접근 방식입니다. unique_subquery의 의미 그대로 서브 쿼리에서 중복되지 않은 유니크한 값만 반환할 때 이 접근 방법을 사용합니다.
EXPLAIN SELECT * FROM departments WHERE dept_no IN ( SELECT dept_no FROM dept_emp WHERE emp_no=10001 );
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | departments | index | ux_deptname | 123 |
| 9 | Using where; |
2 | DEPENDENT | dept_emp | unique_subquery | PRIMARY | 16 | func, const | 1 | Using index; |
위 쿼리 문장의 IN (subquery) 부분에서 subquery를 살펴보겠습니다. emp_no=10001인 레코드 중에서 부서 번호는 중복이 없기 때문에(dept_emp 테이블에서 프라이머리 키가 dept_no + emp_no이므로) 실행 계획의 두 번째 라인의 dept_emp 테이블의 접근 방식은 unique_subquery로 표시된 것입니다.
index_subquery
IN 연산자의 특성상, IN (subquery) 또는 IN (상수 나열) 형태의 조건은 괄호 안에 있는 값의 목록에서 중복된 값이 먼저 제거돼야 합니다. 방금 살펴본 unique_subquery 접근 방법은 IN (subquery) 조건의 subquery가 중복된 값을 만들어내지 않는다는 보장이 있으므로 별도의 중복을 제거할 필요가 없었습니다. 하지만 IN (subquery)에서 subquery가 중복된 값을 반환할 수는 있지만 중복된 값을 인덱스를 이용해 제거할 수 있을 때 index_subqeury 접근 방법이 사용됩니다.
명확한 이해를 위해 index_subquery와 unique_subquery 접근 방법의 차이를 다시 한번 정리해 보겠습니다.
unique_subquery
IN (subquery) 형태의 조건에서 subquery의 반환 값에는 중복이 없으므로 별도의 중복 제거 작업이 필요하지 않음
index_subquery
IN (subquery) 형태의 조건에서 subquery의 반환 값에 중복된 값이 있을 수 있지만 인덱스를 이용해 중복된 값을 제거할 수 있음
사실 index_subquery나 unique_subquery 모두 IN() 안에 있는 중복 값을 아주 낮은 비용으로 제거합니다.
다음 쿼리에서 IN 연산자 내의 서브 쿼리는 dept_emp 테이블을 dept_no로 검색합니다. dept_emp 테이블의 프라이머리 키가 (dept_no + emp_no)로 만들어져 있으므로 서브 쿼리는 프라이머리 키의 dept_no 칼럼을 'd001'부터 'd003'까지 읽으면서 dept_no 값만 가져오면 됩니다. 또한 이미 프라이머리 키는 dept_no 칼럼의 값 기준으로 정렬돼 있어서 중복된 dept_no를 제거하기 위해 별도의 정렬 작업이 필요하지 않습니다.
EXPLAIN SELECT * FROM departments WHERE dept_no IN ( SELECT dept_no FROM dept_emp WHERE dept_no BETWEEN 'd001' AND 'd003');
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | departments | index | ux_deptname | 122 |
| 9 | Using where; |
2 | DEPENDENT | dept_emp | unique | PRIMARY | 12 | func | 18626 | Using index; |
range
range는 우리가 익히 알고 있는 인덱스 레인지 스캔 형태의 접근 방법입니다. range는 인덱스를 하나의 값이 아니라 범위로 검색하는 경우를 의미하는데, 주로 "<, >, IS NULL, BETWEEN, IN, LIKE" 등의 연산자를 이용해 인덱스를 검색할 때 사용됩니다. 일반적으로 애플리케이션의 쿼리가 가장 많이 사용하는 접근 방법인데, 이 책에서 소개되는 접근 방법의 순서상으로 보면 MySQL이 가지고 있는 접근 방법 중에서 상당히 우선순위가 낮다는 것을 알 수 있습니다. 하지만 이 접근 방법도 상당히 빠르며, 모든 쿼리가 이 접근 방법만 사용해도 어느 정도의 성능은 보장된다고 볼 수 있습니다.
EXPLAIN SELECT dept_no FROM dept_emp WHERE dept_no BETWEEN 'd001' AND 'd003';
인덱스 레인지 스캔이라고 하면 const, ref, range라는 세 가지 접근 방법을 모두 묶어서 지칭하는 것임에 유의합니다. 또한 "인덱스를 효율적으로 사용한다" 또는 "범위 제한 조건으로 인덱스를 사용한다"는 표현 모두 이 세 가지 접근 방법을 의미합니다. 업무상 개발자나 DBA와 소통할 때도 const나 ref 또는 range 접근 방법을 구분해서 언급하는 경우는 거의 없으며, 일반적으로 "인덱스 레인지 스캔" 또는 "레인지 스캔"으로 언급할 때가 많습니다.
index_merge
지금까지 설명한 다른 접근 방식과는 달리 index_merge 접근 방식은 2개 이상의 인덱스를 이용해 각각의 검색 결과를 만들어낸 후 그 결과를 병합하는 처리 방식입니다. 하지만 여러 번의 경험을 보면 이름만큼 그렇게 효율적으로 작동하는 것 같지는 않았습니다. index_merge 접근 방식에는 다음과 같은 특징이 있습니다.
여러 인덱스를 읽어야 하므로 일반적으로 range 접근 방식보다 효율성이 떨어집니다.
AND와 OR 연산이 복잡하게 연결된 쿼리에서는 제대로 최적화되지 못할 때가 많습니다.
전문 검색 인덱스를 사용하는 쿼리에서는 index_merge가 적용되지 않습니다.
Index_merge 접근 방식으로 처리된 결과는 항상 2개 이상의 집합이 되기 때문에 그 두 집합의 교집합이나 합집합 또는 중복 제거와 같은 부가적인 작업이 더 필요합니다.
MySQL 매뉴얼에서 index_merge 접근 방법의 우선순위는 ref_or_null 바로 다음에 있습니다. index_merge 접근 방식이 사용될 때는 "Using sort_union(...), Using union(...), Using intersect(...)" 실행 계획에 조금 더 보완적인 내용이 표시됩니다.
다음은 두 개의 조건이 OR로 연결된 쿼리입니다. 그런데 OR로 연결된 두 개 조건이 모두 각각 다른 인덱스를 최적으로 사용할 수 있는 조건입니다. 그래서 MySQL 옵티마이저는 "emp_no BETWEEN 10001 AND 11000" 조건은 employees 테이블의 프라이머리 키를 이용해 조회하고, "first_name='Smith'" 조건은 ix_firstname 인덱스를 이용해 조회한 후 두 결과를 병합하는 형태로 처리하는 실행계획을 만들어 낸 것입니다.
EXPLAIN SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 11000 OR first_name='Smith';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employees | index_merge | PRIMARY, | 4, 44 |
| 1521 | Using union |
index
index 접근 방법은 많은 사람이 자주 오해하는 접근 방법입니다. 접근 방식이 이름이 index라서 MySQL에 익숙하지 않은 많은 사람이 "효율적으로 인덱스를 사용하는구나"라고 생각하게 만드는 것 같습니다. 하지만 index 접근 방식은 인덱스를 처음부터 끝까지 읽는 인덱스 풀 스캔을 의미합니다. range 접근 방식과 같이 효율적으로 인덱스의 필요한 부분만 읽는 것을 의미하는 것은 아니라는 점은 잊지 말아야 합니다.
index 접근 방식은 테이블을 처음부터 끝까지 읽는 풀 테이블 스캔 방식과 비교했을 때 비교하는 레코드 건수는 같습니다. 하지만 인덱스는 일반적으로 데이터 파일 전체보다는 크기가 작아서 풀 테이블 스캔보다는 효율적이므로 풀 테이블 스캔보다는 빠르게 처리됩니다. 또한 쿼리의 내용에 따라 정렬된 인덱스의 장점을 이용할 수 있으므로 풀 테이블 스캔보다는 훨씬 효율적으로 처리될 수도 있습니다. index 접근 방법은 다음의 조건 가운데 (첫 번째 + 두 번째) 조건을 충족하거나 (첫 번째 + 세 번째) 조건을 충족하는 쿼리에서 사용되는 읽기 방식입니다.
range나 const 또는 ref와 같은 접근 방식으로 인덱스를 사용하지 못하는 경우
인덱스에 포함된 칼럼만으로 처리할 수 있는 쿼리인 경우(즉, 데이터 파일을 읽지 않아도 되는 경우)
인덱스를 이용해 정렬이나 그룹핑 작업이 가능한 경우(즉, 별도의 정렬 작업을 피할 수 있는 경우)
다음 쿼리는 아무런 WHERE 조건이 없으므로 range나 const 또는 ref 접근 방식을 사용할 수 없습니다. 하지만 정렬하려는 칼럼은 인덱스(ux_deptname)가 있으므로 별도의 정렬 처리를 피하려고 index 접근 방식이 사용된 예제입니다.
EXPLAIN SELECT * FROM departments ORDER BY dept_name DESC LIMIT 10;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | departments | index | ux_deptname | 123 |
| 9 | Using index |
이 예제의 실행 계획은 테이블의 인덱스를 처음부터 끝까지 읽는 index 접근 방식이지만 LIMIT 조건이 있기 때문에 상당히 효율적인 쿼리입니다. 단순히 인덱스를 거꾸로 (역순으로) 읽어서 10개만 가져오면 되기 때문입니다. 하지만 LIMIT 조건이 없거나 가져와야 할 레코드 건수가 많아지면 상당히 느려질 것입니다.
ALL
우리가 흔히 알고 있는 풀 테이블 스캔을 의미하는 접근 방식입니다. 테이블을 처음부터 끝까지 전부 읽어서 불필요한 레코드를 제거(체크 조건이 존재할 때)하고 반환합니다. 풀 테이블 스캔은 지금까지 설명한 접근 방법으로는 처리할 수 없을 때 가장 마지막에 선택되는 가장 비효율적인 방법입니다.
다른 DBMS와 같이 InnoDB도 풀 테이블 스캔이나 인덱스 풀 스캔과 같은 대량의 디스크 I/O를 유발하는 작업을 위해 한꺼번에 많은 페이지를 읽어들이는 기능을 제공합니다. InnoDB에서는 이 기능을 "리드 어헤드(Read Ahead)"라고 하며, 한 번에 여러 페이지를 읽어서 처리할 수 있습니다. 데이터웨어하우스나 배치 프로그램처럼 대용량의 레코드를 처리하는 쿼리에서는 잘못 튜닝된 쿼리(억지로 인덱스를 사용하도록 튜닝된 쿼리)보다 더 나은 접근 방법이 되기도 합니다. 쿼리를 튜닝한다는 것이 무조건 인덱스 풀 스캔이나 테이블 풀 스캔을 사용하지 못하게 하는 것은 아니라는 점을 기억합시다.
일반적으로 index와 ALL 접근 방법은 작업 범위를 제한하는 조건이 아니므로 빠른 응답을 사용자에게 보내 줘야 하는 웹 서비스 등과 같은 OLTP 환경에는 적합하지 않습니다. 테이블이 매우 작지 않다면 실제로 테이블에 데이터를 어느 정도 저장한 상태에서 쿼리의 성능을 확인해 보고 적용하는 것이 좋습니다.
MySQL에서는 연속적으로 인접한 페이지가 연속해서 몇 번 읽히게 되면 백그라운드로 작동하는 읽기 스레드가 최대 한 번에 64개의 페이지씩 한꺼번에 디스크로부터 읽어들이기 때문에 한 번에 페이지 하나씩 읽어들이는 작업보다는 상당히 빠르게 레코드를 읽을 수 있습니다. 이러한 작동 방식을 리드 어헤드(Read Ahead)라고 합니다.
possible_keys
실행 계획의 이 칼럼 또한 사용자의 오해를 자주 불러일으키곤 합니다. MySQL 옵티마이저는 쿼리를 처리하기 위해 여러 가지 처리 방법을 고려하고 그중에서 비용이 가장 낮을 것으로 예상하는 실행 계획을 선택해서 쿼리를 실행합니다. 그런데 possible_keys 칼럼에 있는 내용은 MySQL 옵티마이저가 최적의 실행 계획을 만들기 위해 후보로 선정했던 접근 방식에서 사용되는 인덱스의 목록일 뿐입니다. 즉, 말 그대로 "사용될 법했던 인덱스의 목록"인 것입니다. 실제로 실행 계획을 보면 그 테이블의 모든 인덱스가 목록에 포함되어 나오는 경우가 허다하기에 쿼리를 튜닝하는 데 아무런 도움이 되지 않습니다. 그래서 실행계획을 확인할 때는 Possible_keys 칼럼은 그냥 무시합니다. 절대 Possible_keys 칼럼에 인덱스 이름이 나열됐다고 해서 그 인덱스를 사용한다고 판단하지 않도록 주의합시다.
key
Possible_keys 칼럼의 인덱스가 사용 후보였던 반면 Key 칼럼에 표시되는 인덱스는 최종 선택된 실행 계획에서 사용하는 인덱스를 의미합니다. 그러므로 쿼리를 튜닝할 때는 Key 칼럼에 의도했던 인덱스가 표시되는지 확인하는 것이 중요합니다. Key 칼럼에 표시되는 값이 PRIMARY인 경우에는 프라이머리 키를 사용한다는 의미이며, 그 이외의 값은 모두 테이블이나 인덱스를 생성할 때 부여했던 고유 이름입니다.
실행 계획의 type 칼럼이 index_merge가 아닌 경우에는 반드시 테이블 하나당 하나의 인덱스만 이용할 수 있습니다. 하지만 index_merge 실행 계획이 사용될 때는 2개 이상의 인덱스가 사용되는데, 이때는 Key 칼럼에 여러 개의 인덱스가 ","로 구분되어 표시됩니다. 위에서 살펴본 index_merge 실행 계획을 다시 한번 살펴보겠습니다. 다음의 실행 계획은 WHERE 절의 각 조건이 PRIMARY와 ix_firstname 인덱스를 사용한다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index_merge | PRIMARY, | 4, 44 |
| 1521 | ... |
그리고 실행 계획의 type이 ALL일 때와 같이 인덱스를 전혀 사용하지 못하면 Key 칼럼은 NULL로 표시됩니다.
MySQL에서 프라이머리 키는 별도의 이름을 부여할 수 없으며, 기본적으로 PRIMARY라는 이름을 가집니다. 그 밖의 나머지 인덱스는 모두 테이블을 생성하거나 인덱스를 생성할 때 이름을 부여할 수 있습니다. 실행 계획뿐 아니라 쿼리의 힌트를 사용할 때도 프라이머리 키를 지칭하고 싶다면 PRIMARY라는 키워드를 사용하면 됩니다.
key_len
key_len 칼럼은 많은 사용자가 쉽게 무시하는 정보지만 사실은 매우 중요한 정보 중 하나입니다. 실제 업무에서 사용하는 테이블은 단일 칼럼으로만 만들어진 인덱스보다 다중 칼럼으로 만들어진 인덱스가 더 많습니다. 실행 계획의 key_len 칼럼의 값은, 쿼리를 처리하기 위해 다중 칼럼으로 구성된 인덱스에서 몇개의 칼럼까지 사용했는지 우리에게 알려줍니다. 더 정확하게는 인덱스의 각 레코드에서 몇 바이트까지 사용했는지 알려주는 값입니다. 그래서 다중 칼럼 인덱스뿐 아니라 단일 칼럼으로 만들어진 인덱스에서도 같은 지표를 제공합니다.
다음 예제는 (dept_no + emp_no)로 두 개의 칼럼으로 만들어진 프라이머리 키를 포함한 dept_emp 테이블을 조회하는 쿼리입니다. 이 쿼리는 dept_emp 테이블의 프라이머리 키 중에서 dept_no만 비교하는 데 사용하고 있습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53288 | Using where |
그래서 key_len 칼럼의 값이 12로 표시된 것입니다. 즉, dept_no 칼럼의 타입이 CHAR(4)이기 때문에 프라이머리 키에서 앞쪽 12바이트만 유효하게 사용했다는 의미입니다. 이 테이블이 dept_no 칼럼은 utf8 문자집합을 사용하고 있습니다. 실제 utf8 문자 하나가 차지하는 공간은 1바이트에서 3바이트까지 가변적입니다. 하지만 MySQL 서버가 utf8 문자를 위해 메모리 공간을 할당해야 할 때는 문자에 관계없이 고정적으로 3바이트로 계산합니다. 그래서 위의 실행 계획에서 key_len 칼럼의 값은 12바이트(4*3 바이트)가 표시된 것입니다.
이제 똑같은 인덱스를 사용하지만 dept_no 칼럼과 emp_no 칼럼에 대해 각각 조건을 하나씩 가지고 있는 다음의 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | const | PRIMARY | 16 | const, const | 1 |
|
dept_emp 테이블의 emp_no의 칼럼 타입은 INTEGER이며, INTEGER 타입은 4바이트를 차지합니다. 위의 쿼리 문장은 프라이머리 키의 dept_no 칼럼뿐 아니라 emp_no까지 사용할 수 있게 적절히 조건이 제공됐습니다. 그래서 key_len 칼럼이 dept_no 칼럼의 길이와 emp_no 칼럼의 길이 합인 16이 표시된 것입니다.
그런데 key_len의 값을 표시하는 기준이 MySQL의 버전별로 다릅니다. 다음 쿼리의 실행 계획을 MySQL 5.0.68 버전과 MySQL 5.1.54 버전에서 각각 확인해보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no <> 10001;
MySQL 5.0 이하의 버전
쿼리 문장은 프라이머리 키를 구성하는 emp_no와 dept_no의 조건을 줬지만 key_len 값은 12로 바뀌었습니다. 왜 16이 아닌 12로 줄어들었을까요? 그 이유는 Key_len에 표시되는 값은 인덱스를 이용해 범위를 제한하는 조건의 칼럼까지만 포함되며, 단순히 체크 조건으로 사용된 칼럼은 key_len에 포함되지 않기 때문입니다. 그래서 MySQL 5.0에서는 key_len 칼럼의 값으로 인덱스의 몇 바이트까지가 범위 제한 조건으로 사용됐는지 판단할 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53298 | Using where |
MySQL 5.1 이상의 버전
MySQL 5.1 버전에서는 실행 계획의 key_len이 16으로 표시됐습니다. 하지만 type 칼럼의 값이 ref가 아니고 range로 바뀐 것을 확인할 수 있습니다. 하지만 "emp_no<>10001" 조건은 단순한 체크 조건임에도 key_len에 같이 포함되어 계산됐습니다. 결과적으로 MySQL 5.1에서는 key_len 칼럼의 값으로 인덱스의 몇 바이트까지가 범위 제한 조건으로 사용됐는지를 알아낼 수는 없습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | range | PRIMARY | 16 |
| 53298 | Using where |
사실 두 버전 간의 차이는 MySQL 엔진과 InnoDB 스토리지 엔진의 역할 분담에 큰 변화가 생긴 것이 원인입니다. MySQL 5.0에서는 범위 제한 조건으로 사용되는 칼럼만 스토리지 엔진으로 전달했습니다. 하지만 MySQL 5.1부터는 조건이 범위 제한 조건이든 체크 조건이든지 관계없이, 인덱스를 이용할 수만 있다면 모두 스토리지 엔진으로 전달하도록 바뀐 것입니다. MySQL에서는 이를 "컨디션 푸시 다운(Condition push down)"이라고 합니다.
ref
접근 방법이 ref 방식이면 참조 조건(Equal 비교 조건)으로 어떤 값이 제공됐는지 보여 줍니다. 만약 상수 값을 지정했다면 ref 칼럼의 값은 const로 표시되고, 다른 테이블의 칼럼값이면 그 테이블 명과 칼럼 명이 표시됩니다. 이 칼럼에 출력되는 내용은 크게 신경쓰지 않아도 무방한데, 아래와 같은 케이스는 조금 주의해서 볼 필요가 있습니다.
가끔 쿼리의 실행 계획에서 ref 칼럼의 값이 "func"라고 표시될 때가 있습니다. 이는 "Function"의 줄임말로 참조용으로 사용되는 값을 그대로 사용한 것이 아니라, 콜레이션 변환이나 값 자체의 연산을 거쳐서 참조됐다는 것을 의미합니다. 간단히 아래 예제 쿼리의 실행 계획을 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM employees e, dept_emp de WHERE e.emp_no=de.emp_no;
이 쿼리는 employees 테이블과 dept_emp 테이블을 조인하는데, 조인 조건에 사용된 emp_no 칼럼의 값에 대해 아무런 변환이나 가공도 수행하지 않았습니다. 그래서 이 쿼리의 실행 계획은 아래와 같이 ref 칼럼에 조인 대상 칼럼의 이름이 그대로 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 | |
1 | SIMPLE | e | eq_ref | PRIMARY | 4 | de.emp_no | 1 |
|
이번에는 위의 쿼리에서 조인 조건에 간단한 산술 표현식을 넣어 쿼리를 만들고, 실행 계획을 한번 확인해 보겠습니다.
EXPLAIN SELECT * FROM employees e, dept_emp de WHERE e.emp_no=(de.emp_no-1);
위의 쿼리에서는 dept_emp 테이블을 읽어서 de.emp_no 값에서 1을 뺀 값으로 employees 조인하고 있습니다. 이 쿼리의 실행 계획에서는 ref 값이 조인 칼럼의 이름이 아니라 "func"라고 표시되는 것을 확인할 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 | |
1 | SIMPLE | e | eq_ref | PRIMARY | 4 | func | 1 | Using where |
그런데 이렇게 사용자가 명시적으로 값을 변환할 때뿐만 아니라, MySQL 서버가 내부적으로 값을 변환해야 할 때도 ref 칼럼에는 "func"가 출력됩니다. 문자집합이 일치하지 않는 두 문자열 칼럼을 조인한다거나, 숫자 타입의 칼럼과 문자열 타입의 칼럼으로 조인할 때가 대표적인 예입니다. 가능하다면 MySQL 서버가 이런 변환을 하지 않아도 되도록 조인 칼럼의 타입은 일치시키는 편이 좋습니다.
rows
MySQL 옵티마이저는 각 조건에 대해 가능한 처리 방식을 나열하고, 각 처리 방식의 비용을 비교해 최종적으로 하나의 실행 계획을 수립합니다. 이때 비용을 산정하는 방법은 각 처리 방식이 얼마나 많은 레코드를 읽고 비교해야 하는지 예측해 보는 것입니다. 대상 테이블에 얼마나 많은 레코드가 포함돼 있는지 또는 각 인덱스 값이 분포도가 어떤지를 통계 정보를 기준으로 조사해서 예측합니다.
MySQL 실행 계획의 rows 칼럼의 값은 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여줍니다. 이 값은 각 스토리지 엔진별로 가지고 있는 통계 정보를 참조해 MySQL 옵티마이저가 산출해 낸 예상 값이라서 정확하지는 않습니다. 또한, rows 칼럼에 표시되는 값은 반환하는 레코드의 예측치가 아니라, 쿼리를 처리하기 위해 얼마나 많은 레코드를 디스크로부터 읽고 체크해야 하는지를 의미합니다. 그래서 실행 계획의 rows 칼럼에 출력되는 값과 실제 쿼리 결과 반환된 레코드 건수는 일치하지 않는 경우가 많습니다.
다음 쿼리는 dept_emp 테이블에서 from_date가 "1985-01-01"보다 크거나 같은 레코드를 조회하는 쿼리입니다. 이 쿼리는 dept_emp 테이블의 from_date 칼럼으로 생성된 ix_fromdate 인덱스를 이용해 처리할 수도 있지만, 풀 테이블 스캔(ALL)을 선택했다는 것을 알 수 있습니다. 다음 쿼리의 실행 계획에서 rows 칼럼의 값을 확인해보면 MySQL 옵티마이저가 이 쿼리를 처리하기 위해 대략 334,868건의 레코드를 읽어야 할 것이라고 예측했음을 알 수 있습니다. Dept_emp 테이블의 전체 레코드가 331,603건인 것을 고려한다면 레코드의 대부분을 비교해봐야 한다고 판단한 것입니다. 그래서 MySQL 옵티마이저는 인덱스 레인지 스캔이 아니라 풀 테이블 스캔을 선택한 것입니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='1985-01-01';
id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
1 | SIMPLE | dept_emp | ALL | ix_fromdate |
|
| 334868 | Using where |
그럼 이제 범위를 더 줄인 쿼리의 실행 계획을 한번 비교해보겟습니다. 다음 쿼리의 실행 계획을 보면 MySQL 옵티마이저는 대략 292건의 레코드만 읽고 체크해보면 원하는 결과를 가져올 수 있을 것으로 예측했음을 알 수 있습니다. 물론 그래서 실행 계획도 풀 테이블 스캔이 아니라 range로 인덱스 레인지 스캔을 사용한 것입니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='2002-07-01';
id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
1 | SIMPLE | dept_emp | range | ix_fromdate | ix_fromdate | 3 | 292 | Using where |
이 예에서 옵티마이저는 from_date 칼럼의 값이 '2002-07-01'보다 큰 레코드가 292건만 존재할 것으로 예측했고, 이는 전체 테이블 건수와 비교하면 8.8%밖에 되지 않습니다. 그래서 최종적으로 옵티마이저는 ix_fromdate 인덱스를 range 방식(인덱스 레인지 스캔)으로 처리한 것입니다. 또한 인덱스에 포함된 from_date가 DATE 타입이므로 key_len은 3바이트로 표시됐습니다.
첫 번째 풀 테이블 스캔을 사용했던 예제 쿼리에 LIMIT 조건이 추가됐을 때 MySQL 옵티마이저가 예측하는 레코드 건수는 어떻게 변하는지 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='1985-01-01' LIMIT 10;
풀 테이블 스캔을 사용하면 rows 칼럼의 값이 334,868로 표시됐는데, LIMIT 10 조건을 추가하면 rows 칼럼의 값이 대략 반 정도로 줄어든 것을 알 수 있습니다. LIMIT가 포함되는 쿼리는 ROWS 칼럼에 표시되는 값이 오차가 심해서 별로 도움이 되지 않는다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | range | ix_fromdate | 3 |
| 167631 | Using where |
Extra
칼럼의 이름과는 달리, 쿼리의 실행 계획에서 성능에 관련된 중요한 내용이 Extra 칼럼에 자주 표시됩니다. Extra 칼럼에는 고정된 몇 개의 문장이 표시되는데, 일반적으로 2~3개씩 같이 표시됩니다. MySQL 5.0에서 MySQL 5.1로 업그레이드된 이후 추가된 키워드는 조금 있지만 MySQL 5.5는 MySQL 5.1과 거의 같습니다. MySQL 5.1에서 새로 추가된 키워드는 "(MySQL 5.1부터)"와 같이 태그를 붙여뒀으니 참고하기 바랍니다. 그럼 Extra 칼럼에 표시될 수 있는 문장을 하나씩 자세히 살펴보겠습니다. 여기서 설명하는 순서는 성능과 무관하므로 각 문장의 순서 자체는 의미가 없습니다.
const row not found (MySQL 5.1부터)
쿼리의 실행 계획에서 const 접근 방식으로 테이블을 읽었지만 실제로 해당 테이블에 레코드가 1건도 존재하지 않으면 Extra 칼럼에 이 내용이 표시됩니다. Extra 칼럼에 이런 메시지가 표시되는 경우에는 테이블에 적절히 테스트용 데이터를 저장하고 실행 계획을 확인해보는 것이 좋습니다.
Distinct
Extra 칼럼에 Distinct 키워드가 표시되는 다음 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT DISTINCT d.dept_no FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | d | index | ux_deptname | 123 | NULL | 9 | Using index; |
1 | SIMPLE | de | ref | PRIMARY | 12 | employees.d | 18603 | Using index; |
위 쿼리에서 실제 조회하려는 값은 dept_no인데, departments 테이블과 dept_emp 테이블에 모두 존재하는 dept_no만 중복 없이 유니크하게 가져오기 위한 쿼리입니다. 그래서 두 테이블을 조인해서 그 결과에 다시 DISTINCT 처리를 넣은 것입니다.
실행 계획의 Extra 칼럼에 Distinct가 표시되는 경우, 어떻게 처리되는지 보여줍니다. 쿼리의 DISTINCT를 처리하기 위해 조인하지 않아도 되는 항목은 모두 무시하고 꼭 필요한 것만 조인했으며, dept_emp 테이블에서는 꼭 필요한 레코드만 읽었다는 것만 표현하고 있습니다.
Full scan on NULL key
이 처리는 "col1 IN (SELECT col2 FROM ...)"과 조건이 포함된 가진 쿼리에서 자주 발생할 수 있는데, 만약 col1의 값이 NULL이 된다면 결과적으로 조건은 "NULL IN (SELECT col2 FROM ...)"과 같이 바뀝니다. SQL 표준에서는 NULL을 "알 수 없는 값"으로 정의하고 있으며, NULL에 대한 연산의 규칙까지 정의하고 있습니다. 그 정의대로 연산을 수행하기 위해 이 조건은 다음과 같이 비교돼야 합니다.
서브 쿼리가 1건이라도 결과 레코드를 가진다면 최종 비교 결과는 NULL
서브 쿼리가 1건도 결과 레코드를 가지지 않는다면 최종 비교 결과는 FALSE
이 비교 과정에서 col1이 NULL이면 풀 테이블 스캔(Full scan)을 해야만 결과를 알아낼 수 있습니다. Extra 칼럼의 "Full scan on NULL key"는 MySQL이 쿼리를 실행하는 중 col1이 NULL을 만나면 예비책으로 풀 테이블 스캔을 사용할 것이라는 사실을 알려주는 키워드입니다. 만약 "col1 IN (SELECT col2 FROM ...)" 조건에서 col1이 NOT NULL로 정의된 칼럼이라면 이러한 예비책은 사용되지 않고 Extra 칼럼에도 표시되지 않을 것입니다.
Extra 칼럼에 "Full scan on NULL key"를 표시하는 실행 계획을 한번 살펴 보겠습니다.
EXPLAIN SELECT d.dept_no, NULL IN (SELECT id.dept_name FROM departments id) FROM departments d;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | d | index | ux_deptname | 123 | NULL | 9 | Using index; |
2 | DEPENDENT | id | index | ux_deptname | 123 | const | 2 | Using index; |
만약 칼럼이 NOT NULL로 정의되지는 않았지만 이러한 NULL 비교 규칙을 무시해도 된다면 col1이 절대 NULL은 될 수 없다는 것을 MySQL 옵티마이저에게 알려주면 됩니다. 가장 대표적인 방버으로는 이 쿼리의 조건에 "col1 IS NOT NULL"이라는 조건을 지정하는 것입니다. 그러면 col1이 NULL이면 "col1 IS NOT NULL" 조건이 FALSE가 되기 때문에 "col1 IN (SELECT col2 FROM tb_test2)" 조건은 실행하지 않습니다.
SELECT * FROM tb_test1 WHERE col1 IS NOT NULL AND col1 IN (SELECT col2 FROM tb_test2);
"Full scan on NULL key" 코멘트가 실행 계획의 Extra 칼럼에 표시됐다고 하더라도, 만약 IN이나 NOT IN 연산자의 왼쪽에 있는 값이 실제로 NULL이 없다면 풀 테이블 스캔은 발생하지 않으므로 걱정하지 않아도 됩니다. 하지만 IN이나 NOT IN 연산자의 왼쪽 값이 NULL인 레코드가 있고, 서브 쿼리에 개별적으로 WHERE 조건이 지정돼 있다면 상당한 성능 문제가 발생할 수도 있습니다.
여기서 사용된 쿼리는 단순히 예제를 만들어 내기 위해 작성한 쿼리입니다. 적절한 예제용 쿼리를 만들기 어려운 경우에는 조금 억지스러운 쿼리도 있을 수 있습니다. 때로는 그 실행 계획을 보여주기 위해 의미 없는 쿼리가 사용된 적도 있습니다. 하지만 설명을 위한 것이므로 "더 효율적으로 쿼리를 작성할 수 있는데, 왜 이렇게 쿼리를 작성했을까요?"라는 의문으로 시간을 낭비하지 않는 것이 좋습니다.
Impossible HAVING
쿼리에 사용된 HAVING 절의 조건을 만족하는 레코드가 없을 때 실행 계획의 Extra 칼럼에는 "Impossible HAVING" 키워드가 표시됩니다.
EXPLAIN SELECT e.emp_no, COUNT(*) AS cnt FROM employees e WHERE e.emp_no=10001 GROUP BY e.emp_no HAVING e.emp_no IS NULL;
위의 예제에서 HAVING 조건에 "e.emp_no IS NULL"이라는 조건이 추가됐지만, 사실 employees 테이블의 e.emp_no 칼럼은 프라이머리 키이면서 NOT NULL 타입의 칼럼입니다. 그러므로 결코 e.emp_no IS NULL 조건을 만족할 가능성이 없으므로 Extra 칼럼에서 "Impossible HAVING"이라는 키워드를 표시합니다. 여기서 보여준 예제에서는 이처럼 명확한 예제(실제 테이블 구조상으로 불가능한 조건)를 사용했지만 실제로 SQL을 개발하다 보면 이렇게 테이블 구조상으로 불가능한 조건뿐 아니라 실제 데이터 때문에 이런 현상이 발생하기도 하므로 주의해야 합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible HAVING |
애플리케이션이 쿼리 중에서 실행 계획의 Extra 칼럼에 "Impossible HAVING" 메시지가 출력된다면 쿼리가 제대로 작성되지 못한 경우가 대부분이므로 쿼리의 내용을 다시 점검하는 것이 좋습니다.
Impossible WHERE (MySQL 5.1부터)
"Impossible HAVING"과 비슷하며, WHERE 조건이 항상 FALSE가 될 수 밖에 없는 경우 "Impossible WHERE"가 표시됩니다.
EXPLAIN SELECT * FROM employees WHERE emp_no IS NULL;
위의 쿼리에서 WHERE 조건절에 사용된 emp_no칼럼은 NOT NULL이므로 emp_no IS NULL 조건은 항상 FALSE가 됩니다. 이럴 때 쿼리의 실행 계획에는 다음과 같이 "불가능한 WHERE 조건"으로 Extra 칼럼이 출력됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible WHERE |
Impossible WHERE noticed after reading const tables
위의 "Impossible WHERE"의 경우에는 실제 데이터를 읽어보지 않고도 바로 테이블의 구조상으로 불가능한 조건이라고 판단할 수 있었지만 다음 예제 쿼리는 어떤 메시지가 표시될까요?
EXPLAIN SELECT * FROM employees WHERE emp_no=0;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible WHERE noticed after reading const tables |
이 쿼리는 실제로 실행되지 않으면 emp_no=0인 레코드가 있는지 없는지 판단할 수 없습니다. 그런데 이 쿼리의 실행 계획만 확인했을 뿐인데, 옵티마이저는 사번이 0인 사원이 없다는 것까지 확인한 것입니다.
이를 토대로 MySQL이 실행 계획을 만드는 과정에서 쿼리의 일부분을 실행해 본다는 사실을 알 수 있습니다. 또한 이 쿼리는 employees 테이블의 프라이머리 키를 동등 조건으로 비교하고 있습니다. 이럴 때는 const 접근 방식을 사용한다는 것은 이미 살펴보았습니다. 쿼리에서 const 접근 방식이 필요한 부분은 실행 계획 수립 단계에서 옵티마이저가 직접 쿼리의 일부를 실행하고, 실행된 결과 값을 원본 쿼리의 상수로 대체합니다.
SELECT * FROM employees oe WHERE oe.first_name = ( SELECT ie.first_name FROM employees ie WHERE ie.emp_no=10001 );
즉, 위와 같은 쿼리를 실행하면 WHERE 조건절의 서브 쿼리는 (프라이머리 키를 통한 조회여서 const 접근 방식을 사용할 것이므로) 옵티마이저가 실행한 결과를 다음과 같이 대체한 다음, 본격적으로 쿼리를 실행합니다.
SELECT * FROM employees oe WHERE oe.first_name='Georgi';
No Matching min/max row (MySQL 5.1부터)
쿼리의 WHERE 조건절을 만족하는 레코드가 한 건도 없는 경우 일반적으로 "Impossible WHERE ..." 문장이 Extra 칼럼에 표시됩니다. 만약 MIN()이나 MAX()와 같은 집합 함수가 있는 쿼리의 조건절에 일치하는 레코드가 한 건도 없을때는 Extra 칼럼에 "No matching min/max row"라는 메시지가 출력됩니다. 그리고 MIN()이나 MAX()의 결과로 NULL이 반환됩니다.
EXPLAIN SELECT MIN(dept_no), MAX(dept_no) FROM dept_emp WHERE dept_no='';
위의 쿼리는 dept_emp 테이블에서 dept_no 칼럼이 빈 문자열인 레코드를 검색하고 있지만 dept_no 칼럼은 NOT NULL이므로 일치하는 레코드는 한 건도 없을 것입니다. 그래서 위 쿼리의 실행 계획의 Extra 칼럼에는 "No matching min/max row" 코멘트가 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| No matching min/max row |
Extra 칼럼에 출력되는 내용 중에서 "No matching ..."이나 "Impossible WHERE ..." 등의 메시지는 잘못 생각하면 쿼리 자체가 오류인 것처럼 오해하기 쉽습니다. 하지만 Extra 칼럼에 출력되는 내용은 단지 쿼리의 실행 계획을 산출하기 위한 기초 자료가 없음을 표현하는 것뿐입니다. Extra 칼럼에 이러한 메시지가 표시된다고 해서 실제 쿼리 오류가 발생하는 것은 아닙니다.
no matching row in const table (MySQL 5.1부터)
다음 쿼리와 같이 조인에 사용된 테이블에서 const 방식으로 접근할 때, 일치하는 레코드가 없다면 "no matching row in const table"이라는 메시지를 표시합니다.
EXPLAIN SELECT * FROM dept_emp de, (SELECT emp_no FROM employees WHERE emp_no=0) tb1 WHERE tb1.emp_no=de.emp_no AND de.dept_no='d005';
이 메시지 또한 "Impossible WHERE ..."와 같은 종류로, 실행 계획을 만들기 위한 기초 자료가 없음을 의미합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY |
|
|
|
|
|
| Impossible WHERE noticed after reading const tables |
2 | DERIVED |
|
|
|
|
|
| no matching row in const table |
No tables used (MySQL 5.0의 "No tables"에서 키워드 변경됨)
FROM 절이 없는 쿼리 문장이나 "FROM DUAL" 형태의 쿼리 실행 계획에서는 Extra 칼럼에 "No tables used"라는 메시지가 출력됩니다. 다른 DBMS와는 달리 MySQL은 FROM 절이 없는 쿼리도 허용됩니다. 이처럼 FROM 절 자체가 없거나, FROM 절에 상수 테이블을 의미하는 DUAL(칼럼과 레코드를 각각 1개씩만 가지는 가상의 상수 테이블)이 사용될 때는 Extra 칼럼에 "No tables used"라는 메시지가 표시됩니다.
EXPLAIN SELECT 1; EXPLAIN SELECT 1 FROM DUAL;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| No tables used |
MySQL에서는 FROM 절이 없는 쿼리도 오류 없이 실행됩니다. 하지만 오라클에서는 쿼리에 반드시 참조하는 테이블이 있어야 하므로 FROM 절이 필요없는 경우에 대비해 상수 테이블로 DUAL이라는 테이블을 사용합니다. 또한 MySQL에서는 오라클과의 호환을 위해 FROM 절에 "DUAL"이라는 테이블을 명시적으로도 사용할 수도 있습니다. MySQL 옵티마이저가 FROM 절에 DUAL이라는 이름이 사용되면 내부적으로 FROM 절이 없는 쿼리 문장과 같은 방식으로 처리합니다.
Not exists
프로그램을 개발하다 보면 A 테이블에는 존재하지만 B 테이블에는 없는 값을 조회해야 하는 쿼리가 자주 사용됩니다. 이럴 때는 주로 NOT IN (subquery) 형태나 NOT EXISTS 연산자를 주로 사용합니다. 이러한 형태의 조인을 안티-조인(Anti-JOIN)이라고 합니다. 똑같은 처리를 아우터 조인(LEFT OUTER JOIN)을 이용해도 구현할 수 있습니다. 일반적으로 안티-조인으로 처리해야 하지만 레코드의 건수가 많을때는 NOT IN (subquery)이나 NOT EXISTS 연산자보다는 아우터 조인을 이용하면 빠른 성능을 낼 수 있습니다.
아우터 조인을 이용해 dept_emp 테이블에는 있지만 departments 테이블에는 없는 dept_no를 조회하는 쿼리를 예제로 살펴보겠습니다. 아래의 예제 쿼리는 departments 테이블을 아우터 조인해서 ON절이 아닌 WHERE절에 아우터 테이블(departments)의 dept_no 칼럼이 NULL인 레코드만 체크해서 가져옵니다. 즉 안티-조인은 일반 조인(INNER JOIN)을 했을 때 나오지 않는 결과만 가져오는 방법입니다.
EXPLAIN SELECT * FROM dept_emp de LEFT JOIN departments d ON de.dept_no=d.dept_no WHERE d.dept_no IS NULL;
이렇게 아우터 조인을 이용해 안티-조인을 수행하는 쿼리에서는 Extra 칼럼에 Not exists 메시지가 표시됩니다. Not exists 메시지는 이 쿼리를 NOT EXISTS 형태의 쿼리로 변환해서 처리했음을 의미하는 것이 아니라 MySQL이 내부적으로 어떤 최적화를 했는데 그 최적화의 이름이 "Not exists"인 것입니다. Extra 칼럼의 Not exists와 SQL의 NOT EXISTS 연산자를 혼동하지 않도록 주의합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 |
|
1 | SIMPLE | d | eq_ref | PRIMARY | 12 | employees. | 1 | Using where; |
Range checked for each record (index map: N)
두 개의 테이블을 조인하는 다음의 쿼리를 보면서 이 메시지의 의미를 이해해 보겠습니다. 조인 조건에 상수가 없고 둘 다 변수(e1.emp_no와 e2.emp_no)인 경우, MySQL 옵티마이저는 e1 테이블을 먼저 일고 조인을 위해 e2를 읽을 때, 인덱스 레인지 스캔과 풀 테이블 스캔 중에서 어느 것이 효율적일지 판단할 수 없게 됩니다. 즉, e1 테이블의 레코드를 하나씩 읽을 때마다 e1.emp_no 값이 계속 바뀌므로 쿼리의 비용 계산을 위한 기준값이 계속 변하는 것입니다. 그래서 어떤 접근 방법으로 e2 테이블을 읽는 것이 좋을지 판단할 수 없는 것입니다.
EXPLAIN SELECT * FROM employees e1, employees e2 WHERE e2.emp_no >= e1.emp_no;
예를 들어 사번이 1번부터 1억까지 있다고 가정해 보겠습니다. 그러면 e1 테이블을 처음부터 끝까지 스캔하면서 e2 테이블에서 e2.emp_no >= e1.emp_no 조건을 만족하는 레코드를 찾아야 하는데, 문제는 e1.emp_no=1인 경우에는 e2 테이블의 1억건 전부를 읽어야 한다는 것입니다. 하지만 e1.emp_no=100000000인 경우에는 e2 테이블을 한 건만 읽으면 된다는 것입니다.
그래서 e1 테이블의 emp_no가 작을 때는 e2 테이블을 풀 테이블 스캔으로 접근하고, e1 테이블의 emp_no가 큰 값일 때는 e2 테이블을 인덱스 레인지 스캔으로 접근하는 형태를 수행하는 것이 최적의 조인 방법일 것입니다. 지금까지 설명한 내용을 줄여서 표현하면 "매 레코드마다 인덱스 레인지 스캔을 체크한다"라고 할 수 있는데, 이것이 Extra 칼럼에 표시되는 "Range checked for each record"의 의미입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e1 | ALL |
| 3 |
| 300584 | Using index |
1 | SIMPLE | e2 | ALL |
|
| 300584 | Range checked for each record (index map: 0x1) |
Extra 칼럼의 출력 내용 중에서 "(index map: 0x1)"은 사용할지 말지를 판단하는 후보 인덱스의 순번을 나타냅니다. "index map"은 16진수로 표시되는데, 이를 해석하려면 우선 이진수로 표현을 바꿔야합니다. 위의 실행 계획에서는 0x1이 표시되는데, 이는 이진수로 바꿔도 1입니다. 그래서 이 쿼리는 e2(employees) 테이블의 첫 번째 인덱스를 사용할지 아니면 풀 테이블을 스캔할지를 매번 판단한다는 것을 의미합니다. 여기서 테이블의 첫 번째 인덱스란 "SHOW CREATE TABLE employees" 명령으로 테이블의 구조를 조회했을 때 제일 먼저 출력되는 인덱스를 의미합니다.
그리고 쿼리 실행 계획의 type 칼럼의 값이 ALL로 표시되어 풀 테이블 스캔으로 처리된 것으로 해석하기 쉽습니다. 하지만 Extra 칼럼에 "Range checked for each record"가 표시되면 type 칼럼에는 ALL로 표시됩니다. 즉 "index map"에 표시된 후보 인덱스를 사용할지 여부를 검토해서, 이 후보 인덱스가 별로 도움이 되지 않는다면 최종적으로 풀 테이블 스캔을 사용하기 때문에 ALL로 표시된 것입니다.
"index map"에 대한 이해를 돕기 위해 조금 더 복잡한 "index map"을 예제로 살펴보겠습니다. 우선 아래와 같이 인덱스가 여러 개인 테이블에 실행되는 쿼리의 실행 계획에서 "(index map: 0x19)"이라고 표시됐다고 가정해보겠습니다.
CREATE TABLE tb_member ( mem_id INTEGER NOT NULL, mem_name VARCHAR(100) NOT NULL, mem_nickname VARCHAR(100) NOT NULL, mem_region TINYINT, mem_gender TINYINT, mem_phone VARCHAR(25), PRIMARY KEY (mem_id), INDEX ix_nick_name (mem_nickname, mem_name), INDEX ix_nick_region (mem_nickname, mem_region), INDEX ix_nick_gender (mem_nickname, mem_gender), INDEX ix_nick_phone (mem_nickname, mem_phone) );
우선 0x19 값을 비트(이진) 값으로 변환해 보면 11001입니다. 이 비트 배열을 해석하는 방법은 다음 표와 같습니다. 이진 비트 맵의 각 자리 수는 "CREATE TABLE tb_member ..." 명령에 나열된 인덱스의 순번을 의미합니다.
자리수 | 다섯번째 자리 | 네번째 자리 | 세번째 자리 | 두번째 자리 | 첫번째 자리 |
비트맵 값 | 1 | 1 | 0 | 0 | 1 |
지칭 인덱스 | ix_nick_phone | ix_nick_gender | ix_nick_region | ix_nick_name | PRIMARY KEY |
결론적으로 실행 계획에서 "(index map: 0x19)"의 의미는 위의 표에서 각 자리 수의 값이 1인 다음 인덱스를 사용 가능한 인덱스 후보로 선정했음을 의미합니다.
PRIMARY KEY
ix_nick_gender
ix_nick_phone
각 레코드 단위로 이 후보 인덱스 가운데 어떤 인덱스를 사용할지 결정하게 되는데, 실제 어떤 인덱스가 사용됐는지는 알 수 없습니다. 단지 각 비트 맵의 자리 수가 1인 순번의 인덱스가 대상이라는 것만 알 수 있습니다.
실행 계획의 Extra 칼럼에 "Range checked for each record"가 표시되는 쿼리가 많이 실행되는 MySQL 서버에서는 "SHOW GLOBAL STATUS" 명령으로 표시되는 상태 값 중에서 "Select_range_check"의 값이 크게 나타납니다.
Scanned N databases(MySQL 5.1부터)
MySQL 5.0부터는 기본적으로 INFORMATION_SCHEMA라는 DB가 제공됩니다.
INFORMATION_SCHEMA DB는 MySQL 서버 내에 존재하는 DB의 메타 정보(테이블, 칼럼, 인덱스 등의 스키마 정보)를 모아둔 DB입니다. INFORMATION_SCHEMA 데이터베이스 내의 모든 테이블은 읽기 전용이며, 단순히 조회만 가능합니다. 실제로 이 데이터베이스 내의 테이블은 레코드가 있는 것이 아니라, SQL을 이용해 조회할 때마다 메타 정보를 MySQL 서버의 메모리에서 가져와서 보여줍니다. 이런 이유로 한꺼번에 많은 테이블을 조회할 경우 시간이 많이 걸립니다.
MySQL 5.1부터는 INFORMATION_SCHEMA DB를 빠르게 조회할 수 있게 개선됐습니다. 개선된 조회를 통해 메타 정보를 검색할 경우에는 쿼리 실행 계획의 Extra 칼럼에 "Scanned N databases"라는 메시지가 표시됩니다. "Scanned N databases"에서 N은 몇 개의 DB 정보를 읽었는지 보여주는 것인데, N은 0과 1 또는 all의 값을 가지며 각각 의미는 다음과 같습니다.
0 : 특정 테이블의 정보만 요청되어 데이터베이스 전체의 메타 정보를 읽지 않음
1 : 특정 데이터베이스내의 모든 스키마 정보가 요청되어 해당 데이터베이스의 모든 스키마 정보를 읽음
All : MySQL 서버 내의 모든 스키마 정보를 다 읽음
이 코멘트는 INFORMATION_SCHEMA 내의 테이블로부터 데이터를 읽는 경우에만 표시됩니다.
EXPLAIN SELECT table_name FROM information_schema.tables WHERE table_schema = 'employees' AND table_name = 'employees';
위 쿼리는 employees DB의 employees 테이블 정보만 읽었기 때문에 employees DB 전체를 참조하지는 않았습니다. 그래서 다음과 같이 "Scanned 0 databases"로 Extra 칼럼에 표시된 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | TABLES | ALL | TABLE_SCHEMA, | ... | ... | ... | Using where; |
애플리케이션에서는 INFORMATION_SCHEMA DB에서 메타 정보를 조회하는 쿼리는 거의 사용하지 않으므로 실행 계획에 "Scanned N databases"라는 코멘트가 표시되는 쿼리는 거의 없을 것입니다.
Select tables optimized away
MIN() 또는 MAX()만 SELECT 절에 사용되거나 또는 GROUP BY로 MIN(), MAX()를 조회하는 쿼리가 적절한 인덱스를 사용할 수 없을 때 인덱스를 오름차순 또는 내림차순으로 1건만 읽는 형태의 최적화가 적용된다면 Extra 칼럼에 "Select tables optimized away"가 표시됩니다.
또한 MyISAM 테이블에 대해서는 GROUP BY 없이 COUNT(*)만 SELECT할 때도 이런 형태의 최적화가 적용됩니다. MyISAM 테이블은 전체 레코드 건수를 별도로 관리하기 때문에 인덱스나 데이터를 읽지 않고도 전체 건수를 빠르게 조회할 수 있습니다. 하지만 WHERE 절에 조건을 가질 때는 이러한 최적화를 사용하지 못합니다.
EXPLAIN SELECT MAX(emp_no), MIN(emp_no) FROM employees; EXPLAIN SELECT MAX(from_date), MIN(from_date) FROM salaries WHERE emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
|
|
|
|
| Select tables optimized away |
첫 번째 쿼리는 employees 테이블에 있는 emp_no 칼럼에 인덱스가 생성돼 있으므로 "Select tables optimized away" 최적화가 가능합니다. employees 테이블의 emp_no 칼럼에 생성된 인덱스에서 첫번째 레코드와 마지막 레코드만 읽어서 최솟값과 최댓값을 가져오는 것을 표현하고 있습니다.
두 번째 쿼리의 경우 salaries 테이블에 emp_no + from_date로 인덱스가 생성돼 있으므로 인덱스가 emp_no=1001인 레코드를 검색하고, 검색된 결과 중에서 오름차순 또는 내림차순으로 하나만 조회하면 되기 때문에 이러한 최적화가 가능한 것입니다.
Skip_open_table, Open_frm_only, Open_trigger_only, Open_full_table(MySQL 5.1부터)
이 코멘트 또한 "Scanned N databases"와 같이 INFORMATION_SCHEMA DB의 메타 정보를 조회하는 SELECT 쿼리의 실행 계획에서만 표시되는 내용입니다. 테이블의 메타 정보가 저장된 파일(*.FRM)과 트리거가 저장된 파일(*.TRG) 또는 데이터 파일 중에서 필요한 파일만 읽었는지 또는 불가피하게 모든 파일을 다 읽었는지 등의 정보를 보여줍니다. Extra 칼럼에 표시되는 메시지는 다음 4가지 중 하나이며, 그 의미는 다음과 같습니다.
Skip_open_table: 테이블의 메타 정보가 저장된 파일을 별도로 읽을 필요가 없음
Open_frm_only: 테이블의 메타 정보가 저장된 파일(*.FRM)만 열어서 읽음
Open_trigger_only: 트리거 정보가 저장된 파일(*.TRG)만 열어서 읽음
Open_full_table: 최적화되지 못해서 테이블 메타 정보 파일(*.FRM)과 데이터(*.MYD) 및 인덱스 파일(*.MYI)까지 모두 읽음
위의 내용에서 데이터(*.FRM) 파일이나 인덱스(*.MYI)에 관련된 내용은 MyISAM에만 해당하며, InnoDB 스토리지 엔진을 사용하는 테이블에는 적용되지 않습니다.
unique row not found (MySQL 5.1부터)
두 개의 테이블이 각각 유니크(프라이머리 키 포함) 칼럼으로 아우터 조인을 수행하는 쿼리에서 아우터 테이블에 일치하는 레코드가 존재하지 않을 때 Extra 칼럼에 이 코멘트가 표시됩니다.
-- // 테스트 케이스를 위한 테스트용 테이블 생성 CREATE TABLE tb_test1 (fdpk INT, PRIMARY KEY(fdpk)); CREATE TABLE tb_test2 (fdpk INT, PRIMARY KEY(fdpk)); -- // 생성된 테이블에 레코드 INSERT INSERT INTO tb_test1 VALUES (1), (2); INSERT INTO tb_test2 VALUES (1); EXPLAIN SELECT t1.fdpk FROM tb_test1 t1 LEFT JOIN tb_test2 t2 ON t2.fdpk=t1.fdpk WHERE t1.fdpk=2;
이 쿼리가 실행되면 tb_test2 테이블에는 fdpk=2인 레코드가 없으므로 다음처럼 "unique row not found"라는 코멘트가 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | t1 | const | PRIMARY | 4 | const | 1 | Using index |
1 | SIMPLE | t2 | const | PRIMARY | 4 | const | 0 | unique row not found |
Using filesort
ORDER BY를 처리하기 위해 인덱스를 이용할 수도 있지만 적절한 인덱스를 사용하지 못할 때는 MySQL 서버가 조회된 레코드를 다시 한 번 정렬해야 합니다. ORDER BY 처리가 인덱스를 사용하지 못할 때만 실행 계획의 Extra 칼럼에는 "Using filesort" 코멘트가 표시되며, 이는 조회된 레코드를 정렬용 메모리 버퍼에 복사해 퀵 소트 알고리즘을 수행하게 됩니다. "Using filesort" 코멘트는 ORDER BY가 사용된 쿼리의 실행 계획에서만 나타날 수 있습니다.
EXPLAIN SELECT * FROM employees ORDER BY last_name DESC;
hire_date 칼럼에는 인덱스가 없으므로 이 쿼리의 정렬 작업을 처리하기 위해 인덱스를 이용하는 것은 불가능 합니다. MySQL 옵티마이저는 레코드를 읽어서 소트 버퍼(Sort Buffer)에 복사하고, 정렬해서 그 결과를 클라이언트에 보냅니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | ALL |
|
|
|
| Using filesort |
실행 계획의 Extra 칼럼에 "Using filesort"가 출력되는 쿼리는 많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋습니다.
Using index(커버링 인덱스)
데이터 파일을 전혀 읽지 않고 인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때 Extra 칼럼에 "Using index"가 표시됩니다. 인덱스를 이용해 처리하는 쿼리에서 가장 큰 부하를 차지하는 부분은 인덱스를 검색해 일치하는 레코드의 나머지 칼럼 값을 가져오기 위해 데이터 파일을 찾아서 가져오는 작업입니다. 최악의 경우에는 인덱스를 통해 검색된 결과 레코드 한 건 한 건마다 디스크를 한번씩 읽어야 할 수도 있습니다.
employees 테이블에 데이터가 저장돼 있고, 아래의 쿼리가 인덱스 레인지 스캔 접근 방식을 사용한다고 해보겠습니다. 만약 아래 쿼리가 인덱스 레인지 스캔으로 처리된다면 디스크에서 읽기 작업이 얼마나 필요한지 한 번 살펴보겠습니다.
SELECT first_name, birth_date FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | ix_firstname | 42 |
| ... | Using where |
1. 이 예제 쿼리는 employees 테이블의 first_name 칼럼에 생성된 인덱스(ix_firstname)를 이용해 일치하는 레코드를 검색할 것입니다.
2. 그리고 일치하는 레코드 5건에 대해 birth_date 칼럼의 값을 읽기 위해 각 레코드가 저장된 데이터 페이지를 디스크로부터 읽어야 합니다.
실제 ix_firstname 인덱스에서 일치하는 레코드 5건을 검색하기 위해 디스크 읽기 3~4번만으로 필요한 인덱스 페이지를 모두 가져올 수 있습니다. 하지만 각 레코드의 나머지 데이터를 가져오기 위해 최대 5번의 디스크 읽기를 더 해야 합니다. 물론 이 예제는 아주 간단하고 적은 개수의 레코드만 처리하기 때문에 디스크 읽기가 적지만 실제로 복잡하고 많은 레코드를 검색해야 하는 쿼리에서는 나머지 레코드를 읽기 위해 수백 번의 디스크 읽기가 더 필요할 수도 있습니다.
그럼 이제 birth_date 칼럼은 빼고 first_name 칼럼만 SELECT하는 쿼리를 한번 생각해보겠습니다. 이 쿼리도 마찬가지로 인덱스 레인지 스캔을 이용해 처리된다고 가정해보겠습니다.
SELECT first_name FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | ix_firstname | 42 |
| ... | Using index |
이 예제 쿼리에서는 employees 테이블의 여러 칼럼 중에서 first_name 칼럼만 사용됐습니다. 즉, first_name 칼럼만 있으면 이 쿼리는 모두 처리되는 것입니다. 그래서 이 쿼리는 위의 첫 번째 예제 쿼리의 두 작업 중에서 1번 과정만 실행하면 됩니다. 필요한 칼럼이 모두 인덱스에 있으므로 나머지 칼럼이 저장된 데이터 파일을 읽어올 필요가 없습니다. 이 쿼리는 디스크에서 4~5개의 페이지만 읽으면 되기 때문에 매우 빠른 속도로 처리됩니다.
두번째 예제와 같이 인덱스만으로 쿼리를 수행할 수 있을 때 실행 계획의 Extra 칼럼에는 "Using index"라는 메시지가 출력됩니다. 이렇게 인덱스만으로 처리되는 것을 "커버링 인덱스(Covering index)"라고 합니다. 인덱스 레인지 스캔을 사용하지만 쿼리의 성능이 만족스럽지 못한 경우라면 인덱스에 있는 칼럼만 사용하도록 쿼리를 변경해 큰 성능 향상을 볼 수 있습니다.
InnoDB의 모든 테이블은 클러스터링 인덱스로 구성돼 있습니다. 그리고 이 때문에 InnoDB 테이블의 모든 보조 인덱스는 데이터 레코드의 주소 값으로 프라이머리 키값을 가집니다. InnoDB 테이블에서는 first_name 칼럼만으로 인덱스를 만들어도, 결국 그 인덱스에 emp_no 칼럼이 같이 저장되는 효과를 냅니다. 이러한 클러스터링 인덱스 특성 때문에 쿼리가 "커버링 인덱스"로 처리될 가능성이 상당히 높습니다. 간단히 다음 쿼리를 한번 살펴보겠습니다. 이 예제 쿼리도 인덱스 레인지로 처리된다고 가정해보겠습니다.
SELECT emp_no, first_name FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
이 쿼리에도 위의 첫번째나 두번째 예제처럼 같은 WHERE 조건이 지정돼 있어서 first_name 칼럼의 인덱스를 이용해 일치하는 레코드를 검색할 것입니다. 그런데 이 쿼리는 위의 두 번째 예제 쿼리와는 달리 first_name 칼럼 말고도 emp_no를 더 가져와야 합니다. 하지만 emp_no는 employees 테이블의 프라이머리 키이기 때문에 이미 인덱스에 포함돼 있어 데이터 파일을 읽지 않아도 됩니다. 즉, InnoDB의 보조 인덱스에는 데이터 레코드를 찾아가기 위한 주소로 사용하기 위해 프라이머리 키를 저장해두는 것이지만, 더불어 추가 칼럼을 하나 더 가지는 인덱스의 효과를 동시에 얻을 수 있게 되는 것입니다.
레코드 건수에 따라 차이는 있겠지만 쿼리를 커버링 인덱스로 처리할 수 있을 때와 그렇지 못할 때의 성능 차이는 수십 배에서 수백 배까지 날 수 있습니다. 하지만 무조건 커버링 인덱스로 처리하려고 인덱스에 많은 칼럼을 추가하면 더 위험한 상황이 초래될 수도 있습니다. 너무 과도하게 인덱스의 칼럼이 많아지면 인덱스의 크기가 커져서 메모리 낭비가 심해지고 레코드를 저장하거나 변경하는 작업이 매우 느려질 수 있기 때문입니다. 너무 커버링 인덱스 위주로 인덱스를 생성하지는 않도록 주의해야합니다.
접근 방법(실행 계획의 type 칼럼)이 eq_ref, ref, range, index_merge, index 등과 같이 인덱스를 사용하는 실행 계획에서는 모두 Extra 칼럼에 "Using index"가 표시될 수 있습니다. 즉 인덱스 레인지 스캔(eq_ref, ref, range, index_merge 등의 접근 방법)을 사용할 때만 커버링 인덱스로 처리되는 것은 아닙니다. 인덱스를 풀 스캔(index 접근 방법)을 실행할 때도 커버링 인덱스로 처리될 수 있는데, 이때도 똑같은 인덱스 풀 스캔의 접근 방법이라면 커버링 인덱스가 아닌 경우보다 훨씬 빠르게 처리됩니다.
Extra 칼럼에 표시되는 "Using index"와 접근 방법 (type 칼럼의 값)의 "index"를 자주 혼동할 때가 있는데, 사실 이 두가지는 성능상 반대되는 개념이라서 반드시 구분해서 이해해야 합니다. 이미 살펴봤듯이 실행 계획의 type 칼럼에 표시되는 "index"는 인덱스 풀 스캔으로 처리하는 방식을 의미하며, 이는 인덱스 레인지 스캔보다 훨씬 느린 처리 방식입니다. 하지만 "Using index"는 커버링 인덱스가 사용되지 않는 쿼리보다는 훨씬 빠르게 처리한다는 것을 의미하는 메시지입니다. 커버링 인덱스는 실행 계획의 type에 관계없이 사용될 수 있습니다.
Using index for group-by
GROUP BY 처리를 위해 MySQL 서버는 그룹핑 기준 칼럼을 이용해 정렬 작업을 수행하고 다시 정렬된 결과를 그룹핑하는 형태의 고부하 작업을 필요로 합니다. 하지만 GROUP BY 처리가 인덱스(B-Tree 인덱스에 한해)를 이용하면 정렬된 인덱스 칼럼을 순서대로 읽으면서 그룹핑 작업만 수행합니다. 이렇게 GROUP BY 처리에 인덱스를 이용하면 레코드의 정렬이 필요하지 않고 인덱스의 필요한 부분만 읽으면 되기 때문에 상당히 효율적이고 빠르게 처리됩니다. GROUP BY 처리가 인덱스를 이용할 때 쿼리의 실행 계획에서는 Extra 칼럼에 "Using index for group-by" 메시지가 표시됩니다. GROUP BY 처리를 위해 인덱스를 읽는 방법을 "루스 인덱스 스캔"이라고 합니다.
GROUP BY 처리를 위해 단순히 인덱스를 순서대로 쭉 읽는 타이트 인덱스 스캔과는 달리 루스 인덱스 스캔은 인덱스에서 필요한 부분만 듬성 듬성 읽습니다.
타이트 인덱스 스캔(인덱스 스캔)을 통한 GROUP BY 처리
인덱스를 이용해 GROUP BY 절을 처리할 수 있더라도 AVG()나 SUB() 또는 COUNT(*)와 같이 조회하려는 값이 모든 인덱스를 다 읽어야 할 때는 필요한 레코드만 듬성듬성 읽을 수가 없습니다. 이런 쿼리는 단순히 GROUP BY를 위해 인덱스를 사용하기는 하지만 이를 루스 인덱스 스캔이라고 하지는 않습니다. 또한 이런 쿼리의 실행 계획에는 "Using index for group-by" 메시지가 출력되지 않습니다.
EXPLAIN SELECT first_name, COUNT(*) AS counter FROM employees GROUP BY first_name;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index | ix_firstname | 44 |
| 299809 | Using index |
루스 인덱스 스캔을 통한 GROUP BY 처리
단일 칼럼으로 구성된 인덱스에서는 그룹핑 칼럼 말고는 아무것도 조회하지 않는 쿼리에서 루스 인덱스 스캔을 사용할 수 있습니다. 그리고 다중 칼럼으로 만들어진 인덱스에서는 GROUP BY 절이 인덱스를 사용할 수 있어야 함은 물론이고 MIN()이나 MAX()와 같이 조회하는 값이 인덱스의 첫 번째 또는 마지막 레코드만 읽어도 되는 쿼리는 "루스 인덱스 스캔"이 사용될 수 있습니다. 이때는 인덱스를 듬성듬성하게 필요한 부분만 읽습니다. 다음 예제 쿼리는 salaries 테이블의 (emp_no+from_date) 칼럼으로 만들어진 인덱스에서 각 emp_no 그룹별로 첫 번째 from_date 값(최솟값)과 마지막 from_date 값(최댓값)을 인덱스로부터 읽으면 되기 때문에 "루스 인덱스 스캔" 방식으로 처리할 수 있습니다.
EXPLAIN SELECT emp_no, MIN(from_date) AS first_changed_date, MAX(from_date) AS last_changed_date FROM salaries GROUP BY emp_no;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | salaries | range | PRIMARY | 4 |
| 711129 | Using index for group-by |
GROUP BY에서 인덱스를 사용하려면 우선 GROUP BY 조건의 인덱스 사용 요건이 갖춰져야 합니다. 하지만 그 이전에 WHERE 절에서 사용하는 인덱스에 의해서도 사용 여부가 영향을 받는다는 사실이 중요합니다.
WHERE 조건절이 없는 경우
WHERE 절의 조건이 전혀 없는 쿼리는 GROUP BY와 조회하는 칼럼이 "루스 인덱스 스캔"을 사용할 수 있는 조건만 갖추면 됩니다. 그렇지 못한 쿼리는 타이트 인덱스 스캔(인덱스 스캔)이나 별도의 정렬 과정을 통해 처리됩니다.
WHERE 조건절이 있지만 검색을 위해 인덱스를 사용하지 못하는 경우
GROUP BY 절은 인덱스를 사용할 수 있지만 WHERE 조건절이 인덱스를 사용하지 못할 때는 먼저 GROUP BY를 위해 인덱스를 읽은 후, WHERE 조건의 비교를 위해 데이터 레코드를 읽어야 합니다. 그래서 이 경우도 "루스 인덱스 스캔"을 이용할 수 없으며, 타이트 인덱스 스캔(인덱스 스캔) 과정을 통해 GROUP BY가 처리됩니다. 다음의 쿼리는 WHERE 절은 인덱스를 사용하지 못하지만 GROUP BY가 인덱스를 사용하는 예제입니다.
EXPLAIN SELECT first_name FROM employees WHERE birth_date < '1994-01-01' GROUP BY first_name;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index | ix_firstname | 44 |
| 299809 | Using where |
WHERE 절의 조건이 있으며, 검색을 위해 인덱스를 사용하는 경우
하나의 단위 쿼리가 실행되는 경우에 index_merge 이외의 접근 방법에서는 단 하나의 인덱스만 사용할 수 있습니다. 그래서 WHERE 절의 조건이 인덱스를 사용할 수 있으면 GROUP BY가 인덱스를 사용할 수 있는 조건이 더 까다로워진다. 즉, WHERE 절의 조건이 검색하는 데 사용했던 인덱스를 GROUP BY 처리가 다시 사용할 수 있을 때만 루스 인덱스 스캔을 사용할 수 있습니다. 만약 WHERE 조건절이 사용할 수 있는 인덱스와 GROUP BY 절이 사용할 수 있는 인덱스가 다른 경우라면 일반적으로 옵티마이저는 WHERE 조건절이 인덱스를 사용하도록 실행 계획을 수립하는 경향이 있습니다. 때로는 전혀 작업 범위를 좁히지 못하는 WHERE 조건이라 하더라도 GROUP BY보다는 WHERE 조건이 먼저 인덱스를 사용할 수 있게 실행 계획이 수립됩니다.
EXPLAIN SELECT emp_no FROM salaries WHERE emp_no BETWEEN 10001 AND 200000 GROUP BY emp_no;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | salaries | range | PRIMARY | 4 |
| 207231 | Using where; |
WHERE 절의 조건이 검색을 위해 인덱스를 이용하고, GROUP BY가 같은 인덱스를 사용할 수 있는 쿼리라 하더라도 인덱스 루스 스캔을 사용하지 않을 수 있습니다. 즉, WHERE 조건에 의해 검색된 레코드 건수가 적으면 루스 인덱스 스캔을 사용하지 않아도 매우 빠르게 처리될 수 있기 때문입니다. 루스 인덱스 스캔은 주로 대량의 레코드를 GROUP BY하는 경우 성능 향상 효과가 있을 수 있기 때문에 옵티마이저가 적절히 손익 분기점을 판단하는 것입니다.
다음 예제 쿼리는 바로 위에서 살펴본 쿼리와 같습니다. WHERE 절의 검색 범위만 더 좁혀졌는데, 실행 계획의 Extra 칼럼에 "Using index for group-by" 처리가 사라진 것을 확인할 수 있습니다.
EXPLAIN SELECT emp_no FROM salaries WHERE emp_no BETWEEN 10001 AND 10099 GROUP BY emp_no;
루스 인덱스 스캔은 DISTINCT나 GROUP BY가 포함된 쿼리에서 최적의 튜닝 방법입니다.
Using join buffer(MySQL 5.1 부터)
일반적으로 빠른 쿼리 실행을 위해 조인이 되는 칼럼은 인덱스를 생성합니다. 실제로 조인에 필요한 인덱스는 조인되는 양쪽 테이블 칼럼 모두가 필요한 것이 아니라 조인에서 뒤에 읽는 테이블의 칼럼에만 필요합니다. MySQL 옵티마이저도 조인되는 두 테이블에 있는 각 칼럼에서 인덱스를 조사하고, 인덱스가 없는 테이블이 있으면 그 테이블을 먼저 읽어서 조인을 실행합니다. 뒤에 읽는 테이블은 검색 위주로 사용되기 때문에 인덱스가 없으면 성능에 미치는 영향이 매우 크기 때문입니다.
RDBMS에서 조인을 처리하는 방법은 2~3가지 정도 되지만 MySQL에서는 "중첩 루프 조인(Nested loop)" 방식만 지원합니다. FROM 절에 아무리 테이블이 많아도 조인을 수행할 때 반드시 두 개의 테이블이 비교되는 방식으로 처리됩니다. 그리고 두 개의 테이블이 조인될 때 먼저 읽는 테이블을 드라이빙(Driving) 테이블이라고 하며, 뒤에 읽히는 테이블을 드리브(Driven) 테이블이라고 합니다. 예를 들어 조인이 다음과 같은 순서로 수행되는 쿼리가 있다고 가정해보겠습니다.
A → B → C
A 테이블과 B 테이블이 조인되는 과정에서 드리이빙 테이블은 A이며, 드리븐 테이블은 B입니다. 그리고 B와 C가 조인되는 과정에서 드라이빙 테이블은 B가 되고 드리븐 테이블은 C가 됩니다. 일반적으로 3개 이상의 여러 개 테이블이 조인되는 경우에도 가장 먼저 읽히는 드라이비이 테이블이 어떤 테이블이냐 따라 성능이 많이 좌우됩니다. (일반적으로 쿼리 전체적으로 가장 먼저 읽히는)
가끔은 드라이빙 테이블을 아우터 테이블(Outer table), 드리븐 테이블을 이너 테이블(Inner table)이라고 표현하기도 합니다.
조인이 수행될 때 드리븐 테이블의 조인 칼럼에 적절한 인덱스가 있다면 아무런 문제가 되지 않습니다. 하지만 드리븐 테이블에 검색을 위한 적절한 인덱스가 없다면 드라이빙 테이블로부터 읽은 레코드의 건수만큼 매번 드리븐 테이블을 풀 테이블 스캔이나 인덱스 풀 스캔해야 할 것입니다. 이때 드리븐 테이블의 비효율적인 검색을 보완하기 위해 MySQL 서버는 드라이빙 테이블에서 읽은 레코드를 임시 공간에 보관해두고 필요할 때 재사용할 수 있게 해줍니다. 읽은 레코드를 임시로 보관해두는 메모리 공간을 "조인 버퍼"라고 하며, 조인 버퍼가 사용되는 실행 계획읜 Extra 칼럼에는 "Using join buffer"라는 메시지가 표시됩니다.
조인 버퍼는 join_buffer_size라는 시스템 설정 변수에 최대 사용 가능한 버퍼 크기를 설정할 수도 있습니다. 만약 조인되는 칼럼에 인덱스가 적절하게 준비돼 있다면 조인 버퍼에 크게 신경 쓰지 않아도 됩니다. 그렇지 않다면 조인 버퍼를 너무 부족하거나 너무 과다하게 사용되지 않게 적절히 제한해두는 것이 좋습니다. 일반적으로 온라인 웹 서비스용 MySQL 서버라면 조인 버퍼는 1MB 정도로 충분하며, 더 크게 설정해야 할 필요는 없습니다. 다음 예제 쿼리는 조인 조건이 없는 카테시안 조인을 수행하는 쿼리입니다. 이런 카테시안 조인을 수행하는 쿼리는 항상 조인 버퍼를 사용합니다.
EXPLAIN SELECT * FROM dept_emp de, employees e WHERE de.from_date > '2005-01-01' AND e.emp_no < 10904;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | range | ix_fromdate | 3 | 1 | Using where | |
1 | SIMPLE | e | range | PRIMARY | 4 |
| 1520 | Using where; |
Using sort_union(...), Using union(...), Using intersect(...)
쿼리가 Index_merge 접근 방식(실행 계획의 type 칼럼의 값이 index_merge)으로 실행되는 경우에는 2개 이상의 인덱스가 동시에 사용될 수 있습니다. 이때 실행 계획의 Extra 칼럼에는 두 인덱스로부터 읽은 결과를 어떻게 병합했는지 조금 더 상세하게 설명하기 위해 다음 3개 중에서 하나의 메시지를 선택적으로 출력합니다.
1. Using intersect(...)
각각의 인덱스를 사용할 수 있는 조건이 AND로 연결된 경우 각 처리 결과에서 교집합을 추출해내는 작업을 수행했다는 의미입니다.
2. Using union(...)
각 인덱스를 사용할 수 있는 조건이 OR로 연결된 경우 각 처리 결과에서 합집합을 추출해내는 작업을 수행했다는 의미입니다.
3. Using sort_union(...)
Using union과 같은 작업을 수행하지만 Using union으로 처리될 수 없는 경우(OR로 연결된 상대적으로 대량의 range 조건들) 이 방식으로 처리됩니다. Using sort_union과 Using union의 차이점은 Using sort_union은 프라이머리 키만 먼저 읽어서 정렬하고 병합한 후에야 비로소 레코드를 읽어서 반환할 수 있다는 것입니다.
Using union()과 Using sort_union()은 둘 다 충분히 인덱스를 사용할 수 있는 조건이 OR로 연결된 경우에 사용됩니다. Using union()은 대체로 동등 비교(Equal)처럼 일치하는 레코드 건수가 많지 않을때 사용되고, 각 조건이 크다 또는 작다와 같이 상대적으로 많은 레코드에 일치하는 조건이 사용되는 경우 Using sort_union()이 사용됩니다. 하지만 실제로는 레코드 건수에 거의 관계없이 각 WHERE 조건에 사용된 비교 조건이 모두 동등 조건이면 Using union()이 사용되며, 그렇지 않으면 Using sort_union()이 사용됩니다.
MySQL 내부적으로 이 둘의 차이는 정렬 알고리즘에서 싱글 패스 정렬 알고리즘과 투 패스 정렬 알고리즘의 차이와 같습니다. Using union()이 싱글 패스 정렬 알고리즘을 사용한다면 Using sort_union()은 투 패스 정렬 알고리즘을 사용합니다.
Using temporary
MySQL이 쿼리를 처리하는 동안 중간 결과를 담아 두기 위해 임시 테이블(Temporary table)을 사용합니다. 임시 테이블은 메모리상에 생성될 수도 있고 디스크상에 생성될 수도 있습니다. 쿼리의 실행 계획에서 Extra 칼럼에 "Using temporary" 키워드가 표시되면 임시 테이블을 사용한 것인데, 이때 사용된 임시 테이블이 메모리에 생성됐었는지 디스크에 생성됐었는지는 실행 계획만으로 판단할 수 없습니다.
EXPLAIN SELECT * FROM employees GROUP BY gender ORDER BY MIN(emp_no);
위의 쿼리는 GROUP BY 칼럼과 ORDER BY 칼럼이 다르기 때문에 임시 테이블이 필요한 작업입니다. 인덱스를 사용하지 못하는 GROUP BY 쿼리는 실행 계획에서 "Using temporary" 메시지가 표시되는 가장 대표적인 형태의 쿼리입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | ALL |
|
|
| 300584 | Using temporary; |
실행 계획의 Extra 칼럼에 "Using temporary"가 표시되지는 않지만, 실제 내부적으로는 임시 테이블을 사용할 때도 많습니다. Extra 칼럼에 "Using temporary"가 표시되지 않았다고 해서 임시 테이블을 사용하지 않는다라고 판단하지 않도록 주의해야 합니다. 대표적으로 메모리나 디스크에 임시 테이블을 생성하는 쿼리는 다음과 같습니다.
FROM 절에 사용된 서브 쿼리는 무조건 임시 테이블을 생성합니다. 물론 이 테이블을 파생 테이블(Derived table)이라고 부르긴 하지만 결국 실체는 임시 테이블입니다.
"COUNT(DISTINCT column1)"를 포함하는 쿼리도 인덱스를 사용할 수 없는 경우에는 임시 테이블이 만들어집니다.
UNION이나 UNION ALL이 사용된 쿼리도 항상 임시 테이블을 사용해서 결과를 병합합니다.
인덱스를 사용하지 못하는 정렬 작업 또한 임시 버퍼 공간을 사용하는데, 정렬해야 할 레코드가 많아지면 결국 디스크를 사용합니다. 정렬에 사용되는 버퍼도 결국 실체는 임시 테이블과 같습니다. 쿼리가 정렬을 수행할 때는 실행 계획의 Extra 칼럼에 "Using filesort"라고 표시됩니다.
그리고 임시 테이블이나 버퍼가 메모리에 저장됐는지, 디스크에 저장됐는지는 MySQL 서버의 상태 변수 값으로 확인할 수 있습니다.
Using where
이미 MySQL의 아키텍처 부분에서 언급했듯이 MySQL은 내부적으로 크게 MySQL 엔진과 스토리지 엔진이라는 두 개의 레이어로 나눠서 볼 수 있습니다. 각 스토리지 엔진은 디스크나 메모리상에서 필요한 레코드를 읽거나 저장하는 역할을 하며, MySQL 엔진은 스토리지 엔진으로부터 받은 레코드를 가동 또는 연산하는 작업을 수행합니다. MySQL 엔진 레이어에서 별도의 가공을 해서 필터링(여과) 작업을 처리한 경우에만 Extra 칼럼에 "Using where" 코멘트가 표시됩니다.
각 스토리지 엔진에서 전체 200건의 레코드를 읽었는데, MySQL 엔진에서 별도의 필터링이나 가공 없이 그 데이터를 그대로 클라이언트로 전달하면 "Using where"가 표시되지 않습니다. "비교 조건의 종류와 효율성"에서 작업 범위 제한 조건과 체크 조건의 구분을 언급한 바 있는데, 실제로 작업 범위 제한 조건은 각 토리지 엔진 레벨에서 처리되지만 체크 조건은 MySQL 엔진 레이어에서 처리됩니다. 다음의 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 10100 AND gender='F';
이 쿼리에서 작업 범위 제한 조건은 "emp_no BETWEEN 10001 AND 10100"이며 "gender='F'"는 체크 조건임을 쉽게 알 수 있습니다. 그런데 처음의 emp_no 조건만을 만족하는 레코드 건수는 100건이지만 두 조건을 모두 만족하는 레코드는 37건밖에 안됩니다. 이는 스토리지 엔진은 100개를 읽어서 MySQL 엔진에 넘겨줬지만 MySQL 엔진은 그중 63건의 레코드를 그냥 필터링해서 버렸다는 의미입니다. 여기서 "Using where"는 63건의 레코드를 버리는 처리를 의미합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | PRIMARY | 4 | NULL | 100 | Using where |
MySQL 실행 계획에서 Extra 칼럼에 가장 흔하게 표시되는 내용이 "Using where"입니다. 그래서 가장 쉽게 무시해버리는 메시지이기도 합니다. 실제로 왜 "Using where"가 표시됐는지 전혀 이해할 수 없을 때도 많습니다. 더욱이 MySQL 5.0에서는 프라이머리 키로 한 건이 레코드만 조회해도 "Using where"로 출력되는 버그가 있었습니다. 그래서 실행 계획의 Extra 칼럼에 표시되는 "Using where"가 성능상의 문제를 일으킬지 아닐지를 적절히 선별하는 능력이 필요한데, MySQL 5.1부터는 실행 계획에 Filtered 칼럼이 함께 표시되므로 쉽게 성능상의 이슈가 있는지 없는지를 알아낼 수 있습니다.
위의 쿼리 예제를 통해 인덱스 최적화를 조금 더 살펴보겠습니다. 위 처리 과정에서 최종적으로 쿼리에 일치하는 레코드는 37건밖에 안되지만 스토리지 엔진은 100건의 레코드를 읽은 것입니다. 상당히 비효율적인 과정이라고 볼 수 있습니다. 그런데 만약 employees 테이블에 (emp_no+gender)로 인덱스가 준비돼 있었다면 어떻게 될까요? 이때는 두 조건 모두 작업 범위의 제한 조건으로 사용되어, 필요한 37개의 레코드만 정확하게 읽을 수 있습니다. 일반적으로 Extra 칼럼에 "Using where"가 표시되는 경우에는 MySQL 엔진에서 한번 필터링 작업을 했다는 것을 의미합니다. 그리고 그와 동시에 스토리지 엔진에 쓸모 없는 일을 추가로 시켰다는 것을 의미합니다. 이는 MySQL이 스토리지 엔진과 MySQL 엔진으로 이원화된 구조 때문에 발생하는 문제점으로 볼 수 있습니다.
똑같이 MySQL 엔진과 스토리지 엔진의 이원화된 구조 탓에 발생하는 문제점을 하나 더 살펴보겠습니다.
CREATE TABLE tb_likefilter ( category INT, name VARCHAR(30), INDEX ix_category_name(category, name) ); SELECT * FROM tb_likefilter WHERE category=10 AND name LIKE '%abc%';
위 쿼리의 경우, category 칼럼과 name 칼럼이 인덱스로 생성돼 있습니다. 하지만 name LIKE '%abc%' 조건은 작업범위 제한 조건으로 사용되지 못합니다. 이처럼 작업 범위 제한 조건으로 사용되지 못하는 조건은 스토리지 엔진에서 인덱스를 통해 체크되는 것이 아니라 MySQL 엔진에서 처리됩니다. 즉, 스토리지 엔진에서는 category=10을 만족하는 모든 레코드를 읽어서 MySQL 엔진으로 넘겨주고 MySQL 엔진에서 name LIKE '%abc%' 조건 체크를 수행해서 일치하지 않는 레코드를 버리는 것입니다.
예를 들어, category=10을 만족하는 레코드가 100건, 그중에서 name LIKE '%abc%' 조건을 만족하는 레코드가 10건이라면 MySQL 엔진은 10건의 레코드를 위해 그 10배의 작업을 스토리지 엔진에 요청합니다. 상당히 불합리한 처리방식이기도 하지만 MySQL 5.0 이하의 버전에서는 피할 수 없는 문제점이었습니다. InnoDB나 MyISAM과 같은 스토리지 엔진과 MySQL 엔진은 모두 하나의 프로세스에서 동작하기 때문에 성능에 미치는 영향이 그다지 크지 않습니다. 하지만 스토리지 엔진이 MySQL 엔진 외부에서 작동하는 NDB 클러스터는 네트워크 전송 부하까지 겹치기 때문에 성능에 미치는 영향이 더 큰편입니다.
MySQL 5.1의 InnoDB 플러그인 버전부터는 이원화된 구조의 불합리를 제거하기 위해 WHERE 절의 범위 제한 조건뿐 아니라 체크 조건까지 모두 스토리지 엔진으로 전달됩니다. 스토리지 엔진에서는 그 조건에 정확히 일치하는 레코드만 읽고 MySQL 엔진으로 전달하기 때문에 이런 비효율적인 부분이 사라진 것입니다. 즉, MySQL 5.1부터는 위의 시나리오에서는 스토리지 엔진이 꼭 필요한 10건의 레코드만 읽게되는 것입니다. MySQL에서 이러한 기능을 "Condition push down"이라고 표현합니다.
Using where with pushed condition
실행 계획의 Extra 칼럼에 표시되는 "Using where with pushed condition" 메시지는 "Condition push down"이 적용됐음을 의미하는 메시지입니다. MySQL 5.1부터는 "Condition push down"이 InnoDB나 MyISAM 스토리지 엔진에도 도입되어 각 스토리지 엔진의 비효율이 상당히 개선됐다고 볼 수 있습니다.
하지만 MyISAM이나 InnoDB 스토리지 엔진을 사용하는 테이블의 실행 계획에는 "Using where with pushed condition" 메시지가 표시되지 않습니다. 이 메시지는 NDB 클러스터 스토리지 엔진을 사용하는 테이블에서만 표시되는 메시지입니다. NDB 클러스터는 MySQL 엔진의 외부에서 작동하는 스토리지 엔진이라서 스토리지 엔진으로부터 읽은 레코드는 네트워크를 통해 MySQL 엔진으로 전달됩니다. NDB 클러스터는 여러 개의 노드로 구성되는데, "SQL 노드"는 MySQL 엔진 역할을 담당하며, "데이터 노드"는 스토리지 엔진 역할을 담당합니다. 그리고 데이터 노드와 SQL 노드는 네트워크를 통해 TCP/IP 통신을 합니다. 그래서 실제 "Condition push down"이 사용되지 못하면 상당한 성능 저하가 발생할 수 있습니다.
EXPLAIN EXTENDED(Filtered 칼럼)
실행 계획의 Extra 칼럼에 표시되는 "Using where"의 의미는 앞서 설명이 되었습니다. MySQL 5.1 이상 버전이라 하더라도 스토리지 엔진에서 최종적으로 사용자에게 전달되는 레코드만 가져오는 것은 아닙니다. 조인과 같이 여러 가지 이유로 여전히 각 스토리지 엔진에서 읽어 온 레코드를 MySQL 엔진에서 필터링하는데, 이 과정에서 버려지는 레코드가 발생할 수밖에 없습니다. 하지만 MySQL 5.1.12 미만의 버전에서는 MySQL 엔진에 의해 필터링 과정을 거치면서 얼마나 많은 레코드가 버려졌고, 그래서 얼마나 남았는지를 알 방법이 없었습니다.
MySQL 5.1.12 버전부터는 필터링이 얼마나 효율적으로 실행됐느지를 사용자에게 알려주기 위해 실행 계획에 Filtered라는 칼럼이 새로 추가됐습니다. 실행 계획에서 Filtered 칼럼을 함께 조회하려면 EXPLAIN 명령 뒤에 "EXTENDED"라는 키워드를 지정하면 됩니다. "EXTENDED" 키워드가 사용된 실행 계획 예제를 한번 살펴보겠습니다.
EXPLAIN EXTENDED SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 10100 AND gender='F';
"EXPLAIN EXTENDED" 명령을 사용해 쿼리의 실행 계획을 조회하면 다음과 같이 실행 계획의 "rows" 칼럼 뒤에 "Filtered"라는 새로운 칼럼이 같이 표시됩니다.
Id | select_type | Table | type | key | key_len | ref | rows | filtered | Extra |
1 | SIMPLE | employees | range | PRIMARY | 4 | NULL | 100 | 20 | Using where |
실행 계획에서 filtered 칼럼에는 MySQL 엔진에 의해 필터링되어 제거된 레코드는 제외하고 최종적으로 레코드가 얼마나 남았는지의 비율(Percentage)이 표시됩니다. 위의 예제에서는 rows 칼럼의 값이 100건이고 filtered 칼럼의 값이 20%이므로, 스토리지 엔진이 전체 100건의 레코드를 읽어서 MySQL 엔진에 전달했는데, MySQL 엔진에 의해 필터링되고 20%만 남았다는 것을 의미합니다. 즉, MySQL 엔진에 의해 필터링되고 남은 레코드는 20건(100건 * 20%)이라는 의미입니다. 여기에 출력되는 filtered 칼럼의 정보 또한 실제 값이 아니라 단순히 통계 정보로부터 예측된 값일 뿐입니다.
EXPLAIN EXTENDED(추가 옵티마이저 정보)
EXPLAIN 명령의 EXTENDED 옵션은 숨은 기능이 하나 더 있습니다. MySQL 엔진에서 쿼리의 실행 계획을 산출하기 위해 쿼리 문장을 분석해 파스 트리를 생성합니다. 또한 일부 최적화 작업도 이 파스 트리를 이용해 수행합니다. "EXPLAIN EXTENDED" 명령의 또 다른 기능은 분석된 파스 트리를 재조합해서 쿼리 문장과 비슷한 순서대로 나열해서 보여주는 것입니다.
EXPLAIN EXTENDED 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;
EXPLAIN EXTENDED 명령을 실행하면 EXTENDED 옵션이 없을 때와 같이 쿼리의 실행 계획만 화면에 출력됩니다. 하지만 EXPLAIN EXTENDED 명령을 실행해 실행 계획이 출력된 직후, "SHOW WARNINGS" 명령을 실행하면 옵티마이저가 분석해서 다시 재조합한 쿼리 문장을 다음과 같이 확인 할 수 있습니다.
mysql> SHOW WARNINGS;
SELECT 'Georgi' AS 'first_name',
(SELECT COUNT(0)
FROM 'employees'.'dept_emp' 'de'
JOIN 'employees'.'dept_manager' 'dm'
WHERE ('employees'.'de'.'dept_no' = 'employees') AS 'cnt'
FROM 'employees'.'employees' 'e' WHERE 1)
SHOW WARNINGS 명령으로 출력된 내용은 표준 SQL 문장이 아닙니다. 지금의 예제는 상당히 비슷하게 출력됐지만 최적화 정보가 태그 형태로 포함된 것들도 있으며 쉽게 알아보기는 어려운 경우도 많습니다. 위의 예제에서는 COUNT(*)가 내부적으로는 COUNT(0)으로 변환되어 처리된다는 것과 emp_no=10001 조건을 옵티마이저가 미리 실행해서 상수화된 값으로 'Georgi'가 사용됐다는 것도 알 수 있습니다.
EXPLAIN EXTENDED 명령을 이용해 옵티마이저가 쿼리를 어떻게 해석했고, 어떻게 쿼리를 변환했으며, 어떤 특수한 처리가 수행됐는지 등을 판단할 수 있으므로 알아두면 도움이 될 것입니다.
EXPLAIN PARTITIONS(Partitions 칼럼)
EXPLAIN 명령에 사용할 수 있는 옵션이 또 하나 있는데, 이 옵션으로 파티션 테이블의 실행 계획 정보를 더 자세히 확인할 수 있습니다. 단순히 EXPLAIN 명령으로는 파티션 테이블이 어떻게 사용됐느닞 확인할 수 없습니다. 하지만 EXPLAIN 명령 뒤에 PARTITIONS 옵션을 사용하면 쿼리를 실행하기 위해 테이블의 파티션 중에서 어떤 파티션을 사용했는지 등의 정보를 조회할 수 있습니다.
CREATE TABLE tb_partition ( reg_date DATE DEFAULT NULL, id INT DEFAULT NULL, name VARCHAR(50) DEFAULT NULL ) ENGINE=INNODB partition BY range (YEAR(reg_date)) ( partition p0 VALUES less than (2008) ENGINE = INNODB, partition p1 VALUES less than (2009) ENGINE = INNODB, partition p2 VALUES less than (2010) ENGINE = INNODB, partition p3 VALUES less than (2011) ENGINE = INNODB ); EXPLAIN PARTITIONS SELECT * FROM tb_partition WHERE reg_date BETWEEN '2010-01-01' AND '2010-12-30';
위 예제의 tb_partition 테이블은 reg_date 칼럼의 값을 이용해 년도별로 구분된 파티션 4개를 가집니다. 그리고 이 테이블에서 reg_date 칼럼의 값이 "2010-01-01"부터 "2010-12-30"까지의 레코드를 조회하는 쿼리에 대해 실행 계획을 확인해 보겠습니다. 이 쿼리에서 조회하려는 데이터는 모두 2010년도 데이터이고 3번째 파티션인 p3에 저장돼 있음을 알 수 있습니다. 실제로 옵티마이저는 이 쿼리를 처리하기 위해 p3 파티션만 읽으면 된다는 것을 알아채고, 그 파티션에만 접근하도록 실행 계획을 수립합니다. 이처럼 파티션이 여러 개인 테이블에서 불필요한 파티션을 빼고 쿼리를 수행하기 위해 접근해야 할 것으로 판단되는 테이블만 골라내는 과정을 파티션 프루닝(Partition pruning)이라고 합니다.
그렇다면 이 쿼리의 실행 계획이 정말 꼭 필요한 p3 파티션만 읽는지 확인해 볼 수 있어야 쿼리의 튜닝이 가능할 것입니다. 이때 옵티마이저가 이 쿼리를 실행하기 위해 접근하는 테이블을 확인해 볼 수 있는 명령이 EXPLAING PARTITIONS입니다. EXPLAIN PRTITIONS 명령으로 출력된 실행 계획에는 partitions라는 새로운 칼럼을 포함해서 표시합니다. Partitions 칼럼에는 이 쿼리가 사용한 파티션 목록이 출력되는데, 예상했던 대로 p3 파티션만 참조했음을 알 수 있습니다.
Id | select_type | Table | partitions | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | tb_partition | p3 | ALL |
|
|
| 2 | Using where |
EXPLAIN PARTITIONS 명령은 파티션 테이블에 실행되는 쿼리가 얼마나 파티션 기능을 잘 활용하고 있는지를 판단할 수 있는 자료를 제공합니다. EXPLAIN 명령에서는 EXTENDED와 PARTITIONS 옵션을 함께 사용할 수 없습니다.
TO_DAYS() 함수는 입력된 날짜 값의 포맷이 잘못돼 있다면 NULL을 반환할 수도 있습니다. 이렇게 MySQL의 파티션 키가 TO_DAYS()와 같이 NULL을 반환할 수 있는 함수를 사용할 때는 쿼리의 실행 계획에서 partitions 칼럼에 테이블의 첫 번째 파티션이 포함되기도 합니다. 레인지 파티션을 사용하는 테이블에서 NULL은 항상 첫 번째 파티션에 저장되기 때문에 실행 계획의 partitions 칼럼에 첫 번째 파티션도 함께 포함되는 것입니다. 하지만 이렇게 실제 필요한 파티션과 테이블의 첫 번째 파티션이 함께 partitions 칼럼에 표시된다 하더라도 성능 이슈는 없으므로 크게 걱정하지 않아도 됩니다.
길은 가면, 뒤에 있다.
[MySQL] MySQL 실행 계획
DBMS의 쿼리 실행에 같은 결과를 만들어 내는 데 한가지 방법만 있는 것은 아닙니다. 아주 많은 방법이 있지만 그중에서 어떤 방법이 최적이고 최소의 비용이 소모될지 결정해야 합니다. DBMS에서는 쿼리를 최적으로 실행하기 위해 각 테이블의 데이터가 어떤 분포로 저장돼 있는지 통계 정보를 참조하며, 그러한 기본 데이터를 비교해 최적의 실행 계획을 수립하는 작업이 필요합니다. DBMS에서는 옵티마이저가 이러한 기능을 담당합니다.
MySQL에서는 EXPLAIN이라는 명령으로 쿼리의 실행 계획을 확인할 수 있으며, 여기에는 많은 정보가 출력됩니다. 실행 계획에 표시되는 내용이 무엇을 의미하고 MySQL 서버가 내부적으로 어떤 작업을 하는지 자세히 살펴보겠습니다. 그리고 어떤 실행 계획이 좋고 나쁜지도 간단히 살펴보겠습니다.
실행 계획 개요
어떤 DBMS든지 쿼리의 실행 계획을 수립하는 옵티마이저는 가장 복잡한 부분으로 알려져 있으며, 옵티마이저가 만들어 내는 실행 계획을 이해하는 것 또한 상당히 어려운 부분입니다. 하지만 그 실행 계획을 이해할 수 있어야만 실행 계획의 불합리한 부분을 찾아내고, 더욱 최적화된 방법으로 실행 계획을 수립하도록 유도할 수 있습니다. 실행 계획을 살펴보기 전에, 먼저 알고 있어야 할 몇 가지 부분을 살펴 보겠습니다.
쿼리 실행 절차
MySQL 서버에서 쿼리가 실행되는 과정은 크게 3가지로 나눌 수 있습니다.
1. 사용자로부터 요청된 SQL 문장을 잘게 쪼개서 MySQL 서버가 이해할 수 있는 수준으로 분리한다.
2. SQL의 파싱 정보(파스 트리)를 확인하면서 어떤 테이블부터 읽고 어떤 인덱스를 이용해 테이블을 읽을지 선택한다.
3. 두번째 단계에서 결정된 테이블의 읽기 순서나 선택된 인덱스를 이용해 스토리지 엔진으로부터 데이터를 가져온다.
첫 번째 단계를 "SQL 파싱(Parsing)"이라고 하며, MySQL 서버의 "SQL 파서"라는 모듈로 처리합니다. 만약 SQL 문장이 문법적으로 잘못됐다면 이 단계에서 걸러집니다. 또한 이 단계에서 "SQL 파스 트리"가 만들어집니다. MySQL 서버는 SQL 문장 그 자체가 아니라 SQL 파스 트리를 이용해 쿼리를 실행합니다.
두 번째 단계는 첫 번째 단계에서 만들어진 SQL 파스 트리를 참조하면서, 다음과 같은 내용을 처리합니다.
불필요한 조건의 제거 및 복잡한 연산의 단순화
여러 테이블의 조인이 있는 경우 어떤 순서로 테이블을 읽을지 결정
각 테이블에 사용된 조건과 인덱스 통계 정보를 이용해 사용할 인덱스 결정
가져온 레코드들을 임시 테이블에 넣고 다시 한번 가공해야 하는지 결정
물론 이 밖에도 수많은 처리를 하지만, 대표적으로 이런 작업을 들 수 있습니다. 두 번째 단계는 "최적화 및 실행 계획 수립" 단계이며, MySQL 서버의 "옵티마이저"에서 처리합니다. 또한 두 번째 단계가 오나료되면 쿼리의 "실행 계획"이 만들어집니다.
세 번째 단계는 수립된 실행 계획대로 스토리지 엔진에 레코드를 읽어오도록 요청하고, MySQL 엔진에서는 스토리지 엔진으로부터 받은 레코드를 조인하거나 정렬하는 작업을 수행합니다.
첫 번째 단계와 두 번째 단계는 거의 MySQL 엔진에서 처리하며, 세 번째 단계는 MySQL 엔진과 스토리지 엔진이 동시에 참여해서 처리합니다. 아래 그림은 "SQL 파서"와 "옵티마이저"가 MySQL 전체적인 아키텍처에서 어느 위치에 있는지 보여줍니다.
옵티마이저의 종류
옵티마이저는 데이터베이스 서버에 두뇌와 같은 역할을 담당하고 있습니다. 옵티마이저는 현재 대부분의 DBMS가 선택하고 있는 비용 기반 최적화(Cost-based optimizer, CBO) 방법과 예전 오라클에서 많이 사용됐던 규칙 기반 최적화 방법(Rule-based optimizer, RBO)으로 크게 나눠 볼 수 있습니다.
규칙 기반 최적화는 기본적으로 대상 테이블의 레코드 건수나 선택도 등을 고려하지 않고 옵티마이저에 내장된 우선순위에 따라 실행 계획을 수립하는 방식을 의미합니다. 이 방식에서는 통계 정보(테이블의 레코드 건수나 칼럼 값의 분포도)를 조사하지 않고 실행 계획이 수립되기 때문에 같은 쿼리에 대해서는 거의 항상 같은 실행 방법을 만들어냅니다. 하지만 규칙 기반의 최적화는 이미 오래 전부터 많은 DBMS에서 거의 지원되지 않거나 업데이트되지 않은 상태로 그대로 남아 있는 것이 현실입니다.
비용 기반 최적화는 쿼리를 처리하기 위한 여러 가지 가능한 방법을 만들고, 각 단위 작업의 비용(부하) 정보와 대상 테이블의 예측된 통계 정보를 이용해 각 실행 계획별 비용을 산출합니다. 이렇게 산출된 각 실행 방법별로 최소 비용이 소요되는 처리 방식을 선택해 최종 쿼리를 실행하게 됩니다.
규칙 기반 최적화는 각 테이블이나 인덱스의 통계 정보가 거의 없고, 상대적으로 느린 CPU 연산 탓에 비용 계산 과정이 부담스러웠기 때문에 사용되던 최적화 방법입니다. 현재는 거의 대부분의 RDBMS가 비용 기반의 옵티마이저를 채택하고 있으며, MySQL 역시 마찬가지입니다.
통계 정보
비용 기반 최적화에서 가장 중요한 것은 통계 정보입니다. 통계 정보가 정확하지 않다면 전혀 엉뚱한 방향으로 쿼리를 실행해 버릴 수 있기 때문입니다. 예를 들어 1억 건의 레코드가 저장된 테이블의 통계 정보가 갱신되지 않아서 레코드가 10건 미만인 것처럼 돼 있다면 옵티마이저는 실제 쿼리 실행 시에 인덱스 레인지 스캔이 아니라 테이블을 처음부터 끝까지 읽는 방식(풀 테이블 스캔)으로 실행해 버릴 수도 있습니다. 부정확한 통계 정보 탓에 0.1초에 끝날 쿼리가 1시간이 소요될 수도 있습니다.
MySQL 또한 다른 DBMS와 같이 비용 기반의 최적화를 사용하지만 다른 DBMS보다 통계 정보는 그리 다양하지 않습니다. 기본적으로 MySQL에서 관리되는 통계 정보는 대략의 레코드 건수와 인덱스의 유니크한 값의 개수 정도가 전부입니다. 오라클과 같은 DBMS에서는 통계 정보가 상당히 정적이고 수집에 많은 시간이 소요되기 때문에 통계 정보만 따로 백업하기도 합니다. 하지만 MySQL에서 통계 정보는 사용자가 알아채지 못하는 순간순간 자동으로 변경되기 때문에 상당히 동적인 편입니다. 하지만 레코드 건수가 많지 않으면 통계 정보가 상당히 부정확한 경우가 많으므로 "ANALYZE" 명령을 이용해 강제적으로 통계 정보를 갱신해야 할 때도 있습니다. 특히 이런 현상은 레코드 건수가 얼마 되지 않는 개발용 MySQL 서버에서 자주 발생합니다.
MEMORY 테이블은 별도로 통계 정보가 없으며, MyISAM과 InnoDB의 테이블과 인덱스 통계 정보는 다음과 같이 확인할 수 있습니다. ANALYZE 명령은 인덱스 키값의 분포도(선택도)만 업데이트하며, 전체 테이블의 건수는 테이블의 전체 페이지 수를 이용해 예측합니다.
SHOW TABLE STATUS LIKE 'tb_test'\G SHOW INDEX FROM tb_test;
통계 정보를 갱신하려면 다음과 같이 ANALYZE를 실행하면 됩니다.
-- // 파티션을 사용하지 않는 일반 테이블의 통계 정보 수집 ANALYZE TABLE tb_test; -- // 파티션을 사용하는 테이블에서 특정 파티션의 통계 정보 수집 ANALYZE TABLE tb_test ANALYZE PARTITION p3;
ANALYZE를 실행하는 동안 MyISAM 테이블은 읽기는 가능하지만 쓰기는 안됩니다. 하지만 InnoDB 테이블은 읽기와 쓰기 모두 불가능하므로 서비스 도중에는 ANALYZE을 실행하지 않는 것이 좋습니다. MyISAM 테이블의 ANALYZE는 정확한 키값 분포도를 위해 인덱스 전체를 스캔하므로 많은 시간이 소요됩니다. 이와는 달리 InnoDB 테이블은 인덱스 페이지 중에서 8개 정도만 랜덤하게 선택해서 분석하고 그 결과를 인덱스의 통계 정보로 갱신합니다.
MySQL 5.1.38 미만 버전에서는 항상 랜덤하게 인덱스 페이지 8개만 읽어서 통계 정보를 수집하지만 MySQL 5.1.38 이상의 InnoDB 플러그인 버전에서는 분석할 인덱스 페이지의 개수를 "innodb_stats_sample_pages" 파라미터로 지정할 수 있습니다. 분석할 페이지 개수를 늘릴수록 더 정확한 통계 정보를 수집할 수 있겠지만 InnoDB의 통계정보는 다른 DBMS보다 훨씬 자주 수집되며 서비스 도중에도 통계 정보가 수집될 수 있습니다. InnoDB의 통계 수집을 위한 인덱스 페이지 개수는 기본값 8개에서 2~3배 이상을 벗어나지 않도록 설정하는 좋습니다.
실행 계획 분석
MySQL에서 쿼리의 실행 계획을 확인하려면 EXPLAIN 명령을 사용하면 됩니다. 아무런 옵션 없이 EXPLAIN 명령만 사용하면 기본적인 쿼리 실행 계획만 보입니다. 하지만 EXPLAIN EXTENDED나 EXPLAIN PARTITIONS 명령을 이용해 더 상세한 실행 계획을 확인할 수도 있습니다. 추가 옵션을 사용하는 경우에는 기본적인 실행 계획에 추가로 정보가 1개씩 더 표시됩니다.
우선 기본 실행 계획을 제대로 이해할 수 있어야 하므로 옵션이 없는 "EXPLAIN" 명령으로 조회하는 실행 계획을 자세히 살펴보겠습니다. 그리고 마지막에 PARTITIONS나 EXTENDED 옵션의 실행 계획을 확인하는 방법을 설명하겠습니다.
EXPLAIN 명령은 다음과 같이 EXPLAIN 키워드 뒤에 확인하고 싶은 SELECT 쿼리 문장을 적으면 됩니다. 실행 계획의 결과로 여러 가지 정보가 표 형태로 표시됩니다. 실행계획 중에는 possible_keys 항목과 같이 내용은 길지만 거의 쓸모가 없는 항목도 있습니다. 실행 계획의 여러 결과 중 꼭 필요한 경우를 제외하고는 모두 생략하고 표시합니다. 또한 실행 계획에서 NULL 값이 출력되는 부분은 모두 공백으로 표시합니다.
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;
EXPLAIN을 실행하면 쿼리 문장의 특성에 따라 표 형태로 된 1줄 이상의 결과가 표시됩니다. 표의 각 라인(레코드)은 쿼리 문장에서 사용된 테이블(서브 쿼리로 임시 테이블을 생성한 경우 그 임시 테이블까지 포함)의 개수만큼 출력됩니다. 실행 순서는 위에서 아래로 순서대로 표시된다(UNION이나 상관 서브쿼리와 같은 경우 순서대로 표시되지 않을 수 있습니다.) 출력된 실행 계획에서 위쪽에 출력된 결과일수록(id 칼럼의 값이 작을수록) 쿼리의 바깥(Outer) 부분이거나 먼저 접근한 테이블이고, 아래쪽에 출력된 결과일수록(id 칼럼의 값이 클수록) 쿼리의 안쪽(Inner) 부분 또는 나중에 접근한 테이블에 해당됩니다. 하지만 쿼리 문장과 직접 비교해 가면서 실행 계획의 위쪽부터 테이블과 매칭해서 비교하는 편이 더 쉽게 이해될 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e | index | ix_firstname | 44 |
| 300584 | Using index |
2 | SIMPLE | s | ref | PRIMARY | 4 | employees. | 4 |
|
다른 DBMS와는 달리 MySQL에서는 필요에 따라 실행 계획을 산출하기 위해 쿼리의 일부분을 직접 실행할 때도 있습니다. 때문에 쿼리 자체가 상당히 복잡하고 무거운 쿼리인 경우에는 실행 계획의 조회 또한 느려질 가능성이 있습니다. 그리고 UPDATE나 INSERT, DELETE 문장에 대해서는 실행 계획을 확인할 방법이 없습니다. UPDATE나 INSERT, DELETE 문장의 실행 계획을 확인하려면 WHERE 조건절만 같은 SELECT 문장을 만들어서 대략적으로 계획을 확인해 볼 수 있습니다.
이제부터는 실행 계획에 표시된 각 칼럼이 어떤 것을 의미하는지, 그리고 각 컬럼에 어떤 값들이 출력될 수 있는지 하나씩 자세히 살펴보겠습니다.
id 칼럼
하나의 SELECT 문장은 다시 1개 이상의 하위(SUB) SELECT 문장을 포함할 수 있습니다. 다음 쿼리를 살펴보겠습니다.
SELECT ... FROM (SELECT ... FROM tb_test1) tb1, tb_test2 tb2 WHERE tb1.id = tb2.id;
위의 쿼리 문장에 있는 각 SELECT를 다음과 같이 분리해서 생각해볼 수 있습니다. 이렇게 SELECT 키워드 단위로 구분한 것을 "단위 (SELECT) 쿼리"라고 표현하겠습니다.
SELECT ... FROM tb_test1; SELECT ... FROM tb1, tb_test2 tb WHERE tb1.id = tb2.d;
실행 계획에서 가장 왼쪽에 표시되는 id 칼럼은 단위 select 쿼리별로 부여되는 식별자 값입니다. 이 예제 쿼리의 경우, 실행 계획에서 최소 2개의 id 값이 표시될 것입니다.
만약 하나의 SELECT 문장 안에서 여러 개의 테이블을 조인하면 조인되는 테이블의 개수만큼 실행 계획 레코드가 출력되지만 같은 id가 부여됩니다. 다음 예제에서처럼 SELECT 문장은 하나인데 여러 개의 테이블이 조인되는 경우에는 id 값이 증가하지 않고 같은 id가 부여됩니다.
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;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e | index | ix_firstname | 44 |
| 300584 | Using index |
1 | SIMPLE | s | ref | PRIMARY | 4 | employees. | 4 |
|
반대로 다음 쿼리의 실행 계획에서는 쿼리 문장이 3개의 단위 SELECT 쿼리로 구성돼 있으므로 실행 계획의 각 레코드가 각기 다른 id를 지닌 것을 확인할 수 있습니다.
EXPLAIN SELECT ( (SELECT COUNT(*) FROM employees) + (SELECT COUNT(*) FROM departments) ) AS total_count;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | No tables used | ||||||
2 | SUBQUERY | departments | index | ux_deptname | 123 | 9 | Using index | |
3 | SUBQUERY | employees | index | ix_hiredate | 3 | 300584 | Using index |
select_type 칼럼
각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시되는 칼럼입니다. select_type 칼럼에 표시될 수 있는 값은 다음과 같습니다.
SIMPLE
UNION이나 서브 쿼리를 사용하지 않는 단순한 SELECT 쿼리인 경우, 해당 쿼리 문장의 select_type은 SIMPLE로 표시됩니다.(쿼리에 조인이 포함된 경우에도 마찬가지) 쿼리 문장이 아무리 복잡하더라도 실행 계획에서 select_type이 SIMPLE인 단위 쿼리는 반드시 하나만 존재합니다. 일반적으로 제일 바깥 SELECT 쿼리의 select_type이 SIMPLE로 표시됩니다.
PRIMARY
UNION이나 서브 쿼리가 포함된 SELECT 쿼리의 실행 계획에서 가장 바깥쪽(Outer)에 있는 단위 쿼리는 select_type이 PRIMARY로 표시됩니다. SIMPLE과 마찬자기로 select_type이 PRIMARY인 단위 SELECT 쿼리는 하나만 존재하며, 쿼리의 제일 바깥 쪽에 있는 SELECT 단위 쿼리가 PRIMARY로 표시됩니다.
UNION
UNION으로 결합하는 단위 SELECT 쿼리 가운데 첫 번째를 제외한 두 번째 이후 단위 SELECT 쿼리의 select_type은 UNION으로 표시됩니다. UNION의 첫 번째 단위 SELECT는 select_type이 UNION이 아니라 UNION 쿼리로 결합된 전체 집합의 select_type이 표시됩니다.
EXPLAIN SELECT * FROM ( (SELECT emp_no FROM employees e1 LIMIT 10) UNION ALL (SELECT emp_no FROM employees e2 LIMIT 10) UNION ALL (SELECT emp_no FROM employees e3 LIMIT 10) ) tb;
위 쿼리의 실행 계획은 다음과 같습니다. UNION이 되는 단위 SELECT 쿼리 3개 중에서 첫 번째(e 테이블)만 UNION이 아니고, 나머지 2개는 모두 UNION으로 표시돼 있습니다. 대신 UNION의 첫 번째 쿼리는 전체 UNION의 결과를 대표하는 select_type으로 설정됐습니다. 여기서는 세 개의 서브 쿼리로 조회된 결과를 UNION ALL로 결합해 임시 테이블을 만들어서 사용하고 있으므로 UNION ALL의 첫번째 쿼리는 DERIVED라는 select_type을 갖는 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 30 | ||||
2 | DERIVED | e1 | index | ix_hiredate | 3 |
| 300584 | Using index |
3 | UNION | e2 | index | ix_hiredate | 3 | 300584 | Using index | |
4 | UNION | e3 | index | ix_hiredate | 3 | 300584 | Using index | |
UNION SELECT | <union2,3,4> | ALL |
DEPENDENT UNION
DEPENDENT UNION select_type 또한 UNION select_type과 같이 쿼리에 UNION이나 UNION ALL로 집합을 결합하는 쿼리에서 표시됩니다. 그리고 여기서 DEPENDENT는 UNION이나 UNION ALL로 결합된 단위 쿼리가 외부의 영향에 의해 영향을 받는 것을 의미합니다. 다음의 예제 쿼리를 보면 두 개의 SELECT 쿼리가 UNION으로 결합됐으므로 select_type에 UNION이 표시된 것입니다. 그런데 UNION으로 결합되는 각 쿼리를 보면 이 서브 쿼리의 외부(Outer)에서 정의된 employees 테이블의 emp_no 칼럼을 사용하고 있음을 알 수 있습니다. 이렇게 내부 쿼리가 외부의 값을 참조해서 처리될 때 DEPENDENT 키워드가 select_type에 표시됩니다.
EXPLAIN SELECT e.first_name, ( SELECT CONCAT('Salary change count : ', COUNT(*)) AS message FROM salaries s WHERE s.emp_no = e.emp_no UNION SELECT CONCAT('Department change count : ', COUNT(*)) AS message FROM dept_emp de WHERE de.emp_no = e.emp_no ) AS message FROM employees e WHERE e.emp_no = 10001;
위 예제는 조금 억지스럽긴 하지만 UNION에 사용된 SELECT 쿼리에 아우터에 정의된 employees 테이블의 emp_no 칼럼이 사용됐기 때문에 DEPENDENT UNION이라 select_type에 표시된 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 1 | |
2 | DEPENDENT | s | ref | PRIMARY | 4 | const | 17 | Using index |
3 | DEPENDENT | de | ref | ix_empno | 3 | 1 | Using where; | |
UNION RESULT | <union2,3,> | ALL |
하나의 단위 SELECT 쿼리가 다른 단위 SELECT를 포함하고 있으면 이를 서브 쿼리라고 표현합니다. 이처럼 서브 쿼리가 사용된 경우에는 외부(Outer) 쿼리보다 서브 쿼리가 먼저 실행되는 것이 일반적이며, 대부분이 이 방식의 반대의 경우보다 빠르게 처리됩니다. 하지만 select_type에 "DEPENDENT" 키워드를 포함하는 서브 쿼리는 외부 쿼리에 의존적이므로 절대 외부 쿼리보다 먼저 실행될 수가 없습니다. 그래서 select_type에 "DEPENDENT" 키워드가 포함된 서버 쿼리는 비효율적인 경우가 많습니다.
UNION RESULT
UNION 결과를 담아두는 테이블을 의미합니다. MySQL에서 UNION ALL이나 UNION (DISTINCT) 쿼리는 모두 UNION의 결과를 임시 테이블로 생성하게 되는데, 실행 계획상에서 이 임시 테이블을 가리키는 라인의 select_type이 UNION RESULT입니다. UNION RESULT는 실제 쿼리에서 단위 쿼리가 아니기 때문에 별도로 id 값은 부여되지 않습니다.
EXPLAIN SELECT emp_no FROM salaries WHERE salary>100000 UNION ALL SELECT emp_no FROM dept_emp WHERE from_date > '2001-01-01';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | salaries | Range | ix_salary | 4 | 171094 | Using where; | |
2 | UNION | dept_emp | Range | ix_fromdate | 3 |
| 10458 | Using where; |
UNION SELECT | <union1,2> | ALL |
위 실행 계획의 마지막 "UNION RESULT" 라인의 table 칼럼은 "<union1,2>"로 표시돼 있는데, 이것은 id가 1번인 단위 쿼리의 조회 결과와 id가 2번인 단위 쿼리의 조회 결과를 UNION했다는 것을 의미합니다.
SUBQUERY
일반적으로 서브 쿼리라고 하면 여러 가지를 통틀어서 이야기할 때가 많은데, 여기서 SUBQUERY라고 하는 것은 FROM 절 이외에서 사용되는 서브 쿼리만을 의미합니다.
EXPLAIN SELECT e.first_name, (SELECT COUNT(*) FROM dept_name, dept_manager dm WHERE dm.dept_no=de.dept_no) AS cnt FROM employees e WHERE e.emp_no = 10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 171094 |
|
2 | SUBQUERY | dm | index | PRIMARY | 16 |
| 24 | Using index |
2 | SUBQUERY | de | ref | PRIMARY | 12 | employees.dm | 18603 | Using index |
MySQL의 실행 계획에서 FROM 절에 사용된 서브 쿼리는 select_type이 DERIVED라고 표시되고, 그 밖의 위치에서 사용된 서브 쿼리는 전부 SUBQUERY라고 표시됩니다. 그리고 이 책이나 MySQL 매뉴얼에서 사용하는 "파생 테이블"이라는 단어는 DERIVED와 같은 의미로 이해하면 됩니다.
서브 쿼리는 사용되는 위치에 따라 각각 다른 이름을 지니고 있습니다.
중첩된 쿼리(Nested Query)
SELECT 되는 칼럼에 사용된 서브 쿼리를 네스티드 쿼리라고 합니다.
서브 쿼리(Sub Query)
WHERE 절에 사용된 경우에는 일반적으로 그냥 서브 쿼리라고 합니다.
파생 테이블(Derived)
FROM 절에 사용된 서브 쿼리를 MySQL에서는 파생 테이블이라고 하며, 일반적으로 RDBMS 전체적으로 인라인 뷰(Inline View) 또는 서브 셀렉트(Sub Select)라고 부르기도 합니다.
또한 서브 쿼리가 반환하는 값의 특성에 따라 다음과 같이 구분하기도 합니다.
스칼라 서브 쿼리(Scalar SubQuery)
하나의 값만(칼럼이 단 하나인 레코드 1건만) 반환하는 쿼리
로우 서브 쿼리(Row Sub Query)
칼럼의 개수에 관계없이 하나의 레코드만 반환하는 쿼리
DEPENDENT SUBQUERY
서브 쿼리가 바깥쪽(Outer) SELECT 쿼리에서 정의된 칼럼을 사용하는 경우를 DEPENDENT SUBQEURY라고 표현합니다. 다음의 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT e.first_name, (SELECT COUNT(*) FROM dept_emp de, dept_manager dm WHERE dm.dept_no=de.dept_no AND de.emp_no=e.emp_no) AS cnt FROM employees e WHERE e.emp_no=10001;
이럴 때는 안쪽(Inner)의 서브 쿼리 결과가 바깥쪽(Outer) SELECT 쿼리의 칼럼에 의존적이라서 DEPENDENT라는 키워드가 붙습니다. 또한 DEPENDENT UNION과 같이 DEPENDENT SUBQUERY 또한 외부 쿼리가 먼저 수행된 후 내부 쿼리(서브 쿼리)가 실행돼야 하므로 (DEPENDENT 키워드가 없는) 일반 서브 쿼리보다는 처리 속도가 느릴 때가 많습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | const | PRIMARY | 4 | const | 1 |
|
2 | DEPENDENT | de | ref | ix_empno | 4 |
| 1 | Using index |
2 | DEPENDENT | dm | ref | PRIMARY | 12 | de.dept_no | 18603 | Using index |
DERIVED
서브 쿼리가 FROM 절에 사용된 경우 MySQL은 항상 select_type이 DERIVED인 실행 계획을 만듭니다. DERIVED는 단위 SELECT 쿼리의 실행 결과를 메모리나 디스크에 임시 테이블을 생성하는 것을 의미합니다. select_type이 DERIVED인 경우에 생성되는 임시 테이블을 파생 테이블이라고도 합니다. 안타깝게도 MySQL은 FROM 절에 사용된 서브 쿼리를 제대로 최적화하지 못할 때가 대부분입니다. 파생 테이블에는 인덱스가 전혀 없으므로 다른 테이블과 조인할 때 성능상 불리할 때가 많습니다.
EXPLAIN SELECT * FROM (SELECT de.emp_no FROM dept_demp de) tb, employees e WHERE e.emp_no=tb.emp_no;
사실 위의 쿼리는 FROM 절의 서브 쿼리를 간단히 제거하고 조인으로 처리할 수 있는 형태입니다. 실제로 다른 DBMS에서는 이렇게 쿼리를 재작성하는 형태의 최적화 기능도 제공합니다. 하지만 다음 실행 계획을 보면 알 수 있듯이 MySQL에서는 FROM 절의 서브 쿼리를 임시 테이블로 만들어서 처리합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 331603 | ||||
2 | PRIMARY | e | eq_ref | PRIMARY | 4 | tb.emp_no | 1 | |
2 | DERIVED | de | index | ix_fromdate | 3 | 334868 | Using index |
MySQL 6.0 이상 버전부터는 FROM 절의 서브 쿼리에 대한 최적화 부분이 많이 개선될 것으로 알려졌으며 다들 많이 기대하는 상태입니다. 하지만 그전까지는 FROM 절의 서브 쿼리는 상당히 신경 써서 개발하고 튜닝해야 합니다. 현재 많이 사용되는 MySQL 5.0 5.1 버전에서는 조인이 상당히 최적화돼 있는 편입니다. 가능하다면 DERIVED 형태의 실행 계획을 조인으로 해결할 수 있게 바꿔주는 것이 좋습니다.
쿼리를 튜닝하기 위해 실행 계획을 확인할 때 가장 먼저 select_type 칼럼의 값이 DERIVED인 것이 있는지 확인해야 합니다. 다른 방법이 없어서 서브 쿼리를 사용하는 것은 피할 수 없습니다. 하지만 조인으로 해결할 수 있는 경우라면 서브 쿼리보다는 조인을 사용할 것을 강력히 권장합니다. 실제로 기능을 조금씩 단계적으로 추가하는 형태로 쿼리를 개발합니다. 이러한 개발 과정 때문에 대부분의 쿼리가 조인이 아니라 서브 쿼리 형태로 작성되는 것입니다. 물론 이런 절차로 개발하는 것이 생산성은 높겠지만 쿼리의 성능은 더 떨어집니다. 쿼리를 서브 쿼리 형태로 작성하는 것이 편하다면 반드시 마지막에는 서브쿼리를 조인으로 풀어서 고쳐 쓰는 습관을 들이는 것이 좋습니다. 그러면 어느 순간에는 서브 쿼리로 작성하는 단계 없이 바로 조인으로 복잡한 쿼리를 개발할 수 있을 것입니다.
UNCACHEABLE SUBQUERY
하나의 쿼리 문장에서 서브 쿼리가 하나만 있더라도 실제 그 서브 쿼리가 한 번만 실행되는 것은 아닙니다. 그런데 조건이 똑같은 서브 쿼리가 실행될 때는 다시 실행하지 않고 이전의 실행 결과를 그대로 사용할 수 있게 서브 쿼리의 결과를 내부적인 캐시 공간에 담아둡니다. 여기서 언급하는 서브 쿼리 캐시는 쿼리 캐시나 파생 테이블(DERIVED)와는 전혀 무관한 기능이므로 혼동하지 않도록 주의해야 합니다.
간단히 SUBQUERY와 DEPENDENT SUBQUERY가 캐시를 사용하는 방법을 비교해 보겠습니다.
SUBQUERY는 바깥쪽(Outer)의 영향을 받지 않으므로 처음 한 번만 실행해서 그 결과를 캐시하고 필요할 때 캐시된 결과를 이용합니다.
DEPENDENT SUBQUERY는 의존하는 바깥쪽(Outer) 쿼리의 칼럼의 값 단위로 캐시해두고 사용합니다.
위 그림은 select_type이 SUBQUERY인 경우 캐시를 사용하는 방법을 표현한 것입니다. 캐시가 처음 한 번만 생성됩니다. 하지만 DEPENDENT SUBQUERY는 서브 쿼리 결과가 딱 한 번만 캐시되는 것이 아니라 외부(Outer) 쿼리의 값 단위로 캐시가 만들어지는(즉, 위의 그림이 차례대로 반복되는 구조) 방식으로 처리됩니다.
select_type이 SUBQUERY인 경우와 "UNCACHEABLE SUBQUERY"는 이 캐시를 사용할 수 있느냐 없느냐에 차이가 있습니다. 서브 쿼리에 포함된 요소에 의해 캐시 자체가 불가능할 수가 있는데, 이 경우 select_type이 UNCACHEABLE SUBQUERY로 표시됩니다. 캐시를 사용하지 못하도록 하는 요소로는 대표적으로 다음과 같은 것들이 있습니다.
사용자 변수가 서브 쿼리에 사용된 경우
NOT-DETERMINISTIC 속성의 스토어드 루틴이 서브 쿼리 내에 사용된 경우
UUID()나 RAND()와 같이 결과값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우
다음은 사용자 변수(@status)가 사용된 쿼리 예제입니다. 이 경우 WHERE 절에 사용된 단위 쿼리의 select_type은 UNCACHEABLE SUBQUERY로 표시되는 것을 확인할 수 있습니다.
EXPLAIN SELECT * FROM employees e WHERE e.emp_no = ( SELECT @status FROM dept_emp de WHERE de.dept_no='d005' );
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e | ALL | 300584 | Using where | |||
2 | UNCACHEABLE SUBQUERY | de | ref | PRIMARY | 12 | const | 53288 | Using where; Using index |
UNCACHEABLE UNION
이미 UNION과 UNCACHEABLE에 대해서는 충분히 설명했으므로 기본적인 의미는 쉽게 이해했을 것입니다. UNCACHEABLE UNION이란 이 두 개의 키워드의 속성이 혼합된 select_type을 의미합니다. UNCACHEABLE UNION은 MySQL 5.0에서는 표시되지 않으며, MySQL 5.1부터 추가된 select_type입니다.
table 칼럼
MySQL의 실행 계획은 단위 SELECT 쿼리 기준이 아니라 테이블 기준으로 표시됩니다. 만약 테이블의 이름에 별칭이 부여된 경우에는 별칭이 표시됩니다. 다음 예제 쿼리와 같이 별도의 테이블을 사용하지 않는 SELECT 쿼리인 경우에는 table 칼럼에 NULL이 표시됩니다.
EXPLAIN SELECT NOW(); EXPLAIN SELECT NOW() FROM DUAL;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | (NULL) | No tables used |
일부 DBMS에서는 SELECT 문장이 반드시 FROM 절을 가져야 하는 제약이 있습니다. 이를 위해 DUAL이라는 스칼라(칼럼 1개짜리 레코드 1개를 가지는) 값을 가지는 테이블을 사용하곤 하는데, MySQL에서는 FROM 절이 없이도 쿼리 실행에 특별히 문제가 되지는 않습니다. 하지만 다른 DBMS와의 호환을 위해 "FROM DUAL"로 사용해도 문제없이 실행됩니다. 위 예제에서 두 번째 쿼리의 "FROM DUAL"은 없어도 무방합니다. 즉, 첫번째 쿼리와 두번째 쿼리는 같은 쿼리입니다.
Table 칼럼에 <derived> 또는 <union>과 같이 "<>"로 둘러싸인 이름이 명시되는 경우가 많은데, 이 테이블은 임시 테이블을 의미합니다. 또한 "<>" 안에 항상 표시되는 숫자는 단위 SELECT 쿼리의 id를 지칭합니다. 다음 실행 계획을 한번 살펴 보겠습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | <derived2> | ALL | 10420 | ||||
1 | PRIMARY | e | eq_ref | PRIMARY | 4 | de1.emp_no | 1 | |
2 | DERIVED | dept_emp | range | ix_fromdate | 3 | 20550 |
위의 예에서 첫번째 라인의 table 칼럼의 값이 <derived 2>인데, 이것은 단위 SELECT 쿼리의 아이디가 2번인 실행 계획으로부터 만들어진 파생 테이블을 가리킵니다. 단위 SELECT 쿼리의 id 2번(실행 계획의 최하위 라인)은 dept_emp 테이블로부터 SELECT된 결과가 저장된 파생 테이블이라는 점을 알 수 있습니다.
실행계획의 id 칼럼과 select_type 그리고 table 칼럼 총 3개의 칼럼은 실행 계획의 각 라인에 명시된 테이블이 어떤 순서로 실행되는지를 판단하는 근거를 표시해줍니다. 그러면 이 3개의 칼럼만으로 위의 실행 계획을 분석해보겠습니다.
1. 첫번째 라인의 테이블이 <derived2>라는 것을 보아 이 라인보다 쿼리의 id가 2번인 라인이 먼저 실행되고 그 결과가 파생 테이블로 준비돼야 한다는 것을 알 수 있다.
2. 세 번째 라인의 쿼리 id 2번을 보면, select_type 칼럼의 값이 DERIVED로 표시돼 있다. 즉, 이 라인은 table 칼럼에 표시된 dept_emp 테이블을 읽어서 파생 테이블을 생성하는 것을 알 수 있다.
3. 세 번째 라인의 분석이 끝났으므로 다시 실행 계획의 첫 번째 라인으로 돌아가자.
4. 첫 번째 라인과 두 번째 라인은 같은 id 값을 가지고 있는 것으로 봐서 2개 테이블(첫번째 라인의 <derived2>와 두번째 라인의 e테이블)이 조인되는 쿼리라는 사실을 알 수 있습니다. 그런데 <derived2> 테이블이 e 테이블보다 먼저 (윗 라인에) 표시됐기 때문에 <derived2>가 드라이빙 테이블이 되고, e 테이블이 드리븐 테이블이 된다는 것을 알 수 있다. 즉, <derived2> 테이블을 먼저 읽어서 e 테이블로 조인을 실행했다는 것을 알 수 있다.
이제 MySQL에서 쿼리의 실행 계획을 어떤 순서로 읽는지 대략 파악됐을 것입니다. 방금 분석해 본 실행 계획의 실제 쿼리를 한번 살펴보겠습니다.
SELECT * FROM (SELECT de.emp_no FROM dept_emp de) tb, employees e WHERE e.emp_no = tb.emp_no;
MySQL은 다른 DBMS와 달리 FROM 절에 사용된 서브 쿼리(Derived, 파생 테이블)는 반드시 별칭을 가져야 합니다. 그렇지 않으면 별칭이 부여되지 않았다는 에러 메시지가 출력되고 쿼리는 실행되지 않을 것입니다. 쿼리를 작성하거나 실행 계획을 확인할 때는 임시 테이블의 별칭을 잊지말고 반드시 명시해야 합니다.
mysql> SELECT dttm FROM (SELECT NOW() AS dttm);
ERROR 1248 (42000): Every derived table must have its own alias
mysql> SELECT dttm FROM (SELECT NOW() AS dttm) derived_table_alias;
type 칼럼
쿼리의 실행 계획에서 type 이후의 칼럼은 MySQL 서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 의미합니다. 여기서 방식이라 함은 인덱스를 사용해 레코드를 읽었는지 아니면 테이블을 처음부터 끝까지 읽는 풀 테이블 스캔으로 레코드를 읽었는지 등을 의미합니다. 일반적으로 쿼리를 튜닝할 때 인덱스를 효율적으로 사용하는지 확인하는 것이 중요하므로 실행 계획에서 type 칼럼은 반드시 체크해야할 중요한 정보입니다.
MySQL의 매뉴얼에서는 type 칼럼을 "조인 타입"으로 소개합니다. 또한 MySQL에서는 하나의 테이블로부터 레코드를 읽는 작업도 조인처럼 처리합니다. 그래서 SELECT 쿼리의 테이블 개수에 관계없이 실행계획의 type 칼럼을 "조인 타입"이라고 명시하고 있습니다. 하지만 크게 조인과 연관 지어 생각하지 말고, 각 테이블의 접근 방식(Access type)으로 해석하면 됩니다.
실행 계획의 type 칼럼에 표시될 수 있는 값은 버전에 따라 조금씩 차이가 있을 수 있지만, 현재 많이 사용되는 MySQL 5.0과 5.1 버전에서는 다음과 같은 값이 표시됩니다.
system const eq_ref
ref fulltext ref_or_null
unique_subquery index_subquery range
index_merge index ALL
위의 12가지 접근 방법 중에서 하단의 ALL을 제외한 나머지는 모두 인덱스를 사용하는 접근 방법입니다. ALL은 인덱스를 사용하지 않고, 테이블을 처음부터 끝까지 읽어서 레코드를 가져오는 풀 테이블 스캔 접근 방식을 의미합니다.
하나의 단위 SELECT 쿼리는 위의 접근 방법 중에서 단 하나만 사용할 수 있습니다. 또한 index_merge를 제외한 나머지 접근 방법은 반드시 하나의 인덱스만 사용합니다. 그러므로 실행 계획의 각 라인에 접근 방법이 2개 이상 표시되지 않으며, index_merge 이외의 type에서는 인덱스 항목에도 단 하나의 인덱스 이름만 표시됩니다.
이제 실행 계획의 type 칼럼에 표시될 수 있는 값을 위의 순서대로 하나씩 살펴보겠습니다. 참고로 위에 표시된 각 접근 방식은 성능이 빠른 순서대로 나열된 것(MySQL에서 부여한 우선순위임)이며, 각 type의 설명도 이 순서대로 진행할 것입니다. MySQL 옵티마이저는 이런 접근 방식과 비용을 함께 계산해서 최소의 비용이 필요한 접근 방식을 선택합니다.
system
레코드가 1건만 존재하는 테이블 또는 한건도 존재하지 않는 테이블을 참조하는 형태의 접근 방법을 system이라고 합니다. 이 접근 방식은 InnoDB 테이블에서는 나타나지 않고, MyISAM이나 MEMORY 테이블에서만 사용되는 접근 방법입니다.
EXPLAIN SELECT * FROM tb_dual;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | tb_dual | system | 1 |
위 예제에서 tb_dual 테이블은 레코드가 1건만 들어있는 MyISAM 테이블입니다. 만약 이 테이블을 InnoDB로 변환하면 결과는 어떻게 될까요?
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | tb_dual | index | PRIMARY | 1 | 1 | Using index |
쿼리의 모양에 따라 조금은 다르겠지만 접근 방법(type 칼럼)이 ALL 또는 index로 표시될 가능성이 큽니다. system은 테이블에 레코드가 1건 이하인 경우에만 사용할 수 있는 접근 방법이므로 실제 애플리케이션에서 사용되는 쿼리의 실행 계획에서는 거의 보이지 않습니다.
const
테이블의 레코드의 건수에 관계없이 쿼리가 프라이머리 키나 유니크 키 칼럼을 이용하는 WHERE 조건절을 가지고 있으며, 반드시 1건을 반환하는 처리 방식을 const라고 합니다. 다른 DBMS에서는 이를 유니크 인덱스 스캔(UNIQUE INDEX SCAN)이라고도 표현합니다.
EXPLAIN SELECT * FROM employees WHERE emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employees | const | PRIMARY | 4 | const | 1 |
|
다음 예제와 같이 다중 칼럼으로 구성된 프라이머리 키나 유니크 키 중에서 인덱스의 일부 칼럼만 조건으로 사용할 때는 const 타입의 접근 방법을 사용할 수 없습니다. 이 경우에는 실제 레코드가 1건만 저장돼 있더라도 MySQL 엔진이 데이터를 읽어보지 않고서는 레코드가 1건이라는 것을 확신할 수 없기 때문입니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
프라이머리 키의 일부만 조건으로 사용할 때는 접근 방식이 const가 아닌 ref로 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53288 | Using where |
하지만 프라이머리 키나 유니크 인덱스의 모든 칼럼을 동등 조건으로 WHERE 절에 명시하면 다음 예제와 같이 const 접근 방법을 사용합니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | const | PRIMARY | 16 | const, const | 1 |
|
실행 계획의 type 칼럼이 const인 실행 계획은 MySQL의 옵티마이저가 쿼리를 최적화하는 단계에서 모두 상수화합니다. 그래서 실행 계획의 type 칼럼의 값이 "상수(const)"라고 표시되는 것입니다. 다음의 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT COUNT(*) FROM employees e1 WHERE first_name = (SELECT first_name FROM employees e2 WHERE emp_no=100001);
위의 예제 쿼리에서 WHERE 절에 사용된 서브 쿼리는 employees(e2) 테이블의 프라이머리 키를 검색해서 first_name을 일고 있습니다. 이 쿼리의 실행 계획은 다음과 같은데, 예상대로 e2 테이블은 프라이머리 키를 const 타입으로 접근한다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | e1 | ref | ix_firstname | 44 | 247 | Using where | |
2 | SUBQUERY | e2 | const | PRIMARY | 4 | const | 1 |
|
여기서 설명하는 것은 실제 이 쿼리는 옵티마이저에 의해 최적화되는 시점에 다음과 같은 쿼리로 변환된다는 것입니다. 즉, 옵티마이저에 의해 상수화된 다음 쿼리 실행기로 전달되기 때문에 접근 방식이 const인 것입니다.
SELECT COUNT(*) FROM employees e1 WHERE first_name='Jasminko'; -- // Jasminko는 사번이 100001인 사원의 first_name 값임
eq_ref
eq_ref 접근 방법은 여러 테이블이 조인되는 쿼리의 실행 계획에서만 표시됩니다. 조인에서 처음 읽은 테이블의 칼럼 값을, 그다음 읽어야 할 테이블의 프라이머리 키나 유니크 키 칼럼의 검색 조건에 사용할 때를 eq_ref라고 합니다. 이때 두번째 이후에 읽는 테이블의 type 칼럼에 eq_ref가 표시됩니다. 또한 두번째 이후에 읽히는 테이블을 유니크 키로 검색할 때 그 유니크 인덱스는 NOT NULL이어야 하며, 다중 칼럼으로 만들어진 프라이머리 키나 유니크 인덱스라면 인덱스의 모든 칼럼이 비교 조건에 사용돼야만 eq_ref 접근 방법이 사용될 수 있습니다. 즉, 조인에서 두번째 이후에 읽는 테이블에서 반드시 1건만 존재한다는 보장이 있어야 사용할 수 있는 접근 방법입니다.
다음 예제 쿼리의 실행 계획을 살펴보겠습니다. 우선 첫번째 라인과 두번째 라인의 id가 1로 같으므로 두 개의 테이블이 조인으로 실행된다는 것을 알 수 있습니다. 그리고 dept_emp 테이블이 실행 계획의 위쪽에 있으므로 dept_emp 테이블을 먼저 읽고 "e.emp_no=de.emp_no" 조건을 이용해 employees 테이블을 검색하고 있습니다. employees 테이블의 emp_no는 프라이머리 키라서 실행 계획의 두 번째 라인은 type 칼럼이 eq_ref로 표시된 것입니다.
EXPLAIN SELECT * FROM dept_emp de, employees e WHERE e.emp_no=de.emp_no AND de.dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ref | PRIMARY | 12 | const | 53288 | Using where |
2 | SIMPLE | e | eq_ref | PRIMARY | 4 | employees | 1 |
|
ref
ref 접근 방법은 eq_ref와는 달리 조인의 순서와 관계없이 사용되며, 또한 프라이머리 키나 유니크 키 등의 제약 조건도 없습니다. 인덱스의 종류와 관계없이 동등(Equal) 조건으로 검색할 때는 ref 접근 방법이 사용됩니다. ref 타입은 반환되는 레코드가 반드시 1건이라는 보장이 없으므로 const나 eq_ref보다는 빠르지 않습니다. 하지만 동등한 조건으로만 비교되므로 매우 빠른 레코드 조회 방법의 하나입니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | dept_emp | ref | PRIMARY | 16 | const | 53288 | Using where |
위의 예에서는 dept_emp 테이블의 프라이머리 키를 구성하는 칼럼(dept_no+emp_no) 중에서 일부만 동등(Equal) 조건으로 WHERE 절에 명시됐기 때문에 조건에 일치하는 레코드가 1건이라는 보장이 없습니다. 그래서 const가 아닌 ref 접근 방법이 사용됐으며 실행 계획의 ref 칼럼 값에는 const가 명시됐습니다. 이 const는 접근 방식이 아니라, ref 비교 방식으로 사용된 입력 값이 상수('d005')였음을 의미합니다. ref 칼럼의 내용은 밑에서 다시 한번 살펴보겠습니다.
지금까지 배운 실행 계획의 type에 대해 간단히 비교하면서 다시 한 번 정리해보겠습니다.
const
조인의 순서에 관계없이 프라이머리 키나 유니크 키의 모든 칼럼에 대해 동등(Equal) 조건으로 검색(반드시 1건의 레코드만 반환)
eq_req
조인에서 첫 번째 읽은 테이블의 칼럼값을 이용해 두 번째 테이블을 프라이머리 키나 유니크 키로 동등(Equal) 조건 검색(두번째 테이블은 반드시 1건의 레코드만 반환)
ref
조인의 순서와 인덱스의 종류에 관계없이 동등(Equal) 조건으로 검색(1건의 레코드만 반환된다는 보장이 없어도 됨)
이 세 가지 접근 방식 모두 WHERE 조건절에 사용되는 비교 연산자는 동등 비교 연산자이어야 한다는 공통점이 있습니다. 동등 비교 연산자는 "=" 또는 "<=>"을 의미합니다. "<=>" 연산자는 NULL에 대한 비교 방식만 조금 다를 뿐 "=" 연산자와 같은 연산자입니다.
또한 세 가지 모두 매우 좋은 접근 방법으로 인덱스의 분포도가 나쁘지 않다면 성능상의 문제를 일으키지 않는 접근 방법입니다. 쿼리를 튜닝할 때도 이 세 가지 접근 방법에 대해서는 크게 신경쓰지 않고 넘어가도 무방합니다.
fulltext
fulltext 접근 방법은 MySQL의 전문 검색(Fulltext) 인덱스를 사용해 레코드를 읽는 접근 방법을 의미합니다. 지금 살펴보는 type의 순서가 일반적으로 처리 성능의 순서이긴 하지만 실제로 데이터의 분포나 레코드의 건수에 따라 빠른 순서는 달라질 수 있습니다. 이는 비용 기반의 옵티마이저에서 통계 정보를 이용해 비용을 계산하는 이유이기도 합니다. 하지만 전문 검색 인덱스는 통계 정보가 관리되지 않으며, 전문 검색 인덱스를 사용하려면 전혀 다른 SQL 문법을 사용해야 합니다. 그래서 MySQL 옵티마이저는 전문 인덱스를 사용할 수 있는 SQL에서는 쿼리의 비용과는 관계없이 거의 매번 fulltext 접근 방법을 사용합니다. 물론, fulltext 접근 방법보다 명백히 빠른 const나 eq_ref 또는 ref 접근 방법을 사용할 수 있는 쿼리에서는 억지로 fulltext 접근 방법을 선택하지는 않습니다.
MySQL의 전문 검색 조건은 우선순위가 상당히 높습니다. 쿼리에서 전문 인덱스를 사용하는 조건과 그 이외의 일반 인덱스를 사용하는 조건을 함께 사용하면 일반 인덱스의 접근 방법이 const나 eq_ref, 그리고 ref가 아니면 일반적으로 MySQL은 전문 인덱스를 사용하는 조건을 선택해서 처리합니다.
전문 검색은 "MATCH ... AGAINST ..." 구문을 사용해서 실행하는데, 반드시 해당 테이블에 전문 검색용 인덱스가 준비돼 있어야만 합니다. 만약 테이블에 전문 인덱스가 없다면 쿼리는 오류가 발생하고 중지될 것입니다. 다음의 "MATCH ... AGAINST ..." 예제 쿼리를 한 번 살펴보겠습니다.
EXPLAIN SELECT * FROM employee_name WHERE emp_no = 10001 AND emp_no BETWEEN 10001 AND 10005 AND MATCH(first_name, last_name) AGAINST('Facello' IN BOOLEAN MODE);
위 쿼리 문장은 3개의 조건을 가지고 있습니다. 첫번째 조건은 employee_name 테이블의 프라이머리 키를 1건만 조회하는 const 타입의 조건이며, 두번째 조건은 밑에서 설명할 range 타입의 조건입니다. 그리고 마지막으로 세 번째 조건은 전문 검색(Fulltext) 조건입니다. 이 문장의 실행 계획을 보면 다음과 같습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employee_name | fulltext | fx_name | 0 |
| 1 | Using where |
이번에는 range 타입의 두 번째 조건이 아니라 전문 검색(Fulltext) 조건인 세번째 조건을 선택했습니다. 일반적으로 쿼리에 전문 검색 조건(MATCH ... AGAINST ...)을 사용하면 MySQL은 아무런 주저 없이 fulltext 접근 방식을 사용하는 경향이 있습니다. 하지만 지금까지의 경험으로 보면 전문 검색 인덱스를 이용하는 fulltext보다 일반 인덱스를 이용하는 range 접근 방법이 더 빨리 처리되는 경우가 더 많았씁니다. 전문 검색 쿼리를 사용할 때는 각 조건별로 성능을 확인해 보는 편이 좋습니다.
ref_or_null
이 접근 방법은 ref 접근 같은데, NULL 비교가 추가된 형태입니다. 접근 방식의 이름 그대로 ref 방식 또는 NULL 비교(IS NULL) 접근 방식을 의미합니다. 실제 업무에서 많이 보이지도 않고, 별로 존재감이 없는 접근 방법이므로 대략의 의미만 기억해두어도 충분합니다.
EXPLAIN SELECT * FROM titles WHERE to_date='1985-03-01' OR to_date IS NULL;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | titles | ref_or_null | ix_todate | 4 | const | 2 | Using where; |
unique_subquery
WHERE 조건절에서 사용될 수 있는 IN (subquery) 형태의 쿼리를 위한 접근 방식입니다. unique_subquery의 의미 그대로 서브 쿼리에서 중복되지 않은 유니크한 값만 반환할 때 이 접근 방법을 사용합니다.
EXPLAIN SELECT * FROM departments WHERE dept_no IN ( SELECT dept_no FROM dept_emp WHERE emp_no=10001 );
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | departments | index | ux_deptname | 123 |
| 9 | Using where; |
2 | DEPENDENT | dept_emp | unique_subquery | PRIMARY | 16 | func, const | 1 | Using index; |
위 쿼리 문장의 IN (subquery) 부분에서 subquery를 살펴보겠습니다. emp_no=10001인 레코드 중에서 부서 번호는 중복이 없기 때문에(dept_emp 테이블에서 프라이머리 키가 dept_no + emp_no이므로) 실행 계획의 두 번째 라인의 dept_emp 테이블의 접근 방식은 unique_subquery로 표시된 것입니다.
index_subquery
IN 연산자의 특성상, IN (subquery) 또는 IN (상수 나열) 형태의 조건은 괄호 안에 있는 값의 목록에서 중복된 값이 먼저 제거돼야 합니다. 방금 살펴본 unique_subquery 접근 방법은 IN (subquery) 조건의 subquery가 중복된 값을 만들어내지 않는다는 보장이 있으므로 별도의 중복을 제거할 필요가 없었습니다. 하지만 IN (subquery)에서 subquery가 중복된 값을 반환할 수는 있지만 중복된 값을 인덱스를 이용해 제거할 수 있을 때 index_subqeury 접근 방법이 사용됩니다.
명확한 이해를 위해 index_subquery와 unique_subquery 접근 방법의 차이를 다시 한번 정리해 보겠습니다.
unique_subquery
IN (subquery) 형태의 조건에서 subquery의 반환 값에는 중복이 없으므로 별도의 중복 제거 작업이 필요하지 않음
index_subquery
IN (subquery) 형태의 조건에서 subquery의 반환 값에 중복된 값이 있을 수 있지만 인덱스를 이용해 중복된 값을 제거할 수 있음
사실 index_subquery나 unique_subquery 모두 IN() 안에 있는 중복 값을 아주 낮은 비용으로 제거합니다.
다음 쿼리에서 IN 연산자 내의 서브 쿼리는 dept_emp 테이블을 dept_no로 검색합니다. dept_emp 테이블의 프라이머리 키가 (dept_no + emp_no)로 만들어져 있으므로 서브 쿼리는 프라이머리 키의 dept_no 칼럼을 'd001'부터 'd003'까지 읽으면서 dept_no 값만 가져오면 됩니다. 또한 이미 프라이머리 키는 dept_no 칼럼의 값 기준으로 정렬돼 있어서 중복된 dept_no를 제거하기 위해 별도의 정렬 작업이 필요하지 않습니다.
EXPLAIN SELECT * FROM departments WHERE dept_no IN ( SELECT dept_no FROM dept_emp WHERE dept_no BETWEEN 'd001' AND 'd003');
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY | departments | index | ux_deptname | 122 |
| 9 | Using where; |
2 | DEPENDENT | dept_emp | unique | PRIMARY | 12 | func | 18626 | Using index; |
range
range는 우리가 익히 알고 있는 인덱스 레인지 스캔 형태의 접근 방법입니다. range는 인덱스를 하나의 값이 아니라 범위로 검색하는 경우를 의미하는데, 주로 "<, >, IS NULL, BETWEEN, IN, LIKE" 등의 연산자를 이용해 인덱스를 검색할 때 사용됩니다. 일반적으로 애플리케이션의 쿼리가 가장 많이 사용하는 접근 방법인데, 이 책에서 소개되는 접근 방법의 순서상으로 보면 MySQL이 가지고 있는 접근 방법 중에서 상당히 우선순위가 낮다는 것을 알 수 있습니다. 하지만 이 접근 방법도 상당히 빠르며, 모든 쿼리가 이 접근 방법만 사용해도 어느 정도의 성능은 보장된다고 볼 수 있습니다.
EXPLAIN SELECT dept_no FROM dept_emp WHERE dept_no BETWEEN 'd001' AND 'd003';
인덱스 레인지 스캔이라고 하면 const, ref, range라는 세 가지 접근 방법을 모두 묶어서 지칭하는 것임에 유의합니다. 또한 "인덱스를 효율적으로 사용한다" 또는 "범위 제한 조건으로 인덱스를 사용한다"는 표현 모두 이 세 가지 접근 방법을 의미합니다. 업무상 개발자나 DBA와 소통할 때도 const나 ref 또는 range 접근 방법을 구분해서 언급하는 경우는 거의 없으며, 일반적으로 "인덱스 레인지 스캔" 또는 "레인지 스캔"으로 언급할 때가 많습니다.
index_merge
지금까지 설명한 다른 접근 방식과는 달리 index_merge 접근 방식은 2개 이상의 인덱스를 이용해 각각의 검색 결과를 만들어낸 후 그 결과를 병합하는 처리 방식입니다. 하지만 여러 번의 경험을 보면 이름만큼 그렇게 효율적으로 작동하는 것 같지는 않았습니다. index_merge 접근 방식에는 다음과 같은 특징이 있습니다.
여러 인덱스를 읽어야 하므로 일반적으로 range 접근 방식보다 효율성이 떨어집니다.
AND와 OR 연산이 복잡하게 연결된 쿼리에서는 제대로 최적화되지 못할 때가 많습니다.
전문 검색 인덱스를 사용하는 쿼리에서는 index_merge가 적용되지 않습니다.
Index_merge 접근 방식으로 처리된 결과는 항상 2개 이상의 집합이 되기 때문에 그 두 집합의 교집합이나 합집합 또는 중복 제거와 같은 부가적인 작업이 더 필요합니다.
MySQL 매뉴얼에서 index_merge 접근 방법의 우선순위는 ref_or_null 바로 다음에 있습니다. index_merge 접근 방식이 사용될 때는 "Using sort_union(...), Using union(...), Using intersect(...)" 실행 계획에 조금 더 보완적인 내용이 표시됩니다.
다음은 두 개의 조건이 OR로 연결된 쿼리입니다. 그런데 OR로 연결된 두 개 조건이 모두 각각 다른 인덱스를 최적으로 사용할 수 있는 조건입니다. 그래서 MySQL 옵티마이저는 "emp_no BETWEEN 10001 AND 11000" 조건은 employees 테이블의 프라이머리 키를 이용해 조회하고, "first_name='Smith'" 조건은 ix_firstname 인덱스를 이용해 조회한 후 두 결과를 병합하는 형태로 처리하는 실행계획을 만들어 낸 것입니다.
EXPLAIN SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 11000 OR first_name='Smith';
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | employees | index_merge | PRIMARY, | 4, 44 |
| 1521 | Using union |
index
index 접근 방법은 많은 사람이 자주 오해하는 접근 방법입니다. 접근 방식이 이름이 index라서 MySQL에 익숙하지 않은 많은 사람이 "효율적으로 인덱스를 사용하는구나"라고 생각하게 만드는 것 같습니다. 하지만 index 접근 방식은 인덱스를 처음부터 끝까지 읽는 인덱스 풀 스캔을 의미합니다. range 접근 방식과 같이 효율적으로 인덱스의 필요한 부분만 읽는 것을 의미하는 것은 아니라는 점은 잊지 말아야 합니다.
index 접근 방식은 테이블을 처음부터 끝까지 읽는 풀 테이블 스캔 방식과 비교했을 때 비교하는 레코드 건수는 같습니다. 하지만 인덱스는 일반적으로 데이터 파일 전체보다는 크기가 작아서 풀 테이블 스캔보다는 효율적이므로 풀 테이블 스캔보다는 빠르게 처리됩니다. 또한 쿼리의 내용에 따라 정렬된 인덱스의 장점을 이용할 수 있으므로 풀 테이블 스캔보다는 훨씬 효율적으로 처리될 수도 있습니다. index 접근 방법은 다음의 조건 가운데 (첫 번째 + 두 번째) 조건을 충족하거나 (첫 번째 + 세 번째) 조건을 충족하는 쿼리에서 사용되는 읽기 방식입니다.
range나 const 또는 ref와 같은 접근 방식으로 인덱스를 사용하지 못하는 경우
인덱스에 포함된 칼럼만으로 처리할 수 있는 쿼리인 경우(즉, 데이터 파일을 읽지 않아도 되는 경우)
인덱스를 이용해 정렬이나 그룹핑 작업이 가능한 경우(즉, 별도의 정렬 작업을 피할 수 있는 경우)
다음 쿼리는 아무런 WHERE 조건이 없으므로 range나 const 또는 ref 접근 방식을 사용할 수 없습니다. 하지만 정렬하려는 칼럼은 인덱스(ux_deptname)가 있으므로 별도의 정렬 처리를 피하려고 index 접근 방식이 사용된 예제입니다.
EXPLAIN SELECT * FROM departments ORDER BY dept_name DESC LIMIT 10;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | departments | index | ux_deptname | 123 |
| 9 | Using index |
이 예제의 실행 계획은 테이블의 인덱스를 처음부터 끝까지 읽는 index 접근 방식이지만 LIMIT 조건이 있기 때문에 상당히 효율적인 쿼리입니다. 단순히 인덱스를 거꾸로 (역순으로) 읽어서 10개만 가져오면 되기 때문입니다. 하지만 LIMIT 조건이 없거나 가져와야 할 레코드 건수가 많아지면 상당히 느려질 것입니다.
ALL
우리가 흔히 알고 있는 풀 테이블 스캔을 의미하는 접근 방식입니다. 테이블을 처음부터 끝까지 전부 읽어서 불필요한 레코드를 제거(체크 조건이 존재할 때)하고 반환합니다. 풀 테이블 스캔은 지금까지 설명한 접근 방법으로는 처리할 수 없을 때 가장 마지막에 선택되는 가장 비효율적인 방법입니다.
다른 DBMS와 같이 InnoDB도 풀 테이블 스캔이나 인덱스 풀 스캔과 같은 대량의 디스크 I/O를 유발하는 작업을 위해 한꺼번에 많은 페이지를 읽어들이는 기능을 제공합니다. InnoDB에서는 이 기능을 "리드 어헤드(Read Ahead)"라고 하며, 한 번에 여러 페이지를 읽어서 처리할 수 있습니다. 데이터웨어하우스나 배치 프로그램처럼 대용량의 레코드를 처리하는 쿼리에서는 잘못 튜닝된 쿼리(억지로 인덱스를 사용하도록 튜닝된 쿼리)보다 더 나은 접근 방법이 되기도 합니다. 쿼리를 튜닝한다는 것이 무조건 인덱스 풀 스캔이나 테이블 풀 스캔을 사용하지 못하게 하는 것은 아니라는 점을 기억합시다.
일반적으로 index와 ALL 접근 방법은 작업 범위를 제한하는 조건이 아니므로 빠른 응답을 사용자에게 보내 줘야 하는 웹 서비스 등과 같은 OLTP 환경에는 적합하지 않습니다. 테이블이 매우 작지 않다면 실제로 테이블에 데이터를 어느 정도 저장한 상태에서 쿼리의 성능을 확인해 보고 적용하는 것이 좋습니다.
MySQL에서는 연속적으로 인접한 페이지가 연속해서 몇 번 읽히게 되면 백그라운드로 작동하는 읽기 스레드가 최대 한 번에 64개의 페이지씩 한꺼번에 디스크로부터 읽어들이기 때문에 한 번에 페이지 하나씩 읽어들이는 작업보다는 상당히 빠르게 레코드를 읽을 수 있습니다. 이러한 작동 방식을 리드 어헤드(Read Ahead)라고 합니다.
possible_keys
실행 계획의 이 칼럼 또한 사용자의 오해를 자주 불러일으키곤 합니다. MySQL 옵티마이저는 쿼리를 처리하기 위해 여러 가지 처리 방법을 고려하고 그중에서 비용이 가장 낮을 것으로 예상하는 실행 계획을 선택해서 쿼리를 실행합니다. 그런데 possible_keys 칼럼에 있는 내용은 MySQL 옵티마이저가 최적의 실행 계획을 만들기 위해 후보로 선정했던 접근 방식에서 사용되는 인덱스의 목록일 뿐입니다. 즉, 말 그대로 "사용될 법했던 인덱스의 목록"인 것입니다. 실제로 실행 계획을 보면 그 테이블의 모든 인덱스가 목록에 포함되어 나오는 경우가 허다하기에 쿼리를 튜닝하는 데 아무런 도움이 되지 않습니다. 그래서 실행계획을 확인할 때는 Possible_keys 칼럼은 그냥 무시합니다. 절대 Possible_keys 칼럼에 인덱스 이름이 나열됐다고 해서 그 인덱스를 사용한다고 판단하지 않도록 주의합시다.
key
Possible_keys 칼럼의 인덱스가 사용 후보였던 반면 Key 칼럼에 표시되는 인덱스는 최종 선택된 실행 계획에서 사용하는 인덱스를 의미합니다. 그러므로 쿼리를 튜닝할 때는 Key 칼럼에 의도했던 인덱스가 표시되는지 확인하는 것이 중요합니다. Key 칼럼에 표시되는 값이 PRIMARY인 경우에는 프라이머리 키를 사용한다는 의미이며, 그 이외의 값은 모두 테이블이나 인덱스를 생성할 때 부여했던 고유 이름입니다.
실행 계획의 type 칼럼이 index_merge가 아닌 경우에는 반드시 테이블 하나당 하나의 인덱스만 이용할 수 있습니다. 하지만 index_merge 실행 계획이 사용될 때는 2개 이상의 인덱스가 사용되는데, 이때는 Key 칼럼에 여러 개의 인덱스가 ","로 구분되어 표시됩니다. 위에서 살펴본 index_merge 실행 계획을 다시 한번 살펴보겠습니다. 다음의 실행 계획은 WHERE 절의 각 조건이 PRIMARY와 ix_firstname 인덱스를 사용한다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index_merge | PRIMARY, | 4, 44 |
| 1521 | ... |
그리고 실행 계획의 type이 ALL일 때와 같이 인덱스를 전혀 사용하지 못하면 Key 칼럼은 NULL로 표시됩니다.
MySQL에서 프라이머리 키는 별도의 이름을 부여할 수 없으며, 기본적으로 PRIMARY라는 이름을 가집니다. 그 밖의 나머지 인덱스는 모두 테이블을 생성하거나 인덱스를 생성할 때 이름을 부여할 수 있습니다. 실행 계획뿐 아니라 쿼리의 힌트를 사용할 때도 프라이머리 키를 지칭하고 싶다면 PRIMARY라는 키워드를 사용하면 됩니다.
key_len
key_len 칼럼은 많은 사용자가 쉽게 무시하는 정보지만 사실은 매우 중요한 정보 중 하나입니다. 실제 업무에서 사용하는 테이블은 단일 칼럼으로만 만들어진 인덱스보다 다중 칼럼으로 만들어진 인덱스가 더 많습니다. 실행 계획의 key_len 칼럼의 값은, 쿼리를 처리하기 위해 다중 칼럼으로 구성된 인덱스에서 몇개의 칼럼까지 사용했는지 우리에게 알려줍니다. 더 정확하게는 인덱스의 각 레코드에서 몇 바이트까지 사용했는지 알려주는 값입니다. 그래서 다중 칼럼 인덱스뿐 아니라 단일 칼럼으로 만들어진 인덱스에서도 같은 지표를 제공합니다.
다음 예제는 (dept_no + emp_no)로 두 개의 칼럼으로 만들어진 프라이머리 키를 포함한 dept_emp 테이블을 조회하는 쿼리입니다. 이 쿼리는 dept_emp 테이블의 프라이머리 키 중에서 dept_no만 비교하는 데 사용하고 있습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53288 | Using where |
그래서 key_len 칼럼의 값이 12로 표시된 것입니다. 즉, dept_no 칼럼의 타입이 CHAR(4)이기 때문에 프라이머리 키에서 앞쪽 12바이트만 유효하게 사용했다는 의미입니다. 이 테이블이 dept_no 칼럼은 utf8 문자집합을 사용하고 있습니다. 실제 utf8 문자 하나가 차지하는 공간은 1바이트에서 3바이트까지 가변적입니다. 하지만 MySQL 서버가 utf8 문자를 위해 메모리 공간을 할당해야 할 때는 문자에 관계없이 고정적으로 3바이트로 계산합니다. 그래서 위의 실행 계획에서 key_len 칼럼의 값은 12바이트(4*3 바이트)가 표시된 것입니다.
이제 똑같은 인덱스를 사용하지만 dept_no 칼럼과 emp_no 칼럼에 대해 각각 조건을 하나씩 가지고 있는 다음의 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | const | PRIMARY | 16 | const, const | 1 |
|
dept_emp 테이블의 emp_no의 칼럼 타입은 INTEGER이며, INTEGER 타입은 4바이트를 차지합니다. 위의 쿼리 문장은 프라이머리 키의 dept_no 칼럼뿐 아니라 emp_no까지 사용할 수 있게 적절히 조건이 제공됐습니다. 그래서 key_len 칼럼이 dept_no 칼럼의 길이와 emp_no 칼럼의 길이 합인 16이 표시된 것입니다.
그런데 key_len의 값을 표시하는 기준이 MySQL의 버전별로 다릅니다. 다음 쿼리의 실행 계획을 MySQL 5.0.68 버전과 MySQL 5.1.54 버전에서 각각 확인해보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE dept_no='d005' AND emp_no <> 10001;
MySQL 5.0 이하의 버전
쿼리 문장은 프라이머리 키를 구성하는 emp_no와 dept_no의 조건을 줬지만 key_len 값은 12로 바뀌었습니다. 왜 16이 아닌 12로 줄어들었을까요? 그 이유는 Key_len에 표시되는 값은 인덱스를 이용해 범위를 제한하는 조건의 칼럼까지만 포함되며, 단순히 체크 조건으로 사용된 칼럼은 key_len에 포함되지 않기 때문입니다. 그래서 MySQL 5.0에서는 key_len 칼럼의 값으로 인덱스의 몇 바이트까지가 범위 제한 조건으로 사용됐는지 판단할 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | ref | PRIMARY | 12 | const | 53298 | Using where |
MySQL 5.1 이상의 버전
MySQL 5.1 버전에서는 실행 계획의 key_len이 16으로 표시됐습니다. 하지만 type 칼럼의 값이 ref가 아니고 range로 바뀐 것을 확인할 수 있습니다. 하지만 "emp_no<>10001" 조건은 단순한 체크 조건임에도 key_len에 같이 포함되어 계산됐습니다. 결과적으로 MySQL 5.1에서는 key_len 칼럼의 값으로 인덱스의 몇 바이트까지가 범위 제한 조건으로 사용됐는지를 알아낼 수는 없습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | range | PRIMARY | 16 |
| 53298 | Using where |
사실 두 버전 간의 차이는 MySQL 엔진과 InnoDB 스토리지 엔진의 역할 분담에 큰 변화가 생긴 것이 원인입니다. MySQL 5.0에서는 범위 제한 조건으로 사용되는 칼럼만 스토리지 엔진으로 전달했습니다. 하지만 MySQL 5.1부터는 조건이 범위 제한 조건이든 체크 조건이든지 관계없이, 인덱스를 이용할 수만 있다면 모두 스토리지 엔진으로 전달하도록 바뀐 것입니다. MySQL에서는 이를 "컨디션 푸시 다운(Condition push down)"이라고 합니다.
ref
접근 방법이 ref 방식이면 참조 조건(Equal 비교 조건)으로 어떤 값이 제공됐는지 보여 줍니다. 만약 상수 값을 지정했다면 ref 칼럼의 값은 const로 표시되고, 다른 테이블의 칼럼값이면 그 테이블 명과 칼럼 명이 표시됩니다. 이 칼럼에 출력되는 내용은 크게 신경쓰지 않아도 무방한데, 아래와 같은 케이스는 조금 주의해서 볼 필요가 있습니다.
가끔 쿼리의 실행 계획에서 ref 칼럼의 값이 "func"라고 표시될 때가 있습니다. 이는 "Function"의 줄임말로 참조용으로 사용되는 값을 그대로 사용한 것이 아니라, 콜레이션 변환이나 값 자체의 연산을 거쳐서 참조됐다는 것을 의미합니다. 간단히 아래 예제 쿼리의 실행 계획을 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM employees e, dept_emp de WHERE e.emp_no=de.emp_no;
이 쿼리는 employees 테이블과 dept_emp 테이블을 조인하는데, 조인 조건에 사용된 emp_no 칼럼의 값에 대해 아무런 변환이나 가공도 수행하지 않았습니다. 그래서 이 쿼리의 실행 계획은 아래와 같이 ref 칼럼에 조인 대상 칼럼의 이름이 그대로 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 | |
1 | SIMPLE | e | eq_ref | PRIMARY | 4 | de.emp_no | 1 |
|
이번에는 위의 쿼리에서 조인 조건에 간단한 산술 표현식을 넣어 쿼리를 만들고, 실행 계획을 한번 확인해 보겠습니다.
EXPLAIN SELECT * FROM employees e, dept_emp de WHERE e.emp_no=(de.emp_no-1);
위의 쿼리에서는 dept_emp 테이블을 읽어서 de.emp_no 값에서 1을 뺀 값으로 employees 조인하고 있습니다. 이 쿼리의 실행 계획에서는 ref 값이 조인 칼럼의 이름이 아니라 "func"라고 표시되는 것을 확인할 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 | |
1 | SIMPLE | e | eq_ref | PRIMARY | 4 | func | 1 | Using where |
그런데 이렇게 사용자가 명시적으로 값을 변환할 때뿐만 아니라, MySQL 서버가 내부적으로 값을 변환해야 할 때도 ref 칼럼에는 "func"가 출력됩니다. 문자집합이 일치하지 않는 두 문자열 칼럼을 조인한다거나, 숫자 타입의 칼럼과 문자열 타입의 칼럼으로 조인할 때가 대표적인 예입니다. 가능하다면 MySQL 서버가 이런 변환을 하지 않아도 되도록 조인 칼럼의 타입은 일치시키는 편이 좋습니다.
rows
MySQL 옵티마이저는 각 조건에 대해 가능한 처리 방식을 나열하고, 각 처리 방식의 비용을 비교해 최종적으로 하나의 실행 계획을 수립합니다. 이때 비용을 산정하는 방법은 각 처리 방식이 얼마나 많은 레코드를 읽고 비교해야 하는지 예측해 보는 것입니다. 대상 테이블에 얼마나 많은 레코드가 포함돼 있는지 또는 각 인덱스 값이 분포도가 어떤지를 통계 정보를 기준으로 조사해서 예측합니다.
MySQL 실행 계획의 rows 칼럼의 값은 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여줍니다. 이 값은 각 스토리지 엔진별로 가지고 있는 통계 정보를 참조해 MySQL 옵티마이저가 산출해 낸 예상 값이라서 정확하지는 않습니다. 또한, rows 칼럼에 표시되는 값은 반환하는 레코드의 예측치가 아니라, 쿼리를 처리하기 위해 얼마나 많은 레코드를 디스크로부터 읽고 체크해야 하는지를 의미합니다. 그래서 실행 계획의 rows 칼럼에 출력되는 값과 실제 쿼리 결과 반환된 레코드 건수는 일치하지 않는 경우가 많습니다.
다음 쿼리는 dept_emp 테이블에서 from_date가 "1985-01-01"보다 크거나 같은 레코드를 조회하는 쿼리입니다. 이 쿼리는 dept_emp 테이블의 from_date 칼럼으로 생성된 ix_fromdate 인덱스를 이용해 처리할 수도 있지만, 풀 테이블 스캔(ALL)을 선택했다는 것을 알 수 있습니다. 다음 쿼리의 실행 계획에서 rows 칼럼의 값을 확인해보면 MySQL 옵티마이저가 이 쿼리를 처리하기 위해 대략 334,868건의 레코드를 읽어야 할 것이라고 예측했음을 알 수 있습니다. Dept_emp 테이블의 전체 레코드가 331,603건인 것을 고려한다면 레코드의 대부분을 비교해봐야 한다고 판단한 것입니다. 그래서 MySQL 옵티마이저는 인덱스 레인지 스캔이 아니라 풀 테이블 스캔을 선택한 것입니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='1985-01-01';
id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
1 | SIMPLE | dept_emp | ALL | ix_fromdate |
|
| 334868 | Using where |
그럼 이제 범위를 더 줄인 쿼리의 실행 계획을 한번 비교해보겟습니다. 다음 쿼리의 실행 계획을 보면 MySQL 옵티마이저는 대략 292건의 레코드만 읽고 체크해보면 원하는 결과를 가져올 수 있을 것으로 예측했음을 알 수 있습니다. 물론 그래서 실행 계획도 풀 테이블 스캔이 아니라 range로 인덱스 레인지 스캔을 사용한 것입니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='2002-07-01';
id | select_type | table | type | possible_keys | key | key_len | rows | Extra |
1 | SIMPLE | dept_emp | range | ix_fromdate | ix_fromdate | 3 | 292 | Using where |
이 예에서 옵티마이저는 from_date 칼럼의 값이 '2002-07-01'보다 큰 레코드가 292건만 존재할 것으로 예측했고, 이는 전체 테이블 건수와 비교하면 8.8%밖에 되지 않습니다. 그래서 최종적으로 옵티마이저는 ix_fromdate 인덱스를 range 방식(인덱스 레인지 스캔)으로 처리한 것입니다. 또한 인덱스에 포함된 from_date가 DATE 타입이므로 key_len은 3바이트로 표시됐습니다.
첫 번째 풀 테이블 스캔을 사용했던 예제 쿼리에 LIMIT 조건이 추가됐을 때 MySQL 옵티마이저가 예측하는 레코드 건수는 어떻게 변하는지 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM dept_emp WHERE from_date>='1985-01-01' LIMIT 10;
풀 테이블 스캔을 사용하면 rows 칼럼의 값이 334,868로 표시됐는데, LIMIT 10 조건을 추가하면 rows 칼럼의 값이 대략 반 정도로 줄어든 것을 알 수 있습니다. LIMIT가 포함되는 쿼리는 ROWS 칼럼에 표시되는 값이 오차가 심해서 별로 도움이 되지 않는다는 것을 알 수 있습니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | dept_emp | range | ix_fromdate | 3 |
| 167631 | Using where |
Extra
칼럼의 이름과는 달리, 쿼리의 실행 계획에서 성능에 관련된 중요한 내용이 Extra 칼럼에 자주 표시됩니다. Extra 칼럼에는 고정된 몇 개의 문장이 표시되는데, 일반적으로 2~3개씩 같이 표시됩니다. MySQL 5.0에서 MySQL 5.1로 업그레이드된 이후 추가된 키워드는 조금 있지만 MySQL 5.5는 MySQL 5.1과 거의 같습니다. MySQL 5.1에서 새로 추가된 키워드는 "(MySQL 5.1부터)"와 같이 태그를 붙여뒀으니 참고하기 바랍니다. 그럼 Extra 칼럼에 표시될 수 있는 문장을 하나씩 자세히 살펴보겠습니다. 여기서 설명하는 순서는 성능과 무관하므로 각 문장의 순서 자체는 의미가 없습니다.
const row not found (MySQL 5.1부터)
쿼리의 실행 계획에서 const 접근 방식으로 테이블을 읽었지만 실제로 해당 테이블에 레코드가 1건도 존재하지 않으면 Extra 칼럼에 이 내용이 표시됩니다. Extra 칼럼에 이런 메시지가 표시되는 경우에는 테이블에 적절히 테스트용 데이터를 저장하고 실행 계획을 확인해보는 것이 좋습니다.
Distinct
Extra 칼럼에 Distinct 키워드가 표시되는 다음 예제 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT DISTINCT d.dept_no FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | d | index | ux_deptname | 123 | NULL | 9 | Using index; |
1 | SIMPLE | de | ref | PRIMARY | 12 | employees.d | 18603 | Using index; |
위 쿼리에서 실제 조회하려는 값은 dept_no인데, departments 테이블과 dept_emp 테이블에 모두 존재하는 dept_no만 중복 없이 유니크하게 가져오기 위한 쿼리입니다. 그래서 두 테이블을 조인해서 그 결과에 다시 DISTINCT 처리를 넣은 것입니다.
실행 계획의 Extra 칼럼에 Distinct가 표시되는 경우, 어떻게 처리되는지 보여줍니다. 쿼리의 DISTINCT를 처리하기 위해 조인하지 않아도 되는 항목은 모두 무시하고 꼭 필요한 것만 조인했으며, dept_emp 테이블에서는 꼭 필요한 레코드만 읽었다는 것만 표현하고 있습니다.
Full scan on NULL key
이 처리는 "col1 IN (SELECT col2 FROM ...)"과 조건이 포함된 가진 쿼리에서 자주 발생할 수 있는데, 만약 col1의 값이 NULL이 된다면 결과적으로 조건은 "NULL IN (SELECT col2 FROM ...)"과 같이 바뀝니다. SQL 표준에서는 NULL을 "알 수 없는 값"으로 정의하고 있으며, NULL에 대한 연산의 규칙까지 정의하고 있습니다. 그 정의대로 연산을 수행하기 위해 이 조건은 다음과 같이 비교돼야 합니다.
서브 쿼리가 1건이라도 결과 레코드를 가진다면 최종 비교 결과는 NULL
서브 쿼리가 1건도 결과 레코드를 가지지 않는다면 최종 비교 결과는 FALSE
이 비교 과정에서 col1이 NULL이면 풀 테이블 스캔(Full scan)을 해야만 결과를 알아낼 수 있습니다. Extra 칼럼의 "Full scan on NULL key"는 MySQL이 쿼리를 실행하는 중 col1이 NULL을 만나면 예비책으로 풀 테이블 스캔을 사용할 것이라는 사실을 알려주는 키워드입니다. 만약 "col1 IN (SELECT col2 FROM ...)" 조건에서 col1이 NOT NULL로 정의된 칼럼이라면 이러한 예비책은 사용되지 않고 Extra 칼럼에도 표시되지 않을 것입니다.
Extra 칼럼에 "Full scan on NULL key"를 표시하는 실행 계획을 한번 살펴 보겠습니다.
EXPLAIN SELECT d.dept_no, NULL IN (SELECT id.dept_name FROM departments id) FROM departments d;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | d | index | ux_deptname | 123 | NULL | 9 | Using index; |
2 | DEPENDENT | id | index | ux_deptname | 123 | const | 2 | Using index; |
만약 칼럼이 NOT NULL로 정의되지는 않았지만 이러한 NULL 비교 규칙을 무시해도 된다면 col1이 절대 NULL은 될 수 없다는 것을 MySQL 옵티마이저에게 알려주면 됩니다. 가장 대표적인 방버으로는 이 쿼리의 조건에 "col1 IS NOT NULL"이라는 조건을 지정하는 것입니다. 그러면 col1이 NULL이면 "col1 IS NOT NULL" 조건이 FALSE가 되기 때문에 "col1 IN (SELECT col2 FROM tb_test2)" 조건은 실행하지 않습니다.
SELECT * FROM tb_test1 WHERE col1 IS NOT NULL AND col1 IN (SELECT col2 FROM tb_test2);
"Full scan on NULL key" 코멘트가 실행 계획의 Extra 칼럼에 표시됐다고 하더라도, 만약 IN이나 NOT IN 연산자의 왼쪽에 있는 값이 실제로 NULL이 없다면 풀 테이블 스캔은 발생하지 않으므로 걱정하지 않아도 됩니다. 하지만 IN이나 NOT IN 연산자의 왼쪽 값이 NULL인 레코드가 있고, 서브 쿼리에 개별적으로 WHERE 조건이 지정돼 있다면 상당한 성능 문제가 발생할 수도 있습니다.
여기서 사용된 쿼리는 단순히 예제를 만들어 내기 위해 작성한 쿼리입니다. 적절한 예제용 쿼리를 만들기 어려운 경우에는 조금 억지스러운 쿼리도 있을 수 있습니다. 때로는 그 실행 계획을 보여주기 위해 의미 없는 쿼리가 사용된 적도 있습니다. 하지만 설명을 위한 것이므로 "더 효율적으로 쿼리를 작성할 수 있는데, 왜 이렇게 쿼리를 작성했을까요?"라는 의문으로 시간을 낭비하지 않는 것이 좋습니다.
Impossible HAVING
쿼리에 사용된 HAVING 절의 조건을 만족하는 레코드가 없을 때 실행 계획의 Extra 칼럼에는 "Impossible HAVING" 키워드가 표시됩니다.
EXPLAIN SELECT e.emp_no, COUNT(*) AS cnt FROM employees e WHERE e.emp_no=10001 GROUP BY e.emp_no HAVING e.emp_no IS NULL;
위의 예제에서 HAVING 조건에 "e.emp_no IS NULL"이라는 조건이 추가됐지만, 사실 employees 테이블의 e.emp_no 칼럼은 프라이머리 키이면서 NOT NULL 타입의 칼럼입니다. 그러므로 결코 e.emp_no IS NULL 조건을 만족할 가능성이 없으므로 Extra 칼럼에서 "Impossible HAVING"이라는 키워드를 표시합니다. 여기서 보여준 예제에서는 이처럼 명확한 예제(실제 테이블 구조상으로 불가능한 조건)를 사용했지만 실제로 SQL을 개발하다 보면 이렇게 테이블 구조상으로 불가능한 조건뿐 아니라 실제 데이터 때문에 이런 현상이 발생하기도 하므로 주의해야 합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible HAVING |
애플리케이션이 쿼리 중에서 실행 계획의 Extra 칼럼에 "Impossible HAVING" 메시지가 출력된다면 쿼리가 제대로 작성되지 못한 경우가 대부분이므로 쿼리의 내용을 다시 점검하는 것이 좋습니다.
Impossible WHERE (MySQL 5.1부터)
"Impossible HAVING"과 비슷하며, WHERE 조건이 항상 FALSE가 될 수 밖에 없는 경우 "Impossible WHERE"가 표시됩니다.
EXPLAIN SELECT * FROM employees WHERE emp_no IS NULL;
위의 쿼리에서 WHERE 조건절에 사용된 emp_no칼럼은 NOT NULL이므로 emp_no IS NULL 조건은 항상 FALSE가 됩니다. 이럴 때 쿼리의 실행 계획에는 다음과 같이 "불가능한 WHERE 조건"으로 Extra 칼럼이 출력됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible WHERE |
Impossible WHERE noticed after reading const tables
위의 "Impossible WHERE"의 경우에는 실제 데이터를 읽어보지 않고도 바로 테이블의 구조상으로 불가능한 조건이라고 판단할 수 있었지만 다음 예제 쿼리는 어떤 메시지가 표시될까요?
EXPLAIN SELECT * FROM employees WHERE emp_no=0;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| Impossible WHERE noticed after reading const tables |
이 쿼리는 실제로 실행되지 않으면 emp_no=0인 레코드가 있는지 없는지 판단할 수 없습니다. 그런데 이 쿼리의 실행 계획만 확인했을 뿐인데, 옵티마이저는 사번이 0인 사원이 없다는 것까지 확인한 것입니다.
이를 토대로 MySQL이 실행 계획을 만드는 과정에서 쿼리의 일부분을 실행해 본다는 사실을 알 수 있습니다. 또한 이 쿼리는 employees 테이블의 프라이머리 키를 동등 조건으로 비교하고 있습니다. 이럴 때는 const 접근 방식을 사용한다는 것은 이미 살펴보았습니다. 쿼리에서 const 접근 방식이 필요한 부분은 실행 계획 수립 단계에서 옵티마이저가 직접 쿼리의 일부를 실행하고, 실행된 결과 값을 원본 쿼리의 상수로 대체합니다.
SELECT * FROM employees oe WHERE oe.first_name = ( SELECT ie.first_name FROM employees ie WHERE ie.emp_no=10001 );
즉, 위와 같은 쿼리를 실행하면 WHERE 조건절의 서브 쿼리는 (프라이머리 키를 통한 조회여서 const 접근 방식을 사용할 것이므로) 옵티마이저가 실행한 결과를 다음과 같이 대체한 다음, 본격적으로 쿼리를 실행합니다.
SELECT * FROM employees oe WHERE oe.first_name='Georgi';
No Matching min/max row (MySQL 5.1부터)
쿼리의 WHERE 조건절을 만족하는 레코드가 한 건도 없는 경우 일반적으로 "Impossible WHERE ..." 문장이 Extra 칼럼에 표시됩니다. 만약 MIN()이나 MAX()와 같은 집합 함수가 있는 쿼리의 조건절에 일치하는 레코드가 한 건도 없을때는 Extra 칼럼에 "No matching min/max row"라는 메시지가 출력됩니다. 그리고 MIN()이나 MAX()의 결과로 NULL이 반환됩니다.
EXPLAIN SELECT MIN(dept_no), MAX(dept_no) FROM dept_emp WHERE dept_no='';
위의 쿼리는 dept_emp 테이블에서 dept_no 칼럼이 빈 문자열인 레코드를 검색하고 있지만 dept_no 칼럼은 NOT NULL이므로 일치하는 레코드는 한 건도 없을 것입니다. 그래서 위 쿼리의 실행 계획의 Extra 칼럼에는 "No matching min/max row" 코멘트가 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| No matching min/max row |
Extra 칼럼에 출력되는 내용 중에서 "No matching ..."이나 "Impossible WHERE ..." 등의 메시지는 잘못 생각하면 쿼리 자체가 오류인 것처럼 오해하기 쉽습니다. 하지만 Extra 칼럼에 출력되는 내용은 단지 쿼리의 실행 계획을 산출하기 위한 기초 자료가 없음을 표현하는 것뿐입니다. Extra 칼럼에 이러한 메시지가 표시된다고 해서 실제 쿼리 오류가 발생하는 것은 아닙니다.
no matching row in const table (MySQL 5.1부터)
다음 쿼리와 같이 조인에 사용된 테이블에서 const 방식으로 접근할 때, 일치하는 레코드가 없다면 "no matching row in const table"이라는 메시지를 표시합니다.
EXPLAIN SELECT * FROM dept_emp de, (SELECT emp_no FROM employees WHERE emp_no=0) tb1 WHERE tb1.emp_no=de.emp_no AND de.dept_no='d005';
이 메시지 또한 "Impossible WHERE ..."와 같은 종류로, 실행 계획을 만들기 위한 기초 자료가 없음을 의미합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | PRIMARY |
|
|
|
|
|
| Impossible WHERE noticed after reading const tables |
2 | DERIVED |
|
|
|
|
|
| no matching row in const table |
No tables used (MySQL 5.0의 "No tables"에서 키워드 변경됨)
FROM 절이 없는 쿼리 문장이나 "FROM DUAL" 형태의 쿼리 실행 계획에서는 Extra 칼럼에 "No tables used"라는 메시지가 출력됩니다. 다른 DBMS와는 달리 MySQL은 FROM 절이 없는 쿼리도 허용됩니다. 이처럼 FROM 절 자체가 없거나, FROM 절에 상수 테이블을 의미하는 DUAL(칼럼과 레코드를 각각 1개씩만 가지는 가상의 상수 테이블)이 사용될 때는 Extra 칼럼에 "No tables used"라는 메시지가 표시됩니다.
EXPLAIN SELECT 1; EXPLAIN SELECT 1 FROM DUAL;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
| |
|
|
| No tables used |
MySQL에서는 FROM 절이 없는 쿼리도 오류 없이 실행됩니다. 하지만 오라클에서는 쿼리에 반드시 참조하는 테이블이 있어야 하므로 FROM 절이 필요없는 경우에 대비해 상수 테이블로 DUAL이라는 테이블을 사용합니다. 또한 MySQL에서는 오라클과의 호환을 위해 FROM 절에 "DUAL"이라는 테이블을 명시적으로도 사용할 수도 있습니다. MySQL 옵티마이저가 FROM 절에 DUAL이라는 이름이 사용되면 내부적으로 FROM 절이 없는 쿼리 문장과 같은 방식으로 처리합니다.
Not exists
프로그램을 개발하다 보면 A 테이블에는 존재하지만 B 테이블에는 없는 값을 조회해야 하는 쿼리가 자주 사용됩니다. 이럴 때는 주로 NOT IN (subquery) 형태나 NOT EXISTS 연산자를 주로 사용합니다. 이러한 형태의 조인을 안티-조인(Anti-JOIN)이라고 합니다. 똑같은 처리를 아우터 조인(LEFT OUTER JOIN)을 이용해도 구현할 수 있습니다. 일반적으로 안티-조인으로 처리해야 하지만 레코드의 건수가 많을때는 NOT IN (subquery)이나 NOT EXISTS 연산자보다는 아우터 조인을 이용하면 빠른 성능을 낼 수 있습니다.
아우터 조인을 이용해 dept_emp 테이블에는 있지만 departments 테이블에는 없는 dept_no를 조회하는 쿼리를 예제로 살펴보겠습니다. 아래의 예제 쿼리는 departments 테이블을 아우터 조인해서 ON절이 아닌 WHERE절에 아우터 테이블(departments)의 dept_no 칼럼이 NULL인 레코드만 체크해서 가져옵니다. 즉 안티-조인은 일반 조인(INNER JOIN)을 했을 때 나오지 않는 결과만 가져오는 방법입니다.
EXPLAIN SELECT * FROM dept_emp de LEFT JOIN departments d ON de.dept_no=d.dept_no WHERE d.dept_no IS NULL;
이렇게 아우터 조인을 이용해 안티-조인을 수행하는 쿼리에서는 Extra 칼럼에 Not exists 메시지가 표시됩니다. Not exists 메시지는 이 쿼리를 NOT EXISTS 형태의 쿼리로 변환해서 처리했음을 의미하는 것이 아니라 MySQL이 내부적으로 어떤 최적화를 했는데 그 최적화의 이름이 "Not exists"인 것입니다. Extra 칼럼의 Not exists와 SQL의 NOT EXISTS 연산자를 혼동하지 않도록 주의합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | ALL |
|
|
| 334868 |
|
1 | SIMPLE | d | eq_ref | PRIMARY | 12 | employees. | 1 | Using where; |
Range checked for each record (index map: N)
두 개의 테이블을 조인하는 다음의 쿼리를 보면서 이 메시지의 의미를 이해해 보겠습니다. 조인 조건에 상수가 없고 둘 다 변수(e1.emp_no와 e2.emp_no)인 경우, MySQL 옵티마이저는 e1 테이블을 먼저 일고 조인을 위해 e2를 읽을 때, 인덱스 레인지 스캔과 풀 테이블 스캔 중에서 어느 것이 효율적일지 판단할 수 없게 됩니다. 즉, e1 테이블의 레코드를 하나씩 읽을 때마다 e1.emp_no 값이 계속 바뀌므로 쿼리의 비용 계산을 위한 기준값이 계속 변하는 것입니다. 그래서 어떤 접근 방법으로 e2 테이블을 읽는 것이 좋을지 판단할 수 없는 것입니다.
EXPLAIN SELECT * FROM employees e1, employees e2 WHERE e2.emp_no >= e1.emp_no;
예를 들어 사번이 1번부터 1억까지 있다고 가정해 보겠습니다. 그러면 e1 테이블을 처음부터 끝까지 스캔하면서 e2 테이블에서 e2.emp_no >= e1.emp_no 조건을 만족하는 레코드를 찾아야 하는데, 문제는 e1.emp_no=1인 경우에는 e2 테이블의 1억건 전부를 읽어야 한다는 것입니다. 하지만 e1.emp_no=100000000인 경우에는 e2 테이블을 한 건만 읽으면 된다는 것입니다.
그래서 e1 테이블의 emp_no가 작을 때는 e2 테이블을 풀 테이블 스캔으로 접근하고, e1 테이블의 emp_no가 큰 값일 때는 e2 테이블을 인덱스 레인지 스캔으로 접근하는 형태를 수행하는 것이 최적의 조인 방법일 것입니다. 지금까지 설명한 내용을 줄여서 표현하면 "매 레코드마다 인덱스 레인지 스캔을 체크한다"라고 할 수 있는데, 이것이 Extra 칼럼에 표시되는 "Range checked for each record"의 의미입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | e1 | ALL |
| 3 |
| 300584 | Using index |
1 | SIMPLE | e2 | ALL |
|
| 300584 | Range checked for each record (index map: 0x1) |
Extra 칼럼의 출력 내용 중에서 "(index map: 0x1)"은 사용할지 말지를 판단하는 후보 인덱스의 순번을 나타냅니다. "index map"은 16진수로 표시되는데, 이를 해석하려면 우선 이진수로 표현을 바꿔야합니다. 위의 실행 계획에서는 0x1이 표시되는데, 이는 이진수로 바꿔도 1입니다. 그래서 이 쿼리는 e2(employees) 테이블의 첫 번째 인덱스를 사용할지 아니면 풀 테이블을 스캔할지를 매번 판단한다는 것을 의미합니다. 여기서 테이블의 첫 번째 인덱스란 "SHOW CREATE TABLE employees" 명령으로 테이블의 구조를 조회했을 때 제일 먼저 출력되는 인덱스를 의미합니다.
그리고 쿼리 실행 계획의 type 칼럼의 값이 ALL로 표시되어 풀 테이블 스캔으로 처리된 것으로 해석하기 쉽습니다. 하지만 Extra 칼럼에 "Range checked for each record"가 표시되면 type 칼럼에는 ALL로 표시됩니다. 즉 "index map"에 표시된 후보 인덱스를 사용할지 여부를 검토해서, 이 후보 인덱스가 별로 도움이 되지 않는다면 최종적으로 풀 테이블 스캔을 사용하기 때문에 ALL로 표시된 것입니다.
"index map"에 대한 이해를 돕기 위해 조금 더 복잡한 "index map"을 예제로 살펴보겠습니다. 우선 아래와 같이 인덱스가 여러 개인 테이블에 실행되는 쿼리의 실행 계획에서 "(index map: 0x19)"이라고 표시됐다고 가정해보겠습니다.
CREATE TABLE tb_member ( mem_id INTEGER NOT NULL, mem_name VARCHAR(100) NOT NULL, mem_nickname VARCHAR(100) NOT NULL, mem_region TINYINT, mem_gender TINYINT, mem_phone VARCHAR(25), PRIMARY KEY (mem_id), INDEX ix_nick_name (mem_nickname, mem_name), INDEX ix_nick_region (mem_nickname, mem_region), INDEX ix_nick_gender (mem_nickname, mem_gender), INDEX ix_nick_phone (mem_nickname, mem_phone) );
우선 0x19 값을 비트(이진) 값으로 변환해 보면 11001입니다. 이 비트 배열을 해석하는 방법은 다음 표와 같습니다. 이진 비트 맵의 각 자리 수는 "CREATE TABLE tb_member ..." 명령에 나열된 인덱스의 순번을 의미합니다.
자리수 | 다섯번째 자리 | 네번째 자리 | 세번째 자리 | 두번째 자리 | 첫번째 자리 |
비트맵 값 | 1 | 1 | 0 | 0 | 1 |
지칭 인덱스 | ix_nick_phone | ix_nick_gender | ix_nick_region | ix_nick_name | PRIMARY KEY |
결론적으로 실행 계획에서 "(index map: 0x19)"의 의미는 위의 표에서 각 자리 수의 값이 1인 다음 인덱스를 사용 가능한 인덱스 후보로 선정했음을 의미합니다.
PRIMARY KEY
ix_nick_gender
ix_nick_phone
각 레코드 단위로 이 후보 인덱스 가운데 어떤 인덱스를 사용할지 결정하게 되는데, 실제 어떤 인덱스가 사용됐는지는 알 수 없습니다. 단지 각 비트 맵의 자리 수가 1인 순번의 인덱스가 대상이라는 것만 알 수 있습니다.
실행 계획의 Extra 칼럼에 "Range checked for each record"가 표시되는 쿼리가 많이 실행되는 MySQL 서버에서는 "SHOW GLOBAL STATUS" 명령으로 표시되는 상태 값 중에서 "Select_range_check"의 값이 크게 나타납니다.
Scanned N databases(MySQL 5.1부터)
MySQL 5.0부터는 기본적으로 INFORMATION_SCHEMA라는 DB가 제공됩니다.
INFORMATION_SCHEMA DB는 MySQL 서버 내에 존재하는 DB의 메타 정보(테이블, 칼럼, 인덱스 등의 스키마 정보)를 모아둔 DB입니다. INFORMATION_SCHEMA 데이터베이스 내의 모든 테이블은 읽기 전용이며, 단순히 조회만 가능합니다. 실제로 이 데이터베이스 내의 테이블은 레코드가 있는 것이 아니라, SQL을 이용해 조회할 때마다 메타 정보를 MySQL 서버의 메모리에서 가져와서 보여줍니다. 이런 이유로 한꺼번에 많은 테이블을 조회할 경우 시간이 많이 걸립니다.
MySQL 5.1부터는 INFORMATION_SCHEMA DB를 빠르게 조회할 수 있게 개선됐습니다. 개선된 조회를 통해 메타 정보를 검색할 경우에는 쿼리 실행 계획의 Extra 칼럼에 "Scanned N databases"라는 메시지가 표시됩니다. "Scanned N databases"에서 N은 몇 개의 DB 정보를 읽었는지 보여주는 것인데, N은 0과 1 또는 all의 값을 가지며 각각 의미는 다음과 같습니다.
0 : 특정 테이블의 정보만 요청되어 데이터베이스 전체의 메타 정보를 읽지 않음
1 : 특정 데이터베이스내의 모든 스키마 정보가 요청되어 해당 데이터베이스의 모든 스키마 정보를 읽음
All : MySQL 서버 내의 모든 스키마 정보를 다 읽음
이 코멘트는 INFORMATION_SCHEMA 내의 테이블로부터 데이터를 읽는 경우에만 표시됩니다.
EXPLAIN SELECT table_name FROM information_schema.tables WHERE table_schema = 'employees' AND table_name = 'employees';
위 쿼리는 employees DB의 employees 테이블 정보만 읽었기 때문에 employees DB 전체를 참조하지는 않았습니다. 그래서 다음과 같이 "Scanned 0 databases"로 Extra 칼럼에 표시된 것입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | TABLES | ALL | TABLE_SCHEMA, | ... | ... | ... | Using where; |
애플리케이션에서는 INFORMATION_SCHEMA DB에서 메타 정보를 조회하는 쿼리는 거의 사용하지 않으므로 실행 계획에 "Scanned N databases"라는 코멘트가 표시되는 쿼리는 거의 없을 것입니다.
Select tables optimized away
MIN() 또는 MAX()만 SELECT 절에 사용되거나 또는 GROUP BY로 MIN(), MAX()를 조회하는 쿼리가 적절한 인덱스를 사용할 수 없을 때 인덱스를 오름차순 또는 내림차순으로 1건만 읽는 형태의 최적화가 적용된다면 Extra 칼럼에 "Select tables optimized away"가 표시됩니다.
또한 MyISAM 테이블에 대해서는 GROUP BY 없이 COUNT(*)만 SELECT할 때도 이런 형태의 최적화가 적용됩니다. MyISAM 테이블은 전체 레코드 건수를 별도로 관리하기 때문에 인덱스나 데이터를 읽지 않고도 전체 건수를 빠르게 조회할 수 있습니다. 하지만 WHERE 절에 조건을 가질 때는 이러한 최적화를 사용하지 못합니다.
EXPLAIN SELECT MAX(emp_no), MIN(emp_no) FROM employees; EXPLAIN SELECT MAX(from_date), MIN(from_date) FROM salaries WHERE emp_no=10001;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE |
|
|
|
|
|
| Select tables optimized away |
첫 번째 쿼리는 employees 테이블에 있는 emp_no 칼럼에 인덱스가 생성돼 있으므로 "Select tables optimized away" 최적화가 가능합니다. employees 테이블의 emp_no 칼럼에 생성된 인덱스에서 첫번째 레코드와 마지막 레코드만 읽어서 최솟값과 최댓값을 가져오는 것을 표현하고 있습니다.
두 번째 쿼리의 경우 salaries 테이블에 emp_no + from_date로 인덱스가 생성돼 있으므로 인덱스가 emp_no=1001인 레코드를 검색하고, 검색된 결과 중에서 오름차순 또는 내림차순으로 하나만 조회하면 되기 때문에 이러한 최적화가 가능한 것입니다.
Skip_open_table, Open_frm_only, Open_trigger_only, Open_full_table(MySQL 5.1부터)
이 코멘트 또한 "Scanned N databases"와 같이 INFORMATION_SCHEMA DB의 메타 정보를 조회하는 SELECT 쿼리의 실행 계획에서만 표시되는 내용입니다. 테이블의 메타 정보가 저장된 파일(*.FRM)과 트리거가 저장된 파일(*.TRG) 또는 데이터 파일 중에서 필요한 파일만 읽었는지 또는 불가피하게 모든 파일을 다 읽었는지 등의 정보를 보여줍니다. Extra 칼럼에 표시되는 메시지는 다음 4가지 중 하나이며, 그 의미는 다음과 같습니다.
Skip_open_table: 테이블의 메타 정보가 저장된 파일을 별도로 읽을 필요가 없음
Open_frm_only: 테이블의 메타 정보가 저장된 파일(*.FRM)만 열어서 읽음
Open_trigger_only: 트리거 정보가 저장된 파일(*.TRG)만 열어서 읽음
Open_full_table: 최적화되지 못해서 테이블 메타 정보 파일(*.FRM)과 데이터(*.MYD) 및 인덱스 파일(*.MYI)까지 모두 읽음
위의 내용에서 데이터(*.FRM) 파일이나 인덱스(*.MYI)에 관련된 내용은 MyISAM에만 해당하며, InnoDB 스토리지 엔진을 사용하는 테이블에는 적용되지 않습니다.
unique row not found (MySQL 5.1부터)
두 개의 테이블이 각각 유니크(프라이머리 키 포함) 칼럼으로 아우터 조인을 수행하는 쿼리에서 아우터 테이블에 일치하는 레코드가 존재하지 않을 때 Extra 칼럼에 이 코멘트가 표시됩니다.
-- // 테스트 케이스를 위한 테스트용 테이블 생성 CREATE TABLE tb_test1 (fdpk INT, PRIMARY KEY(fdpk)); CREATE TABLE tb_test2 (fdpk INT, PRIMARY KEY(fdpk)); -- // 생성된 테이블에 레코드 INSERT INSERT INTO tb_test1 VALUES (1), (2); INSERT INTO tb_test2 VALUES (1); EXPLAIN SELECT t1.fdpk FROM tb_test1 t1 LEFT JOIN tb_test2 t2 ON t2.fdpk=t1.fdpk WHERE t1.fdpk=2;
이 쿼리가 실행되면 tb_test2 테이블에는 fdpk=2인 레코드가 없으므로 다음처럼 "unique row not found"라는 코멘트가 표시됩니다.
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | t1 | const | PRIMARY | 4 | const | 1 | Using index |
1 | SIMPLE | t2 | const | PRIMARY | 4 | const | 0 | unique row not found |
Using filesort
ORDER BY를 처리하기 위해 인덱스를 이용할 수도 있지만 적절한 인덱스를 사용하지 못할 때는 MySQL 서버가 조회된 레코드를 다시 한 번 정렬해야 합니다. ORDER BY 처리가 인덱스를 사용하지 못할 때만 실행 계획의 Extra 칼럼에는 "Using filesort" 코멘트가 표시되며, 이는 조회된 레코드를 정렬용 메모리 버퍼에 복사해 퀵 소트 알고리즘을 수행하게 됩니다. "Using filesort" 코멘트는 ORDER BY가 사용된 쿼리의 실행 계획에서만 나타날 수 있습니다.
EXPLAIN SELECT * FROM employees ORDER BY last_name DESC;
hire_date 칼럼에는 인덱스가 없으므로 이 쿼리의 정렬 작업을 처리하기 위해 인덱스를 이용하는 것은 불가능 합니다. MySQL 옵티마이저는 레코드를 읽어서 소트 버퍼(Sort Buffer)에 복사하고, 정렬해서 그 결과를 클라이언트에 보냅니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | ALL |
|
|
|
| Using filesort |
실행 계획의 Extra 칼럼에 "Using filesort"가 출력되는 쿼리는 많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋습니다.
Using index(커버링 인덱스)
데이터 파일을 전혀 읽지 않고 인덱스만 읽어서 쿼리를 모두 처리할 수 있을 때 Extra 칼럼에 "Using index"가 표시됩니다. 인덱스를 이용해 처리하는 쿼리에서 가장 큰 부하를 차지하는 부분은 인덱스를 검색해 일치하는 레코드의 나머지 칼럼 값을 가져오기 위해 데이터 파일을 찾아서 가져오는 작업입니다. 최악의 경우에는 인덱스를 통해 검색된 결과 레코드 한 건 한 건마다 디스크를 한번씩 읽어야 할 수도 있습니다.
employees 테이블에 데이터가 저장돼 있고, 아래의 쿼리가 인덱스 레인지 스캔 접근 방식을 사용한다고 해보겠습니다. 만약 아래 쿼리가 인덱스 레인지 스캔으로 처리된다면 디스크에서 읽기 작업이 얼마나 필요한지 한 번 살펴보겠습니다.
SELECT first_name, birth_date FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | ix_firstname | 42 |
| ... | Using where |
1. 이 예제 쿼리는 employees 테이블의 first_name 칼럼에 생성된 인덱스(ix_firstname)를 이용해 일치하는 레코드를 검색할 것입니다.
2. 그리고 일치하는 레코드 5건에 대해 birth_date 칼럼의 값을 읽기 위해 각 레코드가 저장된 데이터 페이지를 디스크로부터 읽어야 합니다.
실제 ix_firstname 인덱스에서 일치하는 레코드 5건을 검색하기 위해 디스크 읽기 3~4번만으로 필요한 인덱스 페이지를 모두 가져올 수 있습니다. 하지만 각 레코드의 나머지 데이터를 가져오기 위해 최대 5번의 디스크 읽기를 더 해야 합니다. 물론 이 예제는 아주 간단하고 적은 개수의 레코드만 처리하기 때문에 디스크 읽기가 적지만 실제로 복잡하고 많은 레코드를 검색해야 하는 쿼리에서는 나머지 레코드를 읽기 위해 수백 번의 디스크 읽기가 더 필요할 수도 있습니다.
그럼 이제 birth_date 칼럼은 빼고 first_name 칼럼만 SELECT하는 쿼리를 한번 생각해보겠습니다. 이 쿼리도 마찬가지로 인덱스 레인지 스캔을 이용해 처리된다고 가정해보겠습니다.
SELECT first_name FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | ix_firstname | 42 |
| ... | Using index |
이 예제 쿼리에서는 employees 테이블의 여러 칼럼 중에서 first_name 칼럼만 사용됐습니다. 즉, first_name 칼럼만 있으면 이 쿼리는 모두 처리되는 것입니다. 그래서 이 쿼리는 위의 첫 번째 예제 쿼리의 두 작업 중에서 1번 과정만 실행하면 됩니다. 필요한 칼럼이 모두 인덱스에 있으므로 나머지 칼럼이 저장된 데이터 파일을 읽어올 필요가 없습니다. 이 쿼리는 디스크에서 4~5개의 페이지만 읽으면 되기 때문에 매우 빠른 속도로 처리됩니다.
두번째 예제와 같이 인덱스만으로 쿼리를 수행할 수 있을 때 실행 계획의 Extra 칼럼에는 "Using index"라는 메시지가 출력됩니다. 이렇게 인덱스만으로 처리되는 것을 "커버링 인덱스(Covering index)"라고 합니다. 인덱스 레인지 스캔을 사용하지만 쿼리의 성능이 만족스럽지 못한 경우라면 인덱스에 있는 칼럼만 사용하도록 쿼리를 변경해 큰 성능 향상을 볼 수 있습니다.
InnoDB의 모든 테이블은 클러스터링 인덱스로 구성돼 있습니다. 그리고 이 때문에 InnoDB 테이블의 모든 보조 인덱스는 데이터 레코드의 주소 값으로 프라이머리 키값을 가집니다. InnoDB 테이블에서는 first_name 칼럼만으로 인덱스를 만들어도, 결국 그 인덱스에 emp_no 칼럼이 같이 저장되는 효과를 냅니다. 이러한 클러스터링 인덱스 특성 때문에 쿼리가 "커버링 인덱스"로 처리될 가능성이 상당히 높습니다. 간단히 다음 쿼리를 한번 살펴보겠습니다. 이 예제 쿼리도 인덱스 레인지로 처리된다고 가정해보겠습니다.
SELECT emp_no, first_name FROM employees WHERE first_name BETWEEN 'Babette' AND 'Gad';
이 쿼리에도 위의 첫번째나 두번째 예제처럼 같은 WHERE 조건이 지정돼 있어서 first_name 칼럼의 인덱스를 이용해 일치하는 레코드를 검색할 것입니다. 그런데 이 쿼리는 위의 두 번째 예제 쿼리와는 달리 first_name 칼럼 말고도 emp_no를 더 가져와야 합니다. 하지만 emp_no는 employees 테이블의 프라이머리 키이기 때문에 이미 인덱스에 포함돼 있어 데이터 파일을 읽지 않아도 됩니다. 즉, InnoDB의 보조 인덱스에는 데이터 레코드를 찾아가기 위한 주소로 사용하기 위해 프라이머리 키를 저장해두는 것이지만, 더불어 추가 칼럼을 하나 더 가지는 인덱스의 효과를 동시에 얻을 수 있게 되는 것입니다.
레코드 건수에 따라 차이는 있겠지만 쿼리를 커버링 인덱스로 처리할 수 있을 때와 그렇지 못할 때의 성능 차이는 수십 배에서 수백 배까지 날 수 있습니다. 하지만 무조건 커버링 인덱스로 처리하려고 인덱스에 많은 칼럼을 추가하면 더 위험한 상황이 초래될 수도 있습니다. 너무 과도하게 인덱스의 칼럼이 많아지면 인덱스의 크기가 커져서 메모리 낭비가 심해지고 레코드를 저장하거나 변경하는 작업이 매우 느려질 수 있기 때문입니다. 너무 커버링 인덱스 위주로 인덱스를 생성하지는 않도록 주의해야합니다.
접근 방법(실행 계획의 type 칼럼)이 eq_ref, ref, range, index_merge, index 등과 같이 인덱스를 사용하는 실행 계획에서는 모두 Extra 칼럼에 "Using index"가 표시될 수 있습니다. 즉 인덱스 레인지 스캔(eq_ref, ref, range, index_merge 등의 접근 방법)을 사용할 때만 커버링 인덱스로 처리되는 것은 아닙니다. 인덱스를 풀 스캔(index 접근 방법)을 실행할 때도 커버링 인덱스로 처리될 수 있는데, 이때도 똑같은 인덱스 풀 스캔의 접근 방법이라면 커버링 인덱스가 아닌 경우보다 훨씬 빠르게 처리됩니다.
Extra 칼럼에 표시되는 "Using index"와 접근 방법 (type 칼럼의 값)의 "index"를 자주 혼동할 때가 있는데, 사실 이 두가지는 성능상 반대되는 개념이라서 반드시 구분해서 이해해야 합니다. 이미 살펴봤듯이 실행 계획의 type 칼럼에 표시되는 "index"는 인덱스 풀 스캔으로 처리하는 방식을 의미하며, 이는 인덱스 레인지 스캔보다 훨씬 느린 처리 방식입니다. 하지만 "Using index"는 커버링 인덱스가 사용되지 않는 쿼리보다는 훨씬 빠르게 처리한다는 것을 의미하는 메시지입니다. 커버링 인덱스는 실행 계획의 type에 관계없이 사용될 수 있습니다.
Using index for group-by
GROUP BY 처리를 위해 MySQL 서버는 그룹핑 기준 칼럼을 이용해 정렬 작업을 수행하고 다시 정렬된 결과를 그룹핑하는 형태의 고부하 작업을 필요로 합니다. 하지만 GROUP BY 처리가 인덱스(B-Tree 인덱스에 한해)를 이용하면 정렬된 인덱스 칼럼을 순서대로 읽으면서 그룹핑 작업만 수행합니다. 이렇게 GROUP BY 처리에 인덱스를 이용하면 레코드의 정렬이 필요하지 않고 인덱스의 필요한 부분만 읽으면 되기 때문에 상당히 효율적이고 빠르게 처리됩니다. GROUP BY 처리가 인덱스를 이용할 때 쿼리의 실행 계획에서는 Extra 칼럼에 "Using index for group-by" 메시지가 표시됩니다. GROUP BY 처리를 위해 인덱스를 읽는 방법을 "루스 인덱스 스캔"이라고 합니다.
GROUP BY 처리를 위해 단순히 인덱스를 순서대로 쭉 읽는 타이트 인덱스 스캔과는 달리 루스 인덱스 스캔은 인덱스에서 필요한 부분만 듬성 듬성 읽습니다.
타이트 인덱스 스캔(인덱스 스캔)을 통한 GROUP BY 처리
인덱스를 이용해 GROUP BY 절을 처리할 수 있더라도 AVG()나 SUB() 또는 COUNT(*)와 같이 조회하려는 값이 모든 인덱스를 다 읽어야 할 때는 필요한 레코드만 듬성듬성 읽을 수가 없습니다. 이런 쿼리는 단순히 GROUP BY를 위해 인덱스를 사용하기는 하지만 이를 루스 인덱스 스캔이라고 하지는 않습니다. 또한 이런 쿼리의 실행 계획에는 "Using index for group-by" 메시지가 출력되지 않습니다.
EXPLAIN SELECT first_name, COUNT(*) AS counter FROM employees GROUP BY first_name;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index | ix_firstname | 44 |
| 299809 | Using index |
루스 인덱스 스캔을 통한 GROUP BY 처리
단일 칼럼으로 구성된 인덱스에서는 그룹핑 칼럼 말고는 아무것도 조회하지 않는 쿼리에서 루스 인덱스 스캔을 사용할 수 있습니다. 그리고 다중 칼럼으로 만들어진 인덱스에서는 GROUP BY 절이 인덱스를 사용할 수 있어야 함은 물론이고 MIN()이나 MAX()와 같이 조회하는 값이 인덱스의 첫 번째 또는 마지막 레코드만 읽어도 되는 쿼리는 "루스 인덱스 스캔"이 사용될 수 있습니다. 이때는 인덱스를 듬성듬성하게 필요한 부분만 읽습니다. 다음 예제 쿼리는 salaries 테이블의 (emp_no+from_date) 칼럼으로 만들어진 인덱스에서 각 emp_no 그룹별로 첫 번째 from_date 값(최솟값)과 마지막 from_date 값(최댓값)을 인덱스로부터 읽으면 되기 때문에 "루스 인덱스 스캔" 방식으로 처리할 수 있습니다.
EXPLAIN SELECT emp_no, MIN(from_date) AS first_changed_date, MAX(from_date) AS last_changed_date FROM salaries GROUP BY emp_no;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | salaries | range | PRIMARY | 4 |
| 711129 | Using index for group-by |
GROUP BY에서 인덱스를 사용하려면 우선 GROUP BY 조건의 인덱스 사용 요건이 갖춰져야 합니다. 하지만 그 이전에 WHERE 절에서 사용하는 인덱스에 의해서도 사용 여부가 영향을 받는다는 사실이 중요합니다.
WHERE 조건절이 없는 경우
WHERE 절의 조건이 전혀 없는 쿼리는 GROUP BY와 조회하는 칼럼이 "루스 인덱스 스캔"을 사용할 수 있는 조건만 갖추면 됩니다. 그렇지 못한 쿼리는 타이트 인덱스 스캔(인덱스 스캔)이나 별도의 정렬 과정을 통해 처리됩니다.
WHERE 조건절이 있지만 검색을 위해 인덱스를 사용하지 못하는 경우
GROUP BY 절은 인덱스를 사용할 수 있지만 WHERE 조건절이 인덱스를 사용하지 못할 때는 먼저 GROUP BY를 위해 인덱스를 읽은 후, WHERE 조건의 비교를 위해 데이터 레코드를 읽어야 합니다. 그래서 이 경우도 "루스 인덱스 스캔"을 이용할 수 없으며, 타이트 인덱스 스캔(인덱스 스캔) 과정을 통해 GROUP BY가 처리됩니다. 다음의 쿼리는 WHERE 절은 인덱스를 사용하지 못하지만 GROUP BY가 인덱스를 사용하는 예제입니다.
EXPLAIN SELECT first_name FROM employees WHERE birth_date < '1994-01-01' GROUP BY first_name;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | index | ix_firstname | 44 |
| 299809 | Using where |
WHERE 절의 조건이 있으며, 검색을 위해 인덱스를 사용하는 경우
하나의 단위 쿼리가 실행되는 경우에 index_merge 이외의 접근 방법에서는 단 하나의 인덱스만 사용할 수 있습니다. 그래서 WHERE 절의 조건이 인덱스를 사용할 수 있으면 GROUP BY가 인덱스를 사용할 수 있는 조건이 더 까다로워진다. 즉, WHERE 절의 조건이 검색하는 데 사용했던 인덱스를 GROUP BY 처리가 다시 사용할 수 있을 때만 루스 인덱스 스캔을 사용할 수 있습니다. 만약 WHERE 조건절이 사용할 수 있는 인덱스와 GROUP BY 절이 사용할 수 있는 인덱스가 다른 경우라면 일반적으로 옵티마이저는 WHERE 조건절이 인덱스를 사용하도록 실행 계획을 수립하는 경향이 있습니다. 때로는 전혀 작업 범위를 좁히지 못하는 WHERE 조건이라 하더라도 GROUP BY보다는 WHERE 조건이 먼저 인덱스를 사용할 수 있게 실행 계획이 수립됩니다.
EXPLAIN SELECT emp_no FROM salaries WHERE emp_no BETWEEN 10001 AND 200000 GROUP BY emp_no;
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | salaries | range | PRIMARY | 4 |
| 207231 | Using where; |
WHERE 절의 조건이 검색을 위해 인덱스를 이용하고, GROUP BY가 같은 인덱스를 사용할 수 있는 쿼리라 하더라도 인덱스 루스 스캔을 사용하지 않을 수 있습니다. 즉, WHERE 조건에 의해 검색된 레코드 건수가 적으면 루스 인덱스 스캔을 사용하지 않아도 매우 빠르게 처리될 수 있기 때문입니다. 루스 인덱스 스캔은 주로 대량의 레코드를 GROUP BY하는 경우 성능 향상 효과가 있을 수 있기 때문에 옵티마이저가 적절히 손익 분기점을 판단하는 것입니다.
다음 예제 쿼리는 바로 위에서 살펴본 쿼리와 같습니다. WHERE 절의 검색 범위만 더 좁혀졌는데, 실행 계획의 Extra 칼럼에 "Using index for group-by" 처리가 사라진 것을 확인할 수 있습니다.
EXPLAIN SELECT emp_no FROM salaries WHERE emp_no BETWEEN 10001 AND 10099 GROUP BY emp_no;
루스 인덱스 스캔은 DISTINCT나 GROUP BY가 포함된 쿼리에서 최적의 튜닝 방법입니다.
Using join buffer(MySQL 5.1 부터)
일반적으로 빠른 쿼리 실행을 위해 조인이 되는 칼럼은 인덱스를 생성합니다. 실제로 조인에 필요한 인덱스는 조인되는 양쪽 테이블 칼럼 모두가 필요한 것이 아니라 조인에서 뒤에 읽는 테이블의 칼럼에만 필요합니다. MySQL 옵티마이저도 조인되는 두 테이블에 있는 각 칼럼에서 인덱스를 조사하고, 인덱스가 없는 테이블이 있으면 그 테이블을 먼저 읽어서 조인을 실행합니다. 뒤에 읽는 테이블은 검색 위주로 사용되기 때문에 인덱스가 없으면 성능에 미치는 영향이 매우 크기 때문입니다.
RDBMS에서 조인을 처리하는 방법은 2~3가지 정도 되지만 MySQL에서는 "중첩 루프 조인(Nested loop)" 방식만 지원합니다. FROM 절에 아무리 테이블이 많아도 조인을 수행할 때 반드시 두 개의 테이블이 비교되는 방식으로 처리됩니다. 그리고 두 개의 테이블이 조인될 때 먼저 읽는 테이블을 드라이빙(Driving) 테이블이라고 하며, 뒤에 읽히는 테이블을 드리브(Driven) 테이블이라고 합니다. 예를 들어 조인이 다음과 같은 순서로 수행되는 쿼리가 있다고 가정해보겠습니다.
A → B → C
A 테이블과 B 테이블이 조인되는 과정에서 드리이빙 테이블은 A이며, 드리븐 테이블은 B입니다. 그리고 B와 C가 조인되는 과정에서 드라이빙 테이블은 B가 되고 드리븐 테이블은 C가 됩니다. 일반적으로 3개 이상의 여러 개 테이블이 조인되는 경우에도 가장 먼저 읽히는 드라이비이 테이블이 어떤 테이블이냐 따라 성능이 많이 좌우됩니다. (일반적으로 쿼리 전체적으로 가장 먼저 읽히는)
가끔은 드라이빙 테이블을 아우터 테이블(Outer table), 드리븐 테이블을 이너 테이블(Inner table)이라고 표현하기도 합니다.
조인이 수행될 때 드리븐 테이블의 조인 칼럼에 적절한 인덱스가 있다면 아무런 문제가 되지 않습니다. 하지만 드리븐 테이블에 검색을 위한 적절한 인덱스가 없다면 드라이빙 테이블로부터 읽은 레코드의 건수만큼 매번 드리븐 테이블을 풀 테이블 스캔이나 인덱스 풀 스캔해야 할 것입니다. 이때 드리븐 테이블의 비효율적인 검색을 보완하기 위해 MySQL 서버는 드라이빙 테이블에서 읽은 레코드를 임시 공간에 보관해두고 필요할 때 재사용할 수 있게 해줍니다. 읽은 레코드를 임시로 보관해두는 메모리 공간을 "조인 버퍼"라고 하며, 조인 버퍼가 사용되는 실행 계획읜 Extra 칼럼에는 "Using join buffer"라는 메시지가 표시됩니다.
조인 버퍼는 join_buffer_size라는 시스템 설정 변수에 최대 사용 가능한 버퍼 크기를 설정할 수도 있습니다. 만약 조인되는 칼럼에 인덱스가 적절하게 준비돼 있다면 조인 버퍼에 크게 신경 쓰지 않아도 됩니다. 그렇지 않다면 조인 버퍼를 너무 부족하거나 너무 과다하게 사용되지 않게 적절히 제한해두는 것이 좋습니다. 일반적으로 온라인 웹 서비스용 MySQL 서버라면 조인 버퍼는 1MB 정도로 충분하며, 더 크게 설정해야 할 필요는 없습니다. 다음 예제 쿼리는 조인 조건이 없는 카테시안 조인을 수행하는 쿼리입니다. 이런 카테시안 조인을 수행하는 쿼리는 항상 조인 버퍼를 사용합니다.
EXPLAIN SELECT * FROM dept_emp de, employees e WHERE de.from_date > '2005-01-01' AND e.emp_no < 10904;
id | select_type | table | type | key | key_len | ref | rows | Extra1 |
1 | SIMPLE | de | range | ix_fromdate | 3 | 1 | Using where | |
1 | SIMPLE | e | range | PRIMARY | 4 |
| 1520 | Using where; |
Using sort_union(...), Using union(...), Using intersect(...)
쿼리가 Index_merge 접근 방식(실행 계획의 type 칼럼의 값이 index_merge)으로 실행되는 경우에는 2개 이상의 인덱스가 동시에 사용될 수 있습니다. 이때 실행 계획의 Extra 칼럼에는 두 인덱스로부터 읽은 결과를 어떻게 병합했는지 조금 더 상세하게 설명하기 위해 다음 3개 중에서 하나의 메시지를 선택적으로 출력합니다.
1. Using intersect(...)
각각의 인덱스를 사용할 수 있는 조건이 AND로 연결된 경우 각 처리 결과에서 교집합을 추출해내는 작업을 수행했다는 의미입니다.
2. Using union(...)
각 인덱스를 사용할 수 있는 조건이 OR로 연결된 경우 각 처리 결과에서 합집합을 추출해내는 작업을 수행했다는 의미입니다.
3. Using sort_union(...)
Using union과 같은 작업을 수행하지만 Using union으로 처리될 수 없는 경우(OR로 연결된 상대적으로 대량의 range 조건들) 이 방식으로 처리됩니다. Using sort_union과 Using union의 차이점은 Using sort_union은 프라이머리 키만 먼저 읽어서 정렬하고 병합한 후에야 비로소 레코드를 읽어서 반환할 수 있다는 것입니다.
Using union()과 Using sort_union()은 둘 다 충분히 인덱스를 사용할 수 있는 조건이 OR로 연결된 경우에 사용됩니다. Using union()은 대체로 동등 비교(Equal)처럼 일치하는 레코드 건수가 많지 않을때 사용되고, 각 조건이 크다 또는 작다와 같이 상대적으로 많은 레코드에 일치하는 조건이 사용되는 경우 Using sort_union()이 사용됩니다. 하지만 실제로는 레코드 건수에 거의 관계없이 각 WHERE 조건에 사용된 비교 조건이 모두 동등 조건이면 Using union()이 사용되며, 그렇지 않으면 Using sort_union()이 사용됩니다.
MySQL 내부적으로 이 둘의 차이는 정렬 알고리즘에서 싱글 패스 정렬 알고리즘과 투 패스 정렬 알고리즘의 차이와 같습니다. Using union()이 싱글 패스 정렬 알고리즘을 사용한다면 Using sort_union()은 투 패스 정렬 알고리즘을 사용합니다.
Using temporary
MySQL이 쿼리를 처리하는 동안 중간 결과를 담아 두기 위해 임시 테이블(Temporary table)을 사용합니다. 임시 테이블은 메모리상에 생성될 수도 있고 디스크상에 생성될 수도 있습니다. 쿼리의 실행 계획에서 Extra 칼럼에 "Using temporary" 키워드가 표시되면 임시 테이블을 사용한 것인데, 이때 사용된 임시 테이블이 메모리에 생성됐었는지 디스크에 생성됐었는지는 실행 계획만으로 판단할 수 없습니다.
EXPLAIN SELECT * FROM employees GROUP BY gender ORDER BY MIN(emp_no);
위의 쿼리는 GROUP BY 칼럼과 ORDER BY 칼럼이 다르기 때문에 임시 테이블이 필요한 작업입니다. 인덱스를 사용하지 못하는 GROUP BY 쿼리는 실행 계획에서 "Using temporary" 메시지가 표시되는 가장 대표적인 형태의 쿼리입니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | ALL |
|
|
| 300584 | Using temporary; |
실행 계획의 Extra 칼럼에 "Using temporary"가 표시되지는 않지만, 실제 내부적으로는 임시 테이블을 사용할 때도 많습니다. Extra 칼럼에 "Using temporary"가 표시되지 않았다고 해서 임시 테이블을 사용하지 않는다라고 판단하지 않도록 주의해야 합니다. 대표적으로 메모리나 디스크에 임시 테이블을 생성하는 쿼리는 다음과 같습니다.
FROM 절에 사용된 서브 쿼리는 무조건 임시 테이블을 생성합니다. 물론 이 테이블을 파생 테이블(Derived table)이라고 부르긴 하지만 결국 실체는 임시 테이블입니다.
"COUNT(DISTINCT column1)"를 포함하는 쿼리도 인덱스를 사용할 수 없는 경우에는 임시 테이블이 만들어집니다.
UNION이나 UNION ALL이 사용된 쿼리도 항상 임시 테이블을 사용해서 결과를 병합합니다.
인덱스를 사용하지 못하는 정렬 작업 또한 임시 버퍼 공간을 사용하는데, 정렬해야 할 레코드가 많아지면 결국 디스크를 사용합니다. 정렬에 사용되는 버퍼도 결국 실체는 임시 테이블과 같습니다. 쿼리가 정렬을 수행할 때는 실행 계획의 Extra 칼럼에 "Using filesort"라고 표시됩니다.
그리고 임시 테이블이나 버퍼가 메모리에 저장됐는지, 디스크에 저장됐는지는 MySQL 서버의 상태 변수 값으로 확인할 수 있습니다.
Using where
이미 MySQL의 아키텍처 부분에서 언급했듯이 MySQL은 내부적으로 크게 MySQL 엔진과 스토리지 엔진이라는 두 개의 레이어로 나눠서 볼 수 있습니다. 각 스토리지 엔진은 디스크나 메모리상에서 필요한 레코드를 읽거나 저장하는 역할을 하며, MySQL 엔진은 스토리지 엔진으로부터 받은 레코드를 가동 또는 연산하는 작업을 수행합니다. MySQL 엔진 레이어에서 별도의 가공을 해서 필터링(여과) 작업을 처리한 경우에만 Extra 칼럼에 "Using where" 코멘트가 표시됩니다.
각 스토리지 엔진에서 전체 200건의 레코드를 읽었는데, MySQL 엔진에서 별도의 필터링이나 가공 없이 그 데이터를 그대로 클라이언트로 전달하면 "Using where"가 표시되지 않습니다. "비교 조건의 종류와 효율성"에서 작업 범위 제한 조건과 체크 조건의 구분을 언급한 바 있는데, 실제로 작업 범위 제한 조건은 각 토리지 엔진 레벨에서 처리되지만 체크 조건은 MySQL 엔진 레이어에서 처리됩니다. 다음의 쿼리를 한번 살펴보겠습니다.
EXPLAIN SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 10100 AND gender='F';
이 쿼리에서 작업 범위 제한 조건은 "emp_no BETWEEN 10001 AND 10100"이며 "gender='F'"는 체크 조건임을 쉽게 알 수 있습니다. 그런데 처음의 emp_no 조건만을 만족하는 레코드 건수는 100건이지만 두 조건을 모두 만족하는 레코드는 37건밖에 안됩니다. 이는 스토리지 엔진은 100개를 읽어서 MySQL 엔진에 넘겨줬지만 MySQL 엔진은 그중 63건의 레코드를 그냥 필터링해서 버렸다는 의미입니다. 여기서 "Using where"는 63건의 레코드를 버리는 처리를 의미합니다.
id | select_type | table | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | employees | range | PRIMARY | 4 | NULL | 100 | Using where |
MySQL 실행 계획에서 Extra 칼럼에 가장 흔하게 표시되는 내용이 "Using where"입니다. 그래서 가장 쉽게 무시해버리는 메시지이기도 합니다. 실제로 왜 "Using where"가 표시됐는지 전혀 이해할 수 없을 때도 많습니다. 더욱이 MySQL 5.0에서는 프라이머리 키로 한 건이 레코드만 조회해도 "Using where"로 출력되는 버그가 있었습니다. 그래서 실행 계획의 Extra 칼럼에 표시되는 "Using where"가 성능상의 문제를 일으킬지 아닐지를 적절히 선별하는 능력이 필요한데, MySQL 5.1부터는 실행 계획에 Filtered 칼럼이 함께 표시되므로 쉽게 성능상의 이슈가 있는지 없는지를 알아낼 수 있습니다.
위의 쿼리 예제를 통해 인덱스 최적화를 조금 더 살펴보겠습니다. 위 처리 과정에서 최종적으로 쿼리에 일치하는 레코드는 37건밖에 안되지만 스토리지 엔진은 100건의 레코드를 읽은 것입니다. 상당히 비효율적인 과정이라고 볼 수 있습니다. 그런데 만약 employees 테이블에 (emp_no+gender)로 인덱스가 준비돼 있었다면 어떻게 될까요? 이때는 두 조건 모두 작업 범위의 제한 조건으로 사용되어, 필요한 37개의 레코드만 정확하게 읽을 수 있습니다. 일반적으로 Extra 칼럼에 "Using where"가 표시되는 경우에는 MySQL 엔진에서 한번 필터링 작업을 했다는 것을 의미합니다. 그리고 그와 동시에 스토리지 엔진에 쓸모 없는 일을 추가로 시켰다는 것을 의미합니다. 이는 MySQL이 스토리지 엔진과 MySQL 엔진으로 이원화된 구조 때문에 발생하는 문제점으로 볼 수 있습니다.
똑같이 MySQL 엔진과 스토리지 엔진의 이원화된 구조 탓에 발생하는 문제점을 하나 더 살펴보겠습니다.
CREATE TABLE tb_likefilter ( category INT, name VARCHAR(30), INDEX ix_category_name(category, name) ); SELECT * FROM tb_likefilter WHERE category=10 AND name LIKE '%abc%';
위 쿼리의 경우, category 칼럼과 name 칼럼이 인덱스로 생성돼 있습니다. 하지만 name LIKE '%abc%' 조건은 작업범위 제한 조건으로 사용되지 못합니다. 이처럼 작업 범위 제한 조건으로 사용되지 못하는 조건은 스토리지 엔진에서 인덱스를 통해 체크되는 것이 아니라 MySQL 엔진에서 처리됩니다. 즉, 스토리지 엔진에서는 category=10을 만족하는 모든 레코드를 읽어서 MySQL 엔진으로 넘겨주고 MySQL 엔진에서 name LIKE '%abc%' 조건 체크를 수행해서 일치하지 않는 레코드를 버리는 것입니다.
예를 들어, category=10을 만족하는 레코드가 100건, 그중에서 name LIKE '%abc%' 조건을 만족하는 레코드가 10건이라면 MySQL 엔진은 10건의 레코드를 위해 그 10배의 작업을 스토리지 엔진에 요청합니다. 상당히 불합리한 처리방식이기도 하지만 MySQL 5.0 이하의 버전에서는 피할 수 없는 문제점이었습니다. InnoDB나 MyISAM과 같은 스토리지 엔진과 MySQL 엔진은 모두 하나의 프로세스에서 동작하기 때문에 성능에 미치는 영향이 그다지 크지 않습니다. 하지만 스토리지 엔진이 MySQL 엔진 외부에서 작동하는 NDB 클러스터는 네트워크 전송 부하까지 겹치기 때문에 성능에 미치는 영향이 더 큰편입니다.
MySQL 5.1의 InnoDB 플러그인 버전부터는 이원화된 구조의 불합리를 제거하기 위해 WHERE 절의 범위 제한 조건뿐 아니라 체크 조건까지 모두 스토리지 엔진으로 전달됩니다. 스토리지 엔진에서는 그 조건에 정확히 일치하는 레코드만 읽고 MySQL 엔진으로 전달하기 때문에 이런 비효율적인 부분이 사라진 것입니다. 즉, MySQL 5.1부터는 위의 시나리오에서는 스토리지 엔진이 꼭 필요한 10건의 레코드만 읽게되는 것입니다. MySQL에서 이러한 기능을 "Condition push down"이라고 표현합니다.
Using where with pushed condition
실행 계획의 Extra 칼럼에 표시되는 "Using where with pushed condition" 메시지는 "Condition push down"이 적용됐음을 의미하는 메시지입니다. MySQL 5.1부터는 "Condition push down"이 InnoDB나 MyISAM 스토리지 엔진에도 도입되어 각 스토리지 엔진의 비효율이 상당히 개선됐다고 볼 수 있습니다.
하지만 MyISAM이나 InnoDB 스토리지 엔진을 사용하는 테이블의 실행 계획에는 "Using where with pushed condition" 메시지가 표시되지 않습니다. 이 메시지는 NDB 클러스터 스토리지 엔진을 사용하는 테이블에서만 표시되는 메시지입니다. NDB 클러스터는 MySQL 엔진의 외부에서 작동하는 스토리지 엔진이라서 스토리지 엔진으로부터 읽은 레코드는 네트워크를 통해 MySQL 엔진으로 전달됩니다. NDB 클러스터는 여러 개의 노드로 구성되는데, "SQL 노드"는 MySQL 엔진 역할을 담당하며, "데이터 노드"는 스토리지 엔진 역할을 담당합니다. 그리고 데이터 노드와 SQL 노드는 네트워크를 통해 TCP/IP 통신을 합니다. 그래서 실제 "Condition push down"이 사용되지 못하면 상당한 성능 저하가 발생할 수 있습니다.
EXPLAIN EXTENDED(Filtered 칼럼)
실행 계획의 Extra 칼럼에 표시되는 "Using where"의 의미는 앞서 설명이 되었습니다. MySQL 5.1 이상 버전이라 하더라도 스토리지 엔진에서 최종적으로 사용자에게 전달되는 레코드만 가져오는 것은 아닙니다. 조인과 같이 여러 가지 이유로 여전히 각 스토리지 엔진에서 읽어 온 레코드를 MySQL 엔진에서 필터링하는데, 이 과정에서 버려지는 레코드가 발생할 수밖에 없습니다. 하지만 MySQL 5.1.12 미만의 버전에서는 MySQL 엔진에 의해 필터링 과정을 거치면서 얼마나 많은 레코드가 버려졌고, 그래서 얼마나 남았는지를 알 방법이 없었습니다.
MySQL 5.1.12 버전부터는 필터링이 얼마나 효율적으로 실행됐느지를 사용자에게 알려주기 위해 실행 계획에 Filtered라는 칼럼이 새로 추가됐습니다. 실행 계획에서 Filtered 칼럼을 함께 조회하려면 EXPLAIN 명령 뒤에 "EXTENDED"라는 키워드를 지정하면 됩니다. "EXTENDED" 키워드가 사용된 실행 계획 예제를 한번 살펴보겠습니다.
EXPLAIN EXTENDED SELECT * FROM employees WHERE emp_no BETWEEN 10001 AND 10100 AND gender='F';
"EXPLAIN EXTENDED" 명령을 사용해 쿼리의 실행 계획을 조회하면 다음과 같이 실행 계획의 "rows" 칼럼 뒤에 "Filtered"라는 새로운 칼럼이 같이 표시됩니다.
Id | select_type | Table | type | key | key_len | ref | rows | filtered | Extra |
1 | SIMPLE | employees | range | PRIMARY | 4 | NULL | 100 | 20 | Using where |
실행 계획에서 filtered 칼럼에는 MySQL 엔진에 의해 필터링되어 제거된 레코드는 제외하고 최종적으로 레코드가 얼마나 남았는지의 비율(Percentage)이 표시됩니다. 위의 예제에서는 rows 칼럼의 값이 100건이고 filtered 칼럼의 값이 20%이므로, 스토리지 엔진이 전체 100건의 레코드를 읽어서 MySQL 엔진에 전달했는데, MySQL 엔진에 의해 필터링되고 20%만 남았다는 것을 의미합니다. 즉, MySQL 엔진에 의해 필터링되고 남은 레코드는 20건(100건 * 20%)이라는 의미입니다. 여기에 출력되는 filtered 칼럼의 정보 또한 실제 값이 아니라 단순히 통계 정보로부터 예측된 값일 뿐입니다.
EXPLAIN EXTENDED(추가 옵티마이저 정보)
EXPLAIN 명령의 EXTENDED 옵션은 숨은 기능이 하나 더 있습니다. MySQL 엔진에서 쿼리의 실행 계획을 산출하기 위해 쿼리 문장을 분석해 파스 트리를 생성합니다. 또한 일부 최적화 작업도 이 파스 트리를 이용해 수행합니다. "EXPLAIN EXTENDED" 명령의 또 다른 기능은 분석된 파스 트리를 재조합해서 쿼리 문장과 비슷한 순서대로 나열해서 보여주는 것입니다.
EXPLAIN EXTENDED 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;
EXPLAIN EXTENDED 명령을 실행하면 EXTENDED 옵션이 없을 때와 같이 쿼리의 실행 계획만 화면에 출력됩니다. 하지만 EXPLAIN EXTENDED 명령을 실행해 실행 계획이 출력된 직후, "SHOW WARNINGS" 명령을 실행하면 옵티마이저가 분석해서 다시 재조합한 쿼리 문장을 다음과 같이 확인 할 수 있습니다.
mysql> SHOW WARNINGS;
SELECT 'Georgi' AS 'first_name',
(SELECT COUNT(0)
FROM 'employees'.'dept_emp' 'de'
JOIN 'employees'.'dept_manager' 'dm'
WHERE ('employees'.'de'.'dept_no' = 'employees') AS 'cnt'
FROM 'employees'.'employees' 'e' WHERE 1)
SHOW WARNINGS 명령으로 출력된 내용은 표준 SQL 문장이 아닙니다. 지금의 예제는 상당히 비슷하게 출력됐지만 최적화 정보가 태그 형태로 포함된 것들도 있으며 쉽게 알아보기는 어려운 경우도 많습니다. 위의 예제에서는 COUNT(*)가 내부적으로는 COUNT(0)으로 변환되어 처리된다는 것과 emp_no=10001 조건을 옵티마이저가 미리 실행해서 상수화된 값으로 'Georgi'가 사용됐다는 것도 알 수 있습니다.
EXPLAIN EXTENDED 명령을 이용해 옵티마이저가 쿼리를 어떻게 해석했고, 어떻게 쿼리를 변환했으며, 어떤 특수한 처리가 수행됐는지 등을 판단할 수 있으므로 알아두면 도움이 될 것입니다.
EXPLAIN PARTITIONS(Partitions 칼럼)
EXPLAIN 명령에 사용할 수 있는 옵션이 또 하나 있는데, 이 옵션으로 파티션 테이블의 실행 계획 정보를 더 자세히 확인할 수 있습니다. 단순히 EXPLAIN 명령으로는 파티션 테이블이 어떻게 사용됐느닞 확인할 수 없습니다. 하지만 EXPLAIN 명령 뒤에 PARTITIONS 옵션을 사용하면 쿼리를 실행하기 위해 테이블의 파티션 중에서 어떤 파티션을 사용했는지 등의 정보를 조회할 수 있습니다.
CREATE TABLE tb_partition ( reg_date DATE DEFAULT NULL, id INT DEFAULT NULL, name VARCHAR(50) DEFAULT NULL ) ENGINE=INNODB partition BY range (YEAR(reg_date)) ( partition p0 VALUES less than (2008) ENGINE = INNODB, partition p1 VALUES less than (2009) ENGINE = INNODB, partition p2 VALUES less than (2010) ENGINE = INNODB, partition p3 VALUES less than (2011) ENGINE = INNODB ); EXPLAIN PARTITIONS SELECT * FROM tb_partition WHERE reg_date BETWEEN '2010-01-01' AND '2010-12-30';
위 예제의 tb_partition 테이블은 reg_date 칼럼의 값을 이용해 년도별로 구분된 파티션 4개를 가집니다. 그리고 이 테이블에서 reg_date 칼럼의 값이 "2010-01-01"부터 "2010-12-30"까지의 레코드를 조회하는 쿼리에 대해 실행 계획을 확인해 보겠습니다. 이 쿼리에서 조회하려는 데이터는 모두 2010년도 데이터이고 3번째 파티션인 p3에 저장돼 있음을 알 수 있습니다. 실제로 옵티마이저는 이 쿼리를 처리하기 위해 p3 파티션만 읽으면 된다는 것을 알아채고, 그 파티션에만 접근하도록 실행 계획을 수립합니다. 이처럼 파티션이 여러 개인 테이블에서 불필요한 파티션을 빼고 쿼리를 수행하기 위해 접근해야 할 것으로 판단되는 테이블만 골라내는 과정을 파티션 프루닝(Partition pruning)이라고 합니다.
그렇다면 이 쿼리의 실행 계획이 정말 꼭 필요한 p3 파티션만 읽는지 확인해 볼 수 있어야 쿼리의 튜닝이 가능할 것입니다. 이때 옵티마이저가 이 쿼리를 실행하기 위해 접근하는 테이블을 확인해 볼 수 있는 명령이 EXPLAING PARTITIONS입니다. EXPLAIN PRTITIONS 명령으로 출력된 실행 계획에는 partitions라는 새로운 칼럼을 포함해서 표시합니다. Partitions 칼럼에는 이 쿼리가 사용한 파티션 목록이 출력되는데, 예상했던 대로 p3 파티션만 참조했음을 알 수 있습니다.
Id | select_type | Table | partitions | type | key | key_len | ref | rows | Extra |
1 | SIMPLE | tb_partition | p3 | ALL |
|
|
| 2 | Using where |
EXPLAIN PARTITIONS 명령은 파티션 테이블에 실행되는 쿼리가 얼마나 파티션 기능을 잘 활용하고 있는지를 판단할 수 있는 자료를 제공합니다. EXPLAIN 명령에서는 EXTENDED와 PARTITIONS 옵션을 함께 사용할 수 없습니다.
TO_DAYS() 함수는 입력된 날짜 값의 포맷이 잘못돼 있다면 NULL을 반환할 수도 있습니다. 이렇게 MySQL의 파티션 키가 TO_DAYS()와 같이 NULL을 반환할 수 있는 함수를 사용할 때는 쿼리의 실행 계획에서 partitions 칼럼에 테이블의 첫 번째 파티션이 포함되기도 합니다. 레인지 파티션을 사용하는 테이블에서 NULL은 항상 첫 번째 파티션에 저장되기 때문에 실행 계획의 partitions 칼럼에 첫 번째 파티션도 함께 포함되는 것입니다. 하지만 이렇게 실제 필요한 파티션과 테이블의 첫 번째 파티션이 함께 partitions 칼럼에 표시된다 하더라도 성능 이슈는 없으므로 크게 걱정하지 않아도 됩니다.
'데이터베이스(DA, AA, TA) > MySQL' 카테고리의 다른 글
2017.06.20 |
2017.06.15 |
2017.06.12 |
2017.06.07 |
2017.06.07 |
2017.06.02 |
출처: https://12bme.tistory.com/160 [길은 가면, 뒤에 있다.]
출처: https://12bme.tistory.com/160 [길은 가면, 뒤에 있다.]
'데이터베이스 이야기 > MySQL' 카테고리의 다른 글
[MySQL] 차집합 (0) | 2020.04.10 |
---|---|
[MySQL] 인덱스 생성 조건 (0) | 2020.04.10 |
[MySQL] MySQL 쓰면서 하지 말아야 할 것 17가지 (0) | 2020.04.10 |
[MySQL] MySQL 중복 키 관리 방법 (INSERT 시 중복 키 관리 방법 (INSERT IGNORE, REPLACE INTO, ON DUPLICATE UPDATE) (0) | 2020.04.10 |
[MySql] 인라인 뷰 (0) | 2019.05.15 |
감사합니다^^
많은공부가되었습니다ㅜㅜ
댓글쓰기위해 회원가입까지했어요..
좋은글에 댓글이 없다는게 너무아쉬워서요 ^^
와, 훌륭한 글 정말 정말 감사합니다.
회사 업무로 DB를 튜닝하는 데 큰 도움이 되었습니다. 감사합니다.
이 내용은 이성욱님이 작성하신 위키북스의 Real Mysql(http://www.yes24.com/Product/Goods/6960931) 책의 실행계획 챕터(p.117 ~ )를 그대로 가져오셨는데 매 글마다 출처를 남겨야 하지 않을까 조심스레 댓글 남깁니다.
가져오신 분량도 너무 많으셔서 저작권에 위배될수도 있을것 같아요 :)