인덱스 스캔 튜닝 실습

인덱스 스캔 튜닝

개발자를 위한 오라클 SQL 튜닝 내용에서 참고한 내용입니다.

인덱스를 최대한 활용하여 원하는 결과 검색하기

  • 소비자 테이블, 주문 테이블 생성

  • 기본키, 외래키 생성(참조 무결성 제약 조건)

  • 테이블 관계

    • 두 테이블의 관계는 1:N 관계

    • 한 명의 고객은 여러 건을 주문할 수 있고, 한 건도 주문하지 않을 수 있다.

    • 한 개의 주문은 반드시 한 명의 고객을 가져야 한다.

  • 통계정보 생성

    • 인덱스와 테이블에 대한 통계 정보 생성

1. 테이블 풀 스캔 해보기

SELECT /*+ FULL(A) */ -- FULL 힌트를 사용하여 테이블의 스캔 방법을 테이블 풀 스캔으로 유도
    COUNT(*)
FROM TB_CUST A -- driving table
WHERE A.CUST_NM LIKE 'AB%'
  AND -- Like 문을 이용하여 CUST_NM 컬럼 값이 'AB'로 시작하는 행을 찾는다.
    EXISTS -- EXISTS문을 이용하여 TB_ORD 테이블에 PRDT_CD 컬럼의 값이 'AB'로 시작하는 행을 검색한다.
        (
            SELECT '1'
            FROM TB_ORD C -- driven table
            WHERE C.CUST_ID = A.CUST_ID
              AND C.PRDT_CD LIKE 'AB%'
        );
  • 문제점

    • TB_CUST 테이블의 CUST_NM 컬럼이 변별력 있는 컬럼임에도 인덱스를 생성하지 않음

    • TB_ORD 테이블의 CUST_ID 컬럼이 변별력 있는 컬럼임에도 인덱스를 생성하지 않음

    • OLTP(Online Transaction Processing)환경에서 빈번하게 사용되는 SQL문이라면 반복적인 테이블 풀 스캔은 전체 시스템에 큰 부하를 주게 된다.

  • Execute Plan

    • TB_CUST 테이블 풀 스캔 -> TB_ORD 테이블 풀 스캔

    • TB_CUST 테이블과 TB_ORD 테이블을 해시 세미 조인을 한다.

    • 해시 세미 조인은 옵티마이저가 판단한 조인 방식

      • 조인 방법에 대한 힌트를 지정하지 않을 경우 조인 방식은 옵티마이저의 판단에 따른다.

    • COUNT 함수 연산을 수행

    • SELECT 절의 연산을 수행

2. 인덱스 생성 (튜닝)

  • TB_CUST 테이블에 CUST_NM 컬럼으로 구성된 인덱스를 생성

CREATE INDEX TB_CUST_IDX01 ON TB_CUST (CUST_NM);
CREATE INDEX TB_ORD_IDX01 ON TB_ORD (CUST_ID, PRDT_CD);
  • 통계 정보 생성

ANALYZE
INDEX TB_CUST_IDX01 COMPUTE STATISTICS ;
ANALYZE
INDEX TB_ORD_IDX01 COMPUTE STATISTICS ;
  • 튜닝 후 SQL문

SELECT /*+ INDEX(A TB_CUST_IDX01) */ -- TB_CUST 테이블에 생성한 TB_CUST_IDX01 인덱스를 사용하도록 INDEX 힌트를 사용
    COUNT(*)
