커밋됐는데 왜 안 보일까 — PostgreSQL MVCC의 xmin/xmax 가시성 판정

seonwoo_jung·2026년 9월 2일

1. 도입

세션 A에서 BEGIN 후 REPEATABLE READ로 한 행을 SELECT했다. 그사이 세션 B가 그 행을 UPDATE하고 커밋까지 마쳤는데도, 세션 A에서 같은 행을 다시 SELECT하면 여전히 옛날 값이 나온다. pg_stat_activity로 세션 B의 커밋을 눈으로 확인했는데도 그렇다. 캐시가 안 갱신됐나 싶어 찾아봤지만, 원인은 훨씬 근본적인 곳에 있었다 — PostgreSQL 안에서 "그 값이 커밋됐는가"와 "그 값이 나에게 보이는가"는 완전히 다른 질문이고, 후자를 매번 계산하는 함수가 따로 있다.

2. 핵심 개념

PostgreSQL은 UPDATE를 제자리 수정이 아니라 "새 튜플을 heap에 추가 INSERT + 옛 튜플에 삭제 표시"로 처리한다. 그래서 신·구 버전이 같은 heap 안에 나란히 쌓인다. InnoDB라면 최신 버전만 남기고 옛 버전은 undo log로 밀어내 필요할 때 되감지만, PostgreSQL은 되감을 대상이 없다 — 이미 heap에 다 있다. 그 대신 튜플마다 "누가 이 버전을 만들었고(xmin) 누가 지웠는지(xmax)"를 저장해두고, 조회할 때마다 "이 버전을 만들거나 지운 트랜잭션이 내 스냅샷 기준으로 이미 끝났는가"를 판정한다. 이 판정 하나가 HeapTupleSatisfiesMVCC다.

3. 내부 동작

xmin/xmax는 HeapTupleHeaderData에 항상 박혀 있다.

typedef struct HeapTupleFields {
    TransactionId t_xmin;  // 이 버전을 만든(INSERT/UPDATE) 트랜잭션 ID
    TransactionId t_xmax;  // 이 버전을 지운(DELETE/UPDATE) 트랜잭션 ID, 없으면 0
} HeapTupleFields;

문제는 xmin/xmax가 트랜잭션 ID일 뿐이라는 점이다. 커밋됐는지 abort됐는지는 별도로 pg_xact(clog)를 봐야 안다. 매번 clog를 뒤지면 느리므로, 한 번 확인한 결과를 튜플의 t_infomask 비트(HEAP_XMIN_COMMITTED, HEAP_XMAX_COMMITTED 등)에 캐싱해 다음 방문자는 clog를 안 보게 만든다. 다만 이 hint bit는 커밋 WAL이 실제로 flush됐다는 보장이 있을 때만 세워진다 — 버퍼가 WAL보다 먼저 디스크에 내려가면 "커밋됐다"는 hint만 남고 진짜 커밋 레코드는 없는 모순이 생기기 때문이다.

세션이 들고 있는 스냅샷은 세 값으로 요약된다.

xmin: 이 값 미만 XID는 전부 보임 (오래전에 확정)
xmax: 이 값 이상 XID는 전부 안 보임 (스냅샷 이후 시작)
xip[]: xmin~xmax 구간 중 스냅샷을 뜬 순간 "아직 진행 중"이던 XID 목록

HeapTupleSatisfiesMVCC는 튜플의 xmin이 XMIN_COMMITTED hint를 이미 갖고 있어도 곧바로 "보인다"고 답하지 않는다. 그 커밋이 내 스냅샷 이후일 수 있어서, xip[]를 다시 확인한다. 이게 세션 A가 겪은 상황의 정체다 — 세션 B의 UPDATE는 진짜로 커밋됐지만, 세션 A의 스냅샷은 그보다 먼저 떠 있었고, 그 스냅샷의 xip[](또는 xmax 경계)가 "세션 B는 아직 안 끝난 것으로 취급"하도록 고정해버린 것이다. REPEATABLE READ는 트랜잭션 시작 시점 스냅샷을 트랜잭션 내내 재사용하므로, 세션 A가 다시 SELECT해도 결과는 바뀌지 않는다.

4. 예시로 확인하기

같은 트랜잭션 안에서 재현하면 이렇다.

-- 세션 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, v FROM demo WHERE id = 1;  -- xmin=100, v='old'

-- 세션 B (다른 접속)
UPDATE demo SET v = 'new' WHERE id = 1;
COMMIT;                                  -- xmin=101인 새 튜플이 커밋됨

-- 세션 A, 같은 트랜잭션 안에서 다시
SELECT xmin, v FROM demo WHERE id = 1;  -- 여전히 xmin=100, v='old'
COMMIT;

-- 세션 A, 새 트랜잭션에서
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT xmin, v FROM demo WHERE id = 1;  -- 이제 xmin=101, v='new'
COMMIT;

세션 A가 트랜잭션을 커밋하고 새로 시작해야만 새 스냅샷을 뜨고, 그제야 xmin=101 버전이 "내 스냅샷 기준으로도 커밋 확정"으로 판정된다. HeapTupleSatisfiesMVCC가 매 SELECT마다 새로 도는 게 아니라 트랜잭션이 뜬 스냅샷 하나를 계속 기준 삼는다는 게 이 결과로 드러난다.

5. 정리

커밋 여부와 가시성 여부는 별개 축이다 — xmin이 XMIN_COMMITTED여도 내 스냅샷의 xip[]에 걸리면 안 보인다.

이 xmin/xmax 체계는 이전에 다룬 HOT(Heap-Only Tuples) 글에서 본 t_ctid 체인과 같은 재료 위에서 동작한다 — 체인을 따라갈 때도 "다음 버전의 xmin == 이 튜플의 xmax"를 검증해야 한다. 더 파고들 만한 지점은 두 가지다: XID가 32비트라 발생하는 wraparound와 VACUUM의 freeze 처리, 그리고 이 판정을 페이지 단위로 미리 캐싱해 heap 접근 자체를 건너뛰는 visibility map.

참고 자료

0개의 댓글