[MySQL] BufferPool과 Redo Log(InnoDB의 메모리와 WAL 구조)

왜 InnoDB는 디스크에 즉시 쓰지 않는가(랜덤 I/O의 비용)

데이터베이스에서 응답 시간을 가장 크게 잡아먹는 원인은 랜덤 디스크 I/O(Input/Output)다. SSD(Solid State Drive)에서도 순차 쓰기 대비 랜덤 쓰기는 수 배에서 수십 배 느리고, HDD(Hard Disk Drive)에서는 수백 배까지 벌어진다. 그런데 UPDATE 한 건이 데이터 파일에 즉시 반영된다면 다음 세 단계를 매번 거쳐야 한다.

  1. 변경할 행이 속한 페이지(16KB)를 디스크에서 읽어 메모리에 올린다.
  2. 메모리에서 값을 수정한다.
  3. 수정된 페이지를 디스크의 원래 위치에 다시 쓴다.

클러스터링 인덱스의 페이지는 PK(Primary Key) 순서로 이어지지만, 익스텐트 할당과 페이지 분할 때문에 물리 위치까지 그 순서라는 보장은 없다. 여기에 동시에 들어오는 여러 UPDATE는 서로 다른 PK 구간의 페이지를 건드린다. 그래서 쓰기는 디스크 여기저기를 오가는 랜덤 쓰기가 된다.

InnoDB는 변경을 메모리의 버퍼 풀(Buffer Pool)에서 처리하고, 변경 내역만 리두 로그(Redo Log)에 순차로 먼저 적어 둔 뒤 커밋을 끝낸다. 이 순서가 WAL(Write-Ahead Logging)이다. 즉, 커밋은 데이터 파일 쓰기를 기다리지 않는다. 커밋 시점에 실제로 쓰이는 위치는 메모리와 순차 로그뿐이고, 데이터 파일은 나중에 일괄로 동기화한다.

구성 요소역할
Buffer Pool디스크 페이지를 메모리에 캐시하고, 변경을 우선 메모리에서 처리한다
Redo Log변경 내역을 순차로 기록해 장애 시 복구를 보장한다
체크포인트(Checkpoint)어디까지 데이터 파일에 반영됐는지 표시해 Redo Log를 안전하게 재사용하게 한다

Buffer Pool의 페이지 관리: Free List와 Flush List

버퍼 풀은 InnoDB 메모리 영역 중 가장 큰 비중을 차지하며, 디스크의 데이터 페이지와 인덱스 페이지를 캐시하는 공간이다. 기본 크기는 128MB지만, 운영 서버에서는 보통 물리 메모리의 50~75%까지 할당한다.

[mysqld]
innodb_buffer_pool_size = 8G
innodb_buffer_pool_instances = 8   # 동시성을 위한 분할. 풀이 1GB 미만이면 1로 강제된다
innodb_page_size = 16384           # 16KB. 인스턴스 초기화 시에만 정해지고 이후에는 바꿀 수 없다

버퍼 풀은 16KB(innodb_page_size) 단위의 페이지로 나뉘고, Free List와 LRU 리스트, Flush List 세 개의 목록이 그 페이지들을 관리한다.

Free List는 비어 있는 페이지의 목록이다. 새 디스크 페이지를 메모리에 올릴 때 이 목록에서 빈 페이지를 받는다. 운영 초기에는 Free List가 넉넉하지만, 버퍼 풀이 가득 차면 LRU(Least Recently Used) 리스트의 오래된 페이지를 밀어내며 공간을 확보한다.

Flush List는 반대쪽이다. 데이터가 바뀌면 버퍼 풀의 페이지에서 데이터는 디스크와 다른 상태가 되는데, 이를 더티 페이지(Dirty Page)라 부르고 그 목록이 Flush List다. 목록은 페이지가 처음 더티가 된 시점(oldest_modification) 순으로 정렬되고, 백그라운드 스레드가 가장 오래된 더티 페이지부터 디스크에 동기화한다.

