콘텐츠로 이동

(KO) PostgreSQL 파티셔닝 — 테이블 상속에서 선언적 파티셔닝까지

목차

이 서브시스템이 바뀌어야 했던 이유 (초기 한계)

섹션 제목: “이 서브시스템이 바뀌어야 했던 이유 (초기 한계)”

PostgreSQL 역사의 대부분 동안 “파티셔닝된 테이블”이라는 개념 자체가 없었다. 존재한 것은 테이블 상속뿐이었다. 테이블 상속은 연구 시스템 Postgres에서 유래한 기능으로, 자식 테이블이 CREATE TABLE measurement_y2006m02 () INHERITS (measurement) 형태로 부모의 컬럼 정의를 공유하고 부모 쿼리 시 자동으로 스캔된다. 파티셔닝은 상속 위에 쌓은 레시피였다. 엔진이 하나의 단위로 이해하는 기능이 아니라, 매뉴얼에 기록되고 수천 개의 프로덕션 스키마에 그대로 복제된 관행이었다.

이 레시피는 DBA가 직접 조립하고 직접 동기화해야 하는 네 가지 부품으로 이루어졌다.

  1. 행이 없는 부모 테이블, 그리고 각 파티션마다 INHERITS (parent) 형태로 선언한 자식 테이블.
  2. 각 자식에 붙인 CHECK 제약 조건 — 해당 자식이 어떤 행을 담을 수 있는지 서술하는 규칙. CHECK (logdate >= DATE '2006-02-01' AND logdate < DATE '2006-03-01'). 플래너가 자식 내용을 파악하는 유일한 수단이었다.
  3. 부모에 붙인 INSERT 트리거 (또는 룰) — 새 행을 검사해 올바른 자식에 삽입하는 PL/pgSQL 코드. 파티션을 추가할 때마다 DBA가 직접 수정해야 하는 긴 IF/ELSIF 분기 사슬이었다.
  4. constraint_exclusion 활성화 — 플래너가 각 자식의 CHECK 제약이 쿼리의 WHERE 절과 모순되는지 증명해, 모순되면 해당 자식 스캔을 건너뛰는 방식.

이 각각이 문제의 씨앗이었다. 트리거는 DBA가 SQL 문자열로 직접 관리하는 O(N) IF 사슬이었고, 분기가 틀리거나 누락돼도 엔진이 아무 경고를 주지 않았다. 잘못 라우팅된 행은 소리 없이 엉뚱한 자식에 들어가거나 오류가 났다. CHECK 제약은 자유 형식의 술어였기 때문에 두 자식이 같은 행을 가리키거나 어떤 행도 자식에 해당하지 않아 (원래 비어야 할) 부모로 떨어질 수 있었고, 엔진은 그 사실을 몰랐다.

플랜 성능도 문제였다. constraint_exclusion의 내부 동작은 정리 증명이었다. 각 자식 릴레이션마다 relation_excluded_by_constraints가 자식의 CHECK 술어와 쿼리의 제한 절을 범용 술어 논박 증명기(predicate_refuted_by)에 넘겨 “이 둘이 동시에 참일 수 있는가?”를 물었다. 일반적이고 우아한 방식이지만 자식마다 플랜 시점에 실행되고 술어 복잡도에 비례하는 비용이 들었다. 파티션 1,000개라면 플랜마다 SAT 유사 증명이 1,000번 돌았다. 플랜 시점에 알려진 상수가 있어야 했기 때문에, $1 같은 파라미터나 조인에서 넘어오는 값으로는 자식을 제외할 수 없었다.

진단 결과는 명확했다. “어떤 파티션이 어떤 행을 담는가”라는 지식이 엔진이 인덱싱할 수 있는 카탈로그 메타데이터가 아니라 사용자가 쓴 SQL — 트리거 본문과 CHECK 술어 — 안에 있었다. 라우팅은 해석기를 거치는 IF 사슬이었고, 프루닝은 모든 자식에 맹목적으로 적용되는 범용 술어 증명이었다. 수백, 수천 개의 파티션에는 둘 다 맞지 않았고, 런타임 값도 쓸 수 없었다. 해결책은 그 지식을 구조화된 이진 탐색 가능한 카탈로그 표현으로 옮기고 그것을 읽는 전용 C 코드를 작성하는 것이었다. 이것이 바로 선언적 파티셔닝 프로젝트가 버전을 거치며 이룬 일이다.

flowchart LR
  E0["Pre-10<br/>테이블 상속<br/>CHECK + INSERT 트리거<br/>constraint_exclusion 증명"]
  E1["PG10<br/>PARTITION BY 문법<br/>PartitionBoundInfo 카탈로그<br/>C 튜플 라우팅"]
  E2["PG11<br/>HASH 파티셔닝<br/>DEFAULT 파티션<br/>파티션와이즈 조인/집계<br/>UPDATE 행 이동"]
  E3["PG11<br/>런타임 프루닝<br/>PARAM_EXEC 기반<br/>Append 서브플랜 스킵"]
  E4["PG12<br/>빠른 플랜 시점 프루닝<br/>ATTACH/DETACH CONCURRENTLY<br/>FK + 인덱스 상속"]
  E5["PG13-16<br/>BEFORE-row 트리거<br/>논리복제 루트 게시<br/>플래너 개선"]
  E6["REL_18<br/>partbounds.c<br/>partprune.c<br/>execPartition.c"]
  E0 --> E1 --> E2 --> E3 --> E4 --> E5 --> E6

