1편에서 MySQL 서버 계층이 쿼리를 어떻게 파싱하고 실행 계획을 수립하는지 살펴봤습니다. 이번 글에서는 그 아래, 실제로 데이터를 저장하고 관리하는 InnoDB 스토리지 엔진의 내부 구조를 살펴봅니다.
InnoDB를 이해하는 핵심은 한 가지 질문으로 요약됩니다. "디스크 I/O를 어떻게 줄이면서, 동시에 데이터 안전성과 동시성을 어떻게 보장하는가?" 아래에서 살펴볼 모든 컴포넌트는 이 세 가지 목표 중 하나, 혹은 그 이상을 위해 존재합니다.
1. 버퍼 풀 (Buffer Pool)
목적: 디스크 I/O 최소화
InnoDB의 가장 핵심적인 메모리 구조입니다. 디스크에서 읽은 데이터 페이지와 인덱스 페이지를 메모리에 캐싱해, 동일한 데이터를 다시 읽을 때 디스크 I/O 없이 메모리에서 바로 반환합니다.
쓰기도 마찬가지입니다. InnoDB는 데이터를 변경할 때 디스크에 바로 쓰지 않고, 버퍼 풀의 페이지를 먼저 수정합니다. 이렇게 디스크와 내용이 달라진 페이지를 더티 페이지(Dirty Page)라고 합니다. 더티 페이지는 이후 백그라운드 스레드(페이지 클리너)가 적절한 시점에 디스크로 내려씁니다(플러시).
버퍼 풀의 크기는 InnoDB 성능에 가장 직접적인 영향을 줍니다. 버퍼 풀이 클수록 디스크 I/O가 줄고 캐시 히트율이 높아집니다. 일반적으로 전용 DB 서버라면 물리 메모리의 70~80%를 버퍼 풀에 할당하는 것이 권장됩니다(innodb_buffer_pool_size).
페이지 교체 — LRU와 미드포인트 인서션
버퍼 풀이 가득 찼을 때 새 페이지를 적재하려면 기존 페이지를 내보내야 합니다. InnoDB는 LRU(Least Recently Used) 알고리즘을 사용하지만, 단순한 LRU가 아닌 미드포인트 인서션(Midpoint Insertion) 방식을 씁니다.
일반적인 LRU는 새 페이지를 리스트 맨 앞(MRU 쪽)에 삽입합니다. 그런데 풀 테이블 스캔처럼 대량의 페이지를 한 번만 읽고 버리는 경우, 자주 쓰던 페이지들이 뒤로 밀려나는 문제가 생깁니다. InnoDB는 이를 방지하기 위해 새 페이지를 리스트 중간(기본 3/8 지점)에 삽입합니다. 이후 실제로 다시 접근되면 그때 앞쪽으로 승격됩니다. 한 번만 읽히는 페이지는 중간에 머물다 자연스럽게 제거됩니다.

2. 클러스터링 인덱스 (Clustered Index)
목적: 기본키 기반 데이터 접근 최적화
InnoDB는 모든 테이블을 클러스터링 인덱스 구조로 저장합니다. 일반적인 인덱스가 "인덱스 → 실제 데이터 위치(포인터)"를 저장하는 것과 달리, 클러스터링 인덱스는 인덱스 자체에 행 데이터 전체를 함께 저장합니다. B-Tree의 리프 노드가 곧 실제 데이터 페이지입니다.
InnoDB가 클러스터링 인덱스의 기준 컬럼을 결정하는 순서는 다음과 같습니다.
- 기본키(PRIMARY KEY)가 있으면 그것을 클러스터링 인덱스로 사용합니다.
- 기본키가 없으면 NOT NULL인 UNIQUE 인덱스 중 첫 번째를 사용합니다.
- 위 둘 다 없으면 InnoDB가 내부적으로 숨겨진 6바이트 rowid를 생성해 사용합니다.
클러스터링 인덱스의 특성상 기본키로 조회하면 한 번의 B-Tree 탐색만으로 행 데이터까지 바로 읽을 수 있습니다. 반면 보조 인덱스(Secondary Index)는 리프 노드에 행 데이터 대신 기본키 값을 저장합니다. 보조 인덱스로 조회하면 먼저 보조 인덱스에서 기본키를 찾고, 다시 클러스터링 인덱스를 탐색해 실제 데이터를 가져옵니다. 이를 더블 룩업(Double Lookup)이라고 합니다.
이 구조에서 기본키 설계가 중요한 이유가 나옵니다. 기본키가 단조 증가하는 값(AUTO_INCREMENT 등)이면 새 행이 항상 B-Tree의 오른쪽 끝에 추가되어 페이지 분할이 최소화됩니다. 반면 UUID처럼 무작위 값을 기본키로 쓰면 새 행이 트리 곳곳에 삽입되어 페이지 분할과 단편화가 빈번하게 발생합니다.

