---
title: "MySQL Spatial Index: GIS 타입과 R-Tree의 동작 원리"
description: "MySQL 공간 데이터 타입, SRID, R-Tree 기반 Spatial Index의 탐색 원리와 위치 검색 설계 방법을 운영 관점에서 정리한다."
tags: [ MySQL, 인덱스, 성능최적화, 운영 ]
image: "mysql-report-bg.png"
published: "2026-07-27"
updated: "2026-07-27"
author: "MySQL 기술 노트"
source_url: ""
---

위치 기반 매장 검색, 배송 권역 판정, 설비 좌표 관리, 지도 객체 충돌 검사에서는 좌표를 단순한 숫자 두 개로 저장하는 것만으로 충분하지 않다. 애플리케이션은 보통 “이 사각 영역 안에 무엇이 있는가”, “두 도형이 겹치는가”, “현재 위치에서 반경 5km 안의 지점은 무엇인가”를 묻는다. 일반 B+Tree 인덱스는 하나의 정렬 축에는 강하지만, 2차원 영역이 겹치는 관계를 직접 표현하기 어렵다.

MySQL의 `SPATIAL INDEX`는 공간 객체의 최소 경계 사각형(Minimum Bounding Rectangle, MBR)을 R-Tree에 저장해 후보 객체를 빠르게 줄인다. 그러나 인덱스가 저장하는 것은 실제 도형 전체가 아니라 경계 상자이므로, 정확한 공간 관계와 거리는 후속 계산으로 확인해야 한다. 이 글에서는 공간 타입과 좌표계부터 R-Tree 탐색, 후보 추출과 정밀 판정의 두 단계 실행 경로, 운영 시 주의점을 차례대로 설명한다.

## 1. 공간 데이터는 좌표 두 개보다 많은 정보를 가진다

### 1.1 MySQL의 주요 GIS 타입

MySQL의 공간 타입은 OGC(Open Geospatial Consortium) 모델을 따른다. 가장 자주 쓰는 타입은 다음과 같다.

| 타입 | 표현 대상 | 대표 사용 사례 |
|---|---|---|
| `POINT` | 단일 위치 | 매장, 기지국, 차량의 현재 위치 |
| `LINESTRING` | 순서가 있는 선분 집합 | 도로, 이동 경로, 배관 |
| `POLYGON` | 외곽선과 내부 구멍을 가진 면 | 배송 권역, 행정 구역, 위험 구역 |
| `MULTIPOINT` | 여러 점 | 관측 지점 묶음 |
| `MULTILINESTRING` | 여러 선 | 분리된 노선 구간 |
| `MULTIPOLYGON` | 여러 면 | 섬을 포함한 행정 구역 |
| `GEOMETRY` | 여러 공간 타입의 상위 타입 | 이종 도형을 함께 수용해야 하는 경우 |

가능하면 업무 의미에 맞는 구체 타입을 선택하는 편이 좋다. 매장 위치가 항상 점이라면 `GEOMETRY`보다 `POINT`가 스키마 의도를 분명히 표현하고, 잘못된 도형이 들어오는 것도 막는다.

### 1.2 SRID는 좌표의 해석 규칙이다

같은 숫자 쌍도 어떤 공간 참조 체계(Spatial Reference System)로 해석하느냐에 따라 의미가 달라진다. `SRID 0`은 MySQL의 단순한 Cartesian 평면 좌표계이고, `SRID 4326`은 지리 좌표에 널리 쓰이는 WGS 84이다.

경도와 위도를 다룰 때는 컬럼에 SRID를 명시적으로 제한하는 것이 중요하다.

- 서로 다른 SRID의 도형을 잘못 비교하는 일을 스키마에서 차단한다.
- MySQL Optimizer가 좌표계에 맞는 공간 인덱스를 선택할 수 있는 기반이 된다.
- 거리 함수가 평면 단위가 아니라 지리 좌표의 의미를 반영할 수 있다.
- 데이터 교환 시 축 순서와 단위를 검토해야 한다는 사실이 스키마에 드러난다.

EPSG:4326의 공식 축 순서와 애플리케이션이 흔히 사용하는 `longitude, latitude` 순서가 다를 수 있다. 이 글의 WKT 입력은 의도를 분명히 하기 위해 `axis-order=long-lat` 옵션을 사용한다. 애플리케이션 드라이버와 외부 GIS 도구에서도 같은 규칙을 적용해야 한다.

