DB 스토리지 경고를 받고 급하게 접속해 본 경험은 운영을 해본 사람이라면 한 번쯤 있을 것이다. 그런데 막상 들어가 보면 테이블은 그대로인데 pg_wal만 부풀어 있는 경우가 꽤 있다. 스토리지를 늘려 급한 불은 껐는데 며칠 뒤 같은 알람이 또 오는 상황, 그때마다 내가 밟는 순서를 이번에 정리해 둔다.
이런 증상에서 자주 발견되는 원인 중 하나는 소비자가 사라졌는데도 남아 있는 복제 슬롯이다. 특히 DMS나 CDC를 사용하는 환경이라면 이것부터 의심해 볼 만하다. pg_replication_slots와 pg_stat_replication을 대조해 현재 연결이 없는 슬롯을 찾고, DMS 태스크나 CDC 커넥터가 실제로 폐기됐는지까지 확인한 뒤 pg_drop_replication_slot()으로 삭제한다. 그다음 max_slot_wal_keep_size로 상한을 걸고 알람을 붙여 재발을 막는 것이 이 글의 결론이다.
1. 배경과 증상 정의
먼저 WAL부터 짚고 간다. WAL(Write-Ahead Log, 미리 쓰기 로그)은 데이터 파일을 고치기 전에 "무엇을 어떻게 바꿀지"를 순차적으로 먼저 기록해 두는 로그다. 서버가 갑자기 죽어도 이 기록을 재생해 복구할 수 있고, 복제본이나 논리 복제 소비자도 같은 기록을 읽어서 원본을 따라온다. 기록은 기본 16MB짜리 세그먼트 파일로 pg_wal 디렉터리에 쌓이고, 더 이상 필요 없어진 세그먼트는 체크포인트 시점에 삭제되거나 재활용된다.
문제는 이 재활용이 멈출 때 나타난다. pg_wal(PostgreSQL 9.6 이전은 pg_xlog) 디렉터리 용량이 max_wal_size를 크게 넘어선 상태로 계속 늘어나고, 체크포인트를 아무리 돌려도 세그먼트가 줄지 않는다.
예를 들면 다음과 같이 되겠다. 폐기한 AWS DMS 태스크가 남긴 논리 슬롯 하나가 3주 동안 restart_lsn(그 슬롯이 "여기부터는 아직 못 읽었다"고 표시해 둔 WAL 위치)을 붙잡고 있어서, 100GB 스토리지 인스턴스에서 pg_wal만 68GB를 차지한다. 데이터는 15GB뿐이다.
물론 pg_wal이 줄지 않는 이유는 복제 슬롯 말고도 여러 가지다. WAL 아카이빙 실패, 한참 뒤처진 물리 복제본, 넉넉하게 잡아 둔 wal_keep_size, 오래 열려 있는 논리 디코딩 트랜잭션, 평소보다 높은 WAL 생성량, 진행 중인 백업이나 복구 작업이 모두 같은 증상을 만들 수 있다. 그중에서 슬롯을 먼저 보는 이유는 확인 비용이 가장 싸고, 제한이 없는 복제 슬롯 하나가 필요한 WAL을 계속 붙잡아 결국 디스크를 가득 채울 수 있기 때문이다.
진단은 pg_wal 실제 용량을 재는 것에서 시작한다. 그다음 슬롯 목록에서 active = false이면서 restart_lsn이 크게 뒤처진 항목을 찾고, 그 슬롯의 소비자가 정말 사라졌는지 pg_stat_replication과 소비자 쪽 상태를 함께 확인한다.
손쓸 방법은 즉시 효과를 보는 삭제, 재발을 막는 상한 설정, 미리 감지하는 알람으로 나뉜다.
- 소비자가 확실히 없는 슬롯을 삭제한다 (즉시 효과)
max_slot_wal_keep_size로 슬롯이 붙잡을 수 있는 WAL 상한을 지정한다 (재발 방지)- CloudWatch
OldestReplicationSlotLag,TransactionLogsDiskUsage에 알람을 걸어 미리 잡는다
아래에서는 용량 측정, 슬롯 식별, 안전한 삭제, 상한 설정, 알람 설정을 차례로 살펴본다. RDS for PostgreSQL과 EC2 자체 구축 환경을 함께 다룬다.
2. pg_wal 용량 측정
자체 구축 환경(EC2 등)이면 셸에서 바로 잰다.
$ du -sh /var/lib/pgsql/16/data/pg_wal 68G /var/lib/pgsql/16/data/pg_wal
RDS는 OS 접근이 안 되니 SQL로 잰다. pg_ls_waldir()은 슈퍼유저 또는 pg_monitor 권한이 필요하고, RDS에서는 rds_superuser 역할이면 된다. 이 함수는 서버 안에서 실행되므로 자체 구축 환경에서도 같은 결과가 나온다.
postgres=> SELECT count(*) AS segments, pg_size_pretty(sum(size)) AS pg_wal_size
postgres-> FROM pg_ls_waldir();
segments | pg_wal_size
----------+-------------
4352 | 68 GB
(1 row)
세그먼트 기본 크기는 16MB다. 세그먼트 수 x 16MB가 대략 용량이 된다. 여기서 한 가지 오해를 짚고 가야 한다. max_wal_size는 pg_wal 디렉터리의 절대 상한이 아니라 체크포인트를 유도하는 소프트 한도다. 다만 실제 사용량이 이 값을 크게 넘은 채 계속 증가한다면 복제 슬롯, wal_keep_size, 아카이빙 실패처럼 WAL 재활용을 막는 조건을 확인해야 한다. 현재 설정값도 같이 본다.
postgres=> SELECT name, setting, unit FROM pg_settings
postgres-> WHERE name IN ('max_wal_size','wal_keep_size','max_slot_wal_keep_size');
name | setting | unit
------------------------+---------+------
max_slot_wal_keep_size | -1 | MB
max_wal_size | 1024 | MB
wal_keep_size | 2048 | MB
(3 rows)
커뮤니티 PostgreSQL의 max_wal_size 기본값은 1GB, max_slot_wal_keep_size 기본값은 -1(무제한)이다. RDS는 wal_keep_size 기본값을 2048MB로 두고 있으니(버전 13 이전에는 wal_keep_segments = 32) 실제 값은 사용 중인 파라미터 그룹에서 직접 확인한다.
max_slot_wal_keep_size가 -1이면 슬롯은 WAL을 무한정 붙잡을 수 있다. 이 상태가 이 글의 사고 조건이다.
3. 슬롯 목록에서 범인 찾기
소스 인스턴스(라이터)에 접속해 슬롯별로 붙잡고 있는 WAL 양을 계산한다. pg_current_wal_lsn()은 복제본에서 오류가 나므로 반드시 소스에서 실행한다.
postgres=> SELECT slot_name, slot_type, database, active, active_pid, wal_status,
postgres-> pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
postgres-> FROM pg_replication_slots
postgres-> ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
slot_name | slot_type | database | active | active_pid | wal_status | retained_wal
----------------------------+-----------+----------+--------+------------+------------+--------------
dms_task_20250412 | logical | sarc | f | | extended | 62 GB
debezium_sarc_cdc | logical | sarc | f | | extended | 61 GB
rds_us_west_1_db_555555555 | physical | | t | 13194 | reserved | 48 MB
(3 rows)
여기서 active, retained_wal, slot_type을 같이 놓고 보면 상황이 정리된다.
active = false,active_pid IS NULL: 현재 이 슬롯을 사용하는 연결이 없다. 즉시 삭제 대상이라는 뜻은 아니며, 소비자 상태를 추가로 확인해야 하는 조사 후보다. 야간에만 도는 DMS 태스크, 장애로 일시 중단된 Debezium 커넥터, 재시작 예정인 구독일 수 있다.retained_wal: 현재 WAL 위치와 해당 슬롯의restart_lsn사이 거리다. 이 슬롯 때문에 최소한 어디까지의 WAL을 보존해야 하는지 보여주지만, 슬롯을 삭제했을 때 실제로 줄어드는 용량과 항상 같지는 않다. 다른 슬롯이나wal_keep_size가 같은 WAL을 붙잡고 있을 수 있기 때문이다. 위 예시의 62GB와 61GB도 상당 부분 같은 구간을 중복해서 세고 있다.slot_type = logical: 논리 복제, DMS, Debezium, pglogical 계열이다. 남아서 사고를 내는 슬롯은 대부분 여기다.
wal_status는 그 슬롯이 필요한 WAL을 어디까지 확보한 상태인지 알려주는 컬럼이다. reserved는 max_wal_size 범위 안에 있다는 뜻이고, extended는 max_wal_size를 넘었지만 복제 슬롯이나 wal_keep_size 때문에 필요한 WAL을 계속 보관하고 있다는 뜻이다. unreserved는 다음 체크포인트에서 일부가 지워질 예정이고, lost는 필요한 WAL이 이미 지워져 슬롯을 쓸 수 없는 상태다. unreserved와 lost는 주로 max_slot_wal_keep_size가 음수가 아닐 때 나타난다.
safe_wal_size로 남은 여유를 본다
max_slot_wal_keep_size를 이미 설정한 환경이라면 safe_wal_size가 더 직관적이다. 슬롯이 lost가 되기까지 앞으로 쓸 수 있는 WAL 바이트 수다.
postgres=> SELECT slot_name, wal_status, postgres-> pg_size_pretty(safe_wal_size) AS safe_wal_size_pretty postgres-> FROM pg_replication_slots postgres-> ORDER BY safe_wal_size ASC NULLS FIRST;
이 쿼리를 처음 쓸 때 한 번 걸리는 지점이 있다. pg_size_pretty(safe_wal_size) AS safe_wal_size처럼 별칭을 원본 컬럼과 같은 이름으로 주면 ORDER BY safe_wal_size가 출력 별칭(text)을 먼저 참조한다. 그러면 '20 GB'가 '9000 MB'보다 앞에 오는 문자열 정렬이 되어 정작 위험한 슬롯이 위로 올라오지 않는다. 위처럼 별칭 이름을 다르게 두거나 ORDER BY pg_replication_slots.safe_wal_size로 원본 컬럼을 명시한다.
safe_wal_size가 NULL인 행은 두 가지 의미다. max_slot_wal_keep_size가 -1이거나, 슬롯이 이미 lost다. 이 컬럼은 int8(바이트)이다.
PostgreSQL 17 이상이면 inactive_since를 쓴다
PostgreSQL 17에서 inactive_since와 invalidation_reason 컬럼이 추가됐다. 슬롯이 언제부터 유휴 상태인지 바로 나온다.
postgres=> SELECT slot_name, active, inactive_since, invalidation_reason postgres-> FROM pg_replication_slots WHERE NOT active;
invalidation_reason에는 WAL이 지워진 경우 외에도 rows_removed, wal_level_insufficient 같은 값이 들어갈 수 있다. 그래서 max_slot_wal_keep_size가 -1인 환경에서도 lost를 만날 수 있다.
PostgreSQL 16 이하에는 없는 컬럼이라 그대로 실행하면 ERROR: column "inactive_since" does not exist가 난다. 버전이 헷갈리면 \d pg_replication_slots로 컬럼을 먼저 확인한다.
4. 삭제 전 필수 대조: 살아 있는 복제본인지 확인
active = false라고 무조건 지우면 안 된다. 네트워크 단절로 잠시 끊긴 복제본도 active = false로 보인다. pg_stat_replication과 조인해서 실제 연결이 있는지 본다.
postgres=> SELECT s.slot_name, s.slot_type, s.active,
postgres-> r.client_addr, r.application_name, r.state, r.sync_state
postgres-> FROM pg_replication_slots s
postgres-> LEFT JOIN pg_stat_replication r ON r.pid = s.active_pid
postgres-> ORDER BY s.slot_name;
slot_name | slot_type | active | client_addr | application_name | state | sync_state
----------------------------+-----------+--------+--------------+------------------+-----------+------------
dms_task_20250412 | logical | f | | | |
debezium_sarc_cdc | logical | f | | | |
rds_us_west_1_db_555555555 | physical | t | 10.0.12.34 | walreceiver | streaming | async
(3 rows)
이 조인이 알려주는 것은 "지금 이 슬롯에 붙은 연결이 있는지"까지다. "앞으로 다시는 사용되지 않을 슬롯"임을 증명해 주지는 않는다. 조인 결과가 비어 있는 슬롯은 그다음 단계로 소비자 쪽을 봐야 한다. DMS 콘솔에서 태스크가 삭제됐는지, Debezium 커넥터가 내려갔는지, 구독을 만든 쪽 DB에 pg_subscription 항목이 남아 있는지 확인하는 식이다.
안됨: client_addr가 채워진 슬롯을 지우는 것. 됨: 조인 결과가 전부 비어 있고, 해당 소비자(DMS 태스크, 구독, 복제본)가 실제로 폐기됐음을 콘솔에서 확인한 슬롯을 지우는 것.
RDS 읽기 전용 복제본 슬롯은 손대지 않는다
RDS는 읽기 전용 복제본마다 소스 인스턴스에 물리 슬롯을 하나씩 만든다. 여기서 버전 조건을 정확히 봐야 한다. AWS 문서는 크로스 리전 복제본은 모든 버전이 복제 슬롯을 사용하고, 인 리전 복제본은 PostgreSQL 14.1 이상에서 복제 슬롯을 사용한다고 구분한다. 즉 14.1 조건은 인 리전에만 걸린다. PostgreSQL 13 이하라도 크로스 리전 복제본이 있으면 슬롯이 존재한다.
슬롯 이름이 rds_로 시작하면 RDS가 관리하는 물리 슬롯이다. 이건 SQL로 지우지 말고, 정말 필요 없다면 복제본 인스턴스를 삭제하거나 승격하는 방식으로 처리한다. 살아 있는 복제본의 슬롯을 지우면 복제가 깨져서 재구축해야 한다.
남아서 문제를 일으키는 쪽은 사람이 만든 논리 슬롯이다. 폐기한 DMS 태스크, 실험용 pg_create_logical_replication_slot(), 삭제된 구독의 잔여 슬롯, pglogical 업그레이드 작업 후 정리하지 않은 슬롯이 대표적이다.
5. 슬롯 삭제와 회수 확인
삭제 자체는 SQL 한 줄이다.
postgres=> SELECT pg_drop_replication_slot('dms_task_20250412');
pg_drop_replication_slot
--------------------------
(1 row)
슬롯이 활성 상태면 다음 오류가 난다.
ERROR: replication slot "dms_task_20250412" is active for PID 13194
이때 pg_terminate_backend(13194)로 세션을 끊고 지우는 방법이 있지만, 상대가 자동 재접속하는 복제본이나 CDC 커넥터면 곧 다시 붙는다. 이 오류는 현재 슬롯을 사용하는 세션이 있다는 뜻이다. 소비자를 먼저 확인하고 중지한 뒤 삭제해야 한다.
슬롯을 삭제해도 파일이 SQL 실행과 동시에 사라지는 것은 아니다. WAL 삭제와 재활용 여부는 체크포인트에서 다시 판단된다. 급하게 공간을 회수해야 한다면 운영 영향을 고려한 뒤 CHECKPOINT를 실행한다. 강제 체크포인트는 더티 버퍼를 한꺼번에 내려쓰기 때문에 I/O 부하를 일으킬 수 있으니, 피크 시간대에는 피하는 쪽이 좋다.
postgres=> CHECKPOINT; CHECKPOINT postgres=> SELECT count(*) AS segments, pg_size_pretty(sum(size)) AS pg_wal_size FROM pg_ls_waldir();
CHECKPOINT는 PostgreSQL 15 이상에서 슈퍼유저 또는 pg_checkpoint 역할이 필요하다. PostgreSQL 14 이하는 슈퍼유저만 실행할 수 있다. RDS에서는 권한이 있는 rds_superuser 계정으로 실행한다.
실제 회수량은 다른 슬롯과 wal_keep_size, 아카이빙 상태에 따라 달라진다. 삭제한 슬롯이 가장 오래된 restart_lsn을 단독으로 붙잡고 있었다면 큰 폭으로 줄지만, 다른 슬롯이 비슷한 위치를 붙잡고 있다면 거의 줄지 않을 수도 있다. 앞의 예시처럼 62GB와 61GB 슬롯이 나란히 있는 상황이라면, 하나만 지웠을 때 빠지는 양은 1GB 수준일 수 있다.
스토리지가 이미 꽉 차서 인스턴스가 storage-full 상태로 들어갔다면 순서를 바꾼다. 슬롯 삭제 전에 스토리지를 조금 늘려 DB를 살린 뒤 위 절차를 진행한다.
6. max_slot_wal_keep_size로 재발 막기
슬롯을 지웠으면 상한을 건다. max_slot_wal_keep_size는 체크포인트 시점에 복제 슬롯이 pg_wal에 붙잡아 둘 수 있는 WAL 최대 크기다. 단위를 생략하면 MB로 해석된다. 기본값 -1은 무제한이다.
값은 스토리지 여유분뿐 아니라 피크 시간대 WAL 생성량과 소비자가 중단될 수 있는 최대 시간을 기준으로 정한다. 예를 들어 피크 WAL 생성량이 시간당 5GB이고 4시간의 중단을 허용하려면 최소 20GB 이상이 필요하다. 여기에 변동 폭을 더하되, 데이터와 인덱스, 임시 파일, 일반 WAL이 사용할 공간은 별도로 남겨야 한다. 이 값을 넘어선 슬롯은 무효화되므로, 정상 복제본이 따라잡는 데 필요한 시간보다는 넉넉해야 한다.
피크 WAL 생성량은 CloudWatch TransactionLogsGeneration이나 pg_stat_wal의 wal_bytes 증가량으로 며칠 관측해 정한다. 아래 60GB는 명령 형식을 보여주기 위한 예시이며, 일반적인 권장값은 아니다.
RDS: 파라미터 그룹 수정
먼저 현재 값과 적용 방식을 확인한다. ApplyType이 dynamic이면 재부팅 없이 반영된다.
$ aws rds describe-db-parameters \
--db-parameter-group-name sarc-pg16-custom \
--query "Parameters[?ParameterName=='max_slot_wal_keep_size'].[ParameterName,ParameterValue,ApplyType]" \
--output table
기본 파라미터 그룹은 수정할 수 없으니, 사용자 지정 그룹이 없으면 먼저 만들어 인스턴스에 붙인다. 그다음 값을 넣는다.
$ aws rds modify-db-parameter-group \
--db-parameter-group-name sarc-pg16-custom \
--parameters "ParameterName=max_slot_wal_keep_size,ParameterValue=61440,ApplyMethod=immediate"
반영 상태를 확인하는 명령에서 실수하기 쉬운 부분이 있다. JMESPath 식별자에는 공백이 들어갈 수 없고, describe-db-instances 응답의 최상위 키는 공백 없는 DBInstances다. "DB Instances[0]"처럼 쓰면 Bad value for --query로 명령 자체가 실패한다.
$ aws rds describe-db-instances \
--db-instance-identifier sarc-pg-prod \
--query "DBInstances[0].[DBInstanceStatus,PendingModifiedValues]" \
--output json
[
"available",
{}
]
PendingModifiedValues가 비어 있으면 대기 중인 변경이 없다는 뜻이다. 최종 확인은 DB 안에서 한다.
postgres=> SELECT name, setting, unit, pending_restart FROM pg_settings
postgres-> WHERE name = 'max_slot_wal_keep_size';
name | setting | unit | pending_restart
------------------------+---------+------+-----------------
max_slot_wal_keep_size | 61440 | MB | f
(1 row)
자체 구축 환경: postgresql.conf
max_slot_wal_keep_size = 60GB
postgres=# SELECT pg_reload_conf(); pg_reload_conf ---------------- t (1 row)
pg_settings의 pending_restart가 f면 reload만으로 적용됐다는 뜻이다. t로 남으면 재시작이 필요한 상태이므로 그때 재시작한다.
무효화되면 되돌릴 수 없다
이 파라미터는 디스크를 지키는 대신 슬롯을 버린다. 상한을 넘긴 슬롯은 서버 로그에 invalidating과 슬롯 이름이 포함된 메시지를 남기고 다음 상태가 된다.
postgres=> SELECT slot_name, active, wal_status, safe_wal_size FROM pg_replication_slots;
slot_name | active | wal_status | safe_wal_size
-------------------+--------+------------+---------------
debezium_sarc_cdc | f | lost |
(1 row)
lost가 된 슬롯은 되살릴 수 없다. 그 슬롯을 쓰던 복제본이나 구독은 재구축해야 한다. 그래서 값을 너무 작게 잡지 말고, 알람을 먼저 붙여 무효화 전에 사람이 개입할 시간을 만들어 두는 순서를 권한다.
7. CloudWatch 알람: 단위는 바이트다
RDS라면 CloudWatch 지표로 미리 잡을 수 있다. 감시 목적이라면 지표 세 개로 충분하다.
OldestReplicationSlotLag: 가장 많이 뒤처진 복제본이 아직 받지 못한 WAL 데이터 크기다. 원인 쪽을 가장 먼저 알려준다. 단위 BytesTransactionLogsDiskUsage: WAL이 사용하는 실제 스토리지. 단위 BytesFreeStorageSpace: 남은 스토리지. 결과 쪽 안전장치다. 단위 Bytes
이 글의 주 시나리오처럼 DMS나 Debezium 같은 논리 슬롯이 문제라면 OldestLogicalReplicationSlotLag도 함께 보는 편이 낫다. 논리 슬롯 지연만 따로 떼어 볼 수 있어서 물리 복제본 지연과 섞이지 않는다.
여기서 자주 헷갈리는 지표가 ReplicationSlotDiskUsage다. 이 값은 슬롯이 붙잡고 있는 전체 WAL 크기와 같은 값이 아니다. 논리 디코딩 과정에서 슬롯 관련 파일이 사용하는 디스크 용량을 보여준다. pg_wal 증가를 감시하는 목적이라면 OldestReplicationSlotLag와 TransactionLogsDiskUsage가 더 직접적인 지표다. WAL 생성 속도 추이를 보려면 TransactionLogsGeneration(단위 Bytes per second)을 대시보드에 같이 올려 둔다.
AWS 문서에도 "RDS 콘솔은 지표를 MB로 표시할 수 있지만 CloudWatch로 전송되는 값은 바이트"라는 주석이 있다. 콘솔 그래프에서 본 숫자를 그대로 임계값에 넣으면 안 된다. 10GiB를 임계값으로 하려면 1024가 아니라 10737418240이다.
$ aws cloudwatch put-metric-alarm \
--alarm-name rds-pg-oldest-replication-slot-lag \
--namespace AWS/RDS \
--metric-name OldestReplicationSlotLag \
--dimensions Name=DBInstanceIdentifier,Value=sarc-pg-prod \
--statistic Maximum \
--period 300 \
--evaluation-periods 3 \
--threshold 10737418240 \
--comparison-operator GreaterThanThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:ap-northeast-2:111122223333:rds-alerts
현재 값을 눈으로 확인하려면 get-metric-statistics를 쓴다. 반환되는 Maximum도 바이트다.
$ aws cloudwatch get-metric-statistics \
--namespace AWS/RDS \
--metric-name OldestReplicationSlotLag \
--dimensions Name=DBInstanceIdentifier,Value=sarc-pg-prod \
--start-time 2026-07-28T00:00:00Z \
--end-time 2026-07-29T00:00:00Z \
--period 3600 --statistics Maximum \
--query "sort_by(Datapoints, &Timestamp)[].[Timestamp,Maximum]" \
--output text
FreeStorageSpace 알람까지 걸어 두면 이중 안전장치가 된다. 슬롯 지연이 늘어나는 단계에서 첫 알람이 울리고, 스토리지가 실제로 줄어드는 단계에서 두 번째 알람이 울린다.
8. 정리
pg_wal이 계속 커지면 pg_replication_slots부터 본다. active = false이면서 restart_lsn이 뒤처진 논리 슬롯이 자주 걸리는 원인이고, pg_stat_replication 대조로 현재 연결이 없다는 것을 확인하고 소비자가 실제로 폐기됐는지까지 본 뒤 pg_drop_replication_slot()으로 지운다. 회수 여부는 다음 체크포인트에서 판단되고, 회수량은 남은 다른 슬롯과 wal_keep_size에 따라 달라진다.
그다음 max_slot_wal_keep_size를 피크 WAL 생성량과 허용 중단 시간 기준으로 설정해 무제한 보관을 끊고, OldestReplicationSlotLag, TransactionLogsDiskUsage, FreeStorageSpace에 알람을 건다. 알람 임계값은 바이트로 넣는다. 이 세 가지를 해두면 같은 알람으로 새벽에 깨는 일이 크게 줄어든다.