diff --git "a/JongminJeon/10. \354\213\244\355\226\211 \352\263\204\355\232\215.md" "b/JongminJeon/10. \354\213\244\355\226\211 \352\263\204\355\232\215.md" new file mode 100644 index 0000000..f1d0cc5 --- /dev/null +++ "b/JongminJeon/10. \354\213\244\355\226\211 \352\263\204\355\232\215.md" @@ -0,0 +1,1432 @@ + +- 대부분의 DBMS는 많은 데이터를 안전하게 저장 및 관리하고 사용자가 원하는 데이터를 빠르게 조회할 수 있게 해주는 것이 주목적 +- 이러한 목적을 달성하려면 옵티마이저가 사용자의 쿼리를 최적으로 처리될 수 있게 하는 쿼리의 실행 계획을 수립할 수 있어야 함 +- 하지만 옵티마이저가 관리자나 사용자의 개입 없이 항상 좋은 실행 계획을 만들어낼 수 있는 것은 아님 +- DBMS 서버는 이러한 문제점을 사용자가 보완할 수 있도록 explain 명령으로 옵티마이저가 수립한 실행 계획을 확인할 수 있게 해줌 + +요약: +- 데이터를 빠르게 조회하기 위해 옵티마이저가 실행 계획을 최적화 하지만 항상 완벽하지는 않다. +- 따라서 Explain 명령으로 사용자에게 실행 계획을 보여주고 보완할 수 있게 해준다. + + +## 10. 1. 통계 정보 + +- MySQL 서버는 5.7 버전까지 테이블과 인덱스에 대한 개괄적인 정보를 가지고 실행 계획을 수립 +- MySQL 8.0 버전부터는 인덱스 되지 않은 컬럼들에 대해서도 데이터 분포도를 수집해서 저장하는 히스토그램 정보가 도입 + + +### 10.1.1. 테이블 및 인덱스 통계 정보 + +- 비용 기반 최적화에서 가장 중요한 것은 통계 정보 +- 통계 정보가 정확하지 않다면 엉뚱향 방향으로 쿼리를 실행할 수 있음 + - 예시: 1억 건의 레코드가 저장된 테이블의 통계정보가 갱신되지 않아서 레코드가 10건 미만으로 되어있다면 옵티마이저는 실제 쿼리를 실행할 때 `인덱스 레인지 스캔`이 아니라 `테이블 풀 스캔으로 실행`해 버릴 수 있음. + +#### 10.1.1.1. MySQL 서버의 통계 정보 + +- MySQL 5.6 버전부터 InnoDB 스토리지 엔진을 사용하는 테이블에 대한 통계 정보를 영구적으로 관리할 수 있게 개선 되었음 (5.5 버전까지는 각 테이블의 통계정보가 메모리에서만 관리되어 서버가 재시작 될 경우 모두 사라졌음) + +```sql +select * +from mysql.innodb_table_stats +where database_name = 'employees' +order by n_rows desc; +``` + +![alt](./src/img/10.%20통계정보테이블예시.png) +```text +■ innodb_table_stats.n_rows: 테이블의 전체 레코드 건수 +■ innodb_table_stats.clustered_index_size: 프라이머리 키의 크기(InnoDB 페이지 개수) +■ innodb_table_stats.sum_of_other_index_sizes: 프라이머리 키를 제외한 인덱스의 크기(InnoDB 페이지 개수) +``` + +```sql +mysql> +SELECT * + FROM innodb_index_stats +WHERE database_name='employees' + AND TABLE_NAME='employees'; ++--------------+--------------+------------+-------------+-----------------------------------+ +| index_name | stat_name | stat_value | sample_size | stat_description | ++--------------+--------------+------------+-------------+-----------------------------------+ +| PRIMARY | n_diff_pfx01 | 299202 | 20 | emp_no | +| PRIMARY | n_leaf_pages | 886 | NULL | Number of leaf pages in the index | +| PRIMARY | size | 929 | NULL | Number of pages in the index | +| ix_firstname | n_diff_pfx01 | 1313 | 20 | first_name | +| ix_firstname | n_diff_pfx02 | 294090 | 20 | first_name,emp_no | +| ix_firstname | n_leaf_pages | 309 | NULL | Number of leaf pages in the index | +| ix_firstname | size | 353 | NULL | Number of pages in the index | +| ix_hiredate | n_diff_pfx01 | 5128 | 20 | hire_date | +| ix_hiredate | n_diff_pfx02 | 300069 | 20 | hire_date,emp_no | +| ix_hiredate | n_leaf_pages | 231 | NULL | Number of leaf pages in the index | +| ix_hiredate | size | 289 | NULL | Number of pages in the index | ++--------------+--------------+------------+-------------+-----------------------------------+ +``` + +```text +■ innodb_index_stats.stat_name='n_diff_pfx%': 인덱스가 가진 유니크한 값의 개수 +■ innodb_index_stats.stat_name='n_leaf_pages': 인덱스의 리프 노드 페이지 개수 +■ innodb_index_stats.stat_name='size': 인덱스 트리의 전체 페이지 개수 +``` + + +통계 정보 갱신 이벤트 종류 및 설정 + +``` +MySQL 5.5. 버전까지의 통계 정보 수집 이벤트 종류 +■ 테이블이 새로 오픈되는 경우 +■ 테이블의 레코드가 대량으로 변경되는 경우(테이블의 전체 레코드 중에서 1/16 정도의 UPDATE 또는 INSERT나 DELETE가 실행되는 경우) +■ ANALYZE TABLE 명령이 실행되는 경우 +■ SHOW TABLE STATUS 명령이나 SHOW INDEX FROM 명령이 실행되는 경우 +■ InnoDB 모니터가 활성화되는 경우 +■ innodb_stats_on_metadata 시스템 설정이 ON인 상태에서 SHOW TABLE STATUS 명령이 실행되는 경우 +``` + +테이블 생성시 STAT_AUTO_RECALC 옵션을 이용해 테이블 단위로 통계 정보 갱신 이벤트를 설정할 수 있음 + +``` +■ STATS_AUTO_RECALC=1: 테이블의 통계 정보를 MySQL 5.5 이전의 방식대로 자동 수집한다. +■ STATS_AUTO_RECALC=0: 테이블의 통계 정보는 ANALYZE TABLE 명령을 실행할 때만 수집된다. +■ STATS_AUTO_RECALC=DEFAULT: 테이블을 생성할 때 별도로 STATS_AUTO_RECALC 옵션을 설정하지 않은 것과 동일하며, 테이블의 통계 정보 수집을 innodb_stats_auto_recalc 시스템 설정 변수의 값으로 결정한다. +``` + + +통계 정보 갱신 시 샘플링할 페이지수(MySQL 데이터 저장 단위) 설정 +``` +■ innodb_stats_transient_sample_pages +이 시스템 변수의 기본값은 8인데, 이는 자동으로 통계 정보 수집이 실행될 때 8개 페이지만 임의로 샘플링해서 분석하고 그 결과를 통계 정보로 활용함을 의미한다. +■ innodb_stats_persistent_sample_pages +기본값은 20이며, ANALYZE TABLE 명령이 실행되면 임의로 20개 페이지만 샘플링해서 분석하고 그 결과를 영구적인 통계 정보 테이블에 저장하고 활용함을 의미한다. +``` + +```sql +show variables like '%sample_pages%'; + +innodb_stats_persistent_sample_pages,20 +innodb_stats_transient_sample_pages,8 +``` + + +### 10.1.2. 히스토그램 + +- 5.7 버전까지의 통계 정보는 단순히 인덱스된 컬럼의 유니크한 값의 개수 정도만 가지고 있었고 이는 옵티마이저가 최적의 실행 계획을 수립하기에는 부족했음 +- 그래서 이러한 부족함을 보완하기 위해 옵티마이저는 실행 계획을 수립할 때 실제 인덱스의 일부 페이지를 랜덤으로 가져와 참조하는 방식을 사용 했었음 (실제 데이터 일부 샘플링) +- 8.0 버전으로 업그레이드 되면서 MySQL 서버도 컬럼의 데이터 분포를 참조할 수 있는 히스토그램 도입 + + +#### 10.1.2.1. 히스토그램 정보 수집 및 삭제 + +- 히스토그램 정보는 컬럼단위로 관리되는데 이는 자동으로 수집되지 않고 별도 수집이 필요함 + + +2가지 히스토그램 + +``` + +-- 1. hire_date: 높이 균형 히스토그램으로 생성 +-- 컬럼값의 범위를 균등한 개수로 구분해서 관리하는 히스토그램 + +mysql> ANALYZE TABLE employees.employees + UPDATE HISTOGRAM ON gender, hire_date; + +mysql> SELECT * + FROM COLUMN_STATISTICS + WHERE SCHEMA_NAME='employees' + AND TABLE_NAME='employees' +*************************** 1. row *************************** +SCHEMA_NAME: employees + TABLE_NAME: employees +COLUMN_NAME: gender + HISTOGRAM: {"buckets": [ + [1, 0.5998529796789721], + [2, 1.0] + ], + "data-type": "enum", + "null-values": 0.0, + "collation-id": 45, + "last-updated": "2020-08-03 03:47:45.739242", + "sampling-rate": 0.3477368727939573, + "histogram-type": "singleton", + "number-of-buckets-specified": 100 + } +*************************** 2. row *************************** +SCHEMA_NAME: employees + TABLE_NAME: employees +COLUMN_NAME: hire_date + HISTOGRAM: {"buckets": [ + ["1985-02-01", "1985-02-28", 0.009838277646869273, 28], + ["1985-03-01", "1985-03-28", 0.020159909773830382, 28], + ["1985-03-29", "1985-04-26", 0.030159305580730267, 29], + ["1985-04-27", "1985-05-24", 0.03999758322759954, 28], + ... + ["1997-01-24", "1997-06-25", 0.9700118824643023, 153], + ["1997-06-26", "1997-12-15", 0.980021348156204, 172], + ["1997-12-16", "1998-08-06", 0.9900006041931001, 233], + ["1998-08-07", "2000-01-06", 1.0, 420] + ], + "data-type": "date", + "null-values": 0.0, + "collation-id": 8, + "last-updated": "2020-08-03 03:47:45.742159", + "sampling-rate": 0.3477368727939573, + "histogram-type": "equi-height", + "number-of-buckets-specified": 100 + } + + + +-- 2. gender: 싱글톤 히스토그램으로 생성 +-- 컬럼값 개별로 레코드 건수를 관리하는 히스토그램 + +ANALYZE TABLE employees.employees + UPDATE HISTOGRAM ON gender; + +select * +from information_schema.COLUMN_STATISTICS; +where column_name='gender' + +{ +"buckets": [ +[1, 0.5998620110391168], +[2, 1.0]], +"data-type": "enum", +"null-values": 0.0, +"collation-id": 45, +"last-updated": "2023-07-25 10:20:51.267018", +"sampling-rate": 1.0, +"histogram-type": "singleton", +"number-of-buckets-specified": 100} +``` + +![alt](./src/img/10.2.%20높이%20균형%20히스토그램.png) + +```text +■ sampling-rate: 히스토그램 정보를 수집하기 위해 스캔한 페이지의 비율을 저장한다. 샘플링 비율이 0.35라면 전체 데이터 페이지의 35%를 스캔해서 이 정보가 수집됐다는 것을 의미한다. 물론 샘플링 비율이 높아질수록 더 정확한 히스토그램이 되겠지만, 테이블을 전부 스캔하는 것은 부하가 높으며 시스템의 자원을 많이 소모한다. 그래서 MySQL 서버는 histogram_generation_max_mem_size 시스템 변수에 설정된 메모리 크기에 맞게 적절히 샘플링한다. histogram_generation_max_mem_size 시스템 변수의 메모리 크기는 20MB로 초기화돼 있다. +■ histogram-type: 히스토그램의 종류를 저장한다. +■ number-of-buckets-specified: 히스토그램을 생성할 때 설정했던 버킷의 개수를 저장한다. 히스토그램을 생성할 때 별도로 버킷의 개수를 지정하지 않았다면 기본으로 100개의 버킷이 사용된다. 버킷은 최대 1024개를 설정할 수 있지만, 일반적으로 100개의 버킷이면 충분한 것으로 알려져 있다 +``` + +- 주의사항 + - MySQL 8.0.19 미만의 버전은 히스토그램 생성시 풀 스캔으로 데이터 페이지를 스캔해서 히스토그램을 생성했었음 (서버에 부하를 주고 오래 걸림) + - MySQL 8.0.19 버전 부터는 InnoDB 스토리지 엔진 자체적으로 샘플링 엔진 구현 + + + +#### 10.1.2.2. 히스토그램의 용도 + +히스토그램의 도입으로 데이터의 분포까지 고려해서 실행계획을 수립할 수 있게 되었음 + +히스토그램 정보가 없으면 옵티마이저는 아래와 같이 데이터가 균등하게 분포돼 있을 것으로 예측 +- 테이블의 레코드가 1000건 이고 어떤 컬럼의 유니크한 값의 개수가 100개였다면 아래 동등 비교 검색 결과는 대략 10개 일 것이라고 예측함 +```sql +mysql> SELECT * FROM order WHERE user_id='matt.lee'; +``` + + +``` +-- 1. 히스토그램 생성 전 +EXPLAIN +SELECT * +FROM employees +WHERE first_name='Zita' +AND birth_date BETWEEN '1950-01-01' AND '1960-01-01'; + ++----+-------------+-----------+------+--------------+------+----------+ +| id | select_type | table | type | key | rows | filtered | ++----+-------------+-----------+------+--------------+------+----------+ +| 1 | SIMPLE | employees | ref | ix_firstname | 224 | 11.11 | ++----+-------------+-----------+------+--------------+------+----------+ + +-- 예상 레코드 수: 224 * 0.1111 = 25 개 + + + +-- 2. 히스토그램 생성 후 +ANALYZE TABLE employees +UPDATE histogram ON first_name, birth_date; + +EXPLAIN +SELECT * +FROM employees +WHERE first_name='Zita' +AND birth_date BETWEEN '1950-01-01' AND '1960-01-01'; ++----+-------------+-----------+------+--------------+------+----------+ +| id | select_type | table | type | key | rows | filtered | ++----+-------------+-----------+------+--------------+------+----------+ +| 1 | SIMPLE | employees | ref | ix_firstname | 224 | 60.82 | ++----+-------------+-----------+------+--------------+------+----------+ + +-- 예상 레코드 수: 224 * 0.6082 = 136 개 + + +-- 3. 실제 레코드 수: 143개 +SELECT count(*) +FROM employees +WHERE first_name='Zita' +AND birth_date BETWEEN '1950-01-01' AND '1960-01-01'; + +``` +분포를 알면 유용할 상황 +- orders 테이블의 status 컬럼 + - 1000개 레코드 실제 분포 + - 700개 '완료' + - 150개 '대기' + - 100개 '배송중' + - 50개 '취소' + - 히스토그램이 없다면 모두 250개 일 것이라고 가정 + + +조인을 할 때 옵티마이저는 일반적으로 결과 집합이 적은 테이블을 선두 테이블로 설정 +- 각 컬럼에 대해 히스토그램 정보가 있으면 어느 테이블을 먼저 읽어야 조인의 횟수를 줄일 수 있을지 옵티마이저가 더 정확히 판단할 수 있음 +``` +-- 1. employees, salaries 테이블 순으로 조인 +explain analyze +SELECT /*+ JOIN_ORDER(e, s) */ * + FROM salaries s + INNER JOIN employees e ON e.emp_no=s.emp_no + AND e.birth_date BETWEEN '1950-01-01' AND '1950-02-01' + WHERE s.salary BETWEEN 40000 AND 70000; + +-> Nested loop inner join (cost=30630 rows=1429) (actual time=149..149 rows=0 loops=1) + -> Filter: (e.birth_date between '1950-01-01' and '1950-02-01') (cost=30177 rows=300) (actual time=149..149 rows=0 loops=1) + -> Table scan on e (cost=30177 rows=299612) (actual time=0.0888..93.1 rows=300024 loops=1) + -> Filter: (s.salary between 40000 and 70000) (cost=0.56 rows=4.77) (never executed) + -> Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=0.56 rows=9.54) (never executed) + +-- 2. salaries, employees 테이블 순으로 조인 +explain analyze +SELECT /*+ JOIN_ORDER(s, e) */ * + FROM salaries s + INNER JOIN employees e ON e.emp_no=s.emp_no + AND e.birth_date BETWEEN '1950-01-01' AND '1950-02-01' + WHERE s.salary BETWEEN 40000 AND 70000; + +-> Nested loop inner join (cost=783953 rows=70961) (actual time=1489..1489 rows=0 loops=1) + -> Filter: (s.salary between 40000 and 70000) (cost=287228 rows=1.42e+6) (actual time=0.124..540 rows=1.92e+6 loops=1) + -> Table scan on s (cost=287228 rows=2.84e+6) (actual time=0.122..434 rows=2.84e+6 loops=1) + -> Filter: (e.birth_date between '1950-01-01' and '1950-02-01') (cost=0.25 rows=0.05) (actual time=437e-6..437e-6 rows=0 loops=1.92e+6) + -> Single-row index lookup on e using PRIMARY (emp_no=s.emp_no) (cost=0.25 rows=1) (actual time=189e-6..208e-6 rows=1 loops=1.92e+6) +``` + + + +#### 10.1.2.3. 히스토그램과 인덱스 + +- 히스토그램과 인덱스는 완전히 다른 객체지만 MySQL 서버에서 인덱스는 부족한 통계 정보를 수집하기 위해 사용된다는 측면에서 어느 정도 공통점을 가진다고 볼 수 있음 +- 실행 계획을 수립할 때 사용 가능한 인덱스들로부터 조건절에 일치하는 레코드 건수를 대략적으로 파악해서 최종적으로 가장 나은 실행 계획 선택 +- 인덱스 다이브(Index Dive): 조건절에 일치하는 레코드 건수를 예측하기 위해 실제 인덱스의 B-Tree를 샘플링해서 살펴보는 것 + +```sql +-- first_name: 인덱스 +-- birth_date: 인덱스 없음 +mysql> SELECT * + FROM employees + WHERE first_name='Tonny' + AND birth_date BETWEEN '1954-01-01' AND '1955-01-01'; +``` + +- 인덱스 된 컬럼을 검색 조건으로 사용하는 경우 그 컬럼의 히스토그램은 사용하지 않고 실제 인덱스 다이브를 통해 직접 수집한 정보를 활용함 + + +### 10.1.3. 코스트 모델 (Cost Model) + + +MySQL 서버가 쿼리를 처리할 때 필요한 작업 들 +```text +■ 디스크로부터 데이터 페이지 읽기 +■ 메모리(InnoDB 버퍼 풀)로부터 데이터 페이지 읽기 +■ 인덱스 키 비교 +■ 레코드 평가 +■ 메모리 임시 테이블 작업 +■ 디스크 임시 테이블 작업 +``` + +- 위와 같이 전체 쿼리 비용을 계산하는데 필요한 단위 작업들의 비용을 코스트 모델이라고 함. +- MySQL 5.7 이전 버전까지는 단위 작업들의 비용이 MySQL 서버 소스 코드에 상수화 되어 있었음 +- 5.7 버전 이후로는 작업의 비용을 사용자가 조정할 수 있음 + + +```sql +-- 1. server_cost: 인덱스를 찾고 레코드를 비교하고 임시 테이블 처리에 대한 비용 관리 +select * from mysql.server_cost; +-- 2. engine_cost: 레코드를 가진 데이터 페이지를 가져오는 데 필요한 비용 관리 +select * from mysql.engine_cost; +``` + +위 테이블의 컬럼들 의미 + +```test +■ cost_name: 코스트 모델의 각 단위 작업 +■ default_value: 각 단위 작업의 비용(기본값이며, 이 값은 MySQL 서버 소스 코드에 설정된 값) +■ cost_value: DBMS 관리자가 설정한 값(이 값이 NULL이면 MySQL 서버는 default_value 칼럼의 비용 사용) +■ last_updated: 단위 작업의 비용이 변경된 시점 +■ comment: 비용에 대한 추가 설명 +engine_cost 테이블은 위의 5개 칼럼에 추가로 다음 2개 칼럼을 더 가지고 있다. +■ engine_name: 비용이 적용된 스토리지 엔진 +■ device_type: 디스크 타입 +``` + +![alt](./src/img/10.%20코스트%20모델%20테이블.png) + +코스트 모델에서 중요한 것은 각 단위 작업에 설정되는 비용 값이 커지면 어떤 실행 계획들이 고비용으로 바뀌고 어떤 실행 계획들이 저비용으로 바뀌는지 파악하는 것 + +<각 단위 작업의 비용이 변경되면 예상할 수 있는 대략적인 결과들> + +``` +■ key_compare_cost 비용을 높이면 MySQL 서버 옵티마이저가 가능하면 정렬을 수행하지 않는 방향의 실행 계획을 선택할 가능성이 높아진다. + +■ row_evaluate_cost 비용을 높이면 풀 스캔을 실행하는 쿼리들의 비용이 높아지고, MySQL 서버 옵티마이저는 가능하면 인덱스 레인지 스캔을 사용하는 실행 계획을 선택할 가능성이 높아진다. + +■ disk_temptable_create_cost와 disk_temptable_row_cost 비용을 높이면 MySQL 옵티마이저는 디스크에 임시 테이블을 만들지 않는 방향의 실행 계획을 선택할 가능성이 높아진다. + +■ memory_temptable_create_cost와 memory_temptable_row_cost 비용을 높이면 MySQL 서버 옵티마이저는 메모리 임시 테이블을 만들지 않는 방향의 실행 계획을 선택할 가능성이 높아진다. + +■ io_block_read_cost 비용이 높아지면 MySQL 서버 옵티마이저는 가능하면 InnoDB 버퍼 풀에 데이터 페이지가 많이 적재돼 있는 인덱스를 사용하는 실행 계획을 선택할 가능성이 높아진다. + +■ memory_block_read_cost 비용이 높아지면 MySQL 서버는 InnoDB 버퍼 풀에 적재된 데이터 페이지가 상대적으로 적다고 하더라도 그 인덱스를 사용할 가능성이 높아진다 +``` + + +## 10.2. 실행 계획 확인 + +- 실행 계획의 출력 포맷 조정 (테이블, Tree, JSON) +- 실제 쿼리의 실행 결과 까지 확인 (explain analyze) + +### 10.2.1. 실행 계획 출력 포맷 + +실행 계획: 테이블 포맷 +```sql +mysql> EXPLAIN + SELECT * + FROM employees e + INNER JOIN salaries s ON s.emp_no=e.emp_no + WHERE first_name='ABC'; ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +| 1 | SIMPLE | e | NULL | ref | PRIMARY,ix_firstname | ix_firstname | 58 | const | 1 | 100.00 | NULL | +| 1 | SIMPLE | s | NULL | ref | PRIMARY | PRIMARY | 4 | employees.e.emp_no | 10 | 100.00 | NULL | ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +``` + +실행 계획: 트리 포맷 +```sql +mysql> EXPLAIN FORMAT=TREE + SELECT * + FROM employees e + INNER JOIN salaries s ON s.emp_no=e.emp_no + WHERE first_name='ABC'\G +*************************** 1. row *************************** +EXPLAIN: -> Nested loop inner join (cost=2.40 rows=10) + -> Index lookup on e using ix_firstname (first_name='ABC') (cost=0.35 rows=1) + -> Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=2.05 rows=10) +``` + + +실행 계획: JSON 포맷 +```sql + SELECT * + FROM employees e + INNER JOIN salaries s ON s.emp_no=e.emp_no + WHERE first_name='ABC'\G +*************************** 1. row *************************** +EXPLAIN: { + "query_block": { + "select_id": 1, + "cost_info": { + "query_cost": "2.40" + }, + "nested_loop": [ + { + "table": { + "table_name": "e", + "access_type": "ref", + "possible_keys": [ + "PRIMARY", + "ix_firstname" + ], + "key": "ix_firstname", + "used_key_parts": [ + "first_name" + ], +``` + + +#### 10.2.2. 쿼리의 실행 시간 확인 + +```sql +mysql> EXPLAIN ANALYZE + SELECT e.emp_no, avg(s.salary) + FROM employees e + INNER JOIN salaries s ON s.emp_no=e.emp_no + AND s.salary>50000 + AND s.from_date<='1990-01-01' + AND s.to_date>'1990-01-01' + WHERE e.first_name='Matt' + GROUP BY e.hire_date \G +A) -> Table scan on (actual time=0.001..0.004 rows=48 loops=1) +B) -> Aggregate using temporary table (actual time=3.799..3.808 rows=48 loops=1) +C) -> Nested loop inner join (cost=685.24 rows=135) + (actual time=0.367..3.602 rows=48 loops=1) +D) -> Index lookup on e using ix_firstname (first_name='Matt') (cost=215.08 rows=233) + (actual time=0.348..1.046 rows=233 loops=1) +E) -> Filter: ((s.salary > 50000) and (s.from_date <= DATE'1990-01-01') + and (s.to_date > DATE'1990-01-01')) (cost=0.98 rows=1) + (actual time=0.009..0.011 rows=0 loops=233) +F) -> Index lookup on s using PRIMARY (emp_no=e.emp_no) (cost=0.98 rows=10) + (actual time=0.007..0.009 rows=10 loops=233) +``` + +![[10.쿼리 실행시간 확인.png]] + +```text +■ actual time=0.007..0.009: employees 테이블에서 읽은 emp_no 값을 기준으로 salaries 테이블에서 일치하 는 레코드를 검색하는 데 걸린 시간(밀리초)을 의미한다. 이때 숫자 값이 2개가 표시되는데, 첫 번째 숫자 값은 첫 번째 레코드를 가져오는 데 걸린 평균 시간(밀리초)을 의미한다. 두 번째 숫자 값은 마지막 레코드를 가져오는 데 걸린 평균 시간(밀리초)을 의미한다. + +■ rows=10: employees 테이블에서 읽은 emp_no에 일치하는 salaries 테이블의 평균 레코드 건수를 의미한다 + +■ loops=233: employees 테이블에서 읽은 emp_no를 이용해 salaries 테이블의 레코드를 찾는 작업이 반복된 횟수 +를 의미한다. 결국 여기서는 employees 테이블에서 읽은 emp_no의 개수가 233개임을 의미한다. +``` + + +## 10.3. 실행 계획 분석 + +- 실행 계획의 출력 보다는 실행 계호기이 어떤 접근 방법을 사용해서 어떤 최적화를 수행하는지, 그리고 어떤 인덱스를 사용하는지 등을 이해하는 것이 더 중요하다. + +```sql +-- 출력된 실행 계획에서 위쪽에 출력된 결과일수록(id 컬럼의 값이 작을 수록) 쿼리의 바깥 부분이거나 먼저 접근한 테이블 +-- 아래쪽에 출력된 결과일 수록(id 컬럼의 값이 클수록) 쿼리의 안쪽 또는 나중에 접근한 테이블 + +mysql> EXPLAIN + SELECT * + FROM employees e + INNER JOIN salaries s ON s.emp_no=e.emp_no + WHERE first_name='ABC'; ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +| 1 | SIMPLE | e | NULL | ref | PRIMARY,ix_firstname | ix_firstname | 58 | const | 1 | 100.00 | NULL | +| 1 | SIMPLE | s | NULL | ref | PRIMARY | PRIMARY | 4 | employees.e.emp_no | 10 | 100.00 | NULL | ++----+-------------+-------+------------+------+----------------------+--------------+---------+--------------------+------+----------+-------+ +``` + + +### 10.3.1. id 컬럼 + +- 실행 계획에서 가장 왼쪽에 표시되는 id 컬럼은 Select 쿼리 별로 부여되는 식별자 값 + - 반드시 테이블의 접근 순서를 의미하지는 않음 +- 여러개의 테이블이 조인되는 경우에는 id값이 증가하지 않고 같은 id 값이 부여 +```sql +mysql> EXPLAIN + SELECT e.emp_no, e.first_name, s.from_date, s.salary + FROM employees e, salaries s + WHERE e.emp_no=s.emp_no LIMIT 10; ++----+-------------+-------+-------+--------------+--------------------+--------+-------------+ +| id | select_type | table | type | key | ref | rows | Extra | ++----+-------------+-------+-------+--------------+--------------------+--------+-------------+ +| 1 | SIMPLE | e | index | ix_firstname | NULL | 300252 | Using index | +| 1 | SIMPLE | s | ref | PRIMARY | employees.e.emp_no | 10 | NULL | ++----+-------------+-------+-------+--------------+--------------------+--------+-------------+ +``` + +```sql +mysql> EXPLAIN + SELECT + ( (SELECT COUNT(*) FROM employees) + (SELECT COUNT(*) FROM departments) ) AS total_ +count; ++----+-------------+-------------+-------+-------------+------+--------+----------------+ +| id | select_type | table | type | key | ref | rows | Extra | ++----+-------------+-------------+-------+-------------+------+--------+----------------+ +| 1 | PRIMARY | NULL | NULL | NULL | NULL | NULL | No tables used | +| 3 | SUBQUERY | departments | index | ux_deptname | NULL | 9 | Using index | +| 2 | SUBQUERY | employees | index | ix_hiredate | NULL | 300252 | Using index | ++----+-------------+-------------+-------+-------------+------+--------+----------------+ +``` + +```sql +mysql> EXPLAIN FORMAT=TREE + SELECT * FROM dept_emp de + WHERE de.emp_no= (SELECT e.emp_no + FROM employees e + WHERE e.first_name='Georgi' + AND e.last_name='Facello' LIMIT 1); ++----+-------------+-------+------+-------------------+------+-------------+ +| id | select_type | table | type | key | rows | Extra | ++----+-------------+-------+------+-------------------+------+-------------+ +| 1 | PRIMARY | de | ref | ix_empno_fromdate | 1 | Using where | +| 2 | SUBQUERY | e | ref | ix_firstname | 253 | Using where | ++----+-------------+-------+------+-------------------+------+-------------+ + +-> Filter: (de.emp_no = (select #2)) (cost=0.35 rows=1) (actual time=0.0259..0.0285 rows=1 loops=1) + -> Index lookup on de using ix_empno_fromdate (emp_no=(select #2)) (cost=0.35 rows=1) (actual time=0.0249..0.0273 rows=1 loops=1) + -> Select #2 (subquery in condition; run only once) + -> Limit: 1 row(s) (cost=65.8 rows=1) (actual time=0.39..0.39 rows=1 loops=1) + -> Filter: (e.last_name = 'Facello') (cost=65.8 rows=25.3) (actual time=0.39..0.39 rows=1 loops=1) + -> Index lookup on e using ix_firstname (first_name='Georgi') (cost=65.8 rows=253) (actual time=0.387..0.387 rows=1 loops=1) + +``` + + +### 10.3.2. select_type 컬럼 + +- 각 단위 SELECT 쿼리가 어떤 타입의 쿼리인지 표시하는 컬럼 + - 각 단위 SELECT 쿼리 + - 각 단위 SELECT 쿼리 별로 테이블이 생성 + + +#### 10.3.2.1. SIMPLE + +- UNION이나 서브쿼리를 사용하지 않는 단순한 SELECT 쿼리인 경우 해당 쿼리 문장의 select_type은SIMPLE로 표시 +- 쿼리 문장이 아무리 복잡하더라도 실행 계획에서 select_type이 SIMPLE인 단위 쿼리는 하나만 존재 +- 일반적으로 가장 바깥 Select 쿼리 + + +#### 10.3.2.2. PRIMARY +- UNION이나 서브쿼리를 가지는 SELECT 쿼리의 실행 계획에서 가장 바깥쪽(Outer)에 있는 단위 쿼리는select_type이 PRIMARY로 표시 +- SIMPLE과 마찬가지로 select_type이 PRIMARY인 단위 SELECT 쿼리는 하나만 존재하며, 쿼리의 제일 바깥쪽에 있는 SELECT 단위 쿼리가 PRIMARY + +#### 10.3.2.3. UNION +- UNION으로 결합하는 단위 SELECT 쿼리 가운데 첫 번째를 제외한 두 번째 이후 단위 SELECT 쿼리의 select_type은 UNION으로 표시 + + +```sql +mysql> EXPLAIN + SELECT * FROM ( + (SELECT emp_no FROM employees e1 LIMIT 10) UNION ALL + (SELECT emp_no FROM employees e2 LIMIT 10) UNION ALL + (SELECT emp_no FROM employees e3 LIMIT 10) ) tb; + + ++----+-------------+------------+-------+-------------+------+--------+-------------+ +| id | select_type | table | type | key | ref | rows | Extra | ++----+-------------+------------+-------+-------------+------+--------+-------------+ +| 1 | PRIMARY | | ALL | NULL | NULL | 30 | NULL | +| 2 | DERIVED | e1 | index | ix_hiredate | NULL | 300252 | Using index | +| 3 | UNION | e2 | index | ix_hiredate | NULL | 300252 | Using index | +| 4 | UNION | e3 | index | ix_hiredate | NULL | 300252 | Using index | ++----+-------------+------------+-------+-------------+------+--------+-------------+ +``` + + +#### 10.3.2.4. DEPENDENT UNION + + +```sql +mysql> EXPLAIN + SELECT * + FROM employees e1 WHERE e1.emp_no IN ( + SELECT e2.emp_no FROM employees e2 WHERE e2.first_name='Matt' + UNION + SELECT e3.emp_no FROM employees e3 WHERE e3.last_name='Matt' + ); + +-- UNION에 사용된 SELECT 쿼리의 WHERE 조건에 "e2.emp_no=e1.emp_no"와 +-- "e3.emp_no=e1.emp_no"라는 조건이 자동으로 추가되어 실행된다. +-- 외부에 정의된 employees 테이블의 emp_no 칼럼이 서브쿼리에 사용되기 때문에 DEPENDENT UNION이 +-- select_type에 표시된 것이다. ++----+--------------------+------------+--------+---------+------+--------+-----------------+ +| id | select_type | table | type | key | ref | rows | Extra | ++----+--------------------+------------+--------+---------+------+--------+-----------------+ +| 1 | PRIMARY | e1 | ALL | NULL | NULL | 300252 | Using where | +| 2 | DEPENDENT SUBQUERY | e2 | eq_ref | PRIMARY | func | 1 | Using where | +| 3 | DEPENDENT UNION | e3 | eq_ref | PRIMARY | func | 1 | Using where | +|NULL| UNION RESULT | | ALL | NULL | NULL | NULL | Using temporary | ++----+--------------------+------------+--------+---------+------+--------+-----------------+ +``` + +#### 10.3.2.5. UNION RESULT + +- Union (Union distinct)결과를 담아두는 테이블을 의미함 +- Union all의 경우 임시테이블을 생성되지 않도록 개선됨 (Union all의 경우 임시테이블에 버퍼링하지 않기 때문에 UNION RESUTL 라인이 없어짐) + + +```sql +-- 1. Union +mysql> EXPLAIN + SELECT emp_no FROM salaries WHERE salary>100000 + UNION DISTINCT + SELECT emp_no FROM dept_emp WHERE from_date>'2001-01-01'; ++----+--------------+------------+-------+-------------+--------+--------------------------+ +| id | select_type | table | type | key | rows | Extra | ++----+--------------+------------+-------+-------------+--------+--------------------------+ +| 1 | PRIMARY | salaries | range | ix_salary | 191348 | Using where; Using index | +| 2 | UNION | dept_emp | range | ix_fromdate | 5325 | Using where; Using index | +|NULL| UNION RESULT | | ALL | NULL | NULL | Using temporary | ++----+--------------+------------+-------+-------------+--------+--------------------------+ + +-- 2. Union all +mysql> EXPLAIN + SELECT emp_no FROM salaries WHERE salary>100000 + UNION ALL + SELECT emp_no FROM dept_emp WHERE from_date>'2001-01-01'; ++----+-------------+----------+-------+-------------+--------+--------------------------+ +| id | select_type | table | type | key | rows | Extra | ++----+-------------+----------+-------+-------------+--------+--------------------------+ +| 1 | PRIMARY | salaries | range | ix_salary | 191348 | Using where; Using index | +| 2 | UNION | dept_emp | range | ix_fromdate | 5325 | Using where; + +``` + + +#### 10.3.2.6. SUBQUERY +- select_type의 SUBQUERY는 FROM 절 이외에서 사용되는 서브쿼리만을 의미한다 +- FROM 절에 사용된 서브쿼리는 DERIVED로 표시 + +```sql +mysql> EXPLAIN + SELECT e.first_name, + (SELECT COUNT(*) + FROM dept_emp de, dept_manager dm + WHERE dm.dept_no=de.dept_no) AS cnt + FROM employees e WHERE e.emp_no=10001; ++----+-------------+-------+-------+---------+-------+-------------+ +| id | select_type | table | type | key | rows | Extra | ++----+-------------+-------+-------+---------+-------+-------------+ +| 1 | PRIMARY | e | const | PRIMARY | 1 | NULL | +| 2 | SUBQUERY | dm | index | PRIMARY | 24 | Using index | +| 2 | SUBQUERY | de | ref | PRIMARY | 41392 | Using index | ++----+-------------+-------+-------+---------+-------+-------------+ +``` + + +```text +서브쿼리는 사용하는 위치에 따라 각각 다른 이름을 지니고 있다. +■ 중첩된 쿼리(Nested Query): SELECT되는 칼럼에 사용된 서브쿼리를 네스티드 쿼리라고 한다. +■ 서브쿼리(Subquery): WHERE 절에 사용된 경우에는 일반적으로 그냥 서브쿼리라고 한다. +■ 파생 테이블(Derived Table): FROM 절에 사용된 서브쿼리를 MySQL에서는 파생 테이블이라고 하며, 일반 +적으로 RDBMS에서는 인라인 뷰(Inline View) 또는 서브 셀렉트(Sub Select)라고 부른다. +또한 서브쿼리가 반환하는 값의 특성에 따라 다음과 같이 구분하기도 한다. +■ 스칼라 서브쿼리(Scalar Subquery): 하나의 값만(칼럼이 단 하나인 레코드 1건만) 반환하는 쿼리 +■ 로우 서브쿼리(Row Subquery): 칼럼의 개수와 관계없이 하나의 레코드만 반환하는 쿼리 +``` + + +#### 10.3.2.7. DEPENDENT SUBQUERY + +서브 쿼리가 바깥쪽 SELECT 쿼리에서 정의된 컬럼을 사용하는 경우 + +```sql +mysql> EXPLAIN + SELECT e.first_name, + (SELECT COUNT(*) + FROM dept_emp de, dept_manager dm + WHERE dm.dept_no=de.dept_no AND de.emp_no=e.emp_no) AS cnt + FROM employees e + WHERE e.first_name='Matt'; + ++----+--------------------+-------+------+-------------------+------+-------------+ +| id | select_type | table | type | key | rows | Extra | ++----+--------------------+-------+------+-------------------+------+-------------+ +| 1 | PRIMARY | e | ref | ix_firstname | 233 | Using index | +| 2 | DEPENDENT SUBQUERY | de | ref | ix_empno_fromdate | 1 | Using index | +| 2 | DEPENDENT SUBQUERY | dm | ref | PRIMARY | 2 | Using index | +``` + +안쪽의 서브쿼리과 바깥쪽 Select 쿼리의 컬럼에 의존적이기 때문에 Dependent 키워드가 붙는다. +- 안쪽 select count ... +- 바깥쪽 employees + + + +#### 10.3.2.8 DERIVED +- DERIVED는 단위 SELECT 쿼리의 실행 결과로 메모리나 디스크에 임시 테이블을 생성하는 것을 의미한다. (파생 테이블) + +```sql + EXPLAIN + SELECT * + FROM (SELECT de.emp_no FROM dept_emp de GROUP BY de.emp_no) tb, + employees e + WHERE e.emp_no=tb.emp_no; + +-> Nested loop inner join (cost=213303 rows=305063) (actual time=103..332 rows=300024 loops=1) + -> Table scan on tb (cost=96597..100413 rows=305063) (actual time=103..120 rows=300024 loops=1) + -> Materialize (cost=96597..96597 rows=305063) (actual time=103..103 rows=300024 loops=1) + -> Group (no aggregates) (cost=66091 rows=305063) (actual time=0.0783..88.9 rows=300024 loops=1) + -> Covering index scan on de using ix_empno_fromdate (cost=33138 rows=329534) (actual time=0.0742..53.1 rows=331603 loops=1) + -> Single-row index lookup on e using PRIMARY (emp_no=tb.emp_no) (cost=0.25 rows=1) (actual time=596e-6..614e-6 rows=1 loops=300024) + + +explain analyze +SELECT e.* +FROM dept_emp de +INNER JOIN employees e ON e.emp_no = de.emp_no +GROUP BY de.emp_no; + +-> Table scan on (cost=169829..173877 rows=323646) (actual time=935..993 rows=300024 loops=1) + -> Temporary table with deduplication (cost=169829..169829 rows=323646) (actual time=935..935 rows=300024 loops=1) + -> Nested loop inner join (cost=137465 rows=323646) (actual time=0.126..487 rows=331603 loops=1) + -> Table scan on e (cost=30177 rows=299612) (actual time=0.0977..80.1 rows=300024 loops=1) + -> Covering index lookup on de using ix_empno_fromdate (emp_no=e.emp_no) (cost=0.25 rows=1.08) (actual time=0.00104..0.00125 rows=1.11 loops=300024) +``` + +- MySQL 서버의 버전이 업그레이드 되면서 조인 쿼리에 대한 최적화는 많이 성숙된 상태다. 그래서 파생 테이블에 대한 최적화가 부족한 버전의 MySQL 서버를 사용 중일 경우, 가능하다면 DERIVED 형태의 실행 계획을 조인으로 해결할 수 있게 쿼리를 바꿔주는 것이 좋다. + + +#### 10.3.2.9 DEPENDENT DERIVED + +- 사용해본적 없었습니다. +- `LEFT JOIN LATERAL` +```sql +mysql> + SELECT * + FROM employees e + LEFT JOIN LATERAL + (SELECT * + FROM salaries s + WHERE s.emp_no=e.emp_no + ORDER BY s.from_date DESC LIMIT 2) AS s2 ON s2.emp_no=e.emp_no; + +-- salareis를 최대 2개만 가져와서 보여준다. + ++----+-------------------+------------+------+-------------+----------------------------+ +| id | select_type | table | type | key | Extra | ++----+-------------------+------------+------+-------------+----------------------------+ +| 1 | PRIMARY | e | ALL | NULL | Rematerialize () | +| 1 | PRIMARY | | ref | | NULL | +| 2 | DEPENDENT DERIVED | s | ref | PRIMARY | Using filesort | ++----+-------------------+------------+------+-------------+----------------------------+ +``` + + + +#### 10.3.2.10. UNCACHEABLE SUBQUERY + +캐시된 결과 확인 예시 + +캐시를 사용하지 못하게하는 대표적인 요소 +```text +■ 사용자 변수가 서브쿼리에 사용된 경우 +■ NOT-DETERMINISTIC 속성의 스토어드 루틴이 서브쿼리 내에 사용된 경우 +■ UUID()나 RAND()와 같이 결괏값이 호출할 때마다 달라지는 함수가 서브쿼리에 사용된 경우 +``` + +```sql +mysql> EXPLAIN + SELECT * + FROM employees e WHERE e.emp_no = ( + SELECT @status FROM dept_emp de WHERE de.dept_no='d005'); ++----+----------------------+-------+------+---------+--------+-------------+ +| id | select_type | table | type | key | rows | Extra | ++----+----------------------+-------+------+---------+--------+-------------+ +| 1 | PRIMARY | e | ALL | NULL | 300252 | Using where | +| 2 | UNCACHEABLE SUBQUERY | de | ref | PRIMARY | 165571 | Using index | ++----+----------------------+-------+------+---------+--------+-------------+ +``` + + + +#### 10.3.2.11. UNCACHEABLE UNION +- Union 결과를 다시 사용하지 못하는 것 + + +#### 10.3.2.12. MATERIALIZED +- 5.6 버전 부터 도입 + +아래 처럼 서브쿼리를 구체화 (임시 테이블 생성)) +```sql +mysql> EXPLAIN + SELECT * + FROM employees e + WHERE e.emp_no IN (SELECT emp_no FROM salaries WHERE salary BETWEEN 100 AND 1000); + ++----+--------------+-------------+--------+-----------+------+--------------------------+ +| id | select_type | table | type | key | rows | Extra | ++----+--------------+-------------+--------+-----------+------+--------------------------+ +| 1 | SIMPLE | | ALL | NULL | NULL | NULL | +| 1 | SIMPLE | e | eq_ref | PRIMARY | 1 | NULL | +| 2 | MATERIALIZED | salaries | range | ix_salary | 1 | Using where; Using index | ++----+--------------+-------------+--------+-----------+------+--------------------------+ +``` + + +### 10.3.3. table 컬럼 + +MySQL 서버의 실행 계획은 Slect 쿼리 기준이 아니라 테이블 기준으로 표시된다. +테이블의 이름에 별칭이 부여된 경우에는 별칭이 표시된다. + + +```sql +mysql> EXPLAIN SELECT NOW(); +mysql> EXPLAIN SELECT NOW() FROM DUAL + + ++----+-------------+-------+------+---------+----------------+ +| id | select_type | table | key | key_len | Extra | ++----+-------------+-------+------+---------+----------------+ +| 1 | SIMPLE | NULL | NULL | NULL | No tables used | ++----+-------------+-------+------+---------+----------------+ +``` + + +임시 테이블 <> +```sql ++----+-------------+------------+--------+-------------------+--------+-------------+ +| id | select_type | table | type | key | rows | Extra | ++----+-------------+------------+--------+-------------------+--------+-------------+ +| 1 | PRIMARY | | ALL | NULL | 331143 | NULL | +| 1 | PRIMARY | e | eq_ref | PRIMARY | 1 | NULL | +| 2 | DERIVED | de | index | ix_empno_fromdate | 331143 | Using index | ++----+-------------+------------+--------+-------------------+--------+-------------+ +``` + + +### 10.3.4. partitions 컬럼 + +그런데 파티션 생성 시 제약 사항(파티션 키로 사용되는 컬럼은 프라이머리 키를 포함한 모든 유니크 인덱스의 일부여야 함)으로 인해 프라이머리 키를 (emp_no, hire_date)로 생성했다. + +```sql +mysql> CREATE TABLE employees_2 ( + emp_no int NOT NULL, + birth_date DATE NOT NULL, + first_name VARCHAR(14) NOT NULL, + last_name VARCHAR(16) NOT NULL, + gender ENUM('M','F') NOT NULL, + hire_date DATE NOT NULL, + PRIMARY KEY (emp_no, hire_date) + ) PARTITION BY RANGE COLUMNS(hire_date) + (PARTITION p1986_1990 VALUES LESS THAN ('1990-01-01'), + PARTITION p1991_1995 VALUES LESS THAN ('1996-01-01'), + PARTITION p1996_2000 VALUES LESS THAN ('2000-01-01'), + PARTITION p2001_2005 VALUES LESS THAN ('2006-01-01')); +mysql> INSERT INTO employees_2 SELECT * FROM employees; + + +-- 파티션에 대한 풀 스캔 진행 + EXPLAIN analyze SELECT * + FROM employees_2 + WHERE hire_date BETWEEN '1999-11-15' AND '2000-01-15'; + ++----+-------------+-------------+-----------------------+------+-------+ +| id | select_type | table | partitions | type | rows | ++----+-------------+-------------+-----------------------+------+-------+ +| 1 | SIMPLE | employees_2 | p1996_2000,p2001_2005 | ALL | 21743 | ++----+-------------+-------------+-----------------------+------+-------+ + +-> Filter: (employees_2.hire_date between '1999-11-15' and '2000-01-15') (cost=2201 rows=2418) (actual time=0.334..30.9 rows=68 loops=1) + -> Table scan on employees_2 (cost=2201 rows=21763) (actual time=0.0804..17.6 rows=21925 loops=1) +``` + + +### 10.3.5. type 컬럼 + +- 쿼리의 실행 계획에서 type 이후의 컬럼은 MySQL 서버가 각 테이블의 레코드를 어떤 방식으로 읽었는지를 나타냄 +- 여기서 접근 방식이라 함은 인덱스를 사용해 레코드를 읽었는지 아니면 테이블을 처음부터 끝까지 읽는 풀 테이블 스캔으로 레코드를 읽었는지 등을 의미함 +- 쿼리 튜닝 시 인덱스를 효율적으로 사용하는지 확인하는 것이 중요하므로 실행 계획에서 type 컬럼은 반드시 체크해야함 + +각 테이블에 대한 접근 방법 12가지 + +```text +■ 1.system +■ 2.const +■ 3.eq_ref +■ 4.ref +■ 5.fulltext +■ 6.ref_or_null +■ 7.unique_subquery +■ 8.index_subquery +■ 9.range +■ 10.index_merge +■ 11.index +■ 12.ALL +``` +1. 위의 12가지 접근 방법은 성능이 빠른 순서대로 나열 +2. ALL을 제외한 나머지는 모두 인덱스를 사용하는 접근 +3. 하나의 단위 Select 쿼리는 위의 접근 방법중 단 1가지만 사용할 수 있음 +4. index_merge를 제외한 접근은 오직 1개의 인덱스만 사용함 + +#### 10.3.5.1. system +#### 10.3.5.2. const +#### 10.3.5.3. eq_ref +#### 10.3.5.4. ref + +인덱스의 종류와 관계없이 동등 조건으로 검색할 때는 ref 접근 방법이 사용된다 + +```sql +mysql> EXPLAIN + SELECT * FROM dept_emp WHERE dept_no='d005'; ++----+-------------+----------+------+---------+---------+-------+ +| id | select_type | table | type | key | key_len | ref | ++----+-------------+----------+------+---------+---------+-------+ +| 1 | SIMPLE | dept_emp | ref | PRIMARY | 16 | const | ++----+-------------+----------+------+---------+---------+-------+ +``` + +- ref 컬럼의 cont는 접근 방법이 아니라 ref 접근 방법에서 값 비교에 사용된 입력값이 상수('d005')였음을 의미함 + + +#### 10.3.5.5. fulltext +#### 10.3.5.6. ref_or_null +#### 10.3.5.7. unique_subquery +#### 10.3.5.8. index_subquery +#### 10.3.5.9. range + +```text +이 책에서 인덱스 레인지 스캔이라고 하면 const, ref, range라는 세 가지 접근 방법을 모두 묶어서 지칭하는 것임을 기억하자. 또한 “인덱스를 효율적으로 사용한다” 또는 “작업 범위 결정 조건으로 인덱스를 사용한다”라는 표현 모두 이 세 가지 접근 방법을 의미한다. 업무상 개발자나 DBA와 소통할 때도 const나 ref, range 접근 방법을 구분해서 언급하는 경우는 거의 없으며, 일반적으로 “인덱스 레인지 스캔” 또는 “레인지 스캔”으로 언급할 때가 많다. +``` +#### 10.3.5.10. index_merge +#### 10.3.5.11. index +#### 10.3.5.12. ALL + +### 10.3.6. possible_keys 컬럼 + +### 10.3.7. key 컬럼 +- key 컬럼에 표시되는 인덱스는 최종 선택된 실행 계획에서 사용하는 인덱스를 의미 +- 그러므로 쿼리를 튜닝할 때는 key 칼럼에 의도했던 인덱스가 표시되는지 확인하는 것이 중요 +- key 칼럼에 표시되는 값이 PRIMARY인 경우에는 프라이머리 키를 사용한다는 의미이며, 그 이외의 값은 모두 테이블이나 인덱스를 생성할 때 부여했던 고유 이름 + +### 10.3.8. key_len 컬럼 + + +### 10.3.9 ref 컬럼 +- 테이블 접근 방법이 ref 일 때(동등 비교조건 일 때) 어떤 값이 제공됐는지 보여줌 +- 상숫값을 지정했다면 ref 컬럼의 값은 const로 표시되고, 다른 테이블의 컬럼 값이면 그 테이블명과 컬럼명이 표시됨 + +Const +```sql +mysql> EXPLAIN + SELECT * FROM dept_emp WHERE dept_no='d005'; ++----+-------------+----------+------+---------+---------+-------+ +| id | select_type | table | type | key | key_len | ref | ++----+-------------+----------+------+---------+---------+-------+ +| 1 | SIMPLE | dept_emp | ref | PRIMARY | 16 | const | ++----+-------------+----------+------+---------+---------+-------+ + +``` + +다른 테이블의 컬럼 참조 값 +```sql +mysql> EXPLAIN + SELECT * + FROM employees e, dept_emp de + WHERE e.emp_no=de.emp_no; ++----+-------------+-------+--------+---------+---------------------+ +| id | select_type | table | type | key | ref | ++----+-------------+-------+--------+---------+---------------------+ +| 1 | SIMPLE | de | ALL | NULL | NULL | +| 1 | SIMPLE | e | eq_ref | PRIMARY | employees.de.emp_no | ++----+-------------+-------+--------+---------+---------------------+ +``` + + +### 10.3.10. rows 컬럼 + +- 실행 계획의 효율성 판단을 위해 예측했던 레코드 건수를 보여줌 + - 반환하는 레코드 개수의 예측치가 아니라 쿼리를 처리하기 위해 얼마나 많은 레코드를 읽고 처리해야하는지를 의미 +```sql +mysql> EXPLAIN + SELECT * FROM dept_emp WHERE from_date>='1985-01-01'; + +----+-------------+----------+------+------+---------+--------+ +| id | select_type | table | type | key | key_len | rows | ++----+-------------+----------+------+------+---------+--------+ +| 1 | SIMPLE | dept_emp | ALL | NULL | NULL | 331143 | ++----+-------------+----------+------+------+---------+--------+ +``` + +### 10.3.11. filtered 컬럼 + +- 각 테이블에서 일치하는 레코드 개수를 예측하면 좀 더 효율적인 실행 계획을 수립할 수 있음 + - ex. 최종적으로 일치하는 레코드 건수가 적은 테이블을 드라이빙 테이블로 결정 +```sql +mysql> EXPLAIN + SELECT * + FROM employees e, + salaries s + WHERE e.first_name='Matt' + AND e.hire_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.emp_no=e.emp_no + AND s.from_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.salary BETWEEN 50000 AND 60000; + ++----+-------------+-------+------+--------------+------+----------+ +| id | select_type | table | type | key | rows | filtered | ++----+-------------+-------+------+--------------+------+----------+ +| 1 | SIMPLE | e | ref | ix_firstname | 233 | 16.03 | +| 1 | SIMPLE | s | ref | PRIMARY | 10 | 0.48 | ++----+-------------+-------+------+--------------+------+----------+ + +-- 233 * 0.1603 = 약 37 +``` + +```sql +mysql> EXPLAIN + SELECT /*+ JOIN_ORDER(s, e) */ * + FROM employees e, + salaries s + WHERE e.first_name='Matt' + AND e.hire_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.emp_no=e.emp_no + AND s.from_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.salary BETWEEN 50000 AND 60000; + ++----+-------------+-------+--------+-----------+------+----------+ +| id | select_type | table | type | key | rows | filtered | ++----+-------------+-------+--------+-----------+------+----------+ +| 1 | SIMPLE | s | range | ix_salary | 3314 | 11.11 | +| 1 | SIMPLE | e | eq_ref | PRIMARY | 1 | 5.00 | ++----+-------------+-------+--------+-----------+------+----------+ + +-- 3314 * 0.1111 = 약 368 +``` + +### 10.3.12. extra 컬럼 + +- 실행 계획에서 성능에 관련한 중요한 내용을 extra 컬럼에 표시 +- 일반적으로 2~3개씩 함께 표시되고 주로 내부적인 처리 알고리즘에 대해 조금 더 깊이 있는 내용을 보여주는 경우가 많음 +```sql +EXPLAIN +SELECT DISTINCT d.dept_no +FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no; +``` + +![[10. Extra 컬럼 예시.png]] +- MySQL 서버의 버전이 업그레이드 되고 최적화 기능이 도입될수록 새로운 내용이 더 추가될 것으로 보임 + - 옵티마이저 스위치 옵션이 추가되면 더 추가될 것 같아요. + +#### 10.3.12.1. const row not found +- 쿼리의 실행 계획에서 const 접근 방식으로 테이블을 읽었지만 일치하는 레코드가 존재하지 않음 + +#### 10.3.12.2. Deleting all rows +- 테이블의 모든 레코드를 삭제하는 기능을 제공하는 스토리지 엔진 테이블인 경우 Extra 칼럼에 “Deleting all rows” 문구가 표시됨 (MyISAM) + +#### 10.3.12.3. Distinct + +- 꼭 필요한 데이터만 스킵하며 읽어온 경우 + +```sql +mysql> EXPLAIN + SELECT DISTINCT d.dept_no + FROM departments d, dept_emp de WHERE de.dept_no=d.dept_no; ++----+-------------+-------+-------+-------------+------------------------------+ +| id | select_type | table | type | key | Extra | ++----+-------------+-------+-------+-------------+------------------------------+ +| 1 | SIMPLE | d | index | ux_deptname | Using index; Using temporary | +| 1 | SIMPLE | de | ref | PRIMARY | Using index; Distinct | ++----+-------------+-------+-------+-------------+------------------------------+ +``` + +![[10.6 Distinct의 처리 방식.png]] + +#### 10.3.12.4. FirstMatch + +```sql +mysql> EXPLAIN SELECT * + FROM employees e + WHERE e.first_name='Matt' + AND e.emp_no IN ( + SELECT t.emp_no FROM titles t + WHERE t.from_date BETWEEN '1995-01-01' AND '1995-01-30' + ); ++----+-------+------+--------------+------+-----------------------------------------+ +| id | table | type | key | rows | Extra | ++----+-------+------+--------------+------+-----------------------------------------+ +| 1 | e | ref | ix_firstname | 233 | NULL | +| 1 | t | ref | PRIMARY | 1 | Using where; Using index; FirstMatch(e) | ++----+-------+------+--------------+------+---------------------------------------- + +EXPLAIN analyze + SELECT * + FROM employees e + WHERE e.first_name='Matt' + AND e.emp_no IN ( + SELECT t.emp_no FROM titles t +# WHERE t.from_date BETWEEN '1995-01-01' AND '1995-01-30' + ); + +-> Nested loop semijoin (cost=339 rows=341) (actual time=1.35..15.4 rows=233 loops=1) + -> Index lookup on e using ix_firstname (first_name='Matt') (cost=81.5 rows=233) (actual time=1.3..1.71 rows=233 loops=1) + -> Covering index lookup on t using PRIMARY (emp_no=e.emp_no) (cost=1.4 rows=1.46) (actual time=0.0584..0.0584 rows=1 loops=233) + + +explain analyze +select * from employees as e +left join titles as t +on e.emp_no = t.emp_no +where e.first_name='Matt'; + +-> Nested loop left join (cost=334 rows=341) (actual time=0.385..3.2 rows=337 loops=1) + -> Index lookup on e using ix_firstname (first_name='Matt') (cost=81.5 rows=233) (actual time=0.356..0.863 rows=233 loops=1) + -> Index lookup on t using PRIMARY (emp_no=e.emp_no) (cost=0.936 rows=1.46) (actual time=0.00891..0.00975 rows=1.45 loops=233) +``` + +#### 10.3.12.5. Full scan on NULL key + +#### 10.3.12.6. Impossible HAVING + +#### 10.3.12.7. Impossible WHERE + +#### 10.3.12.8. LooseScan + +#### 10.3.12.9. No matching min/max row + +#### 10.3.12.10. no matching row in const table + +#### 10.3.12.11. No matching rows after partition pruning + +##### 10.3.12.12. No tables used + +```sql +mysql> EXPLAIN SELECT 1; +mysql> EXPLAIN SELECT 1 FROM dual; ++----+-------------+-------+------+------+----------------+ +| id | select_type | table | type | key | Extra | ++----+-------------+-------+------+------+----------------+ +| 1 | SIMPLE | NULL | NULL | NULL | No tables used | ++----+-------------+-------+------+------+----------------+ +``` + +#### 10.3.12.13. Not Exist + +#### 10.3.12.14. Plan isn’t ready yet + +#### 10.3.12.15. Range checked for each record(index map: N) + +#### 10.3.12.16. Recursive + +#### 10.3.12.17. Rematerialize + +#### 10.3.12.18. Select tables optimized away + +#### 10.3.12.19. Start temporary, End temporary + +#### 10.3.12.20. unique row not found + +#### 10.3.12.21. Using filesort + +- 실행 계획의 Extra 칼럼에 “Using filesort”가 출력되는 쿼리는 많은 부하를 일으키므로 가능하다면 쿼리를 튜닝하거나 인덱스를 생성하는 것이 좋음 +- “Using filesort”는 중요한 부분이므로 11.4.9절 ‘ORDER BY’에서 다시 자세히 다룸 + +#### 10.3.12.22. Using index(커버링 인덱스) + + +--- +#### 10.3.12.23. Using index condition +MySQL 옵티마이저가 인덱스 컨디션 푸시 다운(Index condition pushdown) 최적화를 사용하면 다 +음 예제와 같이 Extra 칼럼에 “Using index condition” 메시지가 표시된다. +```sql +mysql> SELECT * FROM employees WHERE last_name='Acton' AND first_name LIKE '%sal'; ++----+-------------+-----------+------+-----------------------+---------+-----------------------+ +| id | select_type | table | type | key | key_len | Extra | ++----+-------------+-----------+------+-----------------------+---------+-----------------------+ +| 1 | SIMPLE | employees | ref | ix_lastname_firstname | 66 | Using index condition | ++----+-------------+-----------+------+-----------------------+---------+-----------------------+ +``` + + +#### 10.3.12.24. Using index for group-by +- Group By 처리는 고부하 작업 + - 1. 그루핑 기준 컬럼을 이용한 정렬 작업 + - 2. 정렬된 결과를 그루핑 +- 그루핑 기준 컬럼이 인덱싱 되어있다면 정렬을 생략할 수 있음 + +##### 10.3.12.24.1. 타이트 인덱스 스캔(인덱스 스캔)을 통한 GROUP BY 처리 +- 듬성 듬성 읽기 불가능한 경우, ex. Count(), Sum(), Avg() +- 이러한 쿼리의 실행 계획에는 “Using index for group-by” 메시지가 출력되지 않음 +```sql +mysql> EXPLAIN + SELECT first_name, COUNT(*) AS counter + FROM employees GROUP BY first_name; ++----+-------------+-----------+-------+--------------+---------+-------------+ +| id | select_type | table | type | key | key_len | Extra | ++----+-------------+-----------+-------+--------------+---------+-------------+ +| 1 | SIMPLE | employees | index | ix_firstname | 58 | Using index | ++----+-------------+-----------+-------+--------------+---------+-------------+ +``` + +##### 10.3.24.2. 루스 인덱스 스캔을 통한 GROUP BY 처리 +- 듬성 듬성 읽을 수 있는 경우, ex. Min(), Max() +``` +mysql> EXPLAIN + SELECT emp_no, MIN(from_date) AS first_changed_date, MAX(from_date) AS last_changed_date + FROM salaries + GROUP BY emp_no; + ++----+-------------+----------+-------+---------+---------+--------------------------+ +| id | select_type | table | type | key | key_len | Extra | ++----+-------------+----------+-------+---------+---------+--------------------------+ +| 1 | SIMPLE | salaries | range | PRIMARY | 4 | Using index for group-by | ++----+-------------+----------+-------+---------+---------+--------------------------+ +``` +- Where 조건이 있는 경우에는 루스 스캔을 하지 못할 수 있음 +- 그루핑과 Where 조건이 같은 인덱스를 사용할 때 루스 인덱스 스캔이 가능함 +- 하지만 Where 조건으로 소량의 데이터만 뽑아낼 수 있다면 루스 인덱스 스캔이 아닌 인덱스 스캔으로 처리됨 +```sql +mysql> EXPLAIN + SELECT emp_no, + MIN(from_date) AS first_changed_date, + MAX(from_date) AS last_changed_date + FROM salaries + WHERE emp_no BETWEEN 10001 AND 10099 + GROUP BY emp_no; ++----+-------------+----------+-------+---------+---------+--------------------------+ +| id | select_type | table | type | key | key_len | Extra | ++----+-------------+----------+-------+---------+---------+--------------------------+ +| 1 | SIMPLE | salaries | range | PRIMARY | 4 | Using where; Using index | ++----+-------------+----------+-------+---------+---------+--------------------------+ +``` + + + +#### 10.3.12.25. Using Index for skip scan + +- MySQL 8.0 이전에는 (gender, birth_date) 인덱스의 경우 gender 컬럼이 Where절에 있어야지만 사용가능했으나 8.0 버전 부터 인덱스 스킵 스캔이 도입되어 경우에 따라 인덱스 선두컬럼인 gender 컬럼이 Where절에 없어도 인덱스를 사용한 레인지 스캔 가능 +```sql +mysql> EXPLAIN + SELECT gender, birth_date + FROM employees + WHERE gender = 'F' and birth_date>='1965-02-01'; ++----+-----------+-------+---------------------+----------------------------------------+ +| id | table | type | key | Extra | ++----+-----------+-------+---------------------+----------------------------------------+ +| 1 | employees | range | ix_gender_birthdate | Using where; Using index for skip scan | +``` + + + +#### 10.3.12.26. Using join buffer(Block Nested Loop), Using join buffer(Batched Key Access), Using join buffer(hash join) + +- 드리븐 테이블의 조인 컬럼에 적절한 인덱스가 없다면 블록 네스티드 루프 조인이나 해시 조인이 발생하고 Extra컬럼에는 `Using join buffer` 가 나타남 + +```sql +mysql> EXPLAIN + SELECT * + FROM dept_emp de, employees e + WHERE de.from_date>'2005-01-01' AND e.emp_no<10904; +위 예제 쿼리의 실행 계획은 다음과 같다. ++----+-------------+-------+-------+-------------+--------------------------------------------+ +| id | select_type | table | type | key | Extra | ++----+-------------+-------+-------+-------------+--------------------------------------------+ +| 1 | SIMPLE | de | range | ix_fromdate | Using index condition | +| 1 | SIMPLE | e | range | PRIMARY | Using where; Using join buffer (hash join) | +``` + +#### 10.3.12.27. Using MRR + +- MRR(Multi Range Read) 최적화 +- **MySQL 엔진**이 **스토리지 엔진**에 키 값을 기준으로 레코드를 1건, 1건 씩 읽어서 가져옴 +- 레코드가 동일 페이지에 있다고 하더라도 레코드 단위로 스토리지 엔진 API 호출이 필요함 + +- 위와 같이 스토리지 엔진 API 호출이 과도하게 발생하는 단점을 보완하기 위해 + 1. **MySQL엔진에서 여러개의 키값을 모았다가 스토리지 엔진**에 전달하고 + 2. **스토리지 엔진은 전달받은 키값을 정렬**해서 **최소한의 페이지 접근만으로 필요한 레코드**를 가져올 수 있도록 최적화함 + +```sql +mysql> EXPLAIN + SELECT /*+ JOIN_ORDER(s, e) */ * + FROM employees e, + salaries s + WHERE e.first_name='Matt' + AND e.hire_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.emp_no=e.emp_no + AND s.from_date BETWEEN '1990-01-01' AND '1991-01-01' + AND s.salary BETWEEN 50000 AND 60000; ++----+-------+--------+-----------+---------+------+----------------------------------+ +| id | table | type | key | key_len | rows | Extra | ++----+-------+--------+-----------+---------+------+----------------------------------+ +| 1 | s | range | ix_salary | 4 | 3314 | Using index condition; Using MRR | +| 1 | e | eq_ref | PRIMARY | 4 | 1 | Using where | ++----+-------+--------+-----------+---------+------+----------------------------------+ +``` + +#### 10.3.12.28 Using sort_union(...), Using union(...), Using intersect(...) + +- 실행계획의 Type 컬럼의 값이 **index_merge**인 경우에는 2개 이상의 인덱스가 사용되어 데이터를 가져옴 +- 이 때 실행계획의 Extra 컬럼에 인덱스로 읽어온 결과를 어떻게 **병합했는지 상세 정보**를 보여줌 + +```text +■ Using intersect(...): 각각의 인덱스를 사용할 수 있는 조건이 AND로 연결된 경우 각 처리 결과에서 교집합을 추출해 내는 작업을 수행했다는 의미다. +■ Using union(...): 각 인덱스를 사용할 수 있는 조건이 OR로 연결된 경우 각 처리 결과에서 합집합을 추출해내는 작업 을 수행했다는 의미다. +■ Using sort_union(...): Using union과 같은 작업을 수행하지만 Using union으로 처리될 수 없는 경우(OR로 연결된 상대적으로 대량의 range 조건들) 이 방식으로 처리된다. Using sort_union과 Using union의 차이점은 Using sort_union은 프라이머리 키만 먼저 읽어서 정렬하고 병합한 이후 비로소 레코드를 읽어서 반환할 수 있다는 것 이다 +``` + +#### 10.3.12.29. Using temporary + +- 쿼리의 실행 계획에서 Extra 칼럼에 `Using temporary` 키워드가 표시되면 임시 테이블을 사용한 것인데, 이때 사용된 **임시 테이블이 메모리에 생성됐는지 디스크에 생성됐는지는 실행 계획**만으로 판단할 수 없음 +```sql +EXPLAIN + SELECT gender, min(emp_no), max(emp_no), count(*) + FROM employees + GROUP BY gender + ORDER BY min(emp_no); + ++----+-------------+-----------+-------+---------------------------------+ +| id | select_type | table | type | Extra | ++----+-------------+-----------+-------+---------------------------------+ +| 1 | SIMPLE | employees | index | Using temporary; Using filesort | ++----+-------------+-----------+-------+---------------------------------+ + +explain analyze +select birth_date, count(*) from employees +group by birth_date; + +-> Table scan on (actual time=107..107 rows=4751 loops=1) + -> Aggregate using temporary table (actual time=107..107 rows=4751 loops=1) + -> Covering index scan on employees using ix_gender_birthdate (cost=30774 rows=299809) (actual time=0.0729..47.1 rows=300024 loops=1) + +explain +select distinct birth_date from employees; + +-> Table scan on (cost=60755..64505 rows=299809) (actual time=81.9..82.2 rows=4751 loops=1) + -> Temporary table with deduplication (cost=60755..60755 rows=299809) (actual time=81.9..81.9 rows=4751 loops=1) + -> Covering index scan on employees using ix_gender_birthdate (cost=30774 rows=299809) (actual time=0.0532..46.3 rows=300024 loops=1) +``` + +주의사항: 임시테이블을 사용하지만 Using temporary가 표시되지 않는 경우도 있음 +임시테이블을 생성하는 경우들 +- From절에 사용한 서브쿼리는 무조건 임시테이블을 생성 (인라인 뷰들) +- Count(distinct column_name) +- Union (Union Distinct), Union All은 X +- 인덱스를 사용하지 못하는 정렬 작업들 (Using filesort) + +#### 10.3.12.30. Using where + +MySQL 서버 내부의 2개 레이어 +- 1. 스토리지 엔진: 디스크나 메모리상에서 필요한 레코드를 읽거나 저장 +- 2. MySQL 엔진: 스토리지 엔진으로부터 받은 레코드를 가공 또는 연산하는 작업 + +**MySQL 엔진 레이어에서 별도의 가공을해서 데이터 필터링 작업이 있었을 경우** Extra 컬럼에 `Using Where` 가 표시됨 +![[10.12. MySQL 엔진과 각 스토리지 엔진의 처리 차이.png]] + +8.3.7.1절 비교 조건의 종류와 효율성 에서 `작업 범위 결정 조건`과 `체크 조건(필터링)`의 구분을 언급했는데 +- 실제로 **작업 범위 결정 조건**은 각 스토리지 엔진 레벨에서 처리되지만 +- 체크 조건은 MySQL 엔진 레이어 에서 처리됨 + +```sql + EXPLAIN analyze + SELECT * + FROM employees + WHERE emp_no BETWEEN 10001 AND 10100 + AND gender='F'; + +-> Filter: ((employees.emp_no between 10001 and 10100) and (employees.gender = 'F')) (cost=20.9 rows=50) (actual time=0.0754..0.319 rows=37 loops=1) + -> Index range scan on employees using PRIMARY over (10001 <= emp_no <= 10100) (cost=20.9 rows=100) (actual time=0.0659..0.282 rows=100 loops=1) + + + EXPLAIN analyze + SELECT * + FROM employees + WHERE emp_no BETWEEN 10001 AND 10100; + +-> Filter: (employees.emp_no between 10001 and 10100) (cost=20.9 rows=100) (actual time=0.0258..0.109 rows=100 loops=1) + -> Index range scan on employees using PRIMARY over (10001 <= emp_no <= 10100) (cost=20.9 rows=100) (actual time=0.0242..0.0976 rows=100 loops=1) +``` + + +``` +위의 쿼리 예제를 통해 인덱스 최적화를 조금 더 살펴보자. 위 처리 과정에서 최종적으로 쿼리에 일치하는 레코 +드는 37건밖에 안 되지만 스토리지 엔진은 100건의 레코드를 읽은 것이다. 상당히 비효율적인 과정이라고 볼 수 있다. +그런데 employees 테이블에 (emp_no, gender)로 인덱스가 준비돼 있었다면 어떻게 될까? 이때는 두 조건 모두 작업 범위의 제한 조건으로 사용되어 필요한 37개의 레코드만 정확하게 읽을 수 있다. + +``` + +#### 10.3.12.31. Zero limit + +- 실제 쿼리 결과가아닌 컬럼 개수, 이름, 타입 등 메타 데이터 정보만 필요할 경우 쿼리의 마지막에 limit 0;를 사용 +- 이 때 Zero limit이 Extra 컬럼에 나오고 실제 데이터를 읽지 않게 됨 + +```sql +mysql> EXPLAIN SELECT * FROM employees LIMIT 0; ++----+-------------+-------+------+------+------------+ +| id | select_type | table | type | key | Extra | ++----+-------------+-------+------+------+------------+ +| 1 | SIMPLE | NULL | NULL | NULL | Zero limit | ++----+-------------+-------+------+------+------------+ +``` \ No newline at end of file diff --git "a/JongminJeon/11. \354\277\274\353\246\254 \354\236\221\354\204\261 \353\260\217 \354\265\234\354\240\201\355\231\224.md" "b/JongminJeon/11. \354\277\274\353\246\254 \354\236\221\354\204\261 \353\260\217 \354\265\234\354\240\201\355\231\224.md" new file mode 100644 index 0000000..06d6c8c --- /dev/null +++ "b/JongminJeon/11. \354\277\274\353\246\254 \354\236\221\354\204\261 \353\260\217 \354\265\234\354\240\201\355\231\224.md" @@ -0,0 +1,477 @@ + +Real MySQL이 출간된 **2012년 즈음은 다양한 DBMS들이 우후죽순으로 탄생했던 춘추전국 시대**와 같은 시기 였으며, 아마도 그때는 **전통적인 RDBMS들은 살아남지 못하고 곧 사라질 것**이라고 생각한 사람들도 많았을 것이다. +... 중략 + +10년이 지난 지금, 그 누구도 그때 출시됐던 NoSQL DBMS를 언급하지 않는 듯하다(여기서 이야기하는 DBMS 범주에서 **Redis와 Memcached는 제외**했다). **그나마 HBase와 MongoDB만이 자기 자리**를 찾아서 사용되는 상황이다. 오히려 전통적인 RDBMS들은 자기만의 영역과 역할을 견고히 다져왔으며, 그중에서도 MySQL 서버는 수 많은 NoSQL DBMS를 대체하면서 발전해왔다. + +앞에서 언급했던 **HBase와 MongoDB는 특정 유스케이스에 적합한 DBMS**인 반면, MySQL 서버와 같은 RDBMS는 범용 DBMS 영역에 속한다. **어떤 서비스를 개발하든 초기에는 범용 DBMS를 선택**하고, 사용량이나 데이터의 크기가 커지면 **일부 도메인 또는 테이블의 데이터만 전용 DBMS로 이전해서 확장하는 형태**를 대부분 회사에서 선택하고 있다. 그래서 어떤 서비스를 개발하더라도 RDBMS 선택을 피할 수 없으며 ... + +- Hbase: Column Family NoSQL, HDFS 기반에서 돌아가는 분산되고 확장 가능한 DB +- MongoDB: Document Store NoSQL + +## 11.1. 쿼리 작성과 연관된 시스템 변수 +- **SQL은 어떠한 데이터를 요청하기 위한 언어**이지, **어떻게 데이터를 읽을지를 표현하는 언어는 아니다**. +- 따라서 C와 자바 같은 언와 비교했을 때 상당히 제한적으로 느껴질 수 있다. +- 그래서 **쿼리가 빠르게 수행되게 하려면 데이터베이스 서버에서 쿼리가 어떻게 요청을 처리할지 예측**할 수 있어야 한다 (그래서 **DBMS의 내부적인 처리 방식에 대해 어느 정도의 지식이 필요**하다) + +- 애플리케이션 코드를 튜닝해서 성능을 2배 개선한다는 것은 쉽지 않은 일이다. 하지만 **DBMS에서 몇십 배에서 몇백 배의 성능 향상이 이뤄지는 것은 상당히 흔한 일**이다 + + +### 11.1.1 SQL 모드 + +```sql +show variables where Variable_name = 'sql_mode'; +>> +Variable_name,Value +sql_mode,STRICT_TRANS_TABLES +``` + + +- STRICT_ALL_TABLES & STRICT_TRANS_TABLES: MySQL 서버에서 **INSERT나 UPDATE 문장으로 데이터를 변경하는 경우 칼럼의 타입과 저장되는 값의 타입이 다를 때 자동으로 타입 변경을 수행**한다. 이때 타입이 적절히 **변환되기 어렵거나 칼럼에 저장될 값이 없거나 값의 길이가 칼럼의 최대 길이보다 큰 경우** MySQL 서버가 INSERT나 UPDATE 문장을 계속 실행할지, 아니면 **에러를 발생시킬지를 결정**한다. + +### 11.1.2. 영문 대소문자 구분 + +MySQL의 DB나 테이블이 디스크의 디렉터리나 파일로 매핑된다. +- 윈도우에 설치된 MySQL: 대소문자 구분 X +- 유닉스 계열의 운영체제: 대소문자 구분 +따라서 윈도우 <-> 유닉스 운영체제간의 데이터 이동 시 문제가 발생할 수도 있다. +MySQL 서버가 운영체제와 관계없이 대소문자 구분의 영향을 받지 않게 하려면 MySQL 서버의 설정 파일에 **lower_case_table_names 시스템 변수**를 설정하면 된다. 이 변수를 1로 설정하면 모두 소문자로만 저장되고, MySQL 서버가 대소문자를 구분하지 않게 해준다 + +### 1.1.3. MySQL 예약어 +생성하는 데이터베이스나 **테이블, 칼럼의 이름을 예약어와 같은 키워드로 생성하면 해당 칼럼이나 테이블을 SQL에서 사용하기 위해 항상 역따옴표(‵)나 쌍따옴표**로 감싸야 한다. + +## 11.2. 매뉴얼의 SQL 문법 표기를 읽는 법 + +![[Pasted image 20231023223331.png]] +- [] 대괄호: 선택 사항 +- | 파이프: 키워드, 표현식 중 하나만 선택 가능 +- {} 중괄호: 괄호 내의 아이템 중에서 반드시 하나를 사용해야함 +- ...: 앞에 명시된 키워드나 표현식의 조합이 반복될 수 있음 (value_list) + +## 11.3. MySQL 연산자와 내장 함수 + +- DBMS에서 사용되는 기본적인 연산자는 MySQL에서도 비슷하게 사용되지만 MySQL에서만 사용되는 연산자나 표기법이 있음 +- 가능하면 SQL의 가독성을 높이기 위해 ANSI 표준 형태의 연산자를 사용하길 권장함 + +### 11.3.1. 리터럴 표기법 문자열 + +#### 11.3.1.1. 문자열 +```sql +SELECT * FROM departments WHERE dept_no='d001'; +SELECT * FROM departments WHERE dept_no="d001"; + + +SQL에서 사용되는 식별자(테이블명이나 칼럼명 등)가 키워드와 충돌할 때 오라클이나 PostgreSQL에 +서는 쌍따옴표나 대괄호로 감싸서 충돌을 피한다. MySQL에서는 역따옴표(‵)로 감싸서 사용하면 예약 +어와의 충돌을 피할 수 있다. + +CREATE TABLE tab_test (`table` VARCHAR(20) NOT NULL, ...); +SELECT `column` FROM tab_test; +MySQL 서버의 sql_mode 시스템 변숫값에 ANSI_QUOTES를 설정하면 쌍따옴표는 문자열 리터럴 표기에 +사용할 수 없다. 그리고 테이블명이나 칼럼명의 충돌을 피하려면 역따옴표(‵)가 아니라 쌍따옴표를 사 +용해야 한다 + +-- ANSI_QUOTES +CREATE TABLE tab_test ("table" VARCHAR(20) NOT NULL, ...); +SELECT "column" FROM tab_test; +``` + +#### 11.3.1.2. 숫자 + +```sql +두 비교 대상이 문자열과 숫자 타입으로 다를 때는 자동으로 타입의 변환이 발생한다. +MySQL은 숫자 타입과 문자열 타입 간의 비교에서 숫자 타입을 우선시하므로 문자열 값을 숫자 값으 +로 변환한 후 비교를 수행한다 + +-- 1. '10001' -> 10001 +SELECT * FROM tab_test WHERE number_column='10001'; + +-- 2. string_column -> number: 변환이 불가능할 수도 있고, 인덱스 컬럼이라면 형변환되어 인덱스를 사용하지 못할 수 있다. +SELECT * FROM tab_test WHERE string_column=10001; +``` + +#### 11.3.1.3. 날짜 + +```sql +SELECT * FROM dept_emp WHERE from_date='2011-04-29'; +SELECT * FROM dept_emp WHERE from_date=STR_TO_DATE('2011-04-29','%Y-%m-%d'); +``` + +#### 11.3.1.4. 불리언 + +```sql +mysql> CREATE TABLE tb_boolean (bool_value BOOLEAN); +mysql> INSERT INTO tb_boolean VALUES (FALSE); +mysql> SELECT * FROM tb_boolean WHERE bool_value=FALSE; +mysql> SELECT * FROM tb_boolean WHERE bool_value=TRUE; + +mysql> CREATE TABLE tb_boolean (bool_value BOOLEAN); +mysql> INSERT INTO tb_boolean VALUES (FALSE), (TRUE), (2), (3), (4), (5); +mysql> SELECT * FROM tb_boolean WHERE bool_value IN (FALSE, TRUE); ++------------+ +| bool_value | ++------------+ +| 0 | +| 1 | +``` + + +### 11.3.2. MySQL 연산자 + +#### 11.3.2.1. 동등 비교: =, <=> + +```sql +mysql> SELECT 1 = 1, NULL = NULL, 1 = NULL; ++-------+-------------+----------+ +| 1 = 1 | NULL = NULL | 1 = NULL | ++-------+-------------+----------+ +| 1 | NULL | NULL | ++-------+-------------+----------+ + +-- "<=>" 연산자는 NULL을 하나의 값으로 인식하고 비교하는 방법 +mysql> SELECT 1 <=> 1, NULL <=> NULL, 1 <=> NULL; ++---------+---------------+------------+ +| 1 <=> 1 | NULL <=> NULL | 1 <=> NULL | ++---------+---------------+------------+ +| 1 | 1 | 0 | ++---------+---------------+------------+ +``` + +#### 11.3.2.2. 부정 비교: <>, != + +하나의 SQL 문장에서 "<>"와 "!="가 혼용되면 가독성이 떨어지므로 통일해서 사용하는 방법을 권장 + +#### 11.3.2.3 Not 연산자: ! + +```sql +-- 1. +mysql> SELECT ! 1; ++----+ +| 0 | ++----+ + +-- 2. +mysql> SELECT !FALSE; ++----+ +| 1 | ++----+ + +-- 3. +mysql> SELECT NOT 1; ++----+ +| 0 | ++----+ + +-- 4. +mysql> SELECT NOT 0; ++----+ +| 1 | ++----+ + +-- 5. +mysql> SELECT NOT (1=1); ++----+ +| 0 | ++----+ +``` + +#### 11.3.2.4. AND(&&)와 OR(||) 연산자 + +SQL의 가독성을 높이기 위해 다른 용도로 사용될 수 있는 "&&" 연산자와 "||" 연산자는 사용을 자 +제하는 것이 좋다 +- ||: 오라클에서는 Concat, Ex. '안녕' || ' 하세요' -> 안녕하세요 + +```sql +-- AND, OR의 연산자 우선순위: AND + +mysql> SELECT TRUE OR FALSE AND FALSE; ++-------------------------+ +| TRUE OR FALSE AND FALSE | ++-------------------------+ +| 1 | ++-------------------------+ + +mysql> SELECT TRUE OR (FALSE AND FALSE); ++---------------------------+ +| TRUE OR (FALSE AND FALSE) | ++---------------------------+ +| 1 | ++---------------------------+ + +mysql> SELECT (TRUE OR FALSE) AND FALSE; ++---------------------------+ +| (TRUE OR FALSE) AND FALSE | ++---------------------------+ +| 0 | ++---------------------------+ +``` + +#### 11.3.2.5. 나누기(/, DIV)와 나머지(%, MOD) 연산자 +... + +#### 11.3.2.6. REGEXP 연산자 + +```sql +-- 문자열이 x or y or z로 시작하는지 검증하는 표현식 예제 +mysql> SELECT 'abc' REGEXP '^[x-z]'; ++-----------------------+ +| 0 | ++-----------------------+ +``` + +REGEXP 연산자를 문자열 칼럼 비교에 사용할 때 **REGEXP 조건의 비교는 인덱스 레인지 스캔을 사용할 수 +없다**. 따라서 WHERE 조건절에 REGEXP 연산자를 사용한 조건을 단독으로 사용하는 것은 성능상 좋지 않 +다. **가능하다면 데이터 조회 범위를 줄일 수 있는 조건과 함께 REGEXP 연산자를 사용하길 권장** + + +#### 11.3.1.7. Like 연산자 + +REGEXP 연산자보다는 훨씬 단순한 문자열 패턴 비교 연산자이지만 DBMS에서는 LIKE 연산자를 더 많이 +사용한다 + +```sql + +-- 1. +mysql> SELECT 'abcdef' LIKE 'abc%'; ++----------------------+ +| 1 | ++----------------------+ + +-- 2. +mysql> SELECT 'abcdef' LIKE '%abc'; ++----------------------+ +| 0 | ++----------------------+ + +-- 3. +mysql> SELECT 'abcdef' LIKE '%ef'; ++---------------------+ +| 1 | ++---------------------+ + + +``` + +LIKE 연산자는 **와일드카드 문자인 (`%`, `_`)가 검색어의 뒤쪽에 있다면 인덱스 레인지 스캔**으로 사용할 수 있지만 **와일드카드가 검색어의 앞쪽에 있다면 인덱스 레인지 스캔을 사용할 수 없으므로 주의**해서 사용해야 한다. + +``` +mysql> EXPLAIN + SELECT COUNT(*) + FROM employees + WHERE first_name LIKE 'Christ%'; + ++----+-----------+-------+--------------+------+--------------------------+ +| id | table | type | key | rows | Extra | ++----+-----------+-------+--------------+------+--------------------------+ +| 1 | employees | range | ix_firstname | 226 | Using where; Using index | ++----+-----------+-------+--------------+------+--------------------------+ + +mysql> EXPLAIN + SELECT COUNT(*) + FROM employees + WHERE first_name LIKE '%rist'; ++----+-----------+-------+--------------+--------+--------------------------+ +| id | table | type | key | rows | Extra | ++----+-----------+-------+--------------+--------+--------------------------+ +| 1 | employees | index | ix_firstname | 300584 | Using where; Using index | ++----+-----------+-------+--------------+--------+--------------------------+ +``` + +#### 11.3.2.8. BETWEEN 연산자 + +```sql +SELECT * FROM dept_emp +WHERE dept_no='d003' AND emp_no=10001; +SELECT * FROM dept_emp +WHERE dept_no BETWEEN 'd003' AND 'd005' AND emp_no=10001; +``` + +BETWEEN과 IN을 동일한 비교 연산자로 생각하는 사람도 있는데, **사실 BETWEEN은 크다와 작다 비교를 +하나로 묶어 둔 것**에 가깝다. 그리고 IN 연산자의 처리 방법은 동등 비교(=) 연산자와 비슷하다. 그림 +11.2는 이 IN과 BETWEEN 처리 과정의 차이를 보여주는데, IN 연산자는 여러 개의 동등 비교(=)를 하나로 +묶은 것과 같은 연산자라서 **IN과 동등 비교 연산자는 같은 형태로 인덱스를 사용**한다. +![[Pasted image 20231023225458.png]] + + +dept_emp 테이블의 인덱스(dept_no, emp_no) + +```sql + +-- 인덱스 선두컬럼인 dept_no가 동등 조건이 아니다. +mysql> SELECT * FROM dept_emp USE INDEX(PRIMARY) + WHERE dept_no BETWEEN 'd003' AND 'd005' AND emp_no=10001; ++----+----------+-------+---------+---------+--------+-------------+ +| id | table | type | key | key_len | rows | Extra | ++----+----------+-------+---------+---------+--------+-------------+ +| 1 | dept_emp | range | PRIMARY | 20 | 165571 | Using where | ++----+----------+-------+---------+---------+--------+-------------+ +# -> Filter: ((dept_emp.emp_no = 10001) and (dept_emp.dept_no between 'd003' and 'd005')) (cost=33070 rows=0.54) (actual time=41.3..41.3 rows=1 loops=1) +# -> Index range scan on dept_emp using PRIMARY over ('d003' <= dept_no <= 'd005' AND emp_no = 10001) (cost=33070 rows=164767) (actual time=0.0223..36.7 rows=91272 loops=1) + + +mysql> SELECT * FROM dept_emp USE INDEX(PRIMARY) + WHERE dept_no IN ('d003', 'd004', 'd005') AND emp_no=10001; ++----+----------+-------+---------+---------+------+-------------+ +| id | table | type | key | key_len | rows | Extra | ++----+----------+-------+---------+---------+------+-------------+ +| 1 | dept_emp | range | PRIMARY | 20 | 3 | Using where | ++----+----------+-------+---------+---------+------+-------------+ + +# -> Filter: ((dept_emp.emp_no = 10001) and (dept_emp.dept_no in ('d003','d004','d005'))) (cost=2.98 rows=3) (actual time=0.0685..0.0691 rows=1 loops=1) +# -> Index range scan on dept_emp using PRIMARY over (dept_no = 'd003' AND emp_no = 10001) OR (dept_no = 'd004' AND emp_no = 10001) OR (dept_no = 'd005' AND emp_no = 10001) (cost=2.98 rows=3) (actual time=0.0654..0.0659 rows=1 loops=1) + +``` + +아래와 같이 `IN (subquery)` 형태로 쿼리 작성도 가능 + +```sql +SELECT * +FROM dept_emp USE INDEX(PRIMARY) +WHERE dept_no IN ( + SELECT dept_no + FROM departments -- dept_no: departments 테이블의 PK로 유니크 값 반환 + WHERE dept_no BETWEEN 'd003' AND 'd005') + AND emp_no=10001; +``` + + +#### 11.3.2.9. IN 연산자 +- IN은 여러 개의 값에 대해 동등 비교 연산을 수행하는 연산자이다. +- 여러 개의 값이 비교되지만 범위로 검색하는 것이 아니라 여러 번의 동등 비교로 실행하기 때문에 일반적으로 빠르게 처리된다. +- **NOT IN은 인덱스 풀 스캔** (동등이 아닌 **부정형 비교여서 인덱스를 이용해 처리 범위를 줄일 수 없음**) + + +### 11.3.3. MySQL 내장 함수 + +MySQL의 함수는 MySQL에서 기본으로 제공하는 내장 함수와 사용자가 직접 작성해서 추가할 수 있는 사용자 정의 함수(UDF, UserDefined Function)로 구분된다. MySQL에서 제공하는 C/C++ API를 이용해 사용자가 원하는 기능을 직접 함수로 만들어 추가할 수 있는데, 이를 사용자 정의 함수라고 한다. +- 내장 함수나 사용자 정의 함수는 스토어드 프로그램으로 작성되는 프로시저나 스토어드 함수와는 다르므로 혼동하지 않도록 주의하자. + +#### 11.3.3.1. NULL 값 비교 및 대체(IFNULL, ISNULL) + +```sql +mysql> SELECT IFNULL(NULL, 1); ++-----------------+ +| 1 | ++-----------------+ +mysql> SELECT IFNULL(0, 1); ++--------------+ +| 0 | ++--------------+ +mysql> SELECT ISNULL(0); ++-----------+ +| 0 | +``` + + +#### 11.3.3.2. 현재 시각 조회(NOW, SYSDATE) + +하나의 SQL에서 모든 **NOW() 함수는 같은 값**을 가지지만 **SYSDATE()함수는 하나의 SQL 내에서도 호출되는 시점에 따라 결괏값이 달라**진다. + +```sql +mysql> SELECT NOW(), SLEEP(2), NOW(); ++---------------------+----------+---------------------+ +| NOW() | SLEEP(2) | NOW() | ++---------------------+----------+---------------------+ +| 2020-08-23 14:55:20 | 0 | 2020-08-23 14:55:20 | ++---------------------+----------+---------------------+ + + +mysql> SELECT SYSDATE(), SLEEP(2), SYSDATE(); ++---------------------+----------+---------------------+ +| SYSDATE() | SLEEP(2) | SYSDATE() | ++---------------------+----------+---------------------+ +| 2020-08-23 14:55:23 | 0 | 2020-08-23 14:55:25 | ++---------------------+----------+---------------------+ +``` + +SYSDATE() 함수는 위에서도 언급했듯이 이 함수가 호출될 때마다 다른 값을 반환하므로 사실은 상수가 +아니다. 그래서 인덱스를 스캔할 때도 매번 비교되는 레코드마다 함수를 실행해야 한다. 하지만 **NOW() +함수는 쿼리가 실행되는 시점에서 실행되고 값을 할당받아서 그 값을 SQL 문장의 모든 부분에서 사용 +하기 때문에 쿼리가 1시간 동안 실행되더라도 실행되는 위치나 시점에 관계없이 항상 같은 값을 보장**할 수 있다. + +일반적인 웹 서비스에서는 특별히 SYSDATE() 함수를 사용해야 할 이유가 없다. 시스템 설정 파일(my.cnf) +에 sysdate-is-now 시스템 변수를 추가해서 **SYSDATE() 함수가 NOW() 함수와 동일하게 작동하게 설정할 +것을 권장**한다. + +#### 11.3.3.3. 날짜와 시간의 포맷(DATE_FORMAT, STR_TO_DATE) + + +```sql +mysql> SELECT DATE_FORMAT(NOW(), '%Y-%m-%d') AS current_dt; ++------------+ +| current_dt | ++------------+ +| 2020-08-23 | ++------------+ + +mysql> SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') AS current_dttm; ++---------------------+ +| current_dttm | ++---------------------+ +| 2020-08-23 15:06:45 | ++---------------------+ + + +mysql> SELECT STR_TO_DATE('2020-08-23','%Y-%m-%d') AS current_dt; ++------------+ +| current_dt | ++------------+ +| 2020-08-23 | ++------------+ +mysql> SELECT STR_TO_DATE('2020-08-23 15:06:45','%Y-%m-%d %H:%i:%s') AS current_dttm; ++---------------------+ +| current_dttm | ++---------------------+ +| 2020-08-23 15:06:45 | ++---------------------+ +``` + + +#### 11.3.3.4. 날짜와 시간의 연산(DATE_ADD, DATE_SUB) + +- DATE_ADD() 함수로 더하거나 빼는 처리를 모두 할 수 있기 때문에 DATE_SUB()는 크게 필요하지 않다 + +```sql +mysql> SELECT DATE_ADD(NOW(), INTERVAL 1 DAY) AS tomorrow; ++---------------------+ +| tomorrow | ++---------------------+ +| 2020-08-24 15:11:07 | ++---------------------+ + +mysql> SELECT DATE_ADD(NOW(), INTERVAL -1 DAY) AS yesterday; ++---------------------+ +| yesterday | ++---------------------+ +| 2020-08-22 15:11:07 | ++---------------------+ +``` + + +#### 11.3.3.5. 타임스탬프 연산(UNIX_TIMESTAMP, FROM_UNIXTIME) + +``` +-- "Unix 에포크 시간" 또는 "Unix 타임 스탬프" +-- 에포크(Epoch) 시간은 1970년 1월 1일 00:00:00(UTC)부터 경과한 시간을 초로 나타내는 것 + +mysql> SELECT UNIX_TIMESTAMP(); ++------------------+ +| UNIX_TIMESTAMP() | ++------------------+ +| 1598163535 | ++------------------+ + +mysql> SELECT UNIX_TIMESTAMP('2020-08-23 15:06:45'); ++---------------------------------------+ +| UNIX_TIMESTAMP('2020-08-23 15:06:45') | ++---------------------------------------+ +| 1598162805 | ++---------------------------------------+ + + +mysql> SELECT FROM_UNIXTIME(UNIX_TIMESTAMP('2020-08-23 15:06:45')); ++------------------------------------------------------+ +| FROM_UNIXTIME(UNIX_TIMESTAMP('2020-08-23 15:06:45')) | ++------------------------------------------------------+ +| 2020-08-23 15:06:45 | +``` diff --git a/JongminJeon/src/Pasted image 20231023223331.png b/JongminJeon/src/Pasted image 20231023223331.png new file mode 100644 index 0000000..2c4f2ec Binary files /dev/null and b/JongminJeon/src/Pasted image 20231023223331.png differ diff --git a/JongminJeon/src/Pasted image 20231023225458.png b/JongminJeon/src/Pasted image 20231023225458.png new file mode 100644 index 0000000..db8c3e2 Binary files /dev/null and b/JongminJeon/src/Pasted image 20231023225458.png differ