· 16분 읽기
PostgreSQL 실전 가이드: 인덱스 구조부터 쿼리 튜닝, PostGIS까지
PostgreSQL은 B-Tree 외에도 GiST, GIN, BRIN 같은 다양한 인덱스 유형을 제공합니다. 대부분의 경우 B-Tree로 충분하지만, 지리 정보나 전문 검색, 범위 데이터처럼 특수한 데이터에는 다른 인덱스가 더 적합합니다. 이 글에서는 각 인덱스의 내부 구조와 작동 원리를 살펴본 뒤, 만든 인덱스가 실제로 쓰이는지 검증하는 EXPLAIN 기반 튜닝 루프, 그리고 GiST의 대표적인 실전 응용인 PostGIS 공간 데이터 관리까지 다룹니다.
인덱스 유형 한눈에 보기
| 인덱스 유형 | 최적 사용처 | 지원 연산자 | 특징 |
|---|---|---|---|
| B-Tree | 스칼라 값 (숫자, 문자열) | =, <, >, BETWEEN, ORDER BY |
기본값, 범용적 |
| Hash | 등가 비교만 필요할 때 | = |
B-Tree보다 약간 빠름, 범위 검색 불가 |
| GiST | 공간, 범위, 전문 검색 | @>, <@, &&, <-> (거리) |
프레임워크형, 확장 가능 |
| GIN | 배열, JSONB, 전문 검색 | @>, ?, @@ |
역인덱스, 읽기 최적화 |
| BRIN | 시계열/순차 데이터 | 범위 기반 | 초소형, 대용량 테이블 |
B-Tree: 기본이자 핵심
B-Tree는 균형 이진 검색 트리의 확장판입니다. 각 노드는 여러 키를 가지며, 키들은 정렬된 순서를 유지합니다.
[50]
/ \
[20, 30] [70, 80]
/ | \ / | \
[...] [...] [...] [...] [...]
작동 원리는 다음과 같습니다.
- 검색: 루트에서 시작해 대소 비교로 내려감 → O(log n)
- 삽입: 적절한 리프에 삽입 → 오버플로우 시 페이지 분할
- 삭제: 리프에서 제거 → 언더플로우 시 병합
B-Tree가 잘 맞는 쿼리 유형입니다.
-- 등가 조건
WHERE email = 'user@example.com'
-- 범위 조건
WHERE created_at BETWEEN '2025-01-01' AND '2025-12-31'
-- 정렬
ORDER BY score DESC
-- 범위 + 정렬 복합
WHERE user_id = 123 ORDER BY created_at DESC
B-Tree의 한계
B-Tree는 "왼쪽이 작고, 오른쪽이 크다"라는 순서 관계에 의존합니다. 그래서 다음과 같은 질문에는 답할 수 없습니다.
- 서울역에서 반경 1km 내의 카페는?
- 이 문서에서 'PostgreSQL'과 'index'가 모두 등장하는가?
- 이 시간대와 겹치는 예약이 있는가?
이런 질문들은 서로 다른 차원의 비교가 필요하며, 단순 대소 비교로는 해결되지 않습니다.
GiST: 일반화된 검색 트리
GiST(Generalized Search Tree)는 B-Tree처럼 균형 잡힌 트리 구조를 가지지만, 임의의 데이터 타입에 대한 인덱싱을 지원하는 프레임워크입니다.
팁
GiST는 인덱스 자체가 아니라 새로운 인덱스 유형을 쉽게 만들 수 있는 템플릿입니다. 새 인덱스를 바닥부터 구현하려면 락(Lock), WAL(Write-Ahead Log), 버퍼 관리 같은 저수준 처리가 필요하지만, GiST가 이를 대신 처리해 개발자는 도메인 로직에만 집중할 수 있습니다.
핵심 아이디어: 조건식(Predicate)
B-Tree에서는 "30보다 크고 50보다 작은 값"처럼 값의 순서로 자식을 찾습니다. GiST에서는 각 노드가 조건식을 가집니다.
- 리프 노드: "이 행의 데이터는 조건 P를 만족한다"
- 내부 노드: "내 자식들의 모든 데이터는 조건 P를 만족한다"
즉, 내부 노드의 조건식은 자식들의 조건을 모두 포함하는 가장 작은 범위를 가집니다.
구조 예시: R-Tree (공간 인덱스)
GiST 위에 구현된 대표적인 예가 R-Tree입니다. 평면의 점(Point)이나 다각형을 인덱싱합니다.
graph TB
subgraph Root["루트 노드"]
R1["MBR 1<br/>(전체 영역)"]
end
subgraph Level1["레벨 1"]
A["MBR A<br/>(좌상단 영역)"]
B["MBR B<br/>(우하단 영역)"]
end
subgraph Leaves["리프 노드"]
P1["점 1"]
P2["점 2"]
P3["점 3"]
P4["점 4"]
end
R1 --> A
R1 --> B
A --> P1
A --> P2
B --> P3
B --> P4
MBR(Minimum Bounding Rectangle)은 자식들을 모두 포함하는 최소 사각형입니다.
노트
점 데이터조차 내부적으로는 "하나의 점으로 축소된 사각형"으로 저장됩니다. 이렇게 해야 점, 선, 다각형을 동일한 구조로 처리할 수 있습니다.
검색 작동 방식
GiST 검색은 consistency 함수를 사용합니다.
- 루트 노드에서 시작합니다.
- 깊이 우선 탐색(DFS)으로 내려가며, 각 노드에서
consistency함수로 "이 노드를 탐색해야 하는가?"를 판단합니다. - B-Tree와 달리 검색 조건에 부합하는 노드가 여러 개일 수 있어, 여러 경로를 탐색할 수 있습니다.
예를 들어 "반경 1km 내 점 찾기" 쿼리에서 MBR이 원과 전혀 겹치지 않으면 해당 서브트리를 건너뛰고, 겹치면 자식 노드를 탐색합니다.
삽입과 삭제: 성능 특성
삽입 시에는 부모 노드의 MBR 확장을 최소화하는 위치를 선택하고, 페이지 오버플로우가 나면 분할합니다. 삭제 시에는 리프에서 데이터를 지우지만 부모의 MBR은 자동으로 줄어들지 않습니다. 이 때문에 데이터 변경이 잦으면 인덱스 효율이 떨어집니다.
경고
데이터 변경이 빈번한 테이블에서 GiST 인덱스를 사용하면, 정기적인 REINDEX 또는 VACUUM FULL이 필요할 수 있습니다.
GiST 실전 활용
공간 데이터 (PostGIS)
-- 공간 인덱스 생성
CREATE INDEX idx_locations_geom ON locations USING GIST(geom);
-- 반경 검색 (k-NN)
SELECT name, ST_Distance(geom, ST_MakePoint(127.0, 37.5))
FROM locations
ORDER BY geom <-> ST_MakePoint(127.0, 37.5)
LIMIT 10;
<-> 연산자는 거리 기반 정렬을 지원합니다. 이것이 GiST의 강력한 기능인 k-NN(k-Nearest Neighbor) 검색입니다.
범위 데이터 (시간, 숫자 범위)
-- 예약 시간대 테이블
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
room_id INT,
time_range TSRANGE
);
-- 범위 인덱스
CREATE INDEX idx_reservations_time ON reservations USING GIST(time_range);
-- 겹치는 예약 찾기
SELECT * FROM reservations
WHERE time_range && '[2025-01-15 14:00, 2025-01-15 16:00)'::tsrange;
배타적 제약 조건 (Exclusion Constraint)
GiST는 UNIQUE 제약의 일반화인 배타적 제약 조건을 지원합니다.
-- btree_gist 확장 필요 (스칼라 타입에 GiST 적용)
CREATE EXTENSION btree_gist;
-- 같은 방에 겹치는 예약 금지
ALTER TABLE reservations ADD CONSTRAINT no_overlapping_reservations
EXCLUDE USING GIST (room_id WITH =, time_range WITH &&);
이제 같은 room_id에 대해 time_range가 겹치는 행을 삽입하려 하면 자동으로 거부됩니다.
전문 검색 (tsvector)
CREATE INDEX idx_documents_fts ON documents USING GIST(to_tsvector('english', content));
SELECT * FROM documents
WHERE to_tsvector('english', content) @@ to_tsquery('postgresql & index');
노트
전문 검색에서는 일반적으로 GIN 인덱스가 더 정밀하고 빠릅니다. GiST는 시그니처 기반으로 동작해 false positive가 발생할 수 있습니다. 다만 삽입 속도는 GiST가 더 빠를 수 있어, 쓰기가 많은 워크로드에서 고려할 수 있습니다.
GiST vs 다른 인덱스
| 특성 | B-Tree | GiST |
|---|---|---|
| 원리 | 값의 순서 비교 | 조건식(Predicate) 기반 |
| 정렬 | 자연스러운 정렬 지원 | 정렬 보장 없음 (거리 정렬은 지원) |
| 최적 대상 | 스칼라 값 | 공간, 범위, 복합 조건 |
| 유니크 제약 | 지원 | 미지원 (배타적 제약으로 대체) |
| 다중 컬럼 | 지원 | 지원 |
| 특성 | GiST | GIN |
|---|---|---|
| 구조 | 트리 | 역인덱스 |
| 전문 검색 정밀도 | 낮음 (시그니처 손실) | 높음 |
| 읽기 성능 | 보통 | 빠름 |
| 쓰기 성능 | 빠름 | 느림 (pending list 사용 가능) |
| k-NN 검색 | 지원 | 미지원 |
인덱스 내부 확인하기
B-Tree와 Hash 인덱스는 pageinspect 확장으로 내부를 볼 수 있습니다.
CREATE EXTENSION pageinspect;
-- B-Tree 메타페이지
SELECT * FROM bt_metap('idx_users_email');
-- 특정 페이지 내용
SELECT * FROM bt_page_items('idx_users_email', 1);
GiST 내부는 표준 도구로 보기 어렵고 gevel 확장을 사용합니다.
-- 설치 후
SELECT * FROM gist_stat('idx_locations_geom');
-- level | 페이지 수 | 튜플 수 등 통계
SELECT * FROM gist_tree('idx_locations_geom');
-- 트리 구조 시각화
튜닝 루프: 인덱스가 실제로 쓰이는지 검증하기
인덱스를 만들었다고 끝이 아닙니다. 튜닝의 출발점은 항상 같습니다. 느린 쿼리를 추측하지 말고 계획과 실행 통계를 봅니다.
- 느린 쿼리 수집 (
pg_stat_statements) EXPLAIN (ANALYZE, BUFFERS)확인- 병목 유형 분류 (스캔/조인/정렬/락)
- 인덱스/쿼리/스키마 중 가장 작은 변경부터 적용
- 재측정
자주 만나는 병목 유형
Seq Scan 과다
필터 컬럼의 인덱스 부재나 통계 부정확이 원인입니다. 인덱스 추가와 ANALYZE 실행, 조건식 단순화로 해결합니다.
잘못된 복합 인덱스 순서
등호 조건 컬럼을 앞쪽에, 범위 조건 컬럼을 뒤쪽에 배치해야 합니다.
과도한 정렬/해시 spill
work_mem 과소 설정이나 불필요한 ORDER BY가 원인입니다.
불필요한 인덱스 증가
인덱스를 늘리면 읽기는 빨라져도 쓰기 비용과 유지비가 함께 증가합니다.
PostgreSQL 튜닝은 마법 옵션이 아니라 측정-가설-검증 루프입니다. 단일 쿼리 최적화보다 반복 가능한 튜닝 프로세스를 먼저 만들어야 합니다.
GiST 실전 응용: PostGIS 공간 데이터
GiST가 가장 널리 쓰이는 곳이 PostGIS 공간 데이터입니다. GeoJSON 입출력과 함께 실전 패턴을 살펴봅니다.
기본 스키마
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE regions (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
geom GEOMETRY(MultiPolygon, 4326) NOT NULL
);
CREATE INDEX idx_regions_geom_gist ON regions USING GIST (geom);
핵심 원칙은 두 가지입니다.
- GeoJSON 표준 호환을 위해 보통 SRID 4326을 기본으로 둡니다.
- 공간 조회 성능은 거의 항상 GIST 인덱스 유무로 갈립니다.
GeoJSON 입력
INSERT INTO regions (name, geom)
VALUES (
'seoul-sample',
ST_SetSRID(
ST_GeomFromGeoJSON('{"type":"Polygon","coordinates":[[[126.9,37.5],[127.1,37.5],[127.1,37.6],[126.9,37.6],[126.9,37.5]]]}'),
4326
)
);
배치 입력에서는 jsonb_array_elements를 써서 한 번에 넣는 편이 효율적입니다.
GeoJSON 출력
SELECT
id,
name,
ST_AsGeoJSON(geom, 6)::json AS geometry
FROM regions;
정밀도 6자리는 대부분의 웹 지도에서 충분합니다. 불필요한 자릿수를 줄이면 응답 크기가 줄어듭니다.
자주 쓰는 공간 쿼리
점 포함 여부를 확인하는 쿼리입니다.
SELECT name
FROM regions
WHERE ST_Contains(geom, ST_SetSRID(ST_Point(127.0, 37.55), 4326));
반경 검색과 거리 계산입니다.
SELECT
name,
ST_Distance(geom::geography, ST_SetSRID(ST_Point(127.0, 37.55), 4326)::geography) AS distance_m
FROM regions
WHERE ST_DWithin(
geom::geography,
ST_SetSRID(ST_Point(127.0, 37.55), 4326)::geography,
10000
)
ORDER BY distance_m;
거리 기반 기능은 geometry보다 geography 캐스팅이 실수를 줄입니다.
유효성 검증
SELECT id, ST_IsValid(geom), ST_IsValidReason(geom)
FROM regions
WHERE NOT ST_IsValid(geom);
문제가 있으면 ST_MakeValid로 교정하되, 원본 보존 컬럼을 두는 것이 안전합니다.
공간 데이터 성능 포인트
- 같은 좌표계를 강제해 런타임 변환 비용을 줄입니다.
- 대형 폴리곤은 저장 전 단순화(
ST_SimplifyPreserveTopology)를 검토합니다. - API 응답은 꼭 필요한 속성만 반환합니다.
- 인덱스 사용 여부는 앞서 본 튜닝 루프대로
EXPLAIN (ANALYZE, BUFFERS)로 확인합니다.
인덱스 선택 가이드
flowchart TD
START[어떤 데이터?] --> SCALAR{스칼라 값?<br/>숫자, 문자열}
SCALAR -->|Yes| RANGE{범위 검색<br/>필요?}
RANGE -->|Yes| BTREE["B-Tree"]
RANGE -->|No, 등가만| HASH["Hash (또는 B-Tree)"]
SCALAR -->|No| SPATIAL{공간 데이터?<br/>좌표, 도형}
SPATIAL -->|Yes| GIST["GiST<br/>(PostGIS)"]
SPATIAL -->|No| ARRAY{배열/JSONB?}
ARRAY -->|Yes| GIN["GIN"]
ARRAY -->|No| TIMESERIES{시계열/순차?<br/>대용량}
TIMESERIES -->|Yes| BRIN["BRIN"]
TIMESERIES -->|No| ANALYSIS["요구사항<br/>재분석 필요"]
운영 체크리스트
- 기본은 B-Tree: 특별한 이유 없이 다른 인덱스를 사용하지 않습니다.
- 공간 데이터는 GiST: PostGIS와 함께 사용하고 SRID를 일관되게 유지합니다.
- 범위 겹침 검사는 GiST 배타적 제약 조건을 활용합니다.
- 전문 검색은 GIN: 정밀도와 성능을 우선합니다.
- 데이터 변경이 잦은 GiST 인덱스는 정기적으로 재구성(
REINDEX)합니다. - 배포 전 핵심 쿼리의 실행 계획을 비교합니다.
- 느린 쿼리 상위 10개를 지속 추적합니다.
- 사용되지 않는 인덱스를 정기적으로 정리합니다.
- 대량 데이터 변동 후에는
ANALYZE를 실행합니다.
정리
PostgreSQL의 인덱스 시스템은 단순한 B-Tree를 넘어 다양한 데이터 모델을 지원하는 확장 가능한 아키텍처를 가지고 있습니다. 핵심은 데이터의 특성을 이해하고 적절한 인덱스를 선택한 뒤, 그 인덱스가 실제로 쓰이는지 측정으로 검증하는 것입니다.
- 단순 비교 → B-Tree
- 공간/거리/겹침 → GiST
- 포함 관계/역인덱스 → GIN
- 순차/대용량 → BRIN
- 검증 →
EXPLAIN (ANALYZE, BUFFERS)기반 튜닝 루프