PostgreSQL은 UPDATE마다 인덱스를 새로 쓸까 — HOT이 인덱스 write를 없애는 법

seonwoo_jung·어제

인덱스가 5개 걸린 테이블에서, 인덱스에 안 걸린 컬럼 하나만 UPDATE 하면 인덱스 write는 몇 번 일어날까? "PostgreSQL의 UPDATE는 결국 DELETE + INSERT라 모든 인덱스에 새 엔트리가 생긴다"고 알고 있었다면 답은 5번이다. 하지만 실제로는 0번일 수 있다. 그걸 가능하게 하는 게 HOT(Heap-Only Tuples)이고, 이 글은 "인덱스를 안 건드렸는데 어떻게 최신 버전을 찾아가는가"를 소스와 문서를 따라가며 정리한 것이다.

1. 왜 인덱스가 문제였나

PostgreSQL은 MVCC를 위해 UPDATE를 제자리 수정이 아니라 새 버전 추가(append) 로 처리한다. 기존 튜플을 지우지 않고, 새 물리 위치에 새 버전을 쓴다.

문제는 인덱스 엔트리가 항상 힙 튜플의 물리 위치(TID = 페이지 번호 + 라인포인터) 를 가리킨다는 점이다. 새 버전이 새 위치에 생기면, 원칙적으로 그 row를 참조하는 모든 인덱스에 "새 TID를 가리키는 엔트리"가 추가돼야 한다. 인덱스가 5개면 컬럼 값이 하나도 안 바뀌어도 UPDATE 한 번에 인덱스 write가 5번. 여기에 인덱스 bloat(불필요하게 커진 인덱스)까지 따라온다. 이게 HOT 이전의 세계다.

2. HOT이 성립하는 두 조건

src/backend/access/heap/README.HOT이 정의하는 HOT UPDATE 조건은 두 가지다.

  1. 인덱싱된 컬럼이 하나도 안 바뀐다. 정확히는 인덱스가 참조하는 표현식의 결과가 이전과 동일해야 한다. 인덱스에 안 걸린 컬럼만 바뀌면 통과.
  2. 새 튜플이 같은 힙 페이지에 들어갈 자리가 있다. 자리가 없어 다른 페이지로 넘어가면, 인덱스가 가리켜야 할 위치가 페이지를 넘어가므로 HOT은 불가능하다.

두 조건을 만족하면 이런 일이 벌어진다.

  • 새 튜플은 힙 페이지 안에 쓰이지만 어떤 인덱스 엔트리도 이 새 튜플을 직접 가리키지 않는다. 그래서 이 튜플이 "heap-only tuple" — 자기를 가리키는 인덱스가 없어서 붙은 이름이다.
  • 구 튜플의 t_ctid 필드가 새 튜플의 라인포인터를 가리키도록 갱신된다 → HOT 체인이 만들어진다.
  • 구 튜플 헤더에 HEAP_HOT_UPDATED, 새 튜플 헤더에 HEAP_ONLY_TUPLE 플래그가 켜진다.

3. 인덱스 스캔이 최신 버전을 찾아가는 법

인덱스는 여전히 체인의 첫(루트) 튜플의 TID만 안다. 인덱스로 그 TID에 도달한 뒤, 실행기는 같은 힙 페이지 안에서 t_ctid를 따라 체인을 타고 내려가며 각 버전의 가시성(스냅샷 대비 보이는가)을 판정한다. 체인은 한 페이지 안에서만 존재하므로, 이 추적은 추가 페이지 I/O 없이 페이지 내부 포인터 점프로 끝난다. 이것이 "인덱스는 안 건드렸는데도 최신 버전을 찾는다"의 정체다.

[Index] --TID--> (LP1)   ← 인덱스가 가리키는 유일한 지점
힙 페이지:
  LP1 → tuple v1 [HEAP_HOT_UPDATED]                 t_ctid = LP2
  LP2 → tuple v2 [HEAP_ONLY_TUPLE, HEAP_HOT_UPDATED] t_ctid = LP3
  LP3 → tuple v3 [HEAP_ONLY_TUPLE]                   t_ctid = self  ← 현재 최신

4. HOT pruning — VACUUM 없이 죽은 버전 걷어내기

체인이 길어지면 스캔 비용이 늘고 페이지가 찬다. PostgreSQL은 일반 페이지 접근 중에도(SELECT/UPDATE가 그 페이지를 읽을 때) 기회적으로 heap_page_prune을 돌린다.

  • 체인 앞쪽의, 모든 트랜잭션에게 더 이상 안 보이는(dead) 튜플의 저장 공간을 회수한다.
  • 이때 인덱스가 가리키던 라인포인터(LP1)를 그냥 지우면 인덱스가 깨진다. 그래서 LP1을 redirect 라인포인터(LP_REDIRECT)로 바꿔 체인의 살아있는 첫 튜플을 가리키게 한다. 인덱스는 여전히 LP1을 가리키고, LP1은 이제 "여기 말고 저기"라고 재지향한다.
  • 완전히 dead여서 아무도 안 가리키는 라인포인터는 LP_DEAD로 표시해 재사용 대기 상태로 둔다.

