여러 Pod가 PostgreSQL 월별 파티션을 동시에 만들지 않게 한 방법

감사 로그는 계속 쌓이지만 오래된 기록을 조회하는 빈도는 낮습니다. 그래서 audit_logaudit_log_targetoccurred_at 기준의 월별 RANGE 파티션으로 나눴습니다. 데이터가 어느 파티션으로 들어갈지는 명확했지만, 다음 달 파티션을 누가 언제 만들지는 별도의 문제였습니다.

PostgreSQL은 입력값을 받아 줄 파티션이 없으면 부모 테이블에 대신 보관하지 않습니다. DEFAULT 파티션을 두지 않은 구조에서는 월이 바뀐 직후 첫 INSERT가 그대로 실패합니다.

애플리케이션 인스턴스가 하나라면 scheduler 하나로도 대응할 수 있지만, 여러 Pod가 함께 기동하고 요청을 처리하면 두 인스턴스가 같은 파티션을 동시에 만들려는 상황까지 고려해야 합니다.

이번 구현은 실제 월 경계 장애를 복구한 작업은 아닙니다. 감사 로그를 도입하면서 월 경계와 다중 인스턴스 조건을 미리 실패 시나리오로 두고, PostgreSQL 16 환경에서 동작을 확인한 과정입니다.

파티션 생성을 맡길 세 시점

처음에는 배포 migration에서 앞으로 사용할 파티션을 미리 만들어 두는 방법을 생각할 수 있습니다. 구조는 단순하지만 얼마나 먼 미래까지 생성할지 정해야 하고, 예정된 기간을 지나면 다시 배포해야 합니다. 반대로 scheduler에만 맡기면 scheduler가 중단됐거나 월 경계 실행이 실패한 순간에 쓰기 경로가 보호되지 않습니다.

여기서는 파티션 준비 시점을 세 군데로 나눴습니다. 애플리케이션이 기동할 때 현재 월과 다음 달을 준비하고, 매일 UTC 00:05에 같은 범위를 다시 확인합니다. 두 경로를 모두 놓쳤더라도 감사 로그를 저장하기 직전에 해당 월 파티션을 확인합니다.

async onApplicationBootstrap(): Promise<void> {
  await this.partitionMaintenance
    .ensureCurrentAndNextMonthlyPartitions();
}

@Cron('0 5 0 * * *', { timeZone: 'UTC' })
async ensurePartitions(): Promise<void> {
  try {
    await this.partitionMaintenance
      .ensureCurrentAndNextMonthlyPartitions();
  } catch (error) {
    this.logger.error('Failed to ensure monthly partitions', error);
  }
}

기동 단계의 실패는 잡아서 무시하지 않습니다. 현재 월 파티션조차 준비할 수 없는 인스턴스가 요청을 받으면 감사 로그를 보장할 수 없기 때문입니다. 반면 매일 실행하는 scheduler는 실패를 기록하고 다음 실행에서 다시 시도할 수 있게 격리했습니다. 같은 오류라도 생명주기에서 어느 시점에 발생했는지에 따라 처리 방식이 달라집니다.

IF NOT EXISTS와 동시 생성 경쟁

Pod 두 개가 거의 동시에 시작하면 둘 다 파티션이 없다는 결과를 볼 수 있습니다. 각 Pod가 곧바로 CREATE TABLE ... PARTITION OF를 실행하면 동일한 부모 테이블을 대상으로 DDL이 겹칩니다.

CREATE TABLE IF NOT EXISTS는 이미 만들어진 객체를 다시 만드는 오류는 줄여 주지만, 존재 여부 확인과 생성 사이의 경쟁 전체를 직렬화하지는 않습니다.

동시성의 기준점을 애플리케이션 메모리가 아니라 PostgreSQL에 뒀습니다. 생성하려는 월을 advisory transaction lock의 key로 사용하면 서로 다른 Pod도 같은 월에 대해서만 순서를 맞출 수 있습니다. 다른 월을 준비하는 작업까지 하나의 전역 락으로 묶을 필요는 없습니다.

