← 목록으로 돌아가기

PostgreSQL 파티셔닝 운영 가이드: Partition Pruning·인덱스·보관주기까지 실전 설계

Database

PostgreSQL Partitioning Pruning Retention

큰 테이블은 어느 순간 운영 방식이 달라진다

PostgreSQL에서 테이블이 수천만 행을 넘어가면 단순 인덱스 추가만으로 해결되지 않는 문제가 생깁니다. 최근 7일 데이터만 자주 보는데도 인덱스와 통계는 전체 테이블 크기의 영향을 받고, 오래된 데이터를 삭제하려면 VACUUM이 길게 돌고, 월말 집계 배치가 실행 계획을 흔듭니다. 테이블 하나가 커졌을 뿐인데 배포, 백업, 통계 수집, 보관 정책이 모두 느려집니다.

파티셔닝은 이런 문제를 해결하는 강력한 도구입니다. 하지만 "큰 테이블은 파티션으로 나누면 빠르다"는 식으로 접근하면 실패합니다. 파티셔닝은 쿼리가 필요한 파티션만 읽도록 pruning이 잘 되는 경우에 효과가 있습니다. 조건절이 파티션 키를 타지 못하거나, prepared statement 때문에 pruning이 늦게 일어나거나, 각 파티션에 필요한 인덱스가 없다면 오히려 더 복잡하고 느린 구조가 됩니다.

이 글에서는 PostgreSQL range partitioning을 중심으로 실무 설계를 정리합니다. 파티션 키 선택, partition pruning, local index, 보관주기와 detach/drop, prepared statement 함정, 운영 모니터링까지 실제 운영에서 필요한 판단 기준을 다룹니다.


1. 파티셔닝의 목적을 먼저 정한다

파티셔닝은 성능 기능이면서 운영 기능입니다. 성능 관점에서는 쿼리가 읽어야 할 데이터 범위를 줄입니다. 운영 관점에서는 오래된 데이터를 빠르게 제거하고, 백업과 유지보수 단위를 작게 나눕니다. 두 목적 중 무엇이 우선인지에 따라 설계가 달라집니다.

로그, 이벤트, 주문 이력처럼 시간 흐름에 따라 쌓이는 데이터는 range partitioning이 자연스럽습니다. created_at 또는 occurred_at 기준으로 일/주/월 단위 파티션을 나눌 수 있습니다. 대부분의 조회가 최근 데이터에 몰리고, 오래된 데이터는 보관주기 이후 삭제하거나 아카이브하기 쉽습니다.

Tenant별 데이터 격리가 중요한 SaaS에서는 hash 또는 list partitioning을 고민할 수 있습니다. 하지만 tenant 수가 많고 분포가 치우쳐 있다면 list partition은 관리 부담이 큽니다. 특정 대형 tenant가 전체 데이터의 절반을 차지하면 hash partition도 균등하지 않을 수 있습니다. 파티션 키는 데이터 분포와 쿼리 패턴을 함께 보고 정해야 합니다.

가장 나쁜 파티션 키는 쿼리에서 거의 쓰지 않는 컬럼입니다. 테이블은 나뉘었지만 WHERE 조건이 파티션 키를 포함하지 않으면 PostgreSQL은 많은 파티션을 열어봐야 합니다. 파티셔닝은 물리 구조를 쿼리 패턴에 맞추는 작업입니다. "나누기 좋은 컬럼"이 아니라 "항상 거르는 컬럼"이 후보입니다.


2. Partition Pruning이 성능의 핵심이다

Partition pruning은 쿼리 실행 시 필요 없는 파티션을 제외하는 과정입니다. 예를 들어 월별 파티션이 있고 쿼리가 created_at >= '2026-04-01' AND created_at < '2026-05-01' 조건을 가진다면 4월 파티션만 읽으면 됩니다. 이때 planner가 다른 달 파티션을 제외할 수 있어야 파티셔닝의 효과가 납니다.

Pruning이 잘 되는 조건은 명확합니다. 파티션 키가 WHERE 절에 있고, 범위가 상수 또는 실행 시점에 확정 가능한 값이어야 합니다. 함수로 컬럼을 감싸면 pruning이 깨질 수 있습니다. date(created_at) = '2026-04-01'보다 created_at >= '2026-04-01' AND created_at < '2026-04-02'가 안전합니다. 컬럼에 함수를 적용하는 대신 상수 범위를 계산해야 합니다.

