MySQL 공부 3 - Redo Log, Undo Log, MVCC 심화

Chu Sang Yoon·2026년 3월 18일

MySQL

목록 보기
4/9

MySQL 공부 3 - Redo Log, Undo Log, MVCC 심화

2편에서 Buffer Pool의 구조와 LRU 동작, 그리고 전체 Write 프로세스 흐름을 봤다. 이번 편에서는 그 흐름 안에서 핵심 역할을 하는 세 가지를 깊게 파고든다. 데이터를 잃지 않게 해주는 WAL과 Redo Log, 롤백과 동시성을 가능하게 하는 Undo Log, 그리고 락 없이 읽기/쓰기가 동시에 가능한 이유인 MVCC다.


WAL(Write-Ahead Logging) — 로그를 먼저 써라

전통적인 쓰기 방식의 문제

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).

WAL의 해결책

먼저 Redo Log에만 쓰고, 실제 데이터는 나중에 비동기로 반영한다.

UPDATE 실행
    ↓
Redo Log(ib_logfile)의 끝에 Sequential Write
    → 디스크 헤드 이동 없음
    → 0.1ms (기존보다 100배 빠름!)
    ↓
나중에 (비동기)
    ↓
실제 데이터 페이지에 쓰기
    → 여러 변경사항을 모아서 한 번에
    → I/O 효율 극대화

커밋 시 Redo Log만 디스크에 쓰면 된다. 실제 데이터는 천천히, 효율적으로. 크래시가 나도 Redo Log를 재생하면 복구할 수 있다.


LSN(Log Sequence Number) — Redo Log의 주소 체계

LSN은 Redo Log의 논리적 주소다. Redo Log를 거대한 바이트 배열로 생각하면 이해하기 쉽다.

LSN:  0    100   200   300   400  ...
      │     │     │     │     │
      ▼     ▼     ▼     ▼     ▼
   [Rec1][Rec2][Rec3][Rec4][Rec5]...

- LSN은 계속 증가 (절대 감소 안 함)
- 각 변경사항마다 고유 LSN 할당
- 64bit 정수

예를 들면:

  • LSN 1000: Page 157의 Offset 894에 1500 씀
  • LSN 1050: Page 201의 Offset 123에 2000 씀
  • LSN 1100: Page 157의 Offset 900에 'Alice' 씀

주요 LSN 포인터 3개

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는 삭제 가능

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

Checkpoint Age = Current LSN - Checkpoint LSN
┌─────────────────────────────────────────┐
│           Redo Log Space (1GB)          │
│                                         │
│  Checkpoint LSN ──┐                     │
│                   ▼                     │
│  ┌─────────────────────────────────┐    │
│  │   안전 영역 (덮어쓰기 가능)           │    │
│  │   이미 디스크에 반영됨               │    │
│  └─────────────────────────────────┘    │
│  ┌─────────────────────────────────┐    │
│  │   위험 영역 (Checkpoint Age)      │    │
│  │   아직 디스크 미반영                │    │
│  │   너무 크면 강제 Checkpoint!       │    │
│  └─────────────────────────────────┘    │
│  Current LSN ────┘                      │
└─────────────────────────────────────────┘

Checkpoint Age가 임계값을 넘으면 강제로 Dirty Page를 디스크에 플러시한다.

Checkpoint 동작 순서

  1. 트리거 조건 확인 (Checkpoint Age > 임계값, 주기적 실행 1초, 서버 종료)
  2. Flush List에서 가장 오래된 페이지(가장 작은 LSN) 선택
  3. fsync() 로 물리적으로 디스크에 기록
  4. Checkpoint LSN 업데이트 (9500 → 9800)

크래시 복구

커밋 후 크래시 — Redo로 복구

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 만족

커밋 전 크래시 — Undo로 롤백

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 만족

실제 Recovery 4단계

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)이 핵심이다. 복구 과정에서 "이미 반영된 것 같은데?"라는 걱정 없이 안전하게 재실행할 수 있는 이유다.


Undo Log와 MVCC

Undo Log의 두 가지 역할

역할 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 — 내가 볼 수 있는 범위

Read View는 트랜잭션이 어떤 데이터를 볼 수 있는지를 결정하는 스냅샷이다.

Read View 구조

  • m_ids: 현재 실행 중인 활성 트랜잭션 ID 목록 (예: [101, 103, 105])
  • min_trx_id: m_ids 중 가장 작은 값 (예: 101)
  • max_trx_id: 다음에 할당될 트랜잭션 ID (예: 106, 아직 시작 안 함)
  • creator_trx_id: 이 Read View를 만든 트랜잭션 ID (예: 103)

가시성 판단 규칙

레코드를 수정한 트랜잭션 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 이상이면 안 보인다. 그 사이에 있으면 내 트랜잭션이거나 이미 커밋된 경우만 보인다.


Undo Log 버전 체인

여러 트랜잭션이 같은 레코드를 수정하면 버전 체인이 형성된다.

초기 상태:
┌───────────────────────────────┐
│ 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 View 생성 시점

READ COMMITTED vs REPEATABLE READ

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처럼 잠금을 거는 경우에는 실제 데이터를 읽으므로 예외가 생길 수 있다.


Purge — Undo Log 정리

Purge가 필요한 이유

Undo Log는 계속 쌓인다. 정리하지 않으면 Undo Tablespace가 무한 증가하고, 버전 체인이 길어져서 탐색이 느려지고, 결국 디스크 공간이 고갈된다.

삭제 가능 조건

버전 체인: V3(TxID=105) → V2(TxID=103) → V1(TxID=100)

현재 활성 트랜잭션 중 가장 오래된 것의 min_trx_id = 106

모든 활성 트랜잭션이 TxID 105까지를 볼 수 없음
→ V1, V2는 삭제 가능 (V3는 최신이므로 유지)

Purge 프로세스

  1. History List 스캔 (커밋된 트랜잭션의 Undo Log 목록, 오래된 것부터)
  2. if (Undo Log TxID < oldest_view) → 삭제 가능
  3. Undo 레코드 제거, 디스크 공간 회수
  4. 1초마다 백그라운드에서 비동기 실행

History List Length(HLL) 모니터링

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는 이 세 가지가 함께 만들어낸다.

0개의 댓글