[PostgreSQL] 테이블 파티션
PostgreSQL에서 테이블을 파티션 단위로 쪼개고 최적화하는 방법을 다룬다. 샤딩이라고도 한다.
여기서 말하는 파티션이란, 하나의 테이블을 여러개의 물리적 테이블로 쪼개서 관리하는 것을 말한다. 파티션을 여러 노드에 분산하는 것은 다루지 않는다.
파티션의 기본 논리
PostgreSQL의 파티션도 다른 DB의 파티션과 의미론이 다르지 않다.
파티션 키를 정해놓고, 해당 키를 기준으로 별도의 테이블 영역에 저장을 하는 것이다.
여기서 보통 파티션 키는 시간 단위가 되는 경우가 많다.
예를 들어, 파티션을 월 단위로 잡는다면, event_202601, event_202602, event_202603 같은 테이블이 만들어지면서 별도로 저장이 되는 셈이다.
PostgreSQL이 제공해주는 영역은, 여러개의 파티션 테이블을 하나의 테이블인 것처럼 조회하고 INSERT/UPDATE/DELETE할 수 있게 해주는 것이다.
다만 파티션 자동생성까지 해주지는 않는다. 이런건 분산 DB들이 잘하고, Postgres의 전공은 아니다.
왜 파티션인가?
근데 테이블을 파티션으로 쪼개면 무슨 이점이 있는걸까?
우선 디스크 성능에 대한 이해부터 가져야 한다. 시퀀스 스캔은 인덱스 스캔보다 행 단위 성능이 훨씬 뛰어나다.
예를 들어 만약 6월 데이터의 전체를 스캔해서 집계하는 쿼리가 있다면, 인덱스를 통해 6월 데이터만 접근하는 것보다 파티션된 테이블을 기준으로 6월 데이터를 풀스캔하는 것이 월등히 빠르다.
그래서 파티션 없이 인덱스만으로는 이러한 디스크 스캔 패턴을 최적화하는 것에는 한계가 있다.
그렇다고 해서 무조건 파티션을 박는 것도 효율적인 것은 아니다.
어느 정도 조건이 있어야 한다.
넓은 데이터 범위 규모에서(최소 수만-수십만)
- 복합적이고 동적인 필터 조건이 존재하는 경우 (모든 조합의 인덱스를 거는 것이 사실상 불가능한 경우)
- 대규모 집계를 돌리는 경우
- 혹은 둘다 포함되는 경우
정도로 생각해두면 될 것 같다.
그리고 정말 대규모로 간다면, 파티션을 멀티 디스크나 멀티 노드로 분산하는 것도 고려하게 된다.
다만 멀티노드 파티션 분산은 PostgreSQL 코어에서 제공하지 않는 기능이므로, 여기에서는 다루지 않는다.
파티션의 제한사항과 한계
파티션도 단점이 많은 기능이다. 한계를 명확하게 알고서 써야한다.
A. 쿼리 제약
파티션이 쪼개진다는 것은, 결과적으로는 전체 대상의 스캔 속도가 떨어진다는 말과 비슷하다.
그래서 전체 범위를 기반으로 한 쿼리는 파티션보다 비-파티션 테이블이 더 효율적일 수 있고, 파티션 컬럼에 대한 조건이 없다면 모든 파티션을 스캔하게 되기 때문에 굉장히 비효율적으로 동작하게 된다.
그래서 파티션을 걸 때는 신중해야 한다. 모든 쿼리에 해당 파티션을 기준으로 한 필터 조건이 존재해야한다는 말과도 같기 때문이다.
B. Unique 제약
파티션, 개별 샤드들은 실제로 별도의 테이블이기 때문에 PK나 유니크의 의미론이 많이 달라진다.
무슨 수로 다른 테이블들 간에 공통되는 유니크를 정의해야한다는 말인가?
그래서 유니크 조건을 가할 때는, 반드시 파티션 키 컬럼이 유니크 조건에 들어가야만 한다.