Prepared statement는 조심해야 합니다. PostgreSQL은 generic plan을 재사용하면서 파라미터 값을 모르는 상태로 계획을 세울 수 있습니다. 이 경우 실행 시점 pruning이 가능하더라도 기대만큼 파티션 제외가 되지 않거나, 계획이 보수적으로 잡힐 수 있습니다. ORM이 prepared statement를 기본으로 쓰는 경우, 파티션 테이블에서 실제 EXPLAIN (ANALYZE, BUFFERS)로 pruning 여부를 반드시 확인해야 합니다.

Pruning 확인은 추측으로 하면 안 됩니다. 실행 계획에 접근한 파티션이 몇 개인지, planning time이 과도하게 늘지 않는지, buffers가 특정 파티션에 집중되는지 봐야 합니다. 파티션 수가 많아지면 planning overhead도 커집니다. 일 단위 파티션을 5년치 유지하면 1,800개가 넘습니다. 너무 작은 파티션은 관리와 계획 비용을 키웁니다.


3. 파티션 단위 인덱스 전략

PostgreSQL 파티션 테이블의 인덱스는 각 파티션에 물리적으로 존재합니다. 부모 테이블에 인덱스를 선언하면 새 파티션에 인덱스가 만들어지도록 관리할 수 있지만, 실제 조회는 파티션별 인덱스를 탑니다. 따라서 모든 파티션에 같은 인덱스가 필요한지, 최근 파티션과 과거 파티션의 인덱스 구성이 달라도 되는지 판단해야 합니다.

최근 데이터는 다양한 조회가 많습니다. 사용자별 조회, 상태별 조회, 정렬, 페이지네이션을 위한 복합 인덱스가 필요할 수 있습니다. 반면 1년 전 데이터는 감사나 고객지원 조회만 필요하다면 인덱스를 줄여도 됩니다. 오래된 파티션의 불필요한 인덱스를 제거하면 저장 공간과 VACUUM 비용을 줄일 수 있습니다.

복합 인덱스에는 파티션 키를 항상 넣어야 하는 것은 아닙니다. 이미 pruning으로 특정 파티션만 선택된다면, 파티션 내부에서는 user_id, status, id 같은 쿼리 조건 중심 인덱스가 더 나을 수 있습니다. 반대로 여러 파티션을 범위로 읽는 쿼리라면 파티션 키와 정렬 컬럼 조합이 필요할 수 있습니다. 인덱스는 파티션 경계와 쿼리 내부 조건을 분리해서 봐야 합니다.

Unique 제약도 함정입니다. PostgreSQL에서 파티션 테이블의 unique constraint는 파티션 키를 포함해야 전체 파티션에서 유일성을 보장할 수 있습니다. order_id만 전역 유니크로 강제하고 싶은데 파티션 키가 created_at이면 제약 설계가 복잡해집니다. 이 경우 ID 생성 전략을 바꾸거나 별도 매핑 테이블을 두는 방식을 검토해야 합니다.


4. 보관주기: DELETE보다 DETACH와 DROP이 빠르다

파티셔닝의 가장 큰 운영 이점 중 하나는 오래된 데이터 삭제입니다. 거대한 테이블에서 DELETE WHERE created_at < ...를 실행하면 많은 row version이 생기고 VACUUM 비용이 커집니다. 반면 날짜 단위 파티션이면 오래된 파티션을 detach하거나 drop하는 방식으로 거의 즉시 제거할 수 있습니다.

실무에서는 보통 detach 후 archive, 검증 후 drop 순서를 씁니다. 먼저 오래된 파티션을 부모에서 분리합니다. 서비스 쿼리에서는 더 이상 보이지 않지만, 테이블 자체는 남아 있습니다. 이후 필요한 경우 object storage로 export하거나 별도 archive DB로 이동합니다. 검증이 끝나면 drop합니다. 이 절차는 실수로 데이터를 즉시 삭제하는 위험을 줄입니다.

보관주기 정책은 법무와 비즈니스 요구를 포함해야 합니다. 결제, 정산, 접속 로그, 개인정보 이벤트는 보존 기간이 다릅니다. 모두 같은 월별 파티션으로 묶으면 삭제 정책이 꼬일 수 있습니다. 데이터 성격별로 테이블을 분리하거나, 파티션 키와 retention policy를 명확히 문서화해야 합니다.

파티션 생성 자동화도 필요합니다. 새 달이 시작됐는데 다음 파티션이 없으면 insert가 실패합니다. 스케줄러로 미래 파티션을 미리 만들고, 생성 여부를 모니터링해야 합니다. 최소 몇 개 기간을 앞서 만들어둘지, 실패 시 알림을 어떻게 보낼지 정해야 합니다.


