본문으로 건너뛰기
Forward Engineering
Go back

MySQL InnoDB 아키텍처 이해: 엔진을 알아야 설계가 보인다

목차

들어가며: 3초의 미스터리

리뷰 관리 서비스를 운영하던 중이었습니다. 어느 순간부터 리뷰 조회 API 응답이 눈에 띄게 느려지기 시작했습니다.

[WARN] Slow Query: 3,245ms
SELECT * FROM reviews WHERE shop_id = ? ORDER BY created_at DESC LIMIT 20

인덱스는 분명히 있었습니다. 쿼리도 단순했습니다. 실행 계획을 확인해봐도 인덱스를 잘 타고 있었죠. 그런데 왜 3초나 걸리는 걸까요?

답은 예상치 못한 곳에 있었습니다. 서버 모니터링에서 발견한 숫자:

Innodb_buffer_pool_hit_rate: 85%

“85%면 괜찮은 거 아닌가?”라고 생각했습니다. 하지만 아니었습니다. 나머지 15%가 디스크 랜덤 I/O를 유발하고 있었고, 데이터가 쌓이면서 그 15%의 영향이 점점 커지고 있었던 겁니다.

이 경험을 계기로 InnoDB 엔진의 내부 구조를 파고들기 시작했습니다. “인덱스를 걸면 빨라진다”, “정규화를 해야 한다”는 말을 그냥 따르던 것에서, 그런지 이해하게 된 계기였습니다.

이 글에서는 InnoDB의 핵심 컴포넌트들을 살펴보고, 각각이 테이블 설계에 어떤 영향을 미치는지 공유하겠습니다.


InnoDB 전체 구조

MySQL은 크게 Server LayerStorage Engine Layer로 나뉩니다.

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
    subgraph "MySQL Server Layer"
        A[SQL Parser] --> B[Optimizer]
        B --> C[Executor]
    end

    subgraph "InnoDB Storage Engine"
        subgraph "In-Memory"
            D[Buffer Pool<br/>데이터 캐시]
            E[Change Buffer<br/>인덱스 변경 버퍼]
            F[Log Buffer<br/>로그 버퍼]
        end
        subgraph "On-Disk"
            G[(Tablespace<br/>.ibd)]
            H[(Redo Log<br/>ib_logfile)]
            I[(Undo Log)]
        end
    end

    C --> D
    D <--> G
    F --> H
    D --> I

    style D fill:#51cf66,stroke:#2b8a3e,color:#fff
    style H fill:#ffd43b,stroke:#fab005,color:#000
    style I fill:#4dabf7,stroke:#1971c2,color:#fff

각 컴포넌트의 역할을 이해하면, 왜 특정 설계가 성능에 영향을 주는지 자연스럽게 알게 됩니다.


1. Page: InnoDB의 기본 단위

본격적으로 Buffer Pool을 이야기하기 전에, Page 개념을 먼저 알아야 합니다.

Page란 무엇인가?

InnoDB의 Page(페이지)는 데이터 저장 및 I/O의 기본 단위입니다. 기본 크기는 16KB이며, 모든 데이터와 인덱스는 이 페이지 단위로 관리됩니다.

Page의 핵심 역할:

왜 1바이트를 읽어도 16KB를 읽을까?

이것은 하드웨어와 OS의 특성 때문입니다.

%%{init: {'theme':'base'}}%%
graph TB
    subgraph "I/O 스택"
        A["Application: InnoDB<br/>16KB Page 요청"]
        B[OS Page Cache<br/>4KB 페이지 단위]
        C["Filesystem: ext4/xfs<br/>4KB 블록 단위"]
        D[Block Device Layer]
        E[Hardware<br/>HDD: 512B/4KB 섹터<br/>SSD: 4KB~16KB 페이지]
    end

    A --> B --> C --> D --> E

하드웨어 계층의 제약:

저장 장치최소 읽기/쓰기 단위특징
HDD (전통)512 bytes (섹터)물리적 섹터 단위로만 접근 가능
HDD (Advanced Format)4KB (섹터)최신 HDD의 표준
SSD4KB~16KB (페이지)NAND Flash 특성상 페이지 단위 접근
NVMe SSD4KB~16KB더 빠르지만 동일한 제약

