RSS듀오랩스
데이터베이스

PostgreSQL autovacuum: 지운 행이 디스크에서 사라지지 않는 이유

작성자
듀오랩스 대표·11분 읽기

테이블에서 100만 행을 지웠습니다. DELETE는 성공했고 SELECT count(*)는 0을 돌려줍니다.

그런데 디스크 사용량은 그대로입니다. VACUUM을 직접 한 번 돌려도 숫자가 안 움직입니다.

버그가 아닙니다. PostgreSQL이 설계대로 동작한 결과입니다.

VACUUM은 공간을 되찾는 명령이 아닙니다

흔히 VACUUM을 "지운 데이터가 차지한 디스크를 회수하는 청소"로 이해합니다. autovacuum은 그 청소를 자동으로 돌려주는 것이고요. 이 이해로는 위 상황이 설명되지 않습니다.

공식 문서는 이렇게 적어 두었습니다.

The standard form of VACUUM removes dead row versions in tables and indexes and marks the space available for future reuse. However, it will not return the space to the operating system, in most cases.

(Routine Vacuuming)

핵심은 marks the space available for future reuse입니다. VACUUM이 하는 일은 죽은 행이 있던 자리를 앞으로 쓸 수 있다고 표시하는 것이지, 파일을 줄이는 것이 아닙니다. 100만 행을 지우고 VACUUM을 돌리면 그 자리는 비었지만, 파일 크기는 그대로 남아 다음 INSERT를 기다립니다.

그래서 정상 상태의 데이터베이스는 논리적 데이터보다 파일이 큽니다. 그게 낭비가 아니라 회전 공간입니다. 지우고 넣기를 반복하는 테이블이 디스크에서 오르내리지 않는 이유이기도 합니다.

저는 이 오해가 모니터링 그래프 때문에 굳어진다고 봅니다. 대시보드에 보이는 것은 대개 파일 크기고, VACUUM은 그 숫자를 안 건드립니다. 일이 되고 있는데 계기판이 안 움직이니 안 된 것처럼 보입니다.

지운 행이 왜 남아 있나

애초에 왜 죽은 행이라는 게 생기는지부터 봐야 합니다.

PostgreSQL은 행을 지울 때 그 자리를 바로 비우지 않습니다. 행마다 xmin(만든 트랜잭션)과 xmax(지운 트랜잭션)를 달아 두고, DELETExmax를 채우기만 합니다. 조회하는 쪽은 자기 스냅샷과 이 값을 비교해서 그 행이 자기에게 보이는지 판단합니다. 이게 MVCC입니다.

덕분에 읽는 쪽이 쓰는 쪽을 기다리지 않습니다. 대신 아무에게도 안 보이게 된 옛 버전이 파일에 남습니다. 이걸 dead tuple이라고 부릅니다.

UPDATE에서 더 커집니다. PostgreSQL의 UPDATE는 제자리 수정이 아니라 새 행을 쓰고 옛 행에 xmax를 찍는 것입니다. 한 행의 컬럼 하나를 백만 번 고치면 죽은 버전이 백만 개 쌓입니다. 갱신이 잦은 작은 테이블이 조회가 느려지는 전형적인 경로가 이겁니다. 행은 열 개인데 실제로 훑어야 하는 페이지는 수천 개일 수 있습니다.

VACUUM은 이 dead tuple을 찾아 그 공간을 Free Space Map에 등록합니다. 겸사겸사 통계도 갱신해서 플래너가 인덱스를 제대로 고르게 합니다. 통계 갱신만 따로 하고 싶으면 ANALYZE가 있습니다.

VACUUM FULL은 이름만 비슷한 다른 명령입니다

파일을 실제로 줄이는 것은 VACUUM FULL입니다. 이건 표시를 바꾸는 게 아니라 테이블을 새 파일로 통째로 다시 쓰고 옛 파일을 버립니다. 그래서 공간이 OS로 돌아갑니다.

이름이 비슷해서 VACUUM의 강한 버전처럼 들리지만 성격이 다릅니다.

VACUUM VACUUM FULL
하는 일 재사용 가능 표시 새 파일로 재작성
잠금 읽기·쓰기와 동시 진행 ACCESS EXCLUSIVE
파일 크기 그대로 줄어듦
추가 공간 없음 원본만큼 더 필요

