포스트

파티셔닝과 샤딩 — 테이블이 너무 커졌을 때

테이블이 너무 커졌을 때 — PostgreSQL 선언적 파티셔닝(RANGE·LIST·HASH)과 샤딩의 차이, 샤드 키 선택과 핫스팟, 교차 샤드 조인·트랜잭션의 비용을 설명합니다.

파티셔닝과 샤딩 — 테이블이 너무 커졌을 때

“인덱스도 잘 탔는데 왜 점점 느려지지?”

주문 테이블이 3억 건을 넘기자 이상한 일이 벌어집니다. EXPLAIN을 보면 인덱스 스캔을 제대로 타는데도 응답이 느려지고, 야간 배치가 끝나지 않습니다. VACUUM은 몇 시간씩 돌고, 인덱스 하나가 디스크 200GB를 먹습니다. 한 달 치 데이터만 지우려고 DELETE를 날렸더니 테이블이 락에 잠기고 dead tuple이 폭증합니다.

문제는 쿼리가 아니라 테이블 그 자체가 너무 커진 것입니다. B-Tree 인덱스의 높이가 한 단 더 늘고, 버퍼 풀에 인덱스 상위 노드가 다 안 들어오고, 통계는 거대한 분포를 요약하느라 부정확해집니다. 앞 글에서 본 복제는 “장애 대비·읽기 확장”을 주지만, 한 테이블·한 서버의 한계는 풀지 못합니다. 복제본 10대를 둬도 각 노드는 똑같이 3억 건짜리 테이블을 들고 있으니까요.

이 글은 거대한 데이터를 쪼개는 두 가지 길 — 한 서버 안에서 쪼개는 파티셔닝과 여러 서버로 쪼개는 샤딩 — 을 PostgreSQL 내부 동작 수준에서 따라갑니다. 그리고 샤딩이 왜 “마지막 수단”인지, 샤드 키 하나를 잘못 고르면 무슨 지옥이 열리는지를 봅니다.

먼저: 수직 분할 vs 수평 분할

큰 테이블을 쪼개는 방향은 두 가지입니다.

  • 수직 분할(vertical partitioning): 열(column) 기준으로 쪼갠다. 자주 읽는 좁은 컬럼(id, status, amount)과 가끔 쓰는 큰 컬럼(description text, payload jsonb)을 다른 테이블로 분리. 효과는 페이지·튜플 글의 관점에서 분명합니다 — 한 페이지(8KB)에 더 많은 행이 들어가 같은 I/O로 더 많은 행을 읽고, 핫한 컬럼만 캐시에 올라갑니다. PG는 큰 값을 자동으로 TOAST로 떼어내므로 일부는 자동이지만, 접근 패턴이 명확히 갈리면 명시적 분리가 유효합니다.
  • 수평 분할(horizontal partitioning): 행(row) 기준으로 쪼갠다. 같은 스키마를 유지한 채 행을 여러 조각(파티션)으로 나눈다. “2024년 1월 주문”, “2024년 2월 주문” 식으로. 우리가 보통 “파티셔닝”이라 부르는 건 이쪽이고, 이걸 여러 서버로 흩으면 샤딩이 됩니다.
flowchart LR
  subgraph V["수직 분할 (열 기준)"]
    direction TB
    V1["orders_core<br/>id, user_id, status, amount"]
    V2["orders_detail<br/>id, payload, memo"]
  end
  subgraph H["수평 분할 (행 기준)"]
    direction TB
    H1["orders_2024_01"]
    H2["orders_2024_02"]
    H3["orders_2024_03"]
  end

이 글의 주인공은 수평 분할입니다.

PostgreSQL 선언적 파티셔닝

PG 10부터 선언적 파티셔닝(declarative partitioning)을 지원합니다. 부모 테이블은 데이터를 직접 갖지 않는 라우팅 껍데기이고, 실제 행은 자식 파티션(각각이 독립적인 힙)에 들어갑니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
-- 부모: PARTITION BY 로 분할 방식 선언 (자체 저장 없음)
CREATE TABLE orders (
    id        bigint,
    user_id   bigint,
    created_at timestamptz NOT NULL,
    amount    numeric
) PARTITION BY RANGE (created_at);

-- 자식 파티션: 경계를 명시
CREATE TABLE orders_2024_01 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

세 가지 분할 전략이 있습니다.

전략키 의미전형적 용도
RANGE값의 범위(FROM ... TO)시계열(날짜), 순번 — 오래된 파티션 통째 삭제(DROP)가 핵심 이점
LIST명시된 값 집합지역 코드, 카테고리, 테넌트 ID 등 이산값
HASHhash(key) mod N자연스러운 범위가 없을 때 균등 분산(MODULUS m, REMAINDER r)
1
2
3
4
-- LIST
CREATE TABLE logs_kr PARTITION OF logs FOR VALUES IN ('KR', 'JP');
-- HASH (4개로 균등 분산)
CREATE TABLE users_h0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);

