프로젝트의 성능 테스트를 진행하며 검색 성능을 최적화한 기록을 정리한 글이다.
이 글에선 검색에 사용된 SQL(카운트 쿼리)을 분석해보고 좀 더 효율적인 쿼리 방법을 찾는 과정을 설명한다.
JVM의 응답속도를 봐도 응답시간이 1초는 가볍게 넘어가고, 분단위까지 넘어가는것을 확인할 수 있었다.
원인을 파악하기 위해 가장 먼저 SQL을 분석해봤다.
아래는 애플리케이션에서 발생한 검색 로그에 찍힌 SQL 이다.
2024-08-06 19:31:40.580 [http-nio-8080-exec-2] [INFO] p6spy - [statement] | 10 ms |
select
pd1_0.product_detail_id,
p1_0.name,
c1_0.name,
p1_0.manufacturer,
p1_0.description,
pd1_0.price,
pd1_0.attribute
from
product_detail pd1_0
left join
product p1_0
on p1_0.product_id=pd1_0.product_id
left join
category c1_0
on c1_0.category_id=p1_0.category_id
where
p1_0.name like '%Chair%' escape '!'
and pd1_0.is_deleted=false
limit
0, 30
2024-08-06 19:31:45.043 [http-nio-8080-exec-2] [INFO] p6spy - [statement] | 4445 ms |
select
count(pd1_0.product_detail_id)
from
product_detail pd1_0
left join
product p1_0
on p1_0.product_id=pd1_0.product_id
where
p1_0.name like '%Chair%' escape '!'
and pd1_0.is_deleted=false
로그를 확인해보면 검색 쿼리에는 10ms 정도, 카운트 쿼리에 4초 정도 걸리는것을 확인할 수 있다.
카운트 쿼리 에서 상당히 많은 시간이 걸린다는것을 알 수 있다.
두 쿼리의 실행 계획 분석을 해보자.
사실 검색 쿼리는 10ms 정도로 아주 짧게 수행된다. 이유는 limit
의 최적화가 동작하기 때문이다.
DBMS는 옵티마이저를 이용해 쿼리를 최대한 최적화시켜 실행하기를 원한다.
때문에 limit
가 있다면 제한된 행 이후 연산은 불필요한 연산 이므로 DBMS는 수행하지 않는다.
때문에 조회 결과가 아주 많더라도 limit
에 의해 30건만 제공되기 때문에 빠른 속도로 수행되는 것이다.
검색 쿼리의 실행 계획을 분석해봤다.
type
: 조회 타입product
: 상품 이름으로 검색을 하는 product
테이블의 경우 풀스캔이 발생한다.category
: 상품이 카테고리의 기본키를 외래키를 통해 조회하기 때문에 빠른 단일행 조힌이 발생한다.product_detail
: 상품의 키를 외래키로 가지고 있는 상세 상품을 조회하기 때문에 ref
으로 조회한다.key
: 검색 인덱스product
: 풀스캔이기 떄문에 인덱스가 없다.category
: 기본키를 사용해 조회하기 때문에 프라이머리키 인덱스가 사용된다.product_detail
: 상품의 키를 외래키로 가지고 있기 때문에 이것이 사용된다.쿼리를 분석해본 결과 상품의 이름으로 product
테이블을 풀스캔해 상품을 찾고 이와 관련된 category
, detail
정보를 조인해서 가져오게 된다.
이때 조인한 테이블은 외래키 인덱스 덕분에 빠르게 데이터를 찾아올 수 있다.
즉 검색쿼리에서 가장 많이 소요되는 작업은 product
테이블의 풀 스캔이다.
카운트 쿼리의 실행 계획을 분석해 봤다.
결과를 보면 product
테이블을 풀스캔으로 스캔해 상품을 찾고, 그와 연관된 상세 상품을 조인을 통해 가져오는것을 알 수 있다.
즉 product
테이블의 풀스캔 시간을 줄이게 된다면 전체 쿼리 수행 시간이 짧아질 것이다.
원인을 찾았으니 최적화를 해보자.
검색쿼리는 like
로 검색하던 where 절을 FULLTEXT INDEX를 적용하는걸로 바꿨다.
그리고 join에 참여하는 드라이브 테이블을 인덱스가 적용된 product
테이블로 변경했다.
변경된 쿼리는 다음과 같다.
explain
select
pd1_0.product_detail_id,
p1_0.name,
c1_0.name,
p1_0.manufacturer,
p1_0.description,
pd1_0.price,
pd1_0.attribute
from
/* 변경 1 */
product_detail pd1_0
left join
product p1_0
on p1_0.product_id=pd1_0.product_id
left join
category c1_0
on c1_0.category_id=p1_0.category_id
where
/* 변경 2 */
p1_0.name like '%Chair%' escape '!'
and pd1_0.is_deleted=false
limit
0, 30
explain
SELECT
p1_0.name AS product_name,
c1_0.name AS category_name,
p1_0.manufacturer,
p1_0.description,
pd1_0.price,
pd1_0.attribute
FROM
/* 변경 1 */
product p1_0
LEFT JOIN
product_detail pd1_0
ON p1_0.product_id = pd1_0.product_id
LEFT JOIN
category c1_0
ON c1_0.category_id = p1_0.category_id
WHERE
/* 변경 2 */
MATCH(p1_0.name) AGAINST ('Chair')
AND pd1_0.is_deleted = false
LIMIT
0, 30;
실행계획을 분석하면 원하던대로 인덱스를 타고 조회하는것을 확인할 수 있다.
카운트 쿼리도 like
검색 where 절을 FULLTEXT
를 사용하도록 변경하고, join 드라이브 테이블을 인덱스가 적용된 proeuct
로 변경했다.
select
count(pd1_0.product_detail_id)
from
product_detail pd1_0
left join
product p1_0
on p1_0.product_id=pd1_0.product_id
where
p1_0.name like '%Chair%' escape '!'
and pd1_0.is_deleted=false
select
count(pd1_0.product_detail_id)
from
product p1_0
left join
product_detail pd1_0
on p1_0.product_id=pd1_0.product_id
where
MATCH(p1_0.name) AGAINST ('Chair')
and pd1_0.is_deleted=false ;
카운트 쿼리도 like
대신 MATCH ... AGAINST
을 사용하고 실행계획을 분석해보면 풀스캔에서 FULLTEXT INDEX
스캔으로 줄어들은것을 확인할 수 있다.
이제 변경된 쿼리의 실행 시간을 비교해보자.
특히 오래 걸리던 카운트 쿼리의 실행 시간을 비교해 봤다.
수행 시간을 보면 인덱스를 적용하기 전과 후 사이에 큰 차이가 없다는것을 알 수 있다.
왜 이런 결과가 나왔을까??
어떤점이 문제인지 확인해보기 위해 그냥 product
테이블만 조회를 해봤다.
인덱스를 거는 쿼리와, 걸지 않는 쿼리로 각각 카운트 쿼리를 날려봤다.
select
count(*)
from
product p1_0
where
MATCH(p1_0.name) AGAINST ('Chair')
select
count(*)
from
product p1_0
where
p1_0.name like '%Chair%'
직접 수행해보면 인덱스를 탈때 조회 시간이 압도적으로 빠른것을 확인할 수 있다.
하지만 왜 join이 들어가면 별차이가 없어질까??
이 문제를 알아보기 위해 좀 더 자세히 알아보았다.
EXPLAIN ANALYZE는 실제 실행을 바탕으로 쿼리를 분석한다. 또한 실행 시간까지 같이 제공해주기 때문에 훨씬 정확한 분석이 가능하다.
이쯤부터 카운트 쿼리 실행에 총 1000ms 가 걸렸는데, 이는 하도 실행을 해서 버퍼풀에 캐싱된 결과인듯 하다.
실제로 테스트 코드를 실행해 버퍼풀에 다른 데이터를 채우면 다시 4 ~ 5000ms 정도로 걸리는것을 확인할 수 있었다.
explain analyze {query}
로 분석할 수 있다.
-> Aggregate: count(pd1_0.product_detail_id) (cost=319751 rows=1) (actual time=1045..1045 rows=1 loops=1)
-> Nested loop inner join (cost=294054 rows=256964) (actual time=0.12..1036 rows=238736 loops=1)
-> Filter: (p1_0.`name` like '%Chair%') (cost=68492 rows=62035) (actual time=0.0841..364 rows=28668 loops=1)
-> Table scan on p1_0 (cost=68492 rows=558375) (actual time=0.0781..182 rows=570553 loops=1)
-> Filter: (pd1_0.is_deleted = false) (cost=2.81 rows=4.14) (actual time=0.0157..0.023 rows=8.33 loops=28668)
-> Index lookup on pd1_0 using idx_product_detail_product_id (product_id=p1_0.product_id) (cost=2.81 rows=8.28) (actual time=0.0156..0.0224 rows=8.33 loops=28668)
간단하게 설명하면
cost
: 예상 비용을 말한다rows
: 예상 결과 행수를 말한다.actual time = N ... M
: 작업을 시작하는데까지 N ms
, 작업을 종료하는데까지 M ms
가 걸린다 actual rows
: 실제 읽은 행 수actual loopgs
: 실제 루프 수를 의미한다.
즉 위의 결과를 말로 설명하자면
count
집계 작업
1045ms
를 기다렸다.1045ms
가 걸렸다. 0ms
인것임.0ms
라는 의미.Nested Loop inner Join
작업
0.12ms
가 걸림. 1036ms
가 걸림.1036ms
238,736
행이 나오게 되었다.p1_0.name like '%Chair%
필터링
like '%Chair%'
필터링을 수행한다.570,553
개의 테이블을 풀스캔 하는데 첫 행을 읽기까지 0.0781ms
가 걸리고 마지막 행을 읽는데까지 182ms
가 걸림0.0841ms
가 걸리고 마지막 행을 필터링하는데까지 364ms
가 걸림28,668
행이 나오게 됨.pd1_0.is_deleted = false
필터링
product_id=p1_0.product_id
작업의 결과만 필터링을 한다.product_id=p1_0.product_id
작업은 인덱스를 사용해 검색하게 된다.pd1_0
에서 한 행을 읽는데 시작하기 까지 0.0156ms
가 걸리고, 모든 행을 읽는데까지 0.0224ms
가 걸린다.8.33개
행을 읽고, 이 과정을 28,668
번 반복한다.8.33개
의 행을 필터링 하는데 0.023ms
가 수행된다.즉 %Chair%
인덱스를 통한 검색에 364ms 가 걸리고, 이 결과를 조인하는데 0.23 * 28668 = 659ms
가 걸려 총 1000ms가 걸리는 것이다.
explain analyze의 결과는 작업 순서를 말하는게 아니다.
작업 순서는 논리적인 순서와 의존성등으로 해석할 수 있다.
mysql-workbench
로 플로우를 그려보면 다음과 같다.
작업의 색깔의 의미는 다음 표와 같다.
빨강 -> 파랑 으로 갈수록 부하가 적다는것을 의미한다.
-> Aggregate: count(pd1_0.product_detail_id) (cost=5.15 rows=1) (actual time=1059..1059 rows=1 loops=1)
-> Nested loop inner join (cost=4.74 rows=4.14) (actual time=5.36..1050 rows=238736 loops=1)
-> Filter: (match p1_0.`name` against ('Chair')) (cost=1.1 rows=1) (actual time=5.3..161 rows=28668 loops=1)
-> Full-text index search on p1_0 using name (name='Chair') (cost=1.1 rows=1) (actual time=5.29..158 rows=28668 loops=1)
-> Filter: (pd1_0.is_deleted = false) (cost=3.22 rows=4.14) (actual time=0.0219..0.0305 rows=8.33 loops=28668)
-> Index lookup on pd1_0 using idx_product_detail_product_id (product_id=p1_0.product_id) (cost=3.22 rows=8.28) (actual time=0.0218..0.0299 rows=8.33 loops=28668)
수정된 카운트 쿼리의 경우에도 결국 Chair
를 인덱스로 검색하는데 161ms
가 걸리지만, join
하는데 약 0.03 * 28668 = 860ms
가 걸리는 것이다.
왜 join
에 이렇게 오래 걸릴까??
인덱스를 통해 검색 시간을 줄어들었지만 상품과 실제 상품을 join
하는데에 엄청난 시간이 걸린다.
이 join
시간을 줄이기 위한 노력을 해야한다.
일반적으로 쿼리는 다음 순서대로 실행된다.
즉 나의 카운트 쿼리에서 JOIN
이후 WHERE
로 인덱스를 탔기 때문에 인덱스를 타기전 JOIN
에서 많은 시간이 소요된 것이다.
이 병목이 되는 JOIN
시간을 줄이기 위해 많은 시도를 해봤다.
MATCH
로 검색을 먼저 수행하고, 그 결과 테이블을 JOIN
의 드라이브 테이블로 사용해 JOIN
시간을 줄여보려는 전략이다.
explain analyze
select
count(*)
from
(
select
*
from
product p
where
match(p.name) against ('Chair')
) as p
left join
product_detail pd
on pd.product_id = p.product_id
where
pd.is_deleted = false;
-> Aggregate: count(0) (cost=5.06 rows=1) (actual time=862..862 rows=1 loops=1)
-> Nested loop inner join (cost=4.64 rows=4.14) (actual time=4.16..854 rows=238736 loops=1)
-> Filter: (match p.`name` against ('Chair')) (cost=1.1 rows=1) (actual time=4.12..94.8 rows=28668 loops=1)
-> Full-text index search on p using name (name='Chair') (cost=1.1 rows=1) (actual time=4.11..93 rows=28668 loops=1)
-> Filter: (pd.is_deleted = false) (cost=3.13 rows=4.14) (actual time=0.0178..0.0261 rows=8.33 loops=28668)
-> Index lookup on pd using idx_product_detail_product_id (product_id=p.product_id) (cost=3.13 rows=8.28) (actual time=0.0177..0.0255 rows=8.33 loops=28668)
하지만 실행 시간에는 큰 차이가 없었다. (800ms로 줄어든것 같지만 캐싱이 없다면 다른 쿼리들과 동일한 시간으로 수행된다.)
역시 동일하게 index 검색에는 시간이 많이 소요되지 않았지만, 조인에서 엄청난 시간이 들었다.
JOIN
에 시간이 많이 드는것 같아 JOIN
없이 in, exists를 사용해 시간을 줄여보려 했다.
하지만 이역시도 큰 효과를 보지 못했다.
현재 상품의 소프트 삭제 때문에 is_deleted
라는 컬럼을 유지하고 있고, 조회시에도 이를 체크하게 된다.
혹시나 싶어서 이 체크 없이 실행을 해봤더니, 눈에 띄는 성능 향상이 있었다.
explain analyze
select
count(pd1_0.product_detail_id)
from
product p1_0
left join
product_detail pd1_0
on p1_0.product_id=pd1_0.product_id
where
MATCH(p1_0.name) AGAINST ('Chair');
-> Aggregate: count(pd1_0.product_detail_id) (cost=3.77 rows=1) (actual time=187..187 rows=1 loops=1)
-> Nested loop left join (cost=2.94 rows=8.28) (actual time=3.99..179 rows=239410 loops=1)
-> Filter: (match p1_0.`name` against ('Chair')) (cost=1.1 rows=1) (actual time=3.96..79.9 rows=28668 loops=1)
-> Full-text index search on p1_0 using name (name='Chair') (cost=1.1 rows=1) (actual time=3.96..78.4 rows=28668 loops=1)
-> Covering index lookup on pd1_0 using idx_product_detail_product_id (product_id=p1_0.product_id) (cost=1.84 rows=8.28) (actual time=0.00196..0.003 rows=8.33 loops=28668)
조인 결과에서 is_deleted
컬럼 검사를 뺀것만으로 한 행을 읽는데 0.03ms
에서 0.003ms
로 10배의 속도 향상이 있었다.
즉 is_deleted
컬럼 비교가 성능에서 많은 시간을 잡아먹고 있는 것이다.
is_deleted
컬럼에 인덱스도 추가해 봤지만, 카리널리티가 너무 낮은 탓에 (true, false) 인덱스를 타지도 않았다.
카운트 쿼리에서 가장 시간을 많이 먹는 부분은 상품이 삭제되었는지 확인하기 위한 is_deleted
컬럼 비교다.
게다가 카디널리티가 너무 낮은 탓에 인덱스를 탈수도 없어서 모든 데이터를 하나하나 비교해야 한다. 그러다 보니 많은 시간과 비용(CPU) 가 들어가는 것이다.
하지만 현재 비즈니스 로직이나, 전체적인 시스템에서 is_deleted
컬럼을 삭제하기에는 힘들기 때문에 다른 방법을 찾아봤다.
그러던중 다중 컬럼 인덱스를 알게되었고 현재
product_id
로 조인을 하고 있기에product_id
와is_deleted
를 하나의 인덱스로 만들면 두개의 조건을 동시에 보기 때문에 효율적으로 처리할 수있지 않을까? 라는 생각이 들었다.
바로 다중 컬럼 인덱스를 적용해 봤다.
create index idx_product_id_is_deleted on product_detail (product_id, is_deleted);
그리고 카운트 쿼리를 테스트 했더니 놀라운 성능 향상이 있었다.
바로 분석을 해봤다.
-> Aggregate: count(pd1_0.product_detail_id) (cost=3.58 rows=1) (actual time=287..287 rows=1 loops=1)
-> Nested loop inner join (cost=2.76 rows=8.26) (actual time=4.16..279 rows=238736 loops=1)
-> Filter: (match p1_0.`name` against ('Chair')) (cost=0.916 rows=1) (actual time=4.14..154 rows=28668 loops=1)
-> Full-text index search on p1_0 using name (name='Chair') (cost=0.916 rows=1) (actual time=4.14..152 rows=28668 loops=1)
-> Covering index lookup on pd1_0 using idx_product_id_is_deleted (product_id=p1_0.product_id, is_deleted=false) (cost=1.84 rows=8.26) (actual time=0.00279..0.00389 rows=8.33 loops=28668)
기존 에는 product
테이블의 결과를 product_detail
테이블에서 join 하기 위해 product_id
인덱스로 조인을 수행하고, is_deleted
컬럼을 하나하나 비교했다.
하지만 다중 컬럼 인덱스를 적용하니 두 동작이 한번에 이루어져 300ms
라는 엄청난 성능 향상이 있었다.
최적화된 SQL로 동일 테스트를 진행해 봤다.
1 → 13
4000ms => 400ms
쿼리 최적화 전보다 성능이 약 10배 정도 향상된것을 확인할 수 있다.
검색 API에서 한번 검색에 약 5초씩 걸리는 문제가 있었다.
이 문제를 해결하기 위해 SQL문을 분석해본 결과, 검색 쿼리는 limit
를 통해 쿼리의 양을 조절하기 때문에 비교적 빠른 속도로 수행이 가능했지만 카운트 쿼리는 해당하는 모든 상품을 필터링 해야하는 특성상 약 4~5초 정도 걸리게 되었다.
FULLTEXT INDEX를 적용해 검색에 소요되는 시간을 줄였지만 가장 큰 병목인 카운트 쿼리의 join
에 걸리는 시간은 줄일 수 없었다.
카운트 쿼리의 join
의 성능을 분석해보니 소프트 삭제를 위한 is_deleted
필드 때문에 join 이후 is_deleted
필드 검사가 일어나고 그러다 보니 엄청난 성능 저하가 있게 된다는것을 알게되었다.
그래서 다중 컬럼 인덱스를 적용해 join
에 참여하는 컬럼과 is_deleted=false
인 컬럼을 한번에 찾음으로써 성능 향상을 이끌어낼 수 있었다.
그 결과 약 카운트 쿼리 수행속도를 약 4000ms
에서 400ms
로 큰 성능 향상을 이끌어 낼 수 있었다.