5. 통계와 VACUUM: 파티션별로 본다

파티션 테이블의 통계는 부모와 자식 파티션 관점이 모두 있습니다. 데이터 분포가 파티션마다 다르면 전체 통계보다 파티션별 통계가 더 중요합니다. 최근 파티션에는 특정 상태 값이 많고, 과거 파티션에는 완료 상태만 남아 있을 수 있습니다. ANALYZE가 제대로 돌지 않으면 planner는 잘못된 row estimate로 나쁜 조인 순서를 선택합니다.

Autovacuum 설정도 파티션별로 조정할 수 있습니다. 최근 파티션은 write가 많아 vacuum/analyze가 자주 필요합니다. 과거 immutable 파티션은 거의 변하지 않으므로 aggressive한 vacuum이 필요 없습니다. 모든 파티션을 같은 기준으로 관리하면 최근 데이터는 통계가 늦고, 과거 데이터는 불필요한 maintenance가 돌 수 있습니다.

Index bloat도 파티션 단위로 봐야 합니다. 큰 단일 테이블에서는 bloat 측정과 reindex가 부담스럽지만, 파티션 단위라면 영향 범위를 줄일 수 있습니다. 특정 월 파티션만 reindex concurrently하거나, archive 전에 인덱스를 제거하는 식의 운영이 가능합니다.


6. 마이그레이션 전략: 한 번에 옮기지 않는다

이미 큰 테이블을 파티션 테이블로 바꾸는 작업은 위험합니다. 가장 안전한 방식은 새 파티션 테이블을 만들고, dual write 또는 trigger 기반 동기화를 거쳐 점진적으로 읽기 경로를 전환하는 것입니다. 작은 서비스라면 maintenance window에 bulk copy 후 rename할 수도 있지만, 고트래픽 테이블에서는 락과 복제 지연을 조심해야 합니다.

Backfill은 배치 크기를 제한해야 합니다. 한 번에 수백만 행을 insert하면 WAL이 폭증하고 replica lag가 커집니다. 날짜 범위나 primary key 범위로 잘라 진행하고, 각 배치 후 지연과 DB 부하를 확인합니다. 인덱스를 먼저 만들지 나중에 만들지도 데이터 크기와 가용 시간에 따라 달라집니다.

전환 전에는 주요 쿼리 목록을 만들어야 합니다. API, 배치, 관리자 도구, 리포트 쿼리가 모두 파티션 키 조건을 포함하는지 점검합니다. 하나라도 전체 파티션 scan을 만들면 새 구조가 운영 장애를 만들 수 있습니다. 파티셔닝은 테이블만 바꾸는 작업이 아니라 쿼리 계약을 바꾸는 작업입니다.


실무 체크리스트

  • 파티션 키가 주요 조회의 WHERE 조건에 항상 들어가는가
  • 함수로 컬럼을 감싸 pruning을 방해하는 쿼리가 없는가
  • Prepared statement와 ORM 환경에서 실제 pruning을 검증했는가
  • 파티션 수가 planning overhead를 만들 정도로 과도하지 않은가
  • 최근 파티션과 과거 파티션의 인덱스 전략을 다르게 가져갈 수 있는가
  • Unique 제약이 파티션 키 요구사항과 충돌하지 않는가
  • 미래 파티션 자동 생성과 실패 알림이 있는가
  • 보관주기 만료 시 DELETE가 아니라 DETACH/DROP 절차를 쓰는가
  • 파티션별 ANALYZE, VACUUM, bloat 지표를 보고 있는가

결론: 파티셔닝은 성능보다 운영 계약이다

PostgreSQL 파티셔닝은 큰 테이블을 작게 나누는 기능처럼 보이지만, 실제로는 데이터 생명주기와 쿼리 계약을 명확히 하는 운영 설계입니다. 어떤 키로 나누고, 어떤 쿼리가 그 키를 사용하며, 오래된 데이터는 언제 어떻게 사라지고, 각 파티션은 어떤 인덱스를 갖는지 정해야 합니다.

Pruning이 잘 되면 파티셔닝은 놀라울 만큼 효과적입니다. 하지만 파티션 키를 타지 않는 쿼리, prepared statement 함정, 과도한 파티션 수, 무계획한 인덱스는 오히려 복잡도만 늘립니다. 큰 테이블이 무서운 이유는 행 수 자체보다 운영 단위가 너무 커지는 데 있습니다. 파티셔닝은 그 단위를 다시 사람이 다룰 수 있는 크기로 나누는 기술입니다.