트랜잭션 — ACID·격리수준·이상현상
계좌 A에서 B로 만 원을 옮긴다 — A에서 빼고, B에 더한다. 이 둘 사이에서 서버가 죽으면? 돈이 증발한다. **트랜잭션(transaction)**은 여러 작업을 하나의 논리 단위로 묶어, 전부 되거나 전부 안 되게 만드는 약속이다. 그 약속의 네 글자가 ACID다. 그리고 여러 트랜잭션이 동시에 돌 때 서로 얼마나 간섭을 허용할지가 격리수준이다 — 이 둘이 DB 신뢰성의 심장이다. (출처: Silberschatz DBSC 7e Ch 17 Transactions / PostgreSQL 17 — Transaction Isolation.)
ACID — 트랜잭션의 네 가지 약속
- 원자성(Atomicity) — 전부 아니면 전무. 중간에 실패하면 모두 롤백. (구현: undo 로그/WAL — [08 회복]에서.)
- 일관성(Consistency) — 트랜잭션 전후로 DB 제약(무결성·FK·CHECK)이 유지된다. 나머지 셋이 받쳐주는 결과적 속성.
- 독립성(Isolation) — 동시 트랜잭션이 서로 안 보이는 것처럼 보인다. 어디까지 보장하냐가 격리수준.
- 지속성(Durability) — 커밋된 것은 장애가 나도 살아남는다. (구현: WAL을 디스크에 먼저 flush.)
BEGIN;
UPDATE account SET balance = balance - 10000 WHERE id = 'A';
UPDATE account SET balance = balance + 10000 WHERE id = 'B';
COMMIT; -- 둘 다 확정. 사이에서 죽으면 ROLLBACK 되어 A도 원복.
원자성과 일관성은 한 트랜잭션 안의 이야기다. 진짜 어려운 건 여러 트랜잭션이 동시에 돌 때의 독립성이다.
동시성이 만드는 이상현상
격리를 약하게 하면 성능은 오르지만 아래 현상들이 샌다. 격리수준은 *"어떤 현상까지 막느냐"*로 정의된다.
- Dirty Read(오손 읽기) — 커밋 안 된 남의 변경을 읽음. 롤백되면 존재한 적 없는 값을 본 셈.
- Non-repeatable Read(반복 불가 읽기) — 같은 행을 두 번 읽었는데 값이 다름(사이에 남이 커밋).
- Phantom Read(유령 읽기) — 같은 조건으로 두 번 조회했는데 행 수가 다름(사이에 남이 INSERT/DELETE).
- Lost Update(갱신 손실) — 두 트랜잭션이 읽고-고쳐-쓰기를 겹쳐 한쪽 변경이 덮여 사라짐.
- Write Skew(쓰기 왜곡) — 각자 다른 행을 보고 결정했는데 합쳐보면 제약이 깨지는 미묘한 경우(예: 당직자 최소 1명 규칙).
표준 격리수준 — 무엇을 막나
ANSI SQL은 네 단계를 정의하고, 위로 갈수록 안전하지만 동시성은 낮아진다.
| 격리수준 | Dirty | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 막음 | 가능 | 가능 |
| REPEATABLE READ | 막음 | 막음 | 가능(표준상) |
| SERIALIZABLE | 막음 | 막음 | 막음 |
표준은 현상을 막는 최소 기준만 정하고 구현은 DB마다 다르다 — 이 부분이 실무에서 가장 자주 어긋나는 지점이다.
PostgreSQL vs MySQL — 기본값과 구현이 다르다
PostgreSQL (MVCC·스냅샷 기반):
- 기본값은 Read Committed. 매 *문장(statement)*마다 새 스냅샷 → 문장 단위로 커밋된 데이터만 본다.
- Read Uncommitted를 요청해도 Read Committed로 동작(dirty read를 아예 안 함).
- Repeatable Read = 스냅샷 격리(Snapshot Isolation) — 트랜잭션 시작 시점 스냅샷을 끝까지 봐서 phantom까지 막는다(표준보다 강함). 단 write skew는 남는다.
- Serializable = SSI(Serializable Snapshot Isolation) — 직렬화 이상을 탐지하면 한쪽을 롤백(
could not serialize에러)시켜 write skew까지 막는다. 애플리케이션은 재시도 준비가 필요.
MySQL InnoDB:
- 기본값은 Repeatable Read. 갭 락(gap lock)/넥스트키 락으로 phantom도 상당히 막는다.
- Read Committed도 흔히 쓰이며(복제·락 경합 줄이려고), 락 잡는 범위가 달라진다.
그래서 *"Repeatable Read"*라는 같은 이름이어도 PostgreSQL(스냅샷)과 MySQL(락+갭락)의 동작과 실패 양상이 다르다. 내 DB의 기본값과 구현을 아는 게 먼저다. (출처: PostgreSQL Transaction Isolation / MySQL InnoDB Isolation Levels.)
Lost Update를 막는 법 — 낙관 vs 비관
읽고-고쳐-쓰는 흐름의 갱신 손실은 격리수준만으론 부족할 때가 많다. 두 전략이 있다.
-- 비관적 락(pessimistic): 읽을 때 잠가 버림
BEGIN;
SELECT stock FROM product WHERE id = 42 FOR UPDATE; -- 행 잠금
UPDATE product SET stock = stock - 1 WHERE id = 42;
COMMIT;
-- 낙관적 락(optimistic): 버전 칼럼으로 충돌 감지
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 42 AND version = 7; -- 영향 행 0이면 누가 먼저 바꾼 것 → 재시도
- 비관적 락(
SELECT ... FOR UPDATE) — 충돌이 잦을 때. 먼저 잠그고 작업. 락 경합·데드락 주의. - 낙관적 락(version 칼럼) — 충돌이 드물 때. 잠그지 않고 쓸 때 버전이 그대로인지 확인, 어긋나면 재시도. 동시성이 높다.
수량 누적처럼 현재값 기반 연산은 아예 SET stock = stock - 1처럼 DB가 원자적으로 계산하게 하면 읽기-쓰기 경합 자체를 줄인다.
실무 원칙 — 트랜잭션은 짧게, 격리는 필요한 만큼
- 트랜잭션은 짧게. 트랜잭션이 길면 락·스냅샷을 오래 쥐어 동시성을 망치고, PostgreSQL에선 오래된 스냅샷이 VACUUM을 막아 테이블이 부푼다([05 MVCC]).
- 외부 호출(HTTP·메일)을 트랜잭션 안에 넣지 마라. 느린 I/O가 DB 락을 인질로 잡는다.
- 격리수준은 기본(Read Committed)에서 시작하고, 갱신 손실·write skew가 문제인 특정 지점만
FOR UPDATE/버전/Serializable로 올린다. - Serializable·낙관적 락을 쓰면 재시도 로직이 짝이다 — 직렬화 실패·버전 충돌은 정상 흐름으로 다뤄야 한다.
한 줄 요약 — ACID는 전부-또는-전무 + 동시성 격리 + 영속. 이상현상(dirty/non-repeatable/phantom/lost update/write skew)을 어디까지 막느냐가 격리수준이고, PostgreSQL 기본 Read Committed·RR은 스냅샷, MySQL 기본 RR은 갭락으로 구현이 다르다. 갱신 손실은 비관/낙관 락으로 따로 막는다.
(출처: Silberschatz DBSC 7e Ch 17·18 / PostgreSQL 17 Transaction Isolation / MySQL 8.4 InnoDB Isolation.)