PostgreSQL 14+에서 선언적 테이블 파티셔닝 이해 및 구현

PostgreSQL 14+ 버전의 네이티브 선언적 파티셔닝 기능을 살펴봅니다. 이 가이드에서는 범위, 목록, 해시 파티셔닝 유형을 자세히 설명하고, 파티션 테이블을 생성하고 관리하기 위한 실용적인 SQL 예제를 제공합니다. 파티션 프루닝과 효율적인 유지 관리 전략을 활용하여 쿼리 성능을 최적화하고 대용량 데이터 세트의 데이터 관리를 간소화하는 방법을 알아보세요.

PostgreSQL 14+에서 선언적 테이블 파티셔닝 이해 및 구현

PostgreSQL 파티셔닝은 하나의 테이블이 쿼리, 진공, 아카이브 또는 삭제가 어려워졌을 때 고려할 가치가 있습니다. 일반적인 예는 하루에 수백만 행을 수신하고 거의 항상 시간 범위로 쿼리되는 이벤트 테이블입니다. 파티셔닝이 없으면 좋은 인덱스조차도 유지 관리 비용이 많이 들고 오래된 데이터를 처리하기 어려운 테이블로 남을 수 있습니다.

선언적 파티셔닝을 사용하면 하나의 논리적 테이블이 행을 파티션이라는 더 작은 물리적 테이블로 라우팅할 수 있습니다. PostgreSQL 10은 네이티브 구문을 도입했으며, 이후 버전에서는 계획, 프루닝, 인덱싱 및 유지 관리 동작이 개선되었습니다. PostgreSQL 14+는 많은 팀이 트리거 기반 상속 체계 없이 파티셔닝을 사용할 수 있을 만큼 성숙했지만, 여전히 신중한 설계가 필요합니다. 잘못된 파티션 키는 시스템을 더 빠르게 만들지 못하면서 더 복잡하게 만들 수 있습니다.

선언적 테이블 파티셔닝이란?

선언적 파티셔닝은 정의된 규칙 집합을 기반으로 단일 논리적 테이블(부모 또는 파티션 테이블)을 여러 물리적 테이블(자식 또는 파티션 테이블)로 분할할 수 있는 데이터베이스 기능입니다. 각 파티션은 부모 테이블의 데이터 하위 집합을 보유합니다. 파티션 키는 행이 속한 파티션을 결정합니다.

선언적 파티셔닝의 주요 이점은 다음과 같습니다.

  • 향상된 쿼리 성능: 파티션 키를 필터링하는 쿼리는 PostgreSQL이 일치하는 행을 포함할 수 없는 파티션을 프루닝할 수 있으므로 더 빨라질 수 있습니다.
  • 더 쉬운 데이터 관리: 오래된 데이터 삭제 또는 아카이브와 같은 작업은 단일 대형 테이블에서 대규모 DELETE 작업을 수행하는 대신 개별 파티션을 분리하거나 삭제하여 훨씬 더 효율적으로 수행할 수 있습니다.
  • 간소화된 유지 관리: 인덱싱 및 진공 관리를 파티션별로 관리할 수 있어 전체 테이블에 미치는 영향을 줄일 수 있습니다.
  • 더 작은 유지 관리 단위: 파티션 수준 인덱스, 분리 작업 및 대상 진공 작업은 일상적인 유지 관리의 영향 범위를 줄일 수 있습니다.

선언적 파티셔닝 유형

PostgreSQL은 각각 다른 데이터 분포 패턴에 적합한 여러 선언적 파티셔닝 방법을 지원합니다.

1. 범위 파티셔닝

범위 파티셔닝은 특정 열(예: 날짜, 숫자)의 연속적인 값 범위를 기준으로 데이터를 나눕니다.

사용 사례: 특정 날짜 또는 숫자 범위 내에서 데이터를 자주 쿼리하는 로그, 이벤트 데이터 또는 판매 기록과 같은 시계열 데이터에 이상적입니다.

예제: sale_date 열로 sales 테이블 파티셔닝.

범위 파티션 테이블 생성

먼저 파티셔닝 방법과 키를 지정하여 부모 테이블을 생성합니다.

CREATE TABLE sales (
    sale_id SERIAL,
    product_name VARCHAR(100),
    sale_amount NUMERIC(10, 2),
    sale_date DATE NOT NULL
)
PARTITION BY RANGE (sale_date);

