은행 송금 — A 계좌 출금, B 계좌 입금. 출금만 되고 입금 전에 서버가 죽으면? 돈이 증발한다. transaction 은 이 둘을 "전부 성공 아니면 전부 취소" 로 묶는다. 여기에 동시에 수백 개의 transaction 이 같은 row 를 읽고 쓰면 누가 누구의 변경을 보느냐 하는 문제가 생긴다 — 이게 isolation level 이다. 이 가이드는 ACID 의 진짜 의미, 네 가지 read anomaly, isolation level 4 단계, MVCC vs locking, SERIALIZABLE 의 비용, deadlock 까지 정리한다.
Transaction 이란 — BEGIN / COMMIT / ROLLBACK
BEGIN; -- transaction 시작
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 둘 다 영구 반영
-- 중간에 문제가 있으면:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 잔액 부족 감지
ROLLBACK; -- 아무 일도 없었던 것처럼 되돌림transaction 은 여러 SQL 문을 하나의 논리적 단위로 묶는다. COMMIT 전까지의 변경은 다른 transaction 에게 (대부분) 보이지 않고, ROLLBACK 하면 전부 사라진다.
SAVEPOINT — 부분 rollback
BEGIN;
INSERT INTO orders (id, user_id) VALUES (1, 42);
SAVEPOINT after_order;
INSERT INTO order_items (order_id, sku) VALUES (1, 'BAD');
-- 이 item 만 실패 → order 는 살리고 싶다
ROLLBACK TO SAVEPOINT after_order;
INSERT INTO order_items (order_id, sku) VALUES (1, 'GOOD');
COMMIT;SAVEPOINT 는 transaction 안에 중간 표식을 둔다. 거기까지만 되돌리고 나머지는 유지. ORM 의 "nested transaction" 은 대부분 내부적으로 SAVEPOINT 다.
ACID — 네 글자의 진짜 의미
- Atomicity (원자성) — transaction 안의 모든 연산은 전부 성공하거나 전부 실패. 중간 상태로 남지 않음. 위 송금 예시의 핵심.
- Consistency (일관성) — transaction 은 DB 를 하나의 valid 상태에서 다른 valid 상태로만 옮긴다. 단, 이건 "당신의 비즈니스 규칙이 맞다" 는 보장이 아니다. DB 가 보장하는 건 declared constraint (FK, UNIQUE, CHECK, NOT NULL) 뿐이다. "출금 + 입금 금액이 같아야 한다" 같은 규칙은 당신의 코드가 책임진다 — ACID 의 C 는 가장 오해받는 글자다.
- Isolation (격리성) — 동시에 실행되는 transaction 들이 서로의 미완성 변경을 어디까지 볼 수 있는가. 얼마나 격리할지는 isolation level 로 조절. 이 글의 절반이 이 주제다.
- Durability (지속성) — COMMIT 이 반환되면 그 변경은 정전이 나도 살아남는다. 대부분의 DB 는 WAL (write-ahead log) 을 disk 에 fsync 한 뒤에야 COMMIT 을 반환해 이를 보장한다.
정리 — A 와 D 는 "단일 transaction 의 신뢰성", C 는 "constraint 준수 + 당신 코드", I 는 "동시성 처리 방식". 실무 버그의 대부분은 I 를 오해해서 생긴다.
Read anomaly — 격리가 약하면 보이는 것들
두 transaction T1 / T2 가 교차 실행될 때 나타나는 비정상 현상. 각각 2-transaction interleaving 으로 본다.
1. Dirty read — 커밋 안 된 값을 읽음
T1: BEGIN
T1: UPDATE accounts SET balance = 0 WHERE id = 1; -- 아직 COMMIT 안 함
T2: BEGIN
T2: SELECT balance FROM accounts WHERE id = 1; -- 0 을 읽음 (!)
T1: ROLLBACK -- 사실 0 이 아니었음
T2: -- 존재한 적 없는 값으로 의사결정T2 가 T1 의 미완성·취소될 변경을 읽었다. Read Committed 이상에서는 절대 발생하지 않는다.
2. Non-repeatable read — 같은 row 가 두 번 다르게 읽힘
T1: BEGIN
T1: SELECT balance FROM accounts WHERE id = 1; -- 100
T2: BEGIN; UPDATE accounts SET balance = 50 WHERE id = 1; COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1; -- 50 (같은 query, 다른 값!)T1 이 한 transaction 안에서 같은 row 를 두 번 읽었는데 값이 바뀌었다. Repeatable Read 이상에서 방지.
3. Phantom read — 행 집합이 바뀜
T1: BEGIN
T1: SELECT count(*) FROM orders WHERE user_id = 42; -- 3 건
T2: BEGIN; INSERT INTO orders (user_id) VALUES (42); COMMIT;
T1: SELECT count(*) FROM orders WHERE user_id = 42; -- 4 건 (유령 등장!)기존 row 값은 그대로지만 조건에 맞는 새 row 가 끼어들었다. non-repeatable read 는 "같은 row 의 값 변경", phantom 은 "조건에 맞는 row 집합의 변경" 이라는 점이 다르다. Serializable (그리고 실무에선 PostgreSQL Repeatable Read 도) 에서 방지.
4. Write skew — 둘 다 옳은데 합치면 틀림
-- 규칙: 당직 의사가 최소 1명은 남아야 한다 (현재 2명: Alice, Bob)
T1 (Alice): SELECT count(*) FROM doctors WHERE on_call = true; -- 2, OK
T2 (Bob): SELECT count(*) FROM doctors WHERE on_call = true; -- 2, OK
T1: UPDATE doctors SET on_call = false WHERE name = 'Alice';
T2: UPDATE doctors SET on_call = false WHERE name = 'Bob';
T1: COMMIT; T2: COMMIT;
-- 결과: 당직 0명. 둘 다 "나 빼도 1명 남네" 라고 판단했다.각 transaction 은 자기만 보면 규칙을 지켰다. 둘이 서로 다른 row 를 읽고 서로 다른 row 를 쓰면서 전역 불변식을 깬다. Snapshot isolation 으로도 못 막고, 오직 Serializable (PostgreSQL SSI 등) 에서만 잡힌다. write skew 가 isolation 논의에서 제일 위험한 anomaly 다 — 흔히 간과된다.
4 가지 isolation level
SQL 표준이 정의한 4 단계. 위로 갈수록 격리가 강해지고 동시성은 떨어진다.
Level | Dirty Read | Non-repeatable | Phantom | Write Skew
-------------------+------------+----------------+---------+-----------
Read Uncommitted | 발생 | 발생 | 발생 | 발생
Read Committed | 방지 | 발생 | 발생 | 발생
Repeatable Read | 방지 | 방지 | 발생* | 발생
Serializable | 방지 | 방지 | 방지 | 방지
* SQL 표준상 Repeatable Read 는 phantom 허용. 단 PostgreSQL 의
Repeatable Read(=snapshot isolation) 는 phantom 도 막는다.-- 세션 단위 설정
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- transaction 단위 (PostgreSQL)
BEGIN ISOLATION LEVEL REPEATABLE READ;
...
COMMIT;주의 — 같은 이름이 엔진마다 다르게 구현된다. PostgreSQL 의 Repeatable Read 는 표준보다 강하고 (phantom 차단), MySQL InnoDB 의 Repeatable Read 는 gap lock 으로 phantom 을 막지만 write skew 는 못 막는다. "이름" 이 아니라 "그 엔진에서 무엇을 막는가" 로 판단해야 한다.
Locking vs MVCC
isolation 을 구현하는 두 가지 큰 전략이 있다.
Lock 기반 (2PL — two-phase locking)
Phase 1 (Growing): lock 을 획득만 한다
Phase 2 (Shrinking): lock 을 해제만 한다 (보통 COMMIT 시 일괄 해제)
- read 는 shared lock (S), write 는 exclusive lock (X)
- S 끼리는 공존, X 는 누구와도 배타
- 읽는 동안 남이 못 쓰고, 쓰는 동안 남이 못 읽음
→ 정확하지만 reader 와 writer 가 서로 막아 동시성이 낮다MVCC (Multi-Version Concurrency Control) — PostgreSQL 의 방식
PostgreSQL 은 row 를 in-place 로 덮어쓰지 않는다. UPDATE 는 새 버전의 row 를 추가하고 옛 버전은 그대로 둔다. 각 row 버전엔 두 개의 숨은 시스템 column 이 있다:
xmin = 이 row 버전을 만든 transaction id
xmax = 이 row 버전을 삭제/대체한 transaction id (없으면 0)
UPDATE accounts SET balance = 50 WHERE id = 1; 직후:
+--------+---------+------+------+
| id | balance | xmin | xmax |
+--------+---------+------+------+
| 1 | 100 | 100 | 200 | ← 옛 버전 (txn 200 이 대체)
| 1 | 50 | 200 | 0 | ← 새 버전
+--------+---------+------+------+각 transaction 은 시작 시점의 snapshot (그 순간 commit 된 transaction id 집합) 을 들고, "내 snapshot 기준으로 보이는 버전" 만 읽는다. 그래서:
- reader 는 writer 를 막지 않고, writer 는 reader 를 막지 않는다. 각자 자기 버전을 보면 되니까. 동시성이 높다.
- dirty read 가 구조적으로 불가능 — commit 안 된 버전은 어떤 snapshot 에도 안 보인다.
- 단점 — 옛 row 버전이 쌓인다. 이걸 정리하는 게 VACUUM. 아무 transaction 도 더는 못 보는 죽은 버전(dead tuple) 을 회수해 공간을 재사용한다. VACUUM 이 밀리면 table 이 부풀고 (bloat) 성능이 떨어진다. autovacuum 이 이걸 자동으로 돈다.
MySQL InnoDB 도 MVCC 를 쓰지만 옛 버전을 undo log 에 따로 두고 (PostgreSQL 은 table 안에 둔다) purge thread 가 정리한다. 그래서 InnoDB 는 VACUUM 같은 table bloat 이슈가 PostgreSQL 만큼 두드러지지 않는다.
SERIALIZABLE — 두 가지 구현
가장 강한 isolation. "동시에 돌았지만 마치 하나씩 순서대로 (serial) 실행한 것과 똑같은 결과" 를 보장한다. 구현은 크게 둘:
Strict 2PL 기반 serializable
- 모든 read 에 shared lock, write 에 exclusive lock, range 엔 predicate / gap lock 까지 걸어 phantom 도 lock 으로 막는다.
- 정확하지만 lock 경합이 심하고 deadlock 이 잦다. MySQL InnoDB 의 SERIALIZABLE 이 대략 이 계열 (모든 SELECT 가 암묵적으로 잠금).
PostgreSQL SSI — Serializable Snapshot Isolation
SSI 는 lock 으로 막지 않고, 낙관적으로 진행시킨 뒤 검출한다:
1. 모든 transaction 은 snapshot isolation 으로 돈다 (read 가 막지 않음)
2. DB 가 transaction 간 read/write 의존성(rw-conflict)을 추적
3. serial 순서로 재배열 불가능한 위험한 사이클이 감지되면
4. 그중 한 transaction 을 ABORT → 애플리케이션이 재시도
장점: read 가 write 를 막지 않아 MVCC 의 높은 동시성 유지
대가: write skew 같은 충돌 시 일부 transaction 이 강제 abort 됨그래서 PostgreSQL SERIALIZABLE 에서는 평소 빠르다가도 충돌이 생기면 ERROR: could not serialize access due to read/write dependencies (SQLSTATE 40001) 로 transaction 이 실패할 수 있다. 이게 정상 동작이며, 애플리케이션은 이걸 재시도해야 한다 (아래 패턴 참조). lock 기반은 막아서 기다리게 하고, SSI 는 진행시킨 뒤 일부를 무르는, 같은 보장의 다른 비용 구조다.
Deadlock — 서로를 기다리다 멈춤
T1: BEGIN; UPDATE accounts SET ... WHERE id = 1; -- row 1 잠금
T2: BEGIN; UPDATE accounts SET ... WHERE id = 2; -- row 2 잠금
T1: UPDATE accounts SET ... WHERE id = 2; -- row 2 대기 (T2 가 쥠)
T2: UPDATE accounts SET ... WHERE id = 1; -- row 1 대기 (T1 이 쥠)
-- 둘이 영원히 서로를 기다림 = deadlock검출과 해소 — DB 는 lock 대기 그래프(wait-for graph) 를 주기적으로 검사한다. 사이클이 발견되면 한쪽을 victim 으로 골라 abort 시킨다:
ERROR: deadlock detected
DETAIL: Process 123 waits for ShareLock on transaction 456;
Process 456 waits for ShareLock on transaction 123.
-- victim transaction 은 롤백됨 → 애플리케이션 재시도예방 — 일관된 lock 순서. 위 deadlock 의 근본 원인은 T1 은 1→2 순, T2 는 2→1 순으로 잠갔다는 것. 모든 코드가 항상 id 오름차순 등 같은 순서로 row 를 잠그면 사이클이 생길 수 없다:
-- 송금 시 항상 작은 id 를 먼저 잠근다
def transfer(from_id, to_id, amount):
a, b = sorted([from_id, to_id])
SELECT ... FROM accounts WHERE id = a FOR UPDATE;
SELECT ... FROM accounts WHERE id = b FOR UPDATE;
... 잔액 갱신 ...그 외 — transaction 을 짧게 유지, 필요한 row 만 잠그기, FK 에 index 를 걸어 불필요한 lock 확대 방지. deadlock 은 완전히 없앨 수는 없으니 재시도 로직은 항상 둔다.
실무 — 기본값과 비용
엔진마다 다른 기본 isolation level
- PostgreSQL — 기본
Read Committed. 매 statement 가 그 시점의 snapshot 을 새로 뜬다. - MySQL InnoDB — 기본
Repeatable Read. transaction 시작 시점의 snapshot 을 끝까지 유지. - Oracle / SQL Server — 또 다른 기본값과 구현. 옮길 때 동작이 미묘하게 달라진다.
그래서 "PostgreSQL 에서 잘 돌던 코드" 가 MySQL 로 옮기면 다르게 동작할 수 있다. 기본값을 모르고 코드를 짜면 안 된다.
"그냥 SERIALIZABLE 쓰면 되잖아" 의 함정
모든 anomaly 를 막아주는 건 맞다. 하지만 공짜가 아니다:
- lock 기반이면 경합·deadlock 증가, 처리량 저하.
- SSI 면 serialization failure (40001) abort 가 늘어 — 애플리케이션이반드시 재시도를 구현해야 한다. 안 하면 사용자에게 에러가 그대로 노출된다.
- 대부분의 워크로드는 Read Committed + 핵심 구간만 명시적 l (
SELECT ... FOR UPDATE) 이나 부분적으로 더 높은 level 로 충분하다.
Serialization failure 재시도 패턴
def run_serializable(work, max_retries=5):
for attempt in range(max_retries):
try:
BEGIN ISOLATION LEVEL SERIALIZABLE;
work() # 모든 read/write
COMMIT;
return
except SerializationFailure as e: # SQLSTATE 40001 또는 40P01
ROLLBACK;
sleep(backoff(attempt)) # 약간의 jitter 백오프
continue
raise TooManyRetries()핵심 — 재시도하는 함수는 side effect 가 없어야 한다 (멱등). transaction 안에서만 DB 를 만지고, 이메일 전송 같은 외부 효과는 COMMIT 이후로 미룬다. SQLSTATE 40001 (serialization failure) 과 40P01 (deadlock) 둘 다 재시도 대상이다.
관련 가이드
index 가 어떻게 lock 범위와 deadlock 가능성에 영향을 주는지는 how-database-indexes-work 에서 다룬다 (FK 에 index 가 없으면 lock 이 넓어진다). 그리고 COMMIT 의 durability 보장이 여러 노드로 퍼질 때 어떻게 흔들리는지 — replica 가 transaction 을 늦게 보는 문제는 how-replication-actually-works 에서 이어진다.
참고 자료
- PostgreSQL — Transaction Isolation — postgresql.org
- MySQL 8 — InnoDB Transaction Isolation Levels — mysql.com
- "A Critique of ANSI SQL Isolation Levels" — Berenson et al. — microsoft.com/research
- PostgreSQL SSI (wiki) — wiki.postgresql.org
요약
- transaction = BEGIN / COMMIT / ROLLBACK 으로 묶은 "전부 아니면 전무" 단위. SAVEPOINT 로 부분 rollback.
- ACID — A·D 는 단일 transaction 신뢰성, C 는 constraint + 당신 코드 (비즈니스 규칙 보장 아님), I 는 동시성. 버그는 대개 I 오해에서.
- anomaly — dirty / non-repeatable / phantom / write skew. write skew 는 snapshot isolation 으로도 못 막고 Serializable 만 잡는다.
- isolation 4 단계 — Read Uncommitted → Committed → Repeatable Read → Serializable. 같은 이름도 엔진마다 막는 게 다르다.
- PostgreSQL 은 MVCC (xmin/xmax 버전 + snapshot, 그래서 VACUUM 필요), 전통적 방식은 lock 기반 2PL.
- SERIALIZABLE — strict 2PL (막고 대기) vs PostgreSQL SSI (진행 후 충돌 abort). SSI 는 40001 로 실패 가능 → 재시도 필요.
- deadlock 은 DB 가 검출해 한쪽을 abort. 일관된 lock 순서 (예: id 오름차순) 로 예방.
- 기본값이 다르다 (Postgres=Read Committed, MySQL=Repeatable Read). "그냥 SERIALIZABLE" 은 비용이 있다 — retry-on-40001 을 멱등하게 구현하라.