공지TechKnowGenie 블로그를 오픈했습니다.
IT•읽는 데 약 15분

카프카 컨슈머 하나 죽었을 뿐인데 결제 DB 디스크 1TB가 꽉 차서 마스터가 멈췄던 새벽: 복제 슬롯(Replication Slot) 유실과 WAL 폭증 인시던트 해결기

결제 데이터 파이프라인의 카프카 컨슈머 장애가 PostgreSQL 마스터 DB 디스크 1TB를 잠식하며 PANIC 다운으로 이어진 인시던트 분석. 복제 슬롯 누적 메커니즘과 max_slot_wal_keep_size 방어 체계, LSN 드레인 긴급 복구 절차를 다룹니다.

관리자
목차 (5)
  1. 워룸에서 나온 rm pg_wal/* 제안을 결사 저지해야 했던 이유
  2. pg_wal을 지우지 않고 0바이트 디스크에서 마스터를 깨운 무손실 응급 런북
  3. PostgreSQL C 커널(slot.c)이 마스터가 죽는데도 WAL 삭제를 거부하는 메커니즘
  4. 숨겨진 2차 재앙: catalog_xmin 동결로 인한 오토진공(Autovacuum) 질식과 리눅스 더티 페이지 블로킹
  5. 마스터의 생존권을 강제하는 물리적 방어벽: max_slot_wal_keep_size와 비상 밸브 파일
2026-10-09 04:45:12.891 KST [7124] PANIC: could not write to log file 00000001000003E8000000A2: No space left on device
2026-10-09 04:45:12.892 KST [7124] HINT:  Check free disk space on the partition hosting pg_wal.
2026-10-09 04:45:12.895 KST [1420] LOG:   terminating any other active server processes

새벽 4시 47분, 머리맡의 아이폰에서 페이저듀티(PagerDuty) P1 고음 사이렌이 울렸을 때 메인 결제 데이터베이스 노드의 /var/log/postgresql 마지막 줄에 찍혀 있던 메시지입니다.

PostgreSQL 마스터 인스턴스가 PANIC 시그널과 함께 스스로 프로세스를 내렸습니다. 모든 백엔드 결제 API 서버의 커넥션 풀이 연쇄적으로 끊어졌고, 새벽 시간대 정기 결제 배치와 실시간 주문 승인이 전면 중단된 상태였습니다. SSH로 마스터 노드에 접속해 파일시스템 마운트 상태를 조회하자 등골이 서늘해지는 숫자가 출력되었습니다.

$ df -h /var/lib/postgresql/data
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme1n1    1.0T  1.0T     0 100% /var/lib/postgresql/data

1TB 크기의 NVMe 볼륨에 남은 여유 공간이 정확히 0바이트였습니다. 실제 유저 테이블과 인덱스가 들어 있는 base/ 디렉토리 크기는 평소와 다름없는 76GB였는데, 트랜잭션 선행 로그(Write-Ahead Log)를 보관하는 pg_wal/ 디렉토리 혼자서 무려 924GB(16MB 세그먼트 파일 57,750개)를 집어삼키고 있었습니다.

워룸에서 나온 rm pg_wal/* 제안을 결사 저지해야 했던 이유

워룸 음성 채널에 온콜 엔지니어들이 접속한 지 7분쯤 지났을 무렵, 다급해진 인프라 엔지니어 한 분이 마이크를 켜고 외쳤습니다. “지금 디스크가 0바이트라 systemctl start postgresql을 쳐도 엔진이 아예 뜨질 않습니다! pg_wal 안에 어제 날짜로 찍힌 오래된 파일들 몇십 개만 rm으로 지워서 공간 만들고 DB부터 살리시죠!”

장애 현장에서 극도의 압박을 받는 엔지니어들이 가장 빈번하게 저지르는, 그리고 데이터베이스를 영구 불능으로 만드는 가장 치명적인 유혹이 바로 pg_wal 수동 삭제입니다. 저는 즉시 마이크를 켜고 손가락도 대지 말라고 소리쳤습니다.

pg_wal 디렉토리 안에 쌓여 있는 16MB짜리 세그먼트 파일들은 아파치 웹 서버의 텍스트 액세스 로그 같은 단순 기록물이 아닙니다. 아직 테이블 데이터 파일(base/ 하위 힙 페이지)에 물리적으로 플러시되지 않은 변경분까지 포함하여 데이터베이스의 물리적 정합성을 지탱하는 트랜잭션 원본 체인입니다. PostgreSQL 엔진이 PANIC으로 비정상 종료된 뒤 다시 기동될 때, 엔진은 컨트롤 파일(global/pg_control)에 기록된 가장 마지막 정상 체크포인트 위치(Latest checkpoint's REDO location)를 읽어들여 그 지점의 LSN(Log Sequence Number)부터 디스크에 존재하는 가장 최신 WAL 레코드까지 단 하나의 누락도 없이 순차 재생(REDO Recovery)해야만 데이터 디렉토리의 일관성을 복구할 수 있습니다.

만약 디스크 공간을 확보하겠다고 임의로 pg_wal 내의 세그먼트 파일을 rm으로 지워버리면, 엔진은 크래시 복구 도중 LSN 체인이 끊어진 것을 감지하고 FATAL: could not open file "pg_wal/..." 에러를 뿜으며 영구적으로 기동을 거부합니다. 그때부터는 pg_resetwal이라는 최후의 강제 초기화 도구를 쓰는 수밖에 없는데, 이는 미반영 트랜잭션 페이지를 강제로 버리는 행위여서 인덱스 포인터 깨짐과 테이블 데이터 부패(Corruption)를 유발해 결국 전날 새벽의 풀 백업본(PITR)으로 롤백하고 수 시간 동안의 결제 원장을 날려버리는 참사로 이어집니다.

pg_wal을 지우지 않고 0바이트 디스크에서 마스터를 깨운 무손실 응급 런북

기존 파티션을 단 1바이트도 훼손하지 않으면서 PostgreSQL 데몬을 기동시키려면, 물리적인 숨통을 외부에서 수혈해야 했습니다. 저희는 즉시 AWS 콘솔에서 50GB 크기의 신규 EBS 볼륨(/dev/nvme2n1)을 프로비저닝하여 장애 노드의 /mnt/emergency_wal 경로에 마운트했습니다. 그리고 pg_wal 디렉토리 안에서 수정 시각(mtime)이 가장 오래된 세그먼트 파일 20개(약 320MB)를 삭제(rm)가 아닌 안전 이동(mv)으로 외장 볼륨에 대피시키고, 원래 자리에는 심볼릭 링크(ln -s)를 걸어주었습니다.

# 04:58 KST - 외장 볼륨 마운트 후 가장 오래된 WAL 세그먼트 20개를 무손실 대피 및 심볼릭 링크 연결
$ sudo mount /dev/nvme2n1 /mnt/emergency_wal
$ sudo chown postgres:postgres /mnt/emergency_wal
$ for f in $(ls -1tr /var/lib/postgresql/data/pg_wal/00000001* | head -n 20); do
    base=$(basename "$f")
    sudo -u postgres mv "$f" "/mnt/emergency_wal/$base"
    sudo -u postgres ln -s "/mnt/emergency_wal/$base" "$f"
  done

PostgreSQL 프로세스 입장에서는 심볼릭 링크를 통해 모든 WAL 체인을 끊김 없이 읽을 수 있으면서도, 메인 볼륨(/dev/nvme1n1)에는 320MB의 여유 공간이 즉시 생겨났습니다. 새벽 5시 12분, systemctl start postgresql 명령을 실행하자 엔진이 REDO 복구를 무사히 완주하고 database system is ready to accept connections 로그를 찍으며 살아났습니다.

엔진이 뜨자마자 왜 평소 15GB 안팎으로 유지되던 pg_wal이 밤사이 924GB까지 불어났는지 원인을 색출하기 위해 시스템 뷰(pg_replication_slots)를 조회했습니다.

postgres=# SELECT slot_name, plugin, slot_type, active, wal_status,
                  pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
           FROM pg_replication_slots;

-[ RECORD 1 ]+------------------------
slot_name    | debezium_orders_cdc
plugin       | pgoutput
slot_type    | logical
active       | f
wal_status   | reserved
retained_wal | 912 GB

범인은 결제 데이터베이스 자체가 아니라, 쿠버네티스 클러스터 건너편에서 데이터 분석팀이 운영하던 카프카 CDC(Change Data Capture) 커넥터인 Debezium의 논리적 복제 슬롯(debezium_orders_cdc)이었습니다. active 컬럼이 f(False, 비활성)로 찍힌 채 혼자서 912GB의 WAL 파일을 붙잡고 놓아주지 않고 있었던 것입니다.

워룸에서 즉시 해당 고아 슬롯을 강제 드롭하고 수동 체크포인트를 발동시켰습니다.

postgres=# SELECT pg_drop_replication_slot('debezium_orders_cdc');
postgres=# CHECKPOINT;

CHECKPOINT가 완료되자마자 백그라운드 프로세스가 912GB 분량의 묶여 있던 WAL 세그먼트들을 일제히 정리하기 시작했고, 2분 만에 디스크 사용량은 100%에서 9%(88GB)로 수직 낙하했습니다. 임시로 심볼릭 링크를 걸어두었던 20개의 세그먼트도 이미 체크포인트 이전을 지나 자동 삭제되었음을 확인한 뒤, 새벽 5시 40분에 모든 결제 트래픽을 정상 복구할 수 있었습니다.

응급 복구 시각 (KST) 실행 단계 및 조치 내역 시스템 및 디스크 상태 변화
04:45 ~ 04:47 pg_wal 924GB 도달로 디스크 잔여 0 Byte ➔ 마스터 DB PANIC 즉사 결제 API 전 서버 커넥션 단절, P1 알람 발령
04:54 워룸 내 rm pg_wal/* 제안 거부 ➔ 크래시 복구 체인 훼손 방어 데이터 정합성 손상 및 풀 백업 롤백 참사 예방
05:02 ~ 05:08 50GB 임시 EBS 마운트 후 구형 세그먼트 20개 mv 및 ln -s 연결 메인 파티션에 320MB 물리 여유 공간 확보
05:12 PostgreSQL 데몬 재기동 및 REDO 크래시 복구 완주 DB 엔진 기동 성공, psql 관리 콘솔 진입
05:15 ~ 05:24 비활성 debezium_orders_cdc 슬롯 확인 후 pg_drop_replication_slot & CHECKPOINT 912GB WAL 즉각 청소 완료, 디스크 점유율 100% ➔ 9% 회복

PostgreSQL C 커널(slot.c)이 마스터가 죽는데도 WAL 삭제를 거부하는 메커니즘

도대체 왜 분석용 카프카 컨슈머 파드 하나가 죽었을 뿐인데 메인 결제 DB 마스터가 디스크 고갈로 동반 자살해야 했을까요? 그 답은 PostgreSQL 소스 코드 src/backend/replication/slot.c에 구현된 복제 슬롯의 엄격한 LSN 보존 불변식(Invariant)에 있습니다.

PostgreSQL 복제 슬롯 WAL 팽창과 50GB 가드레일

flowchart LR
    Debezium["Debezium 파드 OOM 사망<br/>(Standby Status Update 중단)"] --> SlotLock["restart_lsn 동결<br/>(ReplicationSlotsComputeRequiredLSN 고정)"]
    SlotLock --> Bloat["체크포인터 WAL 회수 실패<br/>(924GB 누적 · 디스크 100% 고갈)"]
    Bloat --> Panic["마스터 DB write() 실패<br/>(PANIC 즉사 💀)"]

PostgreSQL은 물리적 스탠바이 레플리카나 Debezium 같은 논리적 CDC 컨슈머가 네트워크 순단으로 잠시 끊어졌다가 다시 붙더라도 유실 없이 변경 이벤트를 이어서 읽어갈 수 있도록 복제 슬롯(Replication Slot) 기능을 제공합니다. 마스터의 백그라운드 체크포인터(Checkpointer)가 주기적으로 RemoveOldXlogFiles() 함수를 호출해 이미 데이터 파일(base/)에 반영된 오래된 WAL 세그먼트들을 삭제하거나 재활용하려 할 때, 커널은 반드시 아래의 ReplicationSlotsComputeRequiredLSN() 함수를 먼저 호출해 모든 슬롯의 최소 LSN을 검사합니다.

/* PostgreSQL C 커널 소스코드: src/backend/replication/slot.c 발췌 */
XLogRecPtr
ReplicationSlotsComputeRequiredLSN(void)
{
    XLogRecPtr  min_required = InvalidXLogRecPtr;
    int         i;

    LWLockAcquire(ReplicationSlotAllocationLock, LW_SHARED);
    for (i = 0; i < max_replication_slots; i++)
    {
        ReplicationSlot *s = &ReplicationSlotCtl->replication_slots[i];
        XLogRecPtr  restart_lsn;

        if (!s->in_use)
            continue;

        SpinLockAcquire(&s->mutex);
        restart_lsn = s->data.restart_lsn;
        SpinLockRelease(&s->mutex);

        if (restart_lsn != InvalidXLogRecPtr &&
            (min_required == InvalidXLogRecPtr || restart_lsn < min_required))
        {
            min_required = restart_lsn;
        }
    }
    LWLockRelease(ReplicationSlotAllocationLock);

    return min_required; /* 이 LSN 이후의 모든 WAL 세그먼트는 삭제가 절대 금지됨 */
}