파티션 프루닝 — 안 볼 파티션은 아예 안 본다

파티셔닝의 진짜 성능 이점은 partition pruning입니다. WHERE 조건에 파티션 키가 있으면 플래너(또는 실행기)가 관련 없는 파티션을 통째로 스킵합니다. 12개월치 12파티션 중 한 달만 보면 됩니다.

1
2
3
EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-02-10' AND created_at < '2024-02-20';
-- Append
--   ->  Index Scan on orders_2024_02   ← 단 한 파티션만 스캔

프루닝에는 두 종류가 있습니다. plan-time pruning은 플래너가 상수 조건으로 미리 잘라내고, execution-time pruning(enable_partition_pruning, PG 11+)은 $1 같은 파라미터나 조인 결과로 런타임에 결정되는 값에 대해 실행 중 잘라냅니다. 후자가 EXPLAIN엔 모든 파티션이 보이지만 ANALYZE엔 never executed로 찍히는 이유입니다.

WHERE created_at ∈ 2024-02 RANGE 라우팅/프루닝 행/쿼리 orders_2024_01 orders_2024_02 ✓ orders_2024_03 초록=스캔 대상 · 빨강 빗금=프루닝으로 제외

로컬 인덱스 vs 글로벌 인덱스 — PG의 중요한 제약

여기서 RDBMS 출신이 자주 데이는 지점입니다. PG의 파티션 인덱스는 로컬 인덱스입니다. 부모에 CREATE INDEX를 걸면 각 파티션마다 독립된 인덱스가 생기고, 각 인덱스는 자기 파티션의 ctid만 가리킵니다. 글로벌(파티션 전체를 한 인덱스로 묶는) 인덱스는 PG에 없습니다.

이게 만드는 결정적 제약: UNIQUE 제약과 PRIMARY KEY는 반드시 파티션 키를 포함해야 합니다.

1
2
3
4
5
6
-- 실패: id만으로는 전역 유일성을 로컬 인덱스로 보장할 수 없다
ALTER TABLE orders ADD PRIMARY KEY (id);
-- ERROR: unique constraint on partitioned table must include all partitioning columns

-- 성공: 파티션 키(created_at)를 포함
ALTER TABLE orders ADD PRIMARY KEY (id, created_at);

왜? 로컬 인덱스만 있으면 “id=42가 전체 파티션을 통틀어 유일한가”를 한 인덱스로 검사할 수 없습니다. 각 파티션의 로컬 인덱스를 다 뒤져야 하는데, 그건 유일성 제약의 비용 모델과 맞지 않습니다. 그래서 PG는 파티션 키를 유일 키에 포함시켜 “각 파티션 안에서만 유일하면 전체에서도 유일”하도록 강제합니다. 이 제약은 시퀀스 기반 글로벌 ID를 쓰는 설계와 충돌하므로 파티셔닝 도입 전에 반드시 검토해야 합니다.

파티셔닝(한 서버) vs 샤딩(여러 서버)

여기서 선을 그어야 합니다. 파티셔닝은 지금까지 본 것처럼 한 서버 안에서 한 논리 테이블을 여러 물리 조각으로 나누는 것입니다. 모든 파티션이 같은 PG 인스턴스, 같은 버퍼 풀, 같은 WAL, 같은 트랜잭션 매니저를 공유합니다. 그래서 트랜잭션·외래키·조인이 평소처럼 동작합니다.

샤딩은 그 조각들을 여러 서버(노드)로 흩는 것입니다. 각 샤드는 독립된 DB 인스턴스라 자기 버퍼 풀·WAL·트랜잭션을 갖습니다. 단일 서버의 CPU·메모리·디스크·쓰기 처리량 한계를 넘는 유일한 길이지만, 대가가 큽니다.

flowchart TB
  subgraph P["파티셔닝 — 한 서버"]
    direction LR
    PG1["PG 인스턴스 (공유 버퍼·WAL·트랜잭션)"]
    PG1 --- pa["part A"]
    PG1 --- pb["part B"]
    PG1 --- pc["part C"]
  end
  subgraph S["샤딩 — 여러 서버"]
    direction LR
    n1["노드1<br/>shard A"]
    n2["노드2<br/>shard B"]
    n3["노드3<br/>shard C"]
  end
