쿼리 튜닝 학습 목적으로 전체 내용은
링크된 업무에 바로 쓰는 SQL 튜닝 - 한빛미디어 도서를 참고하여
글을 작성하였습니다
(1) 사용자가 요청합니다 (SQL을 실행합니다)
(2) 파서(parsor)가 구문을 해석합니다 (SQL을 최소 단위로 분리, 구성 요소를 트리로 작성합니다)
(3) 전처리기(preprocessor)가 실행됩니다 (트리의 구성요소로 권한/존재 여부 확인)
(4) 옵티마이저 (optimizer) (엔진 실행기에 실행계획을 전달)
(5) 엔진 실행기 (engine executor) (스토리지 엔진을 이용해 data file을 가져옵니다)
(6) 가져온 데이터의 최종결과를 사용자에게 전달합니다
쿼리 안의 보조쿼리입니다
가장 바깥쪽 SELECT 문의 쿼리입니다
SELECT ( SELECT ... FROM ...) # SELECT 절 : 스칼라 서브쿼리
FROM ( SELECT ... FROM ...) # FROM 절 : 인라인 뷰
WHERE 컬럼명 IN (SELECT... FROM ...) # WHERE 절 : 중첩 서브쿼리
출력되는 데이터 건수가 1건이어야 하므로 집계함수가 자주 사용됩니다
SELECT 이름,
(SELECT COUNT(*)
FROM 학생 AS 학생2
WHERE 학생2.이름 = 학생1.이름) 카운트
FROM 학생 AS 학생1;
메인쿼리의 FROM 절에 있는 또 다른 SELECT 절이 인라인 뷰입니다.
FROM 절 내부에서 일시적으로 뷰를 생성하는 방식이므로 인라인 뷰라고 부릅니다
인라인 뷰의 결과는 내부적으로 메모리 또는 디스크에 임시 테이블을 생성하여 활용합니다
SELECT 학생2.학번, 학생2.이름
FROM (SELECT *
FROM 학생
WHERE 성별 = '남') 학생2;
메인 쿼리의 WHERE 절에 있는 또 다른 SELECT 절을 중첩 서브쿼리입니다
WHERE 절에서 단순히 값을 비교 연산하는 대신, 서브쿼리를 추가하여 비교 연산하기 위해 중첩 서브쿼리를 사용합니다
WHERE 절에서 중첩 서브쿼리와 비교할 때는 보통 (=, <, >, ≤, ≥, <>,≠를 비롯해 IN, EXISTS, NOT INm NOT EXISTS문을 많이 사용합니다
SELECT 학생2.학번, 학생2.이름
FROM (SELECT *
FROM 학생
WHERE 성별 = '남') 학생2
메인쿼리와 서브쿼리 간에 관계성이 있는 것을 의미합니다
서브쿼리가 수행되려면 메인쿼리 값을 받아야 합니다. 서브와 메인쿼리는 끈끈한 관계를 유지합니다
SELECT ...
FROM 학생
WHERE ... IN (SELECT ...
FROM 지도교수
WHERE 학생.학번 = ...)
메인 쿼리에서 학생 테이블의 학번 결과를 서브쿼리로 전달한 뒤 지도교수 테이블의 학번과 비교 (지도교수.학번 = 학생.학번)합니다. 이렇게 도출된 서브쿼리의 학번 결과를 메인쿼리의 학번과 비교해 최종 결과를 출력합니다
수행순서
1) 메인쿼리 실행(학생.학번 데이터 가져오기)
2) 서브쿼리 실행(지도교수.학번 = 학생. 학번)
3) 다시 메인쿼리 실행한 뒤 결과 출력 (SELECT * FROM 학생~)
SELECT ...
FROM 학생
WHERE ... IN (SELECT ... # 메인쿼리 = 외부쿼리와 서브쿼리 = 내부쿼리 연관성이 없습니다
FROM 지도교수 )
메인쿼리와 서브쿼리의 독립적인 관계
비상관 서브쿼리가 먼저 실행되고 그 결과를 메인쿼리가 활용합니다. 즉, 서브쿼리 실행 → 메인쿼리 실행 순서입니다
SELECT ...
FROM ...
WHERE 학번 = 서브쿼리 (SELECT MAX(학번) FROM 학생) #출력값이 하나인 서브쿼리
서브쿼리 결과가 항상 하나의 값만 반환됩니다
SELECT ...
FROM ...
WHERE 학번 IN 서브쿼리 (SELECT MAX(학번) FROM 학생 GROUP BY 전공코드) # 출력값이 두개 이상인 서브쿼리
서브쿼리 결과가 여러 건의 행으로 반환되는 쿼리입니다
학생 테이블에서 전공코드별 학번 최댓값을 반환하므로 전공 코드의 종류만큼 학번의 최댓값들이 반환됩니다. 이후 메인 쿼리가 수행되고 최종 결과가 나옵니다
서브쿼리 결과가 여러개의 행으로 나오는 것을 의미합니다
출력값이 2개 이상인 서브쿼리
이름 # 컬럼
홍ㅇㅇ # 결과 행1
김ㅇㅇ # 결과 행2
서브쿼리 결과가 여러 개의 열과 행으로 반환됩니다
출력값으로 2개 이상의 컬럼을 출력하는 서브쿼리
이름 | 나이 | 성별 # 컬럼
홍ㅇㅇ | 20 | 남 # 결과 행과 열1
김ㅇㅇ | 25 | 여 # 결과 행과 열2
양쪽 테이블에 같은 데이터가 있는 데이터만 해당됩니다
왼쪽 외부조인(LEFT OUTER JOIN table ON)은 왼쪽 테이블에만 있는
오른쪽 외부조인(RIGHT OUTER JOIN table ON)은 오른쪽 테이블에만 있는 데이터만 해당됩니다
드라이빙 테이블의 데이터 1건당 드리븐 테이블을 반복해서 검색하며 최종적으로 양쪽 테이블에 공통된 데이터를 출력합니다
SELECT 학생.학번, 학생.이름,
비상연락망.관계, 비상연락망.연락처
FROM 학생
JOIN 비상연락망
ON 학생.학번 = 비상연락망.학번
WHERE 학생.학번 IN (1, 100)
극단적인 중첩루프 예시입니다
학번 1과 100에 해당하는 학생들의 ‘학생’ 테이블 정보를 가져옵니다
가져온 학생 테이블 정보 1건 당 ‘비상연락망’ 테이블의 데이터 10,000건에 모두 접근합니다
이 경우,
학번 1 데이터를 찾기 위해 100 + 10,000건
학번 100 데이터를 찾기 위해 100 + 10,000건
총 22,000 건의 데이터를 접근해야 합니다
인덱스를 이용해 테이블의 데이터를 찾아가는 과정에서 임의 접근 방식인 랜덤 액세스가 발생합니다
따라서, 랜덤 액세스를 줄일 수 있도록 데이터 액세스 범위를 좁혀지도록 인덱스를 설계하고 조건절을 작성해야 합니다
기본 키는 인덱스가 순서대로 되어 있어 조회 효율이 높습니다
중첩 루프 조인의 효율성을 높이기 위해 등장했습니다
드라이빙 테이블에 조인 버퍼(join buffer) 개념을 도입하여 성능을 향상시킵니다
학생 인덱스 1, 100 → 조인버퍼(메모리) 0 → 2, 3, 4, 5
[학생테이블] [비상연락망 테이블]
학번 | 이름 학번 | 관계 | 연락처
1 | 정ㅇㅇ ----> 조인버퍼 : 0 <--- 2 1 | 어머니 | ...
2 | 김ㅇㅇ 0 <--- 3 1 | 아버지 | ...
0 <--- 4 2 | 어머니 | ...
조각 테이블의 데이터를 내부적으로 해시값으로 만들어 내부 조인을 수행합니다
해시값으로 내부 조인을 수행한 결과는 조인 버퍼에 저장되므로 조인 열의 인덱스를 필수로 요구하지 않습니다
[학생테이블] [비상연락망테이블]
학생 | 이름 -----> 조인 버퍼 <------ 학번 | 관계 | 연락처
1 | 김ㅇㅇ data1 | Hash#1 1 | 어머니 | ...
data1 | Hash#2 1 | 아버지 | ...
2 | 홍ㅇㅇ data2 | Hash#3 2 | 어머니 | ...
3 | 이ㅇㅇ data3 | Hash#4 3 | 아버지 | ...
100 | 금ㅇㅇ data100 | Hash#n 100 | 어머니 | ...
인덱스를 거치지 않고 테이블로 바로 접근하여 처음부터 끝까지 훑는 방식입니다
인덱스를 범위 기준으로 테이블의 데이터를 찾아가는 방식입니다z
SQL 문에서 BETWEEN ~ AND 구문이나 <, >, LIKE 구문 등 비교 연산 및 구문이 포함된 경우 인덱스 범위 스캔으로 수행합니다
좁은 범위 조회시 효율적이나 넓은 범위 스캔시에는 비효율적입니다
인덱스를 기준으로 처음부터 끝가지 수행하는 방법입니다
기본 키나 고유 인덱스로 테이블에 접근하는 방식입니다
인덱스를 사용하는 스캔 방식 중 가장 효율적인 스캔 방식입니다
인덱스의 필요한 부분만 골라 스캔하는 방식입니다
WHERE 절 조건문 기준으로 필요한 데이터와 필요하지 않은 데이터를 구분한 뒤 불필요한 인덱스 키는 무시합니다
GROUP BY 구문이나 MAX(), MIN() 함수가 포함되면 작동합니다
테이블 내 생성된 인덱스들을 통합해서 스캔하는 방식입니다
WHERE문 조건절의 열들이 서로 다른 인덱스로 존재하며 옵티마이저가 해당하는 인덱스를 가져와 모두 활용하는 방식입니다. 통합 방법으로는 결합과 교차 방식이 있으며 실행 계획으로 출력됩니다
튜닝방법 : 별개로 생성된 인덱스들은 보통 하나의 인덱스로 통합하여 SQL을 튜닝하거나 SQL 문 자체를 하나의 인덱스만 수행하도록 변경할 수 있습니다
WHERE 절의 특정 조건문을 이용해 소량의 데이터를 가져오고
인덱스를 통해 조건절을 선택합니다
SELECT *
FROM TAB
WHERE ID = 1
AND CODE = 'A';
ID = 1 로 일부 데이터에만 접근합니다
[튜닝전]
SELECT *
FROM 사원
WHERE SUBSTRING (사원번호, 1, 4) = 1100
AND LENGTH(사원번호) = 5;
사원 번호는 인덱스키 입니다
사원번호를 그래로 쓰는 대신 SUBSTRING, LENGTH와 같이 가공하여 작성했기 때문에
기본 키를 사용하지 않고 테이블 풀 스캔을 수행합니다
[튜닝후]
SELECT *
FROM 사원
WHERE 사원번호 BETWEEN 11000 AND 11009
범위 검색을 수행하고 비교연산자를 사용하였습니다
그 결과, 사원번호가 변형되지 않아 기본키나 인덱스를 활용할 수 있게 되었습니다
[튜닝전]
SELECT IFNULL(성별, 'NO DATA') AS 성별, COUNT(1) 건수
FROM 사원
GROUP BY IFNULL(성별, 'NO DATA')
Key 항목이 1_성별_성 인덱스로 나타내어 인덱스 풀 스캔 방식으로 수행됩니다
Extra 항목이 ‘Using temporary(임시 테이블)’ 이므로 임시 테이블을 생성합니다
[튜닝후]
SELECT 성별, COUNT(1) 건수
FROM 사원
GROUP BY 성별
Extra 항목이 ‘Using index’ 이므로 임시 테이블 없이 인덱스만 사용하여 데이터를 추출합니다
IFNULL() 함수를 처리하려고 DB 내부적으로 별도 임시 테이블을 만들어 NULL 여부를 검사할 필요가 없습니다. 즉, 성별 열에 NULL 값이 존재할 수 없어 불필요한 로직은 튜닝 대상입니다
[튜닝전]
SELECT 사용여부, COUNT(1)
FROM 급여
GROUP BY 사용여부 = 1
문자 유형인 사용여부를 숫자 유형 1로 써서 DBMS 내부에서 묵시적 형변환이 발생하였습니다
이 경우, 인덱스를 활용하지 못하고 전체 데이터를 스캔하게 됩니다
[튜닝후]
SELECT 사용여부, COUNT(1)
FROM 급여
WHERE 사용여부 = '1'
[튜닝전]
SELECT *
FROM 사원
WHERE CONCAT(성별, ' ' , 성) = 'M Radwan'
결합한 결과로 데이터로 조회시 인덱스 풀스캔을 합니다
[튜닝후]
SELECT *
FROM 사원
WHERE 성별 = 'M'
AND 성 = 'Radwan'
[튜닝전]
SELECT DISTINCT 사원.사원번호, 사원.이름, 사원.성
FROM 사원
JOIN 부서관리자
ON (사원.사원번호 = 부서관리자.사원번호)
DISTINCT 구문을 사용하여 중복 제거하여 조회하는 쿼리입니다
DISTINCT 사용시 실행계획에서 임시 테이블(Using temporary) 생성되는 것을 확인할 수 있습니다
[튜닝후]
SELECT 사원.사원번호, 사원.이름, 사원.성
FROM 사원
JOIN 부서관리자
ON (사원.사원번호 = 부서관리자.사원번호)
중복된 데이터가 없다면 DISTINCT를 제거해 줍니다
[튜닝전]
SELECT 'M' AS 성별, 사원번호
FROM 사원
WHERE 성별 = 'M'
AND 성 = 'Babe'
UNION
SELECT 'F', 사원번호
FROM 사원
WHERE 성별 = 'F'
AND 성 = 'Babe'
SELECT 1, SELECT 2 각 결과 id(Using Index)를 통합하여 중복을 제거합니다
이때, 임시 테이블을 생성하고 내부에서 각 결과의 UNION 작업이 수행됩니다
id 1과 id 2 행의 결과량이 많으면 메모리가 아닌 디스크에 임시 파일을 생성하여 UNION 작업을 수행합니다
실행계획
Extra Using index
Extra Using index
Extra Using temporary
[튜닝후]
SELECT 'M' AS 성별, 사원번호
FROM 사원
WHERE 성별 = 'M'
AND 성 = 'Babe'
UNION ALL
SELECT 'F' AS 성별, 사원번호
FROM 사원
WHERE 성별 = 'F'
AND 성 = 'Babe'
튜닝전 SQL문에서 첫번째 SELECT 문과 두번째 SELECT 문의 결과 데이터가 중복되지 않으므로 중복을 제거하는 작업이 필요하지 않습니;다
따라서 임시 테이블을 만들고 정렬한 뒤, 중복을 제거하는 UNION 연산자 대신 결괏값을 단순히 합치는 UNION ALL 연산자로 변경해주어야 합니다
[튜닝전]
SELECT 성, 성별, COUNT(1) as 카운트
FROM 사원
GROUP BY 성, 성별
성과 성별 순서로 그루핑하여 데이터 건 수를 구하는 쿼리입니다
기존 성별_성 순서로 구성된 인덱스를
성_성별 순서로 재구성 하기 위해 임시테이블을 생성하게 됩니다
[튜닝후]
SELECT 성, 성별, COUNT(1) as 카운트
FROM 사원
GROUP BY 성별, 성
이미 존재하는 성별_성 인덱스를 최대한 활용하기 위해
성별, 성 순서로 그루핑하였습니다. 그럼 별도 임시테이블을 생성하지 않고도 그루핑과 카운트 연산 수행이 가능합니다
[튜닝전]
SELECT 사원번호
FROM 사원 USE INDEX(I_입사일자)
WHERE 입사일자 LIKE '1989%'
AND 사원번호 > 100000;
USE INDEX 힌트를 설정한 경우 인덱스로 테이블을 스캔하지만
인덱스 루스 스캔 (Using index for skip scan) 방식에 의해 인덱스를 스킵(skip)하는 오버헤드가 발생할 수 있습니다
[튜닝후]
SELECT 사원번호
FROM 사원
WHERE 입사일자 >= '1989-01-01' AND 입사일자 < '1990-01-01'
AND 사원번호 > 100000;
기존 입사일자 LIKE ‘1989%’ 조건절을
입사일자 ≥ ‘1989-01-01’ AND 입사일자 < ‘1990-01-01’ 같이 변경했습니다
LIKE 절보다 부등호 조건절이 우선하여 인덱스를 사용하므로 데이터 접근 범위가 줄어듭니다
[튜닝전]
SELECT *
FROM 사원출입기록
WHERE 출입문 = 'B';
출입문 B는 총 66만 건의 전체 데이터를 조회하고 중 30만 건을 차지하고 있습니다
이때 실행계획은 출입문 인덱스로 인덱스를 스캔합니다
여기서 전체의 50% 데이터를 조회하기 위해 인덱스를 활용하는게 맞는지 고민해야 합니다
[튜닝후]
SELECT *
FROM 사원출입기록 IGNORE INDEX(I_출입문)
WHERE 출입문 = 'B'
옵티마이저 로직에 따라 인덱스를 활용하는 방식으로 수행합니다. 사실 내부 옵티마이저 내부 알고리즘은 완벽하지 않습니다
내가 의도한 대로 SQL문이 수행이 되지 않는다면 IGNORE INDEX를 힌트로
인덱스를 무시하고 조회하도록 괄호 안에 직접 작성할 수 있습니다
튜닝후 수행결과는 총 30만 건만 조회합니다
[튜닝전]
입사일자 인덱스로 범위 스캔을 수행합니다
Using index condition로 스토리지 엔진에서 입사일자 조건절로 인덱스 스캔을 수행합니다
SELECT 이름, 성
FROM 사원
WHERE 입사일자 BETWEEN STR_TO_DATE('1994-001-01', '%Y-%m-%d')
AND STR_TO_DATE('2000-12-31', '%Y-%m-d')
[튜닝후]
SELECT 이름, 성
FROM 사원
WHERE YEAR(입사일자) BETWEEN '1994' AND '2000'
입사일자 열로 생성한 인덱스를 사용하지 않게 의도적으로 인덱스 열을 변형한 쿼리입니다.
WHERE 조건절을 YEAR로 가공하여 입사일자 인덱스를 사용하지 못하도록 했습니다
사원 테이블 풀스캔 방식으로 데이터에 접근합니다
인덱스 없이 테이블에 직접 접근하며 한 번에 다수의 페이지에 접근하여 더 효율적입니다
[튜닝전]
SELECT 매핑.사원번호,
부서.부서번호
FROM 부서사원_매핑 매핑,
부서
WHERE 매핑.부서번호 = 부서.부서번호
AND 매핑.시작일자 >= '2002-03-01
부서사원_매핑 테이블은 중첩 루프 조인을 수행합니다
작은 크기의 부서 테이블에서 부서.부서번호 열만 SELECT, WHERE 절에 필요하므로 부서명 인덱스를 활용해 인덱스 풀 스캔을 합니다
상대적으로 큰 부서사원_매핑 테이블은 부서번호 인덱스로 인덱스 스캔을 수행합니다. 인덱스 스캔을 하고 랜덤 액세스로 테이블에 접근하게 됩니다
대량의 데이터에 대해 랜덤 액세스하면 비효율 적입니다
[튜닝후]
SELECT STRAIGHT JOIN
매핑.사원번호,
부서.부서번호
FROM 부서사원_매핑 매핑, 부서
WHERE 매핑.부서번호 = 부서.부서번호
AND 매핑.시작일자 >= '2002-03-01'
부서사원_매핑 테이블은 랜덤 액세스 없이 테이블 풀 스캔으로 한 번에 다수의 페이지에 접근합니다.
그리고 테이블에서 추출된 데이터만큼 반복하여 부서 테이블에 접근하게 됩니다
정리하면, 상대적으로 대용량인 부서사원_매핑 테이블을 테이블 풀 스캔으로 처리하고, 부서 테이블에는 기본 키로 반복 접근하여 1개의 데이터에만 접근하는 식으로 수행합니다
[튜닝전]
SELECT 사원.사원번호, 사원.이름, 사원.성
FROM 사원
WHERE 사원번호 > 450000
AND ( SELECT MAX(연봉)
FROM 급여
WHERE 사워번호 = 사원. 사원번호
) > 100000
사원번호 추출 대상을 정의하고, 중첩 서브쿼리의 급여 테이블에서 메인 테이블의 사원번호를 받아와 사원의 최대 연봉을 확인합니다
사원 테이블에는 30만 건의 데이터가 있습니다. 급여 테이블은 50만 건의 데이터가 있습니다.
사원 번호가 450000를 초과하는 데이터는 5만 건으로 전체 데이터의 약 15%입니다
튜닝 대상 SQL문에 사용하는 인덱스는 모두 기본키입니다
사원 테이블은 사원번호 열로 구성된 기본 키를 사용하고, 급여 테이블은 사원번호와 시작일자로 구성된 기본 키를 사용합니다
[튜닝후]
SELECT 사원.사원번호,
사원.이름,
사원.성,
FROM 사원,
급여
WHERE 사원.사원번호 > 450000
AND 사원.사원번호 = 급여.사원번호
GROUP BY 사원.사원번호
HAVING MAX(급여.연봉) > 100000
그룹별 최댓값을 계산하도록 개선합니다.
GROUP BY 절은 사원번호별 그루핑을 수행하고
HAVING 절에서는 연봉의 최댓값으로 조건을 설정하여 원하는 데이터를 조회합니다
테이블에 접근하기 위한 범위 축소 조건은 사원.사원번호 > 450000 절을 통한
급여.사원번호 > 450000 조건절로 변형되어 적용됩니다. 이는 옵티마이저에 의해 SQL문이 재작성된 부분으로 수행됩니다
튜닝전 서브쿼리 방식은 제거되고 사원, 급여 테이블을 단순히 조인하는 방식으로 변경되었습니다
[튜닝전]
SELECT COUNT(DISTINCT 사원.사원번호) as 데이터건수
FROM 사원,
(SELECT 사원번호
FROM 사원출입기록 기록
WHERE 출입문 = 'A'
) 기록
WHERE 사원.사원번호 = 기록.사원번호
사원출입기록 테이블은 인덱스를 활용하여 A 출입문 기록이 있는 사원번호를 구합니다
‘A’인 상수와 직접 비교하므로 ref 항목이 const로 출력됩니다
인덱스를 사용한 동등(=) 비교를 수행하므로 type 항목이 ref로 표시됩니다
사원 테이블은 기본키를 사용해서 조인 조건절인 사원번호 열로 데이터를 비교합니다.
[튜닝후]
SELECT COUNT(1) as 데이터건수
FROM 사원
WHERE EXISTS (SELECT 1
FROM 사원출입기록 기록
WHERE 출입문 = 'A'
AND 기록.사원번호 = 사원.사원번호)
사원출입기록 테이블의 데이터는 최종 결과에 사용하지 않고 단지 존재 여부만 파악하면 되므로 EXIST 구문으로 변경합니다
먼저, 15만 건의 사원 테이블 데이터 집계 결과가 출력됩니다
서브쿼리는 사원출력기록 테이블에서 EXIST 연산자로 데이터 존재 여부 파악을 위해 임시 테이블을 생성하는 MATERIALIZED로 표기됩니다
4장에서는 기본적인 SQL 튜닝 방법과 예시를 통해 학습할 수 있었습니다
5장에서는 실무에 활용하면 조금 더 도움이 될 수 있는 내용이 포함되어 있습니다
대규모 데이터 부하 분산 위한 계측 방법 (병목 현상, Load Average, vmstat) (1) | 2024.01.14 |
---|---|
단방향 연관관계 (객체 참조, 테이블 외래키) 방법 (1) | 2024.01.13 |
RabbitMQ 메시지 브로커 이해하기 (0) | 2021.10.27 |
Kafka 이벤트 스트리밍 이해하기 (0) | 2021.10.24 |
Database, DBMS (0) | 2020.03.11 |
댓글 영역