3. Change Buffer
목적: 디스크 I/O 최소화
보조 인덱스(Secondary Index) 페이지를 변경할 때, 해당 페이지가 버퍼 풀에 없으면 디스크에서 읽어와야 합니다. 쓰기를 위해 읽기가 먼저 발생하는 셈입니다. Change Buffer는 이 불필요한 읽기 I/O를 뒤로 미루는 장치입니다.
보조 인덱스 페이지가 버퍼 풀에 없는 상태에서 INSERT/UPDATE/DELETE가 발생하면, InnoDB는 디스크에서 페이지를 즉시 읽지 않고 변경 내용을 Change Buffer에 임시로 기록합니다. 이후 해당 인덱스 페이지가 다른 이유로 버퍼 풀에 올라오면, 그때 Change Buffer의 내용을 병합(merge)합니다.
Change Buffer가 효과적인 상황은 보조 인덱스가 많고 쓰기가 빈번하며, 같은 인덱스 페이지가 반복해서 읽히지 않는 경우입니다. 반대로 읽기가 많아 대부분의 페이지가 이미 버퍼 풀에 있다면 Change Buffer의 효과는 미미합니다.
주의: Change Buffer는 보조 인덱스에만 적용됩니다. 클러스터링 인덱스는 행 전체를 담고 있어 수정 시 해당 페이지를 반드시 버퍼 풀에 올려야 하므로, Change Buffer를 통한 지연 없이 버퍼 풀에서 직접 수정됩니다.
4. 어댑티브 해시 인덱스 (Adaptive Hash Index)
목적: 반복 조회 성능 향상
InnoDB는 기본적으로 B-Tree 인덱스를 사용합니다. B-Tree는 범위 검색에 유리하지만, 동일한 값을 반복해서 조회할 때마다 트리를 루트부터 내려가는 탐색 비용이 발생합니다.
어댑티브 해시 인덱스(AHI)는 InnoDB가 자주 조회되는 인덱스 패턴을 감지해, 자동으로 해시 인덱스를 메모리에 생성하는 기능입니다. 해시 조회는 O(1)이므로, B-Tree 탐색(O(log n))보다 빠릅니다.
'어댑티브'라는 이름처럼 사용자가 직접 설정하지 않아도 됩니다. InnoDB가 워크로드를 모니터링하다가 특정 인덱스 접근이 반복되면 자동으로 생성하고, 불필요해지면 제거합니다.
다만 AHI가 항상 도움이 되는 건 아닙니다. 해시 인덱스를 관리하는 비용이 있고, 동시성이 높은 환경에서는 AHI 내부 잠금이 오히려 병목이 될 수 있습니다. 이런 경우 innodb_adaptive_hash_index=OFF로 비활성화를 검토할 수 있습니다.
5. 리두 로그와 언두 로그
목적: 데이터 안전성
InnoDB의 데이터 안전성은 두 종류의 로그 위에서 작동합니다.
5-1. 리두 로그 (Redo Log) — 장애 복구
버퍼 풀에서 살펴봤듯, InnoDB는 변경 사항을 디스크에 즉시 쓰지 않습니다. 더티 페이지는 나중에 플러시됩니다. 그렇다면 플러시 전에 서버가 비정상 종료되면 어떻게 될까요?
이를 대비하는 것이 리두 로그입니다. InnoDB는 데이터를 변경할 때 버퍼 풀을 수정하는 동시에, 변경 내용을 리두 로그에 기록합니다. 리두 로그는 순차 쓰기이기 때문에 랜덤 I/O인 데이터 페이지 플러시보다 훨씬 빠릅니다.
서버가 비정상 종료된 후 재시작하면, InnoDB는 리두 로그를 읽어 아직 디스크에 반영되지 않은 변경 사항을 복구합니다. 이 과정을 크래시 복구(Crash Recovery)라고 합니다.
리두 로그의 내구성은 innodb_flush_log_at_trx_commit 설정으로 제어합니다.
| 설정값 | 동작 | 데이터 손실 위험 |
| 1 (기본값) | 커밋마다 리두 로그를 디스크에 fsync | 없음. ACID 완전 보장 |
| 2 | 커밋마다 OS 버퍼에 기록, 1초마다 fsync | OS 자체가 크래시된 경우 최대 1초 |
| 0 | 1초마다 버퍼 기록 및 fsync | MySQL 프로세스 크래시만으로도 최대 1초 |
설정값 1이 가장 안전하지만 fsync 빈도가 높아 쓰기 성능이 낮습니다. 2는 MySQL 프로세스가 죽어도 OS가 살아있으면 데이터를 보존할 수 있어, 성능과 안전성의 절충점으로 많이 사용됩니다.
5-2. LSN (Log Sequence Number)
리두 로그와 버퍼 풀을 연결하는 핵심 개념이 LSN(Log Sequence Number)입니다. LSN은 리두 로그가 시작된 이후 누적된 변경량을 바이트 단위로 나타내는 단조 증가 숫자로, 새로운 변경이 기록될 때마다 증가합니다.
InnoDB는 LSN을 두 곳에서 관리합니다. 리두 로그 자체에는 지금까지 기록된 변경의 끝 지점을 나타내는 LSN이 있고, 버퍼 풀의 각 더티 페이지에는 그 페이지가 마지막으로 수정됐을 때의 LSN이 붙어 있습니다. 이 두 값을 비교하면 어떤 페이지가 아직 디스크에 반영되지 않았는지 정확히 알 수 있습니다.
페이지 클리너가 플러시 대상을 선별할 때도, 크래시 복구 시 리두 로그를 어느 지점부터 재적용할지 결정할 때도 LSN이 기준입니다. 뒤에서 살펴볼 더블 라이트 버퍼 복구 과정에서 "리두 로그를 적용한다"는 것도, 손상된 페이지의 LSN 이후에 기록된 항목들을 순서대로 재실행한다는 의미입니다.
5-3. 언두 로그 (Undo Log) — 롤백과 MVCC
언두 로그는 데이터 변경 전의 이전 값을 기록합니다. 두 가지 목적으로 사용됩니다.
롤백: 트랜잭션이 취소되면 언두 로그를 이용해 변경 전 상태로 되돌립니다.
MVCC: 다른 트랜잭션이 변경 중인 데이터를 읽을 때, 언두 로그에서 변경 이전 버전의 데이터를 읽어 반환합니다. 덕분에 읽기 트랜잭션이 쓰기 트랜잭션을 기다리지 않아도 됩니다. 자세한 동작은 다음 섹션에서 살펴봅니다.

