Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

이 블로그에는 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 놀이터에서, 대용량 테스트 데이터가 필요하면 목업 데이터 생성기를 활용하세요.

참고 자료

이어서 읽기

현재 단락 (1/133)

이 블로그에는 PostgreSQL 파티셔닝 글이 이미 여러 편 있습니다. [파티셔닝 완벽 가이드](/blog/database/2026-03-03-postgresql-partitio...

작성 글자: 0원문 글자: 10,678작성 단락: 0/133