컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
db · 12

MVCC

PostgreSQL동시성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가 읽기와 쓰기의 충돌을 없애도 같은 행을 동시에 수정하는 두 쓰기는 서로 기다리거나 한쪽이 중단됩니다.

동시성이 좋아집니다 — 조회가 많은 서비스에서 긴 읽기 작업이 쓰기를 막지 않습니다. 읽는 쪽은 읽는 동안 일관된 값을 봅니다.
대가는 이전 버전의 저장과 정리입니다 — 수정이 많은 테이블에는 dead tuple이 쌓이고, 정리가 밀리면 테이블이 불어납니다.
오래 열린 트랜잭션이 정리를 막습니다 — 스냅샷이나 트랜잭션 ID를 쥔 채 오래 열려 있는 트랜잭션보다 나중에 지워진 버전은 회수할 수 없어, 긴 트랜잭션이 테이블 비대화의 원인이 될 수 있습니다. 읽기 전용 트랜잭션도 해당합니다.
구현 방식은 데이터베이스마다 다릅니다 — PostgreSQL은 이전 버전을 테이블 안에 두고, MySQL InnoDB는 클러스터드 인덱스 레코드를 제자리에서 수정하고 이전 값을 언두 로그에 보관해 필요할 때 복원합니다. InnoDB에서도 오래 열린 트랜잭션이 언두 로그 정리를 막습니다.
이 시각화에서 확인할 것 — "버전 만들기" 탭에서 UPDATE마다 xmax가 찍히고 새 버전이 늘어나는 모습을, "스냅샷 읽기" 탭에서 세 읽기가 같은 행에서 서로 다른 값을 쓰기를 기다리지 않고 읽는 모습을, "VACUUM" 탭에서 열린 읽기가 회수를 막다가 끝나자 버전 3개가 회수되는 모습을 STEP으로 확인하세요.

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 위에서 구현됩니다.

숫자로 확인하기

버전 4개 — 값 1000인 행을 삽입하고 500, 300, 100으로 세 번 수정하면 같은 행의 버전이 4개가 됩니다. 이전 버전 3개의 xmax는 수정한 트랜잭션 ID로 표시됩니다.
스냅샷별 값 — 시각 2, 4, 8의 스냅샷으로 읽으면 각각 1000, 500, 100입니다. 읽기 세 개가 같은 행에서 서로 다른 값을 읽는 동안 쓰기는 한 번도 기다리지 않습니다.
VACUUM의 기준 — 가장 오래된 스냅샷이 시각 2에 묶여 있을 때 회수 가능한 버전은 0개이고, 기준이 8로 올라오면 v1, v2, v3 세 개가 회수되어 v4만 남습니다.
코드 확인 — 파이썬과 자바 모델 모두 시각 2, 4, 8에서 1000, 500, 100을 읽고, 기준 2에서는 0개, 기준 8에서는 3개를 지워 현재 버전 하나만 남깁니다.

자주 하는 오해

UPDATE는 행을 제자리에서 고친다 — PostgreSQL에서 UPDATE는 새 행 버전을 만들고 기존 버전에 xmax를 표시합니다. 제자리 수정 방식(InnoDB)과 구현이 다릅니다.
MVCC가 있으면 잠금이 필요 없다 — 읽기와 쓰기가 서로 막지 않을 뿐이고, 같은 행을 동시에 수정하는 두 쓰기는 서로 기다리거나 충돌로 중단됩니다.
이전 버전은 커밋하면 바로 사라진다 — 이전 버전은 다른 트랜잭션이 볼 수 있는 동안 남고, VACUUM이 나중에 회수합니다.
VACUUM이 디스크를 바로 줄여 준다 — 일반 VACUUM은 회수한 공간을 같은 테이블이 다시 쓰게 할 뿐, 테이블 끝쪽 페이지가 완전히 빈 경우를 빼고는 운영체제에 돌려주지 않습니다. 파일을 줄이려면 VACUUM FULL처럼 테이블을 다시 쓰는 방법이 필요하고, 이는 테이블을 배타적으로 잠급니다.
VACUUM은 공간 회수만 한다 — 트랜잭션 ID가 32비트라 약 20억 트랜잭션 안에 오래된 행을 freeze하는 정리가 필요하고, 그러지 않으면 과거 행이 보이지 않게 됩니다. VACUUM과 autovacuum이 이 일도 맡습니다.
읽기는 항상 최신 값을 본다 — 읽기는 자기 스냅샷의 값을 봅니다. 읽는 도중 다른 트랜잭션이 커밋해도 Repeatable Read 이상에서는 그 변경이 보이지 않습니다.

