트랜잭션 격리 수준 (Transaction Isolation Level)
격리 수준이 왜 필요한가
동시성 문제
여러 트랜잭션이 동시에 같은 데이터에 접근할 때 문제가 발생한다.
격리 수준이 높으면 → 정확성 UP, 동시성 DOWN (성능 저하) 격리 수준이 낮으면 → 동시성 UP, 정확성 DOWN (이상 현상 발생 가능)
이 트레이드오프를 조절하기 위해 4단계의 격리 수준을 표준으로 정의했다.
격리 수준별 발생하는 이상 현상
Dirty Read (더티 리드)
커밋되지 않은 데이터를 읽는 현상
트랜잭션 A 트랜잭션 B
UPDATE money = 2000 (아직 커밋 X)
SELECT money = 2000 (읽음!)
ROLLBACK → money는 다시 1000
→ A는 존재하지 않는 값(2000)을 읽었다!
Non-Repeatable Read (반복 불가능한 읽기)
같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 값이 달라지는 현상
트랜잭션 A 트랜잭션 B
SELECT money = 1000
UPDATE money = 2000 + COMMIT
SELECT money = 2000 ← 달라짐!
→ 같은 트랜잭션에서 같은 행을 읽었는데 값이 다르다!
Phantom Read (팬텀 리드)
같은 트랜잭션 내에서 같은 조건으로 조회했는데 결과 건수가 달라지는 현상
트랜잭션 A 트랜잭션 B
SELECT COUNT(*) = 5 (WHERE age > 20)
INSERT new row (age=25) + COMMIT
SELECT COUNT(*) = 6 ← 달라짐!
→ 없던 행이 갑자기 나타났다(Phantom)!
4가지 격리 수준
READ UNCOMMITTED (격리 수준 최하)
커밋되지 않은 변경 사항도 읽을 수 있다.
트랜잭션 A: 잔액 1000원 → 2000원으로 변경 (아직 커밋 X)
트랜잭션 B: 잔액 조회 → 2000원 (커밋 전 데이터 읽음)
트랜잭션 A: ROLLBACK → 잔액 다시 1000원
트랜잭션 B: 2000원이라는 잘못된 데이터 기반으로 처리...
- 발생 문제: Dirty Read, Non-Repeatable Read, Phantom Read
- 실무: 거의 사용하지 않음 (데이터 정합성이 너무 낮음)
READ COMMITTED (격리 수준 2)
커밋된 데이터만 읽는다. Oracle 기본값.
트랜잭션 A: 잔액 1000원 → 2000원으로 변경 (아직 커밋 X)
트랜잭션 B: 잔액 조회 → 1000원 (커밋 전이므로 undo log에서 읽음)
트랜잭션 A: COMMIT
트랜잭션 B: 잔액 다시 조회 → 2000원 (이제 커밋됐으므로)
→ 같은 트랜잭션에서 값이 달라짐!
- 발생 문제: Non-Repeatable Read, Phantom Read
- 실무: 가장 많이 사용 (Oracle 기본, AWS RDS 기본)
REPEATABLE READ (격리 수준 3)
트랜잭션 시작 전에 커밋된 데이터만 읽으며, 트랜잭션 내에서 같은 행은 항상 같은 값. MySQL InnoDB 기본값.
트랜잭션 B (트랜잭션 ID: 10) 시작
트랜잭션 A: 잔액 1000원 → 2000원으로 변경 + COMMIT (트랜잭션 ID: 11)
트랜잭션 B: 잔액 조회 → 1000원 (트랜잭션 ID 10 이전 데이터, undo log에서 읽음)
트랜잭션 B: 잔액 다시 조회 → 1000원 (여전히 동일!)
→ Non-Repeatable Read 없음!
[MVCC (Multi-Version Concurrency Control)]
변경 시 이전 버전을 undo log에 보관
읽기 시 트랜잭션 시작 시점의 undo log 버전 읽음
→ 읽기와 쓰기가 서로 블로킹하지 않음!
- 발생 문제: Phantom Read (SELECT ... FOR UPDATE 등 잠금 읽기 시)
- 실무: MySQL 기본값, 자주 사용
SERIALIZABLE (격리 수준 최상)
모든 트랜잭션을 순차적으로 실행하는 것처럼 처리. 읽기 시 공유 잠금 획득.
트랜잭션 A: SELECT * FROM order WHERE ... (공유 잠금 획득)
트랜잭션 B: INSERT INTO order ... → 대기! (A가 공유 잠금 보유 중)
트랜잭션 A: COMMIT → 잠금 해제
트랜잭션 B: 이제 INSERT 가능
- 발생 문제: 없음 (모든 이상 현상 방지)
- 실무: 거의 사용하지 않음 (성능이 너무 낮음, 데드락 위험)
요약 표
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read | 설명 |
|---|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 | 미완료 변경도 읽음 |
| READ COMMITTED | 없음 | 발생 | 발생 | 커밋된 데이터만 읽음 |
| REPEATABLE READ | 없음 | 없음 | 발생(일부) | 트랜잭션 동안 같은 행은 고정 |
| SERIALIZABLE | 없음 | 없음 | 없음 | 완전 직렬화, 가장 안전 |
이상 현상 상세 설명
Dirty Read
아직 확정되지 않은 값을 읽어버리는 것.
트랜잭션 A 트랜잭션 B
BEGIN
UPDATE account SET money=2000 WHERE id=1
BEGIN
SELECT money FROM account WHERE id=1
-- 결과: 2000 (커밋 전!)
ROLLBACK
-- money는 다시 1000
-- B가 읽은 2000은 "더티 데이터"
Non-Repeatable Read
같은 데이터를 두 번 읽었는데 값이 달라지는 것.
트랜잭션 A 트랜잭션 B
BEGIN
SELECT money = 1000
BEGIN
UPDATE money=2000
COMMIT
SELECT money = 2000 ← 달라짐!
-- 같은 트랜잭션인데 다른 값
문제 시나리오: 재고가 10개인지 확인 후 주문 → 주문 처리 사이에 재고가 0개로 변경됨.
Phantom Read
없던 데이터가 갑자기 나타나거나(또는 사라지는) 현상.
트랜잭션 A 트랜잭션 B
BEGIN
SELECT COUNT(*) = 5 WHERE age > 20
BEGIN
INSERT INTO members (age=25)
COMMIT
SELECT COUNT(*) = 6 ← 행이 추가됨!
-- 없던 행이 나타남 (팬텀)
MySQL REPEATABLE READ에서의 특이점:
- 일반 SELECT: MVCC로 팬텀 리드 방지
- SELECT ... FOR UPDATE (잠금 읽기): 팬텀 리드 발생 가능
DB별 기본 격리 수준
| 데이터베이스 | 기본 격리 수준 |
|---|---|
| MySQL (InnoDB) | REPEATABLE READ |
| Oracle | READ COMMITTED |
| PostgreSQL | READ COMMITTED |
| SQL Server | READ COMMITTED |
| H2 | READ COMMITTED |
실무 선택 기준
| 상황 | 권장 격리 수준 | 이유 |
|---|---|---|
| 일반 웹 서비스 | READ COMMITTED | 성능과 정합성의 균형 |
| 금융 / 정산 | REPEATABLE READ | 반복 읽기 정합성 필요 |
| 배치 집계 작업 | READ COMMITTED | 대용량 데이터, 성능 중요 |
| 타임슬롯/예약 시스템 | REPEATABLE READ + 잠금 | 동시 예약 방지 |
| 실시간 재고 관리 | SELECT FOR UPDATE | 정확한 재고 차감 필요 |
스프링에서 격리 수준 지정
// 기본 (DB 기본값 사용)
@Transactional
public void defaultIsolation() { ... }
// READ COMMITTED 명시
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readCommittedExample() { ... }
// REPEATABLE READ
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void repeatableReadExample() { ... }
// 금융 정산 등 중요한 작업
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalWork() { ... }