각 시대를 관통하는 흐름은 동일하다. 파티션 결정 로직이 사용자가 작성한 SQL (트리거 본문, CHECK 술어, 범용 논박 증명기)에서 src/backend/partitioning/ 안의 전용 C 코드가 읽는 카탈로그 메타데이터로 이동한다. Era 1이 메타데이터와 라우팅 리더를 만들고, Era 2가 전략을 넓히고 파티션 경계 아래로 연산자를 밀어 넣으며, Era 3이 파라미터 바인딩 이후에도 프루닝이 동작하게 하고, Era 4가 프루닝을 저렴하게 만들고 파티션 유지보수를 온라인으로 전환하며, Era 5가 남은 기능 공백을 메운다.

Era 0 — 테이블 상속 + constraint exclusion (PG10 이전)

섹션 제목: “Era 0 — 테이블 상속 + constraint exclusion (PG10 이전)”

릴리스: 기준선 — 상속은 PostgreSQL 초기부터 존재했으며, constraint_exclusion을 파티션 프루닝 도구로 활용하는 기능은 PG 8.1 (2005)에 추가됐고, PG 8.4에서 상속 자식과 UNION ALL에만 동작하는 partition 모드가 추가되어 더 정교해졌다.

구체적인 모습. “파티셔닝된” 테이블은 수작업으로 연결된 트리 구조였다.

-- Era 0: 파티셔닝은 엔진이 아닌 관례로 구현됨
CREATE TABLE measurement (logdate date NOT NULL, peaktemp int);
CREATE TABLE measurement_y2006m02 (
CHECK (logdate >= '2006-02-01' AND logdate < '2006-03-01')
) INHERITS (measurement);
CREATE TABLE measurement_y2006m03 (
CHECK (logdate >= '2006-03-01' AND logdate < '2006-04-01')
) INHERITS (measurement);
-- 라우팅은 직접 작성한 트리거
CREATE FUNCTION measurement_insert_trigger() RETURNS trigger AS $$
BEGIN
IF NEW.logdate >= '2006-02-01' AND NEW.logdate < '2006-03-01' THEN
INSERT INTO measurement_y2006m02 VALUES (NEW.*);
ELSIF NEW.logdate >= '2006-03-01' AND NEW.logdate < '2006-04-01' THEN
INSERT INTO measurement_y2006m03 VALUES (NEW.*);
ELSE
RAISE EXCEPTION 'date out of range'; -- 또는 조용히 소실
END IF;
RETURN NULL;
END; $$ LANGUAGE plpgsql;

프루닝 동작 방식, 그리고 느릴 수밖에 없었던 이유. 플래너가 부모 measurement를 상속 자식들로 펼칠 때, 각 자식마다 쿼리의 WHERE 절이 그 자식의 CHECK 제약을 논박할 수 있는지 물었다. 이 메커니즘은 범용 술어 증명기로, REL_18 기준으로도 constraint_exclusion GUC와 plancat.crelation_excluded_by_constraints로 여전히 존재한다.

// relation_excluded_by_constraints — src/backend/optimizer/util/plancat.c
// (REL_18에도 존재; Era-0 메커니즘)
// ... 릴레이션의 CHECK 제약을 safe_constraints로 수집 ...
// if (predicate_refuted_by(safe_constraints, rel->baserestrictinfo, false))
// return true; // WHERE가 CHECK와 모순됨 -> 해당 자식 제외

Era 0의 비용 구조:

  • 라우팅: 행당 O(N) 해석. INSERT마다 PL/pgSQL 트리거가 실행되고 IF 사슬을 내려가며 분기를 찾는다. 파티션 N개라면 행마다 최대 N번의 비교가 PL/pgSQL 인터프리터에서 실행된다.
  • 프루닝: 플랜마다 O(N) 논박 증명. 각 자식이 범용 predicate_refuted_by 증명기를 호출한다. 이진 탐색이 아니라 임의의 부울 술어에 대한 구조적 증명이므로 자식별·절별로 비용이 든다.
  • 런타임 값 불가. WHERE logdate = $1 형태의 프리페어드 스테이트먼트나 조인에서 넘어오는 logdate 값은 증명기에 논박할 상수를 주지 못해 자식이 하나도 제외되지 않는다.

변화가 필요했던 이유. 위 두 비용은 파티션 수에 선형인데 타이밍이 나쁘다 — INSERT마다, 플랜마다 선형이다. 또한 증명기는 범용이지만 파티셔닝에 필요한 것은 더 특수하다. 파티션 맵은 설계상 전체 커버리지를 갖고 서로 배타적이므로, 키에서 파티션으로의 결정은 이진 탐색이어야지 정리 증명일 필요가 없다. Era 0에는 정렬된 경계 맵을 저장할 장소가 없었다. 엔진이 “이 테이블은 저 테이블의 파티션이다”를 모델링하지 않고 “이 테이블은 저 테이블을 상속하고 CHECK를 갖는다”만 알았기 때문이다. PG10 이후의 모든 것은 엔진에 그 누락된 모델을 부여하는 작업이다.

