인덱스를 사용한다는 것
- 인덱스 컬럼(정확히는 선두 컬럼)을 가공하지 않아야 인덱스를 정상적으로 사용 가능
- 인덱스를 정상적으로 사용한다는 것은 리프 블록에서 스캔 시작점을 찾아 거기서부터 스캔하다 중간에 멈추는 것
- 즉, 리프 블록 일부만 스캔하는 Index Range Scan
- 인덱스 컬럼을 가공해도 인덱스를 사용할 수 있지만, 스캔 시작점을 찾을 수 없고 멈출 수 없어 리프 블록 전체를 스캔
- Index Full Scan
인덱스를 Range Scan 할 수 없는 이유
- 인덱스 컬럼을 가공했을 때, 인덱스 스캔 시작점을 찾을 수 없음
-- 인덱스에는 가공되지 않은 값이 저장돼 있는데, 가공된 값을 기준으로 검색하기 위한 스캔 시작점과 종료점을 찾을 수 없음
WHERE SUBSTR(생년월일, 5, 2) = '05'
WHERE NVL(주문수량, 0) < 100
-- '대한'을 포함하는 값은 전체 구간에 걸쳐 흩어져 있음
WHERE 업체명 LIKE '%대한%'
-- 수직적 탐색을 통해 전화번호가 '01012345678' 이거나 고객명이 '홍길동'인 어느 한 시작지점을 바로 찾을 수 없음
WHERE (전화번호 = :tel_no OR 고객명 = :cust_nm)
OR Expansion
- 아래와 같이 하면 고객명, 전화번호 인덱스 각각에 대해 Index Range Scan 가능
SELECT *
FROM 고객
WHERE 고객명 = :cust_nm -- 고객명이 선두 컬럼인 인덱스 Range Scan
UNION ALL
SELECT *
FROM 고객
WHERE 전화번호 = :tel_no -- 전화번호가 선두 컬럼인 인덳 ㅡRange Scan
AND (고객명 <> :cust_nm OR 고객명 IS NULL)
SELECT /*+ use_concat */ *
FROM 고객
WHERE (전화번호 = :tel_no OR 고객명 = :cust_nm)
- OR 조건식을 옵티마이저가 위와 같은 형태로 변환할 수 있는데, 이를 OR Expansion이라 함
- IN 조건 역시 OR조건을 표현하는 다른 방식일 뿐이라 수직적 탐색으로 시작점을 찾을 수 없음
WHERE 전화번호 in (:tel_no1, :tel_no2)
-- 아래처럼 하면 가능
SELECT *
FROM 고객
WHERE 전화번호 = :tel_no1
UNION ALL
SELECT *
FROM 고객
WHERE 전화번호 = :tel_no2
- IN 조건절에서는 SQL 옵티마이저가 IN-List Iterator 방식 사용
- IN-List 개수만큼 Index Range Scan을 반복하는 것
- SQL을 UNION ALL 방식으로 변환한 것과 같은 효과
더 중요한 인덱스 사용 조건
- 인덱스를 Range Scan 하기 위한 가장 첫 번째 조건은 인덱스 선두 컬럼이 조건절에 있어야 한다
- 가공하지 않은 상태로
-- 소속팀 + 사원명 + 연령
-- 선두 컬럼이 없어서 인덱스 못탐
SELECT *
FROM 사원
WHERE 사원명 = '홍길동'
-- 기준연도 + 과세구분코드 + 보고회차 + 실명확인번호
-- 인덱스 컬럼을 가공했으나, 선두 컬럼이 가공되지 않은 상태로 조건절에 있음
-- 인덱스 스캔 가능
SELECT *
FROM TBL
WHERE 기준연도 = :stdr_year
AND SUBSTR(과세구분코드, 1, 4) = :txtn_dcd
AND 보고회차 = :rpt_tmrd
AND 실명확인번호 = :rnm_cnfm_no
- 단, 인덱스를 Range Scan 한다고 해서 항상 성능이 좋은 건 아니다
인덱스 잘 타니까 튜닝 끝?
Execution Plan
------------------------------------------------------
0 SELECT STATEMENT Optimizer=ALL_ROWS
1 0 TABLE ACCESS (BY INDEX ROWID) OF
2 1 INDEX (RANGE SCAN) OF '주문상품_N1' (INDEX)
------------------------------------------------------
- 주문상품 N1 인덱스는 (주문일자 + 상품번호)순으로 구성됐고, 테이블에 하루 평균 1M건의 데이터가 쌓일 때
SELECT *
FROM 주문상품
WHERE 주문일자 = :ord_dt AND 상품번호 LIKE '%PING%';
SELECT *
FROM 주문상품
WHERE 주문일자 = :ord_dt AND SUBSTR(상품번호, 1, 4) = 'PING'
- 위 SQL에서 상품번호는 스캔 범위를 줄이는 데 역할을 전혀 못하고 있음
- 첫 번째 SQL은 중간 값 검색
- 두 번째 SQL은 인덱스 컬럼을 가공
- 위 조건절을 처리할 때, 인덱스에서 스캔하는 데이터량은 주문일자 조건을 만족하는 1M건
- AND 뒤에 조건들은 있으나 없으나 스캔량을 줄여주지 못함
인덱스를 이용한 소트 연산 생략
- 인덱스를 Range Scan 할 수 있는 이유는 데이터가 정렬돼 있기 때문
- 이를 통해 소트 연산 생략 효과도 부수적으로 나타남
- PK가 (장비번호 + 변경일자 + 변경순번)인 테이블의 경우
- PK 인덱스에서 장비번호, 변경일자가 같은 레코드는 변경순번 순으로 정렬돼 있음
-- 변경순번 순으로 출력
SELECT *
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
-- 옵티마이저는 ORDER BY가 있어도 정렬 연산을 따로 수행하지 않음
-- PK 인덱스를 스캔하면서 출력한 결과집합이 어차피 변경순번 순으로 정렬되기 때문
SELECT *
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
ORDER BY 변경순번
- 정렬 연산을 생략할 수 있게 인덱스가 구성돼 있지 않다면, SORT ORDER BY 연산 단계가 추가됨
- 내림차순(Desc) 정렬의 경우
- 인덱스 리프 블록은 양방향 연결 리스트
- 오름차순(Asc) 정렬일 경우, 조건을 만족하는 가장 작은 값을 찾아 좌측으로 수직적 탐색한 후 우측으로 수평적 탐색
- 내림차순 정렬일 경우, 조건을 만족하는 가장 큰 값을 찾아 우측에서 수직적 탐색한 후 좌측으로 수평적 탐색
- 인덱스 리프 블록은 양방향 연결 리스트
ORDER BY 절에서 컬럼 가공
- 조건절이 아닌 ORDER BY 또는 SELECT-LIST에서 컬럼을 가공함으로 인해 인덱스를 정상적으로 사용할 수 없는 경우도 있음
-- PK가 (장비번호 + 변경일자 + 변경순번)
-- 정렬연산 생략
SELECT *
FROM TBL
WHERE 장비번호 = 'C'
ORDER BY 변경일자, 변경순번
-- 가공한 값 기준으로 정렬 요청했기 때문에 정렬 연산 필요
SELECT *
FROM TBL
WHERE 장비번호 = 'C'
ORDER BY 변경일자 || 변경순번
-- PK가 (주문일자 + 주문번호)
-- ORDER BY 대상이 TO_CHAR로 가공한 주문번호를 대상으로 하기 때문에, 정렬 연산이 생략되지 않음
SELECT *
FROM (
SELECT TO_CHAR(A.주문번호, 'FM000000') AS 주문번호, A.업체번호, A.주문금액
FROM TBL A
WHERE A.주문일자 = :dt AND A.주문번호 > NVL(:next_ord_no, 0)
ORDER BY 주문번호
)
WHERE ROWNUM <= 30
-- 정렬 연산 생략
SELECT *
FROM (
SELECT TO_CHAR(A.주문번호, 'FM000000') AS 주문번호, A.업체번호, A.주문금액
FROM TBL A
WHERE A.주문일자 = :dt AND A.주문번호 > NVL(:next_ord_no, 0)
ORDER BY A.주문번호
)
WHERE ROWNUM <= 30
SELECT-LIST에서 컬럼 가공
- 인덱스가 (장비번호 + 변경일자 + 변경순번)일 때, 변경순번 최소값을 구하는 경우 정렬 연산을 수행하지 않음
- 수직적 탐색을 통해 조건을 만족하는 가장 왼쪽 지점으로 내려가 첫 번째 읽는 레코드가 최소값이기 때문
SELECT MIN(변경순번)
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
- 변경순번 최대값을 구하는 경우도 정렬 연산을 수행하지 않음
- 수직적 탐색을 통해 조건을 만족하는 가장 오른쪽 지점으로 내려가 첫 번째 읽는 레코드가 최대값이기 때문
SELECT MAX(변경순번)
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
- 인덱스를 이용해 정렬 연산 없이 최소 또는 최대값을 빠르게 찾을 때, 인덱스 리프 블록의 왼쪽(MIN) 또는 오른쪽(MAX)에서 레코드 하나(FIRST ROW)만 읽고 멈춤
-- 인덱스가 문자열 기준으로 정렬돼 있는데, 이를 숫자값으로 바꾼 값 기준 최종 변경순번 요구
-- 정렬연산 생략 불가능
SELECT NVL(MAX(TO_NUMBER(변경순번)), 0)
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
-- 정렬 연산 없이 최종 변경순번을 찾을 수 있음
-- 변경순번 값이 고정너비로 입력돼 있어야 함
SELECT NVL(TO_NUMBER(MAX(변경순번)), 0)
FROM TBL
WHERE 장비번호 = 'C' AND 변경일자 = '20180316'
-- 정렬 연산 없이 MIN/MAX, FIRST ROW 방식으로 최종변경일자 출력
SELECT 장비번호, 장비명, 상태코드
,(SELECT MAX(변경일자)
FROM TBL1
WHERE 장비번호 = P.장비번호) 최종변경일자
FROM TBL2 P
WHERE 장비구분코드 = 'A001'
-- 최종 변경순번까지 출력
SELECT 장비번호, 장비명, 상태코드
,(SELECT MAX(변경일자)
FROM TBL1
WHERE 장비번호 = P.장비번호) 최종변경일자
,(SELECT MAX(변경순번)
FROM TBL1
WHERE 장비번호 = P.장비번호
AND 변경일자 = (SELECT MAX(변경일자)
FROM TBL1
WHERE 장비번호 = P.장비번호)) 최종변경순번
FROM TBL2 P
WHERE 장비구분코드 = 'A001'
-- 가독성은 올라가지만, 인덱스 컬럼을 가공함
-- 각 장비가 속한 과거 이력 데이터를 모두 읽어야 해 장비당 이력 레코드가 많다면 성능 이슈가 있음
SELECT 장비번호, 장비명, 상태코드
, SUBSTR(최종이력, 1, 8) 최종변경일자
, SUBSTR(최종이력, 9) 최종변경순번
FROM(
SELECT 장비번호, 장비명, 상태코드
, (SELECT MAX(변경일자 || 변경순번)
FROM TBL1
WHERE 장비번호 = P.장비번호) 최종이력
FROM TBL2 P
WHERE 장비구분코드 = 'A001'
)
자동 형변환
SELECT *
FROM TBL
WHERE 생년월일 = 19821225
-- 옵티마이저가 실제 실행한 SQL
SELECT *
FROM TBL
WHERE TO_NUMBER(생년월일) = 19821225
- 각 조건절에서 양쪽 값의 데이터 타입이 서로 다르면 값을 비교할 수 없음
- 이 경우 오라클을 자동으로 형변환 처리
- 문자형 vs 숫자형은 문자형 컬럼을 숫자형 컬럼 기준으로 변환
- 날짜형 vs 문자형은 문자형 컬럼을 날짜형 컬럼 기준으로 변환
-- 좌변 컬럼 기준으로 우변을 변환하므로, 인덱스 사용이 가능
SELECT *
FROM 고객
WHERE 가입일자 = '01-JAN-2018'
-- NLS_DATE_FORMAT 파라미터가 다르게 설정된 환경에서 수행하면 컴파일 오류가 발생하거나 결과집합이 달라질 수 있음
-- 아래처럼 날짜 포맷을 정확히 지정해주자
SELECT *
FROM 고객
WHERE 가입일자 = TO_DATE('01-JAN-2018', 'DD-MON-YYYY')
LIKE는 문자열 비교 연산자
- 숫자형과 문자형을 LIKE에서 사용할 경우, 문자형 기준으로 숫자형 컬럼이 변환
SELECT * FROM TBL WHERE 고객번호 LIKE '9410%'-- LIKE 조건을 옵션 조건 처리 목적으로 사용하는 경우 -- 예시 -- 1번: 사용자가 계좌번호를 입력하는 경우 SELECT * FROM TBL WHERE 계좌번호 = :acnt_no AND 거래일자 BETWEEN :trd_dt1 AND :trd_dt2 -- 2번: 사용자가 계좌번호를 입력하지 않은 경우 SELECT * FROM TBL WHERE 거래일자 BETWEEN :trd_dt1 AND :trd_dt2 -- 1, 2번을 SQL 하나로 처리하려고 아래처럼 작성하는 경우 SELECT * FROM TBL WHERE 계좌번호 LIKE :acnt_no || '%' AND 거래일자 BETWEEN :trd_dt1 AND :trd_dt2- 조회할 때 사용자가 계좌번호를 입력하지 않으면 :acnt_no에 NULL을 입력해 모든 게좌번호를 조회하려는 의도
- LIKE, BETWEEN을 같이 사용해 인덱스 스캔 효율이 안 좋음
- 특히 계좌번호가 숫자형일 경우, LIKE 사용으로 인해 자동 형변환이 발생해 계좌번호가 인덱스 엑세스 조건으로 사용 불가
- 계좌번호 + 거래일자 순으로 구성된 인덱스 Range Scan 불가능
- 거래일자 + 계좌번호 순으로 구성된 인덱스 Range Scan 가능
- 거래일자 조회 범위에 속한 모든 거래 데이터를 읽고 계좌번호 필터링하므로 비효율
- 특히 계좌번호가 숫자형일 경우, LIKE 사용으로 인해 자동 형변환이 발생해 계좌번호가 인덱스 엑세스 조건으로 사용 불가
자동 형변환 주의
- 형변환을 못하는 경우에 따라 오류가 발생 가능
-- 문자형 컬럼(v_col)에 숫자형 컬럼(n_col)로 변환할 수 없는 문자열이 입력되면 에러 발생
WHERE n_col = v_col
- 실행 결과가 잘못 나올 수도 있음
-- 1번
SELECT ROUND(AVG(sal)) avg_sal
,MIN(sal) min_sal
,MAX(sal) max_sal
,MAX(DECODE(job, 'PRESIDENT', NULL, sal)) max_sal2
FROM EMP
-- 2번
SELECT empno, ename, job, sal
FROM EMP
WHERE job <> 'PRESIDENT'
ORDER BY sal DESC;
-- 3번
SELECT ROUND(AVG(sal)) avg_sal
,MIN(sal) min_sal
,MAX(sal) max_sal
,MAX(DECODE(job, 'PRESIDENT', TO_NUMBER(NULL), sal)) max_sal2
FROM EMP
- 1번 실행 결과
- avg_sal = 2073, min_sal = 800, max_sal = 5000, max_sal2 = 950
- 가장 적게 받는 직원의 급여가 800, 많이 받는 직원이 5000
- 가장 많이 받는 직원인 PRESIDENT를 제외한 최댓값이 950으로 평균인 2703보다 못함
- 2번 실행 결과
- job = ANALYST인 경우 sal이 3000으로 가장 높음
- 오류가 나온 원인
- DECODE 함수 내부의 자동 형변환 규칙 때문
- DECODE(a, b, c, d)
- a = b면 c를, 아니면 d를 반환
- 반환 값의 데이터 타입은 c가 결정
- c가 문자형이고 d가 숫자형이면 d가 문자형 취급
- c가 NULL이면 varchar2 취급
- a = b면 c를, 아니면 d를 반환
- 2번 쿼리에서 세 번째 인자가 NULL이므로, 네 번째 인자인 sal이 문자열로 변환
- 문자열 기준 가장 큰 값인 950이 출력
- 3번처럼 고칠 경우 정상 동작
- TO_NUMBER(NULL) 대신 0을 써도 됨
- 자동 형변환에 의존하지 말고, 인덱스 컬럼 기준으로 반대편 컬럼 또는 값을 정확히 형변환해 주어야 한다
- SQL 성능은 블록 I/O가 결정
- 형변환 함수 생략한다고 연산 횟수가 주는 것도 아님