핵심 인사이트: 물리적으로 1바이트만 읽는 것은 불가능합니다. 디스크 헤드가 특정 위치로 이동하면(Seek), 최소한 섹터 하나 전체를 읽어야 합니다.

왜 16KB인가?

InnoDB가 16KB를 선택한 이유는 여러 Trade-off의 결과입니다:

%%{init: {'theme':'base'}}%%
graph LR
    subgraph "페이지 크기 Trade-off"
        A[4KB<br/>메모리 효율적<br/>랜덤 읽기 최적화]
        B[16KB 기본<br/>균형잡힌 성능<br/>인덱스 fan-out 증가]
        C[64KB<br/>대용량 스캔 효율적<br/>메모리 낭비 가능]
    end

    A -.->|OLTP 소량 랜덤| B
    B -.->|대부분 워크로드| C

    style B fill:#51cf66
페이지 크기장점단점적합한 워크로드
4KB메모리 효율적, 랜덤 읽기 최소화메타데이터 오버헤드, 인덱스 깊이 증가OLTP (소량 랜덤)
16KB (기본)균형잡힌 성능, 인덱스 fan-out 증가일부 메모리 낭비 가능대부분의 워크로드
64KB대용량 스캔 효율적, 인덱스 깊이 감소메모리 낭비, 캐시 오염OLAP (대량 순차 스캔)

Spatial Locality의 활용: 같은 페이지 내의 인접한 레코드는 함께 접근될 가능성이 높습니다. 16KB를 한 번에 읽으면, 다음에 필요한 데이터가 이미 메모리에 있을 확률이 높아집니다.

Page 내부 구조 상세

하나의 16KB 페이지는 위에서 아래로 다음 영역들로 구성됩니다:

영역크기역할
FIL Header38 bytesChecksum, Page Number, Previous/Next Page 포인터, LSN, Page Type
INDEX Header36 bytes레코드 수, Heap Top 위치, Page Level (리프=0)
Infimum Record13 bytes시스템 레코드 - 모든 키보다 작은 “최소값”
Supremum Record13 bytes시스템 레코드 - 모든 키보다 큰 “최대값”
User Records가변실제 데이터. 삽입 순서로 저장되며, 아래 방향으로 성장
Free Space가변사용 가능한 공간. User Records와 Page Directory 사이
Page Directory가변4~8개 레코드마다 16비트 포인터. 위 방향으로 성장
FIL Trailer8 bytesChecksum (Header와 일치 검증), LSN 하위 32비트

참고: Jeremy Cole의 The physical structure of InnoDB index pages에서 더 상세한 구조를 확인할 수 있습니다.

핵심 포인트:

  1. Infimum & Supremum: 모든 레코드는 이 두 시스템 레코드 사이에 연결됩니다. Infimum은 “어떤 레코드보다 작은 값”, Supremum은 “어떤 레코드보다 큰 값”을 의미합니다.

  2. Page Directory: 바이너리 서치를 위한 “희소 인덱스(sparse index)“입니다. 4~8개 레코드마다 하나의 슬롯을 만들어서, O(log n) 검색이 가능합니다.

  3. Two-way Growth: User Records는 아래로, Page Directory는 위로 성장하여 중간의 Free Space에서 만납니다.

  4. Linked List: 레코드는 물리적 순서와 무관하게 “next record” 포인터로 논리적 순서(키 순서)를 유지합니다.

Record란 무엇인가?

Record(레코드)는 테이블의 한 (Row)을 의미합니다. 하지만 실제 저장 형식은 우리가 생각하는 것보다 복잡합니다.

영역크기구성 요소
Record Header5 bytesinfo bits (삭제 마크), n_owned (Page Directory용), heap number, record type, next record offset
Hidden Columns13~19 bytesDB_TRX_ID (6B, 트랜잭션 ID), DB_ROLL_PTR (7B, Undo 포인터), DB_ROW_ID (6B, PK 없을 때만)
User Columns가변실제 컬럼 데이터 (Column 1, Column 2, …)

