장난감 연구소
[DB] 가까운 순 조회시 H3 인덱스 적용해보기 본문
더미데이터 수정
지난 글에서 가까운 순 가게 목록 조회시 MySQL의 공간 인덱스 사용이 충분히 빠르지 않다는 걸 확인하였습니다. 그래서 공간 인덱스 스캔에 너무 오랜 시간이 걸리는 걸 해결하기 위해 H3를 이용하여 쿼리 실행시간을 개선해보기로 하였습니다.
H3 인덱스 추가
우선 기존 더미데이터에 H3 인덱스를 추가하였습니다. 이를 위해 store 테이블에 h3_cell 컬럼을 varchar(16) 타입으로 추가하였습니다. 다음으로 H3 인덱스를 전체 가게에 대해 업데이트하기 위해 오른쪽 이미지와 같이 코드를 작성한 후 CommandLineRunner로 실행하였습니다.




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

실행 계획을 비교하기 위해 강남역(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
'개발 > DB' 카테고리의 다른 글
| [MySQL] 공간 인덱스는 충분히 빠를까? (0) | 2025.12.07 |
|---|---|
| [캐시] 캐시 문제 해결 가이드 (0) | 2025.08.26 |
| [MySQL] 가까운 순 조회시 공간 인덱스 사용 (0) | 2025.07.17 |
| [DB/JPA] Index와 EntityGraph 적용 과정 정리 (0) | 2025.01.20 |