DB Study

7장. 트랜잭션과 격리 수준

"동시에 두 사람이 같은 계좌를 만지면?" — 이 질문의 모든 답이 격리 수준입니다. Dirty Read부터 Serialization Anomaly까지, 그리고 Lock과 Deadlock의 실전 대응까지 다룹니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 3장 MVCC
🧭
이 장에서 배우는 것

① SQL 표준의 이상현상 4종(Dirty·Non-repeatable·Phantom·Serialization Anomaly) ② PostgreSQL 격리 수준 3단계의 실제 동작 ③ 테이블/행 Lock과 SKIP LOCKED 패턴 ④ Deadlock의 발생·탐지·예방과 진단 쿼리

7.1 ACID 복습 — 이 장의 주인공은 I

속성의미PostgreSQL 구현 근거
Atomicity전부 커밋 또는 전부 롤백WAL, CLOG, xmin/xmax
Consistency제약 위반 시 실패CHECK/FK/UNIQUE
Isolation동시 트랜잭션 간 영향 차단MVCC + Lock + 격리 수준 (이 장!)
Durability커밋된 변경은 장애에도 보존WAL fsync, checkpoint (9장)

7.2 이상현상 실험실 — 같은 시나리오, 다른 격리 수준

SQL 표준이 정의한 이상현상은 Dirty Read(미커밋 값 읽음), Non-repeatable Read(같은 행이 변함), Phantom Read(행 개수가 변함), 그리고 PostgreSQL이 중요하게 다루는 Serialization Anomaly입니다. 두 트랜잭션을 나란히 세워두고, 격리 수준에 따라 무엇이 달라지는지 직접 봅시다.

T1: READ COMMITTED (기본) T1′: REPEATABLE READ SELECT balance → 1000 SELECT balance → 1000 📸 스냅샷 고정 ⚡ T2 (다른 세션): UPDATE balance=500; INSERT 신규 계좌 1건; COMMIT 같은 SELECT → 500 (값이 변함!) 😱 Non-repeatable Read 발생 같은 SELECT → 1000 (그대로!) ✓ 시작 시점 스냅샷을 끝까지 재사용 COUNT(*) → 1건 증가 (Phantom!) COUNT(*) → 그대로 (Phantom 차단) PostgreSQL의 Repeatable Read는 표준보다 강력 — Phantom까지 막는 Snapshot Isolation
격리 수준DirtyNon-repeatablePhantomSerialization Anomaly
Read Committed (기본)✗ 없음발생발생발생
Repeatable Read✗ (표준보다 강함)발생
Serializable (SSI)
-- Repeatable Read의 대가: 쓰기 충돌 시 에러 → 재시도 로직 필수
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;   -- 1000 (스냅샷 고정)
-- 그 사이 T2가 같은 행을 UPDATE & COMMIT
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update (40001)
ROLLBACK;  -- 애플리케이션이 처음부터 재시도해야 한다

7.3 Serializable — 스냅샷도 못 막는 Write Skew

Repeatable Read조차 못 막는 미묘한 버그가 있습니다. 각자 "읽고 → 판단하고 → 다른 행을 쓰는" 두 트랜잭션이 교차하면, 개별로는 멀쩡한데 합치면 제약이 깨지는 결과가 나옵니다. 병원 당직 시스템으로 봅시다 — 규칙: 최소 한 명은 당직.

🏥 당직 현황 Alice: ON · Bob: ON T1(Alice): 당직자 수 조회 → 2명 "2명이네? 나는 빠져도 되겠다" T2(Bob): 당직자 수 조회 → 2명 "2명이네? 나도 빠져도 되겠다" UPDATE Alice=OFF → 커밋 ✓ UPDATE Bob=OFF → 커밋 ✓ (다른 행이라 충돌 없음) 😱 당직자 0명 — Repeatable Read로도 못 막았다! SERIALIZABLE이었다면: 읽기 의존성 교차 감지 → ERROR 40001 serialization_failure (한쪽 abort) 에러를 받은 쪽이 재시도하면 "1명은 남는다" 제약이 지켜진다 — 직렬 실행과 동등한 결과 보장
-- 세션 기본 격리 수준
SET default_transaction_isolation = 'read committed';  -- 기본 유지 권장

-- 필요한 트랜잭션만 올린다
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 읽기 전용 리포트라면: 재시도 없는 안전 스냅샷을 기다림
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;

7.4 Lock — MVCC가 못 없애는 최후의 줄서기

MVCC 덕에 읽기·쓰기는 서로 안 막지만, 쓰기끼리·DDL은 Lock이 필요합니다. 테이블 레벨 Lock은 8단계나 되지만, 운영에서 기억할 것은 두 개입니다: INSERT/UPDATE/DELETE가 잡는 ROW EXCLUSIVE, 그리고 DROP·TRUNCATE·대부분의 ALTER가 잡는 ACCESS EXCLUSIVE — 후자는 SELECT까지 차단하므로 DDL 실수가 곧 서비스 장애가 됩니다.