## 2. B+Tree가 아니라 R-Tree가 필요한 이유

복합 B+Tree 인덱스 `(longitude, latitude)`는 먼저 경도를 정렬하고, 같은 경도 안에서 위도를 정렬한다. 따라서 경도 범위를 넓게 잡으면 그 범위 안의 많은 행을 읽은 뒤 위도를 다시 검사해야 한다. 두 축을 동등하게 고려하는 임의의 사각 영역, 선과 면의 교차, 다각형 포함 관계에는 이 정렬 순서가 자연스럽지 않다.

R-Tree는 각 공간 객체를 감싸는 MBR을 계층적으로 묶는다. 리프 노드는 행의 공간 객체와 연결된 MBR을 가지고, 상위 노드는 자식 MBR 전체를 감싸는 더 큰 MBR을 가진다.

```mermaid
flowchart TD
    Q[검색 영역의 MBR] --> R[루트 MBR 비교]
    R -->|겹치지 않음| X[서브트리 제외]
    R -->|겹침| B1[브랜치 MBR 1]
    R -->|겹침| B2[브랜치 MBR 2]
    B1 --> L1[후보 리프 객체]
    B2 --> L2[후보 리프 객체]
    L1 --> E[정확한 공간 함수로 재검사]
    L2 --> E
    E --> F[최종 결과]
```

탐색 중 질의 MBR과 겹치지 않는 노드의 하위 객체는 한꺼번에 제외된다. 데이터가 공간적으로 잘 분산되고 검색 영역이 충분히 좁으면 읽어야 할 후보가 크게 줄어든다.

### 2.1 R-Tree의 비용은 겹침에 좌우된다

B+Tree는 키 순서와 높이가 핵심이지만, R-Tree는 상위 MBR끼리 얼마나 많이 겹치는지도 중요하다. 넓고 긴 도형이 많거나 여러 객체의 MBR이 동일한 전체 영역을 덮으면, 질의 MBR이 여러 브랜치를 동시에 따라가야 한다. 이때 인덱스가 있어도 후보 제거 효과가 작아진다.

운영자는 다음 데이터 형태를 특히 주의해야 한다.

- 전 세계를 가로지르는 긴 선이나 매우 넓은 다각형
- 실제 모양은 가늘지만 MBR은 넓은 대각선 도형
- 거의 동일한 위치에 집중된 다수의 객체
- 작은 검색 객체와 대륙 규모 객체가 한 컬럼에 섞인 데이터
- 잘못된 좌표나 기본값 때문에 특정 지점에 몰린 데이터

즉, `SPATIAL INDEX`의 존재만으로 성능이 보장되지는 않는다. 업무 데이터의 공간 분포와 대표 질의 영역을 함께 측정해야 한다.

## 3. 실행 경로: MBR 후보 추출 후 정확한 판정

공간 검색은 보통 두 단계로 설계한다.

1. `MBRContains`, `MBRIntersects` 같은 MBR 관계 함수로 인덱스를 이용해 후보를 줄인다.
2. `ST_Contains`, `ST_Intersects`, `ST_Distance_Sphere` 같은 함수로 실제 도형 관계나 거리를 판정한다.

점 데이터의 MBR은 점과 같은 퇴화 사각형이므로 사각 범위 검색과 잘 맞는다. 반면 복잡한 `POLYGON`이나 `LINESTRING`은 서로의 MBR이 겹쳐도 실제 도형은 만나지 않을 수 있다. 두 번째 단계가 필요한 이유다.

```mermaid
sequenceDiagram
    participant A as 애플리케이션
    participant O as MySQL Optimizer
    participant R as R-Tree
    participant G as GIS 함수
    A->>O: 경계 상자 + 정확한 조건
    O->>R: MBR 조건으로 후보 탐색
    R-->>O: 후보 행 식별자
    O->>G: 실제 도형/거리 계산
    G-->>A: 정확한 최종 행
```

거리순 상위 N개를 찾는 질의도 같은 패턴이 안전하다. 먼저 업무상 최대 반경을 감싸는 경계 상자로 후보를 제한하고, 그 후보에 대해서만 정확한 거리를 계산해 필터링하고 정렬한다. MySQL의 Spatial Index를 “가장 가까운 객체를 거리순으로 직접 꺼내는 K-nearest-neighbor 인덱스”로 간주하면 안 된다.

