트랜잭션과 격리 수준
동시 트랜잭션 간섭을 막는 4단계 보호
트랜잭션은 여러 SQL을 한 단위로 묶어 ACID(원자성·일관성·격리성·지속성)를 보장하는 단위다. 동시에 도는 트랜잭션끼리 간섭하면 Dirty Read, Non-Repeatable Read, Phantom Read, Lost Update 같은 이상 현상이 생긴다. 격리 수준 4단계(Read Uncommitted~Serializable)는 어떤 이상 현상까지 막을지를 정하는 다이얼이고, 위로 갈수록 안전하지만 느려진다.
개념
트랜잭션은 여러 SQL을 묶어서 하나의 단위로 성공하거나 실패하게 만드는 단위다. 계좌이체처럼 "A에서 빼기 + B에 넣기"가 따로 따로 성공하면 돈이 증발하므로, 둘을 한 묶음으로 보장해야 한다. 이걸 ACID 4가지로 정리한다.
ACID 중 A(원자성), **D(지속성)**는 DB 엔진이 알아서 해주고 **C(일관성)**는 사실 앱 책임에 가깝다. 실무 트러블의 거의 전부는 **I(격리성)**에서 나온다. 동시에 도는 트랜잭션 수백~수천 개가 서로를 어떻게 보고 어떻게 안 보는지를 다루는 영역.
ACID 4글자
| 글자 | 의미 | 누가 보장 |
|---|---|---|
| Atomicity (원자성) | 전부 성공 or 전부 실패. 중간 상태 없음 | DB (undo log) |
| Consistency (일관성) | 트랜잭션 끝나도 DB 제약·비즈니스 규칙 안 깨짐 | DB(제약 조건) + 앱(비즈니스) |
| Isolation (격리성) | 동시 트랜잭션이 서로 간섭 안 함 | DB (격리 수준 + 락/MVCC) |
| Durability (지속성) | COMMIT 후엔 전원 나가도 살아남음 | DB (WAL fsync) |
A — 원자성
계좌이체 중간에 서버가 죽으면 "A에서 1만원 빠졌는데 B엔 안 들어감" 사태가 난다. 원자성이 있으면 둘 다 성공 or 둘 다 ROLLBACK. 되돌리려면 "바꾸기 전 값"을 어딘가 저장해둬야 하므로 undo log가 필요하다. MySQL InnoDB는 별도 undo log를, PostgreSQL은 MVCC의 옛 버전을 undo 대신 사용한다.
C — 일관성 (가장 약한 글자)
DB가 강제하는 부분은 NOT NULL, FK, UNIQUE, CHECK 같은 제약 조건뿐이다. "이체 후 총액이 보존된다"는 비즈니스 규칙은 앱이 직접 만들어야 한다. ACID에 끼워넣은 건 첫 글자 맞추기에 가깝다는 시각이 있다 (DDIA, Kleppmann).
D — 지속성
COMMIT 응답이 떨어진 트랜잭션은 디스크에 살아남는다. 이걸 보장하는 메커니즘이 WAL fsync. 자세한 흐름은 WAL과-버퍼-풀 참고. 매 COMMIT마다 WAL 한 번만 디스크에 쓰면 되도록 설계해서 D를 거의 공짜로 만든 게 핵심.
격리성 — 동시 트랜잭션의 4가지 이상 현상
격리 수준이 약하면 다음 현상들이 생긴다. 위에서 아래로 갈수록 미묘해진다.
1. Dirty Read — 확정 안 된 값 읽기
T1: BEGIN; UPDATE accounts SET balance = 60000 WHERE id='me';
T2: SELECT balance → 60000 봄
T1: ROLLBACK
T2: 존재한 적 없는 값을 본 것
T1이 ROLLBACK 됐는데 T2는 그 사이의 임시 값을 봤다. 사용자에게 "잔액 60000원" 보여주고 결정하게 하면 사고난다. 거의 모든 DB가 기본 설정으로 막는다.
2. Non-Repeatable Read — 같은 행, 두 번 읽었더니 값이 다름
T1: SELECT balance → 50000
T2: UPDATE balance = 60000; COMMIT;
T1: SELECT balance → 60000 ← 같은 트랜잭션 안인데 값 바뀜
T1이 한 트랜잭션 안에서 같은 데이터를 두 번 읽었는데 다른 값이 나온다. 보고서나 집계 도중에 일어나면 모순된 결과가 나옴. Repeatable Read 수준부터 막힌다.
3. Phantom Read — 행의 존재 자체가 새로 생기/사라짐
T1: SELECT COUNT(*) WHERE balance >= 5000 → 100건
T2: INSERT (balance=10000); COMMIT;
T1: SELECT COUNT(*) WHERE balance >= 5000 → 101건 ← 유령처럼 새 행 등장
기존 행이 변경된 게 아니라 새 행이 추가/삭제된 경우. 행 단위 락만으론 못 막는다 — 존재하지 않던 걸 락 걸 수 없으니. SQL 표준은 Serializable부터 막힌다고 본다. 단 PostgreSQL의 Repeatable Read는 스냅샷 기반이라 자동으로 막아준다.
4. Lost Update — 두 트랜잭션의 변경 중 하나가 사라짐
T1: SELECT likes → 100; T2: SELECT likes → 100;
T2: UPDATE likes = 101; COMMIT;
T1: UPDATE likes = 101; COMMIT;
결과: 101 (T2의 +1이 사라짐)
좋아요 두 명이 동시에 눌렀는데 결과는 +1. 표준 SQL 격리 수준 표엔 안 나오지만 실무에서 가장 자주 보는 이상 현상.
UPDATE posts SET likes = likes + 1 같은 atomic update를 쓰면 안 생긴다. 문제는 read-modify-write 패턴이 필요한 경우 (좌석 예약, 재고 차감, 조건부 잔액 변경) — DB로 한 번에 못 풀고 앱이 읽고 판단하고 써야 할 때.
격리 수준 4단계
| 수준 | Dirty Read | Non-Repeatable | Phantom | Lost Update |
|---|---|---|---|---|
| Read Uncommitted | 가능 | 가능 | 가능 | 가능 |
| Read Committed | 막음 | 가능 | 가능 | 가능 |
| Repeatable Read | 막음 | 막음 | 가능 (표준) | DB마다 다름 |
| Serializable | 막음 | 막음 | 막음 | 막음 |
위로 갈수록 빠르고 느슨, 아래로 갈수록 느리고 안전.
DB별 기본값
| DB | 기본 격리 수준 |
|---|---|
| PostgreSQL | Read Committed |
| MySQL InnoDB | Repeatable Read |
| Oracle | Read Committed |
| SQL Server | Read Committed |
기본값이 다르므로 내가 쓰는 DB의 기본이 뭔지 확인해야 한다. PostgreSQL에서 Repeatable Read를 가정하고 코드를 짰다가 운영에서 Read Committed라 이상 동작하는 경우가 흔하다.
Lost Update를 막는 방법
Lost Update가 표에 따로 안 나오는 이유는 격리 수준만으로는 깔끔히 안 풀리기 때문. 실무에서 쓰는 4가지 패턴.
1. Atomic Update — DB가 직접 계산
-- 위험 (read-modify-write)
SELECT likes FROM posts WHERE id=1; -- 100
UPDATE posts SET likes = 101 WHERE id=1;
-- 안전
UPDATE posts SET likes = likes + 1 WHERE id=1;
가능하면 항상 이걸 우선. 빠르고 단순함.
2. 비관적 락 — SELECT ... FOR UPDATE
BEGIN;
SELECT * FROM seats WHERE id=5 FOR UPDATE; -- 락 걸고 읽음
-- 다른 트랜잭션은 여기서 대기
UPDATE seats SET status='BOOKED' WHERE id=5;
COMMIT;
"내가 끝낼 때까지 아무도 손대지 마." 충돌 자주 일어나는 경우(좌석 예약, 재고 차감)에 유리.
3. 낙관적 락 — version 컬럼
SELECT data, version FROM items WHERE id=1; -- version=5
-- ... 앱에서 처리 ...
UPDATE items SET data=..., version=6
WHERE id=1 AND version=5; -- 영향 행 0이면 충돌, 재시도
"일단 락 안 걸고 진행. 끝낼 때 누가 끼어들었는지 검사." 충돌 드문 경우에 유리. 락 비용 없음.
4. Serializable 격리
DB가 충돌을 자동 감지해서 한쪽을 abort. 가장 안전하지만 가장 느리고 재시도 로직 필요.
언제 어떤 락을 쓰나
| 상황 | 추천 |
|---|---|
| 단순 카운터·잔액 가감 | Atomic Update |
| 좌석/재고 등 충돌 잦은 read-modify-write | 비관적 락 (FOR UPDATE) |
| 충돌 드문 read-modify-write (예: 사용자 프로필 편집) | 낙관적 락 (version) |
| 절대 안전 우선 | Serializable + 재시도 |
ORM이 트랜잭션을 추상화해줘도 이 4가지 중 어떤 패턴인지는 개발자가 명시해야 한다 (Django select_for_update(), SQLAlchemy with_for_update() 등). 모르고 안 부르면 production에서 동시성 버그가 잠복한다.
더 보기
- WAL과-버퍼-풀 — D(지속성)를 만드는 메커니즘. COMMIT 시점에 디스크 가는 건 데이터 페이지가 아니라 WAL이라는 분리가 핵심
- DB-스토리지-레이아웃 — 트랜잭션이 다루는 "페이지"의 정체
- 동시성 — OS 레이어의 뮤텍스/세마포어. DB 락은 이 위에 올라간 추상화