코드가 보여주듯, 단 하나의 복제 슬롯이라도 in_use 상태라면 체크포인터는 그 슬롯에 기록된 restart_lsn보다 최신인 WAL 세그먼트를 절대로 삭제할 수 없습니다.

사고 전날 저녁 6시 30분경, 데이터 분석 클러스터의 카프카 브로커 점검 여파로 쿠버네티스에 떠 있던 Debezium 커넥터 파드가 메모리 부족(OOMKilled)으로 죽은 뒤 설정 오류로 인해 CrashLoopBackOff에 빠졌습니다. 컨슈머가 죽었으니 마스터 DB로는 “어느 LSN까지 카프카로 가져갔다”는 확인 응답(Standby Status Update 패킷)이 단 한 건도 오지 않았고, debezium_orders_cdc 슬롯의 restart_lsn과 confirmed_flush_lsn은 전날 저녁 6시 30분 시점에 그대로 얼어붙었습니다.

그날 밤 야간 프로모션과 정기 결제 배치가 돌아가며 시간당 약 90GB의 WAL이 쏟아져 나왔지만, 체크포인터는 죽어버린 Debezium 파드와의 의리를 지키느라 10시간 동안 생성된 57,750개의 WAL 세그먼트를 단 하나도 지우지 못하고 디스크에 차곡차곡 쌓아두었습니다. 아무런 상한선을 설정하지 않은 PostgreSQL의 기본값(max_slot_wal_keep_size = -1)에서는 “다운스트림 컨슈머의 데이터 보존이 마스터 데이터베이스의 생존보다 우선”하기 때문입니다.