## 4. SRID 제한 컬럼과 Spatial Index 만들기

다음 예제는 서울의 몇 개 지점을 `POINT NOT NULL SRID 4326` 컬럼에 저장한다. 입력 WKT의 좌표 순서는 `axis-order=long-lat`으로 고정한다.

```sql
DROP TABLE IF EXISTS geo_place;

CREATE TABLE geo_place (
    place_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    place_name VARCHAR(100) NOT NULL,
    location POINT NOT NULL SRID 4326,
    PRIMARY KEY (place_id),
    SPATIAL INDEX idx_geo_place_location (location)
) ENGINE=InnoDB;

INSERT INTO geo_place (place_name, location) VALUES
('강남역', ST_GeomFromText('POINT(127.0276 37.4979)', 4326, 'axis-order=long-lat')),
('서울시청', ST_GeomFromText('POINT(126.9780 37.5665)', 4326, 'axis-order=long-lat')),
('잠실역', ST_GeomFromText('POINT(127.1002 37.5133)', 4326, 'axis-order=long-lat')),
('인천시청', ST_GeomFromText('POINT(126.7052 37.4563)', 4326, 'axis-order=long-lat'));

SELECT place_id,
       place_name,
       ST_SRID(location) AS srid,
       ST_AsText(location, 'axis-order=long-lat') AS location_wkt
FROM geo_place
ORDER BY place_id;
```

실행 결과(MySQL 8.0.x):

```text
mysql> DROP TABLE IF EXISTS geo_place;

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE geo_place (
    ->     place_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    ->     place_name VARCHAR(100) NOT NULL,
    ->     location POINT NOT NULL SRID 4326,
    ->     PRIMARY KEY (place_id),
    ->     SPATIAL INDEX idx_geo_place_location (location)
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO geo_place (place_name, location) VALUES
    -> ('강남역', ST_GeomFromText('POINT(127.0276 37.4979)', 4326, 'axis-order=long-lat')),
    -> ('서울시청', ST_GeomFromText('POINT(126.9780 37.5665)', 4326, 'axis-order=long-lat')),
    -> ('잠실역', ST_GeomFromText('POINT(127.1002 37.5133)', 4326, 'axis-order=long-lat')),
    -> ('인천시청', ST_GeomFromText('POINT(126.7052 37.4563)', 4326, 'axis-order=long-lat'));

Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> SELECT place_id,
    ->        place_name,
    ->        ST_SRID(location) AS srid,
    ->        ST_AsText(location, 'axis-order=long-lat') AS location_wkt
    -> FROM geo_place
    -> ORDER BY place_id;

+----------+--------------+------+-------------------------+
| place_id | place_name   | srid | location_wkt            |
+----------+--------------+------+-------------------------+
|        1 | 강남역    | 4326 | POINT(127.0276 37.4979) |
|        2 | 서울시청 | 4326 | POINT(126.978 37.5665)  |
|        3 | 잠실역    | 4326 | POINT(127.1002 37.5133) |
|        4 | 인천시청 | 4326 | POINT(126.7052 37.4563) |
+----------+--------------+------+-------------------------+
4 rows in set (0.00 sec)
```

`NOT NULL`과 명시적 `SRID 4326`은 공간 인덱스 컬럼의 권장 기본 형태다. 좌표가 없는 상태를 표현해야 한다면 임의 좌표를 넣지 말고, 위치가 확인된 행과 미확인 행의 생명주기를 분리하거나 별도 nullable 원본 컬럼을 두는 방식을 검토한다. `(0, 0)` 같은 대체 좌표는 검색 결과와 공간 분포를 함께 오염시킨다.

## 5. 경계 상자 검색과 반경 검색

### 5.1 MBR로 후보를 줄인다

다음 경계 상자는 서울 중심부 일부를 감싼다. `EXPLAIN`의 `key`가 `idx_geo_place_location`인지 확인한 뒤 실제 후보를 조회한다.