다음으로 개별 파티션을 생성합니다. 각 파티션은 포함할 범위를 지정하는 FOR VALUES 절로 정의됩니다.

-- 2023년 1월 판매 파티션.
-- 상한은 제외되므로 1월 31일이 포함됩니다.
CREATE TABLE sales_2023_01
    PARTITION OF sales ()
    FOR VALUES FROM ('2023-01-01') TO ('2023-02-01');

-- 2023년 2월 판매 파티션
CREATE TABLE sales_2023_02
    PARTITION OF sales ()
    FOR VALUES FROM ('2023-02-01') TO ('2023-03-01');

-- 2023년 3월 판매 파티션
CREATE TABLE sales_2023_03
    PARTITION OF sales ()
    FOR VALUES FROM ('2023-03-01') TO ('2023-04-01');

: 범위를 정의할 때 연속적이고 가능한 모든 값을 포함하는지 확인하세요. 겹치는 범위를 피하세요. TO 값은 제외됩니다.

2. 목록 파티셔닝

목록 파티셔닝은 열의 개별 값 목록을 기준으로 데이터를 나눕니다.

사용 사례: 지리적 지역, 상태 코드 또는 제품 범주와 같이 고정된 알려진 값 집합이 있는 열에 적합합니다.

예제: region 열로 orders 테이블 파티셔닝.

목록 파티션 테이블 생성

PARTITION BY LIST로 부모 테이블을 정의합니다.

CREATE TABLE orders (
    order_id SERIAL,
    customer_name VARCHAR(100),
    order_total NUMERIC(10, 2),
    region VARCHAR(50) NOT NULL
)
PARTITION BY LIST (region);

특정 지역에 대한 파티션을 생성합니다.

-- 'North America' 주문 파티션
CREATE TABLE orders_north_america
    PARTITION OF orders ()
    FOR VALUES IN ('North America');

-- 'Europe' 주문 파티션
CREATE TABLE orders_europe
    PARTITION OF orders ()
    FOR VALUES IN ('Europe');

-- 'Asia' 주문 파티션
CREATE TABLE orders_asia
    PARTITION OF orders ()
    FOR VALUES IN ('Asia');

중요: 기존 파티션의 IN 목록과 일치하지 않고 DEFAULT 파티션이 없는 region 값을 삽입하면 삽입이 실패합니다. 다른 모든 값을 캐치하기 위해 DEFAULT 파티션을 생성할 수 있습니다.

기본 파티션 생성

-- 명시적으로 나열되지 않은 모든 지역에 대한 기본 파티션
CREATE TABLE orders_other
    PARTITION OF orders ()
    DEFAULT;

3. 해시 파티셔닝

해시 파티셔닝은 파티션 키의 해시 값을 기반으로 여러 파티션에 데이터를 분산합니다.

사용 사례: 명확한 범위 또는 목록 기반 분포 없이 대량의 데이터를 파티션 전체에 균등하게 분산하려는 경우 유용합니다. 로드 밸런싱에 좋습니다.

예제: user_idusers 테이블 파티셔닝.

해시 파티션 테이블 생성

PARTITION BY HASH로 부모 테이블을 정의하고 파티션 수를 지정합니다.

CREATE TABLE users (
    user_id BIGSERIAL,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100)
)
PARTITION BY HASH (user_id);

PostgreSQL은 지정하지 않으면 자동으로 파티션을 생성하지만, 특히 파티션 수와 이름을 제어하려는 경우 명시적으로 생성하는 것이 좋습니다.

명시적 해시 파티션 생성

-- 4개의 해시 파티션 생성
CREATE TABLE users_p0
    PARTITION OF users
    FOR VALUES WITH (modulus 4, remainder 0);

CREATE TABLE users_p1
    PARTITION OF users
    FOR VALUES WITH (modulus 4, remainder 1);

CREATE TABLE users_p2
    PARTITION OF users
    FOR VALUES WITH (modulus 4, remainder 2);

CREATE TABLE users_p3
    PARTITION OF users
    FOR VALUES WITH (modulus 4, remainder 3);

참고: 해시 파티셔닝을 사용할 때는 modulus(전체 파티션 수)와 remainder(이 파티션이 속한 나머지)를 지정해야 합니다.

