[IT 심층 분석]
PostgreSQL VACUUM의 실패로 테이블 Bloat가 누적되어 쿼리 성능이 수십 배 느려진 악몽 해결기
김민준 · IT 시스템 엔지니어|
어느 날 갑자기 핵심 서비스의 데이터베이스 조회 응답 속도가 10ms에서 8초대로 치솟는 대재앙이 발생했습니다. explain analyze로 쿼리 실행 계획을 보니 분명히 B-Tree 인덱스가 존재하는 컬럼을 조건으로 검색하는데도 데이터베이스가 집요하게 Sequential Scan(풀 테이블 스캔)을 고집하고 있었습니다. 인덱스 자체가 손상된 것인지 의심하며 REINDEX를 수행해도 전혀 개선이 없었습니다. 원인은 PostgreSQL의 MVCC(다중 버전 동시성 제어) 구조에서 비롯된 테이블 팽창(Table Bloat)이었습니다.
PostgreSQL은 UPDATE나 DELETE를 수행할 때 기존 행을 물리적으로 지우지 않습니다. 삭제된 버전의 행을 Dead Tuple로 표시만 해두고 새로운 버전을 옆에 씁니다. 이 Dead Tuple들은 주기적으로 실행되는 VACUUM 프로세스가 치워줘야 하는데 문제의 테이블은 Auto-vacuum이 과부하로 인해 제때 실행되지 못한 채 몇 주에 걸쳐 Dead Tuple이 쌓인 결과 실제 살아있는 데이터는 500만 행인데 테이블 파일의 물리적 크기는 죽은 행들이 점령한 채 50배인 2억 5천만 행 분량으로 부풀어 있었습니다. 인덱스도 이 팽창된 테이블 크기를 기준으로 통계가 계산되어 쿼리 플래너가 인덱스 스캔보다 풀 스캔이 더 효율적이라는 완전히 잘못된 판단을 내린 것이었습니다.
즉각적인 복구를 위해 VACUUM FULL 명령을 수행했습니다. 이 명령은 일반 VACUUM과 달리 테이블을 처음부터 재작성하여 Dead Tuple을 모두 제거하고 진짜 데이터만 담은 컴팩트한 파일로 만들어냅니다. 다만 수행 중에는 테이블에 Exclusive Lock이 걸려 접근이 불가능하기 때문에 유지보수 시간을 별도로 확보해 진행했습니다. VACUUM FULL 완료 후 테이블 크기는 원래 크기로 돌아왔고 쿼리 응답 시간은 다시 10ms대로 회복되었습니다. 재발 방지를 위해서는 autovacuum_vacuum_scale_factor 설정값을 낮추고 변경이 잦은 테이블에 대해서는 개별 테이블 단위로 autovacuum 실행 트리거를 더 민감하게 조정했습니다. PostgreSQL을 운영하는 DBA라면 VACUUM은 선택이 아닌 생존의 필수 조건임을 절대로 잊어서는 안 됩니다.