```sql
SET @bbox = ST_GeomFromText(
  'POLYGON((126.90 37.45, 127.12 37.45, 127.12 37.60, 126.90 37.60, 126.90 37.45))',
  4326,
  'axis-order=long-lat'
);

EXPLAIN
SELECT place_id, place_name
FROM geo_place
WHERE MBRContains(@bbox, location);

SELECT place_id, place_name
FROM geo_place
WHERE MBRContains(@bbox, location)
ORDER BY place_id;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SET @bbox = ST_GeomFromText(
    ->   'POLYGON((126.90 37.45, 127.12 37.45, 127.12 37.60, 126.90 37.60, 126.90 37.45))',
    ->   4326,
    ->   'axis-order=long-lat'
    -> );

Query OK, 0 rows affected (0.00 sec)

mysql> EXPLAIN
    -> SELECT place_id, place_name
    -> FROM geo_place
    -> WHERE MBRContains(@bbox, location);

+----+-------------+-----------+------------+-------+------------------------+------------------------+---------+------+------+----------+-------------+
| id | select_type | table     | partitions | type  | possible_keys          | key                    | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------------+-------+------------------------+------------------------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | geo_place | NULL       | range | idx_geo_place_location | idx_geo_place_location | 34      | NULL |    4 |   100.00 | Using where |
+----+-------------+-----------+------------+-------+------------------------+------------------------+---------+------+------+----------+-------------+
1 row in set (0.01 sec)

mysql> SELECT place_id, place_name
    -> FROM geo_place
    -> WHERE MBRContains(@bbox, location)
    -> ORDER BY place_id;

+----------+--------------+
| place_id | place_name   |
+----------+--------------+
|        1 | 강남역    |
|        2 | 서울시청 |
|        3 | 잠실역    |
+----------+--------------+
3 rows in set (0.00 sec)
```

작은 예제에서는 비용 기반 판단이나 통계 때문에 실행 계획의 추정 행 수가 실행마다 달라질 수 있다. 운영 판단에서는 추정치 하나보다 선택된 `key`, 접근 방식, 실제 후보 수, 질의 지연 시간의 방향을 함께 본다.

### 5.2 정확한 반경 조건을 추가한다

경계 상자는 원형 반경을 완전히 감싸지만, 네 모서리는 원 밖에 있다. 따라서 MBR 조건만으로 “반경 안”이라고 판정하면 거짓 양성이 생긴다. 다음 질의는 강남역을 중심으로 넉넉한 사각 후보를 먼저 찾고, `ST_Distance_Sphere`로 10km 이내만 남긴다.

```sql
SET @center = ST_GeomFromText(
  'POINT(127.0276 37.4979)',
  4326,
  'axis-order=long-lat'
);
SET @radius_bbox = ST_GeomFromText(
  'POLYGON((126.91 37.40, 127.15 37.40, 127.15 37.59, 126.91 37.59, 126.91 37.40))',
  4326,
  'axis-order=long-lat'
);

SELECT place_id,
       place_name,
       ROUND(ST_Distance_Sphere(location, @center)) AS distance_m
FROM geo_place
WHERE MBRContains(@radius_bbox, location)
  AND ST_Distance_Sphere(location, @center) <= 10000
ORDER BY distance_m, place_id;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SET @center = ST_GeomFromText(
    ->   'POINT(127.0276 37.4979)',
    ->   4326,
    ->   'axis-order=long-lat'
    -> );

Query OK, 0 rows affected (0.00 sec)

mysql> SET @radius_bbox = ST_GeomFromText(
    ->   'POLYGON((126.91 37.40, 127.15 37.40, 127.15 37.59, 126.91 37.59, 126.91 37.40))',
    ->   4326,
    ->   'axis-order=long-lat'
    -> );

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT place_id,
    ->        place_name,
    ->        ROUND(ST_Distance_Sphere(location, @center)) AS distance_m
    -> FROM geo_place
    -> WHERE MBRContains(@radius_bbox, location)
    ->   AND ST_Distance_Sphere(location, @center) <= 10000
    -> ORDER BY distance_m, place_id;

+----------+--------------+------------+
| place_id | place_name   | distance_m |
+----------+--------------+------------+
|        1 | 강남역    |          0 |
|        3 | 잠실역    |       6629 |
|        2 | 서울시청 |       8793 |
+----------+--------------+------------+
3 rows in set (0.00 sec)
```

