
데이터 저장 매체는 컴퓨터에서 가장 느린 부분이다. 따라서 데이터베이스의 성능 튜닝에서 디스크 I/O를 어떻게 줄이느냐가 관건일 때가 많다.
기계식 하드 디스크 드라이브를 대체하기 위한 전자식 저장 매체이다.
기존 하드 디스크 드라이브에서 원판을 제거한 대신 플래시 메모리를 장착하고 있다.
원판을 기계적으로 회전시킬 필요가 없어 아주 빨리 데이터를 읽고 쓸 수 있다.
컴퓨터의 D-RAM보다는 느리지만 기계식 하드 디스크보다는 훨씬 빠르다.
기존 하드 디스크 드라이브와 같은 인터페이스 (SATA나 SAS)를 지원하므로 내장 디스크나 DAS 또는 SAN에 그대로 사용할 수 있다.
전원이 공급되지 않아도 데이터가 삭제되지 않는다.
순차 I/O에서는 SSD가 HDD보다 조금 빠르거나 거의 비슷한 성능을 보이기도 하지만, SSD의 장점은 랜덤 I/O가 기존 HDD보다 훨씬 빠르다는 것이다. 데이터베이스 서버에서 순차 I/O 작업의 비중이 크지 않기 때문에 SSD의 장점은 DBMS용 스토리지에 최적이다.

위 그림에서 순차 I/O는
3개의 페이지를 디스크에 기록하기 위해 1번의 시스템 콜을 요청했다.
즉, 디스크에 기록해야 할 위치를 찾기 위해 디스크의 헤드를 1번 움직였다.
위 그림에서 랜덤 I/O는
3개의 페이지를 디스크에 기록하기 위해 3번의 시스템 콜을 요청했다.
즉, 디스크에 기록해야 할 위치를 찾기 위해 디스크의 헤드를 3번 움직였다.
=> 디스크의 성능은 디스크 헤더의 위치 이동 없이 얼마나 많은 데이터를 한 번에 기록하느냐에 의해 결정되기 때문에, 위 그림에서 순차 I/O는 랜덤 I/O보다 3배 정도 빠르다고 볼 수 있다.
디스크 원판을 가지지 않는 SSD 역시 랜덤 I/O는 순차 I/O에 비해 전체 Throughput이 떨어진다. 그래서 SSD 드라이브의 사양에도 항상 순차 I/O와 랜덤 I/O의 성능을 구분해서 명시한다.
랜덤 I/O는 여러 번 쓰기 또는 읽기를 요청하기 때문에 순차 I/O에 비해 작업 부하가 훨씬 더 크다. 그리고 데이터베이스의 작업은 작은 데이터를 빈번히 읽고 쓰는 랜덤 I/O가 대부분이기 때문에 MySQL 서버는 그룹 커밋이나 바이너리 로그 버퍼 또는 InnoDB 로그 버퍼 등의 기능을 내장하고 있다.
일반적으로 쿼리 튜닝은 랜덤 I/O 자체를 줄여주는 것이 목적이다. 이는 쿼리를 처리하는 데 꼭 필요한 데이터만 읽도록 쿼리를 개선하는 것을 의미한다.
DBMS에서 테이블의 모든 데이터를 검색해서 원하는 결과를 가져오려면 시간이 오래 걸린다.
그래서 컬럼들의 값과 해당 레코드가 저장된 주소를 key-value 쌍으로 삼아 만들어 둔 것이 인덱스이다.
인덱스가 많아지면 그 안에서 원하는 쌍을 찾는 데에 시간이 걸릴 것이기 때문에, DBMS는 인덱스를 컬럼 값 기준으로 미리 정렬해서 보관한다.
DBMS의 인덱스는 SortedList와 같이 저장되는 컬럼의 값을 이용해 항상 정렬된 상태를 유지한다.
(데이터 파일은 ArrayList와 같이 저장된 순서대로 별도의 정렬 없이 저장해 둔다.)
데이터가 저장될 때마다 값을 정렬해야 하므로 저장 과정이 복잡하고 느려진다.
하지만 이미 정렬이 되어 있는 덕분에 아주 빨리 원하는 값을 찾아올 수 있다.
=> 인덱스의 추가 여부는 데이터의 저장 속도를 어디까지 희생할 수 있는지, 읽기 속도를 얼마나 더 빠르게 만들어야 하는지에 따라 결정해야 한다.
B-Tree 알고리즘
가장 일반적으로 사용되는 인덱스 알고리즘이다.
컬럼의 값을 변형하지 않고 원래의 값을 이용해 인덱싱한다.
MySQL 서버에는 위치 기반 검색 지원을 위한 R-Tree 인덱스 알고리즘도 있지만 이 또한 B-Tree의 응용으로 볼 수 있다.
Hash 알고리즘
컬럼의 값으로 해시 값을 계산해서 인덱싱하는 알고리즘이다.
매우 빠른 검색을 지원한다.
하지만 값을 변형해서 인덱싱하므로 Prefix 일치와 같이 값의 일부만 검색하거나 범위를 검색할 때는 사용할 수 없다.
주로 메모리 기반의 데이터베이스에서 사용한다.
B+-Tree 또는 B*-Tree가 사용된다.