sequenceDiagram
    autonumber
    participant App
    participant BP as Buffer Pool
    participant FL as Flush List
    participant Redo as Redo Log
    participant Disk as Data File

    App->>BP: UPDATE 발생
    BP->>BP: 페이지를 메모리에서 수정(Dirty Page)
    BP->>FL: 더티 페이지 등록
    BP->>Redo: 변경 내역 로그 기록(순차 쓰기)
    App->>BP: COMMIT
    Note over App,Redo: 클라이언트 응답. 이 시점엔 데이터 파일을 아직 건드리지 않는다

    Note over FL,Disk: 백그라운드(체크포인트 또는 시간 경과)
    FL->>Disk: 더티 페이지를 디스크에 플러시
    FL->>FL: 플러시된 페이지를 리스트에서 제거

Free List는 새 페이지가 들어올 자리의 목록이고, Flush List는 아직 디스크와 다른 더티 페이지의 목록이다. 두 목록이 갈라서 있기 때문에 InnoDB는 새 페이지를 받는 일과 더티 페이지를 내려보내는 일을 서로 방해하지 않고 동시에 진행할 수 있다.

LRU 리스트의 Young/Old 분리와 Adaptive Hash Index

버퍼 풀이 가득 차면 어떤 페이지를 밀어낼지 정해야 한다. InnoDB는 단순 LRU가 아니라 두 영역으로 나뉜 LRU를 쓴다.

영역위치역할
Young 영역MRU 측, 기본 5/8자주 쓰이는 핫(hot) 페이지가 머무는 곳
Old 영역LRU 측, 기본 3/8새로 읽은 페이지가 처음 들어오는 곳

핵심 정책은 새 페이지가 곧바로 Young으로 가지 않고 Old의 헤드에 먼저 들어간다는 것이다. 일정 시간(innodb_old_blocks_time, 기본 1000ms) 뒤에 다시 접근되어야만 Young으로 승급한다.

innodb_old_blocks_pct  = 37      # Old 영역 비율(37% = 3/8)
innodb_old_blocks_time = 1000    # Old에서 Young으로 가는 데 필요한 ms

가령 배치가 100기가바이트 테이블을 훑고 지나가면 핫 데이터는 다 밀려날까? InnoDB는 이 상황을 막기 위해 Old 영역을 따로 둔다. 풀스캔으로 읽힌 페이지는 Old 헤드에 쌓일 뿐 1초 안에 다시 접근되는 일이 드물어 Young으로 올라오지 못한다.

Young으로 승급할 수 있는 조건은 접근 횟수가 아니라 재접근이라는 점이다. LRU의 Young/Old 분리는 최근에 읽었는가가 아니라 시간을 두고 다시 찾는가로 핫 데이터를 가린다.

Adaptive Hash Index

자주 접근되는 페이지의 인덱스 키는 InnoDB가 자동으로 적응형 해시 인덱스(Adaptive Hash Index)를 만들어 메모리 상에서 거의 O(1)에 가까운 검색을 제공한다. 별도 설정 없이 동작하며, 워크로드에 따라 끄는 편이 나을 때도 있다(innodb_adaptive_hash_index = OFF). OLAP(Online Analytical Processing)성 대량 스캔이 잦은 서버에서는 이 인덱스가 오히려 유지 비용만 늘리므로, 그런 워크로드는 별도 리플리카로 분리하고 끄는 것이 나을 수 있다.

Redo Log의 구조: 순차 쓰기와 순환

리두 로그는 InnoDB의 내구성(Durability)을 보장하는 핵심 메커니즘이다. 모든 변경은 데이터 파일에 반영되기 전에 리두 로그에 먼저 기록된다.

innodb_redo_log_capacity = 4G          # 8.0.30+ (총 용량, 기본값 100M)
 
# 구버전 호환
innodb_log_file_size      = 1G         # 파일 1개 크기
innodb_log_files_in_group = 4          # 파일 개수 → 총 4GB

리두 로그는 트랜잭션이 끝부분에만 append하는 순차 쓰기 파일이고, 마지막 파일까지 쓰면 다시 첫 파일로 돌아가 덮어쓰는 순환 파일이다. 순환 구조의 부작용은 덮어쓰기가 가능하다는 것이다. 아직 데이터 파일에 반영되지 않은 변경 내역이 덮어써지면 그 변경은 복구가 불가능해진다.

