Skip to content

Split View: 파티셔닝과 샤딩 완전 가이드: 한 노드의 한계를 넘어가는 순서

|

파티셔닝과 샤딩 완전 가이드: 한 노드의 한계를 넘어가는 순서

들어가며

이 블로그에는 PostgreSQL 파티셔닝 글이 이미 여러 편 있습니다. 파티셔닝 완벽 가이드는 Range, List, Hash 각 전략의 문법과 성능을 다루고, 파티셔닝 전략과 병렬 쿼리는 병렬 실행과의 상호작용을 다룹니다.

이 글의 각도는 다릅니다. 파티셔닝을 목적지가 아니라 경로 위의 한 지점으로 봅니다. 데이터가 커질 때 우리가 지나는 길은 인덱스 → 파티셔닝 → 읽기 복제본 → 샤딩입니다. 각 단계는 앞 단계가 더 이상 통하지 않을 때만 의미가 있고, 각 단계는 앞 단계에 없던 새로운 문제를 데려옵니다. 이 글은 "파티셔닝을 언제 시작하고 언제 멈추고 언제 그다음으로 넘어가는가"를 다룹니다. 특히 샤딩으로 넘어갈 때 무엇이 깨지는지를 미리 아는 것이 목적입니다.

기준 엔진은 PostgreSQL 18 이며, 파티셔닝의 제약과 기본값은 모두 PostgreSQL 18 문서에서 확인했습니다. 샤딩 부분은 특정 제품에 종속되지 않는 설계 원칙 위주로 다루되, 제품 고유 동작은 각 제품 문서를 확인하라고 명시했습니다.

1. 파티셔닝이 실제로 해결하는 문제

먼저 오해를 걷어냅니다. 파티셔닝은 디스크를 늘려 주지 않고, 쓰기 처리량을 늘려 주지 않으며, 대부분의 쿼리를 자동으로 빠르게 만들어 주지도 않습니다. 파티션이 모두 같은 서버의 같은 스토리지에 있기 때문입니다.

파티셔닝이 실제로 해결하는 문제는 네 가지입니다.

첫째, 스캔 범위 축소. 쿼리가 특정 파티션들만 건드리면 나머지는 아예 읽지 않습니다. 이것이 파티션 프루닝이고, 파티셔닝의 유일한 근본적 성능 이득입니다.

둘째, 대량 삭제의 상수화. 3년치 로그에서 가장 오래된 1년을 지우는 작업은 DELETE로 하면 몇 시간 걸리고 대량의 죽은 행과 WAL을 만듭니다. 월 단위 파티션이면 DROP TABLE 열두 번입니다. 이 차이가 파티셔닝을 도입하는 가장 흔한 이유입니다.

셋째, 유지보수 단위 축소. VACUUM, ANALYZE, 인덱스 재구축이 테이블 전체가 아니라 파티션 단위로 돕니다. 갱신되지 않는 과거 파티션은 사실상 유지보수가 필요 없어집니다.

넷째, 인덱스 크기 축소. 파티션마다 별도 인덱스를 가지므로 각 인덱스가 작아지고 캐시에 잘 들어갑니다.

거꾸로 말하면, 위 네 가지가 필요 없다면 파티셔닝은 손해입니다. 계획 수립 시간이 늘고, 유일 제약에 제한이 생기고, 운영 작업이 늘어납니다. "테이블이 크니까 파티셔닝하자"는 판단은 그 자체로는 근거가 아닙니다.

2. 세 가지 파티셔닝 방식

PostgreSQL의 선언적 파티셔닝은 세 가지를 제공합니다. 문서의 정의 그대로입니다.

RANGE — 키 컬럼의 범위로 나눕니다. 문서에 따르면 "각 범위의 경계는 하한이 포함(inclusive), 상한이 배제(exclusive)"됩니다. 이 규칙을 잘못 알면 경계일 데이터가 사라지거나 겹칩니다.

CREATE TABLE events (
  id         bigint       GENERATED ALWAYS AS IDENTITY,
  tenant_id  bigint       NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload    jsonb        NOT NULL
) PARTITION BY RANGE (occurred_at);

-- 2026년 8월: 8월 1일 00:00:00 포함, 9월 1일 00:00:00 제외
CREATE TABLE events_2026_08 PARTITION OF events
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

CREATE TABLE events_2026_09 PARTITION OF events
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

LIST — 문서 표현으로 "각 파티션에 어떤 키 값이 들어갈지 명시적으로 나열"합니다. 지역 코드, 국가, 상태값처럼 값의 종류가 유한하고 안정적일 때 씁니다.

HASH — 문서 표현으로 "각 파티션에 모듈러스와 나머지를 지정해서, 파티션 키의 해시 값을 모듈러스로 나눈 나머지가 지정한 값인 행을 담습니다." 값 분포를 균등하게 나누는 것이 목적이며, 범위 프루닝은 불가능합니다.

-- 테넌트를 여덟 조각으로 균등 분산
CREATE TABLE events_h (LIKE events INCLUDING ALL) PARTITION BY HASH (tenant_id);
CREATE TABLE events_h_0 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE events_h_1 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... 나머지 6개

RANGE와 LIST에는 DEFAULT 파티션을 둘 수 있습니다. 어디에도 속하지 않는 행을 받는 곳입니다. 다만 5절과 6절에서 볼 이유로, DEFAULT 파티션은 비어 있게 유지하는 것이 원칙입니다.

3. 파티션 프루닝 — 유일한 진짜 이득

프루닝은 파티션 정의를 보고 조건에 맞을 수 없는 파티션을 계획에서 제거하는 최적화입니다. enable_partition_pruning이 제어하며 문서 예제에도 "the default"라고 표시된 대로 기본값은 on 입니다.