상호 참조: 범용 논박 증명기와 살아남은 constraint_exclusion GUC는 postgres-planner-overview.md에, 그것이 결정에 영향을 주는 술어 기반 스캔 선택은 postgres-scan-nodes.md에 설명되어 있다.

Era 1 — 선언적 파티셔닝 문법 + 튜플 라우팅 (PG10)

섹션 제목: “Era 1 — 선언적 파티셔닝 문법 + 튜플 라우팅 (PG10)”

릴리스: PostgreSQL 10 (2017). 대표 기능은 PARTITION BYPARTITION OF 문법, 파티션을 실제로 모델링하는 카탈로그, 그리고 INSERT 시 C 수준 튜플 라우팅이다. PG10은 RANGELIST 전략을 제공했고 해시는 다음 릴리스에 추가됐다.

문법의 변화. Era 0의 수작업 네 부품이 엔진이 하나의 단위로 이해하는 DDL 두 문장으로 압축된다.

-- Era 1: 엔진이 파티션 맵을 소유
CREATE TABLE measurement (logdate date NOT NULL, peaktemp int)
PARTITION BY RANGE (logdate);
CREATE TABLE measurement_y2006m02 PARTITION OF measurement
FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
CREATE TABLE measurement_y2006m03 PARTITION OF measurement
FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
-- CHECK도 없고 트리거도 없다: INSERT INTO measurement가 그냥 작동한다

CHECK 제약과 INSERT 트리거가 사라졌다. FOR VALUES 절이 카탈로그 행으로 파싱되고, 엔진이 그것을 근거로 라우팅과 프루닝을 결정한다.

새 카탈로그 모델. PG10은 pg_partitioned_table (부모의 전략과 키)을 추가했고 각 파티션의 경계를 pg_class.relpartbound에 저장했다. 플랜/실행 시점에 릴케시는 파티셔닝된 릴레이션마다 PartitionDesc를 만든다. PartitionDesc는 정렬된 oids[] 배열과 LIST/RANGE 경계를 정렬된 datums[] 배열과 indexes[] 맵으로 정규화하는 **PartitionBoundInfo**를 담는다. Era 0에 없던 구조화된, 이진 탐색 가능한 표현이다. (전체 구조는 모듈 문서에 설명되어 있다. 이 진화 문서는 이름만 언급하고 다시 유도하지 않는다.)

트리거를 대체한 튜플 라우팅. 라우팅이 해석된 PL/pgSQL에서 새 파일 src/backend/executor/execPartition.c의 C 코드로 이동했다. INSERT 경로의 전후 비교:

이전 (Era 0): INSERT INTO parent
-> BEFORE INSERT 트리거 발동
-> PL/pgSQL 인터프리터가 IF/ELSIF 사슬을 순회 (O(N))
-> 매칭된 자식에 INSERT
이후 (Era 1): INSERT INTO partitioned_table
-> ExecFindPartition()
-> FormPartitionKeyDatum()이 키를 추출
-> get_partition_for_tuple()이 boundinfo를 이진 탐색
-> 선택된 리프 ResultRelInfo에 튜플 저장
// ExecFindPartition / get_partition_for_tuple — src/backend/executor/execPartition.c
// (Era-0 INSERT 트리거를 대체한 C 라우팅)
// ExecFindPartition(): FormPartitionKeyDatum(...) 후
// partidx = get_partition_for_tuple(dispatch, values, isnull);
// get_partition_for_tuple(): 경계 정보 이진 탐색
// (partition_range_datum_bsearch / partition_list_bsearch)

플랜 시점 프루닝, 초기 버전. PG10은 플랜 시점 프루닝에 기존 제약 조건 기계를 여전히 사용했다. 선언적 경계가 내부적으로 암묵적 파티션 제약의 동치로 변환됐고, 플래너는 동일한 확장-후-제외 경로를 따랐다. 전용 고속 프루닝 엔진(partprune.c)은 아직 없었다. PG10의 성과는 모델의 정확성·편의성과 빠른 C 라우팅이었고, 빠른 프루닝은 아직이었다.

이 릴리스가 전환점인 이유. PG10은 파티션 지식이 사용자 SQL에서 엔진이 인덱싱할 수 있는 카탈로그 메타데이터로 넘어간 지점이다. 경계가 정렬된 PartitionBoundInfo에 들어간 순간, 라우팅 (키 → 파티션 조회)과 프루닝 (술어 → 파티션 집합 조회) 둘 다 이진 탐색 문제가 됐다. 그 전에는 각각 해석된 IF 사슬과 자식별 정리 증명이었다. 이후 모든 시대는 이 표현 위에 쌓인다.

상호 참조: PartitionDesc / PartitionBoundInfo 카탈로그 구조와 ExecFindPartition / get_partition_for_tuple 라우팅 흐름은 모듈 문서 postgres-partitioning.md에 자세히 설명되어 있다.

Era 2 — 해시 파티셔닝, DEFAULT 파티션, 파티션와이즈 조인/집계 (PG11)