선언적 파티셔닝 구현: 모범 사례

  • 올바른 파티션 키 선택: 파티션 키는 가장 빈번한 쿼리 필터 및 데이터 관리 작업과 일치해야 합니다. 좋은 키는 성능을 크게 향상시킵니다.
  • 파티션 수 고려: 파티션이 너무 적으면 충분한 이점을 제공하지 못할 수 있고, 너무 많으면 오버헤드가 증가할 수 있습니다. 관리 용이성과 성능의 균형을 맞추는 수를 목표로 하세요. 범위 파티셔닝의 경우 데이터 증가율과 보존 정책을 고려하세요.
  • 자동화를 위해 pg_partman 사용: 특히 시계열 데이터의 범위 파티셔닝의 경우 pg_partman과 같은 확장 기능 사용을 고려하세요. 새 파티션 생성과 오래된 파티션의 아카이브/삭제를 자동화하여 수동 작업을 크게 줄여줍니다.
  • 전략적 인덱싱: 인덱스는 파티션별로 물리적으로 저장됩니다. 부모에 인덱스를 생성하면 일치하는 파티션 인덱스가 생성되지만, 각 파티션에 동일한 인덱스 패턴이 필요한지 확인해야 합니다.
  • 파티션 프루닝: WHERE 절에 파티션 키를 포함하여 쿼리가 파티션 프루닝을 활용하도록 작성되었는지 확인하세요. EXPLAIN 명령은 프루닝이 발생하는지 보여줄 수 있습니다.
  • DEFAULT 파티션: 목록 파티셔닝의 경우 예기치 않게 새 값이 나타날 때 삽입 오류를 방지하려면 DEFAULT 파티션이 중요합니다.
  • 고유 제약 조건: 파티션 테이블의 고유 제약 조건 또는 기본 키는 일반적으로 모든 파티션 키 열을 포함해야 합니다. 이는 많은 초기 설계를 놀라게 합니다.
  • 데이터 유형: 파티션 키의 데이터 유형이 적절하고 부모 및 자식 테이블 전체에서 일관성이 있는지 확인하세요.

파티션 관리

파티션 연결 및 분리

파티션은 CREATE TABLE ... PARTITION OF ...을 통해 직접 생성되지만, 기존 테이블을 파티션으로 분리하거나 연결할 수도 있습니다. 이는 데이터 마이그레이션이나 대규모 데이터 세트 관리에 유용합니다.

파티션 분리: 분리는 파티션을 일반 테이블로 전환하면서 데이터를 유지합니다.

-- sales_2023_01 파티션 분리
ALTER TABLE sales DETACH PARTITION sales_2023_01;

테이블을 파티션으로 연결: 부모의 스키마를 따르고 파티션 경계에 맞는 데이터가 있는 일반 테이블을 연결할 수 있습니다.

-- sales_2022_12가 sales와 동일한 열을 가진 일반 테이블이고
-- 2022년 12월의 행만 있다고 가정합니다.
ALTER TABLE sales ATTACH PARTITION sales_2022_12
    FOR VALUES FROM ('2022-12-01') TO ('2023-01-01');

대규모 테이블을 연결하기 전에 먼저 일치하는 CHECK 제약 조건을 추가하세요. PostgreSQL은 이 제약 조건을 사용하여 행이 파티션 경계에 맞는지 증명하기 위해 전체 테이블을 스캔하는 것을 피할 수 있습니다.

파티션 삭제

파티션 삭제는 명시적으로 지정하지 않는 한 파티션 테이블만 제거하고 그 안의 데이터는 제거하지 않기 때문에 빠른 작업입니다. 이는 DELETE보다 훨씬 빠릅니다.

-- 파티션을 삭제하려면 자식 테이블을 간단히 삭제하면 됩니다.
DROP TABLE sales_2023_01;

예제: 파티션 프루닝으로 쿼리 성능 향상

앞서 보여준 sale_date로 파티션된 sales 테이블을 고려하세요.

파티션 프루닝이 없는 쿼리(파티션되지 않은 테이블의 가상):

SELECT SUM(sale_amount)
FROM sales
WHERE sale_date >= '2023-01-15' AND sale_date < '2023-01-20';

sales가 파티션되지 않은 대규모 테이블이라면 이 쿼리는 전체 테이블을 스캔합니다. 그러나 선언적 파티셔닝을 사용하면:

-- 이 쿼리는 sales_2023_01 파티션만 스캔합니다.
SELECT SUM(sale_amount)
FROM sales
WHERE sale_date >= '2023-01-15' AND sale_date < '2023-01-20';

PostgreSQL의 쿼리 플래너는 sale_date가 파티션 키이고 지정된 범위가 sales_2023_01 파티션 내에 완전히 속한다는 것을 인식합니다. 따라서 해당 파티션만 스캔하여 I/O를 획기적으로 줄이고 성능을 향상시킵니다.

이를 확인하려면 EXPLAIN을 사용하세요.

EXPLAIN SELECT SUM(sale_amount) FROM sales WHERE sale_date >= '2023-01-15' AND sale_date < '2023-01-20';

출력에는 관련 파티션만 표시되거나 PostgreSQL 버전 및 계획 형태에 따라 제거된 하위 계획이 표시될 수 있습니다. 중요한 신호는 관련 없는 파티션이 스캔되지 않는다는 것입니다.

실용적인 설계 체크리스트

운영상의 이점을 명명할 수 있을 때만 파티셔닝하세요. "테이블이 크다"는 것만으로는 충분하지 않습니다. 인덱스가 잘 지정된 포인트 조회가 있는 대규모 테이블은 괜찮을 수 있습니다. 파티셔닝은 대부분의 쿼리에 파티션 키가 포함되거나, 오래된 데이터가 정기적으로 아카이브되거나 삭제되거나, 하나의 거대한 테이블 유지 관리가 이미 문제를 일으키는 경우에 더 적합합니다.

시계열 테이블의 경우 쿼리 및 보존 패턴과 일치하는 파티션 크기를 선택하세요. 일일 파티션은 매우 높은 수집 및 짧은 보존에 유용합니다. 월별 파티션은 적당한 이벤트 볼륨에 대해 관리하기 더 쉬운 경우가 많습니다. 너무 많은 작은 파티션은 계획을 느리게 하고 유지 관리를 번거롭게 만들 수 있습니다. 너무 적은 거대한 파티션은 원래 문제를 해결하지 못할 수 있습니다.

배포 전에 삽입을 계획하세요. 행이 늦게 도착할 수 있는 경우 오래된 파티션을 충분히 오래 사용할 수 있도록 유지하세요. 파티션 키에 예기치 않은 값이 포함될 수 있는 경우 DEFAULT 파티션을 생성하고 모니터링하세요. 기본 파티션은 안전망이어야 하며, 잊혀진 데이터가 몇 달 동안 조용히 축적되는 장소가 아니어야 합니다.

마지막으로 실제 쿼리 형태로 테스트하세요. 파티션 프루닝은 WHERE 절이 sale_date >= '2023-01-01' AND sale_date < '2023-02-01'와 같이 파티션 키를 명확하게 노출할 때 가장 잘 작동합니다. 키를 함수로 감싸면 프루닝이 더 어려워질 수 있습니다.

-- 프루닝에 덜 친숙함
WHERE date_trunc('month', sale_date) = DATE '2023-01-01';

-- 플래너가 더 쉽게 처리
WHERE sale_date >= DATE '2023-01-01'
  AND sale_date <  DATE '2023-02-01';

선언적 파티셔닝은 쿼리 도구만큼이나 유지 관리 도구입니다. 잘 사용하면 오래된 데이터를 저렴하게 제거하고 핫 데이터를 더 쉽게 스캔할 수 있습니다. 아무렇게나 사용하면 더 많은 테이블, 더 많은 인덱스 및 더 많은 예외 사례가 추가됩니다. 액세스 패턴으로 시작하고, 해당 패턴에서 파티션 키를 선택하고, 설계가 완료되었다고 판단하기 전에 계획을 확인하세요.

기존의 대규모 테이블의 경우 피크 트래픽 중에 위험한 일회성 변환을 계획하지 마세요. 일반적인 마이그레이션 경로는 새 파티션 테이블을 만들고, 데이터를 청크 단위로 복사하고, 애플리케이션 로직 또는 신중하게 테스트된 트리거를 통해 새 쓰기를 유지한 다음, 짧은 유지 관리 기간 동안 이름을 바꾸는 것입니다. 정확한 접근 방식은 쓰기 볼륨과 다운타임 허용 오차에 따라 다르지만, 원칙은 동일합니다. 복사를 증명하고, 제약 조건을 증명하고, 프로덕션을 건드리기 전에 전환을 리허설하세요.