프루닝은 두 시점에 일어납니다.

계획 시점 프루닝WHERE 조건이 상수일 때 계획을 만들면서 제거합니다. EXPLAIN 출력에 남지 않고 그냥 사라집니다.

실행 시점 프루닝 — 파라미터 값이 실행 중에야 정해지는 경우(준비된 문장의 바인드 값, 서브쿼리 결과, Nested Loop의 안쪽)에 실행하면서 제거합니다. 이때는 EXPLAIN 출력에 Subplans Removed가 표시됩니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
 Aggregate  (cost=4210.55..4210.56 rows=1 width=8)
            (actual time=31.204..31.205 rows=1 loops=1)
   Buffers: shared hit=2048
   ->  Seq Scan on events_2026_08 events  (cost=0.00..3901.20 rows=123740 width=0)
         (actual time=0.011..21.882 rows=123740 loops=1)
         Filter: ((occurred_at >= '2026-08-01 00:00:00+09'::timestamptz)
              AND (occurred_at <  '2026-09-01 00:00:00+09'::timestamptz))
         Buffers: shared hit=2048
 Planning Time: 0.502 ms
 Execution Time: 31.240 ms

여기서 확인할 것은 계획에 파티션이 하나만 등장하는가입니다. 서른여섯 개 파티션이 전부 나열되어 있으면 프루닝이 실패한 것이고, 파티셔닝의 이득을 하나도 못 얻고 있는 상태입니다.

프루닝이 실패하는 대표적인 원인 두 가지를 기억하세요. 첫째, 파티션 키에 함수를 씌우면 프루닝이 안 됩니다. WHERE date_trunc('month', occurred_at) = ...은 조건을 키 범위로 환원할 수 없습니다. 둘째, 파티션 키가 조건에 아예 없으면 당연히 전체 파티션을 훑습니다. 이것이 파티션 키 선택이 결정적인 이유입니다.

4. 파티션 키가 거는 제약

파티션 키는 성능만 정하지 않습니다. 스키마가 표현할 수 있는 제약의 종류까지 정합니다.

문서가 명시한 제한 중 가장 중요한 것입니다. 파티션된 테이블에 유일 제약이나 기본 키를 만들려면 "파티션 키에 표현식이나 함수 호출이 포함되어서는 안 되고, 제약의 컬럼이 파티션 키의 모든 컬럼을 포함해야 한다."

이 한 문장이 설계 전체를 바꿉니다.

-- occurred_at으로 파티셔닝한 테이블에서
-- 이것은 만들 수 없다: 파티션 키(occurred_at)가 빠져 있다
ALTER TABLE events ADD CONSTRAINT uq_events_id UNIQUE (id);

-- 이것만 가능하다: 파티션 키를 포함해야 한다
ALTER TABLE events ADD CONSTRAINT uq_events_id_time UNIQUE (id, occurred_at);

시간으로 파티셔닝하면 "id는 전역적으로 유일하다"를 데이터베이스 제약으로 표현할 수 없습니다. 대신 애플리케이션이나 시퀀스가 그것을 보장해야 합니다. UUID나 UUIDv7 같은 전역 유일 식별자를 쓰는 이유가 여기에도 있습니다.

배제 제약(exclusion constraint)에도 같은 제한이 있습니다. 문서에 따르면 "파티션 키 컬럼을 모두 포함해야 하며, 그 컬럼들을 동등 비교해야" 합니다.

그 밖에 문서가 명시한 제한들입니다.

  • INSERT에 대한 BEFORE ROW 트리거는 새 행의 최종 목적지 파티션을 바꿀 수 없습니다.
  • 같은 파티션 트리 안에 임시 테이블과 영구 테이블을 섞을 수 없습니다.

파티션 키를 고르는 실용적 순서는 이렇습니다. 첫째, 대부분의 쿼리가 항상 걸고 들어오는 조건이 무엇인지 봅니다. 둘째, 데이터 보존 정책이 어떤 축으로 잘라 내는지 봅니다. 셋째, 그 축이 유일 제약 요건과 충돌하지 않는지 확인합니다. 셋 다 만족하는 축이 없으면 파티셔닝이 아직 이르다는 신호일 수 있습니다.

5. 파티션 개수와 플래너 비용

"파티션을 잘게 쪼갤수록 좋다"는 직관은 틀렸습니다. 문서가 직접 반박합니다. "파티션이 적은 것보다 많은 것이 낫다고, 혹은 그 반대라고 그냥 가정하지 마라."

숫자에 관한 문서의 안내는 이렇습니다. "질의 플래너는 일반적으로 수천 개까지의 파티션 계층을 꽤 잘 처리한다. 다만 전형적인 질의에서 플래너가 소수의 파티션만 남기고 모두 제거할 수 있어야 한다."

비용은 두 곳에서 발생합니다. 문서 표현 그대로 "플래너가 파티션 프루닝을 수행한 뒤에도 남는 파티션이 많으면 계획 수립 시간이 길어지고 메모리 소비가 늘어난다"이며, 더 무서운 쪽은 메모리입니다. "특히 많은 세션이 많은 수의 파티션을 건드리면 서버의 메모리 소비가 시간이 지나며 크게 늘어날 수 있다. 각 파티션의 메타데이터가 그것을 건드리는 각 세션의 로컬 메모리에 적재되어야 하기 때문이다."

워크로드에 따른 안내도 있습니다. "데이터 웨어하우스 유형의 워크로드에서는 OLTP 유형보다 많은 수의 파티션을 쓰는 것이 합리적일 수 있다. 데이터 웨어하우스에서는 대개 처리 시간의 대부분이 실행에 쓰이므로 계획 수립 시간이 덜 중요하다."

