MVCC: 데이터를 덮어쓰지 않고 버전으로 쌓아 읽기와 쓰기의 충돌을 줄이는 동시성 제어
다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)는 한 행(row)의 여러 버전을 동시에 유지하고, 각 트랜잭션이 자신의 스냅샷(snapshot)에 보이는 버전만 읽게 하는 기법이다. 읽기가 쓰기를 막지 않고 쓰기가 읽기를 막지 않으므로 동시 처리량이 높아진다. PostgreSQL은 튜플의 xmin/xmax와
By 송민성
1. 개념
다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)는 데이터를 수정할 때 기존 값을 덮어쓰지 않고 새 버전(version)을 만들어, 트랜잭션마다 자신이 볼 수 있는 버전을 선택해 읽게 하는 방식이다.
핵심 용어는 다음과 같다.
- 버전(version): 한 행(row)이 특정 시점에 가졌던 값. PostgreSQL에서는 튜플(tuple)이라 부른다.
- 스냅샷(snapshot): 트랜잭션이 "어느 시점까지 커밋된 데이터를 볼 것인가"를 정한 기준.
- 가시성(visibility): 특정 버전이 특정 스냅샷에서 보이는지 여부.
2. 왜 사용하는가
잠금(lock) 기반 동시성 제어에서는 읽는 쪽이 공유 잠금(shared lock)을 잡고, 쓰는 쪽이 배타 잠금(exclusive lock)을 잡는다. 같은 행에 대해 읽기와 쓰기가 서로를 기다리게 된다. 웹 서비스처럼 읽기 요청이 많은 워크로드에서는 이 대기가 병목이 된다.
MVCC는 이 문제를 버전 분리로 해결한다.
- 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.
- 하나의 트랜잭션 안에서 일관된 시점의 데이터를 볼 수 있다(스냅샷 격리, snapshot isolation).
- 같은 행을 동시에 수정하려는 쓰기끼리는 여전히 충돌을 조정해야 한다.
3. 동작 원리
3.1 PostgreSQL의 버전 저장 방식
PostgreSQL은 UPDATE를 "기존 튜플을 삭제 표시하고 새 튜플을 삽입"하는 것으로 처리한다. 각 튜플 헤더에는 다음 시스템 컬럼이 있다.
xmin: 이 튜플을 생성한 트랜잭션 IDxmax: 이 튜플을 삭제(또는 갱신)한 트랜잭션 ID. 살아 있으면 0
UPDATE 이전: [v1: xmin=100, xmax=0 ]
UPDATE 이후: [v1: xmin=100, xmax=105] → 105가 커밋되면 낡은 버전
[v2: xmin=105, xmax=0 ]3.2 가시성 판단
트랜잭션이 튜플을 읽을 때 스냅샷을 기준으로 대략 다음을 확인한다.
xmin트랜잭션이 스냅샷 시점에 커밋되어 있어야 한다.xmax가 0이거나,xmax트랜잭션이 아직 커밋되지 않았거나 스냅샷 이후에 커밋되었어야 한다.
두 조건을 만족하는 버전만 보인다. 그래서 트랜잭션 A가 읽는 동안 B가 같은 행을 수정·커밋해도 A는 자신의 스냅샷에 속한 이전 버전을 계속 본다.
3.3 격리 수준과 스냅샷 시점
- READ COMMITTED(기본값): 문장(statement)마다 새 스냅샷을 만든다.
- REPEATABLE READ: 트랜잭션의 첫 쿼리 시점에 스냅샷을 만들어 끝까지 유지한다. PostgreSQL에서는 스냅샷 격리로 구현된다.
3.4 낡은 버전의 정리
어떤 활성 트랜잭션에게도 보이지 않는 튜플은 죽은 튜플(dead tuple)이다. 이는 VACUUM(주로 autovacuum)이 회수해 재사용 가능하게 만든다.
참고로 MySQL InnoDB는 방식이 다르다. 최신 버전을 테이블에 두고, 이전 버전은 언두 로그(undo log)로 되돌려 구성한다.
4. 코드 예제
두 개의 세션(psql 창)을 열어 순서대로 실행한다.
-- 준비
CREATE TABLE account (
id int PRIMARY KEY,
balance int NOT NULL
);
INSERT INTO account VALUES (1, 1000);-- [세션 A] REPEATABLE READ 트랜잭션 시작
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT id, balance, xmin, xmax FROM account WHERE id = 1;
-- balance = 1000-- [세션 B] 같은 행을 수정하고 커밋 (A가 대기하지 않음)
BEGIN;
UPDATE account SET balance = 700 WHERE id = 1;
COMMIT;-- [세션 A] 다시 조회
SELECT id, balance, xmin, xmax FROM account WHERE id = 1;
-- 여전히 balance = 1000 (A의 스냅샷 기준)
COMMIT;
-- 트랜잭션 종료 후 조회하면 balance = 700, xmin이 B의 트랜잭션 ID로 바뀌어 있다
SELECT id, balance, xmin, xmax FROM account WHERE id = 1;죽은 튜플 확인과 정리는 다음과 같이 한다.
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'account';
VACUUM (VERBOSE) account;쓰기 충돌 예시는 다음과 같다. REPEATABLE READ에서 A가 스냅샷을 잡은 뒤 B가 커밋한 행을 A가 수정하려 하면 오류가 난다.
-- [세션 A]
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM account WHERE id = 1;
-- [세션 B] UPDATE 후 COMMIT 완료
-- [세션 A]
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
ROLLBACK;5. 시간 복잡도 또는 성능 특성
MVCC는 알고리즘 하나가 아니라 설계 방식이므로 단일 시간복잡도로 말할 수 없다. 성능 특성은 다음처럼 정리한다.
- 읽기 동시성: 읽기가 행 잠금을 기다리지 않아 읽기 중심 워크로드에서 처리량이 높다.
- 쓰기 비용: PostgreSQL에서 UPDATE는 새 튜플을 쓰므로 인덱스 갱신과 저장 공간 증가를 동반할 수 있다. 조건이 맞으면 HOT(Heap-Only Tuple) 업데이트로 인덱스 갱신을 피한다.
- 공간 비용: 낡은 버전이 정리되기 전까지 테이블과 인덱스가 부풀어 오른다(bloat).
- 정리 비용: VACUUM이 주기적으로 I/O와 CPU를 사용한다.
6. 실무 사용 사례
- PostgreSQL, MySQL InnoDB, Oracle 등 주요 RDBMS가 MVCC 기반 일관된 읽기를 제공한다.
- 리포트·통계 쿼리: 긴 조회가 OLTP 쓰기를 막지 않고 일관된 시점의 데이터를 읽는다.
- 백업·덤프:
pg_dump는 하나의 스냅샷으로 일관된 백업을 만든다. - 낙관적 재시도: 직렬화 실패(serialization failure)나 갱신 충돌 오류를 애플리케이션에서 재시도하는 패턴이 쓰인다.
7. 주의할 점
- 장시간 트랜잭션: 오래 열린 트랜잭션은 낡은 버전을 회수하지 못하게 막아 bloat의 주된 원인이 된다.
idle in transaction상태를 방치하지 않는다. - autovacuum 설정: 갱신이 잦은 대형 테이블은 기본 임계값으로 부족할 수 있어 테이블별 튜닝이 필요하다.
- 스냅샷 격리 ≠ 직렬화 가능(serializable): REPEATABLE READ에서도 쓰기 왜곡(write skew) 같은 이상 현상이 가능하다. 엄격한 보장이 필요하면 SERIALIZABLE을 쓰고 실패 시 재시도한다.
- SELECT로 읽은 값 기반의 갱신: 읽고 계산해서 쓰는 로직은
SELECT ... FOR UPDATE나 원자적UPDATE로 경합을 다뤄야 한다. - 트랜잭션 ID 고갈: PostgreSQL의 트랜잭션 ID는 32비트이며 순환한다. VACUUM의 freeze 작업이 이를 막아 주므로 autovacuum을 끄면 안 된다.
8. 핵심 정리
- MVCC는 행을 덮어쓰지 않고 여러 버전을 유지하며, 트랜잭션은 스냅샷에 보이는 버전만 읽는다.
- PostgreSQL은 튜플의
xmin/xmax와 스냅샷으로 가시성을 판단하고, InnoDB는 언두 로그로 이전 버전을 구성한다. - 읽기와 쓰기는 서로 막지 않지만, 같은 행에 대한 쓰기끼리는 충돌을 조정한다.
- 대가는 죽은 튜플과 bloat이며, VACUUM으로 정리한다.
- 장시간 트랜잭션을 피하고, 격리 수준별 보장 범위를 이해한 뒤 필요하면 재시도 로직을 둔다.