ACCESS EXCLUSIVE는 그 테이블에 대한 SELECT까지 막습니다. 운영 중인 큰 테이블에 걸면 그동안 서비스가 멈춥니다. 게다가 새 파일을 다 쓸 때까지 옛 파일이 남아 있어야 하므로, 정리하려던 시점에 디스크가 일시적으로 두 배 필요합니다. 공간이 없어서 돌리려던 명령이 공간이 없어서 실패하는 상황이 여기서 나옵니다.

문서도 VACUUM FULL 대신 평소에 자주 VACUUM하기를 권합니다. 저도 같은 의견입니다. VACUUM FULL이 필요하다고 느껴지면 대개 진짜 문제는 따로 있습니다. 테이블 하나가 비정상적으로 부푼 원인을 안 찾고 파일만 줄이면 몇 주 뒤에 같은 자리로 돌아옵니다.

VACUUM을 돌렸는데도 안 지워질 때

여기가 정의만 알아서는 안 되는 지점입니다.

VACUUM은 dead tuple을 무조건 지우지 못합니다. 아직 그 행을 볼 수도 있는 트랜잭션이 하나라도 있으면 남겨 둡니다. 시스템 전체에서 가장 오래된 그 기준선을 xmin horizon이라고 부릅니다. 지평선이 뒤로 안 밀리면 VACUUM은 아무리 돌아도 할 일이 없습니다.

지평선을 붙잡는 것은 대개 셋 중 하나입니다.

긴 트랜잭션. 열어 두고 잊은 BEGIN, 커밋을 안 한 세션, 몇 시간짜리 배치. 그 트랜잭션이 실제로 그 테이블을 안 건드려도 상관없습니다. 열려 있다는 사실만으로 전체 지평선이 묶입니다.

복제 슬롯. 소비자가 사라졌는데 슬롯이 남아 있으면 그 슬롯의 xmin이 그대로 멈춰 있습니다. 쓰지 않는 슬롯 하나가 프라이머리의 청소를 통째로 막습니다.

prepared transaction. 2단계 커밋을 쓰다 만 흔적이 pg_prepared_xacts에 남으면 같은 일이 벌어집니다. 안 쓰는 기능이라 더 오래 방치됩니다.

그래서 "autovacuum이 안 도는 것 같다"는 증상을 볼 때 저라면 설정부터 만지지 않겠습니다. 먼저 pg_stat_activity에서 오래된 트랜잭션을, pg_replication_slots에서 죽은 슬롯을 봅니다. 설정을 공격적으로 바꿔도 지평선이 묶여 있으면 아무것도 달라지지 않습니다. 워커만 더 자주 돌면서 할 일이 없다고 돌아갑니다.

autovacuum이 도는 시점은 개수가 아니라 비율입니다

autovacuum은 주기적으로 도는 게 아닙니다. 테이블마다 죽은 튜플이 임계치를 넘었는지 보고 넘은 것만 처리합니다. 그 임계치가 절대 개수가 아니라 테이블 크기에 비례하는 값이라는 점이 중요합니다.

임계치 = autovacuum_vacuum_threshold
       + autovacuum_vacuum_scale_factor × 테이블 행 수

PostgreSQL 17 기준 기본값은 threshold 50, scale factor 0.2입니다(설정 문서). 행이 1,000개인 테이블은 250개가 죽으면 청소되지만, 1억 행짜리 테이블은 2,000만 개가 죽어야 청소가 시작됩니다.

큰 테이블일수록 늦게, 그리고 한 번에 크게 도는 구조입니다. 대형 테이블에서 autovacuum_vacuum_scale_factor를 테이블 단위로 낮춰 잡으라는 조언이 나오는 이유가 이겁니다. 저는 이 기본값이 작은 테이블에 맞춰져 있다고 보는 쪽입니다.

기본값은 버전에 따라 다릅니다. 현재 문서 기준으로는 scale factor가 0.1로 내려갔고, autovacuum_naptime이 1분에서 10초가 되었으며, 비율이 무한정 커지지 않도록 autovacuum_vacuum_max_threshold라는 상한이 새로 생겼습니다. 자기 서버 버전의 문서를 봐야 합니다. 블로그 글에서 본 숫자가 자기 서버의 기본값이라는 보장이 없고, 이건 저를 포함한 모든 글에 해당합니다.