구분파티셔닝(한 서버)샤딩(여러 서버)
한계 돌파큰 테이블 관리·프루닝CPU/메모리/쓰기 처리량/디스크
트랜잭션일반 ACID교차 샤드는 2PC/분산 트랜잭션 필요
조인일반 조인교차 샤드 조인은 애플리케이션/미들웨어가 fan-out
유일성파티션 키 포함 시 보장전역 유일 ID는 별도 설계(스노우플레이크 등)
운영 난도중상 (재분배·리밸런싱·라우팅)

순서가 중요합니다. “느리다 → 인덱스 → 쿼리 튜닝 → 읽기 복제(15편) → 수직 분할 → 파티셔닝 → 그래도 안 되면 샤딩.” 샤딩은 분산 트랜잭션·교차 샤드 조인이라는 복잡도를 영구히 짊어지므로 정말 마지막에 갑니다.

샤드 키 — 한 번 잘못 고르면 평생 후회

샤딩의 모든 고통은 샤드 키 선택으로 귀결됩니다. 샤드 키는 “이 행이 어느 노드에 사는가”를 결정합니다. 세 가지 고통이 여기서 나옵니다.

고통 1 — 핫스팟(hotspot)

키가 한쪽으로 쏠리면 한 노드만 불타고 나머지는 논다. 전형적 실수:

  • 단조 증가 키(타임스탬프·auto-increment)로 RANGE 샤딩 → 항상 “마지막 샤드”에만 쓰기가 몰린다(write hotspot).
  • 편중된 분포(예: 거대 테넌트 하나가 트래픽의 50%) → 그 테넌트가 사는 샤드만 과부하.
단조 증가 키 (핫스팟) N1 N2 N3 해시 분산 (균등) N1 N2 N3 같은 부하, 키 선택만 다르다

해법은 보통 해시 샤딩(키를 해시해 분산)이나 합성 키(tenant_id + bucket)로 쏠림을 깨는 것입니다. 단, 해시 샤딩은 범위 스캔을 못 하는 대가가 있습니다(연속한 키가 다른 샤드에 흩어짐).

고통 2 — 재분배(resharding)

노드를 4대에서 5대로 늘릴 때, 단순 모듈로(hash(key) mod N)를 쓰면 N이 바뀌는 순간 거의 모든 키의 소속이 바뀝니다. mod 4에서 mod 5로 가면 키의 약 80%가 다른 노드로 이사해야 하고, 그 사이 대량 데이터 이동·캐시 무효화·일시적 불일치가 발생합니다.

고통 3 — 교차 샤드 조인·트랜잭션

샤드 키와 다른 기준으로 묻는 쿼리는 모든 샤드에 부채살(fan-out)로 날아가 결과를 모아야 합니다(scatter-gather). 두 샤드에 걸친 트랜잭션은 2PC가 필요해 코디네이터 단일 실패·blocking 위험을 떠안습니다. 그래서 샤드 키는 가장 흔한 쿼리가 단일 샤드로 떨어지도록(예: 멀티테넌트는 tenant_id) 골라야 하고, 때로는 정규화를 일부러 깨서 조인을 피하는 반정규화가 정당화됩니다.

일관된 해싱(consistent hashing)

고통 2(재분배)를 누그러뜨리는 핵심 아이디어가 consistent hashing입니다. 키와 노드를 같은 해시 공간(0 ~ 2³²−1을 잇는 가상의 링) 위에 올리고, 각 키는 링을 시계 방향으로 돌다 처음 만나는 노드가 담당합니다.

노드를 하나 추가/제거하면 그 노드와 직전 노드 사이의 키만 이동합니다. 평균적으로 K/N 만큼의 키만 재배치됩니다(K=키 수, N=노드 수) — 모듈로 방식의 “거의 전부 이동”과 대조적입니다. 분포 편향을 줄이려 각 물리 노드를 링 위에 여러 가상 노드(vnode)로 흩뿌립니다. Cassandra·DynamoDB·memcached 클러스터·Redis Cluster(해시 슬롯 변형)가 이 계열입니다.

flowchart LR
  k["key → hash"] --> ring["해시 링 (0 ~ 2³²)"]
  ring -->|"시계방향 첫 노드"| owner["담당 노드"]
  add["노드 추가"] -.->|"인접 구간 K/N만 이동"| ring

