[우테코] 인덱스를 활용한 스탬프크러쉬의 쿼리 성능 개선
안녕!
우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다.
사장모드: stampcrush.site/admin
고객모드: stampcrush.site
💋 인트로
안녕하세요, 우아한테크코스 5기 깃짱
입니다.
이번 포스팅에서는 제가 진행하고 있는 프로젝트인, 스탬프크러쉬
에서 자주 사용되는 쿼리들의 실행 계획을 확인하고, 개선하는 과정에 대해서 설명하고자 합니다.
💋 실행 계획
✔️ 실행 계획 확인의 필요성
우리가 애플리케이션을 구성할 때 MySQL과 같은 DBMS를 사용하는 이유는, 데이터를 안전하게 저장, 관리하고, 빠르게 조회하기 위해서입니다.
MySQL에는 쿼리를 쿼리를 최적으로 처리될 수 있도록 실행 계획을 수립하기 위해서, MySQL 엔진의 뇌라고 볼 수 있는 옵티마이저가 존재합니다. MySQL 옵티마이저는 쿼리를 분석해 실행 계획을 만들고, 이후에 수립한 실행 계획대로 실제로 스토리지 엔진을 통해서 디스크에 접근해 데이터를 읽어옵니다.
하지만, 옵티마이저가 항상 좋은 실행 계획을 만드는 것은 아닙니다. 따라서 MySQL에서는 사용자가 실행 계획을 보완할 수 있도록 EXPLAIN
명령어로 실행 계획을 확인할 수 있도록 해서 합리적이지 못한 실행 계획의 경우에 개발자가 직접 실행 계획을 수정할 수 있도록 도와줍니다.
✔️ 실행 계획 확인 명령어
기존에 EXPLAIN
명령어를 통해서 실행 계획을 확인할 수 있었는데, MySQL 8.0.18 버전부터는 EXPLAIN ANALYZE
명령어를 통해서 실행 계획의 단계별로 소요된 시간 정보도 확인할 수 있게 되었습니다.
EXPLAIN ANALYZE
는 실제로로 쿼리가 완료되어야 실행 계획을 확인할 수 있으므로, 실행 시간이 너무 오래 걸린다면 EXPLAIN
명령어로 쿼리를 어느 정도 튜닝한 후에 해당 명령어를 실행하는 것이 좋습니다.
✔️ 실행 계획 필드의 의미
스탬프크러쉬 서비스 쿼리의 실행 계획을 개선하기 이전에 앞서, EXPLAIN ANALYZE
명령어를 통해서 출력한 실행 계획 필드의 의미를 이해할 수 있어야 합니다.
먼저, 예시를 통해 EXPLAIN ANALYZE
출력 결과를 분석해 봅시다!
SELECT first_name, last_name, SUM(amount) AS total
FROM staff INNER JOIN payment
ON staff.staff_id = payment.staff_id
AND
payment_date LIKE '2005-08%'
GROUP BY first_name, last_name;
+------------+-----------+----------+
| first_name | last_name | total |
+------------+-----------+----------+
| Mike | Hillyer | 11853.65 |
| Jon | Stephens | 12218.48 |
+------------+-----------+----------+
2 rows in set (0,02 sec)
이 명령어는 데이터베이스에서 staff
와 payment
테이블을 조인하여 2005년 8월
에 이루어진 결제 내역을 각 직원별
로 합산한 결과를 출력하는 쿼리입니다. 현재 결과로는 Mike, Jon 두 사람만이 출력되었습니다.
EXPLAIN ANALYZE
명령어를 통해서 방금 실행한 쿼리를 분석해 보겠습니다.
EXPLAIN ANALYZE
SELECT first_name, last_name, SUM(amount) AS total
FROM staff INNER JOIN payment
ON staff.staff_id = payment.staff_id
AND
payment_date LIKE '2005-08%'
GROUP BY first_name, last_name;
-> Table scan on <temporary> (actual time=0.001..0.001 rows=2 loops=1)
-> Aggregate using temporary table (actual time=58.104..58.104 rows=2 loops=1)
-> Nested loop inner join (cost=1757.30 rows=1787) (actual time=0.816..46.135 rows=5687 loops=1)
-> Table scan on staff (cost=3.20 rows=2) (actual time=0.047..0.051 rows=2 loops=1)
-> Filter: (payment.payment_date like '2005-08%') (cost=117.43 rows=894) (actual time=0.464..22.767 rows=2844 loops=2)
-> Index lookup on payment using idx_fk_staff_id (staff_id=staff.staff_id) (cost=117.43 rows=8043) (actual time=0.450..19.988 rows=8024 loops=2)
출력된 실행 계획 중, 일부를 더 자세히 살펴봅시다.
Filter: (payment.payment_date like '2005-08%')
(cost=117.43 rows=894)
(actual time=0.464..22.767 rows=2844 loops=2)
두 번째 줄에 등장하는 (cost=117.43 rows=894)
의cost
와 rows
는 옵티마이저가 예측한 통계 정보입니다. cost
는 옵티마이저가 데이터 액세스, 정렬, 조인 등의 작업에 필요한 리소스 소비량을 기준으로 예상 비용을 산출한 총 예상 비용입니다. rows
는 예상되는 결과 행의 수를 나타내며, 예측한 값이기 때문에 실제 결과와 다를 수 있습니다.
마지막 줄에 등장하는 (actual time=0.464..22.767 rows=2844 loops=2)
의 actual time
, rows
, loops
는 실제 쿼리의 실행 결과입니다.
actual time
은 해당 실행 계획을 실행하는 데 걸린 실제 시간으로, 단위는 ms입니다. 두 개의 숫자 중에서 첫 번째 숫자 값인 0.464
은 첫 번째 레코드를 가져오는데 걸린 시간(Actual time to get first row)을 의미하고, 두 번째 숫자 값인 22.767
는 모든 레코드를 가져오는데 걸린 시간(Actual time to get all rows)을 의미합니다. loops
가 1 이상인 경우에는 평균 값을 의미합니다.
rows
는 실제로 읽은 행의 수(Actual number of rows read)를 나타냅니다. 앞서 예측한 값인 894에 비해 세 배 가까이 많은 2844개의 행이 실제로는 반환되었음을 확인할 수 있습니다.
loops
는 실행 계획이 반복 실행된 횟수(Actual number of loops)를 나타냅니다. 일반적으로, loops
값이 높으면 해당 실행 계획이 반복 작업을 수행해야 하는 비용이 높다는 것을 의미합니다. 이는 실행 계획이 반복 작업을 수행하는 횟수가 많아질수록 성능 저하를 초래할 수 있습니다.
참고로, EXPLAIN ANALYZE
명령어는 항상 결과를 TREE 포맷으로 보여줍니다. (EXPLAIN
명령어와 달리 포맷을 직접 설정할 수 없습니다.)
이 포맷에서 들여쓰기가 같은 레벨은 위에서부터 실행되고, 들여쓰기가 다른 레벨에서는 가장 안쪽에 위치한 라인이 먼저 실행됩니다.
이제는 스탬프크러쉬 서비스에서 자주 사용하는 쿼리의 실행 계획을 직접 살펴보고, 개선해 봅시다!
💋 전화번호로 조회 → 전화번호에 인덱스 설정
✔️ 사전 정보
customer
테이블의 총 레코드 수 →5083개
mysql> select count(*) from customer;
+----------+
| count(*) |
+----------+
| 5083 |
+----------+
1 row in set (0.00 sec)
customer
테이블의 정보
mysql> describe table customer;
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
| 1 | SIMPLE | customer | NULL | ALL | NULL | NULL | NULL | NULL | 4653 | 100.00 | NULL |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)
✔️ 성능을 측정할 쿼리
explain analyze select
c1_0.customer_id,
c1_0.customer_type,
c1_0.email,
c1_0.encrypted_password,
c1_0.login_id,
c1_0.nickname,
c1_0.oauth_id,
c1_0.oauth_provider,
c1_0.phone_number
from
customer c1_0
where
c1_0.phone_number='01038626099';
✔️ 인덱스 설정 전
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Filter: (c1_0.phone_number = '01038626099') (cost=469 rows=465) (actual time=0.13..5.19 rows=1 loops=1)
-> Table scan on c1_0 (cost=469 rows=4652) (actual time=0.0534..4.49 rows=5083 loops=1)
|
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
인덱스 설정 전의 실행 계획을 살펴보면, 필터링 작업과 테이블 스캔 작업으로 크게 나눌 수 있습니다.
필터링 조건인 c1_0.phone_number = '01038626099'
을 만족하는 데이터를 찾기 위해 필터링 작업(filter)을 수행하고, 이후에는 c1_0
테이블을 전체 스캔(table scan)하여 데이터를 가져옵니다.
✔️ phone_number
에 인덱스 설정
mysql> create index index_phone_number on customer (phone_number);
Query OK, 0 rows affected (0.08 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> show index from customer;
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.00 sec)
✔️ 인덱스 설정 결과
+------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Index lookup on c1_0 using index_phone_number (phone_number='01038626099') (cost=0.35 rows=1) (actual time=0.0286..0.0302 rows=1 loops=1)
|
+------------------------------------------------------------------------------------------------------------------------------------------------+
인덱스 설정 후의 실행 계획을 살펴보면, 인덱스를 이용하여 c1_0.phone_number = '01038626099'
조건에 해당하는 데이터를 찾습니다. 예상 비용은 0.35이고, 실제 소요 시간은 0.0286에서 0.0302
까지 변동하며, 가져오는 행의 수도 1개입니다.
✔️ 인덱스 설정 결과 비교
인덱스 설정 전 | phone_number에 인덱스 설정 | |
스캔 방식 | 풀 테이블 스캔 | phone_number 인덱스 사용 |
스캔한 열 수 | 전체(5083) | 1 |
소요 시간 | 0.0534..4.49 | 0.0286..0.0302 |
따라서, 인덱스 설정 후에는 필터링 작업과 전체 테이블 스캔이 필요하지 않고, 인덱스를 통해 더 효율적으로 데이터를 찾을 수 있기 때문에 성능이 좋아진 것으로 볼 수 있습니다.
💋 OAuth 인증을 위한 커버링 인덱스
설정
✔️ 성능을 측정할 쿼리
EXPLAIN ANALYZE
SELECT customer_id FROM customer
WHERE oauth_provider = 'KAKAO' AND oauth_id = 2972148695;
✔️ 인덱스 설정 전
위에서 설정한 phone_number
에 대한 인덱스만 설정되어 있는 상황입니다.
mysql> show index from customer;
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.00 sec)
성능 측정 결과는 아래와 같습니다.
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Filter: ((customer.oauth_id = 2972148695) and (customer.oauth_provider = 'KAKAO')) (cost=469 rows=46.5) (actual time=0.0836..2.54 rows=1 loops=1)
-> Table scan on customer (cost=469 rows=4652) (actual time=0.0394..2.2 rows=5083 loops=1)
|
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
아래처럼 세 가지 인덱스 설정을 고려해볼 수 있습니다.
- oauth_provider, oauth_id 순서로 인덱스 설정
oauth_id
,oauth_provider
순서로 인덱스 설정oauth_provider
,oauth_id
복합 unique 인덱스 설정oauth_id
,oauth_provider
복합 unique 인덱스 설정
직접 실습을 통해서 설정해 봅시다!
✔️ oauth_provider
, oauth_id
순서로 인덱스 설정 및 결과
mysql> create index index_oauth_provider_oauth_id on customer (oauth_provider, oauth_id);
Query OK, 0 rows affected (0.05 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> show index from customer;
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_oauth_provider_oauth_id | 1 | oauth_provider | A | 2 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_oauth_provider_oauth_id | 2 | oauth_id | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
성능 측정 결과는 아래와 같습니다.
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Covering index lookup on customer using index_oauth_provider_oauth_id (oauth_provider='KAKAO', oauth_id=2972148695) (cost=0.95 rows=1) (actual time=0.0216..0.0253 rows=1 loops=1)
|
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
✔️ oauth_id
, oauth_provider
순서로 인덱스 설정 및 결과
mysql> create index index_oauth_id_oauth_provider on customer (oauth_id, oauth_provider);
Query OK, 0 rows affected (0.06 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> show index from customer;
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_oauth_id_oauth_provider | 1 | oauth_id | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_oauth_id_oauth_provider | 2 | oauth_provider | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
성능 측정 결과는 아래와 같습니다.
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Covering index lookup on customer using index_oauth_id_oauth_provider (oauth_id=2972148695, oauth_provider='KAKAO') (cost=0.95 rows=1) (actual time=0.0204..0.0238 rows=1 loops=1)
|
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
✔️ oauth_provider, oauth_id 복합 unique 인덱스 설정 및 결과
mysql> CREATE UNIQUE INDEX idx_customer_oauth_provider_oauth_id ON customer (oauth_provider, oauth_id);
Query OK, 0 rows affected (0.05 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> show index from customer;
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 0 | idx_customer_oauth_provider_oauth_id | 1 | oauth_provider | A | 2 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 0 | idx_customer_oauth_provider_oauth_id | 2 | oauth_id | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
+--------------------------------------------------------------------------------------------------+
| EXPLAIN |
+--------------------------------------------------------------------------------------------------+
| -> Rows fetched before execution (cost=0..0 rows=1) (actual time=66e-6..115e-6 rows=1 loops=1)
|
+--------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
✔️ oauth_id, oauth_provider 복합 unique 인덱스 설정 및 결과
mysql> show index from customer;
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer | 0 | PRIMARY | 1 | customer_id | A | 4652 | NULL | NULL | | BTREE | | | YES | NULL |
| customer | 0 | idx_customer_oauth_id_oauth_provider | 1 | oauth_id | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 0 | idx_customer_oauth_id_oauth_provider | 2 | oauth_provider | A | 7 | NULL | NULL | YES | BTREE | | | YES | NULL |
| customer | 1 | index_phone_number | 1 | phone_number | A | 4652 | NULL | NULL | YES | BTREE | | | YES | NULL |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
+--------------------------------------------------------------------------------------------------+
| EXPLAIN |
+--------------------------------------------------------------------------------------------------+
| -> Rows fetched before execution (cost=0..0 rows=1) (actual time=73e-6..114e-6 rows=1 loops=1)
|
+--------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
✔️ 인덱스 설정 결과 비교
인덱스 설정 전 | oauth_provider, oauth_id 순서로 인덱스 설정 | oauth_id, oauth_provider 순서로 인덱스 설정 | oauth_provider, oauth_id 복합 unique 인덱스 설정 | oauth_id, oauth_provider 복합 unique 인덱스 설정 | |
스캔 방식 | Filter 후 Full Table Scan | 설정한 인덱스 사용 | 설정한 인덱스 사용 | 설정한 인덱스 사용 | 설정한 인덱스 사용 |
스캔한 열 수 | Filter: 1, | ||||
Full Table Scan: 5083 | 1 | 1 | 1 | 1 | |
소요 시간 | 0.0286..0.0302 | 0.0216..0.0253 | 0.0204..0.0238 | 66e-6..115e-6 | 73e-6..114e-6 |
- oauth_provider, oauth_id 순서로 인덱스 설정한 경우, 커버링 인덱스를 설정했기 때문에 소요 시간이 조금 단축되기는 했지만, 먼저 설정한 인덱스인 oauth_provider 의 cardinality가 낮아서 큰 효과는 없었습니다.
- oauth_id, oauth_provider 순서로 인덱스 설정한 경우, 커버링 인덱스를 설정해 소요 시간이 단축되었고, oauth_provider, oauth_id 순서로 인덱스 설정한 경우에 비해서 먼저 설정한 인덱스인 oauth_id의 cardinality가 높아서 소요시간이 더 단축됩니다.
💋 참고자료
- https://dev.mysql.com/blog-archive/mysql-explain-analyze/
- Real MySQL 8.0
도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