10. 트랜잭션 이론
10. 트랜잭션 이론¶
학습 목표¶
- 트랜잭션 개념과 ACID 속성을 깊이 이해
- 트랜잭션 상태 전이 추적
- 선행 그래프(Precedence Graph)를 사용한 충돌 직렬화 가능성(Conflict Serializability) 분석
- 복구 가능(Recoverable), 연쇄 없는(Cascadeless), 엄격한(Strict) 스케줄 구분
- SQL 격리 수준과 허용하는 이상 현상 이해
- 스냅샷 격리(Snapshot Isolation)와 그 트레이드오프에 대해 추론
1. 트랜잭션 개념¶
트랜잭션이란?¶
트랜잭션(Transaction)은 데이터베이스의 내용을 접근하고 수정할 수 있는 논리적 작업 단위다. 분할 불가능한 단위로 취급되어야 하는 일련의 연산(읽기와 쓰기)으로 구성된다.
실세계 비유: 계좌 A에서 계좌 B로 은행 이체:
트랜잭션 T: A에서 B로 $100 이체
read(A) // A = 1000
A = A - 100 // A = 900
write(A)
read(B) // B = 500
B = B + 100 // B = 600
write(B)
commit
write(A)와 write(B) 사이에 시스템이 충돌하면, 돈이 사라질 것이다 -- A에서 $100이 차감되지만 B로 입금되지 않음. 트랜잭션이 이를 방지한다.
트랜잭션이 중요한 이유¶
트랜잭션은 두 가지 중요한 보장을 제공한다:
- 실패 원자성(Failure Atomicity): 실행 중 실패가 발생하면, 모든 부분 효과가 취소됨
- 격리(Isolation): 동시 트랜잭션이 서로 간섭하지 않음
트랜잭션이 없으면, 데이터베이스는 실패와 동시 접근이 있는 상황에서 데이터 일관성(Data Consistency)을 보장할 수 없다.
2. ACID 속성¶
ACID 속성은 트랜잭션 처리 시스템이 제공해야 하는 네 가지 기본 보장이다.
2.1 원자성 (All or Nothing)¶
트랜잭션은 원자적(Atomic) 단위다: 모든 연산이 데이터베이스에 반영되거나, 아무것도 반영되지 않는다.
트랜잭션 T: $100 이체
┌─────────────────────┐
│ read(A) │
│ A = A - 100 │
│ write(A) │
│ ────── crash ────── │ ← 여기서 충돌하면, write(A)를 취소
│ read(B) │
│ B = B + 100 │
│ write(B) │
│ commit │
└─────────────────────┘
두 쓰기 모두 지속되거나, 둘 다 지속되지 않는다.
구현: 복구 서브시스템은 로그(Logs)를 사용하여 불완전한 트랜잭션을 취소하고 커밋된 것을 재실행한다 (레슨 12에서 다룸).
2.2 일관성¶
트랜잭션은 데이터베이스를 하나의 일관된 상태(Consistent State)에서 다른 일관된 상태로 변환해야 한다. 일관성은 다음으로 정의된다:
- 무결성 제약(Integrity Constraints): 기본 키, 외래 키, CHECK 제약, NOT NULL
- 응용 수준 불변식(Application-Level Invariants): 예: 은행의 총 금액은 일정해야 함
일관된 상태:
계좌 A = 1000, 계좌 B = 500
불변식: A + B = 1500
트랜잭션 중:
write(A) 후: A = 900, B = 500 → A + B = 1400 ← 불일치
write(B) 후: A = 900, B = 600 → A + B = 1500 ← 다시 일관됨
중간 불일치는 허용됨 (다른 트랜잭션에 보이지 않기 때문에,
격리(Isolation)에 의해 보장됨).
책임: 일관성은 공유 책임이다: - DBMS는 선언된 무결성 제약을 강제함 - 응용 프로그램 (프로그래머)은 트랜잭션 로직이 응용 수준 불변식을 보존하도록 보장해야 함
2.3 격리¶
각 트랜잭션은 시스템에서 유일한 트랜잭션인 것처럼 실행되어야 한다. 동시 트랜잭션은 서로의 중간 상태를 보지 않아야 한다.
격리 없이:
T1: read(A)=1000, A=900, write(A)
T2: read(A)=900 ← 부분 결과를 봄!
T1: read(B)=500, B=600, write(B)
T1: commit
T2: read(B)=600
T2: A + B = 900 + 600 = 1500 ← 우연히 정확
하지만 T2가 T1이 B를 쓰기 전에 B를 읽었다면:
T2: read(B)=500
T2: A + B = 900 + 500 = 1400 ← 잘못됨!
구현: 동시성 제어 서브시스템은 잠금, 타임스탬프, 또는 MVCC를 사용한다 (레슨 11에서 다룸).
2.4 지속성¶
트랜잭션이 커밋되면, 시스템이 즉시 충돌하더라도 그 효과는 지속되어야 한다.
T: write(A), write(B), commit
← 여기서 시스템 충돌
복구 후: A = 900, B = 600 ← 커밋된 변경사항이 생존
구현: 복구 서브시스템은 선행 기록 로깅(Write-Ahead Logging, WAL)과 체크포인트(Checkpoints)를 사용하여 커밋된 데이터가 충돌에서 생존하도록 보장한다 (레슨 12에서 다룸).
ACID 요약¶
| 속성 | 보장 | 구현처 |
|---|---|---|
| 원자성 | 전부 또는 전무 | 복구 시스템 (취소) |
| 일관성 | 유효한 상태에서 유효한 상태로 | DBMS 제약 + 앱 로직 |
| 격리 | 동시 트랜잭션 간 간섭 없음 | 동시성 제어 |
| 지속성 | 커밋된 데이터가 충돌에서 생존 | 복구 시스템 (재실행) + WAL |
3. 트랜잭션 상태¶
트랜잭션은 생애주기 동안 명확하게 정의된 상태 집합을 거친다:
┌──────────┐
begin │ │
─────────────→ │ 활성 │
│ (Active)│
└────┬─────┘
│
마지막 문장
실행됨
│
▼
┌────────────────┐
│ 부분 커밋 │
│ (Partially │
│ Committed) │
└───────┬────────┘
┌────┴────┐
│ │
출력이 디스크로 실패
기록됨 감지
│ │
▼ ▼
┌───────────┐ ┌────────┐
│ 커밋됨 │ │ 실패 │
│(Committed)│ │(Failed)│
└───────────┘ └───┬────┘
│
롤백
완료
│
▼
┌─────────┐
│ 중단됨 │
│(Aborted)│
└─────────┘
상태 설명¶
| 상태 | 설명 |
|---|---|
| 활성(Active) | 초기 상태. 트랜잭션이 읽기/쓰기 연산을 실행하는 동안 이 상태에 머뭄. |
| 부분 커밋(Partially Committed) | 마지막 문장이 실행된 후. 트랜잭션의 효과는 여전히 휘발성 메모리(버퍼)에 있을 수 있음. |
| 커밋됨(Committed) | 모든 변경사항이 안정적 저장소에 성공적으로 기록된 후. 트랜잭션이 완료되고 그 효과는 영구적. |
| 실패(Failed) | 정상 실행을 계속할 수 없음을 발견한 후 (예: 제약 위반, 데드락, 시스템 오류). |
| 중단됨(Aborted) | 트랜잭션이 롤백되고 데이터베이스가 트랜잭션 시작 전 상태로 복원된 후. |
중단 후: 재시작 또는 종료?¶
트랜잭션이 중단되면, 시스템에는 두 가지 옵션이 있다:
- 재시작: 트랜잭션을 재실행 (데드락과 같은 일시적 실패에 적절)
- 종료: 트랜잭션을 완전히 종료 (논리 오류, 제약 위반에 적절)
-- 데드락 후 재시작될 수 있는 트랜잭션:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 데드락 감지되면 → 중단 → 자동 재시작
-- 종료되어야 하는 트랜잭션:
BEGIN;
INSERT INTO users(email) VALUES ('duplicate@email.com');
-- 고유 제약 위반이면 → 중단 → 재시작하지 않음
4. 스케줄¶
4.1 스케줄이란?¶
스케줄(Schedule) (또는 히스토리(History))은 동시 트랜잭션의 명령어가 실행되는 시간순 순서를 나타낸다. 스케줄은 각 트랜잭션의 내부 순서를 보존해야 한다.
표기법: 읽기와 쓰기 연산에 집중한다:
- r_i(X) = 트랜잭션 T_i가 데이터 항목 X를 읽음
- w_i(X) = 트랜잭션 T_i가 데이터 항목 X를 씀
- c_i = 트랜잭션 T_i가 커밋함
- a_i = 트랜잭션 T_i가 중단함
4.2 직렬 스케줄¶
직렬 스케줄(Serial Schedule)은 트랜잭션을 인터리빙 없이 하나씩 실행한다:
직렬 스케줄 S1 (T1 그 다음 T2):
┌─────────────────────────────────────┐
│ T1: r₁(A) w₁(A) r₁(B) w₁(B) c₁ │
│ │
│ T2: r₂(A) w₂(A) r₂(B) w₂(B) c₂ │
└─────────────────────────────────────┘
직렬 스케줄 S2 (T2 그 다음 T1):
┌─────────────────────────────────────┐
│ T2: r₂(A) w₂(A) r₂(B) w₂(B) c₂ │
│ │
│ T1: r₁(A) w₁(A) r₁(B) w₁(B) c₁ │
└─────────────────────────────────────┘
특성:
- 항상 정확 (각 트랜잭션이 일관된 데이터베이스를 봄)
- 동시성 없음 -- 낮은 성능
- n개 트랜잭션에 대해 n!개의 가능한 직렬 스케줄
4.3 직렬화 가능 스케줄¶
직렬화 가능 스케줄(Serializable Schedule)은 어떤 직렬 스케줄과 동일한 결과를 생성하는 동시 스케줄이다. 이는 정확성의 황금 표준이다.
직렬화 가능 스케줄 S3 (인터리빙되지만, S1과 동등):
┌──────────────────────────────────────────────┐
│ T1: r₁(A) w₁(A) │
│ T2: r₂(A) w₂(A) │
│ T1: r₁(B) w₁(B) c₁│
│ T2: r₂(B) w₂(B) c₂│
└──────────────────────────────────────────────┘
이것이 직렬화 가능한지는 알 수 없음 — 이를 결정하기 위해 공식 테스트 필요.
5. 충돌 직렬화 가능성¶
5.1 충돌하는 연산¶
두 연산이 충돌(Conflict)하는 경우: 1. 다른 트랜잭션에 속함 2. 동일한 데이터 항목에 접근 3. 적어도 하나는 쓰기
충돌 유형:
r₁(A) ... r₂(A) → 충돌 없음 (둘 다 읽기)
r₁(A) ... w₂(A) → 읽기-쓰기 충돌(Read-Write, RW)
w₁(A) ... r₂(A) → 쓰기-읽기 충돌(Write-Read, WR)
w₁(A) ... w₂(A) → 쓰기-쓰기 충돌(Write-Write, WW)
충돌하지 않는 연산은 결과를 변경하지 않고 스케줄에서 교환 가능하다.
5.2 충돌 동등¶
두 스케줄이 충돌 동등(Conflict Equivalent)한 경우, 인접한 충돌하지 않는 연산의 교환 시리즈에 의해 하나를 다른 것으로 변환할 수 있다.
5.3 충돌 직렬화 가능¶
스케줄이 어떤 직렬 스케줄과 충돌 동등하면 충돌 직렬화 가능(Conflict Serializable)하다.
예: 이 스케줄이 충돌 직렬화 가능한가?
스케줄 S: r₁(A) r₂(A) w₁(A) w₂(A) r₁(B) r₂(B) w₁(B) w₂(B)
단계별 분석:
1. r₁(A)와 r₂(A): 충돌 없음 → 교환 가능
2. r₂(A)와 w₁(A): A에 대한 RW 충돌 → T₂가 T₁이 쓰기 전에 읽음 (T₂ → T₁? 아니요, A에 대해 T₁이 T₂ 뒤에 와야 함)
3. w₁(A)와 w₂(A): A에 대한 WW 충돌 → T₁이 T₂보다 먼저 씀 → T₁ before T₂
4. r₁(B)와 r₂(B): 충돌 없음
5. r₂(B)와 w₁(B): B에 대한 RW 충돌 → T₂가 T₁이 쓰기 전에 읽음 → T₂ before T₁
6. w₁(B)와 w₂(B): B에 대한 WW 충돌 → T₁ before T₂
A에 대해: 충돌 (2)에서, r₂(A) before w₁(A)는 T₂ → T₁을 의미
충돌 (3)에서, w₁(A) before w₂(A)는 T₁ → T₂를 의미
B에 대해: 충돌 (5)에서, r₂(B) before w₁(B)는 T₂ → T₁을 의미
충돌 (6)에서, w₁(B) before w₂(B)는 T₁ → T₂를 의미
T₁ → T₂와 T₂ → T₁ 둘 다 있음. 이것은 사이클이므로, 스케줄은
충돌 직렬화 가능하지 않음.
5.4 선행 그래프 (직렬화 그래프)¶
선행 그래프(Precedence Graph)는 충돌 직렬화 가능성을 테스트하는 효율적인 알고리즘을 제공한다.
구성:
1. 각 트랜잭션 T_i에 대한 노드 생성
2. T_i의 연산이 먼저 오는 충돌하는 연산 쌍이 존재하면 방향 간선 T_i → T_j 추가
정리: 스케줄은 선행 그래프가 비순환적(사이클 없음)인 경우에만 충돌 직렬화 가능하다.
예 1: 직렬화 가능 스케줄
스케줄: r₁(A) w₁(A) r₂(A) w₂(A) r₁(B) w₁(B) r₂(B) w₂(B)
충돌:
- w₁(A) before r₂(A): T₁ → T₂ (A에 대한 WR)
- w₁(A) before w₂(A): T₁ → T₂ (A에 대한 WW)
- w₁(B) before r₂(B): T₁ → T₂ (B에 대한 WR)
- w₁(B) before w₂(B): T₁ → T₂ (B에 대한 WW)
선행 그래프:
T₁ ──→ T₂
사이클 없음 → 충돌 직렬화 가능 (직렬 순서와 동등: T₁, T₂)
예 2: 비직렬화 가능 스케줄
스케줄: r₁(A) r₂(B) w₂(A) w₁(B)
충돌:
- r₁(A) before w₂(A): T₁ → T₂ (A에 대한 RW)
- r₂(B) before w₁(B): T₂ → T₁ (B에 대한 RW)
선행 그래프:
T₁ ──→ T₂
↑ │
└───────┘
사이클 감지: T₁ → T₂ → T₁
→ 충돌 직렬화 가능하지 않음
예 3: 세 트랜잭션
스케줄: r₁(A) r₂(B) r₃(C) w₁(B) w₂(C) w₃(A)
충돌:
- r₂(B) before w₁(B): T₂ → T₁ (B에 대한 RW)
- r₃(C) before w₂(C): T₃ → T₂ (C에 대한 RW)
- r₁(A) before w₃(A): T₁ → T₃ (A에 대한 RW)
선행 그래프:
T₁ ──→ T₃
↑ │
│ ↓
└── T₂ ←┘
사이클: T₁ → T₃ → T₂ → T₁
→ 충돌 직렬화 가능하지 않음
5.5 직렬 순서를 위한 위상 정렬¶
선행 그래프가 비순환적이면, 위상 정렬(Topological Sort)이 유효한 직렬 순서를 제공한다:
선행 그래프:
T₁ ──→ T₃
T₂ ──→ T₃
T₂ ──→ T₁
위상 정렬: T₂, T₁, T₃
이것이 동등한 직렬 스케줄.
알고리즘:
TOPOLOGICAL-SORT(graph):
result = []
while graph has nodes:
find a node with no incoming edges
add it to result
remove it and its outgoing edges from graph
if all nodes removed:
return result // 유효한 직렬 순서
else:
return CYCLE // 직렬화 가능하지 않음
6. 뷰 직렬화 가능성¶
6.1 정의¶
두 스케줄 S와 S'이 뷰 동등(View Equivalent)한 경우:
- 초기 읽기:
S에서T_i가X의 초기 값을 읽으면,S'에서도T_i가X의 초기 값을 읽음 - 업데이트된 읽기:
S에서T_i가T_j가 쓴X의 값을 읽으면,S'에서도T_i가T_j가 쓴X의 값을 읽음 - 최종 쓰기:
S에서T_i가X의 최종 쓰기를 수행하면,S'에서도T_i가X의 최종 쓰기를 수행
스케줄이 어떤 직렬 스케줄과 뷰 동등하면 뷰 직렬화 가능(View Serializable)하다.
6.2 충돌 vs. 뷰 직렬화 가능성¶
뷰 직렬화 가능 ⊇ 충돌 직렬화 가능
┌─────────────────────────────────────────┐
│ 모든 스케줄 │
│ ┌───────────────────────────────────┐ │
│ │ 뷰 직렬화 가능 │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ 충돌 직렬화 가능 │ │ │
│ │ │ ┌───────────────────────┐ │ │ │
│ │ │ │ 직렬 스케줄 │ │ │ │
│ │ │ └───────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
주요 사실: - 모든 충돌 직렬화 가능 스케줄은 뷰 직렬화 가능 - 그 역은 참이 아님 (일부 뷰 직렬화 가능 스케줄은 충돌 직렬화 가능하지 않음) - 뷰 직렬화 가능성 테스트는 NP-완전 - 충돌 직렬화 가능성 테스트는 다항식 (선행 그래프에서 사이클 감지) - 실제로 DBMS는 계산 가능성 때문에 충돌 직렬화 가능성(또는 더 약한 보장)을 사용
6.3 블라인드 쓰기¶
뷰 직렬화 가능하지만 충돌 직렬화 가능하지 않은 스케줄은 블라인드 쓰기(Blind Writes) -- 동일한 항목의 읽기가 선행하지 않는 쓰기를 포함한다.
스케줄: w₁(A) w₂(A) w₂(B) w₁(B)
선행 그래프:
- w₁(A) before w₂(A): T₁ → T₂ (A에 대한 WW)
- w₂(B) before w₁(B): T₂ → T₁ (B에 대한 WW)
사이클! → 충돌 직렬화 가능하지 않음
하지만 이것은 뷰 직렬화 가능:
- 직렬 순서 T₁, T₂와 뷰 동등:
- 걱정할 읽기 없음 (초기 읽기나 업데이트된 읽기)
- A의 최종 쓰기: 두 스케줄에서 T₂ ✓
- B의 최종 쓰기: 두 스케줄에서 T₁ ✓
7. 복구 가능성¶
직렬화 가능성만으로는 정확성에 충분하지 않다. 트랜잭션 실패로부터 적절한 복구(Recovery)를 허용하는 스케줄도 필요하다.
7.1 복구 가능 스케줄¶
스케줄이 복구 가능(Recoverable)한 경우, 모든 트랜잭션 쌍 T_i와 T_j에 대해, T_j가 T_i가 쓴 값을 읽으면, T_i가 T_j보다 먼저 커밋한다.
복구 가능:
T₁: w₁(A) ................... c₁
T₂: r₂(A) ................. c₂
(T₁이 T₂가 커밋하기 전에 커밋 ✓)
복구 불가능:
T₁: w₁(A) ........................ a₁ (중단!)
T₂: r₂(A) ..... c₂
(T₂가 T₁의 데이터에 기반하여 커밋했지만 T₁이 나중에 중단!)
문제: T₂가 잘못된 데이터를 사용했지만 이미 커밋됨 — T₂를 취소할 수 없음
중요한 이유: 스케줄이 복구 가능하지 않으면, 연쇄 중단이 이미 커밋된 트랜잭션을 취소해야 할 수 있어, 지속성을 위반한다.
7.2 연쇄 없는 스케줄 (연쇄 롤백 피하기)¶
연쇄 롤백(Cascading Rollback)은 한 트랜잭션의 중단이 커밋되지 않은 데이터를 읽은 다른 트랜잭션의 중단을 강제할 때 발생한다.
연쇄 롤백:
T₁: w₁(A) ................... a₁ (중단!)
T₂: r₂(A) w₂(B) ........ ← 중단해야 함 (T₁의 데이터 읽음)
T₃: r₃(B) ... ← 중단해야 함 (T₂의 데이터 읽음)
T₁의 중단이 T₂로, 그 다음 T₃로 연쇄됨.
스케줄이 연쇄 없는(Cascadeless) (또는 연쇄 롤백 피하기(Avoids Cascading Rollbacks, ACR))인 경우, 모든 트랜잭션이 커밋된 트랜잭션이 쓴 값만 읽는다.
연쇄 없는:
T₁: w₁(A) ............ c₁
T₂: r₂(A) w₂(B) ← T₁이 커밋한 후에만 읽음
관계: 모든 연쇄 없는 스케줄은 복구 가능하지만, 그 역은 아니다.
7.3 엄격한 스케줄¶
스케줄이 엄격(Strict)한 경우, 어떤 트랜잭션도 커밋되지 않은 트랜잭션이 쓴 값을 읽거나 덮어쓰지 않는다.
엄격:
T₁: w₁(A) ............ c₁
T₂: r₂(A) OR w₂(A) ← T₁이 커밋한 후에만
연쇄 없는이지만 엄격하지 않음:
T₁: w₁(A) ............ c₁
T₂: w₂(A) ← T₁의 커밋되지 않은 데이터를 덮어씀
(커밋되지 않은 데이터의 읽기 없으므로 연쇄 없음)
(하지만 T₂가 T₁이 커밋하기 전에 덮어쓰므로 엄격하지 않음)
엄격성이 중요한 이유: 엄격한 스케줄은 쓰기의 "이전 이미지"를 다른 트랜잭션의 중간 쓰기에 대해 걱정하지 않고 취소에 사용할 수 있어 복구를 단순화한다.
7.4 스케줄 속성 계층¶
엄격 ⊂ 연쇄 없는 ⊂ 복구 가능 ⊂ 모든 스케줄
┌──────────────────────────────────────────┐
│ 모든 스케줄 │
│ ┌────────────────────────────────────┐ │
│ │ 복구 가능 │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 연쇄 없는 (ACR) │ │ │
│ │ │ ┌────────────────────────┐ │ │ │
│ │ │ │ 엄격 │ │ │ │
│ │ │ │ ┌──────────────────┐ │ │ │ │
│ │ │ │ │ 직렬 │ │ │ │ │
│ │ │ │ └──────────────────┘ │ │ │ │
│ │ │ └────────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
| 속성 | 조건 | 복구 이점 |
|---|---|---|
| 복구 가능 | T_j가 T_i로부터 읽으면 → T_i가 T_j보다 먼저 커밋 | 항상 커밋되지 않은 트랜잭션 취소 가능 |
| 연쇄 없는 | 커밋된 데이터만 읽음 | 연쇄 중단 없음 |
| 엄격 | 커밋되지 않은 데이터의 읽기/덮어쓰기 없음 | 단순한 취소 (이전 이미지 복원) |
8. 격리 수준¶
8.1 동기¶
완전한 직렬화 가능성은 최대 정확성을 제공하지만 상당한 성능 비용이 든다 (더 많은 잠금, 더 적은 동시성). 많은 응용 프로그램은 더 나은 처리량을 위해 더 약한 격리를 허용할 수 있다.
SQL 표준은 네 가지 격리 수준을 정의하며, 각각 특정 이상 현상을 허용한다:
8.2 이상 현상¶
더티 읽기(Dirty Read): 커밋되지 않은 트랜잭션이 쓴 데이터를 읽음.
T₁: w₁(A=100) ............ a₁ (중단, A가 50으로 되돌림)
T₂: r₂(A)=100 ← T₂가 "더티" 값 100을 읽음
반복 불가능 읽기(Non-Repeatable Read, Fuzzy Read): 동일한 항목을 두 번 읽으면 다른 값이 나옴.
T₁: r₁(A)=50 .............. r₁(A)=100 ← 다름!
T₂: w₂(A=100) c₂
팬텀 읽기(Phantom Read): 쿼리를 두 번 실행하면 다른 행 집합이 반환됨.
T₁: SELECT * FROM emp WHERE dept='Eng' → {Alice, Bob}
T₂: INSERT INTO emp VALUES('Carol','Eng') c₂
T₁: SELECT * FROM emp WHERE dept='Eng' → {Alice, Bob, Carol} ← 팬텀!
8.3 SQL 격리 수준¶
| 격리 수준 | 더티 읽기 | 반복 불가능 읽기 | 팬텀 읽기 |
|---|---|---|---|
| Read Uncommitted | 가능 | 가능 | 가능 |
| Read Committed | 불가능 | 가능 | 가능 |
| Repeatable Read | 불가능 | 불가능 | 가능 |
| Serializable | 불가능 | 불가능 | 불가능 |
-- SQL에서 격리 수준 설정:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 또는
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
8.4 Read Uncommitted¶
가장 약한 수준. 트랜잭션은 다른 트랜잭션의 커밋되지 않은 ("더티") 데이터를 읽을 수 있다.
T₁: BEGIN (READ UNCOMMITTED)
SELECT balance FROM accounts WHERE id = 1; → 1000
-- 그 사이 T₂가 업데이트하지만 커밋하지 않음:
-- T₂: UPDATE accounts SET balance = 500 WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1; → 500 ← 더티 읽기!
-- T₂가 롤백하면, 500은 절대 실제 값이 아니었음
사용 사례: 근사 집계, 정확한 값이 중요하지 않은 모니터링 대시보드.
구현: 읽기 잠금을 획득하지 않음. 쓰기는 여전히 쓰기 잠금을 획득.
8.5 Read Committed¶
각 읽기는 읽기 시점에 커밋된 데이터만 본다. 더티 읽기는 없지만, 다른 트랜잭션이 읽기 사이에 커밋하면 동일한 쿼리가 다른 결과를 반환할 수 있다.
T₁: BEGIN (READ COMMITTED)
SELECT balance FROM accounts WHERE id = 1; → 1000
-- T₂: UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;
SELECT balance FROM accounts WHERE id = 1; → 500 ← 반복 불가능 읽기
-- 두 읽기 모두 커밋된 데이터를 보았지만, 다른 값
기본값: PostgreSQL, Oracle, SQL Server
구현: 읽기 잠금이 획득되고 즉시 해제됨 (커밋까지 유지되지 않음). 쓰기 잠금은 커밋까지 유지됨.
8.6 Repeatable Read¶
트랜잭션이 데이터 항목을 읽으면, 트랜잭션 전체에서 해당 항목에 대해 항상 동일한 값을 본다 (더티 읽기, 반복 불가능 읽기 없음). 하지만, 팬텀 행은 여전히 나타날 수 있다.
T₁: BEGIN (REPEATABLE READ)
SELECT * FROM emp WHERE dept = 'Eng'; → {Alice, Bob}
-- T₂: INSERT INTO emp VALUES('Carol', 'Eng'); COMMIT;
SELECT * FROM emp WHERE dept = 'Eng'; → {Alice, Bob, Carol} ← 팬텀!
-- Alice와 Bob의 행 재읽기는 동일한 데이터 제공 (퍼지 읽기 없음)
-- 하지만 새 행(Carol)이 나타남 — 이것이 팬텀
구현: 읽기 잠금이 커밋까지 유지됨. 하지만 술어와 일치하는 새 행은 잠기지 않음 (술어 잠금 없음).
8.7 Serializable¶
가장 강한 격리 수준. 트랜잭션이 직렬인 것처럼 실행됨. 어떤 종류의 이상 현상도 없음.
T₁: BEGIN (SERIALIZABLE)
SELECT * FROM emp WHERE dept = 'Eng'; → {Alice, Bob}
-- T₂: INSERT INTO emp VALUES('Carol', 'Eng');
-- T₂가 BLOCKED됨 (또는 SSI에서 T₁이 커밋 시 실패) T₁이 완료될 때까지
SELECT * FROM emp WHERE dept = 'Eng'; → {Alice, Bob} ← 동일한 결과
COMMIT;
구현 옵션: - 술어 잠금을 포함한 엄격한 2PL (또는 인덱스 범위 잠금): 전통적 접근 - 직렬화 가능 스냅샷 격리(Serializable Snapshot Isolation, SSI): PostgreSQL의 접근 (낙관적, MVCC 기반)
9. 팬텀 문제¶
9.1 팬텀이 특별한 이유¶
팬텀 문제는 더티 읽기나 반복 불가능 읽기와 근본적으로 다르다:
- 더티/반복 불가능 읽기는 기존 행이 수정되는 것을 포함
- 팬텀은 새 행이 삽입되는 것 (또는 기존 행이 술어와 일치하도록 수정됨)을 포함
표준 행 수준 잠금은 팬텀을 방지할 수 없다 왜냐하면 아직 존재하지 않는 행을 잠글 수 없기 때문.
9.2 팬텀 문제 해결책¶
술어 잠금(Predicate Locking): 개별 행이 아닌 쿼리 술어에 기반하여 잠금.
T₁: SELECT * FROM emp WHERE dept = 'Eng'
→ 잠금: "다른 트랜잭션이 dept = 'Eng'인
행을 삽입/업데이트/삭제할 수 없음"
술어 잠금은 일반적으로 비싸다 (술어가 임의로 복잡할 수 있음).
인덱스 범위 잠금(Index-Range Locking, Next-Key Locking): 실용적 근사. 갭을 포함한 인덱스 엔트리 범위를 잠금.
dept에 대한 B+Tree 인덱스:
... ──→ [Acctg] ──→ [Eng] ──→ [HR] ──→ [Sales] ──→ ...
'Eng'에서 'HR'까지 범위 잠금 (배타적).
이것이 dept = 'Eng'인 모든 INSERT를 방지.
MySQL InnoDB는 이를 "넥스트 키 잠금"이라고 부름.
직렬화 가능 스냅샷 격리(SSI): PostgreSQL의 접근. 커밋 시점에 의존성 추적을 사용하여 직렬화 이상 현상을 감지 (술어 잠금 불필요).
10. 스냅샷 격리¶
10.1 개념¶
스냅샷 격리(Snapshot Isolation, SI)는 각 트랜잭션에 트랜잭션 시작 시점의 데이터베이스의 일관된 스냅샷을 제공하는 다중 버전 동시성 제어 방식이다.
시간 100의 데이터베이스 상태: A=50, B=100
T₁이 시간 100에 시작: 스냅샷 {A=50, B=100} 봄
T₂가 시간 100에 시작: 스냅샷 {A=50, B=100} 봄
T₁: write(A=75)
T₂: read(A) → 50 (스냅샷에서 읽음, T₁의 쓰기 아님)
T₁: commit at time 105
T₂: read(A) → 50 (여전히 50! T₂의 스냅샷은 시간 100부터)
T₂: commit at time 110
10.2 First-Committer-Wins 규칙¶
SI는 first-committer-wins(FCW) 규칙을 사용하여 손실된 업데이트를 방지한다:
T₁이 시간 100에 시작, T₂가 시간 100에 시작
T₁: write(A=75) ............ commit → 성공 (A를 먼저 커밋)
T₂: write(A=80) ......................... commit → 중단!
(T₂가 A를 쓰려 했지만, A가 이미 T₁에 의해 수정되고
T₂의 스냅샷 이후 커밋됨)
10.3 스냅샷 격리 vs. 직렬화 가능성¶
SI는 더티 읽기, 반복 불가능 읽기, 팬텀 읽기를 방지한다. 하지만 SI는 직렬화 가능하지 않음! 쓰기 스큐(Write Skew) 이상 현상을 허용한다.
쓰기 스큐 예:
제약: 최소 한 명의 의사가 당직이어야 함.
초기: doctor_A.on_call = true, doctor_B.on_call = true
T₁ (스냅샷): A.on_call=T, B.on_call=T 봄
T₂ (스냅샷): A.on_call=T, B.on_call=T 봄
T₁: "다른 의사(B)가 당직이므로, 나는 당직에서 벗어날 수 있음"
UPDATE doctors SET on_call = false WHERE name = 'A';
T₂: "다른 의사(A)가 당직이므로, 나는 당직에서 벗어날 수 있음"
UPDATE doctors SET on_call = false WHERE name = 'B';
T₁: commit ← 성공 (T₂가 아직 커밋하지 않음, A에 대한 WW 충돌 없음)
T₂: commit ← 성공 (T₁이 A를 수정, T₂가 B를 수정, WW 충돌 없음!)
결과: A.on_call = false, B.on_call = false
→ 아무도 당직이 아님! 제약 위반!
직렬화 가능 실행에서는, 한 트랜잭션이 다른 것의 업데이트를 보고 제약을 유지할 것이다. SI는 T₁과 T₂가 스냅샷에서 읽고 다른 항목에 쓰기 때문에 이를 허용한다.
10.4 직렬화 가능 스냅샷 격리 (SSI)¶
SSI는 SI를 확장하여 쓰기 스큐와 같은 이상 현상을 감지하고 방지한다. 동시 트랜잭션 간 읽기-쓰기 의존성을 추적하고 위험한 구조를 형성하는 트랜잭션을 중단한다.
SSI가 "위험한 구조" 감지:
T₁ →(rw)→ T₂ →(rw)→ T₃
(여기서 T₁이 T₂가 덮어쓰는 것을 읽고,
T₂가 T₃가 덮어쓰는 것을 읽음)
이 패턴이 감지되면, 트랜잭션 중 하나가 중단됨.
PostgreSQL: 버전 9.1부터 SERIALIZABLE 격리 수준에 SSI를 사용한다. 낙관적 접근이다 -- 트랜잭션이 차단 없이 진행하고, 충돌은 커밋 시점에 감지된다.
10.5 실제 SI¶
| 데이터베이스 | 기본 수준 | SI 지원 | 진정한 직렬화 가능 |
|---|---|---|---|
| PostgreSQL | Read Committed | 예 (REPEATABLE READ) | 예 (SSI) |
| Oracle | Read Committed | 예 (SERIALIZABLE*) | 아니오 (SI만, 진정한 직렬 아님) |
| MySQL InnoDB | Repeatable Read | 부분 (갭 잠금) | 예 (잠금 기반) |
| SQL Server | Read Committed | 예 (SNAPSHOT) | 예 (잠금 기반) |
| CockroachDB | Serializable | 예 | 예 (SSI) |
*참고: Oracle의 "SERIALIZABLE" 수준은 실제로 스냅샷 격리를 제공하며, 진정한 직렬화 가능성이 아니다.
11. 직렬화 가능성 테스트: 완전 알고리즘¶
단계별 알고리즘¶
트랜잭션 T₁, T₂, ..., Tₙ을 가진 스케줄 S가 주어졌을 때:
SERIALIZABILITY-TEST(S):
1. 노드 T₁, ..., Tₙ으로 선행 그래프 G 초기화
2. 두 개 이상의 트랜잭션이 접근하는 각 데이터 항목 X에 대해:
a. X에 대한 각 연산 쌍을 시간 순서로:
- r_i(X)가 w_j(X)를 선행하고 i ≠ j이면:
간선 T_i → T_j 추가 (RW 충돌)
- w_i(X)가 r_j(X)를 선행하고 i ≠ j이면:
간선 T_i → T_j 추가 (WR 충돌)
- w_i(X)가 w_j(X)를 선행하고 i ≠ j이면:
간선 T_i → T_j 추가 (WW 충돌)
3. G에 사이클이 있는지 확인:
- DFS 또는 위상 정렬 사용
- 사이클 없으면: S는 충돌 직렬화 가능
위상 정렬이 동등한 직렬 순서를 제공
- 사이클 존재하면: S는 충돌 직렬화 가능하지 않음
상세 예¶
스케줄 S:
r₁(A) r₂(A) w₁(A) r₃(A) w₃(A) w₂(B) r₃(B) w₁(B) c₁ c₂ c₃
단계 1: 노드: T₁, T₂, T₃
단계 2: 데이터 항목별 충돌 분석:
데이터 항목 A:
r₁(A) < w₁(A): 동일 트랜잭션, 건너뜀
r₂(A) < w₁(A): RW 충돌 → T₂ → T₁
r₂(A) < w₃(A): RW 충돌 → T₂ → T₃
w₁(A) < r₃(A): WR 충돌 → T₁ → T₃
w₁(A) < w₃(A): WW 충돌 → T₁ → T₃
데이터 항목 B:
w₂(B) < r₃(B): WR 충돌 → T₂ → T₃
w₂(B) < w₁(B): WW 충돌 → T₂ → T₁
r₃(B) < w₁(B): RW 충돌 → T₃ → T₁
단계 3: 선행 그래프:
T₂ → T₁ (A에서: RW 및 B에서: WW)
T₂ → T₃ (A에서: RW 및 B에서: WR)
T₁ → T₃ (A에서: WR, WW)
T₃ → T₁ (B에서: RW)
T₂
↙ ↘
T₁ ⇄ T₃
사이클: T₁ → T₃ → T₁
결과: 충돌 직렬화 가능하지 않음
12. 연습문제¶
개념적 질문¶
연습문제 1: 각 ACID 속성에 대해, 그 속성이 위반되면 어떤 일이 일어날 수 있는지 구체적인 예를 들어라. 계좌와 이체가 있는 은행 시나리오를 사용하라.
연습문제 2: 트랜잭션 상태 다이어그램을 그리고 다음 시나리오에 대한 상태를 추적하라: - 트랜잭션 T가 시작하고 데이터 항목 A를 읽음 - T가 새 값을 계산하고 A를 씀 - 시스템이 제약 위반을 감지함 - T가 롤백됨
연습문제 3: 모든 연쇄 없는 스케줄이 복구 가능한 이유를 설명하되, 모든 복구 가능 스케줄이 연쇄 없는 것은 아닌 이유를 설명하라. 각 경우에 대한 예 스케줄을 제공하라.
직렬화 가능성 분석¶
연습문제 4: 다음 각 스케줄이 충돌 직렬화 가능한지 결정하라. 그렇다면, 동등한 직렬 순서를 제시하라. 아니라면, 선행 그래프의 사이클을 식별하라.
(a) r₁(A) r₂(B) w₁(B) w₂(A) c₁ c₂
(b) r₁(A) w₂(A) w₁(A) r₂(A) c₁ c₂
(c) r₃(B) r₁(A) w₃(A) r₂(B) w₂(A) w₁(B) c₁ c₂ c₃
(d) r₁(A) r₂(B) r₃(C) w₁(B) w₂(C) w₃(A) c₁ c₂ c₃
연습문제 5: 다음 스케줄이 주어졌을 때:
r₁(X) r₂(X) w₂(X) r₁(Y) w₁(Y) w₂(Y) c₁ c₂
(a) 선행 그래프를 그려라. (b) 충돌 직렬화 가능한가? (c) 복구 가능한가? (d) 연쇄 없는가? (e) 엄격한가?
연습문제 6: 다음과 같은 스케줄을 구성하라: (a) 뷰 직렬화 가능하지만 충돌 직렬화 가능하지 않음 (b) 복구 가능하지만 연쇄 없지 않음 (c) 연쇄 없지만 엄격하지 않음
격리 수준¶
연습문제 7: 각 시나리오에 대해, 이상 현상을 방지하는 데 필요한 최소 격리 수준을 식별하라:
(a) 커밋되지 않은 수량을 읽는 것이 과다 판매로 이어질 수 있는 재고 시스템.
(b) 계좌 잔액을 합산하는 보고서, 합계가 일관되어야 함 (부분 적용 이체 없음).
(c) 부서당 직원을 세는 쿼리, 카운트가 동일한 트랜잭션 내에서 변경되지 않아야 함.
연습문제 8: 스냅샷 격리 하에서 쓰기 스큐 이상 현상을 보여주는 구체적인 SQL 시나리오를 작성하라. 진정한 직렬화 가능 격리 하에서 이것이 발생할 수 없는 이유를 설명하라.
연습문제 9: 세 트랜잭션이 있는 스케줄을 고려하라:
T₁: A를 읽고 씀
T₂: A를 읽고 B를 씀
T₃: B를 읽고 C를 씀
(a) Read Committed에서 실행하면, 어떤 이상 현상이 발생할 수 있는가? (b) Repeatable Read에서는? (c) Serializable에서는?
고급 질문¶
연습문제 10: n개 트랜잭션에 대한 가능한 직렬 스케줄의 수가 n!임을 증명하라. 그런 다음 직렬화 가능성을 검증하기 위해 모든 n! 직렬 스케줄을 테스트하는 것이 비실용적인 이유를 설명하고, 선행 그래프가 어떻게 다항식 시간 대안을 제공하는지 설명하라.
연습문제 11: Oracle의 SERIALIZABLE 격리 수준은 실제로 스냅샷 격리를 제공하며, 진정한 직렬화 가능성이 아니다. 이 차이를 드러낼 테스트 케이스(특정 테이블 스키마와 두 개의 동시 트랜잭션)를 설계하라. Oracle이 제공할 결과 vs. 진정으로 직렬화 가능한 시스템이 제공할 결과는?
연습문제 12: 시스템이 Repeatable Read 격리를 사용한다. 트랜잭션 T₁이 동일한 트랜잭션 내에서 SELECT COUNT(*) FROM orders WHERE status = 'pending'을 두 번 실행한다. 두 select 사이에, 트랜잭션 T₂가 status = 'pending'인 새 주문을 삽입하고 커밋한다.
(a) T₁이 두 COUNT 쿼리에 대해 어떤 값을 보는가? (b) 이것이 팬텀 읽기인가? (c) Serializable 격리가 이를 어떻게 다르게 처리하는가? (d) 갭 잠금이 있는 MySQL InnoDB의 "Repeatable Read"가 SQL 표준 정의와 비교하여 이를 어떻게 처리하는가?