실무 기준을 정리하면 이렇습니다. OLTP에서는 파티션 수를 수십에서 수백 단위로 유지하고, 그 이상이 필요하면 파티션 간격을 넓히거나(일 단위를 월 단위로) 오래된 파티션을 분리해 아카이브로 내보냅니다. 그리고 max_locks_per_transaction을 잊지 마세요. 기본값 64이며, 문서 자체가 "자식이 많은 부모 테이블에 대한 질의"를 값을 올려야 하는 사례로 듭니다. 파티션 수백 개를 한 트랜잭션에서 건드리면 이 한계에 부딪힙니다.

6. 파티션 운영 — 붙이고 떼는 일

파티션 테이블의 운영은 대부분 "미래 파티션을 미리 만들고, 과거 파티션을 떼어 낸다"의 반복입니다.

미리 만들기. 파티션이 없는 범위에 행이 들어오면 오류가 나거나 DEFAULT 파티션으로 들어갑니다. 둘 다 좋지 않습니다. 최소 두세 기간분을 미리 만들어 두는 배치를 스케줄러에 걸어 두세요.

붙이기.

경고: ALTER TABLE ... ATTACH PARTITION은 부모에 SHARE UPDATE EXCLUSIVE 잠금만 걸지만, 붙이는 테이블 자체와 DEFAULT 파티션(있는 경우)에는 ACCESS EXCLUSIVE 잠금을 겁니다. DEFAULT 파티션에 데이터가 많으면 새 범위와 겹치는 행이 없는지 확인하는 스캔이 길어지고, 그동안 DEFAULT 파티션 접근이 전부 차단됩니다. 문서는 DEFAULT 파티션이 있을 때 "붙일 파티션의 제약을 배제하는 CHECK 제약을 만들어 두라"고 권합니다. 더 나은 답은 DEFAULT 파티션을 아예 두지 않거나 항상 비어 있게 유지하는 것입니다.

기존 테이블을 파티션으로 붙일 때는 CHECK 제약을 미리 걸어 두면 검증 스캔을 건너뜁니다.

-- 붙이기 전에 범위를 증명하는 CHECK를 미리 만든다
ALTER TABLE events_2026_10_staging
  ADD CONSTRAINT chk_range
  CHECK (occurred_at >= '2026-10-01' AND occurred_at < '2026-11-01');

ALTER TABLE events ATTACH PARTITION events_2026_10_staging
  FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

떼기. DETACH PARTITION에는 동시 모드가 있습니다. 문서 표현으로 "CONCURRENTLY를 지정하면 파티션된 테이블에 접근하는 다른 세션을 막지 않도록 낮춰진 잠금 등급으로 실행"됩니다. 보존 정책 배치에서는 이 옵션을 기본으로 쓰세요.

-- 잠금 영향을 최소화하며 분리
ALTER TABLE events DETACH PARTITION events_2023_08 CONCURRENTLY;

-- 분리된 뒤에는 평범한 독립 테이블이므로 자유롭게 처리한다
-- 아카이브로 덤프하거나
DROP TABLE events_2023_08;

인덱스. 파티션 테이블에는 CREATE INDEX CONCURRENTLY를 직접 쓸 수 없습니다. 문서가 "파티션된 테이블의 인덱스 동시 생성은 현재 지원되지 않는다"라고 명시하고, 각 파티션에 개별적으로 동시 생성한 뒤 마지막에 부모에 비동시적으로 만들라고 안내합니다.

파티션 단위 조인과 집계. enable_partitionwise_joinenable_partitionwise_aggregate는 파티션 경계가 같은 테이블끼리 파티션 단위로 조인하거나 집계하게 해 줍니다. 두 파라미터 모두 기본값이 off 입니다. 계획 수립 비용이 늘어나기 때문입니다. 파티션 경계가 정렬된 대형 테이블 조인이 잦은 분석 워크로드라면 켜 볼 가치가 있습니다.

7. 파티셔닝의 한계선 — 언제 샤딩인가

파티셔닝은 한 서버 안의 이야기입니다. 다음 셋 중 하나에 걸리면 파티셔닝으로는 더 갈 수 없습니다.

첫째, 쓰기 처리량이 한 노드의 한계에 닿았을 때. 파티션을 아무리 나눠도 WAL은 하나의 스트림이고, 커밋은 하나의 디스크로 갑니다. 읽기는 복제본으로 분산할 수 있지만 쓰기는 그렇지 않습니다.

둘째, 데이터가 한 노드의 스토리지나 백업 창을 넘길 때. 백업과 복구 시간이 사업이 감당할 수 있는 RTO를 넘으면 물리적으로 나눠야 합니다.

셋째, 지역이나 규제로 데이터를 물리적으로 분리해야 할 때. 이건 성능 문제가 아니라 요건입니다.

여기서 순서를 지키는 것이 중요합니다. 샤딩은 마지막 수단입니다. 그 앞에 시도할 것이 아직 남아 있는 경우가 대부분입니다. 인덱스와 쿼리 튜닝, 읽기 복제본으로 읽기 분산, 파티셔닝으로 스캔 축소, 오래된 데이터를 별도 분석 저장소로 분리, 캐시 계층 도입, 그리고 수직 확장. 하드웨어 값과 엔지니어 시간을 비교하면 수직 확장이 여전히 가장 싼 답인 경우가 많습니다.