B-Tree의 루트 노드부터 브랜치 노드를 거쳐 최종 리프 노드까지 이동하며 트리 탐색을 수행한다.
SELECT뿐만 아니라, UPDATE나 DELETE를 할 때도 일단 먼저 레코드를 검색한다.
100% 일치 또는 값의 일부분(Left-most part)만 일치하는 경우에 사용할 수 있다.
인덱스를 구성하는 키 값의 뒷부분만 검색하는 용도로는 인덱스를 사용할 수 없다.
Left-most part
'복합 인덱스에서 앞쪽 컬럼부터'라는 뜻.
INDEX (A, B, C)
복합 인덱스가 위와 같이 있다고 가정하면,
검색이 가능한 경우는 다음과 같다.
WHERE A = 10
WHERE A = 10 AND B = 5
WHERE A = 10 AND B = 5 AND C = 3
정렬의 시작점인 A를 알고 있기 때문에 B-Tree에서 인덱스 범위를 정확히 좁힐 수 있다.
검색이 불가능한 경우는 다음과 같다.
WHERE B = 5
WHERE B = 5 AND C = 3
WHERE A > 10 AND B = 5 // A > 10 범위 안에는 B=1, B=3, B=5 등등이 흩어져 있으니 B=5인 애들을 한 번에 못 찾음...
위 경우들은 A를 확실히 모르는 상태에서, B는 여기 저기 흩어져 있기 때문에 트리를 타고 내려갈 기준이 없다.
인덱스의 키 값에 변형이 가해진 후 비교되는 경우(함수나 연산을 수행한 결과로 정렬, 검색하는 경우 등)에는 B-Tree의 검색 기능을 사용할 수 없다.
InnoDB에서 지원하는 레코드 잠금이나 넥스트 키 락은 검색을 수행한 인덱스를 잠근 후 테이블의 레코드를 잠그는 방식으로 구현되어 있다.
16 * 1024 / (16 + 12) = 582개의 키를 저장할 수 있으며, 자식 노드를 585개 가질 수 있다는 뜻이다.16 * 1024 / (32 + 12) = 372개의 키를 저장할 수 있으며, 자식 노드를 372개 가질 수 있다는 뜻이다.SELECT * FROM employees WHERE first_name BETWEEN 'Ebbe' AND 'Gad';
위 쿼리를 실행했을 때 인덱스 레인지 스캔은 다음의 이미지와 같이 이루어진다.

SHOW STATUS LIKE 'Handler_%';
위 예시는 실제 인덱스만을 읽는 경우를 보여주는데, B-Tree 인덱스의 리프 노드를 스캔하면서 실제 데이터 파일의 레코드를 읽어 와야 하는 경우도 많다.

쿼리가 필요로 하는 데이터에 따라 이렇게 실제 데이터 파일의 레코드를 읽어 오는 작업이 필요할 수도, 필요하지 않을 수도 있다.
실제 데이터 파일의 레코드를 읽어오지 않는 인덱스를 커버링 인덱스라고 한다.
커버링 인덱스로 처리되는 쿼리는 랜덤 읽기가 상당히 줄어들고 성능이 그만큼 빨라진다.