INSERT만 하는 테이블도 따로 봅니다. 지우는 게 없으니 위 조건에 안 걸리는데, 추가만 되는 테이블에도 freeze와 visibility map 갱신이 필요합니다. 그래서 autovacuum_vacuum_insert_threshold 계열이 별도로 있습니다.

사실 더 중요한 일은 청소가 아닙니다

autovacuum을 성능 관리 도구로만 보면 절반만 본 것입니다. 이걸 끄면 결국 데이터베이스가 멈춥니다.

PostgreSQL의 트랜잭션 ID는 32비트입니다. 약 40억 개를 쓰면 한 바퀴 돌아 0으로 돌아옵니다. 그러면 과거 트랜잭션이 갑자기 미래로 보이고, 멀쩡한 행이 아무에게도 안 보이게 됩니다.

VACUUM은 이걸 막는 유일한 수단입니다. 충분히 오래된 행을 frozen으로 표시해서 순환과 무관하게 항상 과거로 취급되게 만듭니다. 그래서 모든 테이블은 20억 트랜잭션 안에 최소 한 번은 vacuum되어야 합니다.

PostgreSQL은 한계에 다가가면 경고를 남기고, 정말 임박하면 새 트랜잭션 ID 발급을 거부합니다. 이 상태가 되면 단일 사용자 모드로 내려가 수동으로 vacuum하는 것 말고 방법이 없습니다. 트래픽이 많은 시스템일수록 빨리 옵니다.

autovacuum_freeze_max_age(기본 2억)를 넘긴 테이블은 다른 조건과 무관하게 무조건 vacuum 대상이 됩니다. autovacuum을 껐더라도 이 경로는 돕니다. 마지막 방어선이라 그렇습니다.

이 사실을 알고 나면 "부하가 심하니 autovacuum을 잠깐 끄자"는 판단이 다르게 보입니다. 끄는 게 아니라 autovacuum_vacuum_cost_delay로 속도를 늦추는 쪽이 맞습니다.

설계에서 무엇이 달라지나

개념을 알고 나면 실제로 바뀌는 것은 대략 이렇습니다.

디스크 그래프가 안 줄어드는 것을 장애로 보지 않게 됩니다. 회전 공간이 잡힌 것이지 새는 게 아닙니다. 문제는 계속 커질 때고, 그때 봐야 하는 것은 VACUUM FULL이 아니라 지평선을 잡고 있는 무언가입니다.

갱신이 잦은 테이블을 다르게 봅니다. UPDATE 한 번이 행 하나를 새로 쓰는 일이라면, 카운터 컬럼 하나를 초당 수백 번 올리는 설계는 그만큼의 죽은 버전을 만듭니다. 그런 값은 별도 테이블이나 다른 저장소로 빼는 편이 낫습니다.

긴 트랜잭션을 성능이 아니라 위생 문제로 봅니다. 열어 둔 채 외부 API를 기다리는 트랜잭션은 그동안 데이터베이스 전체의 청소를 막습니다.

큰 테이블에는 테이블 단위로 autovacuum 설정을 겁니다. 전역 기본값은 작은 테이블 기준입니다.

여기까지가 확실한 부분

지금까지는 문서에 적힌 동작입니다. 그 너머는 갈립니다.

적정 scale_factor가 얼마인지는 정답이 없습니다. 테이블 크기, 갱신 패턴, 저장 장치, 여유 워커 수에 다 걸립니다. 저라면 대형 테이블에서 0.01~0.05 범위를 출발점으로 잡고 관찰하겠습니다. 다만 이건 판단이지 문서가 정해 주는 값이 아닙니다.

관리형 데이터베이스에서는 기본값이 벤더 손에 있습니다. 공식 문서의 기본값과 실제 서버의 값이 다를 수 있으므로, 조정하기 전에 SHOW autovacuum_vacuum_scale_factor로 직접 확인하는 편이 안전합니다.

인덱스 부풀림은 또 다른 주제입니다. VACUUM은 인덱스의 죽은 항목도 정리하지만, 그렇다고 인덱스가 원래 크기로 돌아가지는 않습니다. 그쪽은 REINDEX의 영역이고 판단 기준도 다릅니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.