- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 0. 이 글의 범위와 기준 버전
- 1. 파티셔닝이란?
- 2. Range 파티셔닝
- 3. List 파티셔닝
- 4. Hash 파티셔닝
- 5. Partition Pruning 확인
- 6. 파티션 자동 생성 (pg_partman)
- 7. 오래된 파티션 삭제
- 8. 운영 팁
- 파티션을 붙이고 떼는 DDL과 각각의 락
- 파티션 테이블에 인덱스를 무중단으로 만들기
- 기존 테이블을 파티션 테이블로 바꾸기
- 실패 사례와 진단 순서
- 언제 이렇게까지 하지 않나
- 참고 자료
- 9. 퀴즈
- 퀴즈

0. 이 글의 범위와 기준 버전
이 글이 다루는 것은 파티션을 만들고, 붙이고, 떼는 DDL과 그 DDL이 잡는 락 입니다. Range·List·Hash 중 무엇을 고를지, 파티션 키를 어떤 컬럼으로 잡을지 같은 전략 판단은 PostgreSQL 파티셔닝 완벽 가이드에서 따로 다룹니다. 아래 2~4절의 문법 예제는 각 전략이 실제로 어떤 DDL로 표현되는지 보여주기 위한 최소한의 분량이고, 이 글의 무게 중심은 8절 뒤에 이어지는 운영 DDL 쪽에 있습니다.
기준 엔진은 PostgreSQL 18 입니다. 이 글에 나오는 락 수준과 기본값은 모두 PostgreSQL 18 문서에서 확인한 값이며, 문서 주소는 마지막 참고 자료 절에 모아 두었습니다. 파티셔닝은 메이저 버전마다 동작이 바뀐 영역이라, 운영 중인 서버가 18이 아니라면 같은 문장이 그대로 성립한다고 가정하면 안 됩니다. 특히 DETACH PARTITION CONCURRENTLY 처럼 뒤늦게 들어온 구문은 사용 중인 버전의 문서에서 지원 여부를 먼저 확인하세요.
락 이야기를 앞에 꺼내는 이유는 단순합니다. 파티셔닝에서 사람을 다치게 하는 것은 문법이 아니라 락입니다. 문법은 틀리면 즉시 에러가 납니다. 락은 틀려도 에러가 나지 않습니다. 그냥 그 시간 동안 서비스가 멈출 뿐입니다.
1. 파티셔닝이란?
파티셔닝은 하나의 큰 테이블을 여러 개의 물리적 파티션으로 분할하는 기법입니다. 테이블의 데이터가 수억 건 이상일 때 쿼리 성능을 크게 향상시킬 수 있습니다.
파티셔닝의 장점
- 쿼리 성능 향상: Partition pruning으로 필요한 파티션만 스캔
- 대량 데이터 삭제: DROP PARTITION으로 즉시 삭제 (DELETE보다 수백 배 빠름)
- 병렬 처리: 파티션별 병렬 스캔 가능
- 관리 용이: 파티션별 인덱스, VACUUM, 백업 가능
2. Range 파티셔닝
가장 많이 사용되는 방식으로, 날짜/숫자 범위로 분할합니다.
-- 부모 테이블 생성
CREATE TABLE orders (
id BIGSERIAL,
customer_id INTEGER NOT NULL,
order_date DATE NOT NULL,
amount DECIMAL(10, 2),
status VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY RANGE (order_date);
-- 월별 파티션 생성
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 파티션별 인덱스 (자동 상속됨)
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status, order_date);
-- 기본 파티션 (범위에 맞지 않는 데이터 수용)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
3. List 파티셔닝
특정 값 목록으로 분할합니다. 지역, 카테고리 등에 적합합니다.
CREATE TABLE events (
id BIGSERIAL,
event_type VARCHAR(50) NOT NULL,
payload JSONB,
created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY LIST (event_type);
CREATE TABLE events_user PARTITION OF events
FOR VALUES IN ('user_signup', 'user_login', 'user_logout');
CREATE TABLE events_order PARTITION OF events
FOR VALUES IN ('order_created', 'order_paid', 'order_cancelled');
CREATE TABLE events_system PARTITION OF events
FOR VALUES IN ('health_check', 'deploy', 'config_change');
CREATE TABLE events_default PARTITION OF events DEFAULT;
4. Hash 파티셔닝
해시 함수로 균등 분배합니다. 특정 키의 분포가 고른 경우에 적합합니다.
CREATE TABLE user_sessions (
id BIGSERIAL,
user_id INTEGER NOT NULL,
session_id UUID NOT NULL,
data JSONB,
expires_at TIMESTAMP
) PARTITION BY HASH (user_id);
-- 4개 파티션으로 균등 분배
CREATE TABLE user_sessions_0 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_sessions_2 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_sessions_3 PARTITION OF user_sessions
FOR VALUES WITH (MODULUS 4, REMAINDER 3);
5. Partition Pruning 확인
-- Partition pruning 활성화 확인
SHOW enable_partition_pruning; -- on
-- EXPLAIN으로 pruning 확인
EXPLAIN (ANALYZE, COSTS, BUFFERS)
SELECT * FROM orders
WHERE order_date >= '2026-03-01'
AND order_date < '2026-04-01';
-- 결과 예시:
-- Append (actual rows=50000)
-- -> Seq Scan on orders_2026_03 (actual rows=50000)
-- Filter: (order_date >= '2026-03-01' AND order_date < '2026-04-01')
-- ✅ orders_2026_01, orders_2026_02는 스캔하지 않음!
6. 파티션 자동 생성 (pg_partman)
-- pg_partman 설치
CREATE EXTENSION pg_partman;
-- 자동 파티션 관리 설정
SELECT partman.create_parent(
p_parent_table => 'public.orders',
p_control => 'order_date',
p_type => 'native',
p_interval => 'monthly',
p_premake => 3 -- 3개월 미리 생성
);
-- 유지보수 함수 (cron으로 매일 실행)
SELECT partman.run_maintenance();
cron 설정
# pg_partman 유지보수 (매일 새벽 2시)
0 2 * * * psql -U postgres -d mydb \
-c "SELECT partman.run_maintenance();" \
>> /var/log/pg_partman.log 2>&1
수동 자동화 스크립트
-- pg_partman 없이 직접 자동화
CREATE OR REPLACE FUNCTION create_monthly_partition(
p_table TEXT,
p_year INTEGER,
p_month INTEGER
) RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_name := format('%s_%s_%s',
p_table,
p_year,
LPAD(p_month::TEXT, 2, '0')
);
start_date := make_date(p_year, p_month, 1);
end_date := start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I
FOR VALUES FROM (%L) TO (%L)',
partition_name, p_table, start_date, end_date
);
RAISE NOTICE 'Created partition: %', partition_name;
END;
$$ LANGUAGE plpgsql;
-- 사용 예
SELECT create_monthly_partition('orders', 2026, 4);
SELECT create_monthly_partition('orders', 2026, 5);
7. 오래된 파티션 삭제
-- 파티션 분리 (데이터 보존, 쿼리에서 제외)
ALTER TABLE orders DETACH PARTITION orders_2025_01;
-- 분리된 파티션을 별도 테이블로 유지하거나 삭제
DROP TABLE orders_2025_01; -- 즉시 삭제 (수억 건도 순간)
-- 대비: DELETE는 매우 느림
-- DELETE FROM orders WHERE order_date < '2025-02-01'; ← 이러지 마세요!
8. 운영 팁
파티션 상태 모니터링
-- 파티션별 행 수 확인
SELECT
schemaname || '.' || relname AS partition,
n_live_tup AS row_count,
pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE relname LIKE 'orders_%'
ORDER BY relname;
-- 파티션 목록 조회
SELECT
parent.relname AS parent,
child.relname AS partition,
pg_get_expr(child.relpartbound, child.oid) AS bounds
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;
주의사항
1. PRIMARY KEY에 파티션 키 포함 필수
CREATE TABLE orders (...) PARTITION BY RANGE (order_date);
→ PK는 (id, order_date) 형태여야 함
2. UNIQUE 제약조건도 파티션 키 포함 필요
3. 파티션 수가 너무 많으면 플래닝 오버헤드 증가
→ 1000개 이하 유지 권장
4. 크로스 파티션 UPDATE는 PostgreSQL 11+에서만 지원
파티션을 붙이고 떼는 DDL과 각각의 락
운영에서 실제로 쓰는 파티션 DDL은 네 개뿐입니다. 문제는 네 개가 서로 다른 락을 잡는다는 점입니다.
| DDL | 부모 테이블에 잡는 락 | 대상 파티션에 잡는 락 |
|---|---|---|
CREATE TABLE ... PARTITION OF | ACCESS EXCLUSIVE | 새로 만드는 빈 테이블 |
DROP TABLE (파티션) | ACCESS EXCLUSIVE | ACCESS EXCLUSIVE |
ALTER TABLE ... ATTACH PARTITION | SHARE UPDATE EXCLUSIVE | ACCESS EXCLUSIVE |
ALTER TABLE ... DETACH PARTITION CONCURRENTLY | SHARE UPDATE EXCLUSIVE | ACCESS EXCLUSIVE (2단계에서) |
PostgreSQL 문서는 이 차이를 아주 직접적으로 적어 두었습니다. PARTITION OF 로 파티션을 만들면 부모 파티션 테이블에 ACCESS EXCLUSIVE 락이 필요하고 DROP TABLE 로 파티션을 지울 때도 마찬가지이며, 같은 일을 ATTACH 와 DETACH 로 하면 더 약한 락으로 끝나 동시 작업과 덜 부딪힌다는 것입니다.
ACCESS EXCLUSIVE는 가장 강한 락입니다. 그 테이블을 읽기만 하는 SELECT 조차 대기합니다. 즉 CREATE TABLE orders_2026_09 PARTITION OF orders ... 한 줄은, 실행되는 순간 orders 를 건드리는 모든 세션을 세웁니다. 빈 테이블을 만드는 일이라 작업 자체는 밀리초 단위로 끝나지만, 락을 얻기까지 기다리는 시간은 밀리초가 아닙니다. 앞선 긴 트랜잭션 하나가 orders 에 약한 락이라도 걸고 있으면 우리 DDL은 그 뒤에 줄을 서고, ACCESS EXCLUSIVE 요청이 큐 앞에 서는 순간 그 뒤로 들어오는 모든 SELECT 까지 함께 막힙니다. "파티션 하나 추가했을 뿐인데 장애가 났다"의 정확한 메커니즘이 이것입니다.
안전한 순서: 따로 만들고, 검증하고, 붙인다
-- 1) 부모와 무관한 독립 테이블로 만든다. 부모에는 아무 락도 걸리지 않는다.
CREATE TABLE orders_2026_09 (
LIKE orders INCLUDING DEFAULTS INCLUDING STORAGE
);
-- 2) 필요하면 여기서 미리 데이터를 적재한다.
-- 아직 부모와 아무 관계가 없으므로 얼마나 오래 걸리든 서비스에 영향이 없다.
-- 3) 파티션 경계와 똑같은 CHECK 제약을 NOT VALID로 붙인다.
-- NOT VALID이므로 이 시점에는 기존 행을 스캔하지 않는다.
ALTER TABLE orders_2026_09
ADD CONSTRAINT orders_2026_09_bound
CHECK (order_date >= DATE '2026-09-01' AND order_date < DATE '2026-10-01')
NOT VALID;
-- 4) 검증만 따로 돌린다. 이 단계는 SHARE UPDATE EXCLUSIVE만 잡으므로
-- 동시 INSERT/UPDATE를 막지 않는다.
ALTER TABLE orders_2026_09 VALIDATE CONSTRAINT orders_2026_09_bound;
-- 5) 붙인다. 유효한 CHECK가 이미 있으므로 풀스캔이 생략된다.
ALTER TABLE orders ATTACH PARTITION orders_2026_09
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
-- 6) 이제 중복이 된 CHECK 제약을 떼어낸다.
ALTER TABLE orders_2026_09 DROP CONSTRAINT orders_2026_09_bound;
각 단계가 왜 필요한지는 문서에 그대로 적혀 있습니다.
ATTACH PARTITION 은 붙이려는 테이블이 파티션 경계를 위반하는 행을 갖고 있지 않은지 확인하려고 풀스캔을 합니다. 그리고 그 스캔은 대상 파티션에 ACCESS EXCLUSIVE 락을 잡은 채로 이뤄집니다. 문서는 이 스캔을 피하는 방법으로 "원하는 파티션 제약을 만족하는 행만 허용하는 유효한 CHECK 제약을 미리 붙여 두라"고 명시합니다. 3~4단계가 그것입니다.
그리고 VALIDATE CONSTRAINT 는 이미 제약이 걸린 상태에서 기존 행만 검사하면 되므로, 동시 갱신을 막을 필요가 없습니다. 문서 표현으로는 "검증은 대상 테이블에 SHARE UPDATE EXCLUSIVE 락만 획득한다"입니다. 즉 오래 걸리는 스캔은 약한 락에서 처리하고, 강한 락이 필요한 ATTACH 는 스캔 없이 순식간에 끝나도록 순서를 바꾼 것이 이 레시피의 전부입니다.
DEFAULT 파티션이 있으면 이야기가 하나 더 붙는다
문서는 DEFAULT 파티션이 있는 경우를 따로 경고합니다. 새 파티션을 붙일 때, DEFAULT 파티션 안에 "원래는 새 파티션에 들어갔어야 할 행"이 없는지 확인해야 하고, 그 확인은 DEFAULT 파티션에 ACCESS EXCLUSIVE 락을 잡은 채로 이뤄집니다. DEFAULT 파티션이 커져 있다면 이 스캔 하나 때문에 붙이기 작업이 수십 분을 잡아먹고, 그동안 DEFAULT 파티션은 읽을 수도 없습니다.
피하는 방법은 같습니다. DEFAULT 파티션에 "새로 붙일 범위를 배제하는" CHECK 제약을 미리 걸어 두면 됩니다.
ALTER TABLE orders_default
ADD CONSTRAINT orders_default_excl_2026_09
CHECK (order_date < DATE '2026-09-01' OR order_date >= DATE '2026-10-01')
NOT VALID;
ALTER TABLE orders_default VALIDATE CONSTRAINT orders_default_excl_2026_09;
-- 이제 ATTACH가 DEFAULT 파티션을 스캔하지 않는다.
DEFAULT 파티션을 아예 두지 않는 선택지도 있습니다. 범위 밖 INSERT가 에러로 즉시 드러나는 편이, 조용히 DEFAULT에 쌓였다가 몇 달 뒤 파티션 붙이기를 막는 것보다 낫다고 판단하는 팀이 많습니다. 어느 쪽이든 트레이드오프이지 정답은 아닙니다. DEFAULT 파티션은 안전망이면서 동시에 나중에 청구서가 날아오는 부채입니다.
DETACH는 테이블을 지우지 않는다
DETACH PARTITION 은 부모와의 연결만 끊습니다. 테이블 자체는 그대로 남아 디스크를 계속 차지합니다. 이걸 모르면 "오래된 파티션을 정리했는데 디스크가 안 줄었다"가 됩니다.
-- 동시 접근을 막지 않는 방식으로 떼어낸다
ALTER TABLE orders DETACH PARTITION orders_2025_01 CONCURRENTLY;
-- 확인: 더 이상 부모의 자식이 아니지만 테이블은 살아 있다
SELECT c.relname,
pg_size_pretty(pg_total_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relname = 'orders_2025_01';
-- 정말 지울 거라면 여기서 명시적으로 지운다
DROP TABLE orders_2025_01;
CONCURRENTLY 를 붙이면 PostgreSQL이 내부적으로 트랜잭션을 두 개로 쪼갭니다. 첫 트랜잭션에서 부모와 파티션 양쪽에 SHARE UPDATE EXCLUSIVE 락을 잡고 "떼는 중" 표시를 한 뒤 커밋하고, 그 파티션 테이블을 쓰던 기존 트랜잭션들이 모두 끝나기를 기다립니다. 그다음 두 번째 트랜잭션이 부모에는 SHARE UPDATE EXCLUSIVE를, 떼어낼 파티션에는 ACCESS EXCLUSIVE를 잡고 분리를 마무리합니다. 부모 테이블에 강한 락이 한 번도 걸리지 않는다는 것이 요점입니다.
대가도 있습니다. 내부적으로 두 번 커밋하므로 이 구문은 트랜잭션 블록 안에서 쓸 수 없습니다. 마이그레이션 도구가 모든 DDL을 하나의 트랜잭션으로 감싸는 방식이라면 그대로는 못 씁니다. 해당 마이그레이션만 트랜잭션 밖으로 빼는 설정이 필요하고, 그 방법은 도구마다 다릅니다.
파티션 테이블에 인덱스를 무중단으로 만들기
일반 테이블이라면 답은 간단합니다. 문서 표현대로 일반 CREATE INDEX 는 인덱스가 끝날 때까지 그 테이블의 쓰기를 막고(읽기는 막지 않습니다), CONCURRENTLY 는 동시 INSERT·UPDATE·DELETE를 막는 락을 전혀 잡지 않는 대신 테이블을 두 번 스캔하고 관련 트랜잭션이 끝나기를 기다립니다.
문제는 파티션 테이블에 CONCURRENTLY 를 쓸 수 없다는 점입니다. 문서가 "파티션 테이블의 인덱스는 동시 빌드를 지원하지 않는다"고 명시적으로 적어 둔 제약입니다. 그래서 파티션 부모에 그냥 CREATE INDEX 를 치면 모든 파티션에 인덱스가 다 만들어질 때까지 그 테이블 전체의 쓰기가 막힙니다. 파티션이 36개면 36개가 다 끝날 때까지입니다.
문서가 권하는 우회로는 세 단계입니다.
-- 1) 부모에만 인덱스 정의를 만든다. ONLY가 핵심.
-- 이 시점에 부모 인덱스는 invalid 상태이고 실제 데이터는 건드리지 않는다.
CREATE INDEX idx_orders_customer ON ONLY orders (customer_id);
-- 2) 파티션마다 CONCURRENTLY로 따로 만든다. 여기서는 쓰기가 막히지 않는다.
CREATE INDEX CONCURRENTLY idx_orders_2026_09_customer
ON orders_2026_09 (customer_id);
CREATE INDEX CONCURRENTLY idx_orders_2026_10_customer
ON orders_2026_10 (customer_id);
-- 3) 만든 인덱스를 부모 인덱스에 붙인다.
ALTER INDEX idx_orders_customer
ATTACH PARTITION idx_orders_2026_09_customer;
ALTER INDEX idx_orders_customer
ATTACH PARTITION idx_orders_2026_10_customer;
모든 파티션의 인덱스가 붙는 순간 부모 인덱스는 자동으로 valid로 바뀝니다. 하나라도 빠뜨리면 부모 인덱스는 계속 invalid로 남고, invalid 인덱스는 조회에 쓰이지 않으면서 갱신 비용만 계속 냅니다. 최악의 조합입니다.
그래서 이 작업 뒤에는 반드시 확인 쿼리를 돌려야 합니다.
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;
카탈로그 문서에 따르면 indisvalid 가 false라는 것은 "인덱스가 불완전할 수 있어 조회에 안전하게 쓸 수 없지만 INSERT·UPDATE는 여전히 이 인덱스를 갱신해야 한다"는 뜻입니다. 아무 행도 나오지 않으면 정상입니다. 행이 나온다면 원인은 둘 중 하나입니다. 3단계의 ATTACH 를 빠뜨렸거나, CREATE INDEX CONCURRENTLY 가 도중에 실패한 것입니다.
후자는 흔합니다. 문서는 동시 빌드가 교착이나 유니크 위반으로 실패하면 "invalid 인덱스를 남긴 채 명령이 실패한다"고 적고 있고, 권장 복구 방법으로 그 인덱스를 DROP 하고 CREATE INDEX CONCURRENTLY 를 다시 실행하거나 REINDEX INDEX CONCURRENTLY 로 재구축하는 것을 듭니다. 실패한 채 방치된 invalid 인덱스는 조용히 쓰기 비용만 먹기 때문에, 위 쿼리는 인덱스 작업 직후뿐 아니라 주기적으로도 돌릴 가치가 있습니다.
기존 테이블을 파티션 테이블로 바꾸기
이미 데이터가 들어 있는 일반 테이블을 파티션 테이블로 "바꾸는" 명령은 없습니다. 새 파티션 테이블을 만들고 기존 테이블을 그 안으로 옮기는 수밖에 없습니다. 실무에서 쓰는 방법은 두 가지입니다.
방법 A는 기존 테이블을 통째로 첫 파티션으로 붙이는 것 입니다. 데이터를 한 줄도 복사하지 않으므로 압도적으로 빠릅니다. 대신 기존 테이블의 모든 행이 하나의 파티션 경계 안에 들어가야 합니다. 과거 데이터를 통으로 "2026-09-01 이전"이라는 한 파티션에 몰아넣고, 그 뒤부터 월별로 쪼개는 식이면 이 조건을 만족합니다.
-- 1) 새 파티션 부모를 만든다. 파티션 키가 PK에 포함되어야 하므로
-- 기존 PK를 그대로 복사할 수 없는 경우가 많다. 그래서 INCLUDING ALL은 피한다.
CREATE TABLE orders_new (
LIKE orders INCLUDING DEFAULTS INCLUDING STORAGE
) PARTITION BY RANGE (order_date);
-- 2) 기존 테이블에 경계와 같은 CHECK를 붙이고 따로 검증한다 (앞의 레시피와 동일)
ALTER TABLE orders
ADD CONSTRAINT orders_bound CHECK (order_date < DATE '2026-09-01') NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_bound;
-- 3) 기존 테이블을 첫 파티션으로 붙인다. CHECK 덕분에 스캔은 생략된다.
ALTER TABLE orders_new ATTACH PARTITION orders
FOR VALUES FROM (MINVALUE) TO ('2026-09-01');
-- 4) 앞으로 들어올 데이터를 받을 파티션을 만든다
CREATE TABLE orders_2026_09 PARTITION OF orders_new
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
-- 5) 이름을 바꾼다. 이 단계만 짧게 ACCESS EXCLUSIVE가 필요하다.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders RENAME TO orders_legacy;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;
3단계에서 MINVALUE 를 쓴 이유는 문서가 설명하는 대로입니다. MINVALUE 와 MAXVALUE 는 실제 저장되는 값이 아니라 "아래쪽/위쪽 경계가 없음"을 나타내는 표현입니다. 또한 RANGE 파티션에서 FROM 은 포함, TO 는 미포함입니다. TO ('2026-09-01') 은 9월 1일 0시를 포함하지 않으므로, 2단계의 CHECK 조건과 정확히 같은 경계가 됩니다. 이 부등호를 헷갈리면 ATTACH가 실패하거나 경계에 걸친 하루치가 사라집니다.
5단계의 lock_timeout 이 이 레시피에서 가장 중요한 한 줄입니다. 문서에 따르면 기본값은 0이고, 이는 타임아웃 없음, 즉 무제한 대기를 뜻합니다. 이름 바꾸기 자체는 즉시 끝나지만 ACCESS EXCLUSIVE 락을 얻지 못하면 무한정 기다리고, 그 대기가 뒤따르는 모든 쿼리를 함께 막습니다. lock_timeout 을 짧게 걸어 두면 락을 못 얻었을 때 서비스가 아니라 우리 DDL이 먼저 죽습니다. 실패하고 몇 분 뒤 다시 시도하는 편이, 성공할 때까지 서비스를 세우는 것보다 낫습니다.
방법 B는 새 파티션 테이블로 배치 복사한 뒤 전환하는 것 입니다. 기존 행이 여러 파티션으로 흩어져야 하거나 스키마도 함께 바꿔야 할 때 씁니다. 과거 데이터를 기간별로 나눠 INSERT ... SELECT 로 옮기고, 그동안 들어오는 변경분은 트리거나 논리 복제로 따라잡은 뒤, 마지막에 짧은 락으로 이름을 바꿉니다. 방법 A보다 훨씬 오래 걸리고 따라잡기 로직이 필요하지만, 경계 조건이 자유롭습니다.
어느 쪽이든 리허설 없이 프로덕션에서 처음 실행하지 마세요. 특히 방법 A는 3단계까지 잘 가다가 5단계에서 락을 못 얻어 멈추는 일이 잦습니다. 그때 이미 orders 는 orders_new 의 파티션이 되어 있다는 점을 기억해야 합니다. 롤백 계획은 "3단계를 DETACH 로 되돌린다"까지 미리 적어 두어야 합니다.
실패 사례와 진단 순서
증상: 파티션을 하나 추가했을 뿐인데 서비스 전체가 멈췄다
락 대기입니다. 순서대로 확인합니다.
-- 1) 지금 락을 기다리는 세션이 몇 개인가
SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock';
-- 2) 무엇을 기다리고, 누가 막고 있는가
SELECT pid,
now() - query_start AS waiting_for,
left(query, 60) AS query,
pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY query_start;
예시 출력
pid | waiting_for | query | blocked_by
-------+-------------+--------------------------------------+------------
24815 | 00:04:12 | CREATE TABLE orders_2026_09 PARTITI | {24102}
24903 | 00:04:07 | SELECT id, amount FROM orders WHERE | {24815}
24911 | 00:04:06 | SELECT count(*) FROM orders WHERE o | {24815}
읽는 법이 중요합니다. pg_blocking_pids 는 문서 설명대로 "지정한 프로세스가 락을 얻지 못하도록 막고 있는 세션들의 PID 배열"을 돌려줍니다. 위 출력에서 두 SELECT 는 DDL(24815)에 막혀 있고, 그 DDL은 다시 24102에 막혀 있습니다. 즉 진짜 원인은 DDL이 아니라 24102이 붙잡고 있는 오래된 트랜잭션 이고, DDL은 그 뒤에서 ACCESS EXCLUSIVE를 요청한 채 뒤따르는 읽기 전부를 함께 세워 놓은 것입니다.
그래서 대응 순서도 정해집니다. DDL을 죽여도 24102이 살아 있으면 다음 시도에서 똑같은 일이 반복됩니다. 앞의 오래된 트랜잭션을 먼저 처리하고, 다음번에는 lock_timeout 을 걸고 CREATE TABLE ... PARTITION OF 대신 CREATE → CHECK → VALIDATE → ATTACH 순서로 바꿉니다.
증상: ATTACH PARTITION이 30분째 안 끝난다
거의 항상 스캔입니다. 확인 순서는 둘입니다.
- 붙이려는 테이블에 파티션 경계와 일치하는 유효한 CHECK 제약이 있는가.
\d+ orders_2026_09로 제약이 보이는지, 그리고NOT VALID표시가 남아 있지 않은지 봅니다.NOT VALID상태의 제약은 ATTACH가 스캔 생략 근거로 쓰지 않습니다.VALIDATE CONSTRAINT를 빠뜨린 경우가 가장 흔합니다. - DEFAULT 파티션이 있는가. 있다면 지금 스캔되고 있는 것은 붙이려는 테이블이 아니라 DEFAULT 파티션일 수 있습니다. 앞 절의 배제 CHECK를 걸어야 합니다.
증상: 인덱스를 하나 추가했는데 쓰기가 전부 막혔다
파티션 부모에 CONCURRENTLY 없이 CREATE INDEX 를 실행한 경우입니다. 파티션 테이블에는 CONCURRENTLY 를 못 쓰기 때문에 무심코 이렇게 됩니다. 취소하고 앞의 ON ONLY 3단계 레시피로 다시 하되, 취소한 뒤에는 반드시 invalid 인덱스가 남지 않았는지 pg_index 조회로 확인하세요.
증상: 파티션은 잘 만들어지는데 실행 계획이 이상하다
파티션 부모의 통계가 없을 가능성이 큽니다. 문서는 이렇게 적습니다. 파티션 테이블은 튜플을 직접 저장하지 않으므로 autovacuum이 처리하지 않고, 따라서 autovacuum이 파티션 테이블에 ANALYZE 를 실행하지 않으며, 이 때문에 파티션 테이블의 통계를 참조하는 쿼리가 나쁜 계획을 받을 수 있다는 것입니다. 개별 파티션은 일반 테이블처럼 autovacuum이 처리하지만, 부모는 아무도 돌봐주지 않습니다.
해결도 문서에 그대로 있습니다. 파티션 테이블에 처음 데이터가 채워졌을 때, 그리고 파티션 간 데이터 분포가 크게 바뀔 때마다 수동으로 ANALYZE 를 돌리라는 것입니다.
-- 부모에 대해 명시적으로 실행한다
ANALYZE orders;
파티션을 매달 추가하는 시스템이라면 파티션 생성 스크립트 끝에 이 한 줄을 붙여 두는 것이 가장 확실합니다.
증상: 파티션을 지웠는데 디스크가 안 줄었다
DETACH 만 하고 DROP TABLE 을 안 한 경우입니다. 떨어져 나온 테이블 목록은 이렇게 찾습니다.
-- 부모에 붙어 있는 파티션 목록
SELECT c.relname
FROM pg_inherits i
JOIN pg_class p ON p.oid = i.inhparent
JOIN pg_class c ON c.oid = i.inhrelid
WHERE p.relname = 'orders';
-- 이름 규칙은 orders_로 시작하는데 위 목록에 없는 테이블이
-- 예전에 DETACH된 뒤 잊힌 것들이다
언제 이렇게까지 하지 않나
여기까지 읽고 나면 파티션 운영이 꽤 손이 많이 가는 일이라는 게 보일 겁니다. 실제로 그렇고, 그래서 안 하는 편이 맞는 경우도 많습니다.
테이블이 작으면 파티셔닝은 순손해입니다. 플래너가 파티션마다 판단을 해야 하니 계획 비용이 늘고, 매달 파티션을 만들고 지우는 운영 부담이 생기며, 얻는 것은 없습니다. 문서도 "파티션이 많은 쪽이 항상 낫다거나 항상 나쁘다고 가정하지 말라"고 못 박습니다.
쿼리의 WHERE 절에 파티션 키가 들어오지 않는 워크로드라면 파티셔닝은 손해에 가깝습니다. 프루닝이 걸리지 않으면 하나의 큰 테이블을 스캔하던 것이 N개의 작은 테이블을 스캔하는 것으로 바뀔 뿐이고, 여기에 계획 비용이 추가됩니다. 파티션 키를 고르는 문제는 결국 "우리 쿼리가 무엇으로 필터하는가"의 문제이고, 그건 전략 편의 주제입니다.
보관 정책이 없는 테이블, 즉 데이터를 절대 지우지 않는 테이블이라면 파티셔닝의 가장 큰 이득인 DROP TABLE 을 영영 쓰지 않습니다. 이 경우 파티셔닝의 값어치는 프루닝 하나로 줄어들고, 그건 대개 인덱스로도 얻을 수 있습니다.
마지막으로 정직하게 말하면, 파티셔닝을 검토하기 전에 인덱스와 쿼리를 먼저 봐야 합니다. 파티셔닝은 스키마를 되돌리기 어려운 방향으로 바꾸는 결정입니다. 인덱스는 잘못 만들면 지우면 그만이지만, 파티션 테이블을 되돌리려면 이 글의 마이그레이션을 반대 방향으로 한 번 더 해야 합니다.
참고 자료
- PostgreSQL 18 — ALTER TABLE — ATTACH/DETACH 락 수준, DETACH CONCURRENTLY의 2단계 동작, VALIDATE CONSTRAINT의 SHARE UPDATE EXCLUSIVE. 2026-08-16 확인
- PostgreSQL 18 — CREATE TABLE — PARTITION OF와 DROP TABLE의 ACCESS EXCLUSIVE, RANGE 경계의 포함/미포함, MINVALUE와 MAXVALUE, DEFAULT 파티션. 2026-08-16 확인
- PostgreSQL 18 — Table Partitioning — ON ONLY와 ALTER INDEX ATTACH PARTITION 우회로, DEFAULT 파티션 스캔 경고, 파티션 개수 지침. 2026-08-16 확인
- PostgreSQL 18 — CREATE INDEX — CONCURRENTLY의 락 동작, 파티션 테이블 미지원, 실패 시 invalid 인덱스와 복구 방법. 2026-08-16 확인
- PostgreSQL 18 — Routine Vacuuming — 파티션 테이블이 autovacuum 대상이 아니라는 점과 수동 ANALYZE 권장. 2026-08-16 확인
- PostgreSQL 18 — Client Connection Defaults — lock_timeout의 정의와 기본값 0. 2026-08-16 확인
- PostgreSQL 18 — pg_index — indisvalid, indisready의 의미. 2026-08-16 확인
- PostgreSQL 18 — System Information Functions — pg_blocking_pids의 동작. 2026-08-16 확인
- pg_partman 문서 — create_parent 시그니처와 5.0 이후 변경. 2026-08-16 확인
이 글 6절의 pg_partman 예제는 p_type => 'native' 를 쓰고 있는데, pg_partman 5.0 문서 기준으로 p_type 이 받는 값은 range 와 list 이며 기본값은 range 입니다. 5.x에서는 트리거 기반 파티셔닝이 제거되고 모든 파티셔닝이 선언적 파티셔닝으로 처리되도록 바뀌었습니다. p_premake 의 기본값도 문서 기준 4입니다. 설치된 확장의 버전을 먼저 확인하고, 그 버전의 문서에 맞춰 인자를 쓰세요.
9. 퀴즈
Q1: Range 파티셔닝에서 DEFAULT 파티션의 역할은?
DEFAULT 파티션은 어떤 파티션의 범위에도 속하지 않는 데이터를 수용합니다. 예를 들어 2026년 파티션만 있는데 2027년 데이터가 INSERT되면 DEFAULT 파티션에 저장됩니다. DEFAULT 파티션이 없으면 범위 밖 데이터 INSERT 시 에러가 발생합니다.
Q2: DROP PARTITION이 DELETE보다 빠른 이유는?
DELETE는 각 행을 하나씩 삭제하면서 WAL 로그를 기록하고, dead tuple이 남아 VACUUM이 필요합니다. 반면 DROP TABLE (파티션 삭제)은 테이블의 데이터 파일 자체를 즉시 삭제하므로, 수억 건의 데이터도 순식간에 제거됩니다. 행 수에 관계없이 거의 일정한 시간이 소요됩니다.
Q3: PRIMARY KEY에 파티션 키를 포함해야 하는 이유는?
PostgreSQL은 파티션 테이블의 유니크 제약조건을 각 파티션의 로컬 인덱스로 구현합니다. 파티션 키가 PK에 포함되지 않으면, 다른 파티션에 같은 id가 존재할 수 있어 전체 테이블 수준의 유니크성을 보장할 수 없습니다. 따라서 (id, order_date) 처럼 파티션 키를 PK에 포함해야 합니다.
퀴즈
Q1: 트래픽이 있는 시간에
이 구문은 부모 파티션 테이블에 ACCESS EXCLUSIVE 락을 잡습니다. 가장 강한 락이라 그 테이블을 읽기만 하는 SELECT까지 대기하고, 앞선 트랜잭션 때문에 락을 못 얻으면 그 대기가 뒤따르는 모든 쿼리를 함께 막습니다. 대신 독립 테이블로 만든 뒤 ATTACH PARTITION으로 붙이면 부모에는 SHARE UPDATE EXCLUSIVE만 걸립니다.CREATE TABLE ... PARTITION OF 를 실행하면 안 되는 이유는?
Q2: ATTACH PARTITION의 풀스캔을 생략시키는 방법은?
붙이려는 테이블에 파티션 경계와 같은 조건의 CHECK 제약을 미리 만들어 두면 됩니다. NOT VALID로 붙인 뒤 VALIDATE CONSTRAINT로 따로 검증하면, 오래 걸리는 스캔은 SHARE UPDATE EXCLUSIVE 락에서 처리되고 ATTACH 자체는 스캔 없이 끝납니다. DEFAULT 파티션이 있다면 그쪽에도 새 범위를 배제하는 CHECK가 필요합니다.
Q3: 파티션 테이블에 CREATE INDEX CONCURRENTLY를 쓸 수 없을 때의 대안은?
부모에 CREATE INDEX ON ONLY로 정의만 만들고, 파티션마다 CREATE INDEX CONCURRENTLY로 개별 생성한 뒤, ALTER INDEX ... ATTACH PARTITION으로 붙입니다. 모든 파티션이 붙으면 부모 인덱스가 자동으로 valid가 됩니다. 하나라도 빠지면 invalid로 남아 조회에는 안 쓰이면서 갱신 비용만 냅니다.
Q4: 파티션 테이블의 실행 계획이 이상할 때 가장 먼저 의심할 것은?
부모 테이블의 통계입니다. 파티션 테이블은 튜플을 직접 저장하지 않아 autovacuum이 처리하지 않고, 따라서 부모에 ANALYZE가 자동으로 실행되지 않습니다. 데이터를 처음 채웠을 때와 파티션 간 분포가 크게 바뀔 때 수동으로 ANALYZE를 돌려야 합니다.
Q5: DETACH PARTITION 후에도 디스크 사용량이 줄지 않는 이유는?
DETACH는 부모와의 연결만 끊고 테이블 자체는 남겨 둡니다. 실제로 공간을 회수하려면 분리한 뒤 DROP TABLE을 명시적으로 실행해야 합니다. 아카이브 목적이라면 남겨 두는 것이 의도된 동작입니다.