IF to_regclass(audit_partition_name) IS NULL
   OR to_regclass(target_partition_name) IS NULL
THEN
  PERFORM pg_advisory_xact_lock(
    hashtext('audit_log_monthly_partition'),
    partition_suffix::integer
  );

  IF to_regclass(audit_partition_name) IS NULL
     OR to_regclass(target_partition_name) IS NULL
  THEN
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_log
       FOR VALUES FROM (%L) TO (%L)',
      audit_partition_name, from_value, to_value
    );
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_log_target
       FOR VALUES FROM (%L) TO (%L)',
      target_partition_name, from_value, to_value
    );
  END IF;
END IF;

존재 여부를 lock 앞뒤로 두 번 확인하는 점이 중요합니다. 첫 확인은 이미 준비된 정상 경로에서 불필요한 lock을 피합니다. 두 번째 확인은 lock을 기다리는 동안 다른 인스턴스가 생성을 마쳤을 가능성을 반영합니다. 기다리던 인스턴스는 DDL을 반복하지 않고 그대로 빠져나갑니다.

감사 이벤트와 target 파티션도 같은 임계 구역에서 함께 준비합니다. 둘 중 하나만 존재하는 불완전한 상태에서도 다시 생성 경로로 진입합니다. 생성 순서를 고정했기 때문에 어느 인스턴스가 lock을 잡더라도 두 테이블의 월 범위를 같은 기준으로 맞출 수 있습니다.

lock을 기다리는 시간은 3초

pg_advisory_xact_lock은 transaction이 끝날 때까지 유지됩니다. 파티션을 처음 만드는 fallback이 비즈니스 transaction 안에서 실행되면 같은 월의 첫 요청들이 잠시 직렬화될 수 있습니다. 정상적으로 현재 월과 다음 달 파티션을 미리 준비했다면 자주 지나갈 경로는 아니지만, 문제가 생겼을 때 요청이 끝없이 기다리게 둘 수는 없습니다.

파티션 생성 함수에는 SET lock_timeout = '3s'를 적용했습니다. 이 값은 함수 전체의 실행 제한 시간이 아니라 함수 안에서 각각의 lock을 기다리는 상한입니다. advisory lock이나 DDL에 필요한 relation lock을 시간 안에 얻지 못하면 오류를 호출자에게 돌려보냅니다. 함수가 끝난 뒤에는 기존 session 설정으로 복원되므로 connection pool의 다음 요청에 3초 제한이 남지 않습니다.

timeout을 삼키지 않는 것도 의도한 동작입니다. 저장 직전 fallback까지 실패했다면 감사 로그만 누락한 채 원래 변경을 commit하지 않습니다. 비즈니스 변경과 감사 기록을 같은 transaction 경계에 두었기 때문에 파티션 준비 실패도 전체 요청의 실패로 전파됩니다.

commit 이후의 로컬 캐시

매번 to_regclass와 파티션 보장 함수를 호출하면 이미 준비된 월에도 불필요한 DB 왕복이 생깁니다. 각 프로세스는 확인이 끝난 월의 시작일을 Set에 보관하고, 이후 같은 월에는 DB 호출을 생략합니다.

이 캐시는 Pod끼리 공유하지 않아도 됩니다. 파티션의 실제 존재 여부와 동시성 제어는 PostgreSQL이 담당하고, 로컬 캐시는 해당 프로세스의 반복 확인만 줄입니다.

문제는 캐시에 넣는 시점입니다. 파티션 보장 함수가 성공했더라도 바깥 transaction이 나중에 rollback되면 생성한 파티션도 사라질 수 있습니다. 함수 반환 직후 캐시를 갱신하면 다음 요청은 파티션이 존재한다고 잘못 판단합니다. 그래서 transaction commit hook에서만 월을 캐시에 등록했습니다.

