MVCC
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 한 행의 여러 버전을 함께 유지해서 읽기와 쓰기가 서로 기다리지 않게 하는 동시성 제어 방식입니다. 수정이 일어나면 기존 값을 바로 덮어쓰지 않고 새 버전을 만들고, 읽는 트랜잭션은 자기 스냅샷 시점에 맞는 버전을 읽습니다.
PostgreSQL 문서는 MVCC의 장점을 "읽기가 쓰기를 막지 않고 쓰기가 읽기를 막지 않는다"는 말로 설명합니다. 대신 이전 버전이 남기 때문에 쓸모없어진 버전을 치우는 정리 작업(PostgreSQL의 VACUUM)이 필요합니다. 이 토픽에서는 버전이 만들어지는 과정, 스냅샷 읽기, 정리 작업을 시각화로 확인합니다.
핵심 개념
다중 버전
한 행의 수정마다 새 버전이 생기고 이전 버전은 한동안 남습니다. 서로 다른 시점의 트랜잭션이 서로 다른 버전을 볼 수 있습니다.
핵심 개념
xmin과 xmax
PostgreSQL의 모든 행 버전에는 만든 트랜잭션 ID(xmin)와 지운 트랜잭션 ID(xmax, 지운 적이 없으면 보통 0)가 기록됩니다. 트랜잭션 ID는 32비트 값입니다.
핵심 개념
스냅샷
읽는 시점에 어떤 트랜잭션의 변경이 커밋되어 있는지를 기록한 기준입니다. 이 기준으로 어느 버전이 보이는지가 정해집니다.
핵심 개념
읽기와 쓰기의 분리
읽기는 쓰기와 충돌하는 잠금을 잡지 않고 자기 스냅샷의 버전을 읽으므로 쓰기를 기다리지 않고, 쓰기도 읽기를 기다리지 않습니다. 잠금 읽기(SELECT FOR UPDATE)는 예외입니다.
핵심 개념
dead tuple과 VACUUM
수정으로 남은 이전 버전은 어떤 트랜잭션도 볼 필요가 없어지면 dead tuple(회수 대상)이 되고, VACUUM이 테이블과 인덱스에서 이를 회수합니다. autovacuum이 기본으로 켜져 있어 주기적으로 점검하고 필요한 테이블에 자동 실행합니다.
핵심 개념
쓰기 충돌은 따로 처리
MVCC가 읽기와 쓰기의 충돌을 없애도 같은 행을 동시에 수정하는 두 쓰기는 서로 기다리거나 한쪽이 중단됩니다.
MVCC의 배경
번스타인(Bernstein)과 굿먼(Goodman)이 1983년 논문에서 알고 있는 가장 이른 다중 버전 동시성 제어 알고리즘으로 든 것은 1970년대 말 데이비드 리드(David P. Reed)의 다중 버전 타임스탬프 알고리즘입니다. 두 사람은 같은 논문 "Multiversion Concurrency Control — Theory and Algorithms"에서 다중 버전 데이터베이스의 정확성(직렬 가능성)을 분석하는 이론을 제시했습니다.
오늘날 PostgreSQL, MySQL InnoDB, Oracle 같은 주요 관계형 데이터베이스가 같은 계열의 다중 버전 방식을 사용합니다(Oracle 문서의 용어는 multiversion read consistency). 잠금 기반 방식에서 읽기와 쓰기가 서로 기다리는 문제를 줄이는 것이 목적이며, 격리 수준의 Repeatable Read와 Read Committed가 보는 스냅샷도 MVCC 위에서 구현됩니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
MVCC란 무엇이고 왜 쓰나요?
행의 여러 버전을 유지해 읽기와 쓰기가 서로 막지 않게 하는 방식입니다. 읽는 쪽은 자기 스냅샷의 버전을 보므로 읽는 중에도 일관된 결과를 얻습니다.
면접 질문
VACUUM은 무엇을 하나요?
더 이상 어떤 트랜잭션도 볼 필요가 없는 이전 버전(dead tuple)을 회수해 공간을 재사용할 수 있게 합니다. autovacuum이 기본으로 켜져 있어 필요한 테이블에 자동 수행합니다.
면접 질문
오래 열린 트랜잭션이 왜 문제인가요?
그 트랜잭션의 스냅샷 이후에 지워진 버전을 회수할 수 없어서 dead tuple이 쌓이고 테이블이 불어날 수 있습니다.
면접 질문
PostgreSQL과 InnoDB의 MVCC는 어떻게 다른가요?
PostgreSQL은 이전 버전을 테이블 안에 새 행으로 남기고, InnoDB는 클러스터드 인덱스 레코드를 제자리에서 고치고 이전 값을 언두 로그에 두어 필요할 때 복원합니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
SQL — PostgreSQL에서 버전과 VACUUM 확인
CREATE TABLE account (id int PRIMARY KEY, balance int); INSERT INTO account VALUES (1, 1000); SELECT xmin, xmax, balance FROM account; -- xmin = 삽입한 트랜잭션 ID, xmax = 0 UPDATE account SET balance = 500 WHERE id = 1; -- 새 버전이 만들어진다 SELECT xmin, xmax, balance FROM account; -- xmin이 UPDATE한 트랜잭션 ID로 바뀐 새 버전 SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'account'; -- 살아 있는 / 죽은 버전 수의 추정치 VACUUM (VERBOSE) account; -- 쓸모없어진 버전을 회수 SELECT pid, xact_start, state FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start; -- 오래 열린 트랜잭션 찾기
PostgreSQL은 모든 행에 xmin과 xmax라는 시스템 열을 두어 어떤 트랜잭션이 이 버전을 만들었고 지웠는지 기록합니다. UPDATE는 기존 행을 덮어쓰지 않고 기존 버전의 xmax를 표시한 뒤 새 버전을 만들기 때문에, 한 행이 여러 번 수정되면 테이블 안에 이전 버전이 쌓이고, 아무도 볼 필요가 없어진 버전은 dead tuple이 됩니다. 트랜잭션 ID의 실제 값은 환경마다 다릅니다. 더 이상 어떤 트랜잭션도 볼 필요가 없는 이전 버전은 VACUUM이 회수하고, 기본으로 켜져 있는 autovacuum이 주기적으로 점검해 필요한 테이블에 이를 자동 실행합니다. pg_stat_user_tables의 n_dead_tup은 통계에서 나온 추정치이며 반영이 늦을 수 있고, pg_stat_activity의 xact_start로 오래 열린 트랜잭션을 찾을 수 있습니다. 시각화의 버전 만들기 탭이 보여 주는 xmin과 xmax와 같습니다.
Python — 버전 목록, 스냅샷 읽기, VACUUM 흉내
class Row: def __init__(self, value, xid): self.versions = [{'val': value, 'xmin': xid, 'xmax': 0}] def update(self, value, xid): self.versions[-1]['xmax'] = xid # 이전 버전은 지우지 않고 xmax만 표시 self.versions.append({'val': value, 'xmin': xid, 'xmax': 0}) def read(self, snapshot): # 스냅샷 시점에 보이는 버전 for v in self.versions: if v['xmin'] <= snapshot and (v['xmax'] == 0 or snapshot < v['xmax']): return v['val'] def vacuum(self, horizon): # 기준 이전에 지워진 버전을 회수 before = len(self.versions) self.versions = [v for v in self.versions if v['xmax'] == 0 or v['xmax'] > horizon] return before - len(self.versions) row = Row(1000, 1) for value, xid in ((500, 3), (300, 5), (100, 7)): row.update(value, xid) print(len(row.versions), [row.read(t) for t in (2, 4, 8)]) # 4 [1000, 500, 100] print(row.vacuum(2), len(row.versions)) # 0 4 (기준 2에 묶임) print(row.vacuum(8), len(row.versions)) # 3 1 (v4만 남음)
MVCC의 핵심을 장난감 모델로 옮긴 코드입니다. 수정할 때마다 이전 버전의 xmax를 표시하고 새 버전을 추가하며, 읽을 때는 xmin이 스냅샷 이하이고 xmax가 없거나 스냅샷보다 큰 버전을 고릅니다. 스냅샷 2, 4, 8로 읽으면 각각 1000, 500, 100이 나와 시각화의 스냅샷 읽기 탭과 같은 결과입니다. 쓰기와 읽기가 서로 기다리지 않고 각자 자기 시점의 버전을 봅니다. vacuum은 기준(horizon)보다 이전에 지워진 버전을 제거합니다. 기준이 가장 오래된 열린 스냅샷 2에 묶여 있으면 지울 수 있는 버전이 0개이고, 기준이 8로 올라오면 v1, v2, v3 세 개가 회수되어 현재 버전 v4만 남습니다. 실제 PostgreSQL의 가시성 판단은 커밋 여부와 동시에 실행 중인 트랜잭션 목록까지 따지므로 훨씬 복잡합니다.
Java — TreeMap으로 만든 다중 버전 값
TreeMap<Long, Integer> versions = new TreeMap<>(); // 키 = 버전이 시작된 시각 versions.put(1L, 1000); versions.put(3L, 500); versions.put(5L, 300); versions.put(7L, 100); // 스냅샷 시각 이하에서 가장 최근 버전 → 1000, 500, 100 versions.floorEntry(2L).getValue(); versions.floorEntry(4L).getValue(); versions.floorEntry(8L).getValue(); // 기준 시각에 보이는 버전보다 오래된 버전을 지운다 long horizon = 8; versions.headMap(versions.floorKey(horizon)).clear(); // 기준 2에서는 0개, 8에서는 3개 제거 → {7=100}
자바에서는 TreeMap의 floorEntry가 스냅샷 시각 이하에서 가장 최근 버전을 찾아 주므로 다중 버전 값을 간단히 표현할 수 있습니다. 시각 2, 4, 8로 읽으면 1000, 500, 100을 돌려줍니다. 정리 단계에서는 기준 시각에 보이는 버전보다 오래된 항목을 headMap으로 잘라 내며, 기준 2에서는 지울 것이 없고 기준 8에서는 앞의 3개가 사라져 {7=100}만 남습니다. 이 코드는 개념 설명용이고 동시성 제어를 하지 않으며, 기준이 가장 이른 버전보다 작으면 floorKey가 null이라 오류가 납니다. 실제 데이터베이스는 여러 트랜잭션이 동시에 버전 목록을 읽고 쓰므로 커밋 상태, 동시 실행 목록, 쓰기 충돌 검사가 함께 필요합니다.
Q1
MVCC(다중 버전 동시성 제어)의 핵심 아이디어는?
Q2
PostgreSQL에서 MVCC로 남겨진 오래된 버전(dead tuple)을 정리하는 작업은?
Q3
MVCC에서 읽기 트랜잭션이 보게 되는 데이터는?