FROM TB_CUST A
WHERE A.CUST_NM LIKE 'AB%'
  AND EXISTS
    (
        SELECT /*+ INDEX(C TB_ORD_IDX01) NL_SJ */ -- TB_ORD 테이블의 TB_ORD_IDX01 인덱스를 사용하도록 INDEX 힌트를 사용, 중첩 루프 세미 조인을 위한 NL_SJ 힌트를 사용
            '1'
        FROM TB_ORD C
        WHERE C.CUST_ID = A.CUST_ID
          AND C.PRDT_CD LIKE 'AB%'
    );
  • 중첩 루프 세미 조인(Nested Loop Semi Join)

    • 조건 만족 시 해당 조인 대상 행은 더 이상 스캔을 수행하지 않고 다음 행으로 넘어가는 조인 방식

  • Execute Plan

    • TB_CUST 테이블의 TB_CUST_IDX01 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)합니다.

      • '인덱스 범위 스캔'이란 수직적 탐색을 통해 인덱스 리프 블록의 특정 위치로 이동한 후 지정된 범위를 스캔하는 방식

      • 인덱스의 스캔 방식 중 가장 일반적인 방식

    • TB_CUST 테이블의 TB_CUST_IDX01 인덱스는 CUST_NM으로 이루어진 인덱스

      • 해당 인덱스 스캔 후 CUST_ID를 가져오기 위해 테이블 랜덤 액세스(TABLE ACCESS BY INDEX ROWID)합니다.

      • TB_CUST 테이블의 기본키가 CUST_ID긴 하지만, 여기서는 CUST_NM 컬럼으로 이루어진 인덱스를 스캔하였기 때문에 테이블 랜덤 액세스는 불가피하다.

      • TB_CUST_IDX01 인덱스의 구성을 'CUST_NM + CUST_ID' 컬럼으로 한다면 해당 테이블 랜덤 액세스 부하를 줄일 수 있다.

    • TB_CUST 테이블의 CUST_ID 컬럼을 바탕으로 TB_ORD 테이블의 TB_ORD_IDX01 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)을 하였다.

      • TB_ORD_IDX01 인덱스는 'CUST_ID + PRDT_CD'로 구성되어서 별도로 테이블 랜덤 액세스하지 않았다.

    • TB_CUST 테이블을 기준으로 TB_ORD 테이블과 중첩 세비 조인(NESTED LOOPS SEMI)를 한다.

      • TB_CUST 테이블을 기준으로 TB_ORD 테이블과의 조인 조건이 성립되면 해당 조인 대상 행에 대해 더는 스캔하지 않고 멈추게 된다.

    • COUNT 함수 연산을 수행한다.

    • SELECT절의 연산을 수행한다.

추가 튜닝

  • 인덱스 컬럼을 추가하여 테이블 랜덤 엑세스를 없애고 인덱스 스캔만으로 데이터를 조회하는 기법

    • 인덱스를 재생성하고 통계 정보를 생성

    • TB_CUST_IDX01의 인덱스를 CUST_NM, CUST_ID로 재생성

    • 별도의 테이블 랜덤 액세스를 하지 않고 TB_CUST_IDX01 인덱스 만을 스캔하게 된다.

DROP INDEX TB_CUST_IDX01;
CREATE INDEX TB_CUST_IDX01 ON TB_CUST (CUST_NM, CUST_ID);
ANALYZE
INDEX TB_CUST_IDX01 COMPUTE STATISTICS;
  • Execute Plan

    • TB_CUST 테이블의 TB_CUST_IDX01 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)을 한다.

    • TB_ORD 테이블의 TB_ORD_IDX01 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)을 한다.

    • TB_CUST_IDX01 인덱스를 기준으로 TB_ORD_IDX01 인덱스와 중첩 루프 세미 조인(NESTED LOOPS SEMI) 한다.

    • COUNT 함수 연산을 수행한다.

    • SELECT절의 연산을 수행한다.

인덱스 구성 컬럼을 추가하여 테이블 랜덤 액세스 제거하기

  • 실습을 위한 테이블 생성

CREATE TABLE TB_ORD
(
    ORD_NO VARCHAR2 (10),  -- 주문번호
    ORD_DT VARCHAR2 (8),   -- 주문일자
    ORD_NM VARCHAR2 (150), -- 주문이름
    ORD_AMT NUMBER (15),   -- 주문금액
    PRDT_CD VARCHAR2 (6),  -- 상품코드
    SALE_GB VARCHAR2 (2),  -- 판매구분
    PAY_GB VARCHAR2 (2),   -- 결제구분
    CUST_ID VARCHAR2 (10), -- 고객ID
    INST_DTM DATE,         -- 입력시간
    INST_ID VARCHAR2 (50), -- 입력자ID
    UPDT_DTM DATE,         -- 수정시간
    UPDT_ID VARCHAR2 (5)   -- 수정자ID
);
  • 데이터 입력

CREATE TABLE DUAL_5
(
    DUMMY VARCHAR2 (1)
);

INSERT INTO DUAL_5
SELECT DUMMY
FROM DUAL CONNECT BY LEVEL <= 5;

COMMIT;

ALTER TABLE TB_ORD NOLOGGING;

-- 500 만건
INSERT /*+ APPEND */ INTO TB_ORD -- APPEND 힌트 사용
SELECT LPAD(TO_CHAR(ROWNUM), 10, '0'),
       TO_CHAR(SYSDATE - TRUNC(DBMS_RANDOM.VALUE(1, 3650)), 'YYYYMMDD'),
       DBMS_RANDOM.STRING('U', 150),
       TRUNC(DBMS_RANDOM.VALUE(1000, 100000)),
       LPAD(TO_CHAR(MOD(TRUNC(DBMS_RANDOM.VALUE(1, 1000)), 50)), 6, '0'),
       LPAD(TO_CHAR(MOD(TRUNC(DBMS_RANDOM.VALUE(1, 1000)), 10)), 2, '0'),
       LPAD(TO_CHAR(MOD(TRUNC(DBMS_RANDOM.VALUE(1, 1000)), 10)), 2, '0'),
       LPAD(TO_CHAR(TRUNC(DBMS_RANDOM.VALUE(1, 100000))), 10, '0'),
       SYSDATE,
       'DBMSEXPERT',
       NULL,
       NULL
