왜 UPDATE가 인덱스를 갉아먹는가

PostgreSQL의 MVCC는 UPDATE를 "기존 튜플을 죽이고 새 튜플을 삽입"하는 방식으로 처리한다. 문제는 이 새 튜플이 다른 물리적 위치(다른 페이지)에 생기면, 그 테이블의 모든 인덱스가 새 위치를 가리키도록 갱신되어야 한다는 점이다. 컬럼 하나만 바꿔도 인덱스가 5개면 5개 모두 쓰기가 발생한다. 이는 WAL 증가, 인덱스 팽창(bloat), VACUUM 부담으로 이어진다.

특히 카운터, 상태 플래그, updated_at처럼 인덱스가 걸리지 않은 컬럼을 자주 갱신하는 워크로드에서 이 비용은 순수한 낭비다.

HOT 업데이트가 해결하는 것

HOT(Heap-Only Tuple) 업데이트는 두 조건이 맞을 때 발동한다. 첫째, 변경된 컬럼이 어떤 인덱스에도 포함되지 않을 것. 둘째, 새 튜플이 기존 튜플과 같은 페이지 안에 들어갈 여유가 있을 것. 이 경우 새 튜플은 인덱스를 건드리지 않고, 기존 튜플에서 HOT 체인으로 연결된다. 인덱스는 여전히 옛 튜플을 가리키지만 런타임에 체인을 따라가 최신 버전을 찾는다.

결과적으로 인덱스 쓰기 0회, WAL 감소, bloat 감소. 죽은 튜플은 무거운 VACUUM 없이 페이지 단위 정리(HOT pruning)로 회수된다.

fillfactor가 핵심인 이유

HOT의 두 번째 조건, "같은 페이지 안의 여유"가 바로 fillfactor로 제어된다. 기본값은 100으로, 페이지를 꽉 채운다. 그러면 UPDATE 시 같은 페이지에 새 튜플을 넣을 공간이 없어 HOT이 실패하고 다른 페이지로 이동한다. fillfactor를 낮추면(예: 80) 페이지의 20%를 UPDATE용 여유 공간으로 남겨 HOT 성공률을 높인다.

-- 자주 갱신되지만 인덱스 없는 컬럼을 다루는 테이블
ALTER TABLE sessions SET (fillfactor = 80);

-- 설정만으로는 기존 페이지에 적용되지 않는다. 재작성 필요:
VACUUM FULL sessions;   -- 또는 CLUSTER / pg_repack

-- 신규 생성 시부터 지정하는 편이 낫다
CREATE TABLE sessions (
  id          bigint PRIMARY KEY,
  user_id     bigint NOT NULL,
  last_seen   timestamptz NOT NULL,
  hit_count   int NOT NULL DEFAULT 0
) WITH (fillfactor = 80);

실제로 HOT이 동작하는지 검증

튜닝은 감이 아니라 측정으로 확인한다. pg_stat_user_tables의 n_tup_upd(전체 UPDATE)와 n_tup_hot_upd(HOT UPDATE)를 비교하면 HOT 비율이 나온다.

SELECT
  relname,
  n_tup_upd,
  n_tup_hot_upd,
  round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;

hot_pct가 90% 이상이면 이상적이다. 50% 이하로 낮다면 인덱스 컬럼을 갱신하고 있거나 페이지 여유가 부족한 것이다.

fillfactor 값 선택 기준

워크로드권장 fillfactor이유
읽기 전용 / append-only100 (기본)UPDATE 없음, 공간 낭비 방지
비인덱스 컬럼 잦은 UPDATE70~85HOT 여유 공간 확보
대량·광폭 UPDATE60~70큰 튜플이 페이지에 재삽입될 여지

무작정 낮추면 안 된다. fillfactor 70은 디스크 사용량과 읽기 시 페이지 수를 약 30% 늘린다. 캐시 효율과 스토리지 비용을 함께 고려해 70~85 범위에서 시작하고, hot_pct를 보며 조정하는 것을 권한다.

흔한 함정

  • 인덱스가 HOT을 막는다: updated_at에 인덱스를 걸어두면 updated_at을 갱신하는 순간 HOT이 깨진다. 정말 그 인덱스가 필요한지 재검토하라.
  • ALTER만으로 적용 안 됨: fillfactor 변경은 이후 새로 쓰이는 페이지에만 적용된다. 기존 데이터엔 pg_repack 등으로 재작성해야 반영된다.
  • 넓은 UPDATE: TOAST 대상이 아닌 큰 행을 갱신하면 페이지 여유를 초과해 HOT이 실패할 수 있다.
  • autovacuum 방치: HOT은 pruning에 의존한다. autovacuum이 밀리면 죽은 튜플이 쌓여 HOT 여유를 잠식한다.

정리

순서는 명확하다. 자주 UPDATE되는 테이블을 찾고 → 갱신 컬럼에 불필요한 인덱스가 없는지 확인하고 → fillfactor를 80 안팎으로 낮춰 재작성하고 → n_tup_hot_upd 비율로 검증한다. 코드 한 줄 바꾸지 않고 쓰기 부하와 인덱스 팽창을 줄일 수 있는, 비용 대비 효과가 큰 튜닝이다.