경도 1도와 위도 1도의 실제 거리는 위치에 따라 다르므로, 운영 코드에서 반경을 단순히 일정한 도 단위로 환산해서는 안 된다. 후보 경계 상자는 위도에 따른 경도 폭을 계산하거나 검증된 GIS 라이브러리에서 만들고, 최종 포함 여부는 지리 좌표를 이해하는 거리 함수로 확인한다.

## 6. MBR 관계와 실제 도형 관계는 다르다

아래 두 삼각형의 경계 상자는 겹치지만 실제 면은 떨어져 있다. 이 축소 예제는 MBR 기반 인덱스 검색이 왜 후보 검색인지 보여준다.

```sql
SET @triangle_a = ST_GeomFromText(
  'POLYGON((0 0, 4 0, 0 4, 0 0))'
);
SET @triangle_b = ST_GeomFromText(
  'POLYGON((4 4, 4 2.5, 2.5 4, 4 4))'
);

SELECT MBRIntersects(@triangle_a, @triangle_b) AS mbr_intersects,
       ST_Intersects(@triangle_a, @triangle_b) AS exact_intersects;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SET @triangle_a = ST_GeomFromText(
    ->   'POLYGON((0 0, 4 0, 0 4, 0 0))'
    -> );

Query OK, 0 rows affected (0.00 sec)

mysql> SET @triangle_b = ST_GeomFromText(
    ->   'POLYGON((4 4, 4 2.5, 2.5 4, 4 4))'
    -> );

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT MBRIntersects(@triangle_a, @triangle_b) AS mbr_intersects,
    ->        ST_Intersects(@triangle_a, @triangle_b) AS exact_intersects;

+----------------+------------------+
| mbr_intersects | exact_intersects |
+----------------+------------------+
|              1 |                0 |
+----------------+------------------+
1 row in set (0.01 sec)
```

`mbr_intersects`는 1이지만 `exact_intersects`는 0이다. 실제 서비스에서 배송 권역, 건물 경계, 위험 구역의 포함 여부를 MBR 함수만으로 확정하면 잘못된 판정이 발생할 수 있다. 반대로 정확한 함수만 호출하고 후보 제한을 생략하면 복잡한 도형 계산을 너무 많은 행에 수행할 수 있다. 두 조건의 역할을 분리해야 한다.

## 7. 실행 계획과 인덱스 메타데이터 읽기

Spatial Index가 생성되었는지는 `information_schema.STATISTICS`에서 확인할 수 있다. 일반 인덱스와 달리 `INDEX_TYPE`이 `SPATIAL`로 표시된다.

```sql
SELECT INDEX_NAME,
       COLUMN_NAME,
       INDEX_TYPE,
       NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'geo_place'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;

DROP TABLE geo_place;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT INDEX_NAME,
    ->        COLUMN_NAME,
    ->        INDEX_TYPE,
    ->        NON_UNIQUE
    -> FROM information_schema.STATISTICS
    -> WHERE TABLE_SCHEMA = DATABASE()
    ->   AND TABLE_NAME = 'geo_place'
    -> ORDER BY INDEX_NAME, SEQ_IN_INDEX;

+------------------------+-------------+------------+------------+
| INDEX_NAME             | COLUMN_NAME | INDEX_TYPE | NON_UNIQUE |
+------------------------+-------------+------------+------------+
| idx_geo_place_location | location    | SPATIAL    |          1 |
| PRIMARY                | place_id    | BTREE      |          0 |
+------------------------+-------------+------------+------------+
2 rows in set (0.00 sec)

mysql> DROP TABLE geo_place;

Query OK, 0 rows affected (0.00 sec)
```

공간 질의를 점검할 때는 다음 순서가 실용적이다.

1. 컬럼 타입, `NOT NULL`, SRID 제한이 의도와 맞는지 확인한다.
2. 입력 도형의 SRID와 축 순서를 확인한다.
3. `EXPLAIN`에서 Spatial Index가 선택되는지 확인한다.
4. MBR 후보 행 수와 정확한 판정 후 행 수를 각각 측정한다.
5. 후보 대비 최종 결과 비율이 지나치게 낮으면 MBR 겹침, 경계 상자 크기, 데이터 이상을 조사한다.
6. 대표 위치와 대표 반경으로 부하 테스트하고, 차가운 캐시와 따뜻한 캐시를 구분한다.