면접/리뷰 단골 질문

  • Q. 파티셔닝과 샤딩의 차이는? → 파티셔닝은 한 서버 안에서 한 테이블을 물리 조각으로 나누는 것(트랜잭션·조인 정상). 샤딩은 그 조각을 여러 서버로 흩는 것(교차 샤드 트랜잭션은 2PC, 조인은 scatter-gather). 샤딩은 단일 서버 한계를 넘는 마지막 수단.
  • Q. partition pruning이 뭔가? → WHERE에 파티션 키가 있으면 관련 없는 파티션을 통째로 스킵하는 최적화. plan-time(상수)과 execution-time(파라미터/조인값) 두 종류. 파티션 키 없는 조건은 모든 파티션을 스캔하니 키 설계가 중요.
  • Q. PG에서 파티션 테이블 PK에 왜 파티션 키를 넣어야 하나? → PG는 로컬 인덱스만 있고 글로벌 유일 인덱스가 없어, 파티션 키를 포함해야 “각 파티션 안 유일 = 전체 유일”이 성립하기 때문.
  • Q. RANGE vs HASH 샤딩 트레이드오프? → RANGE는 범위 스캔·오래된 데이터 DROP에 강하나 단조 키면 핫스팟. HASH는 균등 분산이지만 범위 스캔 불가(연속 키가 흩어짐).
  • Q. hash(key) mod N의 문제와 해법은? → 노드 수 N이 바뀌면 대부분 키가 재배치됨. consistent hashing(링 + 가상 노드)으로 평균 K/N만 이동하게 한다.
  • Q. 샤드 키는 어떻게 고르나? → 가장 흔한 쿼리가 단일 샤드로 떨어지고(교차 샤드 회피), 분포가 고르며(핫스팟 회피), 잘 안 바뀌는 컬럼. 멀티테넌트면 보통 tenant_id.

정리

  • 큰 테이블은 수직(열)/수평(행)으로 쪼갠다. 우리가 말하는 파티셔닝·샤딩은 수평 분할.
  • PG 선언적 파티셔닝은 RANGE/LIST/HASH. 진짜 이점은 partition pruning(안 볼 파티션은 안 본다)과 오래된 파티션 통째 DROP.
  • PG는 로컬 인덱스만 있다 → UNIQUE/PK는 파티션 키를 포함해야 한다(글로벌 유일 인덱스 없음).
  • 파티셔닝=한 서버(트랜잭션·조인 정상), 샤딩=여러 서버(교차 샤드 트랜잭션·조인은 고통). 샤딩은 마지막 수단.
  • 샤드 키 3대 고통: 핫스팟·재분배·교차 샤드 조인/트랜잭션. 재분배 완화는 consistent hashing(링+가상 노드, K/N만 이동).

다음 글: 샤딩이 강요하는 “여러 노드가 하나처럼 합의하기” — 분산 트랜잭션·CAP·합의로 이어집니다.

</content> </invoke>

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 데이터베이스 A-Z (20편)

  1. 데이터베이스란 무엇인가 — 왜 우리는 파일 대신 DBMS를 쓰는가
  2. 관계형 모델과 정규화 — 왜 멀쩡한 표를 굳이 쪼개나
  3. SELECT 한 줄이 도는 길 — 파서·플래너·옵티마이저·익스큐터
  4. 디스크 위의 데이터 — 페이지·힙·로우는 어떻게 저장되나
  5. B-Tree 인덱스 — 어떻게 수억 건에서 한 건을 밀리초에 찾나
  6. 인덱스를 걸었는데 왜 안 타나 — 복합·커버링·부분·표현식 인덱스
  7. B-Tree로 안 되는 것들 — Hash·GIN·GiST·BRIN 인덱스 지도
  8. 트랜잭션과 ACID — 원자성과 영속성은 대체 어떻게 보장되나
  9. 격리 수준과 이상현상 — Dirty·Non-repeatable·Phantom, 그리고 그 너머
  10. MVCC 내부 — 읽기가 쓰기를 막지 않는 마법의 정체
  11. 잠금 — 행 락·테이블 락·2PL, 그리고 데드락은 왜 생기나
  12. WAL과 크래시 복구 — 정전이 나도 데이터가 안 날아가는 이유
  13. EXPLAIN을 읽는 법 + 조인 알고리즘 — Nested Loop·Hash·Merge
  14. 플랜이 갑자기 느려졌어요 — 카디널리티 추정과 통계의 세계
  15. 복제 — 한 대가 죽어도 살아남기 + 읽기를 여러 대로
  16. 파티셔닝과 샤딩 — 테이블이 너무 커졌을 때 지금 보는 글
  17. 분산 트랜잭션·CAP·합의 — 여러 노드가 하나처럼 합의하기
  18. NoSQL 지도 — Key-Value·Document·Wide-Column·Graph, 언제 무엇을
  19. 캐싱과 일관성 — Redis를 앞에 두면 생기는 것들
  20. 운영의 기술 + 현대 DB 지형 — 커넥션 풀부터 NewSQL·벡터·시계열까지