PostgreSQL 파티션 테이블에서 로컬·글로벌 인덱스가 쿼리 플랜을 갈라놓는 지점
주간 리포트 하나가 갑자기 12초에서 47초로 늘어난 적이 있습니다. 쿼리도 그대로, 데이터 볼륨도 예측 범위였고, 인덱스도 이미 걸려 있었죠. EXPLAIN을 열어보니 Append 아래로 Index Scan 노드가 스무 개 넘게 늘어서 있었고, 파티션 수만큼 인덱스를 각각 뒤지는 플랜이 잡혀 있었습니다. 파티션 테이블에서 인덱스는 걸어두었다고 자동으로 최적이 되지 않는다는 걸, 실제 장애를 통해 확인했던 순간입니다.
PostgreSQL의 파티션 테이블에서 인덱스는 어떤 컬럼에 걸었느냐뿐 아니라 그 인덱스가 파티션 경계를 인식하는가에 따라 쿼리 플랜이 완전히 갈립니다. 이 두 축을 구분하지 않으면 인덱스를 열심히 만들어놓고도 전 파티션을 순회하는 플랜을 받게 됩니다.
이 글에서는 로컬 인덱스와 글로벌 인덱스의 개념적 차이, 실제 EXPLAIN (ANALYZE, BUFFERS)에서 어떤 신호로 드러나는지, 그리고 어떤 상황에서 어떤 전략이 맞는지를 살펴봅니다. 2026년 8월 기준으로 표준 PostgreSQL(17/18)에 글로벌 인덱스가 아직 포함되지 않았다는 점도 함께 짚습니다.
파티션 테이블에서 인덱스가 두 종류인 이유
로컬 인덱스: PostgreSQL의 기본 동작
부모 테이블에 CREATE INDEX를 실행하면 PostgreSQL은 각 파티션에 개별 인덱스를 자동으로 전파합니다. 이게 로컬 인덱스입니다. 물리적으로는 파티션마다 독립된 인덱스가 하나씩 존재하고, 파티션을 DROP하면 해당 인덱스도 함께 사라집니다. DBA 입장에서는 관리가 깔끔하고, 파티션 단위 재인덱싱도 가능합니다.
아래는 이 글 전반에서 재사용할 12개월치 파티션 스키마입니다(2025년 3월~12월 파티션은 지면상 생략).
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
email TEXT NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_01 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- events_2025_03 ~ events_2025_12 동일 패턴으로 10개 추가 (총 12개)
CREATE INDEX idx_events_created_at_user_id
ON events (created_at, user_id);
CREATE INDEX idx_events_email
ON events (email);여기까지는 직관적입니다. 문제는 쿼리 플랜을 열어볼 때 시작됩니다.
쿼리 플랜 결정 흐름
파티션 테이블에 쿼리가 들어오면 플래너는 아래 순서로 실행 계획을 만듭니다.
핵심은 **파티션 프루닝(Partition Pruning)**입니다. WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01'처럼 파티션 키를 상수 범위로 지정하면, 플래너가 관련 없는 파티션을 통째로 배제합니다.
여기서 EXPLAIN 출력을 읽을 때 자주 오해하는 지점이 있습니다. 정적 프루닝(플랜 시점에 상수로 판단되는 경우)에서는 배제된 파티션이 Append의 자식 목록에서 아예 사라집니다. 별도로 "제거되었다"는 라인이 찍히지 않습니다. 반면 런타임 프루닝(프리페어드 스테이트먼트, 서브쿼리 결과 등)에서만 Subplans Removed: N이 표시됩니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01'
AND user_id = 42;Index Scan using events_2025_01_created_at_user_id_idx on events_2025_01 events
(cost=0.43..8.45 rows=1 width=52) (actual time=0.021..0.024 rows=1)
Index Cond: ((created_at >= '2025-01-01') AND (created_at < '2025-02-01')
AND (user_id = 42))
Buffers: shared hit=4
Planning Time: 0.412 ms
Execution Time: 0.058 ms정적 프루닝이 완전히 성공하면 Append조차 사라지고 단일 파티션의 인덱스 스캔 노드만 남습니다. (created_at, user_id) 복합 인덱스가 있기 때문에 두 조건이 하나의 Index Cond로 묶여 내려간 점도 함께 확인됩니다. 만약 created_at과 user_id에 각각 단일 컬럼 인덱스를 걸었다면 플래너는 둘 중 하나만 인덱스로 쓰고 나머지는 Filter로 처리했을 겁니다. 이 차이는 실제로 커버 컬럼 설계 시 중요한 판단 지점입니다.
파티션 키가 없는 조건에서 벌어지는 일
파티션 키가 조건에 없으면 이야기가 완전히 달라집니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE email = 'foo@example.com';Append (cost=0.43..1284.67 rows=12 width=52) (actual rows=1)
-> Index Scan on events_2025_01_email_idx on events_2025_01 events_1
(cost=0.43..107.07 rows=1 width=52)
Index Cond: (email = 'foo@example.com')
-> Index Scan on events_2025_02_email_idx on events_2025_02 events_2
(cost=0.43..107.07 rows=1 width=52)
Index Cond: (email = 'foo@example.com')
-> Index Scan on events_2025_03_email_idx on events_2025_03 events_3
...
(12개 파티션 모두 반복)
Planning Time: 1.7 ms
Execution Time: 3.9 msemail은 파티션 키가 아니므로 프루닝이 일어나지 않습니다. 플래너는 모든 파티션의 로컬 인덱스를 각각 탐색하는 수밖에 없고, 파티션이 100개라면 Append 아래 Index Scan이 100개 늘어섭니다. 인덱스가 있어도 결국 "모든 인덱스를 다 뒤지는" 상황이 되는 겁니다.
파티션 수가 늘어날수록 이 오버헤드는 선형으로 증가하고, 수백 개를 넘어가면 Planning Time 자체도 무시할 수 없어집니다.
이 패턴에서 글로벌 인덱스가 하는 일
글로벌 인덱스는 파티션 경계와 무관하게 전체 테이블을 단일 인덱스로 커버합니다. email = 'foo@example.com' 같은 포인트 쿼리라면, 글로벌 인덱스는 해당 행이 어느 파티션에 있는지 인덱스 하나에서 바로 찾아냅니다.
비파티션 키 컬럼의 포인트 쿼리에서 글로벌 인덱스가 유리하다는 벤치마크가 Postgres Pro 팀 등에서 여럿 보고되어 있습니다. 다만 그 배수는 파티션 수·데이터 볼륨·선택도·워크로드에 따라 크게 달라지므로 특정 숫자로 단정하기보다는, Append 아래 늘어선 인덱스 스캔이 하나로 줄어드는 구조적 이득이 있다는 정도로 이해하는 편이 안전합니다.
실제 시나리오별로 살펴보기
시나리오 1: 날짜 파티션 + 날짜 조건 쿼리 (로컬 인덱스로 충분)
로컬 인덱스의 교과서적인 성공 패턴입니다.
SELECT count(*), event_type
FROM events
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01'
GROUP BY event_type;파티션 키(created_at)가 명시적 범위 비교로 들어가 있으므로 정적 프루닝이 동작해서 events_2025_01 파티션 하나만 스캔합니다. Append 아래 자식이 하나뿐이라면 아예 Append가 사라지고 단일 노드로 접힙니다.
참고로 파티션 와이즈 집계(enable_partitionwise_aggregate) 자체는 PostgreSQL 11에 도입된 기능이고 기본값은 여전히 off이므로, GROUP BY가 파티션 키와 정렬되는 리포팅 쿼리에서 활용하려면 세션 또는 postgresql.conf에서 명시적으로 켜야 합니다.
시나리오 2: 파티션 프루닝을 놓치는 함수 래핑
프로덕션에서 가장 자주 마주치는 함정입니다.
-- 프루닝 실패
SELECT * FROM events
WHERE date_trunc('month', created_at) = '2025-01-01';
-- 프루닝 동작
SELECT * FROM events
WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01';date_trunc()로 컬럼을 감싸면 플래너가 파티션 경계와 비교할 상수 범위를 도출하지 못합니다. 이는 버그가 아니라, 임의의 함수가 파티션 키에 어떻게 매핑되는지 플래너가 일반적으로 알 수 없다는 설계 제약입니다. 결과적으로 프루닝이 일어나지 않아 전 파티션이 스캔됩니다. 타입 캐스팅, 간접 참조도 같은 이유로 프루닝이 깨질 수 있습니다.
정적 프루닝이 성공하면 Append 자식 목록에서 해당 파티션들이 아예 빠지고, 런타임 프루닝이라면 Subplans Removed: N이 뜹니다. 둘 다 보이지 않는데 Append 아래 모든 파티션이 늘어서 있다면 프루닝이 실패하고 있는 겁니다.
시나리오 3: 멀티테넌트 구조에서 전역 UNIQUE가 필요한 경우
가장 골치 아픈 케이스입니다. 날짜별로 파티션된 테이블에서 email 컬럼의 전역 UNIQUE를 보장해야 하는 상황.
표준 PostgreSQL(17/18)은 파티션 키를 포함하지 않는 컬럼에 전역 UNIQUE를 직접 지원하지 않습니다. 실무에서 주로 쓰이는 우회책은 세 가지입니다.
방법 1: 유니크 감시용 사이드 테이블
CREATE TABLE email_registry (
email TEXT PRIMARY KEY,
user_id BIGINT NOT NULL
);
-- events INSERT와 email_registry INSERT를 같은 트랜잭션에서 수행.
-- UNIQUE 위반이 email_registry의 PK 충돌로 즉시 감지됨.방법 2: 파티션 키에 email 해시를 포함하도록 재설계
PARTITION BY LIST (hashtext(email) % 16) 같은 형태로 파티션 키를 바꾸면, email UNIQUE 제약을 파티션 키를 포함한 UNIQUE로 만들 수 있어 표준 PG에서도 강제 가능합니다. 단, 시간 기반 파티션의 장점을 포기해야 합니다.
방법 3: 트리거 + 어드바이저리 락으로 애플리케이션 레벨 강제
CREATE OR REPLACE FUNCTION check_email_unique()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_advisory_xact_lock(hashtext(NEW.email));
IF EXISTS (SELECT 1 FROM events WHERE email = NEW.email) THEN
RAISE EXCEPTION 'email already exists: %', NEW.email;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_events_email_unique
BEFORE INSERT ON events
FOR EACH ROW EXECUTE FUNCTION check_email_unique();pg_advisory_xact_lock 같은 직렬화 잠금이 없으면 두 트랜잭션이 동시에 EXISTS를 통과하고 둘 다 INSERT하는 레이스 컨디션이 생깁니다. UPDATE까지 커버하려면 별도 트리거와 조건이 필요합니다.
세 방법 중 방법 1이 가장 안전하고 진단하기 쉽지만, 두 테이블을 항상 동기화해야 하는 부담이 있습니다. 결국 쓰기 빈도와 UNIQUE 위반 감지 타이밍 사이의 트레이드오프입니다.
글로벌 인덱스 현황 — 2026년 8월 기준
"글로벌 인덱스 쓰면 되지 않나요?"라는 질문을 자주 받는데, 환경에 따라 답이 완전히 다릅니다.
| 구분 | 글로벌 인덱스 지원 여부 |
|---|---|
| 표준 PostgreSQL 17/18 | 미지원 (커뮤니티 패치 논의 진행) |
| Postgres Pro Enterprise 17.5.1 | pgpro_gbtree 확장으로 실험적 지원 (2025년 출시). CONCURRENTLY 빌드·표현식/부분 인덱스 미지원 |
| PolarDB (Alibaba Cloud) | 정식 지원 |
| YugabyteDB | 분산 환경 전역 UNIQUE 지원 |
Highgo Software 등 여러 기여자가 Global Unique Index 패치를 PostgreSQL 메일링 리스트에 제출했지만, 아직 코어에 머지되지 않았습니다. 표준 PostgreSQL에서 글로벌 인덱스가 필요하다면 당분간은 우회책을 써야 합니다.
로컬 인덱스와 글로벌 인덱스, 언제 무엇을 고를까
수치화된 공식보다는, 아래 조건들 중 어느 쪽에 더 무게가 실리는지로 판단하는 편이 정직합니다.
로컬 인덱스가 유리한 신호
- 쿼리 WHERE 절에 대부분 파티션 키 조건이 포함된다
- 쓰기 처리량이 높아 인덱스 유지 비용이 민감하다
- 파티션 단위의
DROP,ATTACH,DETACH가 자주 일어난다 - 표준 PostgreSQL만 사용 가능한 운영 환경이다
글로벌 인덱스가 유리한 신호
- 비파티션 키 컬럼에 대한 포인트 조회가 대부분이다
- 파티션 수가 수십~수백 개로 많다
- 전역 UNIQUE 제약이 비즈니스 요구사항이다
- Postgres Pro Enterprise, PolarDB 같은 확장 구현체를 쓸 수 있다
| 항목 | 로컬 인덱스 | 글로벌 인덱스 |
|---|---|---|
| 파티션 키 컬럼 조회 | 최적 (프루닝 + 단일 인덱스 스캔) | 과잉 |
| 비파티션 키 포인트 쿼리 | 파티션 수만큼 인덱스 탐색 | 단일 인덱스 탐색 |
| 전역 UNIQUE 보장 | 불가 (표준 PG) | 가능 |
| INSERT/UPDATE 오버헤드 | 낮음 | 높음 (전역 인덱스 갱신) |
| 파티션 DROP/이동 | 자동 처리 | 인덱스 재구성 필요 |
| 표준 PG 지원 | 완전 지원 | 미지원 (2026년 기준) |
| CONCURRENTLY 빌드 | 지원 | Postgres Pro pgpro_gbtree 미지원 |
흔한 판단 실수 두 가지를 꼽자면:
- 파티션 키 컬럼에 글로벌 인덱스를 요구 — 파티션 키로 조회하는 쿼리는 로컬 인덱스 + 프루닝으로 이미 최적화되어 있습니다. 글로벌 인덱스를 얹으면 DML 오버헤드만 늘어납니다.
- 파티션 수가 늘어나면 통계가 틀어짐 — 파티션 부모 테이블의 통계는 자식 파티션의 자동 ANALYZE만으로 갱신되지 않아 수동 ANALYZE가 필요한 경우가 있습니다. 잘못된 행 추정으로 조인 순서나 스캔 방식이 나빠질 수 있습니다.
진단 도구 빠르게 훑기
운영 중인 파티션 테이블에서 인덱스 효율을 확인할 때, LIKE 'events_%'로 자식 파티션을 추리는 방법은 events_backup, events_old 같은 관련 없는 테이블을 함께 잡을 위험이 있습니다. pg_inherits로 부모-자식 관계를 정확히 이용하는 편이 안전합니다.
SELECT
n.nspname AS schema_name,
c.relname AS partition_name,
i.relname AS index_name,
s.idx_scan,
s.idx_tup_read,
s.idx_tup_fetch
FROM pg_inherits h
JOIN pg_class parent ON parent.oid = h.inhparent
JOIN pg_class c ON c.oid = h.inhrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index idx ON idx.indrelid = c.oid
JOIN pg_class i ON i.oid = idx.indexrelid
JOIN pg_stat_all_indexes s ON s.indexrelid = i.oid
WHERE parent.relname = 'events'
ORDER BY s.idx_scan DESC;인덱스 스캔 횟수가 특정 파티션에만 몰려 있다면 그 파티션으로 조회가 집중되고 있다는 뜻입니다. 반대로 모든 파티션에 균일하게 분포되어 있다면 프루닝이 제대로 동작하지 않을 가능성이 큽니다.
슬로우 쿼리의 플랜 변화를 추적하려면 auto_explain을 함께 활성화해두면 편합니다.
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '1s';
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;진단할 때는 FORMAT JSON 옵션을 쓰면 실제 vs. 추정 행 수 차이를 보기 편합니다.
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT * FROM events WHERE email = 'foo@example.com';마무리 — 지금 프로덕션에서 바로 해볼 수 있는 것
파티션 테이블의 인덱스 전략은 결국 하나의 질문으로 수렴합니다. 이 쿼리의 WHERE 조건이 플래너에게 파티션 경계 상수를 넘겨줄 수 있는가. 넘겨줄 수 있다면 로컬 인덱스 + 프루닝만으로 충분하고, 그렇지 못한 쿼리가 많다면 글로벌 인덱스 또는 스키마 재설계로 방향을 틀 시점입니다.
만약 지금 운영 중인 파티션 테이블이 있다면, 이 글을 덮은 뒤 세 가지만 확인해보시길 권합니다.
- 대표 조회 쿼리에
EXPLAIN (ANALYZE, BUFFERS)를 걸어Append아래 자식 파티션 수가 예상과 맞는지,Subplans Removed가 있는지 본다 date_trunc(), 캐스팅, 함수 래핑이 파티션 키 컬럼에 얹혀 있는 쿼리를 grep해서 상수 범위 비교로 리라이트한다pg_stat_all_indexes로 파티션별 인덱스 사용이 균일한지 편중되어 있는지 관찰한다
date_trunc() 같은 함수 래핑이 프루닝을 조용히 죽이는 문제는, 쿼리 결과는 정상이고 성능만 서서히 나빠지는 방식으로 나타나기 때문에 배포 훨씬 뒤에 발견되곤 합니다. EXPLAIN을 릴리스 체크리스트에 넣어두는 습관 하나가 예상보다 많은 장애를 막아줍니다.
참고 자료
- PostgreSQL 공식 문서: Table Partitioning
- PostgreSQL 공식 문서: Partition Pruning
- Global indexes for partitions in Postgres Pro (Habr / Postgres Professional)
- Postgres Pro Enterprise: pgpro_gbtree
- PolarDB: Use Global indexes on partitioned tables (Alibaba Cloud)
- PostgreSQL Mailing List: Proposal — Global Index for PostgreSQL
- PostgreSQL Mailing List: Patch — Global Unique Index (Highgo)
- Oracle to Aurora PostgreSQL Migration Playbook: Local vs Global Partitioned Indexes (AWS)
- Global Unique Constraint on a Partitioned Table in PostgreSQL and YugabyteDB (DEV Community)
- PostgreSQL global statistics on partitioned table require a manual ANALYZE (DEV Community)
- Deep Dive: PostgreSQL 17 Partitioning — Optimize Queries for 1B+ Rows