`EXPLAIN`에서 인덱스가 보인다는 사실만으로 충분하지 않다. 검색 영역이 너무 넓어 대부분의 행이 후보가 되면 테이블 전체에 가까운 작업을 하게 된다. 반대로 작은 테이블에서는 Optimizer가 인덱스보다 전체 스캔이 싸다고 판단할 수도 있다.

## 8. 쓰기 경로와 운영 비용

Spatial Index도 보조 인덱스이므로 `INSERT`, 공간 컬럼 `UPDATE`, `DELETE` 때 유지 비용이 발생한다. 객체의 MBR을 계산하고 적절한 R-Tree 노드를 찾아 엔트리를 추가하며, 노드가 가득 차면 분할이 필요할 수 있다. 대량 적재에서는 다음 선택을 비교해야 한다.

- 빈 테이블에 인덱스를 먼저 만들고 지속적으로 적재할지
- 초기 대량 적재 후 `SPATIAL INDEX`를 생성할지
- 온라인 DDL이 실제 대상 버전과 테이블 조건에서 허용되는지
- 인덱스 생성 중 임시 공간, I/O, 복제 지연 여유가 충분한지
- 위치가 자주 바뀌는 객체의 갱신 주기와 보존 이력을 어떻게 분리할지

차량처럼 위치가 매우 자주 바뀌는 데이터를 매번 동일 행에 갱신하면 데이터 페이지뿐 아니라 공간 인덱스에도 쓰기가 집중된다. 최신 위치 조회와 전체 이동 이력 분석의 요구를 하나의 테이블과 하나의 인덱스로 해결하려 하지 말고, 최신 상태 테이블과 시계열 이력 저장소를 분리하는 설계를 검토한다.

통계와 계획은 실제 데이터 분포를 반영해 관찰해야 한다. 배포 전 소량 샘플만으로 만든 계획을 운영 규모에 그대로 적용하거나, 균등하게 생성한 테스트 좌표만으로 도심 밀집 데이터를 대표하면 잘못된 결론을 내리기 쉽다.

## 9. Aurora MySQL에서의 해석

Aurora MySQL 호환 클러스터에서도 공간 타입과 함수, Spatial Index 사용 여부는 호환 MySQL 버전과 Aurora 엔진 버전에 맞춰 검증해야 한다. Aurora의 분산 스토리지, 자동 백업, 복제 구조가 다음 애플리케이션 의미를 대신 해결해 주지는 않는다.

- SRID와 축 순서의 일관성
- MBR 후보와 정확한 공간 판정의 구분
- 넓은 검색 영역에서 발생하는 후보 폭증
- Spatial Index 유지에 따른 쓰기 비용
- 인덱스 생성 DDL의 실행 시간과 부하

배포 전에는 개발용 Community MySQL 검증만으로 끝내지 말고 실제 Aurora MySQL 엔진 버전의 스테이징 클러스터에서 DDL 지원 방식, 실행 계획, 함수 결과를 다시 확인한다. Performance Insights에서는 해당 SQL의 DB load와 대기 분포를 관찰하되, 후보 행 수와 정확한 판정 후 행 수는 애플리케이션 메트릭이나 별도 검증 질의로 함께 추적하는 편이 좋다.

Writer에서 대형 Spatial Index를 생성하거나 재구성할 때는 CPU와 I/O뿐 아니라 reader 지연, failover 여유, 배포 창을 함께 본다. 관리형 스토리지가 인덱스 설계와 DDL 위험을 제거하는 것은 아니다.

## 10. 흔한 실패와 오해

### 10.1 경도와 위도를 뒤집어 저장한다

축 순서가 일관되지 않으면 좌표가 다른 대륙에 나타나거나 입력 단계에서 범위 오류가 발생한다. WKT 생성, 드라이버 바인딩, 외부 API, 지도 SDK의 순서를 계약으로 정하고 대표 좌표를 왕복 검증한다.

### 10.2 SRID 0과 4326을 같은 의미로 취급한다

SRID 0의 좌표는 단순 평면 값이다. 경도·위도 숫자를 넣었다고 해서 지구 곡률이나 미터 단위 거리가 자동으로 부여되지 않는다. 기존 숫자 컬럼을 공간 컬럼으로 이관할 때는 원본 좌표계부터 확인한다.