섹션 제목: “Era 2 — 해시 파티셔닝, DEFAULT 파티션, 파티션와이즈 조인/집계 (PG11)”

릴리스: PostgreSQL 11 (2018). PG11은 선언적 파티셔닝을 단순히 올바른 것에서 실제 규모에서 쓸 수 있는 것으로 만든 릴리스다. 크게 네 개의 독립적인 기능이 함께 도착했고, 런타임 프루닝이라는 다섯 번째 기능은 충분히 크기 때문에 별도 시대로 다룬다.

1. HASH 파티셔닝. PG10에는 RANGE와 LIST가 있었다. PG11은 교과서의 세 번째 전략인 PARTITION BY HASH를 추가했다. 파티션은 모듈러스(modulus)와 나머지(remainder)로 선언된다.

CREATE TABLE orders (order_id bigint, ...) PARTITION BY HASH (order_id);
CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE orders_p1 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 1);
-- ... p2, p3

해시 파티셔닝은 자연 순서가 없어 Era-0 방식으로는 증명기가 활용할 수 있는 hashfn(key) % n = r 형태의 명시적 CHECK 없이는 표현할 수 없었다. 선언적 경계에서는 해시 파티션으로의 라우팅이 탐색이 아니라 모듈러 연산이다.

// compute_partition_hash_value 경로 — src/backend/partitioning/partbounds.c
// PARTITION_STRATEGY_HASH: 키 컬럼을 해시한 뒤 modulus/remainder 적용.
// case PARTITION_STRATEGY_HASH: (partbounds.c 경계 비교 + 라우팅 스위치)

카탈로그 모델이 왜 중요한지를 가장 명확하게 보여주는 사례다. 해시 파티셔닝은 매핑 함수를 사용자 CHECK가 아니라 엔진이 소유할 때에만 실용적이다.

2. DEFAULT 파티션. PG11은 PARTITION OF parent DEFAULT — 명시적 경계와 매칭되지 않는 행을 받는 캐치올 — 을 추가했다. Era 0에서는 어떤 자식의 CHECK와도 맞지 않는 행이 비어야 할 부모에 소리 없이 들어가거나 트리거에서 오류가 났다. PG10 선언적 파티셔닝은 그런 행을 오류로 거부했다. DEFAULT 파티션은 캐치올 동작을 안전하게 복원한다. 경계 정보에 자체 항목을 갖는 퍼스트클래스 파티션으로 취급된다 (LIST와 RANGE에만 해당. 해시는 설계상 전체 공간을 커버하므로 기본값이 없다).

3. 파티션와이즈 조인. enable_partitionwise_join으로 제어한다. 플래너가 호환 가능하게 파티셔닝된 두 테이블을 조인할 때, 먼저 양쪽을 합친 뒤 조인하는 대신 매칭되는 파티션끼리 쌍으로 조인하고 결과를 Append로 합칠 수 있다. 핵심은 경계 매칭이다. 플래너가 PG11에서 partbounds.c에 새로 추가된 partition_bounds_merge를 호출해 두 입력의 파티션 경계가 맞는지 확인하고, 맞으면 조인을 Append 아래로 밀어 넣는다.

4. 파티션와이즈 집계. enable_partitionwise_aggregate로 제어한다. GROUP BY에 대한 대응 기능이다. 그룹화 키가 파티션 키일 때 각 파티션을 독립적으로 집계하고 부분 결과를 이어 붙일 수 있다. 큰 해시/정렬 집계 하나 대신 작은 집계 N개가 되고, 병렬성이 있으면 N개 병렬 집계가 된다.

파티션와이즈 연산자의 구조 변화, 전후 비교:

flowchart TB
  subgraph Before["PG11 이전 — Append 위에서 조인"]
    A1["Append (테이블 A: a0,a1)"] --> J1["Join A x B"]
    B1["Append (테이블 B: b0,b1)"] --> J1
  end
  subgraph After["PG11 — Append 아래에서 파티션와이즈 조인"]
    J2a["Join a0 x b0"] --> AP["Append"]
    J2b["Join a1 x b1"] --> AP
  end

장점은 두 가지다. 파티션별 조인은 데이터의 일부만 다루므로 해시 조인의 빌드 사이드가 전체 테이블 규모에서 스필됐던 것이 메모리에 들어가고, N개의 독립 조인이 병렬화된다. 이를 위한 전제 — 경계 매칭 — 가 partition_bounds_merge가 존재해야 하는 이유다. 플래너는 a0가 b0하고만 조인되고 b1과는 절대 조인되지 않음을 증명해야 하는데, 이것이 정확히 경계 겹침 확인이다.

5. UPDATE 행 이동. PG11에 함께 추가됐다. UPDATE가 행의 파티션 키를 변경해 더 이상 현재 파티션에 속하지 않게 되면, 이제 오류 대신 행 이동(기존 파티션에서 DELETE, 새 파티션에 INSERT)으로 처리된다. INSERT 튜플 라우팅의 UPDATE 경로 버전으로, 역시 execPartition.cExecCrossPartitionUpdate 경로다.

