쿼리 튜닝으로 getConnection()의 긴 응답시간 해결

이번 포스트에서 성능을 개선해볼 쿼리는 1:N관계에 대한 두개의 필드를 가지고 있기 때문에 이들을 모두 메모리에서 조립해주어야하는 부담이 있었고, 이 점이 서버의 과부하의 원인이 되는 것으로 추측되었기 때문에 기존의 100ms정도의 쿼리 성능을 조금 더 개선해보려 합니다.

기존 쿼리

  • 기존의 쿼리는 store와 1:n관계에 있는 image와 keyword에 대한 정보를 불러와야했습니다. 쿼리의 결과로 중첩 리스트 구조를 만들 수 없었기 때문에 메인쿼리, count쿼리, 1:n관계에있는 entity를 불러오기 위한 IN쿼리 로 총 세번의 쿼리가 필요했습니다. 

메인 쿼리

//메인 쿼리
select
        store0_.store_id as col_0_0_,
        store0_.name as col_1_0_,
        store0_.address as col_2_0_,
        store0_.avg_review_rating as col_3_0_,
        store0_.like_count as col_4_0_ 
    from
        store store0_ 
    inner join
        category category1_ 
            on store0_.category_id=category1_.category_id 
    left outer join
        store_keyword storekeywo2_ 
            on store0_.store_id=storekeywo2_.store_id 
    left outer join
        keyword keyword3_ 
            on storekeywo2_.keyword_id=keyword3_.keyword_id 
    where
        store0_.status=? 
        and ?=? 
        and keyword3_.name=? 
        and ?=? 
        and (
            store0_.name like ? escape '!'
        ) 
    order by
        store0_.avg_review_rating desc limit ?
-> Limit: 10 row(s)  (cost=548 rows=1.11) (actual time=0.0398..0.195 rows=10 loops=1)
    -> Nested loop inner join  (cost=548 rows=1.11) (actual time=0.0387..0.193 rows=10 loops=1)
        -> Nested loop inner join  (cost=274 rows=1.11) (actual time=0.0295..0.144 rows=10 loops=1)
            -> Filter: ((store0_.`status` = 'APPROVED') and (store0_.`name` like '가게%') and (store0_.category_id is not null))  (cost=0.971 rows=1.11) (actual time=0.0242..0.134 rows=10 loops=1)
                -> Index scan on store0_ using avg_review_rating (reverse)  (cost=0.971 rows=10) (actual time=0.0208..0.126 rows=10 loops=1)
            -> Single-row covering index lookup on category1_ using PRIMARY (category_id=store0_.category_id)  (cost=0.25 rows=1) (actual time=588e-6..619e-6 rows=1 loops=10)
        -> Covering index lookup on storekeywo2_ using store_keyword_idx_store_id_keyword_id (store_id=store0_.store_id, keyword_id='1')  (cost=0.25 rows=1) (actual time=0.00397..0.00461 rows=1 loops=10)
  • sort 생략: 실행계획을분석해보면 sort하는 과정이 빠져있습니다. 이는 정렬에 쓰인 필드가 인덱스로 사용되어 불필요한 연산이 없어졌음을 의미합니다.  
  • 10개의 row반환: 인덱스를 사용하게 되면 Single block I/O로 디스크 블록을 읽는데, 이 과정 때문에 소량의 데이터를 읽을 때 인덱스 사용이 적합합니다. 현재 쿼리에선 인덱스를 사용하고 있고, 대량의 데이터를 읽지 않기 때문에 적합하게 사용하고 있습니다.
  • 커버링 인덱스 사용: 커버링 인덱스를 사용하면 쿼리 실행에 필요한 모든 데이터를 인덱스 자체에서 제공할 수 있기 때문에 빠릅니다.
    • 일반적인 인덱스 사용 시에는  인덱스를 검색하여 원하는 데이터의 위치를 찾고 나서 해당 위치에 있는 실제 데이터를 가져오기 위해 별도로 테이블을 조회하는 과정을 거칩니다
    • 반면 커버링 인덱스를 사용하면 인덱스를 검색하고 인덱스 내에 이미 쿼리에 필요한 모든 데이터가 있기 때문에 별도의 테이블 조회 없이 바로 원하는 정보를 가져올 수 있습니다.
  • 인덱스로 설정된 항목들의 제약조건을 반영하여 키 길이 줄이기: 인덱스로 설정된 항목들은 길이에 대한 제한을 가지고 있습니다. 그 제약조건을 디비단에도 반영하여 키의 길이를 줄이려고 합니다.
    • 데이터베이스 인덱스는 주로 B-tree 구조로 구현됩니다. B-tree는 여러 레벨로 구성되며, 각 레벨은 여러 페이지(데이터를 읽고 쓰기 위한 기본 단위)로 나뉩니다. 인덱스의 길이가 길면, B-tree의 레벨 수가 증가할 수 있고, 그 결과로 특정 데이터에 액세스하기 위해 더 많은 페이지를 읽게 됩니다.
    • 인덱스의 길이가 길다는 것은 인덱스의 키 값이 길다는 것을 의미합니다. 인덱스의 키 값이 길어지면, 각 페이지(또는 블록)에 저장할 수 있는 키의 개수가 줄어듭니다.
    • (페이지에는 실제 데이터와 인덱스 키 값이 들어있습니다)
    • B-tree는 페이지의 최대 용량에 도달하면 분할되어 새로운 페이지를 생성하게 됩니다. 인덱스 키의 길이가 길면, 페이지당 포함될 수 있는 키의 수가 줄어들기 때문에, 동일한 데이터 양에 대해서도 더 많은 페이지가 필요하게 됩니다
    • 이렇게 페이지의 수가 늘어나면, B-tree의 깊이(레벨)도 자연스럽게 증가하게 됩니다.