유일성 보장 수준이 떨어져서 신경써야할 부분이 더 생길 수도 있지만, 이건 어쩔 수 없다.
C. 파티션 선택 부하 (파티션 pruning)
파티션을 잘 쓰면 최적화된 형태로 스캔을 깔끔하게 끝낼 수 있지만, 결국에 파티션을 선택해서 스캔하고 insert하는 것은 DB의 부담이다.
그래서 파티션이 많아지면 많아질수록 쿼리 Plan의 성능이 떨어질 수 있다. 쿼리 실행이 조금 빠르면 뭐하나? 결국 파티션을 선택하는 것도 실행 성능에 포함되는 것이다.
파티션의 종류
PostgreSQL에는 Hash 파티션과 Range 파티션, List 파티션 3가지가 존재한다. 용도가 다르다.
Hash는 일치 비교를 위한 파티션 조건으로 사용된다. 예를 들면, user_id 같은 개별 값들이 이에 속한다.
Range 파티션은 범위 값에 사용되는 파티션 조건이다. 날짜 등 시간값 기반의 파티셔닝 기능을 제공한다.
List는 비교적 용처가 애매한 파티션 알고리즘이다. 값을 명시적으로 지정해서 넘기는 방식이다.
Range 파티션
Range 파티션을 적용해보자.
이벤트를 기록하는 테이블을 하나 만들어보겠다.
CREATE TABLE public.events (
id bigint,
created_at timestamptz NOT NULL,
data jsonb
) PARTITION BY RANGE (created_at);
이런 식으로 날짜 값을 파티션 조건으로 넣어주면 된다.
그 다음에는 파티션을 구체화해야 한다.
다시 말해서, 날짜 범위를 기반으로 개별 샤드들을 생성해주는 것이다. 여기에서 각 샤드의 값 범위를 지정해준다.
CREATE TABLE public.events_202607
PARTITION OF public.events
FOR VALUES FROM ('2026-07-01 00:00:00+09')
TO ('2026-08-01 00:00:00+09');
CREATE TABLE public.events_202608
PARTITION OF public.events
FOR VALUES FROM ('2026-08-01 00:00:00+09')
TO ('2026-09-01 00:00:00+09');
이러면 이제 7월 샤드와 8월 샤드가 만들어지는 셈이다.
실제 데이터들은 Root 테이블이 아닌, 개별 샤드에 저장된다.
값을 한번 넣어보자.
insert into public.events
values
(1, '2026-07-05 00:00:00+09', '{"text":"foo1"}'::jsonb)
, (2, '2026-07-16 00:00:00+09', '{"text":"foo2"}'::jsonb)
, (3, '2026-08-21 00:00:00+09', '{"text":"foo3"}'::jsonb)
, (4, '2026-08-10 00:00:00+09', '{"text":"foo4"}'::jsonb)
, (5, '2026-07-22 00:00:00+09', '{"text":"foo5"}'::jsonb)
, (6, '2026-08-02 00:00:00+09', '{"text":"foo6"}'::jsonb)
, (7, '2026-08-18 00:00:00+09', '{"text":"foo7"}'::jsonb)

그러면 단일 테이블인 것처럼 조회할 수 있지만

실제로는 2개의 테이블을 따로 풀스캔해서 조합해주는 구조가 된다.

파티션 키를 기반으로 필터를 걸면, 해당 파티션만 조회하게 된다.
항상 이런 식으로 조건이 달려있어야 한다.
Hash 파티션
이번에는 해시 파티션을 다뤄보자.
이건 일반적인 선두 컬럼 필터링에 적합한 방법이다.
유저 행동을 기록한다는 의미의 테이블을 하나 추가해보자.
CREATE TABLE public.actions (
id bigint,
user_id bigint,
action text,
created_at timestamptz NOT NULL,
primary key(id, user_id)
) PARTITION BY HASH (user_id);
user_id를 해시 조건으로 넣었다.
그럼 이번에도 Range와 마찬가지로 파티션 구체화를 해줘야 한다.
create table public.actions_odd partition of public.actions for values with(modulus 2, remainder 1);
create table public.actions_even partition of public.actions for values with(modulus 2, remainder 0);
여기서는 보통 나머지셈 연산 등을 사용해서 파티션 분배를 한다.
짝수와 홀수 파티션을 정의했다.
그리고 넣으면
insert into public.actions
values
(1, 100, 'eat', now())
, (2, 101, 'eat', now())
, (3, 102, 'run', now())
, (4, 102, 'fight', now())
, (5, 100, 'fly', now())
, (6, 101, 'fly', now())
, (7, 102, 'rust', now())
잘 들어가고
하나의 테이블처럼 조회도 잘 될 것이다.
하지만 이것도 마찬가지로 파티션 단위로 별도의 스캔이 발생한다.
특정 파티션만 효율적으로 스캔하고 싶다면, 항상 파티션 컬럼을 조건으로 넣어서 조회해야 한다.
비교 연산이나 IN 연산 등에는 파티션 선택이 제대로 동작하지 않을 확률이 높다.
그래서 항상 저 파티션 컬럼에 대한 동등 비교가 쿼리에 들어가야만 한다.
List 파티션
List 파티션은 한정된 값을 기반으로 분배하는 방식이다.
Hash 파티션이 여러개의 값을 버킷 단위로 적당히 고르게 분산한다면, 이건 예를 들어 "한국 유저"는 한국 파티션으로, "일본 유저"는 일본 파티션으로 넘기는 식으로 기능한다.
해시보다는 유연성이 떨어지지만 부하가 적고 높은 처리량과 강한 테넌트 분리가 필요하다면 적합하다.
사용법은 어렵지 않다.
다음은 국가 단위로 파티션되는 테이블을 만드는 쿼리다.
CREATE TABLE users (
id bigint,
country_code text NOT NULL,
name text
)
PARTITION BY LIST (country_code);

그러면 다음과 같이 값을 명시적으로 지정해서 파티션을 생성할 수 있다.

