본문으로 건너뛰기
yutils

데이터베이스 transaction 은 어떻게 동작할까?

ACID, isolation level, dirty/phantom read, write skew, MVCC 와 locking, SERIALIZABLE, deadlock, serialization 실패 재시도 패턴까지 실무로 정리.

약 9분 읽기

은행 송금 — 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 에서 이어진다.

참고 자료

요약

  • 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 을 멱등하게 구현하라.
가이드 목록으로