Count 쿼리

//count쿼리
Hibernate: 
    select
        store1_.store_id as col_0_0_ 
    from
        store_keyword storekeywo0_ 
    inner join
        store store1_ 
            on storekeywo0_.store_id=store1_.store_id 
    inner join
        keyword keyword2_ 
            on storekeywo0_.keyword_id=keyword2_.keyword_id 
    where
        store1_.status=? 
        and ?=? 
        and keyword2_.name=? 
        and ?=? 
        and (
            store1_.name like ? escape '!'
        )
-> Nested loop inner join  (cost=1449 rows=1093) (actual time=0.132..29.5 rows=9700 loops=1)
    -> Filter: ((store1_.`status` = 'APPROVED') and (store1_.`name` like '가게%'))  (cost=1067 rows=1093) (actual time=0.0495..6.5 rows=9701 loops=1)
        -> Covering index scan on store1_ using avg_review_rating  (cost=1067 rows=9864) (actual time=0.00932..3.55 rows=9864 loops=1)
    -> Covering index lookup on storekeywo0_ using store_keyword_idx_store_id_keyword_id (store_id=store1_.store_id, keyword_id='1')  (cost=0.25 rows=1) (actual time=0.00205..0.00223 rows=1 loops=9701)
  • 메인 쿼리보다도 cost가 많이 드는것을 확인할 수 있습니다. store_id에 인덱스가 걸려 있더라 store_id 값을 빠르게 찾는 데 유용하고, count 연산에 사용되는 유일한 값만을 카운팅하는 데에서는 인덱스의 효용성이 떨어지기 때문입니다.
  • 많은 row를 loop하는 것이 필연적이기 때문에 쿼리에 손을 대기보단 다른 방법을 선택하였습니다.
  • 검색 버튼 클릭유무를 백엔드 서버로 전달하여 그 유무에 따라 count쿼리의 실행 유무를 결정짓는 방법입니다. 다음과 같은 상황이 있다고 가정합니다.