리두 로그의 순환 구조에선 이를 방지하기 위해 체크포인트가 쓰인다. 체크포인트가 이 LSN 이전은 데이터 파일에 이미 반영됐다고 표시해야 그 지점 이전의 로그만 안전하게 덮어쓸 수 있다.

InnoDB는 모든 로그 항목에 단조 증가하는 일련번호 LSN(Log Sequence Number)을 붙인다. 페이지 헤더에는 그 페이지가 마지막으로 변경된 시점의 LSN이 기록되고, 체크포인트는 어디까지 데이터 파일에 반영했는지를 LSN으로 표시한다. 장애 복구 시에는 체크포인트 LSN 이후의 로그만 다시 적용해서 메모리를 크래시 직전 상태로 되돌린다. 복구가 이 LSN 표식 하나에 의존하는 셈이라, 체크포인트가 정확히 어디를 가리키는지가 곧 복구 범위를 정한다.

innodb_flush_log_at_trx_commit

커밋 시점에 정확히 무슨 일이 일어나는지는 아래 순서로 요약된다.

sequenceDiagram
    autonumber
    participant Trx
    participant LogBuf as log buffer(메모리)
    participant LogFile as Redo Log 파일(디스크)
    participant BP as Buffer Pool

    Trx->>BP: 페이지 변경(Dirty)
    Trx->>LogBuf: 변경 내역을 log buffer에 기록
    Trx->>Trx: COMMIT 요청
    Trx->>LogBuf: 트랜잭션 끝났음을 표시
    LogBuf->>LogFile: fsync(디스크에 강제 동기화)
    LogFile-->>Trx: ACK
    Trx-->>Client: COMMIT 응답

커밋 시점에 발생하는 것은 log buffer를 디스크 리두 로그 파일에 fsync하는 작업뿐이고, 데이터 파일은 건드리지 않는다. innodb_flush_log_at_trx_commit은 이 fsync를 얼마나 엄격하게 할지 정하는 값이고, 내구성과 성능의 트레이드오프를 직접 조절하는 가장 중요한 파라미터다.

동작내구성성능
01초마다 log buffer를 디스크에 flush. 커밋 자체는 디스크 보장 안 함낮음(1초치 손실 가능)가장 빠름
1(기본)매 커밋마다 log buffer를 디스크에 flush + fsync가장 높음(ACID 보장)가장 느림
2매 커밋마다 log buffer를 OS 캐시에 write. fsync는 1초마다중간(OS 크래시 시 손실)중간
innodb_flush_log_at_trx_commit = 1   # 운영은 기본값 1을 유지하는 것이 안전하다

1로 두는 것이 ACID(Atomicity, Consistency, Isolation, Durability)의 D(Durability)를 만족하는 유일한 값이다. 다만 이 값 하나로 끝나지는 않는다. 복제나 PITR(Point In Time Recovery)까지 보면 바이너리 로그도 매 커밋 fsync해야 하므로 sync_binlog = 1을 함께 둬야 한다.

그러면 왜 굳이 이 값을 그대로 두라고 하는가? 결제, 주문처럼 유실을 허용할 수 없는 트랜잭션은 1을 유지해야 응답이 된 후에도 그 커밋이 디스크에 살아 있다는 것을 보장받는다. 배치나 캐시성 데이터베이스처럼 약간의 유실을 허용할 수 있는 워크로드만 2나 0으로 낮춰 처리량을 올리는 판단을 한다.

체크포인트: Redo Log를 재활용하는 경계

체크포인트(Checkpoint)는 이 LSN 이전의 값들은 데이터 파일에 이미 반영됐다고 표시하는 경계선이다. 체크포인트가 진행되려면 Flush List의 더티 페이지들이 먼저 디스크에 동기화되어야 한다.

구분발생 시점동작
Sharp Checkpoint셧다운 시모든 더티 페이지를 한 번에 디스크에 동기화한다
Fuzzy Checkpoint운영 중백그라운드 스레드가 더티 페이지를 조금씩 디스크로 내려보낸다

