PostgreSQL 트랜잭션 심화: MVCC, 격리 수준, 데드락, VACUUM — Part 2
PostgreSQL이 높은 동시성과 일관성을 동시에 제공할 수 있는 이유는 MVCC 덕분이다. xmin/xmax로 행 버전을 관리하고, Read Committed·Repeatable Read·Serializable 세 격리 수준이 스냅샷 시점을 달리해 각기 다른 보장을 제공한다. 데드락 감지 전략과 예방 원칙, MVCC의 구조적 부산물인 Dead Tuple을 청소하는 VACUUM까지 PostgreSQL 트랜잭션 운영의 핵심을 한 편에 정리한다.
시리즈 구성 — 트랜잭션? PostgreSQL과 MongoDB의 차이점은?!
- Part 1 — 트랜잭션의 기본 개념과 ACID 원칙
- Part 2 — PostgreSQL 트랜잭션 심화 (MVCC, 격리 수준, 데드락, VACUUM) (현재 편)
- Part 3 — MongoDB의 트랜잭션 진화와 멀티 도큐먼트 트랜잭션
- Part 4 — 실전 비교 — 성능, 확장성, 어떤 DB를 선택해야 할까?
목차
- 들어가며 — PostgreSQL이 트랜잭션 강자인 이유
- MVCC: PostgreSQL 동시성의 핵심
- 2-1. MVCC란 무엇인가?
- 2-2. xmin / xmax — 행 버전 관리의 비밀
- 2-3. MVCC의 동작 흐름
- 격리 수준(Isolation Level) 완전 정복
- 3-1. Read Committed (기본값)
- 3-2. Repeatable Read
- 3-3. Serializable (SSI)
- Deadlock: 트랜잭션의 적
- 4-1. 데드락이란?
- 4-2. 실전 데드락 시나리오
- 4-3. 데드락 감지와 해결 전략
- VACUUM: MVCC가 만든 필수 청소부
- 5-1. Dead Tuple이란?
- 5-2. VACUUM vs AutoVacuum
- 5-3. Transaction ID Wraparound 문제
- 실무에서 자주 쓰는 트랜잭션 패턴
- Part 2 정리
- 실무 적용 노트
1. 들어가며 — PostgreSQL이 트랜잭션 강자인 이유
PostgreSQL은 오랜 시간 개발자들의 신뢰를 받아온 데이터베이스다. 그 신뢰의 핵심에는 언제나 탄탄한 트랜잭션 모델이 있다.
이번 파트에서는 PostgreSQL의 트랜잭션을 내부 동작 수준까지 파고든다. MVCC의 실제 작동 방식, 격리 수준별 동작 차이, 실무에서 마주치는 데드락 시나리오, 그리고 MVCC의 부산물인 VACUUM까지 다룬다.
PostgreSQL 공식 문서의 한 줄 설명: "PostgreSQL은 데이터 일관성을 유지하기 위해 내부적으로 멀티버전 모델(MVCC)을 사용한다. 각 SQL 구문은 과거 특정 시점의 데이터 스냅샷을 바라보며, 현재 진행 중인 다른 트랜잭션의 변경 내용으로부터 독립적으로 동작한다."
2. MVCC: PostgreSQL 동시성의 핵심
2-1. MVCC란 무엇인가?
**MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)**는 PostgreSQL이 동시에 실행되는 여러 트랜잭션을 처리하는 핵심 메커니즘이다.
전통적인 락 기반 방식은 한 트랜잭션이 데이터를 쓰는 동안 다른 트랜잭션의 읽기조차 차단한다. MVCC는 이 문제를 완전히 다른 방식으로 해결한다.
"쓰는 트랜잭션이 읽는 트랜잭션을 막지 않고, 읽는 트랜잭션이 쓰는 트랜잭션을 막지 않는다."
각 트랜잭션은 자신이 시작된 시점의 데이터 스냅샷을 갖게 되며, 다른 트랜잭션이 데이터를 변경해도 현재 트랜잭션이 바라보는 스냅샷은 변하지 않는다.
2-2. xmin / xmax — 행 버전 관리의 비밀
PostgreSQL은 테이블의 모든 행(row)에 사용자 눈에 보이지 않는 시스템 컬럼을 숨겨두고 있다.
| 시스템 컬럼 | 설명 |
|---|---|
xmin | 이 행 버전을 생성한 트랜잭션 ID |
xmax | 이 행 버전을 삭제(또는 업데이트)한 트랜잭션 ID |
ctid | 행의 물리적 위치 (파일 페이지 번호 + 오프셋) |
-- 숨겨진 시스템 컬럼 직접 확인하기
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 1;
-- 결과 예시:
-- xmin | xmax | ctid | id | balance
-- -------+------+-------+----+---------
-- 10023 | 0 | (0,1) | 1 | 100000
xmax가 0이면 아직 삭제되지 않은 "살아있는" 행이다.
2-3. MVCC의 동작 흐름
UPDATE 하나가 실행될 때 PostgreSQL 내부에서 일어나는 일을 살펴보자.
1. 기존 행(xmin=100, xmax=0)이 존재
2. Transaction 200이 UPDATE 실행
→ 기존 행의 xmax = 200 으로 설정 (논리적 삭제)
→ 새로운 행(xmin=200, xmax=0) 삽입
3. Transaction 200이 COMMIT
→ 새 행이 공식적으로 "최신 버전"
4. 이미 Transaction 150에서 조회 중이던 트랜잭션은?
→ xmin=100인 구버전을 계속 읽음 (스냅샷 유지)
→ Transaction 200의 변경 사항은 보이지 않음
이렇게 "구버전"과 "신버전"이 잠시 공존하는 것이 MVCC의 핵심이다. 이 구버전 데이터(아무도 참조하지 않게 된 행)가 바로 Dead Tuple이 된다.
3. 격리 수준(Isolation Level) 완전 정복
PostgreSQL은 SQL 표준에서 정의한 4가지 격리 수준 중 실질적으로 3가지를 지원한다. READ UNCOMMITTED는 구문은 허용되지만 내부적으로 READ COMMITTED로 동작한다.
-- 격리 수준 설정 방법
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... 쿼리 실행 ...
COMMIT;
-- 세션 전체 기본값 변경
SET default_transaction_isolation = 'repeatable read';
3-1. Read Committed (기본값)
"각 SQL 구문이 실행되는 시점에 새로운 스냅샷을 찍는다."
가장 많이 사용되는 기본 격리 수준. 커밋된 데이터만 읽는다는 보장을 제공한다.
-- Session 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 결과: 100000
-- (Session 2가 이 사이에 balance를 90000으로 커밋)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 90000 (변경 반영됨)
COMMIT;
특징:
- Dirty Read 방지 (커밋 안 된 데이터는 절대 읽지 않음)
- Non-Repeatable Read 발생 가능 (같은 쿼리를 두 번 실행하면 결과가 다를 수 있음)
- Phantom Read 발생 가능
- 용도: 대부분의 OLTP 애플리케이션, 빠른 쓰기 처리가 필요한 서비스
3-2. Repeatable Read
"트랜잭션 시작 시점에 찍은 스냅샷을 끝까지 유지한다."
-- Session 1
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 결과: 100000
-- (Session 2가 이 사이에 balance를 90000으로 커밋)
SELECT balance FROM accounts WHERE id = 1; -- 결과: 100000 (변경 안 보임)
COMMIT;
특징:
- Dirty Read 방지
- Non-Repeatable Read 방지
- Phantom Read도 PostgreSQL에서는 방지됨 (SQL 표준과 다른 PostgreSQL의 강점)
- 쓰기 충돌 시 트랜잭션 중 하나가 실패할 수 있음
- 용도: 보고서 생성, 긴 분석 쿼리, 재고/잔액 계산
3-3. Serializable (SSI)
"모든 트랜잭션을 순차적으로 실행한 것과 동일한 결과를 보장한다."
PostgreSQL은 단순 잠금 방식이 아닌 SSI(Serializable Snapshot Isolation) 기법을 사용한다. 불필요한 차단 없이 직렬화를 보장하는 고급 알고리즘이다.
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... 복잡한 읽기/쓰기 작업 ...
COMMIT;
-- 직렬화 충돌 감지 시:
-- ERROR: could not serialize access due to concurrent update
특징:
- 최강의 데이터 무결성 보장
- 충돌 감지 시 트랜잭션 재시도 로직 필요
- 용도: 금융 거래, 회계 시스템, 정합성이 극도로 중요한 도메인
세 격리 수준의 비교를 정리하면 다음과 같다.
| 격리 수준 | 스냅샷 기준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|---|
| Read Committed | 구문 실행 시점 | 방지 | 발생 | 발생 |
| Repeatable Read | 트랜잭션 시작 시점 | 방지 | 방지 | 방지(PG 특성) |
| Serializable | 트랜잭션 시작 시점 + SSI | 방지 | 방지 | 방지 |
4. Deadlock: 트랜잭션의 적
4-1. 데드락이란?
**데드락(Deadlock, 교착 상태)**은 두 개 이상의 트랜잭션이 서로 상대방이 보유한 락을 기다리며 영원히 멈춰버리는 상황이다.
PostgreSQL은 이 상황을 자동으로 감지하고, 두 트랜잭션 중 하나를 강제 롤백시켜 상황을 해소한다.
ERROR: deadlock detected
DETAIL: Process 101 waits for ShareLock on transaction 2002; blocked by process 102.
Process 102 waits for ShareLock on transaction 2001; blocked by process 101.
4-2. 실전 데드락 시나리오
패턴 1: 테이블 접근 순서가 다른 두 트랜잭션
-- Worker 1: A → B 순서로 업데이트
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A'; -- 락 획득
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B'; -- 대기...
COMMIT;
-- Worker 2: B → A 순서로 업데이트 (역순!)
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE id = 'B'; -- 락 획득
UPDATE accounts SET balance = balance + 50000 WHERE id = 'A'; -- 대기...
COMMIT;
-- DEADLOCK 발생!
패턴 2: SELECT FOR UPDATE 남용
-- Session 1
BEGIN;
SELECT * FROM orders WHERE id = 10 FOR UPDATE; -- 행 #10 락
SELECT * FROM inventory WHERE id = 5 FOR UPDATE; -- 행 #5 기다리는 중
-- Session 2
BEGIN;
SELECT * FROM inventory WHERE id = 5 FOR UPDATE; -- 행 #5 락
SELECT * FROM orders WHERE id = 10 FOR UPDATE; -- 행 #10 기다리는 중
-- DEADLOCK!
4-3. 데드락 감지와 해결 전략
데드락 모니터링 쿼리
-- 현재 락 대기 중인 프로세스 확인
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS current_statement_in_blocking_process
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity
ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity
ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
데드락 예방 원칙 4가지
| 원칙 | 설명 |
|---|---|
| 일관된 접근 순서 | 항상 동일한 순서로 테이블/행에 접근 |
| 트랜잭션 짧게 유지 | 트랜잭션 내 외부 API 호출, 사용자 입력 대기 금지 |
| 락 범위 최소화 | FOR UPDATE 대신 가능하면 FOR NO KEY UPDATE 사용 |
| 재시도 로직 구현 | 데드락은 완전히 막을 수 없으므로 앱 레벨 재시도 필수 |
낙관적 잠금(Optimistic Locking) — 데드락 근본 회피
-- version 컬럼을 이용한 낙관적 잠금
ALTER TABLE products ADD COLUMN version INT DEFAULT 1;
-- 읽기 (락 없음)
SELECT id, stock, version FROM products WHERE id = 42;
-- 업데이트 (version이 일치할 때만 실행)
UPDATE products
SET stock = stock - 1,
version = version + 1
WHERE id = 42 AND version = 7; -- 읽어온 버전 명시
-- affected rows = 0 means another transaction updated first; retry
5. VACUUM: MVCC가 만든 필수 청소부
5-1. Dead Tuple이란?
MVCC의 동작 방식을 떠올려보자. UPDATE나 DELETE가 실행되면 PostgreSQL은 기존 행을 실제로 지우지 않는다. 단지 xmax를 채워 "논리적으로 삭제됨" 표시만 한다. 이렇게 아무도 참조하지 않지만 물리적으로 남아있는 행을 Dead Tuple이라고 한다.
[테이블 물리 구조 예시]
Page 1:
Tuple 1 (xmin=100, xmax=200) <- Dead Tuple, 아무도 참조 안 함
Tuple 2 (xmin=200, xmax=0) <- Live Tuple (현재 버전)
Tuple 3 (xmin=150, xmax=300) <- Dead Tuple
Tuple 4 (xmin=300, xmax=0) <- Live Tuple
Dead Tuple이 쌓이면:
- 디스크 공간 낭비
SELECT시 불필요한 Dead Tuple까지 스캔 → 성능 저하- 인덱스 비대화
5-2. VACUUM vs AutoVacuum
VACUUM은 Dead Tuple을 정리하여 공간을 재사용 가능한 상태로 만드는 PostgreSQL 고유의 프로세스다. Oracle, MySQL, SQL Server에는 존재하지 않는 PostgreSQL만의 개념이다.
-- 수동 VACUUM (테이블 락 없이 실행)
VACUUM accounts;
-- VACUUM + 통계 갱신
VACUUM ANALYZE accounts;
-- FULL VACUUM: 실제 공간 반환 (테이블 락 발생! 운영 중 사용 주의)
VACUUM FULL accounts;
AutoVacuum은 이 작업을 자동으로 수행하는 백그라운드 프로세스다. 기본적으로 활성화되어 있지만, 기본 설정은 매우 보수적이기 때문에 트래픽이 많은 서비스에서는 반드시 튜닝이 필요하다.
# postgresql.conf 주요 AutoVacuum 설정
autovacuum = on # AutoVacuum 활성화
autovacuum_vacuum_threshold = 50 # Dead Tuple 최소 개수 임계치
autovacuum_vacuum_scale_factor = 0.2 # 테이블의 20% 이상 Dead Tuple 시 실행
autovacuum_vacuum_cost_delay = 2ms # 작업 간 대기 시간 (낮출수록 빠름)
쓰기가 매우 많은 테이블(로그, 이벤트 테이블 등)은 AutoVacuum 임계치를 테이블별로 낮게 설정해야 한다. 방치하면 Dead Tuple이 폭발적으로 쌓여 쿼리 성능이 급격히 저하된다.
5-3. Transaction ID Wraparound 문제
PostgreSQL의 Transaction ID(XID)는 32비트 정수로, 약 42억 개의 트랜잭션을 표현할 수 있다. XID가 한 바퀴 돌아 다시 처음으로 돌아오면 기존 데이터들이 모두 "미래 트랜잭션"으로 인식되어 데이터베이스 전체가 읽히지 않는 치명적 장애가 발생한다. 이것이 Transaction ID Wraparound 문제다.
-- 현재 XID Wraparound 위험도 확인
SELECT
schemaname,
tablename,
age(relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_stat_user_tables
ORDER BY xid_age DESC
LIMIT 10;
-- age가 2억을 넘으면 경고 수준, 10억에 가까우면 긴급
VACUUM의 FREEZE 기능이 이 오래된 XID들을 특수 값(FrozenXID = 2)으로 변환하여 문제를 예방한다.
6. 실무에서 자주 쓰는 트랜잭션 패턴
패턴 1: SAVEPOINT — 부분 롤백
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 50000);
SAVEPOINT before_payment; -- 중간 저장점
INSERT INTO payments (order_id, method) VALUES (100, 'card');
-- 결제 실패 시 주문은 살리고 결제만 롤백
ROLLBACK TO SAVEPOINT before_payment;
INSERT INTO payments (order_id, method) VALUES (100, 'bank_transfer');
COMMIT;
패턴 2: FOR UPDATE SKIP LOCKED — 큐 처리
-- 여러 워커가 동시에 작업 큐를 처리할 때
-- 이미 다른 워커가 처리 중인 행은 건너뜀
BEGIN;
SELECT *
FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- 가져온 작업 처리 후
UPDATE job_queue SET status = 'done' WHERE id = :job_id;
COMMIT;
패턴 3: Repeatable Read를 활용한 일관된 보고서
-- 보고서 생성: 긴 쿼리 동안 데이터가 변해도 일관된 결과 보장
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT SUM(amount) FROM orders WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31';
SELECT COUNT(*) FROM orders WHERE status = 'completed';
-- 두 쿼리 모두 동일 스냅샷 기준으로 실행됨
COMMIT;
7. Part 2 정리
이번 파트에서는 PostgreSQL 트랜잭션의 내부 동작을 심층적으로 살펴봤다.
핵심 요약:
- MVCC: 읽기와 쓰기가 서로를 차단하지 않는 PostgreSQL 동시성의 핵심. 각 트랜잭션은 자신만의 데이터 스냅샷을 갖는다.
- xmin/xmax: 행(row) 단위로 트랜잭션 ID를 기록해 버전 관리를 구현한다.
- 격리 수준: Read Committed(기본) → Repeatable Read → Serializable 순으로 일관성이 강해지고, 성능 트레이드오프가 생긴다.
- Deadlock: 접근 순서를 일관되게 유지하고, 트랜잭션을 짧게 유지하며, 앱 레벨 재시도 로직으로 대응한다.
- VACUUM: MVCC의 구조적 부산물인 Dead Tuple을 정리하는 PostgreSQL 고유 프로세스. AutoVacuum 튜닝은 필수다.
Part 3에서는 MongoDB의 트랜잭션 역사와 진화를 다룬다. 멀티 도큐먼트 트랜잭션이 어떻게 동작하는지, 그리고 PostgreSQL의 MVCC와 어떻게 다른지 비교한다.
8. 실무 적용 노트
참고 자료
- PostgreSQL 공식 문서: MVCC Introduction
- PostgreSQL 공식 문서: Concurrency Control
- Medium: The Core of PostgreSQL — MVCC
- Leapcell: Diving Deep into MVCC in PostgreSQL
- Netdata: 10 Real-World PostgreSQL Deadlock Examples
- 우아한형제들 기술블로그: PostgreSQL Vacuum에 대한 거의 모든 것
- CYBERTEC: Debugging Deadlocks in PostgreSQL