이 기능들이 함께 나온 이유. PG10이 카탈로그 모델이 작동한다는 것을 증명했다면, PG11은 그것을 현금화했다. 해시 파티셔닝과 DEFAULT 파티션이 전략 표면을 완성한다 (세 가지 교과서 전략 + 캐치올). 파티션와이즈 조인과 집계는 경계 정보를 단순 포인트 조회를 넘어 활용하는 첫 기능이다. 두 경계 집합을 병합하고, 최적화기가 파티션 경계를 단순한 프루닝 힌트가 아니라 연산자를 밀어 넣을 수 있는 구조로 다루기 시작한 시점이다.

상호 참조: partition_bounds_merge와 파티션와이즈 메커니즘은 postgres-partitioning.md에, 파티션와이즈 플랜 사용 여부를 결정하는 비용/경로 선택은 postgres-planner-overview.md에 있다.

릴리스: PostgreSQL 11 (2018). Era 2 기능들과 함께 출시됐지만 개념적으로는 구분해서 다룰 만하다. 플랜 시점에 알려지지 않은 값으로도 프루닝이 동작하는 첫 메커니즘이다.

어떤 공백을 메웠나. constraint exclusion (Era 0)과 PG10 프루닝 모두 플랜 시점 상수가 있어야 했다. 상수가 없는 중요한 쿼리 클래스가 두 가지 있었다.

  1. 제네릭 프리페어드 플랜 / 파라미터. PREPARE p AS SELECT * FROM measurement WHERE logdate = $1; EXECUTE p('2006-02-15'). 제네릭 플랜은 $1을 모르는 상태에서 한 번 만들어지므로 어떤 자식도 제외할 수 없었다. 실행할 때마다 모든 파티션이 스캔됐다.
  2. 중첩 루프 조인의 내부 측. ... JOIN measurement ON measurement.logdate = outer.d. outer.d의 값은 외부 행마다 런타임에 알려지므로 플랜 시점 프루닝이 아무것도 제외할 수 없었다.

메커니즘. PG11은 바운드 파라미터 값으로 재실행 가능한 프루닝 표현을 도입했다. 플래너가 파티션 키 절을 PartitionPruneStep 노드 목록 — PartitionPruneStepOp (경계 정보에 연산자 적용)와 PartitionPruneStepCombine (절별 결과 교집합/합집합) — 으로 컴파일하고 Append / MergeAppend 플랜 노드에 붙인다. 이 단계들은 런타임에 해석 가능하며, 플랜 시점에 없던 PARAM_EXEC 값을 읽어 살아남는 서브플랜 집합을 계산한다.

두 개의 런타임 프루닝 시점이 추가됐다.

  • 초기 프루닝 (ExecDoInitialPruning / execPartition.cExecFindMatchingSubPlans): 외부 파라미터(제네릭 플랜의 EXECUTE 인자)가 바인딩된 뒤 실행기 시작 시 한 번 실행해, 매칭될 수 없는 서브플랜을 제거한다. 제네릭 프리페어드 플랜이 프루닝되게 하는 것이 이 단계다.
  • 스캔별/실행 프루닝: PARAM_EXEC 값이 변경될 때 — 예를 들어 중첩 루프의 외부 튜플이 새로 오면 — 단계를 재실행한다. Append가 중첩 루프 아래에 있을 때 현재 외부 값과 매칭되는 파티션만 스캔한다.

파라미터화된 스캔의 전후 비교:

이전 (PG10): EXECUTE p($1='2006-02-15')
-> 제네릭 플랜: 모든 파티션에 대한 Append
-> 모든 리프가 스캔되고 결과는 필터로 버려짐
이후 (PG11): EXECUTE p($1='2006-02-15')
-> ExecDoInitialPruning()이 $1 바인딩 후 프루닝 단계 실행
-> get_matching_partitions() -> {measurement_y2006m02}
-> Append가 살아남은 서브플랜만 스캔
// ExecDoInitialPruning / ExecFindMatchingSubPlans — execPartition.c
// 파라미터가 알려지면 컴파일된 PartitionPruneStep 목록을 한 번 실행하여
// 유효한 (살아남는) Append 서브플랜의 비트맵을 만든다.
// get_matching_partitions — partprune.c
// 플랜 시점과 런타임 프루닝 모두가 호출하는 공유 엔진.

구조적으로 중요한 이유. 런타임 프루닝은 프루닝 로직이 일회성 플랜 시점 결정이 아니라 재사용 가능하고 파라미터화 가능한 프로그램 (프루닝 단계 목록)이 되는 시점이다. 컴파일된 동일한 단계가 플랜 시점 프루닝 (상수와 함께 플랜 확정 전에 실행)과 런타임 프루닝 (실행 중 PARAM_EXEC 값과 함께 실행) 모두에 쓰인다. “술어를 한 번 단계로 컴파일하고, 값이 알려질 때마다 단계를 실행한다”는 이 통합이 partprune.c가 제공하는 것이며, Era-4 플랜 시점 속도 개선이 세워질 토대다.

상호 참조: 프루닝 단계 컴파일과 get_matching_partitions 엔진은 postgres-partitioning.md에, 살아남은 서브플랜이 Append / MergeAppend 실행기 노드에 연결되는 방식은 postgres-scan-nodes.md에 있다.

