테이블 엑세스 최소화
테이블 랜덤 엑세스
인덱스에 대한 맹신 또는 섣부른 자신감
- Partition Pruning: 조건절에 해당하는 파티션만 읽는 기능
- ❓ 인덱스를 이용해도 조건절에 맞는 데이터로 바로 찾아가는데?
Partition Pruning 은 왜 필요할까? 인덱스로 검색하는데 왜 느릴까?
인덱스 ROWID 는 물리적 주소? 논리적 주소?
- 인덱스 ROWID 는 데이터파일 번호, 오브젝트 번호, 블록 번호 같은 물리적 요소로 이루어짐
- ✅ 그러나 ROWID 는 논리적 주소에 가깝다.
- 물리적 주소라고 하면 쉽게 이해하는 C 언어의 pointer 처럼 메모리상의 주소를 가진것이 아님
- 그 물리적인 주소를 찾아기기 위한 논리적인 정보가 담겨있음
- ⚠️ 테이블 레코드와 물리적으로 직접 연결된 구조가 아니다
메인 메모리 DB 와 비교
- 메인 메모리 DB: 데이터를 메모리에 모두 로드함. 메모리 I/O 만 있음
- 데이터가 메모리의 고정된 주소에 있기에, 인덱스는 메모리의 물리적 주소를 가질 수 있음
- ORACLE DB: 테이블 블록이 버퍼캐시에서 밀려났다 다시 캐싱되며 수시로 주소가 바뀜
- 메모리 주소 정보(포인터)가 아닌 디스크 주소 정보를 이용해 해시 알고리즘으로 버퍼 블록을 찾아감
I/O 메커니즘 복습
- 인덱스 탐색 => ROWID = DBA + 로우 번호
- DBA(Data Block Address)
- DBA 해시함수에 대입
- 해시 체인 탐색
- 찾았다. 버퍼 헤더가 가지고 있는 포인터로 버퍼 블록 접근
- 못 찾았다. DBA 를 사용해서 디스크 접근
- 테이블 Full Scan
- 익스텐트 맵에서 읽을 블록들의 DBA 정보 가져옴
- 해시함수에 대입, 체인 탐색
- 찾았다 / 못 찾았다
인덱스 클러스터링 팩터
- Clustering Factor: 특정 컬럼을 기준으로 같은 값을 갖는 데이터가 서로 모여있는 정도
- CF 가 좋은 컬럼에 대한 인덱스는 검색 효율이 매우 좋다
- 버퍼 Pinning: 래치 획득, 체인 스캔 과정을 거쳐 어렵게 얻은 테이블 블록에 대한 포인터,
DBA 와 포인터의 연결을 바로 해제하지 않고 유지함
- 다음 인덱스 레코드를 읽었는데 직전과 같은 블록을 가리킨다면,
유지했던 포인터를 바로 사용 가능
인덱스 손익분기점
- 인덱스를 이용하면 전체중에 얼마나 추출하느냐에 따라 성능이 달라짐
- 테이블 랜덤 액세스 때문에 추출 건수가 많을수록 느려짐
| Table Full Scan | Index ROWID 를 이용한 테이블 액세스 |
|---|
| 시퀀셜 엑세스 | 랜덤 액세스 |
| Multiblock I/O | Single Block I/O |
- CF 가 좋으면 손익분기점이 상승, 나쁘면 감소
- 전체 테이블의 절대적 크기도 영향이 있음
- 데이터 양이 크면 BCHR 감소, CF 감소
- 1000만건 정도의 테이블이고, 메모리가 막대하게 큰게 아니면
손익분기점이 의미가 없고, Table Full Scan 이 압도적으로 유리
온라인 프로그램 튜닝 VS 배치 프로그램 튜닝
- 온라인 프로그램(OLTP)
- 소량의 데이터를 읽고 갱신
- 인덱스 활용이 중요
- NL 조인, 부분범위 처리
- 배치 프로그램(OLAP)
- 대량의 데이터를 읽고 갱신
- 전체를 빠르게 처리하는 것이 목표
- Full Scan, Hash 조인
- 파티션 활용 전략, 병렬 처리가 중요
인덱스 컬럼 추가
- 테이블 액세스를 최소화 하기 위한 가장 기본적인 방법
- 전체 블록 I/O 에서 테이블 랜덤 액세스 비중이 높다면 효과적인 선택
- 즉, 테이블 액세스 단계에서 필터 조건에 의해 버려지는 레코드가 많을 때
- ⚠️ 인덱스를 수정하는 기능은 없음. 기존의 인덱스를 삭제하고 새로 만들기
- ⚠️ 인덱스를 무작정 많이 만들면 인덱스 관리 비용 증가, DML 성능 저하가 생김
인덱스만 읽고 처리
- 쿼리에 사용된 컬럼은 모두 인덱스에 추가
- 테이블 액세스가 발생하지 않음
- Covered Query: 인덱스만 읽어서 처리하는 쿼리
- Covered Index: covered query 에 사용한 인덱스
Include 인덱스
- ⚠️ ORACLE 에 없음, SQL Server 기능
- 인덱스 키 외에 미리 지정한 컬럼을 리프 레벨에 함께 저장
- 인덱스의 추가적인 비용 없이 테이블 액세스 없앰
인덱스 구조 테이블
- IOT(Index-Organized Table)
- 테이블을 인덱스 구조로 생성함
- 테이블 블록에 있어야할 데이터를 리프 블록에 모두 저장
- 정렬되어 있어서 같은 값을 가진 레코드들은 반드시 모여 있음 -> Clustering Factor 좋음
- 입력과 조회 패턴이 서로 다른 테이블에 유용
- 조회 쿼리에 사용되는 컬럼을 첫번째 정렬 기준이 되도록 IOT 를 생성
- 랜덤 액세스가 아니라 시퀀셜 액세스를 써서 블록 I/O 가 줄어든다
CREATE TABLE INDEX_ORG_T(
A NUMBER,
B VARCHAR(10),
CONSTRAINT INDEX_ORG_T_PK PRIMARY KEY (A)
)
ORGANIZATION INDEX;
- Heap 구조 VS IOT
- Heap: Freelist 로부터 할당 받은 블록에 정해진 순서 없이 랜덤 방식 입력
- IOT: 정렬 상태를 유지하며 입력
클러스터 테이블
- 클러스터 테이블
- 인덱스 클러스터 테이블
- 클러스터 키 값이 같은 레코드를 한 블록에 저장
(한 블록보다 크면 새로운 블록을 할당해 클러스터 체인으로 연결)
- IOT 나 클러스터형 인덱스(SQL Server) 와 달리 정렬은 안함
- 일반 인덱스는 테이블 레코드와 1:1 관계, 클러스터 인덱스는 블록을 가리킴 1:M 관계
- 랜덤 액세스가 키 값마다 한번. 인덱스를 타고 클러스터에 도착하면 시퀀셜 스캔
- 해시 클러스터 테이블
- 인덱스 클러스터 테이블에서 인덱스를 해시 알고리즘으로 바꾼 것
CREATE CLUSTER C_DEPT
CREATE INDEX C_DEPT
CREATE TABLE DEPT_CLUSTER (
DEPTNO NUMBER(2) NOT NULL,
DNAME VARCHAR2(14) NOT NULL,
LOC VARCHAR2(13)
)
CLUSTER C_DEPT
INSERT INTO DEPT_CLUSTER VALUES (10, 'ACCOUNTING', 'NEW YORK');
INSERT INTO DEPT_CLUSTER VALUES (20, 'RESEARCH', 'DALLAS');
INSERT INTO DEPT_CLUSTER VALUES (30, 'SALES', 'CHICAGO');
INSERT INTO DEPT_CLUSTER VALUES (40, 'OPERATIONS', 'BOSTON');
SELECT * FROM DEPT_CLUSTER WHERE DEPTNO = 20;
CREATE CLUSTER C_DEPT
CREATE TABLE DEPT_CLUSTER (
DEPTNO NUMBER(2) NOT NULL,
DNAME VARCHAR2(14) NOT NULL,
LOC VARCHAR2(13)
)
CLUSTER C_DEPT
INSERT INTO DEPT_CLUSTER VALUES (10, 'ACCOUNTING', 'NEW YORK');
INSERT INTO DEPT_CLUSTER VALUES (20, 'RESEARCH', 'DALLAS');
INSERT INTO DEPT_CLUSTER VALUES (30, 'SALES', 'CHICAGO');
INSERT INTO DEPT_CLUSTER VALUES (40, 'OPERATIONS', 'BOSTON');
SELECT * FROM DEPT_CLUSTER WHERE DEPTNO = 20;
부분범위 처리 활용
- 테이블 액세스로 인한 인덱스 손익분기점의 한계를 극복
부분범위 처리
- DBMS 가 클라이언트에게 데이터를 전송할 때 일정량(Array Size)씩 나누어 전송
- 아직 전송하지 않은 부분이 많이 남아있어도 서버 프로세스는 추가 Fetch Call 전까지 기다림
- 데이터를 일정량 전송하고 나면 서버 프로세스는 CPU 를 OS 에 반환하고 대기 큐에서 잠을 잠
- 다음 Fetch Call 을 받으면 대기 큐에서 나와 그다음 데이터부터 일정량을 읽어 전송하고 다시 잠
- OLTP 환경에서 대용량 데이터를 빠르게 핸들링
- 우리가 사용하는 쿼리 툴에서 아무리 큰 테이블을 전체 조회 해도 결과가 바로 나오는 이유
- 결과에서 스크롤을 내리면 추가 Fetch Call
정렬 조건이 있을 때 부분범위 처리
- 정렬 조건이 선두 컬럼인 인덱스가 없다면,
모든 데이터를 다 읽어 정렬을 마치고서야 클라이언트에게 데이터 전송을 시작
Array Size 조정을 통한 Fetch Call 최소화
- 네트워크를 통해 전송해야 할 데이터량에 따라 Array Size 를 조절할 필요가 있음
- 파일 다운로드 처럼 모든 데이터를 전송해야 한다면, Array Size 를 키워 Fetch Call 횟수를 줄임
- 앞쪽 일부 데이터만 Fetch 하다 멈추는 프로그램이면, Array Size 를 줄여 불필요한 데이터 전송을 줄임
OLTP 환경에서 부분범위 처리에 의한 성능개선 원리
- OLTP 는 일반적으로 소량 데이터를 읽고 갱신하지만, 가끔 대량을 조회할 때가 있음
- 그러나 사용자가 그 결과 데이터를 모두 확인하지는 않음
- 특정한 정렬 순서로 상위 일부 데이터만 확인
- 항상 정렬 상태를 유지하는 인덱스를 이용하면 부분범위 처리 가능
멈출 수 있어야 의미있는 부분범위 처리
- 앞쪽 일부만 출력하고 멈출 수 있는가가 중요
- 클라이언트와 DB 사이에 WAS, AP 서버 등이 존재하는 n-Tier 아키텍처에서는
클라이언트가 특정 DB 커넥션을 독점할 수 없음
- 단위 작업을 마치고 DB 커넥션을 커넥션 풀에 반환해야 함
- 그 전에 조회 결과를 클라이언트에게 모두 전송하고 커서를 닫아야함
- 그러나 Top N Query 를 이용하면 여전히 부분범위 처리가 유효하다
배치 I/O
- 테이블 블록에 대한 디스크 I/O Call 을 미뤘다가 읽을 블록이 일정량 쌓이면 한번에 처리
- 블록마다 건건이 I/O Call 을 발생시키는 비효율을 줄임
- ⚠️ 정렬 순서가 매번 다를 수 있음
- 인덱스를 써도 배치 I/O 기능이 작동하면 ORDER BY 에의한 SORT 연산을 생략할 수 없음
- ✅ 인덱스를 믿고 ORDER BY 를 생략하지 말자
SORT 연산 수행과 생략은 옵티마이저가 결정함
인덱스 스캔 효율화
인덱스 탐색
- 인덱스 컬럼 기준으로 정렬된 데이터들을 블록 크기로 분할
- 분할된 리프블록의 첫번째 레코드의 키 값을 부모 루트/브랜치 블록의 엔트리 키 값으로 등록
- 루트/브랜치 블록의 엔트리 키 값이 가리키는 리프 블록의 레코드들은 크거나 같음이 보장됨
- ⚠️ 그러나 직전의 리프 블록에도 엔트리 키 값과 같은 레코드가 존재할 수 있음
- ✅ 리프 블록 수평 탐색의 시작점을 찾기 위해서는
조건이 가르키는 리프 블록보다 하나 전으로 가야함
- 인덱스 컬럼의 순서에 따라 조건절의 BETWEEN 이 탐색범위를 줄일 수도, 아닐 수도 있음
인덱스 스캔 효율성
- 인덱스 선행 컬럼이 조건절에 없거나 '=' 조건이 아니면 인덱스 스캔 과정에서 비효율 발생
- 정렬 순서가 다르기 때문에, 수평 탐색의 시작 지점과 끝 지검을 정할 수 없다
인덱스 스캔 효율성 측정
SET AUTOTRACE ON
SELECT * FROM TABLE_NAME;
Using Autotrace in SQL*Plus
| Database Statistic Name | Description (설명) |
|---|
recursive calls | 사용자 및 시스템 수준에서 생성된 재귀 호출의 수. 내부적으로 처리되는 Oracle 시스템 SQL 호출 횟수입니다. |
db block gets | CURRENT 모드로 블록을 요청한 횟수입니다. 주로 데이터 변경 시 사용됩니다. |
consistent gets | Consistent Read(일관성 읽기) 방식으로 블록을 요청한 횟수입니다. 주로 SELECT 시 사용됩니다. |
physical reads | 디스크에서 실제로 읽은 데이터 블록의 총 수입니다. 직접 읽기와 버퍼 캐시를 통한 읽기를 모두 포함합니다. |
redo size | 생성된 Redo 로그의 총 바이트 수입니다. |
bytes sent via SQL*Net to client | 클라이언트에 전송된 총 바이트 수입니다. |
bytes received via SQL*Net from client | 클라이언트로부터 수신한 총 바이트 수입니다. |
SQL*Net roundtrips to/from client | 클라이언트와 주고받은 Oracle Net 메시지의 총 수입니다. |
sorts (memory) | 메모리에서만 정렬이 완료되어 디스크 I/O가 발생하지 않은 정렬 작업의 수입니다. |
sorts (disk) | 디스크에 적어도 한 번 이상 쓰기가 발생한 정렬 작업의 수입니다. |
rows processed | SQL 실행 중 처리된 행(row)의 총 수입니다. |
액세스 조건과 필터 조건
- 인덱스 액세스 조건
- 인덱스 스캔 범위를 결정하는 조건절
- 스캔 시작점, 스캔 끝점
- 인덱스 필터 조건
- 테이블 필터 조건
- 쿼리 수행 다음 단계로 전달하거나 최종 결과집합에 포함할지를 결정
비교 연산자 종류와 컬럼 순서에 따른 군집성
- 인덱스 컬럼을 앞에서 부터 순서대로 '=' 로 조회하면 결과는 모여있음
- 반대로, 중간에 하나라도 누락하거나 '=' 가 아니면 결과가 흩어짐
- '=' 가 아닌 처음 등장하는 범위 조건을 만족하는 결과 까지는 모여있음
범위검색 조건 맨 처음과 마지막 구간에서의 액세스 조건
- 범위 조건도 수평 탐색의 시작점과 끝점을 결정하는데 영향을 줌
- 몇 가지를 제외하면 인덱스 컬럼에 대한 조건절은 작지만 액세스 조건에 영향을 줌
- 인덱스 액세스 조건에 영향을 주지 않는 경우
- 좌변 컬럼을 가공한 조건절
- 왼쪽 '%' 또는 양쪽 '%' 기호를 사용한 LIKE 조건절
- 같은 컬럼에 대한 조건절이 두 개 이상일 때, 인덱스 액세스 조건으로 선택되지 못한 조건절
- OR Expansion 또는 INLIST ITERATOR 로 선택되지 못한 OR 또는 IN 조건절
인덱스 선행 컬럼이 등치 조건이 아닐 때 생기는 비효율
- 인덱스 스캔 효율성은 인덱스 컬럼을 조건절에 모두 등치 조건으로 사용할 때 가장 좋음
- 모두 테이블 액세스로 이어지기 때문
- 인덱스 선행 컬럼이 조건절에 없거나 부등호, BETWEEN, LIKE 같은 범위검색 조건이면 인덱스 스캔 비효율
BETWEEN 을 IN-List 로 전환
- BETWEEN 조건절을 IN-List 조건절로 바꿔 질의 하면 효율적
- 옵티마이저가 수직 탐색을 여러번 하도록 실행 계획을 세울 수 있다
- 등치 조건을 사용한 어러개의 질의들에 UNION ALL 을 적용한 것과 같은 효과
BETWEEN 조건을 IN-List 로 전환할 때 주의 사항
- BETWEEN 조건 때문에 리프 블록을 많이 스캔하는 비효율보다
IN-List 개수만큼 브랜치 블록을 반복 탐색하는 비효율이 클 수 있음
- Depth 가 깊을수록 주의
Index Skip scan 활용
- 선두 컬럼이 BETWEEN 이어서 나머지 검색 조건을 만족하는 데이터들이 서로 멀리 있을 때
- 하위 컬럼의 Range Scan 이 끝나면 상위 컬럼의 다음 키값의 시작점으로 Skip
IN 조건은 '=' 인가
- IN 조건은 '=' 가 아니다
- IN 조건이 IN-List Iterator 방식으로 풀려야 효율적인 상황도 있지만
- IN 조건이 필터 조건으로 사용되야 효율적인 경우도 있다
NUM_INDEX_KEYS 힌트 활용
- IN-List 를 액세스 조건 또는 필터 조건으로 유도하는 방법
- num_index_keys(테이블명, 인덱스명, 액세스 조건으로 사용할 컬럼 수)
SELECT *
FROM 고객별가입상품 a
WHERE 고객번호 = 1234
AND 상품ID IN ('NH00037', 'NH00041', 'NH00050');
- 인덱스 컬럼을 가공해서 조건절에 사용하면 힌트 없이 제한할 수 있음
BETWEEN 과 LIKE 스캔 범위 비교
- LIKE 보다 BETWEEN 을 사용하는 게 낫다
- BETWEEN 쓰면 적어도 손해는 안봄
SELECT * FROM SALES
WHERE SALES_DATE LIKE '2025%';
SELECT * FROM SALES
WHERE SALES_DATE BETWEEN '202501' AND '202512';
- BETWEEN 을 쓸 때가 항상 더 구체적이고 제한적
- 인덱스 스캔 범위를 조금이라도 줄일 가능성이 있음
Dynamic SQL
- 옵션 조건: 조건이 있을 수도 없을 수도 있음
- 옵션 조건을 처리하기 위해 OR, LIKE/BETWEEN, UNION ALL, NVL/DECODE 등 여러 방법이 있음
- MyBatis 같은 SQL 매핑 프레임워크에서는 SQL 을 동적으로 구성할 수 있음
- 따라서 옵션 조건에 '=' 연산자를 사용할 수 있음
- Dynamic SQL 을 허용하지 않는 시스템이 아니라면 고민하지 말고 옵션 조건을 동적으로 처리하자
- 힌트 사용에 주의, 액세스 경로가 원하지 않는 방향으로 고정될 수 있음
- 하드 파싱에 의한 문제가 발생하지 않게 바인드 변수를 잘 활용, 입력 값까지 동적으로 구성할 필요 없음
인덱스 설계
인덱스 설계가 어려운 이유
- 인덱스는 정렬상태를 유지해야 해서 삽입/삭제할 때 마다 비용 발생
- 입력할 리프 블록에 공간이 없으면 Index Split 도 발생
- 인덱스가 여러개라면 하나의 삽입/삭제에도 부담이 커짐
- DML 성능 저하
- 개별 쿼리들의 성능을 높이면서 인덱스의 개수는 최소화 해야함 -> 어렵다
가장 중요한 두 가지 선택 기준
- 조건절에 항상 사용하거나, 자주 사용하는 컬럼을 선정
- '=' 조건으로 자주 조회하는 컬럼을 앞쪽에 두기
스캔 효율성 이외의 판단 기준
- 기본적인 스캔 효율성 외에도 예외적으로 고려해야할 기준들이 있다
수행 빈도
- 업무상 중요도
- 클러스터링 팩터
- 데이터량
- DML 부하
- 저장 공간
- 인덱스 관리 비용
- 이중 수행 빈도가 가장 중요
- NL 조인 Inner 쪽 인덱스는 '=' 조건 컬럼을 선두에 두는 것이 중요
- 테이블 액세스 없이 인덱스에서 필터링을 마치는 게 좋음
공식을 초월한 전략적 설계
- 조건절 패턴중에 최적을 달성해야 할 핵심적인 액세스 경로 한두개를 전략적으로 선택
- 최소한의 최적의 인덱스를 설계
- 나머지 액세스 경로는 약간의 비효율이 있더라도 목표 성능을 만족하는 수준으로
- 왜 이런 설계를 했는가, 전략적 판단 근거
- 인덱스 개수를 줄이기 위해 범위 조건 컬럼이 인덱스 선두 컬럼이 될 수도 있음
소트 연산을 생략하기 위한 컬럼 추가
- 인덱스는 정렬 상태를 보장해서 ORDER BY, GROUP BY 를 위한 소트 연산 생략 가능
- 조건절에 사용하지 않는 컬럼이라도 소트 연산 생략을 위해 인덱스 구성에 포함 가능
- I/O 를 최소화하면서도 소트 연산을 생략하기 위한 공식
- '=' 연산자로 사용한 조건절 컬럼 선정 (IN 조건 안됨)
- ORDER BY 절에 기술한 컬럼 추가
- '=' 연산자가 아닌 조건절 컬럼은 데이터 분포를 고려해 추가 여부 결정
결함 인덱스 선택도
- Selectivity(선택도): Selection 연산 결과 레코드 수 / 전체 레코드 수
- Cardinality: 선택도 * 전체 레코드 수
- 인덱스 선택도: 인덱스 컬럼을 모두 '=' 로 조회할 때 평균적으로 선택되는 비율
- 선택도(카디널리티)가 높은 인덱스는 테이블 액세스가 많아 생성할 필요가 없음
SELECT COUNT(*) NDV, MAX(CNT) MX_CARD, MIN(CNT) MN_CARD, AVG(CNT) AVG_CARD
FROM (
SELECT 계약ID, 취급지점ID, COUNT(*) CNT
FROM 계약조직
WHERE (계약ID IS NOT NULL OR 취급지점ID IS NOT NULL)
GROUP BY 계약ID, 취급지점ID
);
컬럼 순서 결정 시, 선택도 이슈
- 선택도와 인덱스 구성 컬럼 순서는 관계가 없음
- 항상 사용하는 컬럼을 앞쪽에 두고 그중 '=' 조건을 앞쪽에 위치시키는 게 중요
- 선택도가 낮은 컬럼을 앞쪽에 두려는 노력은 의미 없거나 오히려 손해
- 인덱스 생성 여부 -> 선택도 고려
- 컬럼 간 순서 결정 -> 필수 조건 여부, 연산자 형태 고려
중복 인덱스 제거
- 선두 컬럼을 완전히 포함하고 있는 인덱스는 제거해도 됨
- X01: 계약ID + 청약일자
- X02: 계약ID + 청약일자 + 보험개시일자
- X03: 계약ID + 청약일자 + 보험개시일자 + 보험종료일자
- X01, X02 는 완전 중복으로 제거해도 됨