7장. 트랜잭션과 격리 수준
"동시에 두 사람이 같은 계좌를 만지면?" — 이 질문의 모든 답이 격리 수준입니다. Dirty Read부터 Serialization Anomaly까지, 그리고 Lock과 Deadlock의 실전 대응까지 다룹니다.
① 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. 둘 다 잔액을 읽어 1000을 얻습니다. RR은 이 순간 스냅샷을 고정합니다.
그 사이 다른 세션 T2가 잔액을 500으로 UPDATE하고, 새 계좌도 하나 INSERT한 뒤 커밋합니다.
Read Committed의 T1이 같은 행을 다시 읽으면? 500 — 문(statement)마다 새 스냅샷을 뜨기 때문에 커밋된 최신값이 보입니다. 같은 트랜잭션 안에서 값이 변했으니 Non-repeatable Read.
Repeatable Read의 T1′은? 여전히 1000. 시작 시점 스냅샷을 끝까지 재사용하므로 "그 순간의 세상"이 유지됩니다.
행 개수도 마찬가지 — RC에서는 T2가 INSERT한 행이 나타나는 Phantom Read가 발생하지만, RR에서는 차단됩니다.
정리: PostgreSQL의 RR은 표준 요구(NRR만 방지)보다 강한 Snapshot Isolation이라 Phantom까지 막습니다. 참고로 Read Uncommitted를 지정해도 MVCC 덕분에 Read Committed와 동일하게 동작 — Dirty Read는 PostgreSQL에 아예 없습니다.
| 격리 수준 | Dirty | Non-repeatable | Phantom | Serialization 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와 Bob 모두 당직 중입니다. 규칙은 "최소 1명 당직". Alice의 트랜잭션 T1이 당직자 수를 세어 봅니다 — 2명. "빠져도 되겠네."
거의 동시에 Bob의 T2도 같은 조회를 합니다 — 역시 2명(스냅샷상). "나도 빠져도 되겠네."
T1은 Alice 행을, T2는 Bob 행을 UPDATE합니다. 서로 다른 행이라 쓰기 충돌이 없어 Repeatable Read에서도 둘 다 커밋 성공 — 결과는 당직자 0명. 이것이 Write Skew입니다.
Serializable(SSI)은 다릅니다. 각 트랜잭션이 "읽은 범위"를 predicate lock으로 추적해, 읽기-쓰기 의존성이 위험하게 교차하면 한쪽을 serialization_failure(40001)로 abort시킵니다.
에러 받은 쪽이 재시도하면 이번엔 "1명뿐이네, 못 빠진다"를 보게 됩니다. 비용은 predicate lock 오버헤드와 재시도 — 진짜 필요한 불변식이 있을 때만 올리세요.
-- 세션 기본 격리 수준
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 실수가 곧 서비스 장애가 됩니다.
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 Manager가 아니라 튜플 헤더의 xmax 필드에 기록됩니다(3장의 그 xmax!). 그래서 수백만 행을 잠가도 락 테이블이 고갈되지 않습니다. 여러 트랜잭션이 한 행을 공유 잠금하면 MultiXact라는 구조가 동원됩니다.
7.5 Deadlock — 서로를 기다리다 함께 멈춘다
A가 B의 Lock을 기다리고, B가 A의 Lock을 기다리면 영원히 못 풉니다. PostgreSQL은 deadlock_timeout(기본 1초) 후 대기 그래프에서 사이클을 찾아 한쪽을 강제 abort합니다.
T1은 계좌 1→2 이체, T2는 반대로 2→1 이체. 각자 첫 번째 행을 UPDATE하며 행 Lock을 잡습니다 — 여기까진 정상.
T1이 두 번째 행(id=2)을 UPDATE하려는데, T2가 잡고 있어 대기에 들어갑니다.
T2도 id=1을 원합니다 — 그런데 그건 T1이 잡고 있죠. 서로가 서로를 기다리는 사이클이 완성됐습니다. 아무도 스스로는 못 빠져나옵니다.
deadlock_timeout(기본 1초)이 지나면 PostgreSQL이 대기 그래프를 검사해 사이클을 발견하고, 한 트랜잭션을 골라 abort합니다.
T2가 롤백되며 Lock이 풀리고 T1은 정상 진행합니다. 예방책은 단순합니다 — 모든 코드가 같은 순서로(예: id 오름차순) 행에 접근하면 사이클 자체가 생기지 않습니다.
- 접근 순서 고정 — 모든 트랜잭션이 같은 순서로 행·테이블에 접근.
- 트랜잭션은 짧게 — Lock 보유 시간 최소화.
- UPSERT 활용 — 같은 키 경합은
INSERT … ON CONFLICT DO UPDATE한 문장으로. log_lock_waits = on— deadlock 직전의 긴 대기를 로그로 남겨 사전 감지.
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); -- 연결 종료
트랜잭션을 열어놓고 커밋을 잊은 세션은 Lock뿐 아니라 VACUUM의 dead tuple 회수까지 막습니다(오래된 스냅샷 유지). idle_in_transaction_session_timeout = '5min'을 반드시 설정하세요. 기본값 0(무제한)은 사고를 기다리는 상태입니다.
✍️ 이해도 체크
FOR UPDATE SKIP LOCKED는 이미 다른 워커가 잠근 행을 대기 없이 건너뛰므로, 워커들이 자연스럽게 서로 다른 작업을 나눠 갖습니다. 테이블 전체 잠금은 병렬성을 죽이고, Serializable은 재시도 폭증을 부릅니다. 이 패턴이 PostgreSQL 기반 작업 큐의 표준입니다.이 장의 원문 문서: chapters/ch07_transactions_isolation.md — 테이블 Lock 8단계 호환표, Advisory Lock, SAVEPOINT와 서브트랜잭션 오버플로우, 운영 권장 설정이 담겨 있습니다.