PostgreSQL 생태계에서 샤딩을 구현하는 경로는 세 가지입니다.

  • 애플리케이션 레벨 샤딩 — 애플리케이션이 샤드 키를 보고 어느 데이터베이스로 갈지 결정합니다. 가장 단순하고 가장 통제 가능하지만, 라우팅과 리밸런싱을 직접 만들어야 합니다.
  • postgres_fdw 기반 페더레이션 — 외부 테이블을 파티션으로 붙여 다른 노드의 데이터를 한 테이블처럼 조회합니다. 조건 푸시다운이 어디까지 되는지가 성능을 좌우합니다.
  • 분산 확장이나 분산 SQL 엔진 — Citus 같은 확장이나 별도 분산 SQL 제품을 씁니다. 각 제품의 동작과 제약은 해당 제품 문서에서 확인하세요. 버전마다 지원 범위가 크게 달라지는 영역입니다.

8. 샤딩하면 깨지는 것들

샤딩을 결정하기 전에 무엇을 포기하는지 정확히 알아야 합니다. 네 가지입니다.

첫째, 교차 샤드 조인. 샤드 키가 다른 두 테이블을 조인하려면 여러 노드의 데이터를 한곳으로 모아야 합니다. 대응은 두 가지입니다. 자주 조인하는 테이블들을 같은 샤드 키로 배치해 조인이 항상 한 샤드 안에서 끝나게 하거나(코로케이션), 크기가 작고 변경이 드문 테이블은 모든 샤드에 복제해 두는 것입니다(참조 테이블). 이 두 가지로 커버되지 않는 조인이 많다면 샤드 키 선택이 잘못된 것입니다.

둘째, 전역 유일성과 시퀀스. 각 샤드의 bigserial은 서로 겹칩니다. 대응은 샤드마다 시퀀스 시작값과 증가폭을 다르게 주거나, UUID 계열 식별자를 쓰거나, 상위 비트에 샤드 번호를 넣는 방식입니다. PostgreSQL 18에는 시간 순서를 담는 uuidv7() 함수가 추가되었으므로 정렬 지역성이 필요한 경우 후보가 됩니다.

셋째, 트랜잭션. 여러 샤드를 걸치는 원자적 갱신은 2단계 커밋을 요구하고, 2단계 커밋은 조율자가 죽었을 때 잠금이 남는 문제를 데려옵니다. 실무의 답은 대개 샤드를 걸치지 않는 트랜잭션만 허용하도록 도메인을 설계하는 것입니다. 걸쳐야 한다면 사가 패턴처럼 보상 트랜잭션으로 정합성을 맞춥니다.

넷째, 재균형. 샤드를 8개에서 16개로 늘리는 작업은 데이터 이동을 수반합니다. 단순 모듈러 해시를 쓰면 거의 모든 데이터가 이동합니다. 일관성 해시나 가상 샤드(논리 샤드를 물리 노드에 매핑하는 간접층)를 처음부터 도입해야 나중에 고생하지 않습니다.

여기에 운영 비용이 더해집니다. 샤드 수만큼 백업, 모니터링, 버전 업그레이드, 장애 대응이 곱해집니다. 샤딩은 기술 결정이 아니라 조직 결정입니다.

퀴즈: 실력을 확인해 보세요

퀴즈 1: 월 단위로 파티셔닝한 테이블인데 이 쿼리가 모든 파티션을 훑습니다. 왜일까요?
SELECT count(*) FROM events
WHERE date_trunc('month', occurred_at) = '2026-08-01'::timestamptz;

정답: 파티션 키에 함수를 씌웠기 때문에 프루닝이 동작하지 않습니다.

설명: 프루닝은 WHERE 조건을 파티션 경계와 비교할 수 있을 때만 작동합니다. date_trunc('month', occurred_at)은 컬럼 자체가 아니라 컬럼의 함수 결과이므로, 플래너가 그것을 occurred_at의 범위로 환원하지 못합니다. 고쳐 쓰면 이렇습니다.

SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';

RANGE 파티션의 경계는 하한 포함, 상한 배제이므로 조건도 같은 형태로 맞추는 것이 자연스럽습니다. EXPLAIN에서 파티션이 하나만 등장하는지 반드시 확인하세요.

퀴즈 2: occurred_at으로 파티셔닝한 events 테이블에 PRIMARY KEY (id)를 만들려는데 오류가 납니다.

정답: 파티션된 테이블의 유일 제약은 파티션 키의 모든 컬럼을 포함해야 하기 때문입니다.

설명: 문서는 "제약의 컬럼이 파티션 키의 모든 컬럼을 포함해야 한다"라고 명시합니다. 파티션마다 별도의 인덱스가 존재하므로, 파티션 키가 빠진 유일 인덱스는 파티션 간 유일성을 보장할 방법이 없습니다. 선택지는 세 가지입니다. 첫째, PRIMARY KEY (id, occurred_at)처럼 파티션 키를 포함시킵니다. 둘째, 전역 유일성을 데이터베이스가 아니라 식별자 생성 방식으로 보장합니다(UUID, uuidv7(), 스노플레이크 계열). 셋째, 유일성이 정말 중요하고 파티셔닝의 이득이 크지 않다면 파티셔닝을 재검토합니다. 이 제약을 뒤늦게 발견해 설계를 갈아엎는 일이 흔하므로 파티션 키를 정할 때 함께 검토해야 합니다.

퀴즈 3: 일 단위 파티션으로 3년치를 운영 중입니다(약 1,100개). 최근 계획 수립 시간이 실행 시간보다 길어졌습니다.

정답: 파티션 수가 많아 플래너 부담이 커졌습니다. 간격을 넓히거나 오래된 파티션을 분리해야 합니다.