GROUP BY 또는 집합 함수 가운데 MAX() 또는 MIN() 함수에 대해 최적화를 하는 경우에 사용된다.SELECT dept_no, MIN(emp_no)
FROM dept_emp
WHERE dep_no BETWEEN 'd002' AND 'd004'
GROUP By dept_no;
위 쿼리에서 사용된 dept_emp 테이블은 dept_no와 emp_no라는 두 개의 컬럼으로 인덱스가 생성되어 있다.
이 인덱스는 (dept_no, emp_no) 조합으로 정렬까지 되어 있으므로, dept_no의 그룹별로 첫 번째 레코드의 emp_no 값만 읽으면 된다.
즉, 인덱스에서 WHERE 조건을 만족하는 범위 전체를 스캔할 필요가 없다는 것을 옵티마이저는 알고 있기 때문에 조건을 만족하지 않는 레코드는 무시하고 다음 레코드로 이동한다.
다음 명령을 사용해 인덱스 스킵 스캔을 활성화할 수 있다.
SET optimizer_switch='skip_scan=on';
다음과 같이 인덱스를 생성했다고 해보자.
ALTER TABLE employees
ADD INDEX ix_gender_birthdate (gender, birth_date);
위 인덱스를 사용하려면 WHERE 조건절에 gender 컬럼에 대한 비교 조건이 필수다.
-- // 인덱스를 사용하지 못하는 쿼리
SELECT * FROM empolyees WHERE birh_date>='1965-02-01';
-- // 인덱스를 사용할 수 있는 쿼리
SELECT * FROM employees WHERE gender='M' AND birth_date>='1965-02-01';
그러므로 위 두 쿼리 중 두 번째 쿼리는 인덱스를 효율적으로 사용할 수 있지만, 첫 번째 쿼리는 인덱스를 사용할 수 없었다.
하지만 MySQL 8.0 버전부터는 옵티마이저가 gender 컬럼을 건너뛰어서 birth_date 컬럼만으로도 인덱스 검색이 가능하게 해주는 인덱스 스캡 스캔 최적화 기능이 도입되었다.
(루스 인덱스 스캔은 GROUP BY 처리에만 적용할 수 있었지만 인덱스 스킵 스캔은 WHERE 조건절의 검색을 위해 사용 가능하도록 용도가 훨씬 넓어졌다.)