Era 4 — 빠른 플랜 시점 프루닝 + ATTACH/DETACH CONCURRENTLY + FK/인덱스 상속 (PG12)

섹션 제목: “Era 4 — 빠른 플랜 시점 프루닝 + ATTACH/DETACH CONCURRENTLY + FK/인덱스 상속 (PG12)”

릴리스: PostgreSQL 12 (2019). PG12는 “빠르게, 그리고 운영 가능하게” 만드는 릴리스다. 이 시점에서 파티셔닝 프로젝트는 기능이 풍부했지만 두 가지 고통 지점이 남아 있었다. 파티션 수가 많을수록 플랜 시점 프루닝이 느렸고, 모든 파티션 유지보수 작업(ATTACH, DETACH)이 쿼리를 차단하는 강한 락을 요구했다.

1. 빠른 플랜 시점 프루닝. PG11의 런타임 프루닝 기계 (partprune.c, 프루닝 단계 프로그램, get_matching_partitions)를 일반화해서 플랜 시점 프루닝도 기존의 “모든 자식을 펼친 뒤 증명” 경로 대신 prune_append_rel_partitions로 같은 기계를 사용하도록 했다. 당시 널리 보고된 실질적 효과: 수천 개의 파티션을 가진 테이블에 대한 SELECT 플랜이 초 단위에서 밀리초 단위로 줄었다. 플래너가 자식마다 논박 증명을 실행하는 대신 매칭 파티션을 경계 정보 이진 탐색으로 찾기 때문이다. PG12는 사실상 파티션 수의 실용적 상한을 “수백이면 부담”에서 “수천도 괜찮음”으로 올렸다.

// prune_append_rel_partitions — src/backend/partitioning/partprune.c
// 플랜 시점 진입점: 릴레이션의 제한 절을 프루닝 단계로 컴파일하고
// get_matching_partitions()를 실행해 살아남는 파티션 집합을 반환한다 —
// Era 3이 런타임용으로 도입한 것과 동일한 엔진이 이제 플랜 시점에도 쓰인다.

Era 3에서 열린 루프가 닫힌다. 단일 프루닝 엔진(partprune.c / get_matching_partitions)이 이제 플랜 시점 프루닝 (prune_append_rel_partitions)과 런타임 프루닝 (ExecFindMatchingSubPlans) 모두를 담당하게 됐고, 선언적 테이블의 constraint-exclusion 방식 자식별 증명이 은퇴했다. constraint_exclusion은 레거시 상속 트리와 UNION ALL을 위해 트리에 남아 있지만, 선언적 파티셔닝 테이블은 더 이상 그것에 의존하지 않는다.

2. ATTACH PARTITION (가벼운 락)과 DETACH … CONCURRENTLY. PG12는 ALTER TABLE ... ATTACH PARTITION의 락 영향을 줄여서 새 파티션 연결 시 전체 테이블 독자를 차단하는 ACCESS EXCLUSIVE 락이 더 이상 필요하지 않게 했다. DETACH PARTITION CONCURRENTLY — 두 트랜잭션 프로토콜을 사용해 긴 분리 작업이 쿼리를 차단하지 않는 온라인 분리 — 는 PG14에서 최종 완성됐다. PG12는 락 감소 작업이 시작된 릴리스다. 분리 동시 실행 로직은 아직 tablecmds.c에 있으며, DEFAULT 파티션이 존재하면 동시 분리를 할 수 없다는 규칙도 포함된다 (DEFAULT는 어차피 강한 락 하에서 재검증해야 하기 때문).

// DETACH PARTITION CONCURRENTLY — src/backend/commands/tablecmds.c
// "DEFAULT 파티션이 있을 때 동시에 파티션을 분리할 수 없다"
// 두 번째 트랜잭션이 inhdetachpending을 설정하고 동시 스냅샷을 기다림

3. FK와 인덱스가 파티션 계층으로 상속된다. 초기 선언적 파티셔닝에는 날카로운 모서리가 있었다. 파티셔닝된 부모에 인덱스를 만들어 모든 파티션에 적용할 수 없었고, 파티셔닝된 테이블을 참조하는 외래키도 지원되지 않았다. PG11이 이 부분을 메우기 시작했고 (부모에 선언된 인덱스가 자식으로 전파, 파티셔닝된 테이블의 기본키와 유니크 제약 조건), PG12가 파티셔닝된 테이블이 FK의 참조 대상이 될 수 있도록 외래키 지원을 완성했다. DDL 자식 전파 기계는 tablecmds.c와 제약 조건 서브시스템에 있다. 진화 관점에서 파티션 자식은 인덱스와 제약 조건을 하나씩 따로 관리해야 하는 독립 테이블에서 계층 구조의 관리된 구성원으로 바뀌었다.

이 릴리스가 성숙 릴리스인 이유. PG12 이후에는 선언적 파티셔닝에 “하지만”이 붙지 않는다. 어떤 합리적인 파티션 수에서도 프루닝이 저렴하고, 파티션 유지보수에 서비스 중단 수준의 락이 필요 없으며, 주변 스키마 객체(인덱스, PK, FK)가 계층 구조 전체에 상속된다. Era 3에서 도입되고 여기서 일반화된 단일 공유 프루닝 엔진이 REL_18이 현재도 사용하는 설계다.