설명: 문서는 플래너가 "수천 개까지의 파티션 계층을 꽤 잘 처리한다"고 하면서도 "전형적인 질의에서 소수의 파티션만 남기고 모두 제거할 수 있어야 한다"는 단서를 답니다. 프루닝 후에도 많은 파티션이 남으면 계획 수립 시간과 메모리 소비가 함께 늘어납니다. 특히 각 세션이 건드린 파티션의 메타데이터를 로컬 메모리에 적재하므로, 커넥션이 많은 서버에서는 메모리 압박이 누적됩니다. 대응은 세 가지입니다. 최근 데이터만 일 단위로 두고 과거는 월 단위로 병합하기, 보존 기간이 지난 파티션을 DETACH PARTITION ... CONCURRENTLY로 떼어 내기, 그리고 max_locks_per_transaction(기본값 64) 여유가 있는지 확인하기입니다.

퀴즈 4: 팀에서 "쓰기가 느리니 파티셔닝하자"고 합니다. 타당한가요?

정답: 대체로 타당하지 않습니다. 파티셔닝은 쓰기 처리량을 늘려 주지 않습니다.

설명: 파티션은 모두 같은 서버, 같은 WAL 스트림, 같은 스토리지를 씁니다. 파티셔닝으로 쓰기가 빨라지는 경우는 간접적입니다. 인덱스가 작아져 인덱스 갱신 비용이 줄거나, 대량 삭제가 DROP TABLE로 바뀌어 죽은 행 생성이 사라지는 정도입니다. 쓰기가 느린 진짜 원인은 대개 다른 곳에 있습니다. 인덱스가 너무 많거나, 커밋마다 동기 디스크 쓰기가 발생하거나(synchronous_commit은 기본값 on), 체크포인트가 너무 잦거나(max_wal_size 기본값 1GB), 잠금 경합이 있거나, 트리거가 무겁습니다. 이 순서로 먼저 확인하고, 그래도 한 노드 한계라면 그때는 파티셔닝이 아니라 샤딩이나 수직 확장을 검토해야 합니다.

퀴즈 5: 샤딩을 도입하기로 했습니다. 샤드 키를 고를 때 가장 먼저 확인해야 할 것은?

정답: 트랜잭션과 조인이 샤드 경계를 넘지 않는지입니다.

설명: 샤드 키 선택의 1차 기준은 데이터 분포 균등성이 아니라 경계 넘김의 빈도입니다. 멀티테넌트 서비스에서 tenant_id가 흔히 좋은 샤드 키인 이유는, 대부분의 트랜잭션과 조인이 한 테넌트 안에서 끝나기 때문입니다. 반대로 시간을 샤드 키로 쓰면 최신 샤드에 모든 쓰기가 몰리는 핫스팟이 생기고, 사용자 ID로 샤딩한 뒤 "주문과 상품을 조인"하면 매 쿼리가 교차 샤드가 됩니다. 확인 순서는 이렇습니다. 첫째, 상위 열 개 쿼리가 모두 샤드 키를 조건으로 갖는가. 둘째, 트랜잭션 경계가 샤드 안에 들어가는가. 셋째, 특정 샤드에 데이터나 트래픽이 쏠리지 않는가. 셋 중 하나라도 어긋나면 그 키는 후보에서 빼야 합니다.

마치며

파티셔닝과 샤딩은 이름이 비슷하고 그림도 비슷하지만 성격이 전혀 다릅니다. 파티셔닝은 한 데이터베이스 안의 물리 설계이고 되돌리기가 비교적 쉽습니다. 샤딩은 시스템 아키텍처의 변경이고 되돌리기가 사실상 불가능합니다.

그래서 순서가 중요합니다. 인덱스와 쿼리로 해결되면 거기서 멈추고, 스캔 범위와 보존 정책 때문이라면 파티셔닝을 하고, 읽기 부하라면 복제본을 늘리고, 그래도 한 노드 한계라면 그때 샤딩을 검토합니다. 각 단계에서 "다음 단계로 갈 근거"를 숫자로 적어 두면 조직의 논쟁이 짧아집니다.

파티션 프루닝을 직접 확인해 보려면 Postgres 놀이터에서, 대용량 테스트 데이터가 필요하면 목업 데이터 생성기를 활용하세요.

참고 자료

이어서 읽기

The Complete Guide to Partitioning and Sharding: The Order for Moving Past a Single Node

Introduction

This blog already has several posts on PostgreSQL partitioning. PostgreSQL Partitioning Complete Guide covers the syntax and performance of the Range, List, and Hash strategies, and PostgreSQL Partitioning Strategies and Parallel Queries covers how partitioning interacts with parallel execution.

This post takes a different angle. It treats partitioning not as a destination but as one point along a path. As data grows, the road we travel runs index → partitioning → read replicas → sharding. Each stage matters only once the stage before it stops working, and each stage brings a new problem that the previous stage did not have. This post covers "when to start partitioning, when to stop, and when to move to the next stage." The specific goal is to know in advance what breaks the moment you move to sharding.

The reference engine is PostgreSQL 18, and every constraint and default value for partitioning was confirmed against the PostgreSQL 18 documentation. The sharding section focuses on design principles that are not tied to any single product, and it explicitly calls out that product-specific behavior needs to be checked against the documentation for that particular product.

1. What Partitioning Actually Solves

Let's clear up a misconception first. Partitioning does not add disk space, does not increase write throughput, and does not automatically make most queries faster. All partitions live on the same storage on the same server.

Partitioning actually solves four problems.

First, a smaller scan range. When a query only touches specific partitions, it never reads the rest at all. This is partition pruning, and it is the one truly fundamental performance gain that partitioning provides.