1. 선행 인덱스 컬럼에 대해 가능한 값을 모두 구한다. 위 예시에서 gender 컬럼은 'M'와 'F'만을 가진다.
2. 내부적으로 여러 개의 AND 조건을 처리하는 쿼리를 실행하는 것과 비슷한 형태의 최적화를 실행한다.
SELECT gender, birth_date FROM employees WHERE gender='M' AND birth_date<='1965-02-01';
SELECT gender, birth_date FROM employees WHERE gender='F' AND birth_date<='1965-02-01';
WHERE 조건절에 조건이 없는 인덱스의 선행 컬럼의 유니크한 값의 개수가 적어야 한다.
// team_name은 오름차순 정렬, user_score는 내림차순 정렬
CREATE INDEX ix_teamname_userscore ON empolyees (team_name ASC, user_score DESC);ORDER BY 처리나 MIN() 또는 MAX() 함수 등의 최적화가 필요한 경우에도 마찬가지이다.B-Tree 인덱스의 특징은 왼쪽 값이 기준해서(Left-most) 오른쪽 값이 정렬되어 있다는 것이다. 따라서 정렬 우선순위가 낮은 뒷부분의 값만으로는 왼쪽 정렬 기반 인덱스인 B-Tree에서는 인덱스의 효과를 얻을 수 없다.
SELECT * FROM employees WHERE first_name LIKE '%mer';
위 쿼리는 first_name 컬럼에 인덱스가 생성되어 있더라도 인덱스 레인지 스캔 방식으로 인덱스를 이용할 수 없다.
조건절에 주어진 상숫값의 왼쪽 부분이 고정되지 않았기 때문이다.
SELECT * FROM dept_emp WHERE emp_no>=10144;
인덱스가 (dept_no, emp_no) 순서대로 생성되어 있다면, 위 쿼리는 선행 컬럼인 dept_no 조건 없이 emp_no 값으로만 검색하고 있기에 인덱스를 효율적으로 사용할 수 없다.
다중 컬럼으로 구성된 인덱스이므로 dept_no에 대해 먼저 정렬한 후, 다시 emp_no로 정렬되어 있기 때문이다.
기본적으로 B-Tree 인덱스의 특성상 다음 조건에서는 사용할 수 없다.
컬럼의 값을 변형해서 만들어진 값에 대해 인덱스를 구축해야 할 때는 함수 기반의 인덱스를 활용하면 된다. MySQL 8.0부터 함수 기반 인덱스를 지원하기 시작했다.
다음과 같은 테이블이 있다고 해보자.
CREATE TABLE user (
user_id BIGINT,
first_name VARCHAR(10),
last_name VARCHAR(10),
PRIMARY KEY (user_id)
);
first_name과 last_name을 합쳐서 검색해야 하는 요건이 생겼다면, 다음과 같이 가상 컬럼을 추가하고 그 가상 컬럼에 인덱스를 생성할 수 있다.
ALTER TABLE user
ADD full_name VARCHAR(30) AS (CONCAT(first_name,' ', last_name)) VIRTUAL,
ADD INDEX ix_fullname (full_name);
가상 컬럼은 테이블에 새로운 컬럼을 추가하는 것과 같은 효과를 내기 때문에 실제 테이블의 구조가 변경된다는 단점이 있다.
MySQL 8.0 버전부터는 다음과 같이 테이블의 구조를 변경하지 않고, 함수를 직접 사용하는 인덱스를 생성할 수 있게 되었다.
함수를 직접 사용하는 인덱스는 테이블의 구조는 변경하지 않고 계산된 결괏값의 검색을 빠르게 만들어준다.
CREATE TABLE user (
user_id BIGINT,
first_name VARCHAR(10),
last_name VARCHAR(10),
PRIMARY KEY (user_id),
INDEX ix_fullname ((CONCAT(first_name, ' ', last_name)))
);
함수 기반 인덱스를 제대로 활용하려면 반드시 조건절에 함수 기반 인덱스에 명시된 표현식이 그대로 사용되어야 한다. 함수 생성 시 명시된 표현식과 쿼리의 WHERE 조건절에 사용된 표현식이 다르다면 옵티마이저가 다른 표현식으로 간주해서 함수 기반 인덱스를 사용하지 못한다.
EXPLAIN SELECT * FROM user WHERE CONCAT(first_name, ' ', last_name)='Matt Lee';
PK 값이 비슷한 레코드끼리 묶어서 저장하는 컷을 클러스터링 인덱스라고 표현한다.
PK 값에 의해 레코드의 저장 위치가 결정되므로 테이블 레코드의 저장 방식이라고 볼 수 있다.
InnoDB와 같이 항상 클러스터링 인덱스로 저장되는 테이블은 일반적으로 PK 기반 검색이 매우 빠르며, 대신 레코드의 저장이나 PK의 변경이 상대적으로 느리다.

클러스터링 인덱스의 리프 노드에는 모든 컬럼이 같이 저장되어 있다.
PK가 없는 경우 InnoDB 스토리지 엔진이 다음 우선순위대로 PK를 대체할 컬럼을 선택해 클러스터링 테이블을 구성한다.
1. PK가 존재하면 기본적으로 PK를 클러스터링 키로 선택
2. NOT NULL 옵션의 UNIQUE INDEX 중 첫 번째 인덱스를 클러스터링 키로 선택
3. 자동으로 유니크한 값을 가지도록 증가되는 컬럼을 내부적으로 추가한 후 클러스터링 키로 선택
이렇게 자동으로 추가되는 PK는 사용자에게 노출되지 않으며, 쿼리 문장에 명시적으로 사용할 수 없다. 이는 아무 의미 없는 값으로 클러스터링되는 것이기 때문에 어떠한 혜택이 없다. 가능하면 PK를 명시적으로 생성하자.
MySQL에서는 인덱스 없이 유니크 제약만 설정할 수 없다.
유니크 인덱스에서 NULL 값 저장이 가능하며, NULL은 2개 이상 저장될 수 있다.
유니크하지 않은 세컨더리 인덱스는 중복된 값이 허용되므로 읽어야 할 레코드가 많아서 느릴 뿐, 인덱스 자체의 특성 때문에 느린 것은 아니다.
읽어야 할 레코드의 건수가 같다는 전제 하에서는 성능상의 차이가 미미하다.
유니크 인덱스의 키 값을 쓸 때는 중복된 값이 있는지 없는지 체크하는 과정이 한 단계 더 필요하기 때문에 일반 세컨더리 인덱스보다 느리다.