장난감 연구소

[DB] 가까운 순 조회시 H3 인덱스 적용해보기 본문

개발/DB

[DB] 가까운 순 조회시 H3 인덱스 적용해보기

changi1122 2025. 12. 27. 17:04

    더미데이터 수정

    지난 글에서 가까운 순 가게 목록 조회시 MySQL의 공간 인덱스 사용이 충분히 빠르지 않다는 걸 확인하였습니다. 그래서 공간 인덱스 스캔에 너무 오랜 시간이 걸리는 걸 해결하기 위해 H3를 이용하여 쿼리 실행시간을 개선해보기로 하였습니다.

    H3 인덱스 추가

    우선 기존 더미데이터에 H3 인덱스를 추가하였습니다. 이를 위해 store 테이블에 h3_cell 컬럼을 varchar(16) 타입으로 추가하였습니다. 다음으로 H3 인덱스를 전체 가게에 대해 업데이트하기 위해 오른쪽 이미지와 같이 코드를 작성한 후 CommandLineRunner로 실행하였습니다.

    추가된 h3_cell 컬럼(좌), H3 인덱스 변환 코드(우)
    H3 인덱스 추가 결과

    h3_cell 컬럼 인덱스 추가

    CREATE INDEX idx_h3_cell ON store(h3_cell)

    h3_cell 컬럼 스캔시 인덱스를 사용하도록 인덱스를 추가하였습니다.

    쿼리 작성

    H3는 지구 표면을 육각형 셀로 나누어 관리합니다. H3를 사용할 때 특정 좌표(위경도) 주변의 H3 셀들을 미리 계산하여 IN 절에 포함시킴으로써, 전체 데이터를 대상으로 거리 계산을 하기 전에, 일반적인 B-Tree 인덱스를 사용해 후보군을 대폭 줄여 검색 성능을 향상시킬 수 있습니다.

    res=8, k-ring=2일 때 강남역 인근 셀 37개

    실행 계획을 비교하기 위해 강남역(37.498095 127.027610)을 중심으로 주변 가게를 가까운 순으로 조회하는 쿼리를 작성하였습니다. Resolution이 8일 때 강남역이 속하는 셀 ID 8830e1ca2bfffff 와 주변 셀 ID를 IN 조건절에 넣어 주변 가게만 필터링하도록 수정하였습니다.

    이때 기존 조회 범위였던 25km²과 최대한 비슷하게 Resolution 8일 때 2단계 이웃(k-ring) 셀까지 조회하도록 작성하였습니다.

    SELECT *
    FROM store
    WHERE h3_cell IN (
        '8830e1ca2bfffff', '8830e1ca29fffff', '8830e1ca21fffff',
        '8830e1ca23fffff', '8830e1ca3dfffff', '8830e1ca07fffff',
        '8830e1ca05fffff', '8830e1ca63fffff', '8830e1ca67fffff',
        '8830e1ca2dfffff', '8830e1ca25fffff', '8830e1ca27fffff',
        '8830e1c849fffff', '8830e1ca35fffff', '8830e1ca31fffff',
        '8830e1ca39fffff', '8830e1ca03fffff', '8830e1ca01fffff',
        '8830e1ca0dfffff', '8830e1ca6bfffff', '8830e1ca61fffff',
        '8830e1ca65fffff', '8830e1cb5bfffff', '8830e1cb53fffff',
        '8830e1cb19fffff', '8830e1cb1bfffff', '8830e1c84dfffff',
        '8830e1c841fffff', '8830e1c84bfffff', '8830e1ca37fffff',
        '8830e1ca33fffff', '8830e1ca3bfffff', '8830e1ca15fffff',
        '8830e1ca1dfffff', '8830e1ca0bfffff', '8830e1ca09fffff',
        '8830e1ca47fffff'
    )
      AND store_category_id = 1
      AND is_deleted = 0
    ORDER BY ST_Distance_Sphere(
        location,
        ST_GeomFromText('POINT(37.498095 127.027610)', 4326)
    ) ASC
    LIMIT 20

    쿼리 실행 시간 비교

    더보기
    |id |select_type|table|partitions|type |possible_keys                                               |key        |key_len|ref|rows |filtered|Extra                                             |
    |---|-----------|-----|----------|-----|------------------------------------------------------------|-----------|-------|---|-----|--------|--------------------------------------------------|
    |1  |SIMPLE     |store|          |range|FKsir1fvf5exrjweu8cu120dta0,idx_store_is_deleted,idx_h3_cell|idx_h3_cell|67     |   |7,241|25      |Using index condition; Using where; Using filesort|
    
    더보기
    EXPLAIN
    -> Limit: 20 row(s)
       (cost=5938 rows=20)
       (actual time=220..220 rows=20 loops=1)
    
        -> Sort: st_distance_sphere(
                     store.location,
                     st_geomfromtext('POINT(37.498095 127.027610)', 4326)
                 ),
                 limit input to 20 row(s) per chunk
           (cost=5938 rows=7241)
           (actual time=220..220 rows=20 loops=1)
    
            -> Filter:
                 (store.is_deleted = 0)
                 AND (store.store_category_id = 1)
               (cost=5938 rows=7241)
               (actual time=0.0317..173 rows=7241 loops=1)
    
                -> Index range scan on store
                   using idx_h3_cell
                   over
                     (h3_cell = '8830e1c841fffff')
                     OR (h3_cell = '8830e1c849fffff')
                     OR (35 more)
                   with index condition:
                     store.h3_cell IN (
                       '8830e1ca2bfffff', '8830e1ca29fffff',
                       (...)
                     )
                   (cost=5938 rows=7241)
                   (actual time=0.0295..172 rows=7241 loops=1)

     

    이전 글의 MySQL 공간 인덱스를 사용할 때와 실행 시간을 비교하기 위해 쿼리 실행 계획과 실행 시간을 출력하면 위와 같습니다.

    구분 공간 인덱스 H3 인덱스 개선율
    인덱스 스캔 시간 1,270ms 172ms 86.5% 감소
    필터링 시간 210ms (13.7%) 1ms 99.5% 감소
    정렬 시간 52ms 47ms 9.6% 감소
    전체 실행 시간 1,532ms (약 1.53초) 220ms (약 0.22초) 85.6% 감소

     

    결과 공간 인덱스를 사용할 때 대비, 전체 실행 시간이 1,532ms → 220ms로 85.6% 감소하여 개선된 결과를 보였습니다. 가장 큰 차이로 이전에는 공간 범위 스캔(POLYGON)으로 17,580개 행을 읽어 1,270ms가 소요되었으나, IN 절(37개 H3 셀)에서 h3_cell의 인덱스를 읽어 7,241개 행만 읽어 소요 시간이 172ms로 대폭 감소되었습니다.

    성능 테스트

    코드 변경

    /*
     * 카테고리별 가게 목록 조회 (H3 인덱스 사용)
     */
    @Query(value = """
        SELECT *
        FROM store
        WHERE h3_cell IN (:h3Cells)
            AND store_category_id = :categoryId
            AND is_deleted = 0
        ORDER BY ST_Distance_Sphere(location, ST_GeomFromText(:point, 4326))
    """, nativeQuery = true)
    Slice<Store> findNearByStoresByCategoryUsingH3(
            @Param("point") String pointWkt,
            @Param("h3Cells") List<String> h3Cells,
            @Param("categoryId") Long categoryId,
            Pageable pageable
    );

    위 쿼리를 JPA 코드로 작성하여 적용하였습니다.

    성능 테스트 결과

    이전 글과 동일한 조건(300vu, 60s)으로 K6로 성능 테스트를 진행한 결과, 전체 요청 3987개가 모두 성공하였습니다.

    더보기
    2025-12-04T16:48:35.288+09:00  INFO 27076 --- [itseats] [io-8080-exec-72] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 215 ms
    2025-12-04T16:48:35.290+09:00  INFO 27076 --- [itseats] [io-8080-exec-76] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 217 ms
    2025-12-04T16:48:35.292+09:00  INFO 27076 --- [itseats] [o-8080-exec-101] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 219 ms
    ...
    2025-12-04T16:48:58.491+09:00  INFO 27076 --- [itseats] [o-8080-exec-107] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 122 ms
    2025-12-04T16:48:58.516+09:00  INFO 27076 --- [itseats] [io-8080-exec-95] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 118 ms
    2025-12-04T16:48:58.532+09:00  INFO 27076 --- [itseats] [o-8080-exec-100] c.i.i.global.aop.QueryPerformanceAspect  : Repository: $Proxy186, Method: findNearByStoresByCategoryUsingH3, Execution time: 116 ms
    더보기
      █ TOTAL RESULTS 
    
        checks_total.......................: 3987    62.236693/s
        checks_succeeded...................: 100.00% 3987 out of 3987
        checks_failed......................: 0.00%   0 out of 3987
    
        ✓ status is 200
    
        HTTP
        http_req_duration.......................................................: avg=3.65s min=429.96ms med=3.23s max=13.05s p(90)=3.64s p(95)=8.03s
          { expected_response:true }............................................: avg=3.65s min=429.96ms med=3.23s max=13.05s p(90)=3.64s p(95)=8.03s
        http_req_failed.........................................................: 0.00%  0 out of 3987
        http_reqs...............................................................: 3987   62.236693/s
    
        EXECUTION
        iteration_duration......................................................: avg=4.65s min=1.43s    med=4.23s max=14.04s p(90)=4.63s p(95)=9.04s
        iterations..............................................................: 3987   62.236693/s
        vus.....................................................................: 5      min=5         max=300
        vus_max.................................................................: 300    min=300       max=300
    
        NETWORK
        data_received...........................................................: 5.4 MB 84 kB/s
        data_sent...............................................................: 514 kB 8.0 kB/s