Second, turning bulk deletes into a constant-time operation. Deleting the oldest year out of three years of logs takes hours with DELETE and generates a huge number of dead rows and WAL. With monthly partitions, it is twelve calls to DROP TABLE. This difference is the single most common reason teams adopt partitioning.

Third, a smaller unit of maintenance. VACUUM, ANALYZE, and index rebuilds run per partition instead of across the whole table. A past partition that no longer receives updates effectively needs no maintenance at all.

Fourth, smaller indexes. Each partition carries its own separate index, so each individual index is smaller and fits into cache more easily.

Put the other way around, if you do not need any of the four things above, partitioning is a net loss. Planning time goes up, unique constraints become more restricted, and there is more operational work to do. "The table is big, so let's partition it" is not, by itself, a justification.

2. The Three Partitioning Methods

PostgreSQL's declarative partitioning provides three methods. These are the definitions straight from the documentation.

RANGE — splits data by ranges of a key column. Per the documentation, "the ranges should always have inclusive lower bounds and exclusive upper bounds." Get this rule wrong and data that falls right on a boundary either disappears or overlaps.

CREATE TABLE events (
  id         bigint       GENERATED ALWAYS AS IDENTITY,
  tenant_id  bigint       NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload    jsonb        NOT NULL
) PARTITION BY RANGE (occurred_at);

-- August 2026: includes 08-01 00:00:00, excludes 09-01 00:00:00
CREATE TABLE events_2026_08 PARTITION OF events
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

CREATE TABLE events_2026_09 PARTITION OF events
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

LIST — in the documentation's words, each partition is defined "by explicitly listing which key value(s) appear in each partition." Use it when the set of values is finite and stable, like region codes, countries, or status values.

HASH — in the documentation's words, you give each partition "a modulus and a remainder, and each partition will hold the rows for which the hash value of the partition key divided by the modulus produces the specified remainder." The goal is an even split of the value distribution, and range-based pruning is not possible with it.

-- Spread tenants evenly across eight slices
CREATE TABLE events_h (LIKE events INCLUDING ALL) PARTITION BY HASH (tenant_id);
CREATE TABLE events_h_0 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE events_h_1 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... the remaining 6

RANGE and LIST can both have a DEFAULT partition, which catches rows that do not belong anywhere else. However, for reasons we will see in sections 5 and 6, the rule of thumb is to keep the DEFAULT partition empty.

3. Partition Pruning — The One Real Win

Pruning is the optimization that looks at partition definitions and removes, from the plan, any partition that cannot possibly satisfy the condition. It is controlled by enable_partition_pruning, and as the documentation itself marks it "the default," the default value is on.

Pruning happens at two different points.

Plan-time pruning — when the WHERE condition is a constant, pruning happens while the plan is being built, and the excluded partitions leave no trace in the EXPLAIN output; they simply are not there.

Execution-time pruning — when a parameter value is only determined during execution (a bind value in a prepared statement, a subquery result, the inner side of a Nested Loop), pruning happens as the query runs. Here, EXPLAIN output shows Subplans Removed.

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
 Aggregate  (cost=4210.55..4210.56 rows=1 width=8)
            (actual time=31.204..31.205 rows=1 loops=1)
   Buffers: shared hit=2048
   ->  Seq Scan on events_2026_08 events  (cost=0.00..3901.20 rows=123740 width=0)
         (actual time=0.011..21.882 rows=123740 loops=1)
         Filter: ((occurred_at >= '2026-08-01 00:00:00+09'::timestamptz)
              AND (occurred_at <  '2026-09-01 00:00:00+09'::timestamptz))
         Buffers: shared hit=2048
 Planning Time: 0.502 ms
 Execution Time: 31.240 ms

What you need to check here is whether only a single partition shows up in the plan. If all thirty-six partitions are listed out, pruning has failed, and you are getting none of the benefit of partitioning at all.

Remember the two most common reasons pruning fails. First, wrapping the partition key in a function disables pruning. WHERE date_trunc('month', occurred_at) = ... cannot be reduced to a range over the key. Second, if the partition key does not appear in the condition at all, the planner naturally scans every partition. This is why choosing the partition key is such a decisive choice.

4. The Constraints a Partition Key Imposes

A partition key does not just determine performance. It also determines what kinds of constraints your schema is even able to express.

This is the most important restriction the documentation states. To create a unique constraint or primary key on a partitioned table, "the partition key of the table must not include any expressions or function calls and the constraint's columns must include all of the partition key columns."

This single sentence changes your entire design.

-- On a table partitioned by occurred_at,
-- this cannot be created: the partition key (occurred_at) is missing
ALTER TABLE events ADD CONSTRAINT uq_events_id UNIQUE (id);

-- only this works: it has to include the partition key
ALTER TABLE events ADD CONSTRAINT uq_events_id_time UNIQUE (id, occurred_at);

In other words, once you partition by time, you cannot express "id is globally unique" as a database constraint. Instead, the application or a sequence has to guarantee that on its own. This is one more reason people reach for globally unique identifiers such as UUID or UUIDv7.

Exclusion constraints carry the same restriction. Per the documentation, they "must include all the partition key columns" and "must use equality comparisons for these columns."

A few other restrictions the documentation spells out:

  • A BEFORE ROW trigger on INSERT cannot change which partition a new row ultimately ends up in.
  • You cannot mix temporary and permanent tables within the same partition tree.

A practical order for choosing a partition key: first, look at what condition most of your queries always carry. Second, look at which axis your data retention policy cuts along. Third, confirm that axis does not conflict with your unique constraint requirements. If no single axis satisfies all three, that can be a sign that partitioning is premature.