실제 계산 예시:

reviews 테이블 레코드 크기 계산 (VARCHAR(36) UUID PK 기준):

구성 요소크기설명
Record Header5 bytes시스템 헤더
DB_TRX_ID6 bytes트랜잭션 ID
DB_ROLL_PTR7 bytesUndo 포인터
UUID PK36 bytesVARCHAR(36)
shop_id36 bytesVARCHAR(36)
content~200 bytes리뷰 내용 (평균)
rating1 byteTINYINT
created_at8 bytesDATETIME
기타~50 bytes나머지 컬럼
합계350~500 bytes

BINARY(16) UUID로 최적화 시:

변경 사항결과
UUID 크기36 → 16 bytes (-20 bytes)
레코드 크기약 300~400 bytes
한 페이지당 레코드 수~50개
I/O 감소약 30%

2. Buffer Pool: InnoDB의 심장

Buffer Pool이란?

Buffer Pool은 페이지들을 메모리에 캐싱하는 영역입니다. InnoDB에서 가장 중요한 메모리 구조입니다.

%%{init: {'theme':'base'}}%%
graph LR
    subgraph "Query 실행"
        Q[SELECT * FROM reviews<br/>WHERE shop_id = 'xxx']
    end

    subgraph "Buffer Pool - 메모리"
        BP[Page Cache]
    end

    subgraph "Disk"
        D[(reviews.ibd)]
    end

    Q -->|1. 페이지 요청| BP
    BP -->|Hit: 즉시 반환| Q
    BP -.->|Miss: 디스크 읽기| D
    D -.->|페이지 로드| BP

    style BP fill:#51cf66

같은 쿼리를 두 번 실행하면 왜 빨라지는가?

이것이 Buffer Pool의 핵심 가치입니다.

첫 번째 실행 (Cold Start):

SELECT * FROM reviews WHERE shop_id = 123
단계상태소요 시간
Buffer Pool 확인Miss (페이지가 메모리에 없음)-
B+-tree Root Page 읽기디스크 I/O~0.1ms
B+-tree Internal Page 읽기디스크 I/O~0.1ms
B+-tree Leaf Pages (10개)디스크 I/O~1ms
Total~2-5ms

두 번째 실행 (Warm Buffer Pool):

SELECT * FROM reviews WHERE shop_id = 123  -- 동일 쿼리
단계상태소요 시간
Root Page 확인Hit (메모리에 있음)~0.001ms
Internal Page 확인Hit~0.001ms
Leaf Pages (10개) 확인Hit~0.01ms
Total~0.1ms (20~50배 빠름!)

성능 차이 요약:

시나리오Buffer Pool Hit Rate평균 응답 시간디스크 IOPS
Cold Start0%50~150ms2000+
Warming Up50%10~25ms500
Hot99%0.5~2ms20
Hot (99.9%)99.9%0.1~0.5ms5

권장 목표: Buffer Pool Hit Rate 99% 이상

하지만 주의: Hit Rate만으로는 부족합니다. Full Table Scan을 하면서도 99% Hit Rate를 기록할 수 있습니다. Response Time을 함께 모니터링해야 합니다.

Query Cache vs Buffer Pool (중요!)

MySQL 8.0 이전 사용자들은 “Query Cache”와 혼동할 수 있습니다.

측면Query Cache (MySQL 5.7까지)Buffer Pool
캐시 단위쿼리 결과 전체 (Result Set)페이지 (16KB)
캐시 키SQL 문자열 (정확히 일치해야 함)(space_id, page_no)
재사용성낮음 (쿼리가 조금만 달라도 Miss)높음 (페이지는 여러 쿼리가 공유)
무효화테이블 변경 시 전체 무효화페이지 단위 관리
확장성낮음 (글로벌 뮤텍스 경합)높음 (Instance로 분산)
-- Query Cache에서는 이 두 쿼리가 별개로 캐싱됨
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;

-- Buffer Pool에서는 같은 페이지를 공유 가능!
-- id=1과 id=2가 같은 페이지에 있다면, 두 번째 쿼리는 디스크 I/O 없이 실행