### 10.3 MBR 함수 결과를 최종 판정으로 사용한다

복잡한 선과 면은 MBR이 겹쳐도 실제로 만나지 않을 수 있다. 인덱스 후보 조건과 정확한 GIS 조건을 둘 다 둔다.

### 10.4 거리 함수만 모든 행에 적용한다

정확한 거리 계산은 필요하지만, 대규모 테이블 전체에 적용하면 CPU 비용과 읽기량이 커진다. 합리적인 최대 반경의 MBR 조건을 먼저 적용한다.

### 10.5 Spatial Index가 거리순 정렬까지 해결한다고 생각한다

R-Tree의 MBR 탐색과 정확한 거리순 정렬은 다른 작업이다. 후보를 충분히 줄이지 못하면 `ORDER BY distance LIMIT N`도 비싸다. 서비스가 요구하는 최대 검색 반경, 결과 개수, 밀집 지역의 최악 조건을 정의해야 한다.

### 10.6 잘못된 기본 좌표를 넣는다

위치 미확인 행에 `(0, 0)`을 넣으면 기니만 주변에 데이터가 집중되고 인덱스 분포와 분석 결과가 왜곡된다. “알 수 없음”은 실제 위치와 다른 상태로 모델링한다.

### 10.7 작은 개발 데이터의 계획을 신뢰한다

공간 인덱스의 효율은 객체 수, 크기, 밀집도, 겹침에 크게 의존한다. 운영과 유사한 공간 분포를 가진 데이터로 후보 제거율과 지연 시간을 측정한다.

## 11. 설계·배포 체크리스트

### 데이터 모델

- [ ] 공간 객체의 업무 의미에 맞는 `POINT`, `LINESTRING`, `POLYGON` 타입을 선택했는가?
- [ ] 좌표계와 SRID를 확인하고 컬럼에 명시적으로 제한했는가?
- [ ] 경도·위도 축 순서를 입력부터 출력까지 일관되게 정의했는가?
- [ ] 위치 미확인 상태를 `(0, 0)` 같은 가짜 좌표로 표현하지 않는가?
- [ ] 서로 다른 좌표계의 외부 데이터를 적재 전에 변환·검증하는가?

### 질의와 인덱스

- [ ] `SPATIAL INDEX` 컬럼을 `NOT NULL`과 명시적 SRID로 정의했는가?
- [ ] MBR 함수는 후보 추출, `ST_*` 함수는 정확한 판정이라는 역할을 구분했는가?
- [ ] 반경 검색에서 경계 상자와 정확한 거리 조건을 함께 사용하는가?
- [ ] `EXPLAIN`에서 실제 Spatial Index 선택 여부를 확인했는가?
- [ ] 대표 검색 영역별로 후보 수와 최종 결과 수를 비교했는가?

### 운영

- [ ] 도심 밀집, 넓은 다각형, 긴 선 등 최악의 공간 분포를 시험했는가?
- [ ] 대량 적재와 Spatial Index 생성 순서를 부하 시험으로 결정했는가?
- [ ] DDL 중 임시 공간, I/O, 복제 지연, 장애 조치 여유를 확인했는가?
- [ ] 자주 갱신되는 최신 위치와 장기 이동 이력을 분리할 필요가 있는가?
- [ ] Aurora MySQL에서는 실제 엔진 버전에서 함수, DDL, 실행 계획을 재검증했는가?

## 마무리

MySQL Spatial Index의 핵심은 “실제 도형을 R-Tree에서 완벽하게 판정한다”가 아니라 “객체의 MBR을 계층적으로 검색해 비싼 정밀 계산의 후보를 줄인다”는 데 있다. 따라서 안정적인 공간 검색은 올바른 GIS 타입과 SRID, 일관된 축 순서, 선택도 높은 MBR 조건, 정확한 후속 공간 함수가 함께 구성되어야 한다.

다음 단계에서는 공간 데이터의 분포와 대표 질의를 기준으로 `EXPLAIN`과 실제 후보 제거율을 해석하고, 반경 검색·영역 교차·최근접 검색 패턴별로 어떤 보조 조건과 데이터 모델이 필요한지 더 세밀하게 다룰 수 있다.