데이터베이스에 총 95개의 게시물이 있습니다.
한 페이지에 10개의 게시물이 보여집니다.
사용자가 페이지 번호를 선택하여 페이지를 조회할 수 있습니다.
  • 사용자가 처음 게시판을 열었을 때 (검색 버튼 클릭 포함):
    • 대략적인 페이지 수를 보여주기 위해 일단 10개의 페이지 번호를 보여줍니다 (즉, 10 x 10 = 100개의 게시물이 있다고 가정)
  • 사용자가 9번 페이지를 클릭:
    • 9번 페이지는 실제로 데이터베이스에 있는 게시물을 표시할 수 있습니다. 그러므로 정상적으로 9번 페이지의 게시물들이 보여집니다. 
    • 이때 전체 row의 개수를 세는 count쿼리는 직접 쿼리를 날리는 것이 아닌 10x10=100개라고 가정하여 응답으로 보냅니다
  • 사용자가 10번 페이지를 클릭:
    • 실제로는 95개의 게시물만 있기 때문에 10번 페이지는 없습니다.
      이때 총 게시물 수(95)와 요청된 페이지 번호(10)를 확인하여 10번 페이지 클릭 시 마지막 페이지를 보여줍니다.

1:N 쿼리

//1:n관계 쿼리
Hibernate: 
    select
        store0_.store_id as col_0_0_,
        storeimage1_.url as col_1_0_,
        keyword3_.name as col_2_0_ 
    from
        store store0_ 
    left outer join
        image storeimage1_ 
            on store0_.store_id=storeimage1_.store_id 
    left outer join
        store_keyword storekeywo2_ 
            on store0_.store_id=storekeywo2_.store_id 
    inner join
        keyword keyword3_ 
            on storekeywo2_.keyword_id=keyword3_.keyword_id 
    where
        store0_.store_id in (
            ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ?
        )
-> Nested loop left join  (cost=31.6 rows=65.3) (actual time=0.0412..0.223 rows=29 loops=1)
    -> Nested loop inner join  (cost=10.2 rows=11) (actual time=0.033..0.124 rows=23 loops=1)
        -> Nested loop inner join  (cost=6.38 rows=11) (actual time=0.0182..0.0465 rows=23 loops=1)
            -> Filter: (store0_.store_id in (10,11,12,13,14,15,16,17,18,19,20))  (cost=2.53 rows=11) (actual time=0.0128..0.0217 rows=9 loops=1)
                -> Covering index range scan on store0_ using PRIMARY over (store_id = 10) OR (store_id = 11) OR (9 more)  (cost=2.53 rows=11) (actual time=0.011..0.0187 rows=9 loops=1)
            -> Filter: (storekeywo2_.keyword_id is not null)  (cost=0.259 rows=1) (actual time=0.00182..0.0024 rows=2.56 loops=9)
                -> Covering index lookup on storekeywo2_ using store_keyword_idx_store_id_keyword_id (store_id=store0_.store_id)  (cost=0.259 rows=1) (actual time=0.00164..0.00206 rows=2.56 loops=9)
        -> Single-row index lookup on keyword3_ using PRIMARY (keyword_id=storekeywo2_.keyword_id)  (cost=0.259 rows=1) (actual time=0.00314..0.00317 rows=1 loops=23)
    -> Index lookup on storeimage1_ using FK10wms0kbcv5yy3cmrjdbdretf (store_id=store0_.store_id)  (cost=1.4 rows=5.94) (actual time=0.00331..0.00406 rows=1.26 loops=23)
  • in 쿼리를 통해 n+1문제를 예방하고 한번에 데이터를 가져오고 있습니다
  • storeKeyword 테이블에 store_id와 keyword_id의 인덱스가 설정됨으로써 둘을 기반으로 하는 조인이 효과적으로 수행 될 수 있습니다.

결론

  • 커버링 인덱스 및 count쿼리 개선 등으로 인해 130 ms -> 30ms 로 4배 가량 응답속도가 개선되었습니다. (useSearchBtn값이 false인 경우엔 60ms정도)
  • 속도에 대해선 개선이 되었고, 서버의 자원을 많이 사용할것으로 예상되는 count쿼리의 경우도 특정 순간에만 실행하기 때문에 기존의 서버의 높은 getConnection()지연시간의 문제를 해결할 수 있다고 생각하였습니다.
  • getConnection()의 포스트를 이어서 글을 작성하겠습니다!

count쿼리개선