diff --git a/Document/2023/1022/hajunyoo/.gitkeep b/Document/2023/1022/hajunyoo/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/haneul/.gitkeep b/Document/2023/1022/haneul/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/jincheol/.gitkeep b/Document/2023/1022/jincheol/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/seungho/.gitkeep b/Document/2023/1022/seungho/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/siyeon/.gitkeep b/Document/2023/1022/siyeon/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/taehyun/.gitkeep b/Document/2023/1022/taehyun/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1022/yubin/.gitkeep b/Document/2023/1022/yubin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Document/2023/1112/taehyun/Dockerfile b/Document/2023/1112/taehyun/Dockerfile new file mode 100644 index 0000000..1aa5ee1 --- /dev/null +++ b/Document/2023/1112/taehyun/Dockerfile @@ -0,0 +1,8 @@ +FROM mysql:5.7 + +ENV MYSQL_ROOT_PASSWORD=root +ENV MYSQL_DATABASE=test_group_by + +COPY ./query.sql /docker-entrypoint-initdb.d/ + +CMD ["--max_allowed-packet=32505856"] diff --git a/Document/2023/1112/taehyun/README.md b/Document/2023/1112/taehyun/README.md new file mode 100644 index 0000000..c25c41b --- /dev/null +++ b/Document/2023/1112/taehyun/README.md @@ -0,0 +1,414 @@ +# 디비 디비 딥(DB DB Deep) 스터디 9회차 + +## [ SQL 레벨업 ] 3장 SQL의 조건 분기 + +### 08강 UNION을 사용한 쓸데없이 긴 표현 + +`UNION` 구를 사용한 조건 분기는 성능적인 측면에서 굉장히 큰 단점이 존재한다. 외부적으로 하나의 SQL 구문을 실행하는 것처럼 보이지만, 내부적으로는 여러 개의 `SELECT` 구문을 실행하는 실행하는 실행 계획으로 해석되기 때문에 테이블에 접근하는 횟수가 많아져 I/O 비용이 크게 늘어난다. + +예를 들어서 아래와 같은 구조를 가진 `Items` 테이블이 있다고 가정해보자. + +``` ++--------------+----------+------+-----+---------+-------+ +| Field | Type | Null | Key | Default | Extra | ++--------------+----------+------+-----+---------+-------+ +| item_id | int | NO | PRI | NULL | | +| year | int | NO | PRI | NULL | | +| item_name | char(32) | NO | | NULL | | +| price_tax_ex | int | NO | | NULL | | +| price_tax_in | int | NO | | NULL | | ++--------------+----------+------+-----+---------+-------+ +``` + +이때 `year` 필드의 값이 `2021` 이하인 경우 `price_tax_ex` 필드를, `2022` 이상인 경우 `price_tax_in` 필드를 사용해야 하는 쿼리를 실행해야 한다고 생각하면, 먼저 `WHERE` 구와 `UNION ALL` 구를 사용해서 아래와 같은 쿼리를 실행할 수 있다. + +```SQL +SELECT + item_name, + year, + price_tax_ex AS price +FROM Items +WHERE year <= 2001 +UNION ALL +SELECT + item_name, + year, + price_tax_in AS price +FROM Items +WHERE year <= 2002; +``` + +그리고 위 쿼리에 대한 실행 계획을 출력해보면 아래와 같은데, `WHERE` 구를 사용해서 `UNION` 구가 실행된 결과를 알 수 있다. 이때 `select_type` 컬럼의 값이 `PRIMARY`인 실행 계획은 `UNION` 구를 사용할 때 또는 서브쿼를 가지는 `SELECT` 쿼리의 실행 계획에서 가장 바깥쪽에 있는 단위 쿼리를 의미한다. `select_type` 컬럼의 값이 `UNION`인 실행 계획은 `PRIMARY`를 제외하고 `UNION ALL` 구 뒤에 오는 다른 테이블이다. 중요한 점은 `type` 컬럼 값이 `ALL`이라는 것인데, 이는 곧 테이블 풀 스캔(Table Full Scan)을 의미하며, 결국 테이블에 접근해 모든 컬럼을 조회한다는 뜻이 된다. + +``` ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+ +| 1 | PRIMARY | Items | NULL | ALL | NULL | NULL | NULL | NULL | 299512 | 33.33 | Using where | +| 2 | UNION | Items | NULL | ALL | NULL | NULL | NULL | NULL | 299512 | 33.33 | Using where | ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+ +``` + +만약 `UNION ALL` 구가 아닌 `UNION` 구를 사용하면 실행 계획은 어떻게 될까? `UNION ALL` 구를 사용했던 것과 달리 `UNION RESULT` 행이 하나 추가된 것을 확인할 수 있다. 그리고 해당 행의 `Extra` 컬럼 값은 `Using temporary`로 임시 테이블을 사용한다는 것을 알 수 있는데, `UNION RESULT` 행은 결국 `UNION` 구의 결과를 담아두는 임시 테이블을 의미한다. MySQL 8.0 이전 버전에서는 `UNION` 구와 `UNION ALL` 구 모두 그 결과를 임시 테이블로 생성했는데, MySQL 8.0 버전부터는 `UNION ALL` 구의 경우 임시 테이블을 사용하지 않도록 기능이 개선됐다. + +``` ++----+--------------+------------+------------+------+---------------+------+---------+------+--------+----------+-----------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+--------------+------------+------------+------+---------------+------+---------+------+--------+----------+-----------------+ +| 1 | PRIMARY | Items | NULL | ALL | NULL | NULL | NULL | NULL | 299512 | 33.33 | Using where | +| 2 | UNION | Items | NULL | ALL | NULL | NULL | NULL | NULL | 299512 | 33.33 | Using where | +| 3 | UNION RESULT | | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary | ++----+--------------+------------+------------+------+---------------+------+---------+------+--------+----------+-----------------+ +``` + +`UNION ALL` 구를 사용했던 것과 반대로 아래와 같이 `SELECT` 구 내에 `CASE` 문을 사용하여 이를 해결할 수 있다. + +```SQL +SELECT + item_name, + year, + CASE + WHEN year <= 2001 THEN price_tax_ex + WHEN year >= 2002 THEN price_tax_in + END AS price +FROM Items; +``` + +`CASE` 문을 사용한 쿼리에 대한 실행 계획을 출력해보면 아래와 같은데, 앞서 `UNION ALL` 구와 달리 테이블에 한 번만 접근한다는 것을 알 수 있다. + +``` ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------+ +| 1 | SIMPLE | Items | NULL | ALL | NULL | NULL | NULL | NULL | 299512 | 100.00 | NULL | ++----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------+ +``` + + +### 10강 그래도 UNION이 필요한 경우 + +책에서는 `UNION` 구를 사용해야 하는 경우에 대해 각기 다른 테이블을 병합해야 하는 경우와 `UNION` 구를 위해 분기 처리가 되는 조건이 인덱스를 사용하는 경우를 이야기했다. 이를 위해 `ThreeElements` 테이블을 생성한 뒤, `UNION` 구에 각각 대상이 되는 테이블에서 분기를 위한 `WHERE` 구에 조건으로 인덱스를 사용하는 예시를 만들었다. 이를 통해 `UNION` 구는 테이블에 세 번 접근하지만 인덱스를 사용하는 실행 계획을 보여주었고, 단순 `OR` 또는 `IN` 연산을 활용한 `WHERE` 구의 경우 테이블에는 한 번 접근하지만 테이블 풀 스캔을 사용하는 실행 계획을 보여주었다. 하지만 MySQL 기준 8.0 이상 버전부터는 `WHERE` 구에 `OR` 또는 `IN` 연산을 사용하더라도 옵티마이저가 자동으로 이를 최적화한다. + +먼저 아래와 같이 `UNION ALL` 구를 사용한 쿼리를 살펴보자. + +```SQL +SELECT + item_name, + price_tax_in AS price +FROM Items +WHERE ( + item_id = 100 + AND + year = 2023 +) +UNION ALL +SELECT + item_name, + price_tax_in AS price +FROM Items +WHERE ( + item_id = 101 + AND + year = 2023 +) +UNION ALL +SELECT + item_name, + price_tax_in AS price +FROM Items +WHERE ( + item_id = 102 + AND + year = 2023 +); +``` + +그리고 위 쿼리의 실행 계획을 출력해보면 아래와 같다. 각 쿼리가 기본 키인 `item_id` 및 `year` 튜플을 대상으로 했기 때문에 반드시 한 건의 결과를 반환하는 처리 방식을 의미하는 `type` 컬럼 값 `const`가 출력 됐다. + +``` ++----+-------------+-------+------------+-------+---------------+---------+---------+-------------+------+----------+-------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+-------+---------------+---------+---------+-------------+------+----------+-------+ +| 1 | PRIMARY | Items | NULL | const | PRIMARY | PRIMARY | 8 | const,const | 1 | 100.00 | NULL | +| 2 | UNION | Items | NULL | const | PRIMARY | PRIMARY | 8 | const,const | 1 | 100.00 | NULL | +| 3 | UNION | Items | NULL | const | PRIMARY | PRIMARY | 8 | const,const | 1 | 100.00 | NULL | ++----+-------------+-------+------------+-------+---------------+---------+---------+-------------+------+----------+-------+ +``` + +동일한 쿼리를 `UNION ALL` 구를 사용하지 않고 `WHERE` 구에 조건을 통해서 나타내보면 아래와 같다. + +```SQL +SELECT + item_name, + price_tax_in AS price +FROM Items +WHERE ( + (item_id = 100 AND year = 2023) + OR + (item_id = 101 AND year = 2023) + OR + (item_id = 102 AND year = 2023) +); +``` + +해당 쿼리의 실행 계획을 출력해보면 아래와 같다. `type` 컬럼을 보면 `range` 값이 있는 걸 확인할 수 있는데, 이는 곧 인덱스 레인지 스캔(Index Range Scan)을 하고 있다는 것을 알 수 있다. + +``` ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| 1 | SIMPLE | Items | NULL | range | PRIMARY | PRIMARY | 8 | NULL | 3 | 100.00 | Using where | ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +``` + +위 쿼리를 `IN` 연산을 사용해서 간단하게 나타내보면 아래와 같다. + +```SQL +SELECT + item_name, + price_tax_in AS price +FROM Items +WHERE (item_id, year) IN ((100, 2023), (101, 2023), (102, 2023)); +``` + +그리고 해당 쿼리의 실행 계획을 출력해보면 아래와 같다. 앞서 `OR` 연산을 나열한 것과 마찬가지로 인덱스 레인지 스캔을 하고 있다는 것을 알 수 있다. + +``` ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +| 1 | SIMPLE | Items | NULL | range | PRIMARY | PRIMARY | 8 | NULL | 3 | 100.00 | Using where | ++----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+-------------+ +``` + +## [ SQL 레벨업 ] 4장 집약과 자르기 + +### 12강 집약 + +최근에는 `GROUP BY` 구를 통한 집약에서 정렬 방식보다 해시 함수를 사용한 방식을 사용하는 경우가 더 많아지고 있다. 이때 해시 키는 `GROUP BY` 구에 지정되어 있는 필드를 해시 함수를 사용해서 만들고, 이 해시 키를 가진 그룹을 모아 집약하는 방법이다. Oracle의 경우 10.2 버전부터 `GROUP BY` 구를 사용했을 때 실행 계획에 `SORT GROUP BY` 대신 `HASH GROUP BY`가 출력되는 것을 알 수 있다. + +먼저, MySQL에서의 `GROUP BY` 구에 관해 살펴보기 위해 아래와 같은 구조를 가진 `Items1` 테이블이 있다고 가정해보자. + +``` ++-----------+--------------+------+-----+---------+-------+ +| Field | Type | Null | Key | Default | Extra | ++-----------+--------------+------+-----+---------+-------+ +| item_id | int | NO | PRI | NULL | | +| year | int | NO | PRI | NULL | | +| item_name | varchar(255) | NO | | NULL | | +| price | int | NO | | NULL | | ++-----------+--------------+------+-----+---------+-------+ +``` + +`GROUP BY` 구를 사용하는 방식은 크게 인덱스를 대상으로 한 방식과 인덱스를 대상으로 하지 않은 방식이 있으며, 집계 함수를 무엇을 사용하느냐에 따라서 `SHOW KEYS` 명령어를 활용해 `Items1` 테이블의 기본 키를 출력해보면 아래와 같이 `(item_id, year)` 튜플 형태의 기본 키가 설정된 것을 확인할 수 있다. + +``` ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression | ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +| Items1 | 0 | PRIMARY | 1 | item_id | A | 2 | NULL | NULL | | BTREE | | | YES | NULL | +| Items1 | 0 | PRIMARY | 2 | year | A | 299625 | NULL | NULL | | BTREE | | | YES | NULL | ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +``` + +이제 `item_id` 필드를 기준으로 아래와 같이 각 아이템 아이디 별 행 개수를 셈하는 쿼리를 실행한다고 가정해보자. + +```SQL +SELECT + item_id, + year, + COUNT(*) AS number_of_items +FROM Items1 +GROUP BY item_id, year; +``` + +위 쿼리의 실행 계획을 출력해보면 아래와 같다. `type` 컬럼의 값이 `index`인 것을 통해 해당 쿼리가 인덱스 풀 스캔(Index Full Scan)을 통해 개수를 셈하는 사실을 알 수 있다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +| 1 | SIMPLE | Items1 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 299625 | 100.00 | Using index | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +``` + +그러면 이제 반대로 아래와 같은 고유 키를 가진 `Items2` 테이블을 생성하고 동일한 쿼리를 실행해보자. `Items1` 테이블과 달리 `(year, item_id)` 튜플로 기본 키를 설정하여 `item_id` 컬럼과 `year` 컬럼의 순서가 바뀌었다. + +``` ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression | ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +| Items2 | 0 | PRIMARY | 1 | year | A | 102995 | NULL | NULL | | BTREE | | | YES | NULL | +| Items2 | 0 | PRIMARY | 2 | item_id | A | 299303 | NULL | NULL | | BTREE | | | YES | NULL | ++--------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ +``` + +실행 계획을 살펴보면 `Extra` 컬럼에 `Using temporary` 값이 추가된 것을 확인할 수 있다. 복합 컬럼 인덱스의 순서가 바뀌었기 때문에 `(item_id, year)` 튜플을 기준으로 한 `GROUP BY` 구는 인덱스를 사용하지 못하고 임시 테이블을 만들 수밖에 없는 것이다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+------------------------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+------------------------------+ +| 1 | SIMPLE | Items2 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 299303 | 100.00 | Using index; Using temporary | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+------------------------------+ +``` + +한 가지 특이한 점은 MySQL 8.0 이전 버전까지는 `GROUP BY` 구를 사용할 때 내부적으로 `GROUP BY` 구의 대상이 되는 컬럼을 기준으로 묵시적 정렬을 함께 수행했기 때문에 실행 계획 내의 `Extra` 컬럼에 `Using filesort` 값이 추가된다는 점이다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+----------------------------------------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+----------------------------------------------+ +| 1 | SIMPLE | Items2 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 15 | 100.00 | Using index; Using temporary; Using filesort | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+----------------------------------------------+ +``` + +그래서 아래 쿼리와 같이 MySQL 8.0 이전 버전에서는 정렬이 필요하지 않은 경우 `ORDER BYU NULL` 구를 추가하여 불필요한 정렬을 수행하지 않는 작업을 추가해주었다. + +```SQL +SELECT + item_id, + year, + COUNT(*) AS number_of_items +FROM Items2 +GROUP BY item_id, year +ORDER BY NULL; +``` + +`ORDER BY NULL` 구를 추가한 쿼리의 실행 계획을 출력해보면 아까와 달리 `Extra` 컬럼 값에 `Using filesort`가 제외된 것을 확인할 수 있다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ +| 1 | SIMPLE | Items2 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 15 | 100.00 | Using index; Using temporary | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ +``` + +그러나 MySQL 8.0 버전부터는 내부적으로 임시 테이블 생성한 뒤 중복 제거와 집합 함수 연산을 수행하는데, 이때 임시 테이블에 `INSERT` 쿼리를 실행하며, 만약 이미 존재하는 컬럼일 경우, 다시 말해 `GROUP BY` 구에 대상이 되는 컬럼일 경우 `UPDATE` 쿼리를 실행해서 집계 함수 연산을 수행한다. 따라서 MySQL 8.0 이전 버전에서 명시적으로 `ORDER BY NULL` 구를 사용하지 않고 인덱스가 아닌 컬럼을 대상으로 하는 `GROUP BY` 구를 사용하는 경우 임시 테이블을 생성하는 것은 물론 추가적인 정렬 작업까지 이루어지기 때문에 예상하지 못한 OOM(Out Of Memory) 오류를 조심해야 한다. + +MySQL은 정렬을 수행하기 위해 별도의 메모리 공간을 할당받아서 사용하며, SQL 표준에서 흔히 워킹 메모리(Working Memory)라 불리는 이 공간을 MySQL에서는 소트 버퍼(Sort Buffer)라 부른다. `sort_buffer_size` 시스템 변수를 지정해서 그 값을 변경할 수 있으며 기본으로 설정되어 있는 값은 262,144 바이트이며, 최소로 설정할 수 있는 값은 32,768 바이트, 최고로 설정할 수 있는 값은 64비트 플랫폼 기준 18,446,744,073,709,551,615 바이트로 꽤 크다. + +이때 특이한 점은 MySQL 서버의 메모리 구조는 InnoDB 버퍼 풀과 같이 클라이언트 스레드가 함께 공유하는 글로벌 메모리 영영과 클라이언트 별로 독립적으로 사용하는 로컬 메모리 영역으로 나뉘는데, 소트 버퍼의 경우 로컬 메모리 영역에 해당한다는 점이다. 따라서 소트 버퍼의 경우 개별 클라이언트 별로 독립된 메모리 공간을 가질 수 있으며, 커넥션의 개수가 많고 각 커넥션 별로 정렬 연산을 수행하는 경우가 많으면 각자 개별적인 소트 버퍼 메모리 공간을 할당 받아서 사용하기 때문에 점점 더 소트 버퍼 자체에 많은 메모리가 낭비된다. 물론 로컬 메모리 영역의 경우 MySQL이 메모리 공간을 아예 할당하지 않는 순간이 존재하는데, 이를 테면 소트 버퍼의 경우 쿼리에서 사용이 필요하지 않을 경우 메모리 공간을 할당하지 않고 있다가 소트 버퍼가 필요한 쿼리가 실행되면 메모리 공간을 할당했다가 쿼리가 종료되면서 다시 해제하는 방식이다. + +이처럼 MySQL에서 `GROUP BY` 구를 사용할 때는 우선 기본적으로 `GROUP BY` 구의 대상이 되는 컬럼이 인덱스를 활용할 수 있는지 여부를 확인해야 하며, MySQL 8.0 이전 버전에서는 내부적으로 정렬을 수행하기 때문에 `GROUP BY` 구의 결과 테이블이 정렬된 상태여야 하는지 여부를 판단해서 불필요한 추가 처리를 없애주어야 한다. + +이와 별개로 인덱스를 사용할 때도 타이트 인덱스 스캔(Tight Index Scan)과 루스 인덱스 스캔(Loose Index Scan) 두 가지 경우로 나뉠 수 있다. + +먼저 `(item_id, year)` 튜플로 복합 컬럼 인덱스가 설정된 `Items1` 테이블을 대상으로 아래와 같은 쿼리를 실행한다고 가정해보자. + +```SQL +SELECT + item_id, + COUNT(year) AS nubmer_of_years +FROM Items1 +GROUP BY item_id; +``` + +인덱스를 사용할 수 있기 때문에 실행 계획은 다음과 같이 나올 것이다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +| 1 | SIMPLE | Items1 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 299625 | 100.00 | Using index | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------------+ +``` + +그러면 이제 집계 함수 `COUNT` 대신 `MAX`를 사용한 아래와 같은 쿼리를 실행해보자. + +```SQL +SELECT + item_id, + MAX(year) AS latest_year +FROM Items1 +GROUP BY item_id; +``` + +그러면 아래와 같이 `Extra` 컬럼에 `Using index for group-by`라는 값이 생성된 것을 확인할 수 있다. `rows` 컬럼 또한 앞선 결과 달리 3개의 행만 조회하게 된다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+--------------------------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+--------------------------+ +| 1 | SIMPLE | Items1 | NULL | range | PRIMARY | PRIMARY | 4 | NULL | 3 | 100.00 | Using index for group-by | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+------+----------+--------------------------+ +``` + +`COUNT` 집계 함수를 사용한 경우가 타이트 인덱스 스캔 예시에 해당하며, `MAX` 집계 함수를 사용한 경우가 루스 인덱스 스캔에 해당한다. 타이트 인덱스 스캔은 일반적인 인덱스 스캔을 이용하는 방식이며, 루스 인덱스 스캔의 경우 인덱스의 레코드를 건너뛰면서 필요한 부분만 조회하여 가져오는 것을 의미한다. 인덱스를 조회하는 상황에서 `MAX` 집계 함수를 사용할 경우 이미지 정렬된 레코드를 조회하게 되어 가장 마지막에 존재하는 값을 조회하면 되기 때문에 굳이 처음부터 차례로 인덱스의 레코드를 조회할 필요 없이 중간 과정을 생략해서 필요한 부분만 조회할 수 있는 것이다. 결국 인덱스를 `GROUP BY` 구의 대상으로 했을 때, `MIN` 또는 `MAX` 집계 함수의 경우 가장 처음의 값 또는 가장 마지막 값만 가져오면 되기 때문에 조회가 불필요한 인덱스 레코드를 생략할 수 있지만 `AVG`, `COUNT`, `SUM` 등의 집계 함수의 경우 테이블을 처음부터 끝까지 조회해서 평균, 개수, 합계 등을 구해야 하기 때문에 인덱스 스캔을 할 수 밖에 없다. 이때 인덱스 레인지 스캔에서는 고유한 값이 많을수록, 다시 말해 카디널리티가 높을수록 성능이 향상되기 때문에 타이트 인덱스 스캔 `GROUP BY` 구에서는 카디널리티가 높은 상황이 좋지만 루스 인덱스 스캔에서는 카디널리티가 낮을수록 성능이 향상된다. 물론 `WHERE` 구를 사용하여 `GROUP BY` 구에서 인덱스를 사용할 수 있는지 여부를 따져야 하는 경우라면 상황은 더욱 복잡해진다. + +그렇다면 최종적으로 아래와 같은 쿼리는 어떤 실행 계획을 반환하게 될까? 각 아이템 별 가장 최근 일자와 가장 큰 가격을 조회하는 쿼리다. + +```SQL +SELECT + item_id, + MAX(year) AS latest_year, + MAX(price) AS maximum_price +FROM Items1 +GROUP BY item_id; +``` + +`Items1` 테이블에서 인덱스는 `(item_id, year)` 튜플 형태의 복합 컬럼 인덱스를 가졌기 때문에 그 순서에 맞게 `item_id` 컬럼을 `GROUP BY` 구의 대상으로 지정하여 인덱스를 사용할 수 있다. 그러나 집계 함수를 사용하는 과정에서 `year` 컬럼은 인덱스이기 때문에 레코드를 생략하여 가장 마지막 레코드만 조회할 수 있는, 다시 말해 루스 인덱스 스캔이 가능하지만 `price` 컬럼의 경우 인덱스가 아니기 때문에 풀 스캔을 할 수 밖에 없다. + +결론적으로 실행 계획은 아래와 같이 `type` 컬럼에 인덱스 풀 스캔을 의미하는 `index` 값이 나오게 되고, `Extra` 컬럼 값에는 `price` 컬럼으로 인해 인덱스를 조회하지 못하고 데이터 파일을 조회해야 하기 때문에 `Using index for group-by` 또는 커버링 인덱스 또한 사용할 수 없어 `Using index` 같은 값이 적히지 않고 `NULL` 값을 출력한다. + +``` ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------+ +| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------+ +| 1 | SIMPLE | Items1 | NULL | index | PRIMARY | PRIMARY | 8 | NULL | 299625 | 100.00 | NULL | ++----+-------------+--------+------------+-------+---------------+---------+---------+------+--------+----------+-------+ +``` + +## [ Programmers ] 문제 풀이 + +### 대여 기록이 존재하는 자동차 리스트 구하기 + +#### 풀이 + +아래와 같이 `INNER JOIN` 구와 함께 `WHERE` 구를 통해 조건에 맞는 레코드를 필터링하고, `DISTINCT` 키워드를 사용해서 고유한 `CAR_ID` 값만 조회하는 방법으로 문제를 풀 수 있다. + +```SQL +SELECT DISTINCT CAR_RENTAL_COMPANY_CAR.CAR_ID +FROM CAR_RENTAL_COMPANY_CAR +JOIN CAR_RENTAL_COMPANY_RENTAL_HISTORY +USING (CAR_ID) +WHERE ( + CAR_RENTAL_COMPANY_CAR.CAR_TYPE = '세단' + AND + MONTH(CAR_RENTAL_COMPANY_RENTAL_HISTORY.START_DATE) = 10 +) +ORDER BY CAR_ID DESC; +``` + +### 저자 별 카테고리 별 매출액 집계하기 + +#### 풀이 + +아래와 같이 서브쿼리를 활용하여 `INNER JOIN` 구의 대상 테이블에 `SUM` 집계 함수를 사용하여 판매 개수를 먼저 구한 값을 필드로 생성하고, 이를 외부 테이블에서 다시 `SUM` 집계 함수와 함께 사용하는 방법으로 문제를 풀 수 있다. + +```SQL +SELECT + AUTHOR.AUTHOR_ID, + AUTHOR.AUTHOR_NAME, + BOOK.CATEGORY, + SUM(BOOK.PRICE * SPECIFIC_BOOK_SALES.TOTAL_SALES) AS TOTAL_SALES +FROM BOOK +JOIN ( + SELECT + BOOK_ID, + SUM(SALES) AS TOTAL_SALES + FROM BOOK_SALES + WHERE ( + YEAR(SALES_DATE) = 2022 + AND + MONTH(SALES_DATE) = 1 + ) + GROUP BY BOOK_ID +) AS SPECIFIC_BOOK_SALES +USING (BOOK_ID) +JOIN AUTHOR +USING (AUTHOR_ID) +GROUP BY BOOK.AUTHOR_ID, BOOK.CATEGORY +ORDER BY AUTHOR_ID ASC, CATEGORY DESC; +``` \ No newline at end of file diff --git a/Document/2023/1112/taehyun/query.sql b/Document/2023/1112/taehyun/query.sql new file mode 100644 index 0000000..6504026 --- /dev/null +++ b/Document/2023/1112/taehyun/query.sql @@ -0,0 +1,44 @@ +CREATE DATABASE IF NOT EXISTS test_group_by DEFAULT CHARACTER SET utf8mb4; + +USE test_group_by; + +CREATE TABLE IF NOT EXISTS Items1 ( + item_id INTEGER NOT NULL, + year INTEGER NOT NULL, + item_name VARCHAR(255) NOT NULL, + price INTEGER NOT NULL, + + PRIMARY KEY (item_id, year) +); + +CREATE TABLE IF NOT EXISTS Items2 ( + item_id INTEGER NOT NULL, + year INTEGER NOT NULL, + item_name VARCHAR(255) NOT NULL, + price INTEGER NOT NULL, + + PRIMARY KEY (year, item_id) +); + +DELIMITER $$ +CREATE PROCEDURE CreateItem(IN fixed_item_id INT, IN fixed_item_name VARCHAR(255)) +BEGIN + DECLARE current_year INT DEFAULT 1; + DECLARE current_price INT DEFAULT 10; + + WHILE current_year <= 1000000 DO + INSERT INTO Items1 (item_id, year, item_name, price) VALUES (fixed_item_id, current_year, fixed_item_name, current_price); + SET current_year = current_year + 1; + SET current_price = current_price + 10; + END WHILE; +END $$ +DELIMITER ; + +CALL CreateItem(101, '머그컵'); +CALL CreateItem(102, '티스푼'); +CALL CreateItem(103, '나이프'); + +INSERT INTO Items2 (item_id, year, item_name, price) +SELECT item_id, year, item_name, price FROM Items1; + +