면접에서 이렇게 물어요

면접 질문

MVCC란 무엇이고 왜 쓰나요?

행의 여러 버전을 유지해 읽기와 쓰기가 서로 막지 않게 하는 방식입니다. 읽는 쪽은 자기 스냅샷의 버전을 보므로 읽는 중에도 일관된 결과를 얻습니다.

면접 질문

VACUUM은 무엇을 하나요?

더 이상 어떤 트랜잭션도 볼 필요가 없는 이전 버전(dead tuple)을 회수해 공간을 재사용할 수 있게 합니다. autovacuum이 기본으로 켜져 있어 필요한 테이블에 자동 수행합니다.

면접 질문

오래 열린 트랜잭션이 왜 문제인가요?

그 트랜잭션의 스냅샷 이후에 지워진 버전을 회수할 수 없어서 dead tuple이 쌓이고 테이블이 불어날 수 있습니다.

면접 질문

PostgreSQL과 InnoDB의 MVCC는 어떻게 다른가요?

PostgreSQL은 이전 버전을 테이블 안에 새 행으로 남기고, InnoDB는 클러스터드 인덱스 레코드를 제자리에서 고치고 이전 값을 언두 로그에 두어 필요할 때 복원합니다.

활용 분야

읽기가 많은 웹 서비스 — 읽기가 쓰기와 서로 기다리지 않으므로 같은 데이터에 읽기와 쓰기가 섞여도 잠금 대기가 줄어듭니다.
일관된 스냅샷이 필요한 긴 읽기 — 보고서나 백업처럼 시작 시점의 데이터를 일관되게 읽어야 하는 작업이 쓰기(DML)를 막지 않고 실행됩니다.
운영 점검 — 오래 열린 트랜잭션과 dead tuple 수, autovacuum 동작을 감시해 테이블 비대화를 줄입니다.

함께 보면 좋은 토픽

트랜잭션 격리 수준 — MVCC 스냅샷 위에서 이상 현상을 막는 방식을 봅니다.
트랜잭션 — 커밋과 롤백, ACID를 봅니다.
락 — MVCC에서도 필요한 쓰기 충돌 제어를 봅니다.
인덱스 — 행 버전이 늘어날 때 인덱스가 받는 영향과 이어집니다.

참고 자료

공식 문서 — PostgreSQL: Routine Vacuuming
공식 문서 — PostgreSQL: System Columns
공식 문서 — MySQL InnoDB Multi-Versioning
논문 — Bernstein, P. A., Goodman, N., "Multiversion Concurrency Control — Theory and Algorithms", ACM Transactions on Database Systems 8(4), 1983

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(다중 버전 동시성 제어)의 핵심 아이디어는?

읽는 쪽은 자기 시점의 "과거 버전"을 볼 수 있습니다.
MVCC는 행이 수정될 때 기존 버전을 바로 지우지 않고 새 버전을 만들어 함께 유지합니다. 읽기 트랜잭션은 자기 시점에 맞는 버전을 읽으므로 쓰기와 서로 대기하지 않습니다.

Q2

PostgreSQL에서 MVCC로 남겨진 오래된 버전(dead tuple)을 정리하는 작업은?

"진공청소기"처럼 쓸모없어진 것을 치웁니다.
수정·삭제로 더 이상 어떤 트랜잭션도 볼 필요가 없어진 이전 버전(dead tuple)은 VACUUM이 회수합니다. autovacuum이 이를 주기적으로 자동 수행합니다.

Q3

MVCC에서 읽기 트랜잭션이 보게 되는 데이터는?

"찍어 둔 사진" 같은 시점 일관된 뷰를 봅니다.
읽기 트랜잭션은 자신이 보는 스냅샷 시점에 커밋되어 있던 버전만 봅니다. 스냅샷은 Read Committed에서는 문장마다, Repeatable Read 이상에서는 트랜잭션마다 잡히므로, 그 시점 이후에 다른 트랜잭션이 커밋한 변경은 보이지 않아 일관된 결과를 얻습니다.