[Daily morning study] MVCC(다중 버전 동시성 제어) 작동 원리
#daily morning study
MVCC란
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 데이터베이스가 동시성을 처리하는 핵심 메커니즘이다. 하나의 데이터에 여러 버전을 유지해서 읽기와 쓰기가 서로를 차단하지 않게 만드는 방식이다.
기존 락(Lock) 기반 방식에서는 누군가 데이터를 쓰고 있으면 읽는 쪽도 기다려야 했다. MVCC를 쓰면 쓰기가 진행 중인 데이터도 이전 버전을 읽어서 대기 없이 응답할 수 있다.
PostgreSQL, MySQL InnoDB, Oracle 등 대부분의 상용 RDBMS가 MVCC를 지원한다.
핵심 개념
스냅샷(Snapshot)
트랜잭션이 시작될 때 데이터베이스의 “스냅샷”을 찍어서, 그 시점의 데이터만 본다. 이후 다른 트랜잭션이 데이터를 수정해도 이 트랜잭션은 원래 스냅샷 기준으로 읽는다.
트랜잭션 ID(XID)
각 트랜잭션에는 고유한 ID가 순서대로 부여된다. 데이터의 각 버전에는 어느 트랜잭션이 만들었고(xmin), 어느 트랜잭션이 삭제했는지(xmax)를 기록한다.
Undo Log / Version Chain
MySQL InnoDB는 데이터를 변경할 때 이전 버전을 Undo Log에 저장하고, 각 행에 이전 버전 포인터를 달아 버전 체인을 형성한다. 오래된 스냅샷이 필요한 트랜잭션은 체인을 타고 거슬러 올라가 해당 버전을 찾는다.
PostgreSQL의 MVCC 구현
PostgreSQL은 행(row)마다 숨겨진 시스템 컬럼을 붙인다.
| 컬럼 | 설명 |
|---|---|
xmin | 이 행을 삽입한 트랜잭션 ID |
xmax | 이 행을 삭제/갱신한 트랜잭션 ID (없으면 0) |
ctid | 행의 물리적 위치 (페이지 번호 + 행 번호) |
-- 숨겨진 컬럼 직접 조회 가능
SELECT xmin, xmax, ctid, * FROM users WHERE id = 1;
UPDATE를 하면 기존 행을 지우는 게 아니라 xmax를 채우고 새 행을 INSERT한다. 즉 UPDATE = 논리적 삭제 + 새 버전 삽입.
-- 트랜잭션 100이 name='Alice'를 INSERT
(xmin=100, xmax=0) → name='Alice'
-- 트랜잭션 200이 name='Bob'으로 UPDATE
(xmin=100, xmax=200) → name='Alice' ← 구버전, xmax 채워짐
(xmin=200, xmax=0) → name='Bob' ← 신버전
트랜잭션 150이 이 시점에 SELECT하면 xmin=100 ≤ 150 이고 xmax=200 > 150이므로 구버전 Alice를 읽는다.
MySQL InnoDB의 MVCC 구현
InnoDB는 행마다 두 가지 숨겨진 필드를 관리한다.
- DB_TRX_ID: 해당 행을 마지막으로 수정한 트랜잭션 ID
- DB_ROLL_PTR: Undo Log의 이전 버전을 가리키는 포인터
UPDATE가 발생하면 변경 전 데이터를 Undo Log에 기록하고 DB_ROLL_PTR로 연결한다.
현재 행: DB_TRX_ID=500, name='Bob'
↓ DB_ROLL_PTR
Undo Log: DB_TRX_ID=300, name='Alice'
↓ DB_ROLL_PTR
Undo Log: DB_TRX_ID=100, name='Chuck'
트랜잭션이 예전 스냅샷을 필요로 하면 포인터를 따라가며 자신의 Read View 기준에 맞는 버전을 찾는다.
Read View
InnoDB는 트랜잭션 시작 시 Read View를 생성한다. Read View에는 아래 정보가 담긴다.
m_ids: 현재 활성 상태인 트랜잭션 ID 목록min_trx_id: 활성 트랜잭션 중 가장 작은 IDmax_trx_id: 다음에 생성될 트랜잭션 ID (현재 최대 + 1)creator_trx_id: Read View를 만든 트랜잭션 ID
행의 DB_TRX_ID를 보고 가시성을 판단한다.
if DB_TRX_ID < min_trx_id:
→ Read View 생성 전에 커밋됨 → 보임
elif DB_TRX_ID >= max_trx_id:
→ Read View 생성 후 시작됨 → 안 보임
elif DB_TRX_ID in m_ids:
→ 아직 활성 트랜잭션 → 안 보임
else:
→ Read View 생성 전에 커밋됨 → 보임
격리 수준과 MVCC의 관계
MVCC는 격리 수준마다 스냅샷을 다르게 관리한다.
| 격리 수준 | 스냅샷 시점 | 특징 |
|---|---|---|
READ COMMITTED | 각 SELECT마다 새 스냅샷 | Non-repeatable Read 발생 가능 |
REPEATABLE READ | 트랜잭션 시작 시 한 번만 | Phantom Read 방지 (InnoDB는 Gap Lock 병행) |
SERIALIZABLE | 모든 읽기에 락 추가 | 완전 직렬화, 성능 저하 |
-- READ COMMITTED: 매번 다른 스냅샷
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT name FROM users WHERE id = 1; -- 'Alice' 반환
-- 다른 트랜잭션이 'Bob'으로 UPDATE + COMMIT
SELECT name FROM users WHERE id = 1; -- 'Bob' 반환 (스냅샷 갱신됨)
COMMIT;
-- REPEATABLE READ: 트랜잭션 시작 시 스냅샷 고정
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT name FROM users WHERE id = 1; -- 'Alice' 반환
-- 다른 트랜잭션이 'Bob'으로 UPDATE + COMMIT
SELECT name FROM users WHERE id = 1; -- 여전히 'Alice' 반환
COMMIT;
MVCC의 장단점
장점
- 읽기-쓰기 충돌 없음: 읽는 트랜잭션이 쓰는 트랜잭션을 기다리지 않는다.
- 높은 동시성: Lock 경쟁이 줄어들어 처리량이 높다.
- 일관된 읽기: 트랜잭션 중간에 외부 변경이 생겨도 일관된 데이터를 볼 수 있다.
단점
- 스토리지 오버헤드: 여러 버전의 데이터를 보관해야 하므로 공간을 더 쓴다.
- Vacuum/Purge 필요: 오래된 버전은 주기적으로 정리해줘야 한다. PostgreSQL의
VACUUM, InnoDB의 Purge Thread가 이 역할을 한다. - Long-running Transaction 문제: 오래 열린 트랜잭션이 있으면 그 스냅샷 시점까지의 구버전을 계속 유지해야 해서 용량이 불어난다.
-- PostgreSQL: 오래된 행 확인
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY duration DESC;
-- VACUUM으로 불필요한 구버전 정리
VACUUM ANALYZE users;
락과의 비교
| 구분 | Lock 기반 | MVCC |
|---|---|---|
| 읽기-쓰기 충돌 | 블로킹 발생 | 블로킹 없음 |
| 쓰기-쓰기 충돌 | 락으로 직렬화 | 락으로 직렬화 (동일) |
| 메모리 사용 | 적음 | 버전 관리 오버헤드 |
| 구현 복잡도 | 단순 | 복잡 |
쓰기-쓰기 충돌은 MVCC도 락이 필요하다. MVCC가 해결하는 건 읽기-쓰기 충돌이다.
정리
MVCC는 읽기 성능과 동시성을 높이기 위해 데이터의 여러 버전을 유지하는 기술이다. PostgreSQL은 행 자체에 버전 정보를 포함하는 방식, InnoDB는 Undo Log와 포인터 체인 방식으로 구현한다. 격리 수준에 따라 스냅샷 생성 시점이 달라지고, 오래된 버전은 주기적인 정리 작업이 필요하다는 점을 기억해두자.