5. Partition Count and Planner Cost

The intuition that "the more finely you slice partitions, the better" is wrong. The documentation directly pushes back on it: "do not assume that a large number of partitions will always be better than a small number, or vice versa."

On the actual numbers, the documentation says: "the query planner is generally able to handle partition hierarchies with up to a few thousand partitions fairly well, provided that typical queries allow the planner to prune all but a small number of partitions."

The cost shows up in two places. In the documentation's own words, "planning times become longer and more memory is consumed if many partitions survive pruning," and the scarier side of that is memory: "especially if many sessions touch large numbers of partitions, the server's memory consumption can grow considerably over time, because each partition's metadata must be loaded into the local memory of each session that touches it."

There is also guidance by workload type: "it may be more reasonable to use a larger number of partitions in a data warehousing type of workload than in an OLTP type of workload, because in data warehouses the majority of processing time is usually spent on execution, making planning time less significant."

Summed up as a practical rule of thumb: for OLTP, keep the partition count in the tens to low hundreds, and if you need more than that, widen the partition interval (daily to monthly) or detach old partitions out to an archive. And do not forget max_locks_per_transaction. Its default is 64, and the documentation itself cites queries against a parent table with many child tables as a case that requires raising it. Touch hundreds of partitions inside a single transaction and you will run straight into this limit.

6. Operating Partitions — Attaching and Detaching

Operating a partitioned table is, for the most part, a repeating cycle of "create future partitions ahead of time, detach past partitions."

Creating ahead of time. When a row arrives for a range that has no partition, you either get an error or the row lands in the DEFAULT partition. Neither is good. Schedule a batch job that creates at least two or three periods' worth of partitions in advance.

Attaching.

Warning: ALTER TABLE ... ATTACH PARTITION only takes a SHARE UPDATE EXCLUSIVE lock on the parent, but it takes an ACCESS EXCLUSIVE lock on the table being attached itself and on the DEFAULT partition, if one exists. If the DEFAULT partition holds a lot of data, the scan that checks for rows overlapping the new range takes a long time, and for that entire duration all access to the DEFAULT partition is blocked. When a DEFAULT partition exists, the documentation recommends "creating a CHECK constraint which excludes the values that are to be moved" before attaching. The better answer is to not have a DEFAULT partition at all, or to always keep it empty.

When attaching an existing table as a partition, adding a CHECK constraint ahead of time lets the operation skip the validation scan.

-- Create a CHECK constraint proving the range before attaching
ALTER TABLE events_2026_10_staging
  ADD CONSTRAINT chk_range
  CHECK (occurred_at >= '2026-10-01' AND occurred_at < '2026-11-01');

ALTER TABLE events ATTACH PARTITION events_2026_10_staging
  FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

Detaching. DETACH PARTITION has a concurrent mode. In the documentation's words, specifying CONCURRENTLY "runs with a reduced lock level so that other sessions accessing the partitioned table are not blocked." Make this option your default for retention-policy batch jobs.

-- Detach while minimizing lock impact
ALTER TABLE events DETACH PARTITION events_2023_08 CONCURRENTLY;

-- Once detached it is just an ordinary standalone table, so handle it freely
-- dump it to an archive, or
DROP TABLE events_2023_08;

Indexes. You cannot use CREATE INDEX CONCURRENTLY directly on a partitioned table. The documentation states that "concurrent index creation on a partitioned table is currently not supported," and it instructs you to build the index concurrently on each individual partition and then, last, build it non-concurrently on the parent.

Partition-wise joins and aggregates. enable_partitionwise_join and enable_partitionwise_aggregate let tables with matching partition boundaries join or aggregate partition by partition. Both parameters default to off, because they increase planning cost. If you run an analytical workload that frequently joins large tables with aligned partition boundaries, it is worth trying them on.

7. Where Partitioning Hits Its Limit — When Is It Time to Shard

Partitioning is a story that happens inside one server. If you hit any one of the following three situations, partitioning cannot take you any further.

First, when write throughput reaches the limit of a single node. No matter how you slice partitions, WAL is still a single stream and every commit goes to a single disk. Reads can be spread across replicas; writes cannot.

Second, when data outgrows a single node's storage or backup window. Once backup and recovery time exceeds the RTO the business can tolerate, you have to split things up physically.

Third, when data has to be physically separated for geographic or regulatory reasons. This is not a performance problem — it is a requirement.

Keeping the order matters here. Sharding is the last resort. In most cases there is still something left to try before it: index and query tuning, spreading reads across read replicas, shrinking scans with partitioning, moving old data out to a separate analytical store, adding a caching layer, and vertical scaling. Weigh hardware cost against engineering time, and vertical scaling is still frequently the cheapest answer.

In the PostgreSQL ecosystem, there are three paths to implementing sharding.

  • Application-level sharding — the application looks at the shard key and decides which database to go to. It is the simplest and most controllable option, but you have to build routing and rebalancing yourself.
  • Federation based on postgres_fdw — foreign tables get attached as partitions, so data on other nodes can be queried as if it were one table. How far condition push-down reaches determines performance.
  • Distributed extensions or distributed SQL engines — extensions such as Citus, or separate distributed SQL products. Check each product's own documentation for its behavior and constraints; this is an area where the supported scope varies a great deal from version to version.

8. What Breaks the Moment You Shard

Before you decide to shard, you need to know exactly what you are giving up. There are four things.

First, cross-shard joins. Joining two tables that have different shard keys means pulling data from multiple nodes into one place. There are two responses. Either place tables that are joined frequently under the same shard key so the join always finishes inside a single shard (co-location), or replicate small, rarely changed tables onto every shard (reference tables). If a lot of your joins are not covered by either of these, your shard key choice is wrong.