Query Cache가 MySQL 8.0에서 제거된 이유:

  1. 글로벌 락 경합: 멀티코어 환경에서 병목
  2. 무효화 오버헤드: 테이블 변경 시 관련 모든 캐시 삭제
  3. 낮은 재사용성: 실무에서 Cache Hit이 낮음
  4. 대안의 우수성: Application-level caching (Redis)이 더 효율적

LRU 알고리즘: 왜 “개선된” LRU가 필요했나?

Buffer Pool이 가득 차면 어떤 페이지를 버릴지 결정해야 합니다. 가장 직관적인 방법은 LRU(Least Recently Used) 알고리즘입니다.

기본 LRU 알고리즘:

위치페이지상태
HeadPage A방금 접근됨
Page B
Page C
Page D
TailPage Z오래전 접근 → 제거 대상

동작 규칙:

  1. 페이지 접근 시 → 리스트 맨 앞(Head)으로 이동
  2. 새 페이지 삽입 → 리스트 맨 앞에 추가
  3. 공간 부족 시 → 리스트 맨 뒤(Tail) 제거

문제: Full Table Scan이 Buffer Pool을 오염시킨다

-- 분석용 대량 스캔 쿼리
SELECT AVG(rating), COUNT(*)
FROM reviews
WHERE created_at BETWEEN '2023-01-01' AND '2025-12-31';

이 쿼리가 수백만 페이지를 한 번 훑습니다:

시점HeadTail문제
Before ScanHot Page 1 (자주 사용)Hot Page 2, 3, …Cold Page Z정상
During ScanScan Page 999,999 (방금 읽음)Scan Page 999,998, …Hot Page 1Hot Page가 Tail로 밀림
After ScanScan Pages로 가득 참Hot Pages 제거됨성능 급락

결과:

이 문제는 실제로 MySQL Bug #45015 (2009년)로 보고되었습니다:

“InnoDB buffer pool can be severely affected by table scans”

InnoDB의 개선된 LRU: Midpoint Insertion Strategy

이 문제를 해결하기 위해 MySQL 5.1.41 (2009년 11월)에 Midpoint Insertion Strategy가 도입되었습니다.

%%{init: {'theme':'base'}}%%
graph TB
    subgraph "Buffer Pool LRU List"
        subgraph "Young Sublist - 5/8 (62.5%)"
            Y1["Hot Page 1: 자주 접근"]
            Y2[Hot Page 2]
            Y3[Hot Page 3]
        end
        subgraph "Old Sublist - 3/8 (37.5%)"
            O1["New Page: 여기에 삽입"]
            O2[Cold Page 1]
            O3["Cold Page 2: 제거 대상"]
        end
    end

    NEW[새 페이지] -->|삽입| O1
    O1 -->|조건 충족 시 승격| Y3
    O3 -->|제거| EVICT[제거됨]

    style Y1 fill:#51cf66
    style Y2 fill:#51cf66
    style Y3 fill:#51cf66
    style O1 fill:#ffd43b
    style O3 fill:#ff6b6b

동작 메커니즘:

  1. 새 페이지 삽입: Old Sublist의 Head (Midpoint)에 삽입

    • 아직 “Hot”으로 인정받지 못함
  2. 시간 기반 승격 (핵심!):

    if (현재시각 - 페이지.첫접근시각) > innodb_old_blocks_time(기본 1000ms)
       AND 페이지가 다시 접근됨:
        → Young Sublist의 Head로 이동 (승격)
  3. 제거: Old Sublist의 Tail에서 제거

    • Full Scan으로 한 번만 읽힌 페이지는 1초 내에 제거됨

Full Table Scan 시나리오 (개선된 LRU):

시점Young Sublist (5/8)Old Sublist (3/8)상태
Before ScanHot Page 1, 2, 3, …일부 Cold Pages정상
During ScanHot Pages 유지 (보호됨)Scan Pages 쌓임Young 보호
After Scan (1초 후)Hot Pages 유지Scan Pages 빠르게 제거됨정상 복구

