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와 서브트랜잭션 오버플로우, 운영 권장 설정이 담겨 있습니다.