상호 참조: prune_append_rel_partitions와 플랜 시점 프루닝 흐름은 postgres-planner-overview.mdpostgres-partitioning.md에, 결과 파티션 스캔은 postgres-scan-nodes.md에 있다.

Era 5 — 플래너 다듬기, 트리거, 논리 복제 (PG13–16)

섹션 제목: “Era 5 — 플래너 다듬기, 트리거, 논리 복제 (PG13–16)”

릴리스: PostgreSQL 13 (2020)부터 16 (2023)까지. PG13 시점에 핵심 아키텍처가 안정됐고, 이 시대는 “더 이상 X를 파티셔닝된 테이블에서 할 수 없다”는 제약을 하나씩 제거하고 플래너가 이미 갖추어진 구조를 더 잘 활용하게 만드는 긴 후처리 시기다. 릴리스별 주요 사항:

  • PG13 — 파티션와이즈 적용 범위 확장과 BEFORE-row 트리거. 파티션 키를 보존하는 연산이 더 폭넓게 전파되도록 했다. 특히 BEFORE ROW 트리거를 파티셔닝된 테이블에서 지원하기 시작했다. 부모에 선언하면 자식에 상속된다 (Era 0/PG10에서는 문장 수준이거나 자식별 행 트리거만 허용됐다). PG13은 더 많은 절 형태(예: 값 목록 / IN)의 프루닝도 개선했고, 쿼리가 많은 파티션 중 일부만 건드릴 때 메모리와 락 오버헤드를 줄였다.
  • PG13 — 파티셔닝된 테이블의 논리 복제. 파티셔닝된 테이블을 PUBLICATION에 직접 추가할 수 있게 됐고, publish_via_partition_root를 설정하면 게시자가 변경 사항을 루트 테이블에 속한 것처럼 보낼 수 있어, 파티셔닝된 테이블이 파티셔닝되지 않은 (또는 다르게 파티셔닝된) 테이블로 복제 가능해졌다. 이전에는 리프 테이블을 개별적으로 게시해야 했다.
  • PG14 — DETACH PARTITION CONCURRENTLY. 온라인 분리 완성: 두 트랜잭션 프로토콜이 파티션에 inhdetachpending을 표시하고 진행 중인 스냅샷을 기다려 분리가 동시 쿼리를 차단하지 않는다 (Era 4에서 언급된 메커니즘, 여기서 완성). PG14는 소수의 파티션으로 프루닝되는 쿼리에서 파티션별 플래너/실행기 오버헤드도 줄였다.
  • PG15 — 파티션와이즈 개선과 정렬 스캔. 플래너 작업 계속: 파티션 키로 ORDER BY할 때 최상위 정렬 없이 파티션별 정렬 스캔에서 스트리밍할 수 있도록 MergeAppend를 통한 순서 보존 활용을 강화했고, 파티션와이즈 조인/집계가 적용되는 경우도 늘었다.
  • PG16 — 더 적극적인 프루닝과 파티션와이즈 조인. 파티션 경계가 호환되지만 동일하지는 않은 더 많은 상황으로 파티션와이즈 조인을 확장했고, 프루닝 단계가 활용할 수 있는 절 형태를 넓혔다. 이전 시대들이 가능하게 만든 파티션와이즈·프루닝 스캔 형태에 더 많은 플랜을 집어넣는 것이 이 릴리스의 전반적인 방향이었다.

이 시대의 형태. PG10–12와 달리, PG13–16의 어떤 단일 변경도 아키텍처를 다시 쓰지 않았다. 각각은 동일한 세 기둥에 대한 증분이다. PartitionBoundInfo 카탈로그 모델 (PG10), 경계 병합 / 파티션와이즈 최적화기 훅 (PG11), 통합 partprune.c 프루닝 엔진 (PG11 런타임 + PG12 플랜 시점). 작업은 넓이 — 더 많은 트리거 유형, 더 많은 복제 시나리오, 더 많은 절 형태 프루닝, 더 많은 조인 형태를 Append 아래로 밀기 — 이며 토대는 움직이지 않았다.

flowchart TB
  Cat["PartitionBoundInfo<br/>카탈로그 모델 (PG10)"]
  Merge["경계 병합 / 파티션와이즈<br/>최적화기 훅 (PG11)"]
  Prune["통합 프루닝 엔진<br/>partprune.c (PG11 rt + PG12 pt)"]
  P13["PG13: BEFORE-row 트리거<br/>publish_via_partition_root"]
  P14["PG14: DETACH CONCURRENTLY<br/>파티션별 오버헤드 감소"]
  P15["PG15: MergeAppend 정렬<br/>파티션와이즈 확장"]
  P16["PG16: 호환 경계 조인<br/>프루닝 절 확대"]
  Cat --> P13
  Merge --> P15
  Merge --> P16
  Prune --> P14
  Prune --> P16
  Cat --> P13

상호 참조: 현재 트리거·복제·정렬 스캔 동작은 각 전용 모듈 문서에 설명되어 있다. 파티셔닝 전용 메커니즘은 postgres-partitioning.md에, 파티션와이즈·정렬 추가 플랜을 만드는 플래너 선택은 postgres-planner-overview.mdpostgres-scan-nodes.md에 있다.

