PostgreSQL의 인덱스: B-tree, GIN 그리고 언제 사용할지
1. 개요
PostgreSQL은 다양한 인덱스를 지원하지만, B-tree와 GIN은 일상 업무에서 가장 자주 마주치는 두 가지입니다. 잘못된 인덱스 종류를 선택하면 쿼리가 느려지거나 인덱스가 저장 공간만 차지하고 도움이 되지 않습니다.
이 글에서는 B-tree와 GIN이 어떻게 작동하는지 실무 수준으로 설명하고, 인덱스를 올바르게 만드는 방법, EXPLAIN을 읽어서 인덱스가 사용되는지 확인하는 방법, 상황에 맞는 인덱스 종류를 선택하는 체크리스트를 제시합니다. 이 글을 다 읽고 나면 Order, Product 같은 테이블에 자신감 있게 인덱스를 추가할 수 있을 겁니다.
2. B-tree 인덱스: 기본값이면서 가장 널리 사용됨
2.1. B-tree는 어떻게 작동하는가
B-tree(균형 트리)는 값을 정렬된 순서대로 저장하므로 검색, 비교(<, >, BETWEEN)와 정렬(ORDER BY)을 매우 빠르게 해줍니다 — 시간 복잡도는 O(log n)입니다. 이것은 CREATE INDEX 명령에서 방법을 지정하지 않을 때의 기본값입니다.
CREATE TABLE customer_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
total_amount NUMERIC(12,2)
);
CREATE INDEX idx_order_customer_id ON customer_order (customer_id);
CREATE INDEX 라인은 customer_id 컬럼에 B-tree를 생성합니다. WHERE customer_id = 123 쿼리를 실행하면 PostgreSQL은 전체 테이블을 스캔하는 대신 트리를 순회합니다.
2.2. 인덱스가 사용되는지 확인하기
EXPLAIN ANALYZE
SELECT * FROM customer_order WHERE customer_id = 123;
Index Scan using idx_order_customer_id on customer_order
Index Cond: (customer_id = 123)
Planning Time: 0.123 ms
Execution Time: 0.045 ms
Index Scan은 플래너가 인덱스 사용을 선택했다는 뜻입니다. 인덱스가 있는데도 Seq Scan이 보이면 보통 테이블이 너무 작아서 (플래너가 전체 테이블 스캔이 더 저렴하다고 판단) 또는 통계 정보가 오래되었기 때문입니다 — ANALYZE customer_order;를 실행해서 통계를 갱신하세요.
2.3. 복합 인덱스와 컬럼 순서
B-tree는 여러 컬럼을 지원하지만, 컬럼 순서가 효율성을 결정합니다.
CREATE INDEX idx_order_customer_status
ON customer_order (customer_id, status);
-- 인덱스 전체를 사용할 수 있음
SELECT * FROM customer_order
WHERE customer_id = 123 AND status = 'PAID';
-- 인덱스의 customer_id 부분만 사용 가능
SELECT * FROM customer_order WHERE customer_id = 123;
-- 이 인덱스를 사용할 수 없음 (customer_id 부재)
SELECT * FROM customer_order WHERE status = 'PAID';
원칙: 복합 인덱스는 (customer_id, status) 순서로 정렬된 주소록 같습니다 — 먼저 customer_id로 찾을 수 있지만, status만으로는 직접 찾을 수 없습니다.
주의: 컬럼들이 독립적으로 각각의 쿼리에서 사용되면 선택도(selectivity)가 높은 컬럼(많은 서로 다른 값들을 가진)을 먼저 배치하세요. 항상 두 컬럼을 함께 쿼리한다면, 범위를 가장 빠르게 좁히는 컬럼을 먼저 배치하세요.
3. GIN 인덱스: “한 행에 여러 값” 데이터용
3.1. B-tree가 무력한 경우
B-tree는 한 행이 독립적으로 검색할 여러 “요소”를 포함하는 데이터 타입에는 효율적이지 않습니다: 배열(array), JSON(jsonb), 전문검색(tsvector). GIN(Generalized Inverted Index)이 이 경우를 위해 만들어졌습니다 — 전체 값이 아닌 각 요소를 개별적으로 인덱싱합니다.
3.2. JSONB용 GIN
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
attributes JSONB NOT NULL
);
CREATE INDEX idx_product_attributes ON product USING GIN (attributes);
INSERT INTO product (name, attributes) VALUES
('Laptop Dell', '{"brand": "Dell", "ram_gb": 16, "tags": ["laptop", "office"]}'),
('Laptop HP', '{"brand": "HP", "ram_gb": 8, "tags": ["laptop", "gaming"]}');
SELECT * FROM product WHERE attributes @> '{"brand": "Dell"}';
SELECT * FROM product WHERE attributes -> 'tags' @> '["gaming"]';
USING GIN (attributes)은 전체 JSONB 컬럼에 인덱스를 생성합니다. 연산자 @>(“포함한다”)는 GIN이 가장 최적화하는 것입니다 — 왼쪽 JSON이 오른쪽 구조를 포함하는지 확인하는데, 매번 전체 문서를 파싱할 필요가 없습니다.
Bitmap Heap Scan on product
Recheck Cond: (attributes @> '{"brand": "Dell"}'::jsonb)
-> Bitmap Index Scan on idx_product_attributes
Index Cond: (attributes @> '{"brand": "Dell"}'::jsonb)
3.3. 전문검색용 GIN
ALTER TABLE product ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('simple', name)) STORED;
CREATE INDEX idx_product_search ON product USING GIN (search_vector);
SELECT name FROM product
WHERE search_vector @@ to_tsquery('simple', 'laptop & dell');
search_vector 컬럼은 name에서 자동으로 tsvector를 생성하는 generated column입니다. 이 컬럼의 GIN 인덱스는 @@(매치) 연산을 큰 텍스트 데이터에서 빠르게 합니다. 전체 테이블을 스캔하는 LIKE '%laptop%'보다 훨씬 빠릅니다.
3.4. 배열용 GIN
CREATE TABLE article (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(200),
tag_list TEXT[]
);
CREATE INDEX idx_article_tags ON article USING GIN (tag_list);
SELECT * FROM article WHERE tag_list @> ARRAY['spring-boot'];
JSONB처럼, GIN은 배열 tag_list의 각 요소를 개별적으로 인덱싱하므로 “이 태그를 포함하는 기사 찾기” 쿼리는 큰 테이블에서 B-tree보다 몇 배 빠릅니다.
4. 비교와 비용
| 기준 | B-tree | GIN |
|---|---|---|
| 적합한 데이터 | 스칼라: 숫자, 문자열, 날짜 | 배열, jsonb, tsvector |
| 지원 연산자 | =, <, >, BETWEEN, ORDER BY |
@>, ?, @@, && |
| 쓰기 속도 (INSERT/UPDATE) | 빠름 | 더 느림, 특히 큰 JSONB UPDATE |
| 인덱스 크기 | 더 작음 | 더 큼 (각 요소를 저장) |
| 인덱스 생성 속도 | 빠름 | 큰 테이블에서 더 느림 |
GIN은 쓰기 비용이 더 비쌉니다. B-tree처럼 하나의 엔트리를 업데이트하는 것이 아니라 각 INSERT/UPDATE마다 여러 엔트리를 갱신해야 하기 때문입니다(JSON/배열의 각 요소마다 하나씩).
-- 실제 인덱스 크기 확인
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass))
FROM pg_indexes
WHERE tablename = 'product';
5. 인덱스 동작 확인 테스트 (Spring Boot 통합)
Spring Data JPA로 작업할 때, 배포 전에 인덱스가 제대로 작동하는지 확인하고 싶을 것입니다. Testcontainers를 사용해서 실제 PostgreSQL에서 테스트하세요.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
@Testcontainers
@SpringBootTest
class ProductIndexTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldUseGinIndexForJsonbQuery() {
jdbcTemplate.execute("""
INSERT INTO product (name, attributes)
VALUES ('Laptop Dell', '{"brand": "Dell"}')
""");
String plan = jdbcTemplate.queryForList(
"EXPLAIN SELECT * FROM product WHERE attributes @> '{\"brand\": \"Dell\"}'"
).stream()
.map(row -> row.get("QUERY PLAN").toString())
.reduce("", (a, b) -> a + b);
assertThat(plan).containsIgnoringCase("idx_product_attributes");
}
}
이 테스트는 EXPLAIN을 실행해서 결과 라인들을 하나의 문자열로 합치고, 플랜이 예상한 인덱스 이름을 언급하는지 확인합니다. 이것이 인덱스가 실제로 쿼리 플랜에 참여하는지, 단순히 “스키마에 존재하는지”만이 아닌지 확인하는 방법입니다.
주의:
EXPLAIN은 실제 쿼리를 실행하지 않고, 플래너에게만 물어봅니다. 실제 실행 시간을 알려면EXPLAIN ANALYZE를 사용하세요 — 하지만 이것은 실제로 쿼리를 실행하므로,DELETE/UPDATE에는 트랜잭션 롤백 안에서만 사용하세요.
6. 인덱스 종류 선택 체크리스트
- 스칼라 컬럼(숫자, 문자열, 날짜)을
=,<,>, 또는ORDER BY로 필터링하는 쿼리 → B-tree. - 여러 컬럼을 동시에 “AND” 형식으로 필터링 → 복합 B-tree, 선택도가 높거나 독립적으로 사용되는 컬럼을 앞에 배치.
- JSONB 컬럼을
@>,?,?&,?|로 쿼리 → GIN. - 전문검색(
@@과tsvector사용) → GIN. - 배열을
@>,&&,<@로 쿼리 → GIN. - 읽기는 적은데 쓰기가 많고, JSONB 데이터가 자주 변경되는 테이블 → GIN을 전체 컬럼에 걸기를 재고려하고, 필요한 표현식에만 expression index를 사용하세요.
-- Expression index: JSONB 내 특정 키만 인덱싱, GIN 전체보다 가벼움
CREATE INDEX idx_product_brand ON product ((attributes ->> 'brand'));
이 expression index는 실제로 attributes ->> 'brand'(텍스트 값) 결과에 대한 B-tree이며, 항상 정확히 같은 키만 쿼리하는 경우에 적합합니다. GIN의 유연성이 필요 없을 때 사용하세요.
7. 흔한 실수들
- 카디널리티가 낮은 컬럼에 인덱스 생성(예:
status컬럼이 백만 행 테이블에서 3개 값만 가짐): 플래너는 보통 인덱스를 무시하고 Seq Scan을 선택합니다. 인덱스가 있다고 해서 항상 사용되는 것은 아닙니다. - 대량 데이터 삽입 후
ANALYZE잊기: 플래너는 오래된 통계에 의존해서 행 수를 잘못 추정하고 나쁜 플랜을 선택합니다. - 변경이 거의 없지만 자주 쓰기 작업이 일어나는 컬럼에 GIN 사용: 인덱스 업데이트의 누적 비용이 쿼리 이점을 상쇄할 수 있습니다 — 결정 전에
EXPLAIN ANALYZE로 측정하세요. - 복합 B-tree에서 컬럼 순서 실수: 적게 사용되는 컬럼을 먼저 배치하면 인덱스는 실제 쿼리에는 거의 쓸모없습니다.
- 한 테이블에 너무 많은 인덱스 생성: 각
INSERT/UPDATE마다 모든 관련 인덱스를 갱신해야 하므로 쓰기 경로가 느려집니다. 모든 인덱스가 실제로 사용되지는 않습니다.
8. 결론
B-tree는 기본값이면서 스칼라 데이터의 대부분 필터링과 정렬에 적합합니다. GIN은 “여러 값” 데이터 — JSONB, 배열, 전문검색 — 을 위해 따로 만들어졌으며, B-tree가 효율적으로 나타낼 수 없는 곳입니다.
인덱스를 추가하기 전에 항상 EXPLAIN ANALYZE로 플래너가 실제로 이를 사용하는지 확인하고, 테이블의 INSERT/UPDATE 빈도가 높다면 쓰기 비용도 측정하세요. 인덱스는 성능을 위해 무조건 추가하는 것이 아닙니다 — 각 인덱스는 읽기 속도와 쓰기 속도 사이의 트레이드오프입니다.