숨겨진 2차 재앙: catalog_xmin 동결로 인한 오토진공(Autovacuum) 질식과 리눅스 더티 페이지 블로킹

사후 포스트모템을 위해 사고 직전 10시간 동안의 메트릭을 정밀 복기하던 저희 팀은, 디스크가 100% 차서 죽기 직전 약 40분 동안 이미 데이터베이스의 모든 SELECT 및 UPDATE 쿼리 레이턴시가 평소 3ms에서 1,200ms 이상으로 폭증해 있었다는 사실을 발견했습니다. 여기에는 복제 슬롯이 유발하는 두 가지 은밀한 커널 부작용이 겹쳐 있었습니다.

첫 번째는 논리적 복제 슬롯의 catalog_xmin 고정 현상입니다. 논리적 디코딩(pgoutput)은 과거의 WAL 레코드를 읽을 때 해당 시점의 테이블 스키마 구조를 해석해야 하므로, 슬롯이 멈춰 서 있는 시점의 트랜잭션 ID(catalog_xmin)를 전역 스냅샷 하한선으로 묶어둡니다. 이로 인해 PostgreSQL의 오토진공(Autovacuum) 데몬은 catalog_xmin 이후에 발생한 시스템 카탈로그 테이블(pg_class, pg_attribute, pg_statistic 등)의 데드 튜플(Dead Tuples)을 단 한 건도 청소(Vacuum)하지 못하고 건너뛰게 됩니다. 밤새 수만 건의 임시 테이블 생성·삭제와 통계 갱신이 일어나면서 시스템 카탈로그 테이블이 비정상적으로 부풀어 올랐고(Catalog Bloat), 쿼리 플래너가 실행 계획을 세우기 위해 카탈로그를 스캔하는 시간 자체가 수백 밀리초씩 지연되고 있었습니다.

