Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2.1 ~ 2.2 인덱스 구조 및 탐색, 인덱스 기본 사용법 #7

Merged
merged 1 commit into from
Oct 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 64 additions & 0 deletions LA/인덱스 기본/2.1|인덱스 구조 및 탐색.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 인덱스
인덱스는 `추가적인 쓰기 작업과 저장공간을 사용하여 데이터 조회 속도를 향상시키기 위한 자료구조`를 의미한다.

인덱스는 항상 정렬된 상태가 유지되어야 한다. 이와 같은 특징 때문에 수정, 삭제 와 같은 연산이 일어날 때 마다 정렬을 해줘야 하는 단점이 존재한다.

# 미리 보는 인덱스 튜닝
### 데이터를 찾는 두 가지 방법
예를 들어 어떤 학교를 방문하여 홍길동이라는 학생을 찾는 방법은 두 가지가 있다. 첫 번째 방법은 각 학년마다 존재하는 모든 교실을 돌아다니며 찾는 것, 두 번째 방법은 교무실에 들어가 학생명부로 홍길동이라는 학생이 소속된 교실을 찾아가는 것이다.

둘 중 어느 쪽이 빠를까?? `만약 홍길동이라는 이름을 가진 학생의 수가 많다면 첫 번째 방법이 빠를 것이고, 아니라면 두 번째 방법이 빠를 것이다.`

### 인덱스 튜닝의 두 가지 핵심요소
인덱스는 테이블에서 소량 데이터를 검색할 때 사용한다. 인덱스를 튜닝하는 방법에는 여러가지가 존재하지만 핵심요소는 크게 두 가지로 나뉜다. 첫 번째는 `인덱스 스캔 과정에서 발생하는 비효율을 줄이는 것`, 두 번째는 `랜덤 엑세스를 최소화화는 것`이다.

인덱스를 튜닝하는데 있어서 두 방법 모두 중요하지만 랜덤 엑세스를 최소화하는 것이 성능을 개선하는데 더 큰 영향을 끼친다.

> 💡 SQL 튜닝은 랜덤 I/O 와의 전쟁이다.