리두 로그 용량이 크면 체크포인트가 밀려도 재사용 가능 구간에 여유가 있으니 당장은 안전해 보인다. 다만 장애가 났을 때 체크포인트 이후 쌓인 로그 전체를 다시 적용해야 하므로 복구 시간이 그만큼 늘어난다.

반대로 로그가 너무 작으면 체크포인트가 자주 강제로 발생해 런타임 성능이 떨어진다. 리두 로그 크기는 복구 시간과 체크포인트 빈도 사이의 트레이드오프를 담은 값이다.

Cleaner Thread

innodb_page_cleaners 설정이 더티 페이지를 디스크에 동기화하는 백그라운드 스레드 개수를 정한다.

innodb_page_cleaners = 4

Cleaner Thread는 가장 오래된 더티 페이지부터 잡아 디스크에 쓴다. 너무 적으면 더티 페이지가 쌓여 체크포인트가 밀리고, 너무 많으면 CPU와 디스크 부하가 늘어난다.

쓰기 폭증과 더블 라이트 버퍼

더티 페이지가 너무 많이 쌓이면 한 번에 디스크에 쓰느라 CPU와 디스크 I/O가 급증하는 쓰기 폭증(Write Spike) 현상이 발생한다. 리두 로그 공간이 부족해질 때 큰 체크포인트가 일어나면 응답 시간이 폭발적으로 늘어난다. 이를 막는 방법은 사전 분산 플러시다. 더티 페이지가 일정 비율을 넘으면 Cleaner Thread가 미리 조금씩 디스크에 쓰기 시작한다.

innodb_max_dirty_pages_pct     = 90    # 더티 페이지 최대 비율
innodb_max_dirty_pages_pct_lwm = 10    # 이 값을 넘으면 사전 플러시를 시작한다
innodb_io_capacity             = 2000  # 백그라운드 I/O 처리량 추정치(IOPS)
innodb_io_capacity_max         = 4000  # 폭증 시 최대치