6. MVCC와 트랜잭션 격리 수준
6-1. MVCC란
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 데이터의 여러 버전을 동시에 유지해, 읽기와 쓰기가 서로를 차단하지 않도록 하는 메커니즘입니다.
InnoDB는 행을 변경할 때 이전 값을 언두 로그에 보관합니다. 이로써 하나의 행에 대해 여러 버전이 존재하게 됩니다. 각 트랜잭션은 시작 시점에 스냅샷(Snapshot)을 찍고, 이후 다른 트랜잭션이 데이터를 변경하더라도 언두 로그를 통해 자신의 스냅샷 기준의 데이터를 읽을 수 있습니다.
이를 잠금 없는 읽기(Non-Locking Consistent Read)라고 합니다. 읽기가 쓰기 잠금을 기다리지 않아도 되므로, InnoDB가 높은 동시성을 달성하는 핵심 이유입니다.
그렇다면 스냅샷을 언제 찍느냐에 따라 동작이 달라집니다. 이것이 바로 트랜잭션 격리 수준의 차이입니다.
6-2. READ COMMITTED vs REPEATABLE READ
InnoDB의 기본 격리 수준은 REPEATABLE READ입니다.
READ COMMITTED: 트랜잭션 내에서 SELECT를 실행할 때마다 그 시점의 새 스냅샷을 생성합니다. 같은 트랜잭션 안에서도 다른 트랜잭션이 커밋한 변경 사항이 이후 조회에 반영됩니다. 이를 Non-Repeatable Read(반복 불가 읽기)라고 합니다. 항상 최신 커밋 데이터를 보고 싶을 때 적합합니다.
REPEATABLE READ: 트랜잭션이 시작될 때 스냅샷을 한 번 생성하고, 트랜잭션이 끝날 때까지 그 스냅샷을 유지합니다. 트랜잭션 도중 다른 트랜잭션이 데이터를 변경하고 커밋해도, 현재 트랜잭션은 시작 시점의 데이터를 계속 읽습니다. 같은 쿼리를 반복 실행해도 결과가 동일하게 보장됩니다.
| READ COMMITTED | REPEATABLE READ | |
| 스냅샷 생성 시점 | SELECT마다 새로 생성 | 트랜잭션 시작 시 한 번 생성 |
| 다른 트랜잭션의 커밋 반영 | 즉시 반영 | 반영 안 됨 |
| Non-Repeatable Read | 발생 | 발생 안 됨 |
| 주요 사용처 | 최신 데이터가 중요한 경우 | 일관된 읽기가 중요한 경우 |
두 격리 수준 모두 MVCC 위에서 동작하므로, 읽기가 쓰기 잠금을 기다리지 않는다는 점은 동일합니다. 차이는 오직 언제 찍은 스냅샷을 보는가입니다.