두 번째는 디스크 사용량이 99%를 넘어선 시점에 발생한 리눅스 커널 페이지 캐시(Page Cache)의 동기 I/O 블로킹과 ext4 파일시스템의 블록 할당 병목이었습니다. 디스크에 빈 공간이 거의 사라지자 ext4의 멀티블록 할당자(ext4_mb_new_blocks)가 파편화된 프리 블록 비트맵을 뒤지느라 디스크 쓰기 처리량이 초당 5MB 이하로 급락했습니다. 그러자 백그라운드 플러시 데몬이 밀어내지 못한 더티 페이지(Dirty Page)가 OS 메모리의 vm.dirty_ratio 임계치를 돌파했고, 리눅스 커널은 WAL과 데이터 페이지를 기록하려던 PostgreSQL 백엔드 워커 프로세스들을 커널 공간에서 강제로 잠재우는 D-state(Uninterruptible Sleep, 동기 디스크 대기)로 몰아넣었습니다. 즉, 디스크가 정확히 0바이트가 되어 PANIC이 터지기 수십 분 전부터 이미 데이터베이스는 반쯤 질식해 있었던 것입니다.

마스터의 생존권을 강제하는 물리적 방어벽: max_slot_wal_keep_size와 비상 밸브 파일

이 우선순위 역전(Priority Inversion)을 원천 차단하기 위해 저희가 전사 모든 PostgreSQL 클러스터의 postgresql.conf에 즉시 배포한 설정은 max_slot_wal_keep_size와 idle_replication_slot_timeout(PostgreSQL 17+)입니다.