Second, global uniqueness and sequences. A bigserial on each shard collides with the others. The responses are giving each shard a different sequence start value and increment, using a UUID-family identifier, or encoding the shard number into the high bits. PostgreSQL 18 added a uuidv7() function that carries time ordering, which makes it a candidate whenever you need sort locality.

Third, transactions. An atomic update spanning multiple shards requires two-phase commit, and two-phase commit brings along the problem of locks being left behind if the coordinator dies. In practice, the answer is usually to design the domain so that only transactions staying within a single shard are allowed. When a transaction has to span shards, you reach for compensating transactions, as in the saga pattern, to keep things consistent.

Fourth, rebalancing. Growing from 8 shards to 16 involves moving data. With a naive modular hash, almost all the data moves. You need to adopt consistent hashing or virtual shards (an indirection layer that maps logical shards onto physical nodes) from the very start, or you will suffer for it later.

On top of all this comes operational cost. Backups, monitoring, version upgrades, and incident response all multiply by the number of shards. Sharding is not a technical decision — it is an organizational one.

Quiz: Test Yourself

Quiz 1: You have a table partitioned by month, and this query scans every single partition. Why?
SELECT count(*) FROM events
WHERE date_trunc('month', occurred_at) = '2026-08-01'::timestamptz;

Answer: Pruning does not fire, because the partition key has a function wrapped around it.

Explanation: Pruning only works when the WHERE condition can be compared against the partition boundaries. date_trunc('month', occurred_at) is the result of a function on the column, not the column itself, so the planner cannot reduce it to a range over occurred_at. Here is the fix.

SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';

Since a RANGE partition's boundary is an inclusive lower bound and an exclusive upper bound, it is natural to shape your condition the same way. Always confirm in EXPLAIN that only a single partition shows up.

Quiz 2: You are trying to create PRIMARY KEY (id) on an events table that is partitioned by occurred_at, and you get an error.

Answer: Because a unique constraint on a partitioned table must include all of the partition key's columns.

Explanation: The documentation states that "the constraint's columns must include all of the partition key columns." Since each partition has its own separate index, a unique index that is missing the partition key has no way to guarantee uniqueness across partitions. There are three options. First, include the partition key, as in PRIMARY KEY (id, occurred_at). Second, guarantee global uniqueness through the identifier generation method rather than the database (UUID, uuidv7(), Snowflake-style IDs). Third, if uniqueness genuinely matters and the benefit of partitioning is not that large, reconsider partitioning itself. Teams commonly discover this constraint too late and have to tear up the design, so review it together with the partition key from the start.

Quiz 3: You are running three years of data on daily partitions (about 1,100 of them). Planning time has recently grown longer than execution time.

Answer: The high partition count has grown the burden on the planner. You need to widen the interval or detach old partitions.

Explanation: The documentation says the planner "can handle partition hierarchies with up to a few thousand partitions fairly well," but adds the caveat that this holds only when "typical queries" let it "prune all but a small number of partitions." When a lot of partitions survive pruning, planning time and memory consumption both grow. In particular, because each session loads the metadata of every partition it touches into its local memory, memory pressure accumulates on servers with many connections. There are three responses: keep only recent data on daily partitions and merge the past into monthly ones, detach partitions past their retention period with DETACH PARTITION ... CONCURRENTLY, and check whether you have headroom on max_locks_per_transaction (default 64).

Quiz 4: Your team says "writes are slow, so let's partition." Is that reasonable?

Answer: Generally not. Partitioning does not increase write throughput.

Explanation: All partitions share the same server, the same WAL stream, and the same storage. Where partitioning does speed up writes, it is indirect: smaller indexes lower the cost of index updates, or bulk deletes turn into DROP TABLE and stop generating dead rows. The real cause of slow writes is usually somewhere else: too many indexes, synchronous disk writes on every commit (synchronous_commit defaults to on), checkpoints firing too often (max_wal_size defaults to 1GB), lock contention, or heavy triggers. Check these first, in this order, and only if you are still hitting a single-node limit after that should you look at sharding or vertical scaling instead of partitioning.

Quiz 5: You have decided to adopt sharding. What is the first thing to check when choosing a shard key?

Answer: Whether transactions and joins stay within shard boundaries.

Explanation: The primary criterion for choosing a shard key is not even distribution of data, but how often boundaries get crossed. tenant_id is often a good shard key for multi-tenant services because most transactions and joins finish inside a single tenant. Conversely, using time as the shard key creates a hotspot where every write lands on the newest shard, and sharding by user ID and then joining "orders with products" turns every single query into a cross-shard query. Check them in this order: first, do your top ten queries all carry the shard key as a condition? Second, do transaction boundaries stay within a single shard? Third, is data or traffic skewed toward a particular shard? If even one of these three is off, that key should be dropped from consideration.

Closing

Partitioning and sharding sound similar and look similar in diagrams, but they are fundamentally different in nature. Partitioning is physical design inside a single database, and it is relatively easy to reverse. Sharding is a change to your system architecture, and reversing it is close to impossible.

This is why the order matters. If indexes and query tuning solve the problem, stop there. If it is about scan range or retention policy, partition. If it is read load, add replicas. Only if you are still hitting a single-node limit after all that should you consider sharding. Writing down, in numbers, "the evidence for moving to the next stage" at each step shortens the arguments an organization has with itself.

To see partition pruning for yourself, try the PostgreSQL Playground; if you need large volumes of test data, use the Mock Data Generator.

References

Continue Reading