🚨
DDL 전에는 반드시 lock_timeout

SET lock_timeout = '2s'; 후 ALTER를 실행하세요. Lock을 2초 안에 못 잡으면 에러로 물러나므로, ALTER가 기존 긴 쿼리 뒤에 줄 서 있는 동안 그 뒤의 모든 SELECT까지 연쇄 대기하는 참사를 막을 수 있습니다. CREATE INDEX CONCURRENTLY·REINDEX CONCURRENTLY도 같은 맥락의 도구입니다.

행 레벨 Lock은 SELECT … FOR UPDATE 계열로 명시적으로 잡을 수 있습니다. 특히 SKIP LOCKED는 큐 워커 패턴의 표준입니다.

-- 큐 소비자 패턴: 여러 워커가 경합 없이 서로 다른 작업을 집는다
BEGIN;
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED   -- 이미 잠긴 행은 건너뛴다 (9.5+)
LIMIT 10;
-- ... 처리 ...
UPDATE jobs SET status = 'done' WHERE id = ANY(...);
COMMIT;
💡
행 Lock은 어디에 저장될까

메모리의 Lock Manager가 아니라 튜플 헤더의 xmax 필드에 기록됩니다(3장의 그 xmax!). 그래서 수백만 행을 잠가도 락 테이블이 고갈되지 않습니다. 여러 트랜잭션이 한 행을 공유 잠금하면 MultiXact라는 구조가 동원됩니다.

7.5 Deadlock — 서로를 기다리다 함께 멈춘다

A가 B의 Lock을 기다리고, B가 A의 Lock을 기다리면 영원히 못 풉니다. PostgreSQL은 deadlock_timeout(기본 1초) 후 대기 그래프에서 사이클을 찾아 한쪽을 강제 abort합니다.

row id=1 row id=2 T1 이체: 1→2 T2 이체: 2→1 UPDATE id=1 🔒 UPDATE id=2 🔒 id=2도 필요… ⏳ 대기 id=1도 필요… ⏳ 대기 ♻️ 사이클 완성 = Deadlock deadlock_timeout(1s) 경과 → 대기 그래프 검사 ERROR: deadlock detected → T2 abort 💀 롤백됨 T1이 Lock 획득, 진행 ✓

7.6 실전 진단 — 누가 누구를 막고 있나

-- 블로킹 체인 추적 (9.6+)
SELECT blocked.pid  AS blocked_pid,
       blocked.query AS blocked_query,
       blocking.pid  AS blocking_pid,
       blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN LATERAL (
    SELECT pid, query FROM pg_stat_activity
    WHERE pid = ANY(pg_blocking_pids(blocked.pid))
) blocking ON true
WHERE blocked.wait_event_type = 'Lock';

-- 원인 세션 정리: 부드럽게 → 강제로
SELECT pg_cancel_backend(12345);     -- 쿼리만 취소
SELECT pg_terminate_backend(12345);  -- 연결 종료
⚠️
조용한 빌런: idle in transaction

트랜잭션을 열어놓고 커밋을 잊은 세션은 Lock뿐 아니라 VACUUM의 dead tuple 회수까지 막습니다(오래된 스냅샷 유지). idle_in_transaction_session_timeout = '5min'을 반드시 설정하세요. 기본값 0(무제한)은 사고를 기다리는 상태입니다.

✍️ 이해도 체크

PostgreSQL의 Repeatable Read에 대한 설명으로 옳은 것은?
✅ PostgreSQL의 RR은 트랜잭션 시작 시 스냅샷을 한 번 뜨고 끝까지 재사용하는 Snapshot Isolation이라 표준 요구보다 강하게 Phantom까지 차단합니다. 그러나 서로 다른 행을 쓰는 Write Skew는 통과시키므로, 그런 불변식이 필요하면 Serializable(SSI)로 올려야 합니다. "문마다 새 스냅샷"은 Read Committed의 동작입니다.
여러 워커 프로세스가 jobs 테이블에서 대기 작업을 나눠 가져가는 큐를 구현하려 합니다. 가장 적합한 방법은?
FOR UPDATE SKIP LOCKED는 이미 다른 워커가 잠근 행을 대기 없이 건너뛰므로, 워커들이 자연스럽게 서로 다른 작업을 나눠 갖습니다. 테이블 전체 잠금은 병렬성을 죽이고, Serializable은 재시도 폭증을 부릅니다. 이 패턴이 PostgreSQL 기반 작업 큐의 표준입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch07_transactions_isolation.md — 테이블 Lock 8단계 호환표, Advisory Lock, SAVEPOINT와 서브트랜잭션 오버플로우, 운영 권장 설정이 담겨 있습니다.