7. 더블 라이트 버퍼 (Doublewrite Buffer)
목적: 데이터 안전성
InnoDB의 페이지 크기는 기본 16KB입니다. 그런데 OS가 디스크에 데이터를 쓰는 단위는 보통 4KB입니다. 즉, 하나의 InnoDB 페이지를 디스크에 쓰려면 OS 레벨에서 네 번의 쓰기가 필요합니다.
만약 이 과정 중에 서버가 비정상 종료된다면, 페이지 일부만 기록된 부분 쓰기(Partial Write) 상태가 됩니다. 리두 로그는 온전한 페이지를 기준으로 변경 사항을 덧씌우는 방식으로 복구하는데, 페이지 자체가 깨져 있으면 리두 로그로도 복구할 수 없습니다.
더블 라이트 버퍼는 이 문제를 해결합니다. InnoDB는 더티 페이지를 실제 데이터 파일에 쓰기 전에, 더블 라이트 버퍼 파일에 먼저 순차적으로 씁니다. 이후 실제 데이터 파일 위치에 씁니다. (MySQL 8.0.20부터 더블 라이트 버퍼는 시스템 테이블스페이스에서 독립된 별도 파일로 분리되었습니다.)
크래시 복구 시 InnoDB는 더블 라이트 버퍼 파일과 실제 데이터 파일을 비교합니다. 데이터 파일의 페이지가 손상돼 있다면, 더블 라이트 버퍼의 온전한 사본으로 복구한 뒤 해당 페이지의 LSN 이후 리두 로그를 재적용합니다.
쓰기가 두 번 발생하지만, 더블 라이트 버퍼 쓰기는 순차 I/O이기 때문에 실제 성능 부담은 크지 않습니다. 데이터 무결성을 위한 합리적인 트레이드오프입니다.
8. 잠금과 자동 데드락 감지
목적: 동시성 제어
MVCC는 읽기와 쓰기 사이의 충돌을 방지하지만, 쓰기와 쓰기 사이의 충돌은 잠금(Lock)으로 제어합니다.
8-1. 행 수준 잠금 (Row-Level Lock)
InnoDB는 행 수준 잠금을 지원합니다. MyISAM이 테이블 전체를 잠그는 것과 달리, InnoDB는 실제로 접근하는 행에만 잠금을 겁니다. 덕분에 서로 다른 행을 수정하는 트랜잭션은 서로를 기다리지 않고 동시에 진행될 수 있습니다.
InnoDB의 대표적인 잠금 유형은 다음과 같습니다.
| 잠금 유형 | 설명 |
| 레코드 락 (Record Lock) | 인덱스 레코드 하나에 거는 잠금 |
| 갭 락 (Gap Lock) | 인덱스 레코드 사이의 빈 공간에 거는 잠금. 새 행 삽입을 방지 |
| 넥스트 키 락 (Next-Key Lock) | 레코드 락 + 갭 락의 조합. REPEATABLE READ에서 Phantom Read(조건을 만족하는 행 수가 트랜잭션 도중 달라지는 현상)를 방지 |
8-2. 자동 데드락 감지
두 트랜잭션이 서로 상대방이 잡고 있는 잠금을 기다리면 데드락(Deadlock)이 발생합니다. 예를 들어 트랜잭션 A가 행 1을 잠근 채 행 2를 기다리고, 트랜잭션 B가 행 2를 잠근 채 행 1을 기다리는 상황입니다.
InnoDB는 자동 데드락 감지(Automatic Deadlock Detection) 기능을 내장하고 있습니다. 별도 설정 없이 백그라운드에서 잠금 대기 그래프를 주기적으로 확인하다가 순환 대기를 감지하면, 가장 적은 비용으로 롤백할 수 있는 트랜잭션을 희생자(Victim)로 선정해 자동으로 롤백합니다. 희생된 트랜잭션은 아래 오류를 받습니다.
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction
이 오류를 받은 트랜잭션은 애플리케이션 레벨에서 재시도 로직을 구현하는 것이 일반적입니다. 데드락 자체를 피하려면 여러 행에 접근하는 트랜잭션에서 항상 일정한 순서로 잠금을 획득하도록 설계하는 것이 중요합니다.
정리
InnoDB의 각 컴포넌트는 "I/O 최소화", "데이터 안전성", "동시성 제어" 세 축 위에 놓여 있습니다.
| 컴포넌트 | 목적 |
| 버퍼 풀 | I/O 최소화 — 데이터/인덱스 페이지를 메모리에 캐싱 |
| 클러스터링 인덱스 | 기본키 기반 단일 탐색으로 행 데이터 직접 접근 |
| Change Buffer | I/O 최소화 — 보조 인덱스 변경을 지연 병합 |
| 어댑티브 해시 인덱스 | 성능 향상 — 반복 조회 패턴에 자동으로 해시 인덱스 생성 |
| 리두 로그 + LSN | 데이터 안전성 — 커밋된 변경 사항의 장애 복구 보장 |
| 언두 로그 | 롤백 + MVCC 기반 잠금 없는 읽기 지원 |
| 더블 라이트 버퍼 | 데이터 안전성 — 부분 쓰기로 인한 페이지 손상 방지 |
| 행 수준 잠금 / 데드락 감지 | 동시성 제어 — 쓰기 충돌 방지 및 교착 상태 자동 해소 |
그리고 MVCC는 이 구조 전체를 관통하는 동시성 메커니즘입니다. 언두 로그에 쌓인 이전 버전 데이터 덕분에, 읽기와 쓰기가 서로를 차단하지 않고 높은 동시성을 달성합니다.
이전 글: [MySQL 아키텍처 1편 — 엔진 아키텍처]
'Data > MySQL' 카테고리의 다른 글
| [MySQL] B-Tree 인덱스 완전 해부 — 구조부터 가용성까지 (1) | 2026.06.07 |
|---|---|
| [MySQL] 트랜잭션과 잠금 2편 — 격리 수준과 MVCC (0) | 2026.06.07 |
| [MySQL] 트랜잭션과 잠금 1편 — 락 메커니즘 (0) | 2026.06.07 |
| [MySQL] 아키텍처 1편 — 엔진 아키텍처 (0) | 2026.06.07 |