PostgreSQL MVCC와 격리 수준
“읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다.” PostgreSQL 동시성 모델의 한 줄 요약이다. 이 단순한 원칙을 가능하게 만드는 것이 MVCC(Multi-Version Concurrency Control)이다.
락 기반 동시성의 한계
전통적인 DB는 락(lock)으로 동시성을 제어했다.
한 트랜잭션이 행을 읽는 동안 다른 트랜잭션은 그 행을 쓸 수 없고, 쓰는 동안에는 읽지도 못한다. 정합성은 보장되지만 읽기와 쓰기가 서로를 막아 처리량이 떨어진다.
조회가 많은 OLTP 서비스에서 이 비용은 결정적이다. 한 건의 보고서 쿼리가 길게 돌면 그 동안 들어오는 모든 쓰기가 멈춘다.
MVCC는 이 문제를 다른 방식으로 푼다. 행을 직접 수정하지 않고 새로운 버전을 만든다.
읽는 쪽은 자신이 시작한 시점의 버전을 보고, 쓰는 쪽은 새 버전을 추가한다. 둘이 서로를 기다리지 않는다.
PostgreSQL MVCC의 핵심
PostgreSQL은 한 행(row)을 물리적으로 여러 튜플(tuple) 버전으로 보관한다. SQL 사용자 관점에서는 row 하나지만, 스토리지 레벨에서는 갱신될 때마다 새 tuple이 추가된다.
PostgreSQL은 모든 tuple에 두 개의 시스템 컬럼을 숨겨 둔다.
xmin: 이 행을 만든 트랜잭션의 IDxmax: 이 행을 삭제(또는 갱신으로 무효화)한 트랜잭션의 ID. 살아있으면 0
UPDATE는 행을 덮어쓰지 않는다. 기존 튜플에 xmax를 찍어 “죽은 것”으로 표시하고, 새 값을 가진 튜플을 새로 추가한다.
flowchart LR
subgraph Before["UPDATE 전"]
T1["xmin=100, xmax=0<br/>balance=10000"]
end
subgraph After["UPDATE 후 (T200이 갱신)"]
T2["xmin=100, xmax=200<br/>balance=10000 (dead)"]
T3["xmin=200, xmax=0<br/>balance=8000 (live)"]
end
Before --> After
각 트랜잭션은 시작할 때 스냅샷을 받는다. 스냅샷은 “이 시점에 어떤 트랜잭션이 커밋되어 있었는가”의 목록이다.
행을 읽을 때 PostgreSQL은 xmin/xmax와 스냅샷을 비교해 나에게 보여야 할 버전인지 판단한다.
대략의 가시성 규칙은 이렇다.
xmin이 내 스냅샷 시점에 이미 커밋됐고xmax가 0이거나, 아직 커밋되지 않았으면 → 보인다
이 규칙 덕에 한 행에 여러 버전이 떠 있어도 각 트랜잭션은 자기 스냅샷에 맞는 하나의 버전만 본다. 락 없이도 일관된 읽기가 가능해진다.
PostgreSQL의 격리 수준
ANSI SQL 표준은 격리 수준을 4단계로 정의하지만, PostgreSQL은 실제로 세 가지만 지원한다.
Read Uncommitted를 요청해도 내부적으로 Read Committed로 동작한다 (MVCC 구조상 dirty read가 원천 불가능하기 때문이다).
| 격리 수준 | 동작 | 기본값 |
|---|---|---|
| Read Committed | 매 쿼리마다 새 스냅샷 | ✓ |
| Repeatable Read | 트랜잭션 시작 시점의 스냅샷 고정 | |
| Serializable | RR + 직렬화 충돌 감지(SSI) |
Read Committed (기본값)
쿼리 하나하나가 시작될 때마다 새 스냅샷을 잡는다.
같은 트랜잭션 안에서도 두 SELECT 사이에 다른 트랜잭션이 커밋되면 두 번째 SELECT는 새 값을 본다.
-- T1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 10000
-- (이 사이에 T2가 balance=8000으로 UPDATE + COMMIT)
SELECT balance FROM accounts WHERE id = 1; -- 8000
COMMIT;
대부분의 일반 조회와 쓰기에는 이 수준이면 충분하다. 동시성이 가장 높다.
Repeatable Read
PostgreSQL의 Repeatable Read는 Snapshot Isolation으로 동작한다. 트랜잭션이 시작될 때 한 번 잡은 스냅샷을 끝까지 사용한다. 같은 행을 몇 번 읽어도 항상 같은 값이 나오고, 새로 추가된 행도 보이지 않는다.
ANSI 표준 RR은 Phantom을 허용하지만, PostgreSQL의 RR은 Snapshot Isolation이라 Phantom도 차단된다. 같은 이름이지만 표준보다 강한 보장이다.
-- T1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE status='paid'; -- 100
-- (T2가 새 paid 주문 INSERT + COMMIT)
SELECT count(*) FROM orders WHERE status='paid'; -- 여전히 100
COMMIT;
다만 SI는 Write Skew라는 미묘한 이상을 막지 못한다. 두 트랜잭션이 서로의 읽기 결과에 의존해 각자 다른 행을 갱신하면, 단일 직렬 실행에서는 불가능한 결과가 나올 수 있다.
그림: 두 트랜잭션이 같은 스냅샷에 의존해 각자 안전하다고 판단한 결과, 단일 직렬 실행에서는 불가능한 상태가 만들어진다.
이 부분이 다음 수준이 필요한 이유다.
Serializable (SSI)
PostgreSQL의 Serializable은 락이 아니다. Serializable Snapshot Isolation(SSI)이라는 알고리즘으로, RR과 똑같이 스냅샷을 사용하되 트랜잭션 간 읽기, 쓰기 의존성을 추적해 직렬화가 깨질 위험이 감지되면 한 쪽을 abort시킨다.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT; -- ERROR: could not serialize access due to ...
abort된 트랜잭션은 애플리케이션이 재시도해야 한다. 직렬성을 락 없이 얻는 대신 재시도 비용을 부담하는 모델이다.
MySQL InnoDB와의 비교
같은 MVCC 계열이지만 구현 철학이 꽤 다르다.
| 항목 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 기본 격리 수준 | Read Committed | Repeatable Read |
| 옛 버전 저장 위치 | 같은 테이블에 새 튜플 추가 | Undo Log (별도 영역) |
| 가비지 수집 | VACUUM (별도 작업) | Purge Thread (백그라운드 자동) |
| RR의 Phantom 처리 | Snapshot으로 자연스럽게 차단 | Gap Lock으로 차단 (잠금 기반) |
| Serializable 구현 | SSI (충돌 감지, 락 없음) | 모든 SELECT를 LOCK IN SHARE MODE처럼 처리 |
| UPDATE 시 IO 패턴 | 새 튜플 추가 → 테이블 비대화 가능 | 인플레이스 + Undo |
핵심 차이는 옛 버전을 어디에 두느냐다.
- PostgreSQL: 같은 테이블, 같은 페이지에 새 버전을 추가한다. 구조는 단순하지만 죽은 튜플이 쌓여 테이블이 부풀고, VACUUM이라는 백그라운드 청소 작업이 주기적으로 회수해야 한다
- MySQL InnoDB: 옛 버전을 Undo Log라는 별도 영역에 보관한다. 테이블은 깨끗하게 유지되지만 옛 버전을 읽을 때 Undo Log를 거꾸로 따라가야 한다 (롱 트랜잭션이 길어지면 Undo가 폭증)
이 한 가지 결정이 두 DB의 운영 특성을 가른다. PostgreSQL에서 VACUUM이 그렇게 중요한 이유, MySQL에서 긴 트랜잭션이 Undo를 키워 성능을 떨어뜨리는 이유가 모두 여기서 나온다.
PostgreSQL의 VACUUM은 단순한 청소가 아니다. dead tuple을 재사용 가능 공간으로 표시하고, Index-Only Scan을 가능하게 하는 Visibility Map을 갱신하고, Transaction ID wraparound를 막기 위해 오래된 튜플을 freeze한다. 자주 돌지 않으면 테이블이 부풀어 조회까지 느려지고, 너무 밀리면 데이터베이스 전체가 read-only로 떨어진다. 자세한 동작과 튜닝은 PostgreSQL VACUUM과 Autovacuum 글에서 다뤘다.
또 하나, MySQL InnoDB의 RR은 Gap Lock으로 Phantom을 막기 때문에 PostgreSQL의 RR보다 락 경합이 더 잘 일어난다. PostgreSQL의 RR은 충돌이 있을 때 abort 하는 쪽이지 락으로 막는 쪽이 아니다.
Things to note
Lost Update
두 트랜잭션이 같은 행을 읽고 각자 갱신하면 한 쪽 변경이 사라질 수 있다.
그림: 두 트랜잭션이 같은 값을 읽고 각자 계산한 뒤 차례로 덮어쓰면, 한 쪽 변경이 사라진다.
해결은 셋 중 하나.
SELECT ... FOR UPDATE로 명시적 락UPDATE products SET stock = stock - 1 WHERE id = 1처럼 원자적 갱신- Serializable 격리에서 충돌 감지에 맡기기
Long-Running Transaction
PostgreSQL은 어떤 트랜잭션이 살아 있는 한 그 트랜잭션이 볼 수 있는 옛 튜플을 VACUUM이 지우지 못한다. 하루짜리 보고서 트랜잭션 하나가 전체 테이블의 dead tuple을 누적시키는 사고가 흔하다.
SELECT pid, xact_start, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
idle in transaction이 길면 격리 모델이 무너지기 시작하는 신호다.
SELECT FOR UPDATE
MVCC가 모든 동시성 문제를 푸는 것은 아니다. 잔액 이체, 재고 차감처럼 읽은 값을 기준으로 갱신해야 하는 로직은 명시적 락이 답이다.
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- balance를 확인하고 계산
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
COMMIT;
FOR UPDATE는 행 락을 잡아 다른 트랜잭션의 동시 갱신을 직렬화한다. 동시성 일부를 포기하는 대신 명확한 직렬성을 얻는다.