await repository.query(
  'SELECT ensure_audit_log_monthly_partitions($1, $2)',
  [startDate, endDate],
);

runOnTransactionCommit(() => {
  this.ensuredPartitionStartDates.add(startDate);
});

query가 실패하거나 주변 transaction이 commit되지 않으면 캐시는 비어 있습니다. 다음 요청은 DB 확인을 다시 수행합니다. 테스트에서도 commit hook 실행 전에는 같은 월을 연속 호출해도 query가 생략되지 않고, commit 이후에만 건너뛰는지 확인했습니다.

UTC로 고정한 월 경계

파티션 범위는 감사 이벤트의 occurred_at과 동일하게 UTC 월을 사용합니다. 애플리케이션이나 DB session의 지역 시간대를 기준으로 월을 계산하면 같은 시각이 서로 다른 월로 해석될 수 있습니다. 특히 월말과 DST 전환이 겹치면 로컬 시간 계산만으로 경계를 검증하기 어렵습니다.

애플리케이션에서는 getUTCFullYear()getUTCMonth()[이번 달 1일, 다음 달 1일) 범위를 만들었습니다. 데이터베이스 함수도 UTC가 포함된 파티션 bound를 생성합니다. PostgreSQL session timezone을 America/New_York으로 바꾼 검증에서도 DST가 포함된 월과 연말 경계가 같은 UTC 파티션으로 라우팅되는지 확인했습니다.

다중 인스턴스 조건은 connection pool 크기를 각각 1로 제한한 두 애플리케이션 인스턴스가 현재 월과 다음 달을 동시에 준비하도록 구성했습니다. 결과로 두 테이블에 필요한 네 개 파티션만 생성되는지, 같은 범위를 다시 요청해도 추가 객체가 생기지 않는지 확인했습니다. 별도의 테스트에서는 lock 대기가 3초 제한을 따르고 함수 종료 후 session의 원래 lock_timeout이 유지되는지도 검증했습니다.

자동 생성이 가져온 운영 비용

이 구조에서는 애플리케이션 DB 계정이 자식 파티션을 생성할 DDL 권한을 가져야 합니다. 쓰기 전용 계정에 비해 권한 범위가 넓어지므로, 조직의 권한 정책상 애플리케이션에서 DDL을 허용할 수 없다면 배포 pipeline이나 별도 관리 작업이 파티션 생성을 맡는 편이 맞습니다.

파티션을 자동으로 삭제하지도 않습니다. 보관 기한은 데이터 정책과 복구 절차의 영향을 받으므로 현재 월과 다음 달을 제외한 오래된 파티션은 운영자가 백업과 조회 여부를 확인한 뒤 제거합니다. 생성 자동화와 보관 만료를 같은 권한으로 묶지 않은 선택입니다.

운영에서 확인할 신호도 분명합니다. bootstrap 실패는 인스턴스가 준비되지 않은 상태로 보고, scheduler 오류는 다음 달 파티션을 미리 만들지 못했다는 경고로 다룹니다. 저장 경로에서 파티션 fallback이나 lock timeout이 발생한다면 정상적인 선행 준비가 깨진 degraded 상태이므로 요청 오류만 보지 말고 파티션 생성 경로를 함께 점검해야 합니다.

월별 파티션을 선택하는 것만으로 월 경계가 안전해지지는 않습니다. 여러 인스턴스가 같은 DDL을 실행할 수 있는 구조에서는 존재 확인, database lock, lock 이후 재확인, commit 이후 캐시 갱신이 하나의 흐름으로 맞아야 합니다. 이 구현에서 가장 중요한 조건은 다음 달 파티션을 빨리 만드는 것이 아니라, 어느 Pod가 먼저 움직이더라도 감사 로그와 비즈니스 변경의 transaction 경계가 달라지지 않는 것입니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다