InnoDB는 페이지를 디스크에 쓸 때 페이지 일부만 기록되고 끊기는 partial write를 막기 위해 더블 라이트 버퍼(Double Write Buffer)라는 추가 영역을 쓴다.

  1. 더티 페이지를 먼저 더블 라이트 버퍼에 순차로 기록한다. MySQL 8.0.20부터 이 영역은 데이터 디렉터리에 놓인 별도 파일(#ib_16384_0.dblwr)이고, 그 이전에는 시스템 테이블스페이스 안에 있었다.
  2. 그다음 원래 위치의 데이터 파일에 쓴다.
  3. 데이터 파일 쓰기 도중 크래시가 나면, 더블 라이트 버퍼의 온전한 사본으로 그 페이지를 복구한다.

더블 라이트 버퍼는 페이지 쓰기 중 크래시가 나도 온전한 사본으로 복구할 수 있게 하는 이중 기록이다. 쓰기 비용이 두 배가 되지만 페이지 무결성을 지킨다.

innodb_doublewrite_dir   = /var/lib/mysql-dblwr   # 8.0.20+. 다른 디스크로 떼어 놓을 수 있다
innodb_doublewrite_files = 4                      # 버퍼 풀 인스턴스마다 flush/LRU 각 1개가 기본

파일이 시스템 테이블스페이스에서 떨어져 나온 덕에 리두 로그와 다른 디스크에 배치해 쓰기 부하를 분산할 수 있다. 비활성화(innodb_doublewrite = OFF)는 스토리지가 자체적으로 원자적 쓰기를 보장하는 환경이 아니라면 권하지 않는다.

장애 복구

서버가 갑자기 죽었을 때 InnoDB는 다음 순서로 복구한다.

sequenceDiagram
    autonumber
    participant Recovery as InnoDB Recovery
    participant Redo as Redo Log
    participant BP as Buffer Pool
    participant Disk as Data File
    participant Undo as Undo Log

    Recovery->>Redo: 마지막 체크포인트 LSN 찾기
    Recovery->>Redo: 체크포인트 LSN 이후 로그 읽기
    loop 각 로그 엔트리
        Recovery->>Disk: 대상 페이지를 버퍼 풀로 읽어 올림
        Recovery->>BP: 버퍼 풀의 페이지에 변경 재적용(REDO)
    end
    Note over Recovery,BP: 버퍼 풀이 크래시 직전 상태로 복원됨. 데이터 파일은 아직 그대로다

    Recovery->>Undo: 미완료 트랜잭션 찾기
    loop 미완료 트랜잭션
        Recovery->>BP: undo log로 변경 되돌리기(ROLLBACK)
    end
    BP->>Disk: 이후 체크포인트가 더티 페이지를 플러시
    Recovery->>Recovery: 서비스 복구 완료

데이터 파일이 부분적으로 손상되어도 리두 로그와 백업이 있으면 복구할 수 있다는 것이 WAL 구조가 주는 이점이다. 백업과 리두 로그를 별도 디스크에 두면 SPOF(Single Point Of Failure)를 줄일 수 있고, 리두 로그 디스크의 IOPS가 전체 처리량을 좌우하는 경우가 많으므로 가능한 한 빠른 디스크(NVMe SSD)를 할당하는 편이 효과적이다.

이 구조 위에서 트랜잭션을 어떻게 여닫는지가 곧 버퍼 풀과 리두 로그를 얼마나 건강하게 쓰는지를 정한다. 트랜잭션이 길어지면 그만큼 더티 페이지가 오래 쌓이고, undo log가 길어져 MVCC(Multi-Version Concurrency Control) 가시성 추적 비용도 늘어난다. 아래는 같은 배치 작업을 트랜잭션 하나에 몰아넣었을 때와, 청크 단위로 나눠 커밋했을 때의 설정 차이다.

# as-is: 기본값 그대로 둔 설정. 대량 배치가 몰리는 운영 트래픽에서 병목이 된다
innodb_buffer_pool_size        = 128M   # 기본값. 물리 메모리를 거의 못 쓴다
innodb_buffer_pool_instances   = 1      # 풀이 1GB 미만이라 자동으로 1로 강제된다
innodb_redo_log_capacity       = 100M   # 기본값. 큰 배치 한 건에도 금방 체크포인트가 밀린다
innodb_flush_log_at_trx_commit = 1      # 값 자체는 그대로 둔다
 
# to-be: 워크로드를 보고 늘린 설정. 트랜잭션은 여전히 짧게 쪼갠다
innodb_buffer_pool_size        = 32G    # 물리 메모리의 50~75%
innodb_buffer_pool_instances   = 8      # 인스턴스당 4G. 하나가 1G 이상 되게 맞춰 락 경합을 줄인다
innodb_redo_log_capacity       = 8G     # 1시간치 변경량을 담고도 여유가 있게
innodb_flush_log_at_trx_commit = 1      # 결제, 주문처럼 유실을 허용할 수 없다면 그대로 유지

버퍼 풀과 리두 로그를 키우는 것은 증상을 늦출 뿐이고, 근본적인 해법은 트랜잭션을 짧게 쪼개 더티 페이지와 로그가 한 번에 쌓이지 않게 하는 것이다.

상황선택근거
결제, 주문처럼 유실을 허용할 수 없는 트랜잭션innodb_flush_log_at_trx_commit = 1 유지매 커밋 fsync만이 ACID의 D를 보장한다
배치나 캐시성 데이터베이스처럼 약간의 유실을 허용innodb_flush_log_at_trx_commit을 2나 0으로 낮춘다처리량이 우선이고 손실 범위가 제한적이다
리두 로그 크기를 정해야 한다1시간당 LSN 증가량을 측정해 그 값보다 여유 있게 잡는다너무 작으면 체크포인트 폭주, 너무 크면 복구 지연
대량 배치를 트랜잭션 하나에 몰아넣고 싶다청크 단위로 쪼개 커밋한다더티 페이지와 undo log가 한 번에 쌓이지 않는다
버퍼 풀에 워킹 셋이 들어가지 않는다버퍼 풀을 늘리거나 불필요한 인덱스를 정리한다워킹 셋이 메모리에 다 들어가야 히트율이 유지된다