이후의 사용법은 Hash 등과 동등하다.
파티션 조건: Default 파티션
파티션에서 번거로운 것은, 아무튼간에 파티션을 만들어줘야 한다는 것이다.
파티션 조건에 없는 값이 들어오면 그건 항상 실패를 던진다.
이렇게 말이다.

그래서 때로는 DEFAULT 조건을 통해서 값을 쌓기도 한다.
CREATE TABLE public.events_default PARTITION OF public.events DEFAULT;
파티션 조건에 해당되지 않는 값이 있다면, 전부 저 DEFAULT 파티션으로 간다.
그러면 이제 9월 파티션이 없어도 삽입이 되긴 된다.

다만 여기에는 맹점이 있다. 파티션끼리는 값이 겹치는 것이 불가능하다는 것이다.
그래서 9월 데이터를 DEFAULT에 대충 쌓아놨다가, 나중에 9월 파티션을 만든다는 식의 게으른 방법은 단순하게는 통자히 않는다.
이렇게 실패한다.
그래서 DEFAULT를 버퍼처럼 쓰다가 옮기려면 좀 복잡한 수단이 필요하다.
직접 샤드 테이블을 생성한 뒤에 손으로 옮기고 수동 ATTACH를 해야하는데, 이것도 Lock을 걸고 비관적으로 처리를 해야해서 성능도 성능이고 실시간성에 큰 제약을 가한다.
그래서 가능하면 샤드를 미리 다 만들어주는 편이 좋다.
주의: 파티션 폭주의 위험
그런데 user가 10만명 있다고 해서, user_id 기반 샤드도 10만개를 두는 것은 효율적이지 못하다.
일반적으로는 10만개의 user가 있다면, 8, 16개 정도의 고정 샤드를 두고 적당히 분산하는 것을 추천한다.
파티션이 많다고 해서 좋은 것이 아니고, 파티션이 많을수록 파티션을 고르는 prune 성능(planning 과정)이 박살날 수 있기 때문이다. 메모리도 좀 먹을 수 있다.
응용: 복합 파티션 (user_id + date)
근데 단순하게 Hash, Range 저 2가지만으로는 부족할 수도 있다.
예를 들어, (user_id, created) 복합 조건으로 파티션을 걸고 싶다면 어떻게 해야할까? 저 2개 컬럼을 concat하더라도 그건 사전순으로 처리될 것이기 때문에, 생각처럼 동작할 확률은 낮을 것이다.
이런 상황에서는 파티션을 중첩해서 구성하는 것을 추천할만하다.
예를 들어, 이런 식이다.
events
├─ 2026-07
│ ├─ hash 0
│ ├─ hash 1
│ └─ ...
├─ 2026-08
│ ├─ hash 0
│ ├─ hash 1
│ └─ ...
└─ 2026-09
├─ hash 0
├─ hash 1
└─ ...
날짜로 먼저 범위 prune을 한 다음에, user_id로 Hash Prune해서 테이블을 선택하는 것이다.
CREATE TABLE login_history (
id bigint not null,
user_id bigint NOT NULL,
created_at timestamptz NOT null
)
PARTITION BY RANGE (created_at);
CREATE TABLE login_history_2026_08
PARTITION OF login_history
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
PARTITION BY HASH (user_id);

그러면 이렇게 날짜 파티션을 만든 다음에
CREATE TABLE login_history_2026_08_h0
PARTITION OF login_history_2026_08
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE login_history_2026_08_h1
PARTITION OF login_history_2026_08
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE login_history_2026_08_h2
PARTITION OF login_history_2026_08
FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE login_history_2026_08_h3
PARTITION OF login_history_2026_08
FOR VALUES WITH (MODULUS 4, REMAINDER 3);
유저 파티션-해시 링을 적당히 만들어서 쓰면 된다.
파티션 자동 생성: pg_partman
이건 날짜 기반 파티션을 자동으로 만들어주는 오피셜급 확장이다.
설치하고

다음과 같이 파티션 테이블에 함수를 날려주면 된다.
CREATE TABLE public.events (
id bigint,
created_at timestamptz NOT NULL,
data jsonb
) PARTITION BY RANGE (created_at);
SELECT create_partition(
p_parent_table := 'public.events',
p_control := 'created_at',
p_interval := '1 month',
p_premake := 7 -- 7월어치를 미리 만들기
);
그러면
파티션을 이렇게 잔뜩 만들어준다.
그 이후에는 전용 함수를 사용해서 파티션 생성을 트리거하면 된다.
현재 시간에 맞춰서 미리미리 만들어줄 것이다.
CALL run_maintenance_proc();

참조
https://www.postgresql.org/docs/current/ddl-partitioning.html
https://needjarvis.tistory.com/574
https://www.digitalocean.com/community/tutorials/understanding-database-sharding
https://github.com/pgpartman/pg_partman
https://docs.aws.amazon.com/ko_kr/AmazonRDS/latest/UserGuide/PostgreSQL_Partitions.html
https://github.com/pgpartman/pg_partman/blob/master-old/doc/pg_partman.md#maintenance-functions