결과: Hot Pages는 Young Sublist에서 보호되고, Scan Pages는 Old에서 1초 내에 제거되어 OLTP 쿼리 성능이 유지됩니다.

관련 MySQL 소스 코드 (storage/innobase/buf/buf0lru.cc):

// 핵심 함수들
buf_LRU_make_block_young()    // 페이지를 Young으로 승격
buf_LRU_make_block_old()      // 새 페이지를 Old에 삽입
buf_page_peek_if_too_old()    // 승격 조건 검사 (시간 체크)
buf_LRU_get_free_block()      // LRU Tail에서 제거할 페이지 선택

실무 설정

# my.cnf

# Buffer Pool 크기 (전체 메모리의 70-80%)
innodb_buffer_pool_size = 12G

# Buffer Pool Instance 수 (락 경합 감소, 1GB당 1개 권장)
innodb_buffer_pool_instances = 8

# Old Sublist 비율 (기본: 37 = 3/8 ≈ 37.5%)
innodb_old_blocks_pct = 37

# 승격 대기 시간 (기본: 1000ms = 1초)
innodb_old_blocks_time = 1000

# 재시작 후 캐시 복구 (Buffer Pool Warming)
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON

실제 운영 사례: Working Set 최적화

핵심 원칙: “테이블 전체가 아니라, 자주 접근하는 데이터(Working Set)가 Buffer Pool에 들어가야 한다”

%%{init: {'theme':'base'}}%%
graph LR
    subgraph "Before"
        A1[reviews 50GB<br/>전체 데이터]
        B1[Buffer Pool 12GB]
    end

    subgraph "After"
        A2[reviews 5GB<br/>최근 3개월]
        A3[archive_reviews 45GB<br/>콜드 데이터]
        B2[Buffer Pool 12GB]
    end

    A1 -.->|경쟁| B1
    A2 -->|상주| B2

    style A1 fill:#ff6b6b
    style B1 fill:#ffd43b
    style A2 fill:#51cf66
    style B2 fill:#51cf66
    style A3 fill:#868e96

결과:

지표BeforeAfter개선
Buffer Pool Hit Rate85%99.2%+14.2%p
리뷰 조회 P993,245ms45ms72배
디스크 IOPS2,50015094% 감소

Buffer Pool 모니터링

-- Buffer Pool 상태 확인
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';

-- 핵심 지표
Innodb_buffer_pool_read_requests: 100,000,000  -- 논리적 읽기 요청
Innodb_buffer_pool_reads: 800,000              -- 실제 디스크 읽기

-- Hit Rate 계산
-- Hit Rate = 1 - (reads / read_requests)
--          = 1 - (800,000 / 100,000,000)
--          = 99.2%

-- 더 상세한 모니터링
SELECT
  FORMAT(A.pages_dirty, 0) AS dirty_pages,
  FORMAT(B.pages_total, 0) AS total_pages,
  ROUND(A.pages_dirty / B.pages_total * 100, 2) AS dirty_pct