### SQL 튜닝은 랜덤 I/O 와의 전쟁
데이터베이스 성능이 느린 이유는 디스크 I/O 때문이다. 읽어야 할 데이터량이 많고, 그 과정에 디스크 I/O 가 많이 발생할 때 느리다. `인덱스를 많이 사용하는 OLTP 시스템이라면 디스크 I/O 중에서도 랜덤 I/O 가 특히 중요하다.`
- [OLTP](https://www.oracle.com/kr/database/what-is-oltp/)

### 인덱스 구조
인덱스는 대용량 테이블에서 필요한 데이터만 빠르게 효율적으로 엑세스하기 위해 사용하는 오브젝트다.

데이터베이스에서 인덱스 없이 데이터를 검색하려면 테이블을 처음부터 끝까지 모두 읽어야 한다. 반면, 인덱스를 이용하면 일부만 읽고 멈출 수 있다. 즉, 범위 스캔이 가능하다. `범위 스캔이 가능한 이유는 인덱스가 정렬되어 있기 때문`이다.

루트와 브랜치 블록에는 키값을 갖지 않는 특별한 레코드가 하나 있다. 이를 'LMC' 라고 하며 'Leftmost Child' 의 줄임말이다. `LMC 는 자식 노드 중 가장 왼쪽 끝에 위치한 블록`을 가리킨다. LMC 가 가리키는 주소로 찾아간 블록에는 키값을 가진 첫 번째 레코드보다 작거나 같은 레코드가 저장돼 있다.

리프 블록에 저장된 각 레코드는 키값 순으로 정렬돼 있을 뿐만 아니라 테이블 레코드를 가리키는 주소값, 즉 ROWID 를 갖는다. 인덱스 키값이 같으면 ROWID 순으로 정렬된다. `인덱스를 스캔하는 이유는, 검색 조건을 만족하는 소량의 데이터를 빨리 찾고 거기서 ROWID 를 얻기 위해서다.` ROWID 는 DBA 와 로우 번호로 구성되므로 이 값을 알면 테이블 레코드를 찾아갈 수 있다.
- ROWID = DBA + 로우 번호
- DBA = 데이터 파일 번호 + 블록 번호
- 블록 번호: 데이터파일 내에서 부여한 상대적 순번
- 로우 번호: 블록 내 순번

# 인덱스 수직적 탐색
정렬된 인덱스 레코드 중 조건을 만족하는 첫 번째 레코드를 찾는 과정이다. 즉, `인덱스 스캔 시작지점을 찾는 과정`이다.

인덱스 수직적 탐색은 루트 블록에서부터 시작한다. `수직적 탐색이 가능한 이유는 루트를 포함해 브랜치 블록에 저장된 각 인덱스 레코드는 하위 블록에 대한 주소값을 갖기 때문이다.`

수직적 탐색 과정에 찾고자 하는 값보다 크거나 같은 값을 만나면, 바로 직전 레코드가 가리키는 하위 블록으로 이동한다. 이렇게 하위 블록으로 이동하는 이유는 수직적 탐색 과정이 `조건에 맞는 레코드를 찾는 과정이 아닌 조건을 만족하는 첫 번째 레코드를 찾는 과정` 이기 때문이다.

# 인덱스 수평적 탐색
수직적 탐색을 통해 스캔 시작점을 찾았으면, 찾고자 하는 데이터가 더 안 나타날 때까지 인덱스 리프 블록을 수평적으로 스캔한다. `본격적으로 데이터를 탐색하는 과정`이다. `인덱스 리프 블록끼리는 서로 앞뒤 블록에 대한 주소값을 가진 양방향 연결 리스트 구조`이다. 이것이 좌에서 우로, 우에서 좌로 수평적 탐색이 가능한 이유이다.

# 결합 인덱스 구조와 탐색
두 개 이상 컬럼을 결합해서 인덱스를 만들 수도 있다.

여기서 주목할 것은, `인덱스를 'A, B' 로 구성하든, 'B, A' 로 구성하든 읽는 인덱스 블록 개수가 똑같다`는 사실이다. 인덱스 선두 컬럼을 모두 "=" 조건으로 검색하면 어느 컬럼을 인덱스 앞쪽에 두든 블록 I/O 개수가 같으므로 성능도 똑같다.

인터넷에서 다음과 같은 글을 찾아 볼 수 있다.
```sql
select 이름, 성별
from 사원
where 성별 = ‘여자’
and 이름 = ‘유관순’

1. 인덱스를 [성별 + 이름] 순으로 구성한 경우
총 사원 50명 중에서 성별 = ‘여자’인 레코드 25건을 찾고, 거기서 이름을 검사해 최종적으로 2명 출력 -> 25번의 검사

2. 인덱스를 [이름 + 성별] 순으로 구성한 경우
총 사원 50명 중에서 이름 = ‘유관순'인 레코드 2건을 찾고, 거기서 성별을 검사해 최종적으로 2명 출력 -> 2번의 검사
```
해당 내용에서는 선택도가 낮은 '이름' 컬럼을 앞쪽에 두고 결합인덱스를 생성해야 검사 횟수를 줄일 수 있어 성능에 유리하다고 한다. 하지만 이는 잘못된 내용이다. `DBMS 가 사용하는 B*Tree 인덱스는 엑셀처럼 평면 구조가 아니다. 때문에 어느 컬럼을 앞에 두든 일량에는 차이가 없다.`
112 changes: 112 additions & 0 deletions LA/인덱스 기본/2.2|인덱스 기본 사용법.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 인덱스를 사용한다는 것
인덱스 컬럼을 가공하지 않아야 인덱스를 정상적으로 사용할 수 있다. '인덱스를 정상적으로 사용한다' 는 표현은 리프 블록에서 스캔 시작점을 찾아 거기서부터 스캔하다가 중간에 멈추는 것을 의미한다. 즉, `Index Range Scan 을 의미`한다.

인덱스 컬럼을 가공하더라도 인덱스를 사용할 수 있다. 하지만 스캔 시작점을 찾을 수 없고 멈출 수도 없어 리프 블록 전체를 스캔해야만 한다. `즉, Index Full Scan 방식으로 작동`한다.

# 인덱스를 Range Scan 할 수 없는 이유
인덱스 컬럼을 가공하면 인덱스를 정상적으로 사용할 수 없다. 이유는 `인덱스 스캔 시작점을 찾을 수 없기 때문`이다.

데이터베이스에서 아래와 같은 조건절을 처리할 때 인덱스를 정상적으로 사용할 수 없는 문제에 직면한다.
1. 조건절에서 인덱스 컬럼을 가공하는 경우
```sql
where substr(생년월일, 5, 2) = '05';
where nvl(주문수량,0) < 100;
```

2. like 키워드로 중간값을 검색하는 경우
```sql
where 업체명 like '%대한%';
```

3. OR 조건으로 검색하는 경우
```sql
where (전화번호 =: tel_no OR 고객명 =: cust_nm);
```
- OR Expansion
```sql
다음과 같이 쿼리를 작성하면 Index Rage Scan 이 가능하다.

select *
from 고객
where 고객명 =: cust_nm -- 고객명이 선두 컬럼인 인덱스 Range Scan
union all
select *
from 고객
where 고객명 =: tel_no -- 전화번호가 선두 컬럼인 인덱스 Range Scan
and (고객명<> : cust_nm or 고객명 is null)

OR 조건식을 SQL 옵티마이저가 위와 같은 형태로 변환할 수 있는데 이를 'OR Expansion' 이라고 한다. /*+use_concat*/ 힌트를 이용해서 OR expansion 을 유도하는 것도 가능하다.
```

4. IN 조건으로 검색하는 경우
```sql
where 전화번호 in (:tel_no1, :tel_no2);

IN 조건은 OR 조건과 같다. 다행히 UNION ALL 방식으로 작성하면 각 브랜치 별로 인덱스 스캔 시작점을 찾을 수 있어 Range Scan 이 가능하다.

select *
from 고객
where 전화번호 = :tel_no1
union all
select *
from 고객
where 전화번호 = :tel_no2;

그래서 IN 조건절에 대해서는 SQL 옵티마이저가 IN-List Iterator 방식을 사용한다. IN-List 개수만큼 Index Range Scan 을 반복하는 것이다. 이를 통해 UNION ALL 방식으로 변환한 것과 같은 효과를 얻을 수 있다.
```

# 더 중요한 인덱스 사용 조건
조건절에서 인덱스 컬럼을 가공하면 인덱스를 정상적으로 사용할 수 없다. 하지만 인덱스를 정상적으로 사용하는 데 있어 더 중요한 선행조건이 있다.

인덱스를 정상적으로 사용하기 위한 가장 첫 번째 조건은 `인덱스 선두 컬럼이 조건절에 있어야 한다`는 사실이다. 즉 `인덱스 선두 컬럼이 가공되지 않은 상태로 조건절에 있으면 인덱스 Range Scan 은 무조건 가능`하다.

하지만 여기서 문제는, `인덱스를 Range Scan 한다고 해서 항상 성능이 좋은 건 아니다.`

### 인덱스 잘 타니까 튜닝 끝?
> 💡 인덱스 Range Scan 을 하더라도 스캔 범위(인덱스 리프 블록에서 스캔하는 양)를 효율적으로 줄일 수 없다면 인덱스를 잘 탄다고 말할 수 없다.

# 인덱스를 이용한 소트 연산 생략
인덱스는 항상 정렬 상태를 유지한다. 항상 정렬되어 있기 때문에 Range Scan 이 가능하고, `소트 연산 생략 효과도 부수적으로 얻을 수 있다.`

옵티마이저는 이런 속성을 활용하여 SQL 에 ORDER BY 가 있어도 정렬 연산을 따로 수행하지 않는다. `PK 인덱스를 스캔하면서 출력한 결과집합은 어차피 변경순번 순으로 정렬되기 때문이다.`

오름차순 정렬일 때는 조건을 만족하는 가장 작은 값을 찾아 좌측으로 수직적 탐색한 후 우측으로 수평적 탐색을, 내림차순 정렬일 때는 조건을 만족하는 가장 큰 값을 찾아 우측으로 수직적 탐색 후 좌측으로 수평적 탐색을 한다.

# ORDER BY 절에서 컬럼 가공
인덱스 컬럼을 가공하여 조건절에 사용하는 경우 인덱스를 정상적으로 사용할 수 없다. 그런데 `조건절이 아닌 ORDER BY 또는 SELECT-LISt 에서 컬럼을 가공함으로 인해 인덱스를 정상적으로 사용할 수 없는 경우도 종종 존재`한다.
```sql
SELECT *
FROM (
SELECT TO_CHAR( A.주문번호, 'FM00000') AS 주문번호, A. 업체번호, A.주문금액
FROM 주문 A
WHERE A.주문일자 =: dt
AND A.주문번호 > NVL(:next_ord_no, 0)
ORDER BY 주문번호
)
WHERE ROWNUM <= 30
```
해당 SQL 의 실행 계획을 확인하면 SORT ORDER BY 연산이 추가되어 있다. 이유는 무엇일까?? `ORDER BY 절에 기술한 '주문번호' 는 순수한 주문번호가 아니라 TO_CHAR 함수로 가공한 주문번호를 가리키기 때문이다.`

# SELECT-LIST 절에서 컬럼 가공
인덱스를 `장비번호 + 변경일자 + 변경순번` 순으로 구성하면 변경순번 최소값을 구할 때도 옵티마이저는 정렬 연산을 따로 하지 않는다. 수직적 탐색을 통해 조건을 만족하는 가장 왼쪽 지점으로 내려가서 첫 번째 읽는 레코드가 바로 최소값이기 때문이다. 반대로 가장 오른쪽 지점으로 내려가서 첫 번째 읽는 레코드가 최대값이 된다.

# 자동 형변환
각 조건절에서 양쪽 값의 데이터 타입이 서로 다르면 값을 비교할 수 없다. 그럴 때 `타입 체크를 엄격하게 함으로써 컴파일 시점에 에러를 낸다.` (DBMS 마다 상이하며 오라클은 에러를 발생시키는 쪽에 속한다)
```sql
select * from 고객
where 가입일자 = '01-JAN-2018';

오라클은 날짜형과 문자형이 만나면 날짜형이 이기기 때문에 좌변 컬럼 기준으로 우변을 변환한다.
```
이렇게 하더라도 사용하는데 있어 문제가 전혀 없다. 하지만 NLS_DATE_FORMAT 파라미터가 다르게 설정된 환경에서 수행하면 컴파일 오류가 나거나 결과집합이 틀려질 수 있다. 그렇기 때문에 `포맷을 정확히 지정해 주는 코딩 습관이 필요하다.`
```sql
select * from 고객
where 가입일자 = TO_DATE('01-JAN-2018', 'DD-MON-YYYY');
```

### 자동 형변환 주의
자동 형변환이 작동하면 편리하다고 생각할 수 있지만, 이 기능 때문에 `성능과 애플리케이션 품질에 종종 문제가 발생`한다.

'형변환을 함수를 생략함으로써 연산횟수가 줄어 성능이 더 좋아진다' 라고 생각할 수 있지만 SQL 성능은 결국 블록 I/O 를 줄일 수 있느냐 없느냐에서 결정된다. 그리고 `형변환 함수를 생략하더라도 옵티마이저가 자동으로 생성`한다.

그렇기 때문에 해당 기능에 의존하지 말고, 인덱스 컬럼 기준으로 반대편 컬럼 또는 값을 정확히 형변환을 해주는 것이 좋다.