2편에서 Buffer Pool의 구조와 LRU 동작, 그리고 전체 Write 프로세스 흐름을 봤다. 이번 편에서는 그 흐름 안에서 핵심 역할을 하는 세 가지를 깊게 파고든다. 데이터를 잃지 않게 해주는 WAL과 Redo Log, 롤백과 동시성을 가능하게 하는 Undo Log, 그리고 락 없이 읽기/쓰기가 동시에 가능한 이유인 MVCC다.
UPDATE users SET balance = 1500 WHERE id = 1 을 실행하면 디스크의 여러 위치에 써야 한다.
1. 데이터 페이지 (users.ibd) → Sector 12,345
2. PRIMARY KEY 인덱스 페이지 → Sector 98,765
3. Secondary 인덱스 (있다면) → Sector 54,321
디스크 헤드가 세 군데를 이동해야 한다. HDD 기준 seek time이 약 10ms이니 3번이면 30ms. 느릴 뿐만 아니라 크래시가 나면 일부만 쓰여진 채로 멈출 수 있다(Partial Write).
먼저 Redo Log에만 쓰고, 실제 데이터는 나중에 비동기로 반영한다.
UPDATE 실행
↓
Redo Log(ib_logfile)의 끝에 Sequential Write
→ 디스크 헤드 이동 없음
→ 0.1ms (기존보다 100배 빠름!)
↓
나중에 (비동기)
↓
실제 데이터 페이지에 쓰기
→ 여러 변경사항을 모아서 한 번에
→ I/O 효율 극대화
커밋 시 Redo Log만 디스크에 쓰면 된다. 실제 데이터는 천천히, 효율적으로. 크래시가 나도 Redo Log를 재생하면 복구할 수 있다.
LSN은 Redo Log의 논리적 주소다. Redo Log를 거대한 바이트 배열로 생각하면 이해하기 쉽다.
LSN: 0 100 200 300 400 ...
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
[Rec1][Rec2][Rec3][Rec4][Rec5]...
- LSN은 계속 증가 (절대 감소 안 함)
- 각 변경사항마다 고유 LSN 할당
- 64bit 정수
예를 들면:
Current LSN: 지금 막 생성된 Redo Log 레코드 위치. 메모리(Log Buffer)에만 있음.
Flushed LSN: 디스크에 실제로 쓰여진 Redo Log 위치. ib_logfile에 fsync 완료.
Checkpoint LSN: 디스크에 반영된 Dirty Page의 최소 LSN. 이 지점 이전은 이미 데이터 파일에 반영됐다는 의미.
항상 Current LSN ≥ Flushed LSN ≥ Checkpoint LSN 이 성립한다.
t=0ms 트랜잭션 시작
Current: 10000 / Flushed: 9800 / Checkpoint: 9500
t=1ms UPDATE 실행 (Log Buffer에 기록)
Current: 10050 ← 50바이트 증가
Flushed: 9800 / Checkpoint: 9500
t=2ms COMMIT (Redo Log 플러시)
Current: 10050 / Flushed: 10050 / Checkpoint: 9500
t=1000ms 백그라운드 Flush (Dirty Page → 디스크)
Current: 10500 (다른 트랜잭션 진행 중)
Flushed: 10500 / Checkpoint: 10050 ← 업데이트
→ LSN 10050 이전 Redo Log는 삭제 가능
innodb_log_file_size = 512MB
innodb_log_files_in_group = 2
파일:
ib_logfile0 (512MB)
ib_logfile1 (512MB)
총 1GB
순환 방식:
ib_logfile0 가득 참 → ib_logfile1으로 이동
ib_logfile1 가득 참 → ib_logfile0 처음으로
(단, Checkpoint된 부분만 덮어쓰기 가능!)
Checkpoint Age = Current LSN - Checkpoint LSN
┌─────────────────────────────────────────┐
│ Redo Log Space (1GB) │
│ │
│ Checkpoint LSN ──┐ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ 안전 영역 (덮어쓰기 가능) │ │
│ │ 이미 디스크에 반영됨 │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ 위험 영역 (Checkpoint Age) │ │
│ │ 아직 디스크 미반영 │ │
│ │ 너무 크면 강제 Checkpoint! │ │
│ └─────────────────────────────────┘ │
│ Current LSN ────┘ │
└─────────────────────────────────────────┘
Checkpoint Age가 임계값을 넘으면 강제로 Dirty Page를 디스크에 플러시한다.
fsync() 로 물리적으로 디스크에 기록t=0 START TRANSACTION
t=1 UPDATE balance = 1500 → Log Buffer + Buffer Pool(Dirty)
t=2 COMMIT → Redo Log fsync → "성공" 응답
정전! (Dirty Page 미반영)
복구 프로세스:
1. Checkpoint LSN부터 Redo Log 스캔
2. COMMIT 기록 발견
3. Redo Log 재실행: LSN 10050 → Page 157, balance = 1500으로 수정
4. 데이터 완전 복구 → Durability 만족
t=0 START TRANSACTION
t=1 UPDATE balance = 1500 → Log Buffer + Buffer Pool(Dirty)
t=2 정전! (COMMIT 전)
복구 프로세스:
1. Redo Log 스캔 → UPDATE 기록은 있지만 COMMIT 없음
2. Undo Log 사용 → balance = 1000(원래 값)으로 복구
3. 트랜잭션 취소 → Atomicity 만족
Phase 1 — Redo Log 분석: Checkpoint LSN부터 로그를 읽어 각 트랜잭션의 상태 파악 (COMMIT 있음/없음).
Phase 2 — Redo (Roll Forward): COMMITTED 트랜잭션을 모두 재실행. 멱등성이 보장되므로 이미 반영된 페이지에 재실행해도 안전하다.
Phase 3 — Undo (Roll Backward): IN PROGRESS 트랜잭션을 모두 롤백. Undo Log를 읽어 원래 값으로 복구.
Phase 4 — 정리: 모든 Dirty Page 플러시, Checkpoint 실행, 정상 운영 시작.
💡 팁: Redo Log 재실행 시 같은 작업을 여러 번 실행해도 결과가 동일한 멱등성(Idempotency)이 핵심이다. 복구 과정에서 "이미 반영된 것 같은데?"라는 걱정 없이 안전하게 재실행할 수 있는 이유다.
역할 1 — 트랜잭션 롤백
START TRANSACTION;
UPDATE users SET balance = 1500 WHERE id = 1;
UPDATE users SET balance = 2000 WHERE id = 2;
ROLLBACK;
-- Undo Log 사용:
-- id=1: balance = 1000 (원래 값)으로 복구
-- id=2: balance = 1500 (원래 값)으로 복구
역할 2 — MVCC
Lock 없이 동시 읽기/쓰기를 가능하게 한다. 이전 버전 데이터를 Undo Log에서 읽는다.
-- Transaction A (읽기)
SELECT balance FROM users WHERE id = 1; -- 결과: 1000
-- Transaction B (쓰기, 동시 실행)
UPDATE users SET balance = 1500 WHERE id = 1;
-- Transaction A (다시 읽기)
SELECT balance FROM users WHERE id = 1; -- 결과: 여전히 1000
B가 쓰고 있어도 A는 자신이 봐야 할 버전(Undo Log)을 읽는다. 락 없이 일관된 읽기가 가능한 이유다.
Read View는 트랜잭션이 어떤 데이터를 볼 수 있는지를 결정하는 스냅샷이다.
레코드를 수정한 트랜잭션 ID를 TxID라고 할 때:
| 조건 | 의미 | 보임? |
|---|---|---|
| TxID < min_trx_id (101) | Read View 생성 전에 이미 커밋됨 | ✅ |
| TxID ≥ max_trx_id (106) | Read View 생성 후에 시작된 트랜잭션 | ❌ |
| TxID == creator_trx_id (103) | 내가 수정한 데이터 | ✅ |
| TxID in m_ids [101,103,105] | 아직 실행 중인 다른 트랜잭션 | ❌ |
| TxID not in m_ids, min ≤ TxID < max | 이미 커밋된 트랜잭션 | ✅ |
요약하면: min_trx_id보다 작으면 보이고, max_trx_id 이상이면 안 보인다. 그 사이에 있으면 내 트랜잭션이거나 이미 커밋된 경우만 보인다.
여러 트랜잭션이 같은 레코드를 수정하면 버전 체인이 형성된다.
초기 상태:
┌───────────────────────────────┐
│ balance=1000, TxID=100 │
│ Roll Pointer=NULL │
└───────────────────────────────┘
TxID 101 수정 → balance=1200:
┌───────────────────────────────┐
│ balance=1200, TxID=101 │ ← 최신 (디스크)
│ Roll Pointer ──────┐ │
└────────────────────┼───────────┘
▼
┌───────────────────────────────┐
│ Undo Log (V1) │
│ balance=1000, TxID=100 │
│ Roll Pointer=NULL │
└───────────────────────────────┘
TxID 102 수정 → balance=1500:
┌───────────────────────────────┐
│ balance=1500, TxID=102 │ ← 최신 (디스크)
│ Roll Pointer ──────┐ │
└────────────────────┼───────────┘
▼
┌───────────────────────────────┐
│ Undo Log (V2) │
│ balance=1200, TxID=101 │
│ Roll Pointer ──────┐ │
└────────────────────┼───────────┘
▼
┌───────────────────────────────┐
│ Undo Log (V1) │
│ balance=1000, TxID=100 │
│ Roll Pointer=NULL │
└───────────────────────────────┘
버전 체인: V3(1500) → V2(1200) → V1(1000)
각 트랜잭션은 자신의 Read View에 따라 체인을 탐색해서 볼 수 있는 버전을 찾는다.
current = 최신 레코드
while (current != NULL):
if (current가 가시적):
return current // 보이면 리턴
current = current.Roll_Pointer // 안 보이면 이전 버전으로
return NULL // 볼 수 있는 버전 없음
READ COMMITTED: 매 SELECT마다 새로운 Read View를 생성한다.
START TRANSACTION;
SELECT balance FROM users WHERE id = 1; -- Read View #1 생성, 결과: 1000
-- 다른 트랜잭션이 balance = 1500으로 커밋
SELECT balance FROM users WHERE id = 1; -- Read View #2 생성 (새로)
-- 결과: 1500 (변경 감지!)
COMMIT;
Non-Repeatable Read가 발생할 수 있다. 항상 최신 커밋 데이터를 읽는다. Oracle, PostgreSQL 기본값.
REPEATABLE READ: 첫 SELECT 시 한 번만 Read View를 생성하고 재사용한다.
START TRANSACTION;
SELECT balance FROM users WHERE id = 1; -- Read View #1 생성, 결과: 1000
-- 다른 트랜잭션이 balance = 1500으로 커밋
SELECT balance FROM users WHERE id = 1; -- Read View #1 재사용
-- 결과: 1000 (변함없음!)
COMMIT;
트랜잭션 내 일관된 스냅샷을 보장한다. MySQL InnoDB 기본값.
💡 팁: REPEATABLE READ가 기본값인데도 MySQL이 Phantom Read를 대부분 막아주는 이유가 여기 있다. MVCC 덕분에 같은 Read View로 Undo Log를 읽으니, 다른 트랜잭션이 INSERT를 해도 기존 SELECT에는 보이지 않는다. 단,
SELECT ... FOR UPDATE처럼 잠금을 거는 경우에는 실제 데이터를 읽으므로 예외가 생길 수 있다.
Undo Log는 계속 쌓인다. 정리하지 않으면 Undo Tablespace가 무한 증가하고, 버전 체인이 길어져서 탐색이 느려지고, 결국 디스크 공간이 고갈된다.
버전 체인: V3(TxID=105) → V2(TxID=103) → V1(TxID=100)
현재 활성 트랜잭션 중 가장 오래된 것의 min_trx_id = 106
모든 활성 트랜잭션이 TxID 105까지를 볼 수 없음
→ V1, V2는 삭제 가능 (V3는 최신이므로 유지)
if (Undo Log TxID < oldest_view) → 삭제 가능| HLL | 상태 | 대응 |
|---|---|---|
| < 1,000 | 정상 | — |
| 1,000 ~ 10,000 | 주의 | 긴 트랜잭션 확인 |
| 10,000 ~ 100,000 | 경고 | 즉시 긴 트랜잭션 종료 |
| > 100,000 | 위험 | 쿼리 성능 급락, 긴급 대응 |
💡 팁: HLL이 급증한다면 커밋하지 않고 오래 열려있는 트랜잭션을 먼저 의심해야 한다. 긴 트랜잭션이 살아있는 동안 그 트랜잭션의 Read View보다 오래된 Undo Log는 Purge가 불가능하다. 배치 작업이나 긴 분석 쿼리를 트랜잭션으로 감싸두면 이 문제가 발생하기 쉽다.
전체 흐름을 한 줄로 정리하면 이렇다.
쓰기: 데이터를 바로 디스크에 쓰지 않고 Redo Log에 먼저 순차적으로 기록한다(WAL). 커밋 시 Redo Log만 fsync하면 되므로 빠르다.
복구: 크래시가 나면 Redo Log를 재생해서 커밋된 데이터를 복원하고, Undo Log를 사용해서 미완료 트랜잭션을 롤백한다.
읽기: 읽기 트랜잭션은 Read View를 통해 자신이 봐야 할 버전을 결정하고, Undo Log 버전 체인을 탐색해서 해당 버전을 읽는다. 락 없이 동시 읽기/쓰기가 가능한 이유다.
정리: Purge가 백그라운드에서 더 이상 필요 없는 Undo Log를 정리한다.
ACID의 각 속성이 어디서 보장되는지 연결해보면: Atomicity는 Undo Log, Durability는 Redo Log + WAL, Isolation은 MVCC + Read View, Consistency는 이 세 가지가 함께 만들어낸다.