FROM
  (SELECT VARIABLE_VALUE AS pages_dirty
   FROM performance_schema.global_status
   WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty') A,
  (SELECT VARIABLE_VALUE AS pages_total
   FROM performance_schema.global_status
   WHERE VARIABLE_NAME = 'Innodb_buffer_pool_pages_total') B;

3. Dirty Page와 Checkpoint

Dirty Page란?

Buffer Pool의 페이지는 두 가지 상태를 가집니다:

상태조건특징
Clean Page메모리 버전 = 디스크 버전언제든지 제거 가능 (oldest_modification = 0)
Dirty Page메모리 버전 ≠ 디스크 버전아직 디스크에 쓰지 않은 변경사항이 있음. 디스크에 flush 전까지 제거 불가 (oldest_modification > 0)

페이지가 Dirty가 되는 시점:

BEGIN;
SELECT * FROM users WHERE id = 123 FOR UPDATE;  -- 페이지 읽기 (Clean 상태)
UPDATE users SET name = 'John' WHERE id = 123;  -- 이 순간 Dirty로 전환!
COMMIT;
단계SQL페이지 상태동작
1BEGIN-트랜잭션 시작
2SELECT ... FOR UPDATEClean페이지를 Buffer Pool로 로드
3UPDATEDirtyBuffer Pool에서만 수정. Redo Log에 변경 기록
4COMMITDirty (유지)Redo Log fsync로 내구성 보장. 페이지는 나중에 Checkpoint에서 flush

MySQL 소스 코드에서 Dirty Page 추적 (buf_page_t 구조체):

class buf_page_t {
  // Dirty 상태 추적
  lsn_t oldest_modification;  // 0이면 Clean, >0이면 Dirty
  lsn_t newest_modification;  // 가장 최근 수정 LSN

  // ...
};

왜 Dirty Page를 바로 디스크에 안 쓰는가?

1. Write Amplification 감소:

시나리오: 같은 페이지를 3번 수정

방식동작디스크 쓰기 횟수총 쓰기량
즉시 쓰기UPDATE → 디스크 (x3)3번48KB
Buffer PoolUPDATE → 메모리 (x3) → Checkpoint → 디스크1번16KB (67% 감소)

2. Random I/O → Sequential I/O 변환:

방식트랜잭션 순서대로 쓰기Checkpoint에서 배치 쓰기
패턴Page X(위치A) → Page Y(위치Z) → Page Z(위치B)Dirty Pages 모아서 정렬 후 쓰기
디스크 동작매번 헤드 이동 (Random I/O)정렬된 순서로 쓰기 (효율적)
HDD 성능~100 IOPS100 MB/s 수준

4. Redo Log: 트랜잭션 내구성의 비밀

왜 Redo Log가 필요한가?

트랜잭션이 COMMIT되면, 데이터는 “영구적으로 저장”되어야 합니다. 하지만 Dirty Page를 매번 디스크에 쓰면 랜덤 I/O가 발생합니다.

InnoDB의 해결책: WAL(Write-Ahead Logging) - 먼저 로그에 쓰고, 나중에 데이터를 쓴다

%%{init: {'theme':'base'}}%%
sequenceDiagram
    participant App
    participant BP as Buffer Pool
    participant LB as Log Buffer
    participant RL as Redo Log - Disk
    participant DF as Data File - Disk

    App->>BP: UPDATE - Page를 Dirty로 표시
    App->>LB: 변경 내용 기록
    App->>App: COMMIT
    LB->>RL: fsync - 순차 쓰기
    Note over RL: 이 시점에 COMMIT 완료<br/>데이터는 아직 디스크에 없음

    Note over BP,DF: ... 나중에 Checkpoint ...
    BP->>DF: Dirty Pages flush - 배치

왜 순차 I/O가 빠른가?

%%{init: {'theme':'base'}}%%
graph TB
    subgraph "랜덤 I/O"
        R1[Page A 쓰기]
        R2[Page Z 쓰기]
        R3[Page M 쓰기]
        R1 --> R2 --> R3
    end

    subgraph "순차 I/O"
        S1[Log Entry 1]
        S2[Log Entry 2]
        S3[Log Entry 3]
        S1 --> S2 --> S3
    end

    style R1 fill:#ff6b6b
    style R2 fill:#ff6b6b
    style R3 fill:#ff6b6b
    style S1 fill:#51cf66
    style S2 fill:#51cf66
    style S3 fill:#51cf66
I/O 패턴HDDSSD
랜덤 I/O~100 IOPS~10,000 IOPS
순차 I/O~100 MB/s~500 MB/s

왜 Dirty 페이지 직접 쓰기는 랜덤 I/O인가?

데이터 페이지들은 테이블스페이스 파일(.ibd) 내에서 논리적 순서로 저장됩니다. 하지만 트랜잭션이 수정하는 페이지들은 접근 순서저장 위치가 전혀 다릅니다.

트랜잭션 실행 순서:
1. UPDATE users SET name='...' WHERE id=5     → Page 100 수정
2. UPDATE orders SET status='...' WHERE id=99 → Page 5000 수정
3. UPDATE users SET email='...' WHERE id=3    → Page 98 수정
4. INSERT INTO logs VALUES(...)               → Page 12000 수정

디스크 쓰기 시 헤드 이동:
[Page 100] -----(4900 페이지 점프)-----> [Page 5000]
[Page 5000] ----(4902 페이지 점프)-----> [Page 98]
[Page 98] -----(11902 페이지 점프)----> [Page 12000]

HDD의 경우: 디스크 헤드가 물리적으로 이동해야 합니다(Seek Time ~10ms). 위 예시에서 4번의 랜덤 접근 = 약 40ms 소요.

SSD의 경우: Seek Time은 없지만, 랜덤 쓰기는 내부적으로 더 많은 연산이 필요합니다. 또한 SSD의 쓰기 증폭(Write Amplification) 문제가 발생할 수 있습니다.

왜 Redo Log는 순차 I/O인가?

Redo Log는 Append-Only 구조입니다. 새로운 로그 레코드는 항상 파일의 끝에만 추가됩니다.

Redo Log 쓰기 패턴:
시간 →
[LSN 1000][LSN 1001][LSN 1002][LSN 1003][LSN 1004]...
     ↑         ↑         ↑         ↑         ↑
   순차로 연속 쓰기 (디스크 헤드 이동 없음)

내용:
[Page 100 변경][Page 5000 변경][Page 98 변경][Page 12000 변경]
     ↓              ↓              ↓              ↓
  모두 같은 위치에 순서대로 기록됨

핵심 차이점:

비교Dirty 페이지 직접 쓰기Redo Log 쓰기
쓰기 위치각 페이지의 고유 위치 (분산)항상 파일 끝 (연속)
I/O 패턴랜덤 I/O순차 I/O
HDD 성능~100 IOPS (초당 100회)~100 MB/s
같은 데이터 4개 쓰기40ms (4 × 10ms seek)0.1ms 미만

Redo Log의 마법 요약:

  1. 즉시 쓰기: 변경 내용을 Redo Log에 순차적으로 빠르게 기록 (COMMIT 보장)
  2. 지연 쓰기: Dirty 페이지는 Buffer Pool에 유지하며 나중에 Checkpoint에서 배치로 쓰기
  3. 배치 처리: 여러 Dirty 페이지를 모아서 한 번에 쓰면 디스크 헤드 이동 최소화

Checkpoint: Dirty Page Flush

Checkpoint는 Dirty Page를 디스크에 쓰는 과정입니다.

%%{init: {'theme':'base'}}%%
graph LR
    subgraph "Checkpoint 발생 조건"
        C1[Redo Log 사용률 증가<br/>75% 이상에서 적극적 플러싱]
        C2[Dirty Page 비율 초과<br/>innodb_max_dirty_pages_pct]
        C3[Sharp Checkpoint<br/>서버 종료 시]
    end

    subgraph "동작"
        A[Dirty Pages 선택]
        B[디스크에 flush]
        C[Redo Log 공간 해제]
    end

    C1 --> A
    C2 --> A
    A --> B --> C

Adaptive Flushing (MySQL 5.6+):

MySQL은 Redo Log 생성 속도를 모니터링하고, flush 속도를 자동으로 조절합니다:

Redo Log 상태Flush 속도목적
빠르게 쌓임증가Redo Log 공간 확보
느리게 쌓임감소불필요한 I/O 방지

목표: Sharp Checkpoint 방지 (급격한 성능 저하 방지)

innodb_flush_log_at_trx_commit: 내구성 vs 성능

COMMIT 시 Redo Log를 언제 디스크에 쓸지 결정합니다:

동작내구성성능위험 시나리오
1 (기본)매 COMMIT마다 fsync최고느림없음 (ACID 완전 보장)
2OS 버퍼까지만 write중간중간OS/서버 장애 시 최대 1초 손실
01초마다 fsync낮음빠름MySQL 장애 시에도 최대 1초 손실

우리 서비스의 설정:

# 리뷰 데이터는 중요하지만, 1초 손실은 허용 가능
# (외부 플랫폼에서 다시 수집 가능한 데이터)
innodb_flush_log_at_trx_commit = 2

왜 1이 아닌 2를 선택했는가?

주의: 값 2는 MySQL 프로세스만 비정상 종료되면 안전합니다 (OS가 버퍼를 flush). 하지만 OS나 서버 자체가 장애나면 데이터 손실 가능합니다.


5. Undo Log: MVCC와 일관된 읽기

읽기 잠금 없는 SELECT

MVCC(Multi-Version Concurrency Control)는 읽기 작업이 쓰기 작업을 블로킹하지 않도록 하는 메커니즘입니다.

%%{init: {'theme':'base'}}%%
sequenceDiagram
    participant TxA as Transaction A
    participant BP as Buffer Pool
    participant Undo as Undo Log
    participant TxB as Transaction B

    TxA->>BP: UPDATE review SET content='new'
    Note over BP: 새 버전 생성
    BP->>Undo: 이전 버전 저장

    TxB->>BP: SELECT * FROM review
    Note over BP: 현재 버전은 TxA가 수정 중
    BP->>Undo: 이전 버전 요청
    Undo->>TxB: 이전 버전 반환 - 즉시!

    Note over TxB: Lock 대기 없이 읽기 완료

Without MVCC (Lock-based):

With MVCC (InnoDB):

긴 트랜잭션의 위험성

Undo Log는 트랜잭션이 종료될 때까지 유지됩니다.

%%{init: {'theme':'base'}}%%
graph TB
    subgraph "긴 트랜잭션 문제"
        T1[Tx 시작 10:00]
        T2[... 30분 경과 ...]
        T3[Tx 진행 중 10:30]

        U1[Undo Version 1]
        U2[Undo Version 2]
        U3[Undo Version ...]
        U4[Undo Version N]

        T1 --> T2 --> T3
        T3 -.->|유지해야 함| U1
        U1 --> U2 --> U3 --> U4
    end

    style T3 fill:#ff6b6b
    style U4 fill:#ff6b6b

문제:

적용한 해결책:

// Bad: 긴 트랜잭션
async processAllReviews() {
  await this.dataSource.transaction(async (manager) => {
    for (const review of allReviews) {  // 수만 건
      await this.process(review, manager);
    }
    // 여기서 COMMIT - 수만 건 처리 후
  });
}

// Good: 배치 분할
async processAllReviews() {
  for (const batch of chunk(allReviews, 100)) {
    await this.dataSource.transaction(async (manager) => {
      for (const review of batch) {
        await this.process(review, manager);
      }
      // 100건마다 COMMIT - Undo 빠르게 정리
    });
  }
}

6. 설계 원칙 정리

InnoDB 아키텍처를 이해하면, 다음 설계 원칙들이 “왜” 필요한지 알게 됩니다:

InnoDB 특성원인설계 원칙실무 적용
16KB PageI/O 최소 단위레코드 크기 최소화VARCHAR(36) → BINARY(16) 검토
Buffer Pool메모리 캐시Working Set을 Buffer Pool에오래된 데이터 Archive
LRU Young/OldFull Scan 보호분석 쿼리 분리Read Replica 사용
Redo Log순차 쓰기COMMIT 비용 인식배치 INSERT
Undo LogMVCC짧은 트랜잭션100건 단위 배치

마치며

처음에 던졌던 질문으로 돌아가봅시다.

“왜 Buffer Pool Hit Rate가 85%인데 3초나 걸렸을까?”

이제 답할 수 있습니다:

이 원리들을 이해하고 적용한 결과:

다음 글에서는 이 지식을 바탕으로 B+-tree 인덱스 구조를 파고들겠습니다. 왜 복합 인덱스의 컬럼 순서가 중요한지, 왜 UUID PK가 INSERT 성능에 영향을 주는지 “Page 분할” 관점에서 설명하겠습니다.


참고자료

MySQL 공식 문서

기술 블로그

Percona 블로그

MySQL Bug Reports


Share this post on:

Previous Post
배달 플랫폼 스크래핑 대장정 Part 1: API에서 브라우저 자동화로
Next Post
대규모 브라우저 자동화 시스템의 메모리 누수 해결기: 3개의 정리 경로가 만든 완벽한 폭풍