트랜잭션과 격리 수준 — ACID부터 Phantom Read까지
A 계좌에서 돈은 빠졌는데 B 계좌에는 안 들어갔다면? 그리고 두 트랜잭션이 같은 데이터를 동시에 읽으면 어떤 일이 벌어질까?
트랜잭션이란
하나의 논리적 작업 단위 입니다. 여러 SQL이 모여서 하나의 작업을 구성하고, 이 작업은 전부 성공하거나 전부 실패 해야 합니다.
계좌 이체가 가장 좋은 예입니다.
-- A가 B에게 10만원 이체
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100000 WHERE id = 'B';
COMMIT;
첫 번째 UPDATE는 성공했는데 두 번째가 실패하면? A에서 돈은 빠졌는데 B에는 안 들어간 겁니다. 이걸 방지하는 게 트랜잭션입니다. 실패하면 ROLLBACK 으로 전부 되돌립니다.
ACID
트랜잭션이 보장해야 하는 4가지 속성입니다.
Atomicity (원자성)
트랜잭션의 연산은 **전부 수행되거나 전부 수행되지 않습니다 **. 중간 상태가 없습니다.
어떻게 보장하나? **Undo 로그 **. 트랜잭션 중 변경사항을 기록해두고, 실패하면 되돌립니다.
Consistency (일관성)
트랜잭션 전후로 DB의 ** 무결성 제약 조건 **이 유지됩니다. 계좌 잔고가 음수가 되면 안 되는 제약이 있다면, 트랜잭션이 이걸 위반할 수 없습니다.
Isolation (격리성)
동시에 실행되는 트랜잭션이 ** 서로 간섭하지 않습니다 **. 마치 순차적으로 실행된 것처럼 보여야 합니다.
근데 완벽한 격리는 성능 비용이 큽니다. 그래서 ** 격리 수준 **으로 트레이드오프합니다.
Durability (지속성)
COMMIT된 트랜잭션의 결과는 ** 영구적으로 보존 **됩니다. 시스템이 장애가 나더라도요.
어떻게 보장하나? Redo 로그(WAL). 변경사항을 로그에 먼저 기록하고, 나중에 디스크에 반영합니다. 장애 발생 시 로그를 재생해서 복구합니다.
격리 수준 (Isolation Level)
격리 수준이 낮으면 성능이 좋지만 동시성 문제가 발생하고, 높으면 안전하지만 느립니다.
발생 가능한 문제 3가지
1. Dirty Read (더티 리드)
커밋되지 않은 다른 트랜잭션의 변경을 읽음.
트랜잭션 A: UPDATE balance = 0 WHERE id = 1 (아직 COMMIT 안 함)
트랜잭션 B: SELECT balance FROM ... → 0 (커밋 안 된 데이터를 읽음)
트랜잭션 A: ROLLBACK (원래 잔고로 되돌림)
→ B가 읽은 0은 존재한 적 없는 데이터
2. Non-Repeatable Read (반복 불가능 읽기)
같은 쿼리를 두 번 실행했는데 결과가 다름. 다른 트랜잭션이 그 사이에 UPDATE/DELETE 했기 때문.
트랜잭션 A: SELECT balance → 100
트랜잭션 B: UPDATE balance = 200, COMMIT
트랜잭션 A: SELECT balance → 200 (다른 결과!)
3. Phantom Read (팬텀 리드)
같은 조건으로 쿼리했는데 행의 ** 수가 다름 **. 다른 트랜잭션이 INSERT 했기 때문.
트랜잭션 A: SELECT COUNT(*) WHERE age > 20 → 5
트랜잭션 B: INSERT (age=25), COMMIT
트랜잭션 A: SELECT COUNT(*) WHERE age > 20 → 6 (유령처럼 행이 나타남)
4가지 격리 수준
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 방지 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | 발생 (이론상) |
| SERIALIZABLE | 방지 | 방지 | 방지 |
READ UNCOMMITTED
커밋 안 된 데이터도 읽음. 실무에서 거의 안 씁니다.
READ COMMITTED
** 커밋된 데이터만 읽음 **. Oracle, PostgreSQL의 기본 격리 수준입니다. 매 SELECT마다 최신 커밋 데이터를 읽으니까 Non-Repeatable Read가 발생합니다.
REPEATABLE READ
** 트랜잭션 시작 시점의 스냅샷 **을 읽습니다. 다른 트랜잭션이 커밋해도 이 트랜잭션 내에서는 같은 데이터를 봅니다.
MySQL InnoDB의 기본 격리 수준입니다. InnoDB는 MVCC(Multi-Version Concurrency Control)와 Next-Key Lock 으로 Phantom Read도 대부분 방지합니다.
SERIALIZABLE
가장 높은 격리 수준. 트랜잭션이 순차적으로 실행된 것처럼 보장합니다. 성능이 크게 떨어지므로 특수한 경우에만 사용합니다.
MVCC (Multi-Version Concurrency Control)
InnoDB가 격리성을 구현하는 핵심 메커니즘입니다.
데이터를 수정할 때 기존 버전을 Undo 영역에 보관 합니다. 각 트랜잭션은 자신의 시작 시점에 해당하는 버전을 읽습니다.
데이터: balance = 100
트랜잭션 A 시작 (시점: T1)
트랜잭션 B: UPDATE balance = 200, COMMIT (시점: T2)
트랜잭션 A: SELECT balance → 100 (T1 시점의 스냅샷)
트랜잭션 A: COMMIT
덕분에 **읽기와 쓰기가 서로 블로킹하지 않습니다 **. 읽기는 스냅샷을 보고, 쓰기는 최신 버전을 수정합니다.
주의할 점
"MySQL의 REPEATABLE READ에서 Phantom Read가 발생하나요?"
이론적으로는 REPEATABLE READ에서 Phantom Read가 발생하지만, InnoDB는 Next-Key Lock 으로 대부분 방지합니다.
Next-Key Lock은 인덱스 레코드와 그 사이 갭(gap) 을 함께 잠그는 방식입니다. 다른 트랜잭션이 해당 범위에 INSERT하는 걸 막습니다.
다만, SELECT ... FOR UPDATE 같은 잠금 읽기가 아닌 일반 SELECT에서는 MVCC 스냅샷으로 Phantom을 방지하지만, 쓰기 작업이 복잡하게 얽히면 여전히 이슈가 될 수 있습니다.
"낙관적 잠금(Optimistic Locking)과 비관적 잠금(Pessimistic Locking)의 차이는?"
- **비관적 잠금 **: 충돌이 날 거라 가정하고 ** 미리 잠금 **.
SELECT ... FOR UPDATE - ** 낙관적 잠금 **: 충돌이 안 날 거라 가정하고 ** 커밋 시점에 검증 **. 버전 번호나 타임스탬프 비교
// 낙관적 잠금 (JPA)
@Version
private Long version;
// 업데이트 시 버전이 달라졌으면 예외 발생
// → OptimisticLockException
읽기가 많고 충돌이 적으면 낙관적, 쓰기가 많고 충돌이 잦으면 비관적이 적합합니다.
"DB 락의 종류는?"
| 락 | 설명 |
|---|---|
| Shared Lock (S) | 읽기 잠금. 여러 트랜잭션이 동시에 읽기 가능 |
| Exclusive Lock (X) | 쓰기 잠금. 다른 트랜잭션의 읽기/쓰기 모두 차단 |
| Record Lock | 특정 인덱스 레코드에 대한 잠금 |
| Gap Lock | 인덱스 레코드 사이의 갭에 대한 잠금 |
| Next-Key Lock | Record Lock + Gap Lock |
파생되는 개념들
- ** 정규화 (1NF ~ 3NF, BCNF)** — 테이블 설계 원칙
- N+1 문제 — ORM에서 흔히 발생하는 쿼리 성능 이슈
- Connection Pool — 커넥션 관리 전략
- ** 분산 트랜잭션** — 2PC, SAGA 패턴
- Redis 캐시 전략 — Cache Aside, Write Through