# /etc/postgresql/16/main/postgresql.conf

# 복제 슬롯이 보유할 수 있는 최대 WAL 크기를 50GB로 물리적 강제 제한 (기본값 -1 무제한 금지!)
max_slot_wal_keep_size = 51200MB

# 체크포인트 간격 및 최대 WAL 볼륨 크기 정렬
max_wal_size = 32768MB
min_wal_size = 4096MB

max_slot_wal_keep_size를 51200MB(50GB)로 지정하면, 카프카 컨슈머가 다시 죽어 미소비 WAL이 50GB를 넘어서는 순간 마스터 체크포인터는 가차 없이 해당 복제 슬롯의 wal_status를 lost로 무효화(Invalidate)시키고 오래된 WAL 세그먼트들을 삭제해 버립니다.

아키텍처 방어 축 기본 설정 (max_slot_wal_keep_size = -1) 프로덕션 가드레일 적용 (51200MB)
컨슈머 장기 장애 시 복제 슬롯이 무제한으로 WAL 세그먼트 삭제 차단 누적 지연 50GB 도달 시 슬롯 자동 무효화 (wal_status = 'lost')
마스터 디스크 상태 924GB까지 폭증하여 디스크 고갈로 마스터 PANIC 즉사 최대 50GB 선에서 안전 절단, 마스터 디스크 90% 이상 여유 유지
비즈니스 영향 반경 전사 결제 및 주문 트랜잭션 전면 마비 분석용 CDC 파이프라인만 격리 중단, 본체 결제 서비스 100% 무중단

이와 함께 저희 인프라 팀은 모든 DB 노드의 데이터 파티션에 5GB 크기의 비상 안전 밸브(Emergency Ballast File)를 미리 할당해 두는 운영 수칙을 추가했습니다.

# 평상시 DB 볼륨 초기 프로비저닝 단계에서 5GB 예약 더미 파일을 생성해 둠
$ sudo fallocate -l 5G /var/lib/postgresql/emergency_ballast.reserve
$ sudo chmod 000 /var/lib/postgresql/emergency_ballast.reserve