REL_18 (273fe94, PostgreSQL 18.x) 기준으로, 선언적 파티셔닝은 PG10–12 아크가 만들어 낸 성숙한 카탈로그 기반 서브시스템에 PG13–16의 폭 넓히기가 더해진 상태다. 오늘날 이를 정의하는 세 소스 파일은 진화가 수렴한 결과물 그 자체다.

  • src/backend/partitioning/partbounds.c — LIST / RANGE / HASH 경계를 정렬된 PartitionBoundInfo로 정규화하고, 라우팅에 쓰이는 경계 비교를 실행하며, 파티션와이즈 조인/집계를 위해 두 경계 집합을 병합한다 (partition_bounds_merge, merge_list_bounds, merge_range_bounds).
  • src/backend/partitioning/partprune.c — 단일 프루닝 엔진. prune_append_rel_partitions가 플랜 시점 진입점이고, get_matching_partitions가 컴파일된 PartitionPruneStepOp / PartitionPruneStepCombine 프로그램을 실행한다. 동일한 단계들이 런타임에도 재실행된다. 선언적 테이블의 자식별 제약 증명을 은퇴시키고 플랜 시점과 런타임 프루닝 모두를 담당하는 하나의 모듈이다.
  • src/backend/executor/execPartition.c — INSERT/UPDATE 시 튜플 라우팅 (ExecFindPartition -> get_partition_for_tuple, PARTITION_CACHED_FIND_THRESHOLD = 16 마지막 발견 캐시 포함)과 Append/MergeAppend 서브플랜의 런타임 프루닝 (ExecDoInitialPruning, ExecFindMatchingSubPlans), 그리고 크로스 파티션 UPDATE 행 이동.

constraint_exclusionplancat.c / guc_tables.c에 레거시 테이블 상속 트리와 UNION ALL을 위해 남아 있다. Era-0 메커니즘이 하위 호환성을 위해 유지되지만, 선언적 파티셔닝 테이블의 경로에는 더 이상 없다. 위 내용의 메커니즘 수준 세부 사항은 현재 상태 모듈 문서 postgres-partitioning.md에, 플래너와 스캔 노드 컨텍스트는 postgres-planner-overview.mdpostgres-scan-nodes.md에 있다. 이 진화 문서는 그것을 중복하지 않는다.

다음 단계 (PG19, 방금 출시). 또 다른 아키텍처 재작성 없이 같은 토대 위에서 작업이 계속된다. 파티션와이즈 플랜·프루닝 커버리지 증분 개선과 파티션 유지보수 편의성이 방향이다. PG10–12 설계가 PostgreSQL이 현재도 교체가 아닌 확장을 택한 것이다.

PostgreSQL 릴리스 노트 (기능 귀속):

  • PostgreSQL 10 릴리스 노트 — 선언적 테이블 파티셔닝 (PARTITION BY RANGE/LIST, PARTITION OF, INSERT 시 튜플 라우팅).
  • PostgreSQL 11 릴리스 노트 — 해시 파티셔닝, DEFAULT 파티션, 파티션와이즈 조인 (enable_partitionwise_join), 파티션와이즈 집계 (enable_partitionwise_aggregate), UPDATE 행 이동, 런타임 파티션 프루닝, 파티셔닝된 테이블 인덱스/기본키.
  • PostgreSQL 12 릴리스 노트 — 빠른 플랜 시점 프루닝, 감소된 ATTACH 락, 파티셔닝된 테이블을 참조하는 외래키, COPY 개선.
  • PostgreSQL 13 릴리스 노트 — 파티셔닝된 테이블의 BEFORE-row 트리거, 논리 복제 / publish_via_partition_root, 프루닝 개선 및 오버헤드 감소.
  • PostgreSQL 14 릴리스 노트 — DETACH PARTITION ... CONCURRENTLY, 파티션 플랜/실행 오버헤드 추가 감소.
  • PostgreSQL 15 / 16 릴리스 노트 — 정렬된 (MergeAppend) 파티션 스캔, 더 폭넓은 파티션와이즈 조인 사례, 더 넓어진 프루닝 절 지원.

현재 상태 모듈 문서 (메커니즘 — 인용, 중복 없음):

핵심 소스 파일 (REL_18, commit 273fe94 기준 관찰):

  • src/backend/partitioning/partbounds.c — 경계 정규화, 비교, 병합.
  • src/backend/partitioning/partprune.cprune_append_rel_partitions, get_matching_partitions, 프루닝 단계 생성.
  • src/backend/partitioning/partdesc.cPartitionDesc 빌드.
  • src/backend/executor/execPartition.cExecFindPartition, get_partition_for_tuple, ExecDoInitialPruning, ExecFindMatchingSubPlans.
  • src/backend/optimizer/util/plancat.crelation_excluded_by_constraints (레거시 constraint-exclusion 메커니즘).
  • src/backend/commands/tablecmds.c — ATTACH / DETACH (CONCURRENTLY 포함)와 파티션으로의 DDL 전파.
  • src/backend/utils/misc/guc_tables.cconstraint_exclusion, enable_partitionwise_join, enable_partitionwise_aggregate GUC.