격리 수준
여러 트랜잭션이 동시에 실행될 때 서로 얼마나 격리할지 정하는 수준입니다.
4단계 격리 수준
| 레벨 | 설명 |
|---|---|
| READ UNCOMMITTED | 커밋 안 된 데이터도 읽음 |
| READ COMMITTED | 커밋된 데이터만 읽음 |
| REPEATABLE READ | 같은 쿼리는 같은 결과 보장 |
| SERIALIZABLE | 완전 격리 |
발생 가능한 문제 4가지
1. Dirty Read
커밋 안 된 데이터를 읽음
A: UPDATE age = 30 (커밋 전)
B: SELECT age → 30 읽음
A: ROLLBACK
B: 30을 믿고 로직 수행 → 문제!
2. Non-Repeatable Read
같은 row인데 값이 달라짐
A: SELECT age → 25
B: UPDATE age = 30, COMMIT
A: SELECT age → 30 (값이 바뀜!)
3. Phantom Read
없던 row가 생김
A: SELECT * WHERE age > 20 → 3건
B: INSERT (age=25), COMMIT
A: SELECT * WHERE age > 20 → 4건 (row 추가됨!)
4. Write Skew
각 트랜잭션은 규칙을 지켰지만, 결합하면 규칙이 깨짐
다른 이상 현상과 달리 단일 행이 아닌 여러 행에 걸친 제약 조건이 위반되는 현상입니다.
[규칙: 최소 1명의 의사가 당직이어야 함]
현재 상태: 의사A = 당직, 의사B = 당직 (2명 당직 중)
트랜잭션A (의사A): SELECT COUNT(*) WHERE 당직 = true → 2명
→ "2명이니까 내가 빠져도 1명 남음" → UPDATE 의사A = 비당직
트랜잭션B (의사B): SELECT COUNT(*) WHERE 당직 = true → 2명
→ "2명이니까 내가 빠져도 1명 남음" → UPDATE 의사B = 비당직
둘 다 COMMIT → 당직 의사 0명 → 규칙 위반! X
[Dirty Read / Non-Repeatable Read / Phantom Read와의 차이]
Dirty Read: 하나의 행에서 커밋 안 된 값을 읽음
Non-Repeatable Read: 하나의 행의 값이 달라짐
Phantom Read: 행의 수가 달라짐
Write Skew: 각각의 읽기와 쓰기는 정상이지만
두 트랜잭션의 쓰기가 결합되면 제약 조건 위반
→ 읽기 이상이 아닌 "쓰기 이상"
→ 개별 트랜잭션만 보면 문제가 없어서 감지가 어려움
Write Skew의 다른 예시들:
[예시 1: 회의실 중복 예약]
규칙: 같은 시간대에 같은 회의실 예약 불가
트랜잭션A: SELECT → 10시 예약 없음 → INSERT 10시 예약
트랜잭션B: SELECT → 10시 예약 없음 → INSERT 10시 예약
둘 다 COMMIT → 10시에 2건 예약 → 중복! X
[예시 2: 잔액 제약]
규칙: 계좌A + 계좌B 합계가 0 이상이어야 함
현재: 계좌A = 100, 계좌B = 100 (합계 200)
트랜잭션1: SELECT 합계 → 200 → UPDATE 계좌A = 계좌A - 200
트랜잭션2: SELECT 합계 → 200 → UPDATE 계좌B = 계좌B - 200
둘 다 COMMIT → 합계 = -200 → 규칙 위반! X
Write Skew 방지 방법:
1. SERIALIZABLE 격리 수준 사용
→ 트랜잭션을 순차 실행하므로 Write Skew 원천 차단
2. SELECT ... FOR UPDATE로 관련 행 잠금
SELECT COUNT(*) FROM doctors WHERE 당직 = true FOR UPDATE;
→ 조회 대상 행에 배타 락 → 다른 트랜잭션 대기
3. 제약 조건을 DB 레벨에서 강제 (가능한 경우)
→ CHECK 제약, TRIGGER 등으로 규칙 위반 시 롤백
4. 낙관적 락 + 재시도
→ 버전 비교 후 충돌 감지 시 재시도
// Spring에서 Write Skew 방지 — FOR UPDATE 사용
@Transactional
public void cancelDuty(Long doctorId) {
// 당직 의사 목록을 FOR UPDATE로 잠금
int onDutyCount = doctorRepository.countOnDutyForUpdate();
if (onDutyCount <= 1) {
throw new IllegalStateException("최소 1명의 당직 의사가 필요합니다");
}
Doctor doctor = doctorRepository.findById(doctorId).orElseThrow();
doctor.cancelDuty();
}
격리 수준별 문제 발생
| 레벨 | Dirty Read | Non-Repeatable Read | Phantom Read | Write Skew |
|---|---|---|---|---|
| READ UNCOMMITTED | O | O | O | O |
| READ COMMITTED | X | O | O | O |
| REPEATABLE READ | X | X | O | O |
| SERIALIZABLE | X | X | X | X |
아래로 갈수록 안전하지만 느려집니다.
참고: Write Skew는 SERIALIZABLE에서만 완전히 방지됩니다. REPEATABLE READ 이하에서는
SELECT ... FOR UPDATE로 명시적으로 방지해야 합니다.
각 레벨 상세
READ UNCOMMITTED
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
- 다른 트랜잭션의 커밋 안 된 데이터도 읽음
- 가장 빠름, 가장 위험
- 거의 안 씀
READ COMMITTED
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
- 커밋된 데이터만 읽음
- Oracle, PostgreSQL 기본값
- 실무에서 가장 많이 사용
REPEATABLE READ
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
- 트랜잭션 시작 시점의 데이터를 계속 봄
- MySQL InnoDB 기본값
SERIALIZABLE
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
- 트랜잭션을 순차 실행하는 것처럼 동작
- 가장 안전, 가장 느림
- Write Skew까지 방지하는 유일한 격리 수준
- 금융 등 특수 상황에서 사용
격리 수준 vs 성능
격리 수준 높음 → 안전 ↑ 동시성 ↓ 성능 ↓
격리 수준 낮음 → 안전 ↓ 동시성 ↑ 성능 ↑
기본값
| DB | 기본 격리 수준 |
|---|---|
| MySQL | REPEATABLE READ |
| PostgreSQL | READ COMMITTED |
| Oracle | READ COMMITTED |
각 격리 레벨 동작 방식
READ UNCOMMITTED
읽기: 락 X, 스냅샷 X → 메모리에 있는 값 그대로 읽음
쓰기: 배타 락 + 최신 데이터
트랜잭션 1 트랜잭션 2
───────── ─────────
UPDATE 나이 = 30 (커밋 전)
SELECT 나이 → 30 (커밋 안 된 값!)
ROLLBACK
30을 믿고 로직 수행 → 문제!
READ COMMITTED
읽기: 쿼리 시점 스냅샷 (쿼리마다 새로운 스냅샷)
쓰기: 배타 락 + 최신 데이터
트랜잭션 1 트랜잭션 2
───────── ─────────
SELECT 나이 → 25 (현재 스냅샷)
UPDATE 나이 = 30
COMMIT
SELECT 나이 → 30 (새 스냅샷 → 값이 바뀜!)
REPEATABLE READ
읽기: 트랜잭션 시작 시점 스냅샷 (끝까지 유지)
쓰기: 배타 락 + 최신 데이터
트랜잭션 1 (10시 시작) 트랜잭션 2
───────── ─────────
SELECT 나이 → 25 (10시 스냅샷)
UPDATE 나이 = 30
COMMIT
SELECT 나이 → 25 (여전히 10시 스냅샷)
SERIALIZABLE
읽기: 트랜잭션 시작 시점 스냅샷 + 공유 락
쓰기: 배타 락 + 최신 데이터
트랜잭션 1 트랜잭션 2
───────── ─────────
SELECT * WHERE 나이 >= 25
(스냅샷 + 공유 락)
INSERT (나이=28)
→ 락 대기... (블로킹!)
COMMIT (락 해제)
(이제 INSERT 가능)
락 정리
| 락 종류 | 다른 읽기 | 다른 쓰기 |
|---|---|---|
| 공유 락 (읽기) | 가능 | 대기 |
| 배타 락 (쓰기) | 대기 | 대기 |
격리 수준별 동작 요약
| 격리 수준 | 읽기 방식 | 쓰기 방식 |
|---|---|---|
| READ UNCOMMITTED | 현재 메모리 값 | 배타 락 + 최신 데이터 |
| READ COMMITTED | 쿼리 시점 스냅샷 | 배타 락 + 최신 데이터 |
| REPEATABLE READ | 트랜잭션 시작 스냅샷 | 배타 락 + 최신 데이터 |
| SERIALIZABLE | 트랜잭션 시작 스냅샷 + 공유 락 | 배타 락 + 최신 데이터 |
격리 수준 = MVCC + LBCC의 조합 비율
READ UNCOMMITTED ──→ READ COMMITTED ──→ REPEATABLE READ ──→ SERIALIZABLE
MVCC 의존도: 없음 높음 높음 중간
LBCC 의존도: 없음 낮음 중간 높음
MVCC ████████████████████████████░░░░░░░░
LBCC ░░░░░░░░░░░░████████████████████████
← MVCC 비중 높음 LBCC 비중 높음 →
← 동시성 높음 정합성 높음 →
격리 수준별 MVCC/LBCC 사용 내역
READ UNCOMMITTED
MVCC: 사용 안 함
LBCC: 사용 안 함
일반 SELECT: 현재 데이터를 그대로 읽음 (버전 관리 없음)
쓰기: 배타 락 (최소한의 보호)
→ 거의 아무런 보호 장치 없음
→ 실무에서 사용하지 않음
READ COMMITTED
MVCC: ████████ (주력)
LBCC: ██ (보조)
일반 SELECT: MVCC (매 SELECT마다 새 스냅샷) → 락 없음
SELECT ... FOR UPDATE: LBCC (레코드 락)
UPDATE / DELETE: LBCC (레코드 락)
INSERT: LBCC (레코드 락)
→ 읽기는 MVCC가 전담 → 동시성 높음
→ 쓰기만 LBCC 사용 → 최소한의 락
REPEATABLE READ (InnoDB)
MVCC: ██████ (주력)
LBCC: ████ (강화)
일반 SELECT: MVCC (트랜잭션 시작 시 스냅샷 고정) → 락 없음
SELECT ... FOR UPDATE: LBCC (레코드 락 + 갭 락)
UPDATE / DELETE: LBCC (레코드 락 + 갭 락)
INSERT: LBCC (레코드 락)
→ 읽기는 여전히 MVCC → 동시성 유지
→ 쓰기에 갭 락 추가 → LBCC 범위 확대
→ MVCC 스냅샷 유지 기간도 늘어남
SERIALIZABLE
MVCC: ██ (보조)
LBCC: ████████ (주력)
일반 SELECT: LBCC (자동으로 공유 락 + 갭 락) ← 핵심 차이
SELECT ... FOR UPDATE: LBCC (배타 락 + 갭 락)
UPDATE / DELETE: LBCC (배타 락 + 갭 락)
INSERT: LBCC (배타 락)
→ 읽기도 LBCC 사용 → 읽기-쓰기 상호 차단
→ MVCC는 버전 관리 용도로만 보조적 사용
→ 사실상 순차 실행
→ Write Skew까지 완전 방지
한눈에 비교
| 격리 수준 | 일반 SELECT | 잠금 읽기 (FOR UPDATE) | 쓰기 (UPDATE/DELETE) |
|---|---|---|---|
| READ UNCOMMITTED | 보호 없음 | 레코드 락 | 레코드 락 |
| READ COMMITTED | MVCC (매번 새 스냅샷) | 레코드 락 | 레코드 락 |
| REPEATABLE READ | MVCC (스냅샷 고정) | 레코드 락 + 갭 락 | 레코드 락 + 갭 락 |
| SERIALIZABLE | 공유 락 + 갭 락 (LBCC) | 배타 락 + 갭 락 | 배타 락 + 갭 락 |
격리 수준이 올라갈수록:
MVCC 측면: 스냅샷 유지 기간이 길어짐
(매번 새로 → 고정 → 고정)
LBCC 측면: 락의 범위와 종류가 추가됨
(레코드 락 → + 갭 락 → + 일반 SELECT에도 공유 락)
비유로 이해하기
[도서관 비유]
READ UNCOMMITTED:
→ 아무나 자유롭게 열람/수정 가능
→ 누군가 수정 중인 책도 볼 수 있음
→ MVCC: X LBCC: X
READ COMMITTED:
→ 수정이 완료된 책만 열람 가능 (MVCC — 완성된 버전만 제공)
→ 수정 중인 책은 이전 버전을 보여줌
→ MVCC: O LBCC: 쓰기만
REPEATABLE READ:
→ 입장 시점의 책 상태를 계속 봄 (MVCC — 스냅샷 고정)
→ 수정 시 주변 선반까지 잠금 (LBCC — 갭 락)
→ MVCC: O LBCC: 쓰기 + 갭
SERIALIZABLE:
→ 열람할 때도 해당 선반 잠금 (LBCC — 읽기에도 락)
→ 한 사람이 보고 있으면 다른 사람은 수정 불가
→ MVCC: 보조 LBCC: 전면 적용
면접 포인트
- 격리 수준은 MVCC와 LBCC의 조합 비율로 결정되며, 낮은 격리 수준일수록 MVCC 비중이 높고(동시성 우선), 높은 격리 수준일수록 LBCC 비중이 높습니다(정합성 우선).
- 핵심적인 전환점은 SERIALIZABLE에서 일반 SELECT에도 공유 락이 자동 적용되는 것이며, 이로 인해 읽기-쓰기가 상호 차단되어 동시성이 크게 저하됩니다.
- Write Skew는 개별 트랜잭션은 정상이지만 결합 시 제약 조건이 위반되는 현상으로, SERIALIZABLE에서만 자동 방지되며, 그 이하 격리 수준에서는
SELECT ... FOR UPDATE로 명시적으로 방지해야 합니다. - 현대 DB의 설계 철학은 "MVCC를 최대한 활용하고, LBCC는 필요한 최소한으로"이며, 이것이 대부분의 DB가 READ COMMITTED를 기본으로 선택하는 이유입니다.