만약 미래에 또 다른 예기치 못한 원인으로 데이터 볼륨이 100% 가득 차서 엔진이 멈추더라도, 온콜 엔지니어는 새벽에 당황하며 AWS 콘솔에서 EBS 볼륨을 붙이고 마운트하느라 15분을 허비할 필요가 없습니다. SSH로 접속해 sudo rm -f /var/lib/postgresql/emergency_ballast.reserve 단 한 줄만 실행하면 0.01초 만에 5GB의 연속된 물리 블록이 즉시 반환되어 그 자리에서 데몬을 살려낼 수 있습니다.

나아가 슬롯 지연이 50GB 하드 리밋에 도달하기 전에 사전에 감지하고, 비활성 상태로 방치된 고아 슬롯과 catalog_xmin 지연을 자동 추적하는 가디언 데몬을 프로덕션에 상주시켰습니다.

# slot_guardian_daemon.py
import psycopg2
import requests

DB_DSN = "dbname=postgres user=postgres host=127.0.0.1 port=5432"
SLACK_WEBHOOK = "https://hooks.slack.com/services/T000/B000/XXXXX"
WARN_LAG_BYTES = 20 * 1024 ** 3      # 20 GB 경고 임계치
ABORT_LAG_BYTES = 42 * 1024 ** 3     # 42 GB 강제 절단 임계치 (50GB 커널 리밋 전 선제 대응)

def enforce_slot_guardrails():
    query = """
    SELECT slot_name,
           slot_type,
           active,
           wal_status,
           pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS wal_lag_bytes,
           age( coalesce(xmin, catalog_xmin) ) AS xmin_age_txids
    FROM pg_replication_slots;
    """
    with psycopg2.connect(DB_DSN) as conn:
        conn.autocommit = True
        with conn.cursor() as cur:
            cur.execute(query)
            for name, stype, active, status, lag_bytes, xmin_age in cur.fetchall():
                if lag_bytes is None:
                    continue
                lag_gb = lag_bytes / (1024 ** 3)

                # 비활성 고아 슬롯이 42GB 이상 WAL을 쥐고 있거나 트랜잭션 나이가 5천만을 넘기면 선제 드롭
                if (not active and lag_bytes >= ABORT_LAG_BYTES) or (xmin_age and xmin_age > 50_000_000):
                    cur.execute("SELECT pg_drop_replication_slot(%s);", (name,))
                    cur.execute("CHECKPOINT;")
                    alert = (
                        f"🚨 *[DB 자율 방어 발동]* 비활성 복제 슬롯 `{name}` ({stype}) 강제 드롭 완료!\n"
                        f"• 누적 WAL 지연: `{lag_gb:.2f} GB` / XMIN Age: `{xmin_age}`\n"
                        f"• 마스터 DB 디스크 고갈 및 Autovacuum 질식 방어를 위해 슬롯을 격리하고 CHECKPOINT를 수행했습니다."
                    )
                    requests.post(SLACK_WEBHOOK, json={"text": alert}, timeout=3.0)
                elif lag_bytes >= WARN_LAG_BYTES:
                    warn = (
                        f"⚠️ *[복제 슬롯 지연 경고]* 슬롯 `{name}` (active={active}) 지연 `{lag_gb:.2f} GB` 돌파 (XMIN Age: `{xmin_age}`)"
                    )
                    requests.post(SLACK_WEBHOOK, json={"text": warn}, timeout=3.0)

if __name__ == "__main__":
    enforce_slot_guardrails()

분산 데이터 파이프라인을 설계할 때 잊지 말아야 할 제1원칙은 명확합니다. 데이터 웨어하우스 적재나 실시간 분석 스트림은 비즈니스를 돕는 조연이지만, 고객의 결제를 처리하는 OLTP 마스터 데이터베이스는 비즈니스의 심장 그 자체입니다. 조연이 쓰러졌을 때 심장까지 함께 멈추도록 방치하는 기본 설정(-1)을 지금 당장 프로덕션에서 걷어내야 합니다.

← 목록으로