핵심은 HOT pruning이 인덱스를 전혀 만지지 않는다는 점이다. heap-only 튜플들은 애초에 인덱스가 안 가리키므로 힙 안에서 조용히 정리할 수 있다. 인덱스까지 청소하는 일반 VACUUM과 다른, HOT이 주는 저비용 정리다.

라인포인터(ItemId)가 오가는 세 상태를 정리하면 이렇다.

상태의미인덱스가 가리켜도 되나
LP_NORMAL실제 튜플을 가리키는 정상 포인터
LP_REDIRECT"여기 말고 저 라인포인터로" 재지향예 (재지향을 따라감)
LP_DEAD튜플은 회수됐고 포인터만 남음 (재사용 대기)아니오

미묘한 점 하나: HOT 체인은 커밋된 버전만이 아니라 진행 중이거나 롤백된 버전도 잠시 담는다. UPDATE가 abort되면 그 새 튜플은 곧 dead가 되어 다음 pruning 때 회수된다. 인덱스는 처음부터 이 실패한 버전을 안 가리켰으므로 롤백해도 인덱스 정합성 문제가 없다 — HOT이 없었다면 abort된 인덱스 엔트리까지 나중에 청소해야 했을 것이다.

5. fillfactor — HOT이 계속 성립하게 하는 여백

두 번째 조건("같은 페이지에 자리")을 자주 만족시키려면 페이지에 빈 공간이 있어야 한다. fillfactor(기본 100)를 90 등으로 낮추면 INSERT 시 페이지의 10%를 비워두고, 이후 UPDATE의 새 버전이 그 여백에 들어가 HOT을 유지할 확률이 올라간다. 자주 UPDATE되는 테이블에서 fillfactor를 낮추는 튜닝이 여기서 나온다.

6. 눈으로 확인하기

pageinspect 확장으로 플래그와 체인을 직접 따라가 볼 수 있다.

CREATE EXTENSION pageinspect;
CREATE TABLE t (id int PRIMARY KEY, val text, memo text) WITH (fillfactor = 90);
CREATE INDEX ON t (val);           -- val은 인덱싱됨
INSERT INTO t VALUES (1, 'a', 'x');

-- (A) 인덱스 안 걸린 memo만 UPDATE → HOT 기대
UPDATE t SET memo = 'y' WHERE id = 1;

-- (B) 인덱싱된 val을 UPDATE → HOT 불가, 새 인덱스 엔트리 발생 기대
UPDATE t SET val = 'b' WHERE id = 1;

-- 힙 튜플 플래그 확인 (t_infomask2의 HEAP_HOT_UPDATED / HEAP_ONLY_TUPLE 비트)
SELECT lp, t_ctid, t_infomask2
FROM heap_page_items(get_raw_page('t', 0));
  • (A) 후: 새 튜플에 HEAP_ONLY_TUPLE, 구 튜플에 HEAP_HOT_UPDATED가 켜지고 t_ctid가 다음 라인포인터를 가리킨다.
  • (B) 후: 새 튜플에는 heap-only 플래그가 없고, val 인덱스에 새 엔트리가 추가된다.

통계로도 갈린다. SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't'; 에서 (A)는 n_tup_hot_upd를 올리지만 (B)는 n_tup_upd만 올린다. (버전에 따라 n_tup_newpage_upd 같은 컬럼이 추가됐다고 공식 문서에 나온다.) 즉 n_tup_hot_upd / n_tup_upd 비율은 실전에서 HOT 성공률을 진단하는 지표로 쓸 수 있다.

7. 정리하며 — 내가 잘못 알던 것

세 가지를 바로잡았다.

  • "PostgreSQL UPDATE는 항상 DELETE+INSERT라 인덱스도 매번 새로 쓴다" → 인덱싱된 컬럼이 안 바뀌고 페이지에 자리가 있으면 HOT이 인덱스 write를 통째로 생략한다. "항상"이 틀렸다.
  • "HOT이면 새 row 버전이 아예 안 생긴다" → 아니다. 새 버전은 여전히 힙에 만들어진다. 다만 인덱스 엔트리를 안 만들고, 정리를 VACUUM 없이 힙 내부에서 할 수 있을 뿐이다. MVCC의 append 성질 자체는 그대로다.
  • "인덱스가 최신 튜플을 직접 가리킨다" → 인덱스는 HOT 체인의 루트(또는 redirect 라인포인터)만 가리킨다. 최신 버전은 힙 안에서 t_ctid를 따라가 찾는다.

HOT은 "인덱싱 컬럼이 안 바뀐 UPDATE"에 한해 인덱스를 그대로 두고, 같은 페이지 안에서 t_ctid 체인으로 버전을 잇는 최적화다. 인덱스 write와 인덱스 bloat를 통째로 없앤다.

더 파고들 만한 주제로는 (1) redirect/dead 라인포인터가 쌓일 때의 인덱스 bloat와 VACUUMLP_DEAD 회수 상호작용, (2) HOT과 index-only scan·visibility map의 관계가 남아 있다.

참고 자료

  • PostgreSQL 소스 src/backend/access/heap/README.HOT
  • PostgreSQL 16 Documentation §73.7 "Heap-Only Tuples (HOT)"
  • PostgreSQL Documentation — Storage Parameters (fillfactor), pageinspect, pg_stat_user_tables

0개의 댓글