시간 순서로 계속 쌓이는 이벤트 데이터는 처음에는 단일 PostgreSQL 테이블로도 충분했습니다. 최근 며칠을 조회하는 API, 장비별 필터, 결함 통계 모두 적절한 복합 인덱스로 빠르게 처리됐습니다. 문제는 데이터와 인덱스가 함께 커진 뒤 나타났습니다.
조회 조건은 최근 범위였지만 조회에 사용되는 인덱스는 전체 이력을 포함했습니다. 대량 삭제로 발생한 WAL과 vacuum 부하도 온라인 요청과 겹쳤습니다.
테이블을 시간 범위 파티션으로 나누기로 했지만 파티셔닝 자체가 목표는 아니었습니다. 최근 조회에서 오래된 데이터의 영향을 줄이고 싶었습니다. 보존과 Cold Storage 이관은 범위 단위로 다루기로 했습니다.
파티션 키와 크기만 정해서는 부족합니다. 생성 시점과 인덱스도 필요합니다. 늦게 도착한 데이터까지 함께 설계해야 합니다.
조회와 데이터 수명을 함께 보는 파티션 키
이벤트에는 수집 서버가 받은 createdAt과 원천 시스템에서 기록한 발생 시각인 occurredAt이 있었습니다. 조회 API와 보존 정책은 실제 이벤트 발생 시각을 따릅니다. 따라서 occurredAt을 파티션 키로 선택했습니다.
CREATE TABLE event_record (
event_id text NOT NULL,
occurred_at timestamptz NOT NULL,
source_key text NOT NULL,
device_key text NOT NULL,
category_key text NOT NULL,
has_issue boolean NOT NULL,
PRIMARY KEY (event_id, occurred_at)
) PARTITION BY RANGE (occurred_at);
PostgreSQL partitioned table의 unique constraint에는 파티션 키가 포함돼야 합니다. 업무 식별자 하나만으로 전역 고유성을 보장하고 싶다면 별도 registry 테이블이나 다른 설계가 필요합니다. 원본 고유성과 Worker 멱등성을 함께 고려했습니다.
고유 키는 event_id와 occurred_at 조합입니다. 쿼리에서 occurred_at 조건을 빠뜨리면 partition pruning 효과를 얻기 어렵습니다. 주요 목록과 통계 쿼리의 계약을 통일했습니다.
모두 반개구간 >= start AND < end를 사용합니다.
월 단위 대신 10일 단위를 선택한 이유
파티션이 너무 크면 최근 파티션의 인덱스와 이관 단위가 커집니다. 너무 작으면 relation 수, 생성 작업, planner 메타데이터가 늘어납니다. 한 달, 하루, 10일 단위를 비교했습니다.
| 단위 | 장점 | 단점 |
|---|---|---|
| 월 | 파티션 수가 적고 관리가 단순 | 월별 데이터 편차가 크고 이관 파일이 큼 |
| 일 | pruning과 삭제 단위가 작음 | 파티션과 인덱스 수가 빠르게 증가 |
| 10일 | 한 달을 3개 범위로 나눠 크기를 제한 | 월말 파티션 길이가 8~11일로 달라짐 |
실제 하루 적재량, 조회 기간, Cold 이관 window를 기준으로 1~10일, 11~20일, 21일~다음 달 1일의 세 파티션을 사용했습니다. 마지막 파티션의 길이는 달마다 다릅니다. plusDays(10) 대신 다음 달 1일을 경계로 삼았습니다.
fun partitionStart(date: LocalDate): LocalDate = when {
date.dayOfMonth < 11 -> date.withDayOfMonth(1)
date.dayOfMonth < 21 -> date.withDayOfMonth(11)
else -> date.withDayOfMonth(21)
}
fun partitionEnd(start: LocalDate): LocalDate = when (start.dayOfMonth) {
1, 11 -> start.plusDays(10)
21 -> start.plusMonths(1).withDayOfMonth(1)
else -> error("invalid partition start: $start")
}
모든 경계 계산은 UTC의 LocalDate로 고정했습니다. 애플리케이션 기본 타임존을 사용하면 서버 환경에 따라 자정 근처 데이터가 다른 파티션으로 들어갈 수 있습니다.
쓰기 직전의 파티션 보장
미래 파티션을 스케줄러로 미리 만드는 방법이 가장 단순하지만, 배포 초기나 스케줄 실패에서도 쓰기가 계속돼야 했습니다. Worker는 batch 처리 전에 PostgreSQL 함수를 호출했습니다. 이 함수가 각 occurredAt의 파티션을 보장합니다.
fun ensureTenDayPartition(occurredAt: Instant) {
val start = partitionStart(LocalDate.ofInstant(occurredAt, ZoneOffset.UTC))
if (!ensuredStarts.add(start)) return
try {
jdbcTemplate.query(
"SELECT ensure_event_ten_day_partitions(:start, :end)",
mapOf("start" to start, "end" to partitionEnd(start)),
RowCallbackHandler { },
)
} catch (e: Exception) {
ensuredStarts.remove(start)
throw e
}
}
같은 프로세스에서 매 row마다 DDL 확인을 반복하지 않도록 성공한 start date를 메모리 set에 캐시했습니다. 중요한 것은 실패 시 캐시에서 제거하는 것입니다. 함수 호출이 실패했는데 날짜를 그대로 남기면 이후 모든 batch가 파티션이 있다고 믿고 insert에서 실패합니다.
여러 Worker 인스턴스가 동시에 같은 파티션을 만들 수 있으므로 데이터베이스 함수도 IF NOT EXISTS와 advisory lock 또는 catalog 확인을 사용해 경쟁에 안전해야 합니다. 애플리케이션의 set은 인스턴스 내부 최적화일 뿐 분산 락이 아닙니다.
부모·자식 테이블의 파티션 경계
이벤트 한 건에는 여러 세그먼트와 결과 항목이 연결됩니다. 부모만 파티션하고 자식을 단일 테이블에 두면 자식 인덱스가 계속 커지고 Cold 이관 시 join 범위를 관리하기 어렵습니다. 자식도 같은 occurred_at 기준과 10일 경계를 사용했습니다.
CREATE TABLE event_finding (
event_id text NOT NULL,
occurred_at timestamptz NOT NULL,
segment_index integer NOT NULL,
finding_index integer NOT NULL,
name text NOT NULL,
confidence real,
PRIMARY KEY (
event_id, occurred_at, segment_index, finding_index
)
) PARTITION BY RANGE (occurred_at);
파티션 생성 함수는 이벤트, 세그먼트, 결과 항목, 태그처럼 같은 생명주기를 가진 테이블의 동일 범위를 한 번에 준비합니다. 한 테이블만 빠지면 Worker transaction 전체가 롤백되므로 생성 목록을 migration과 테스트로 고정했습니다.
실제 조회 형태에서 결정하는 인덱스
파티션을 만들었다고 느린 쿼리가 자동으로 빨라지지는 않습니다. pruning은 읽을 파티션을 줄일 뿐, 선택된 파티션 안에서 어떤 인덱스를 쓸지는 별도 문제입니다. 최근 이벤트 목록은 대략 다음 조건을 사용했습니다.
SELECT event_id, occurred_at, device_key, category_key, has_issue FROM event_record WHERE occurred_at >= :start_at AND occurred_at < :end_at AND source_key = :source_key AND (:device_key IS NULL OR device_key = :device_key) ORDER BY occurred_at DESC, event_id DESC LIMIT :limit;
source_key, occurred_at DESC, event_id DESC처럼 자주 쓰는 필터와 안정 정렬을 반영한 로컬 인덱스를 각 파티션에 만들었습니다. 선택도가 낮은 boolean 컬럼을 무조건 앞에 두지 않았고, 선택 옵션이 많은 동적 필터는 실제 EXPLAIN (ANALYZE, BUFFERS) 결과를 보고 결정했습니다. 파티션 수만 늘고 pruning이 되지 않는 대표적인 원인은 컬럼에 함수를 씌우는 조건입니다.
date(occurred_at) = :date 대신 경계 시각을 애플리케이션에서 계산해 원본 컬럼을 그대로 비교했습니다.
늦게 도착한 데이터와 기본 파티션
수집 시각과 이벤트 발생 시각이 다르기 때문에 며칠 전 데이터가 늦게 들어올 수 있습니다. 최근 파티션만 미리 만드는 정책이라면 과거 파티션이 이미 detach된 뒤 insert가 올 수도 있습니다. 정책은 세 가지 중 하나를 명시해야 합니다.
- 허용 지연 범위의 과거 파티션은 Hot에 유지합니다.
- 이관된 범위의 late arrival를 별도 correction 파이프라인으로 보냅니다.
- 기본 파티션에 임시 수용하고 정기적으로 올바른 파티션으로 이동합니다.
기본 파티션은 쓰기 실패를 막지만 조용히 데이터가 쌓이면 pruning과 이관 계약이 깨집니다. 사용한다면 기본 파티션 row 수에 알림을 두고 정상 상태를 0에 가깝게 유지해야 합니다.
삭제 대신 detach와 이관을 사용했다
보존 기간을 지난 수백만 행을 DELETE하면 긴 트랜잭션, WAL, dead tuple, vacuum이 발생합니다. 파티션 구조에서는 완료된 범위를 단위로 다룰 수 있습니다.
ALTER TABLE event_record DETACH PARTITION event_record_p20260801; -- Parquet 이관과 검증이 완료된 뒤 DROP TABLE event_record_p20260801;
실제 운영에서는 detach 전에 Cold manifest가 같은 범위와 행 수를 포함하는지 확인합니다. 부모와 모든 자식 파티션의 이관이 끝나기 전에 일부만 drop하면 데이터셋 간 참조가 깨집니다. 삭제 작업은 Migration Job의 성공 상태와 분리된 승인 단계로 두는 편이 안전합니다.
운영에서 확인한 지표와 테스트
- 파티션별 row 수와 인덱스 크기.
- 기본 파티션 유입량.
- 미래 파티션 생성 실패.
- 쿼리별 실제 scanned partition 수.
- 파티션 DDL lock wait.
- Hot 파티션과 Cold manifest의 범위·행 수 차이.
- 월말, 윤년, UTC 자정 경계 테스트.
특히 21일 파티션의 end 계산은 2월과 31일이 있는 달 모두 테스트했습니다. 동일 batch에서 같은 날짜가 여러 번 등장해도 파티션 생성 함수가 한 번만 호출되는지, 생성 실패 후 다음 batch에서 다시 시도하는지도 확인했습니다.
파티션 단위를 운영 계약으로 만들기
PostgreSQL 파티셔닝을 도입한 이유는 테이블을 잘게 나누는 것 자체가 아니었습니다. 최근 조회가 오래된 데이터의 성장에 덜 민감하게 만들고, 보존과 Cold 이관을 예측 가능한 시간 범위로 바꾸기 위해서였습니다. 효과를 얻으려면 파티션 키가 주요 조회에 포함돼야 하고, 단위는 실제 적재량과 이관 크기로 결정해야 합니다.
미래 생성, 동시 DDL, 실패 캐시 제거, late arrival, 자식 테이블, 인덱스, 삭제 절차까지 함께 설계해야 합니다. 파티셔닝은 쿼리 최적화 한 줄이 아니라 데이터 생명주기를 데이터베이스 구조로 표현하는 선택입니다.
답글 남기기