FROM DUAL_5,
     (SELECT LEVEL LV FROM DUAL CONNECT BY LEVEL <= 500000);

COMMIT;

# [2021-07-10 20:14:13] 2,500,000 rows affected in 10 m 33 s 125 ms
  • 기본키 생성

ALTER TABLE TB_ORD
    ADD CONSTRAINT TB_ORD_PK
        PRIMARY KEY (ORD_NO);
  • 인덱스 생성

CREATE INDEX TB_ORD_IDX01 ON TB_ORD (ORD_DT, ORD_NM, ORD_AMT);
  • 통계 정보 생성

ANALYZE TABLE TB_ORD COMPUTE STATISTICS
FOR TABLE FOR ALL INDEXES FOR ALL INDEXED COLUMNS SIZE 254;

쿼리 비교를 통한 분석

SELECT ORD_DT,
       SALE_GB,
       PAY_GB,
       COUNT(*)               AS 주문건수,
       SUM(ORD_AMT)           AS 총주문금액,
       ROUND(AVG(ORD_AMT), 2) AS 평균주문금액
FROM TB_ORD
WHERE ORD_DT BETWEEN '20210101' AND '20211231'
  AND ORD_NM LIKE 'A%'
  AND ORD_AMT >= 1000
GROUP BY ORD_DT, SALE_GB, PAY_GB
ORDER BY ORD_DT, SALE_GB, PAY_GB;
  • SQL 분석

    • ORD_DT 컬럼을 조건으로 주어 2015년의 주문 건을 검색

    • ORD_DT + ORD_NM + ORD_AMT로 구성된 TB_ORD_IDX01 인덱스를 이용

  • 위 쿼리의 문제점

    • TB_ORD_IDX01 인덱스를 사용한 효율적인 인덱스 스캔을 하고 있다.

    • 하지만 SELECT 절에 인덱스 컬럼이 아닌 다른 컬럼도 조회하고 있으므로 인덱스 스캔에 이은 테이블 랜덤 액세스 부하가 발생한다.

    • 넓은 범위를 주로 조회하고 결과 건수가 많아진다면 DBMS에 큰 부하를 주게 된다.

  • Execute Plan

    • TB_ORD 테이블의 TB_ORD_IDX01 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)한다.

    • TB_ORD_IDX01 인덱스의 리프 블록에 저장된 ROWID를 이용하여 테이블 랜덤 액세스(TABLE ACCESS BY INDEX ROWID)를 수행

      • 인덱스 스캔에서 조건에 만족하는 ROWID가 많다면 자연스레 테이블 랜덤 액세스 부하가 발생한다.

    • ORDER BY와 GROUP BY 연산을 수행한다.

    • SELECT 절의 연산을 수행한다.

튜닝

  • 인덱스 추가

CREATE INDEX TB_ORD_IDX02 ON TB_ORD (ORD_DT, ORD_NM, ORD_AMT, SALE_GB, PAY_GB);
ANALYZE
INDEX TB_ORD_IDX02 COMPUTE STATISTICS;
  • 튜닝 후 SQL 문

    • TB_ORD 테이블의 TB_ORD_IDX02 인덱스를 사용하도록 INDEX 힌트를 사용

    • 해당 인덱스는 SALE_GB 컬럼과 PAY_GB 컬럼이 존재하기 때문에 테이블 랜덤 액세스를 생략할 수 있다.

    • 테이블 랜덤 액세스가 사라졌다는 것은 인덱스 구성 컬럼만으로 SQL문의 요청 내용을 검색한 것을 뜻한다.

SELECT /*+ INDEX(TB_ORD TB_ORD_IDX02) */
    ORD_DT,
    SALE_GB,
    PAY_GB,
    COUNT(*)               AS 주문건수,
    SUM(ORD_AMT)           AS 총주문금액,
    ROUND(AVG(ORD_AMT), 2) AS 평균주문금액
FROM TB_ORD
WHERE ORD_DT BETWEEN '20150101' AND '20151231'
  AND ORD_NM LIKE 'A%'
  AND ORD_AMT >= 1000
GROUP BY ORD_DT, SALE_GB, PAY_GB
ORDER BY ORD_DT, SALE_GB, PAY_GB;
  • Execute Plan

    • TB_ORD 테이블의 TB_ORD_IDX02 인덱스를 인덱스 범위 스캔(INDEX RANGE SCAN)을 한다.

    • ORDER BY와 GROUP BY 연간을 수행한다.

    • SELECT 절의 연산을 수행한다.

Last updated