홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

데이터는 디스크에 어떻게 저장되는가 — InnoDB 스토리지 엔진의 내부 구조 (1편)

정기창·2026년 3월 28일

들어가며

MySQL에서 INSERT INTO users (name) VALUES ('홍길동')을 실행하면, 이 데이터는 어디에 저장될까? "디스크에 저장된다"는 건 알지만, 정확히 어떤 형태로, 어떤 과정을 거쳐 저장되는지 설명하기는 쉽지 않습니다.

면접에서 "InnoDB의 데이터 저장 구조를 설명해보세요"라는 질문을 받으면, 단순히 "B+Tree"라고 답하는 것만으로는 부족합니다. 페이지 단위로 데이터를 관리하고, 메모리에서 먼저 처리한 뒤 디스크에 반영하는 전체 흐름을 이해해야 합니다.

이 글에서는 InnoDB가 데이터를 물리적으로 어떻게 조직하고, 디스크 I/O를 최소화하면서도 데이터 무결성을 보장하는 구조를 바닥부터 추적합니다.

MySQL 아키텍처의 두 계층

MySQL은 크게 서버 계층과 스토리지 엔진 계층으로 나뉩니다.

  • 서버 계층: SQL 파싱, 옵티마이저, 커넥션 관리 — "무엇을 할지" 결정
  • 스토리지 엔진 계층: 실제 데이터 읽기/쓰기 — "어떻게 저장할지" 담당

InnoDB는 MySQL 5.5부터 기본 스토리지 엔진입니다. MyISAM과 달리 트랜잭션, 행 수준 잠금, 외래 키를 지원하며, 현재 MySQL에서 사실상 유일한 선택지입니다.

왜 스토리지 엔진이 중요한가

같은 SQL을 실행해도 스토리지 엔진에 따라 동작이 완전히 다릅니다. 트랜잭션 지원 여부, 잠금 단위(테이블 vs 행), 크래시 복구 가능 여부가 모두 스토리지 엔진의 설계에 달려 있습니다. InnoDB의 내부 구조를 이해하면 "왜 이 쿼리가 느린지", "왜 이 설정이 필요한지"에 대한 답을 엔진 수준에서 찾을 수 있습니다.

페이지: InnoDB의 최소 I/O 단위

InnoDB는 데이터를 페이지(Page) 단위로 관리합니다. 기본 크기는 16KB.

한 행이 100바이트라도, 디스크에서 읽을 때는 그 행이 속한 16KB 페이지 전체를 읽습니다. 반대로 한 행만 수정해도, 해당 페이지 전체가 더티(dirty) 상태가 됩니다.

왜 페이지 단위인가

디스크 I/O의 특성 때문입니다. HDD는 물론이고 SSD도 바이트 단위가 아닌 블록 단위로 읽고 씁니다. OS의 파일시스템도 보통 4KB 블록 단위입니다. InnoDB가 16KB 페이지를 쓰면, OS는 이를 4개의 4KB 블록으로 처리합니다.

페이지 크기는 innodb_page_size로 4KB, 8KB, 16KB, 32KB, 64KB 중 선택할 수 있지만, 테이블 생성 후에는 변경할 수 없습니다.

페이지의 내부 구조

하나의 16KB 페이지는 다음과 같이 구성됩니다:

  • File Header (38B): 페이지 번호, 이전/다음 페이지 포인터, 체크섬
  • Page Header (56B): 레코드 수, 힙 내 첫 번째 레코드 위치, 삭제된 레코드 포인터
  • Infimum/Supremum Records: 페이지 내 최솟값/최댓값 가상 레코드 (범위 검색 경계)
  • User Records: 실제 행 데이터가 저장되는 영역
  • Free Space: 아직 사용되지 않은 공간
  • Page Directory: 페이지 내 레코드를 빠르게 찾기 위한 슬롯 배열
  • File Trailer (8B): 체크섬 검증 (쓰기 중 크래시 감지)

행(Row)은 페이지 안에서 어떻게 배치되는가

InnoDB의 기본 행 포맷은 DYNAMIC입니다 (MySQL 5.7+). 각 행은 다음 정보를 포함합니다:

  • 가변 길이 필드 목록: VARCHAR 컬럼의 실제 길이
  • NULL 비트맵: 어떤 컬럼이 NULL인지
  • 레코드 헤더 (5B): 삭제 플래그, 다음 레코드 오프셋, 레코드 타입
  • 숨겨진 컬럼: DB_TRX_ID (6B, 트랜잭션 ID), DB_ROLL_PTR (7B, Undo 포인터), PK가 없으면 DB_ROW_ID (6B)
  • 실제 컬럼 데이터

행들은 PK 순서대로 단방향 링크드 리스트로 연결됩니다. 이것이 "클러스터드 인덱스"의 실체입니다 — 2편에서 자세히 다룹니다.

익스텐트와 세그먼트: 페이지 위의 논리 구조

페이지 하나는 16KB. 그러면 수백만 개의 페이지는 어떻게 관리할까요?

  • 익스텐트(Extent): 64개의 연속된 페이지 = 1MB. 디스크에서 연속 공간을 할당하면 순차 I/O 성능이 올라갑니다.
  • 세그먼트(Segment): 테이블스페이스 내에서 특정 용도를 위한 익스텐트 모음. 데이터 세그먼트, 인덱스 세그먼트, 롤백 세그먼트 등이 있습니다.
  • 테이블스페이스(Tablespace): InnoDB의 최상위 저장 단위. innodb_file_per_table=ON이면 테이블마다 .ibd 파일 하나입니다.
테이블스페이스 (.ibd 파일)
└── 세그먼트 (데이터, 인덱스, 롤백)
    └── 익스텐트 (1MB = 64페이지)
        └── 페이지 (16KB)
            └── 행 (Row)

Buffer Pool: 디스크와 메모리 사이의 캐시

InnoDB의 성능 핵심은 Buffer Pool입니다. 데이터를 읽을 때 디스크에서 직접 읽지 않고, Buffer Pool이라는 메모리 영역에 페이지를 올려놓고 거기서 읽습니다.

Buffer Pool의 동작 원리

  1. 읽기 요청: 페이지가 Buffer Pool에 있으면 (캐시 히트) 바로 반환. 없으면 디스크에서 읽어서 Buffer Pool에 적재합니다.
  2. 쓰기 요청: Buffer Pool의 페이지를 수정 → 더티 페이지(Dirty Page)가 됨 → 즉시 디스크에 쓰지 않습니다.
  3. 플러시: 더티 페이지를 백그라운드에서 디스크에 기록합니다 (Checkpoint).

쓰기를 즉시 디스크에 반영하지 않는 이유는 성능 때문입니다. 메모리 쓰기는 나노초, 디스크 쓰기는 밀리초 단위입니다. 수백 배 차이가 납니다.

LRU 알고리즘 — 단순 LRU가 아니다

Buffer Pool은 무한하지 않습니다. 메모리가 꽉 차면 어떤 페이지를 내릴지 결정해야 합니다. InnoDB는 단순 LRU가 아닌 변형 LRU를 사용합니다:

  • Young 영역 (5/8): 자주 접근하는 "핫" 페이지
  • Old 영역 (3/8): 새로 읽어온 페이지가 처음 들어가는 곳

새 페이지는 Old 영역의 머리(midpoint)에 삽입됩니다. 일정 시간(innodb_old_blocks_time, 기본 1초) 이후에도 다시 접근되면 Young 영역으로 승격됩니다.

왜 이렇게 복잡하게 할까요? 풀 테이블 스캔 때문입니다. 대량의 페이지를 한 번 훑는 작업이 자주 쓰이는 핫 데이터를 밀어내는 것을 방지합니다.

Buffer Pool 크기 설정

innodb_buffer_pool_size는 MySQL 튜닝의 가장 중요한 파라미터입니다. 전용 DB 서버라면 총 메모리의 70-80%를 할당하는 것이 일반적입니다.

-- Buffer Pool 상태 확인
SHOW ENGINE INNODB STATUS\G

-- 또는 간단하게
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool%';
-- Innodb_buffer_pool_read_requests  (Buffer Pool 읽기 요청)
-- Innodb_buffer_pool_reads          (디스크에서 읽은 횟수)
-- 히트율 = 1 - (reads / read_requests)

히트율이 99% 이상이면 Buffer Pool 크기가 적절하다는 의미입니다. 95% 이하로 떨어지면 크기를 늘리는 것을 고려해야 합니다.

WAL: Write-Ahead Logging

Buffer Pool에서 더티 페이지를 바로 디스크에 안 쓴다고 했습니다. 그러면 서버가 크래시하면 메모리의 데이터가 날아가지 않을까요?

WAL(Write-Ahead Logging)이 이 문제를 해결합니다. 데이터를 변경하기 전에, 변경 내용을 먼저 로그에 기록합니다. InnoDB에서는 두 종류의 로그가 WAL 역할을 합니다.

Redo Log — "무엇을 했는지" 기록

Redo Log는 "이 페이지의 이 오프셋에 이 값을 썼다"는 물리적 변경 기록입니다.

  • 데이터 변경 시 즉시 Redo Log에 기록 (순차 쓰기 → 빠름)
  • 커밋 시 fsync()로 Redo Log를 디스크에 강제 플러시합니다
  • 데이터 페이지 자체는 나중에 백그라운드로 플러시 (랜덤 쓰기 → 느림)

서버가 크래시하면 어떻게 될까요? 재시작 시 Redo Log를 읽어서 Buffer Pool에 없던 변경 사항을 재적용(redo)합니다. 커밋된 트랜잭션은 반드시 복구됩니다.

Redo Log의 구조

Redo Log는 순환 버퍼(circular buffer) 형태입니다:

ib_logfile0  ──→  ib_logfile1  ──→  (다시 ib_logfile0)
     ↑ write pos                ↑ checkpoint pos
  • write pos: 현재 쓰기 위치
  • checkpoint pos: 여기까지는 더티 페이지가 디스크에 플러시되었음
  • write pos가 checkpoint를 따라잡으면? 체크포인트를 강제 실행해야 해서 성능이 급감합니다.

MySQL 8.0.30+에서는 innodb_redo_log_capacity로 전체 Redo Log 크기를 설정합니다. 이전에는 innodb_log_file_size × innodb_log_files_in_group으로 계산했습니다.

Undo Log — "무엇이었는지" 기록

Undo Log는 변경 이전의 값을 기록합니다. 두 가지 목적이 있습니다:

  1. 롤백: 트랜잭션을 취소하면 Undo Log로 원래 값을 복원
  2. MVCC: 다른 트랜잭션이 이전 버전의 데이터를 읽을 수 있게 제공 (3편에서 상세)

Undo Log는 Redo Log와 달리 테이블스페이스 내부에 저장됩니다. 트랜잭션이 커밋되면 바로 삭제되지 않고, 해당 버전을 참조하는 트랜잭션이 모두 끝나야 퍼지(purge) 스레드가 정리합니다.

쓰기 흐름 총정리: INSERT 한 줄의 여정

INSERT INTO users (name) VALUES ('홍길동')이 실행되면:

  1. Undo Log 기록: "이 행은 존재하지 않았다" (롤백 대비)
  2. Buffer Pool에서 페이지 수정: 해당 페이지를 메모리에 올리고 행을 삽입. 더티 페이지가 됩니다.
  3. Redo Log 기록: "페이지 X의 오프셋 Y에 이 데이터를 썼다"
  4. COMMIT 시: Redo Log를 fsync()로 디스크에 플러시. 이 순간 트랜잭션이 영속(durable)해집니다.
  5. 나중에 (백그라운드): 체크포인트 시 더티 페이지를 디스크의 데이터 파일에 플러시합니다.

핵심은 순차 쓰기(Redo Log)를 먼저, 랜덤 쓰기(데이터 페이지)는 나중에 처리한다는 점입니다. 순차 쓰기는 랜덤 쓰기보다 10-100배 빠릅니다.

Doublewrite Buffer: Partial Write 방어

InnoDB 페이지는 16KB인데 OS의 파일시스템 블록은 4KB입니다. 디스크에 쓰는 도중 크래시하면, 16KB 중 일부만 기록된 Partial Write(Torn Page)가 발생할 수 있습니다.

Redo Log로 복구하려면 페이지의 원본이 온전해야 하는데, 반쯤 쓴 페이지는 원본도 아니고 변경본도 아닙니다.

Doublewrite Buffer가 이 문제를 해결합니다:

  1. 더티 페이지를 먼저 Doublewrite Buffer(디스크의 연속 공간)에 순차 기록
  2. Doublewrite Buffer 쓰기가 완료되면, 실제 데이터 파일의 위치에 기록
  3. 크래시 발생 시, Doublewrite Buffer에서 온전한 복사본을 가져와 복구

성능 오버헤드는 약 5-10%. 데이터 무결성을 위한 보험입니다.

Change Buffer: 세컨더리 인덱스 쓰기 최적화

INSERT/UPDATE/DELETE 시 세컨더리 인덱스도 함께 갱신해야 합니다. 그런데 세컨더리 인덱스 페이지가 Buffer Pool에 없으면 어떻게 될까요?

디스크에서 읽어와서 수정하는 대신, Change Buffer에 변경 사항을 임시 기록해둡니다. 나중에 해당 인덱스 페이지가 읽히면 그때 병합(merge)합니다.

이 최적화는 유니크하지 않은 세컨더리 인덱스에만 적용됩니다. 유니크 인덱스는 중복 체크를 위해 어차피 페이지를 읽어야 하므로 Change Buffer를 쓸 수 없습니다.

정리

구성 요소역할핵심 포인트
페이지데이터 저장 최소 단위16KB, 행은 PK 순서로 저장
Buffer Pool메모리 캐시변형 LRU, 히트율 99% 목표
Redo Log크래시 복구WAL, 순차 쓰기, fsync 시점 = 커밋
Undo Log롤백 + MVCC이전 버전 보관, purge로 정리
Doublewrite BufferPartial Write 방어16KB 페이지의 안전한 기록
Change Buffer세컨더리 인덱스 쓰기 최적화비유니크 인덱스만 대상

이 구조를 이해하면, 다음 편에서 다룰 인덱스가 왜 B+Tree 형태인지, 왜 클러스터드 인덱스가 PK와 묶여 있는지가 자연스럽게 연결됩니다.

다음 편 예고

2편에서는 인덱스의 내부 구조를 다룹니다. B-Tree와 B+Tree의 차이, 클러스터드 인덱스와 세컨더리 인덱스의 관계, 커버링 인덱스가 왜 빠른지, 그리고 복합 인덱스 설계 원칙까지.

MySQLInnoDB스토리지 엔진Buffer PoolWAL데이터베이스면접 준비

관련 글

인덱스는 왜 빠른가 — B+Tree부터 커버링 인덱스까지 (2편)

인덱스를 걸면 빨라진다는 건 알지만, 왜 빠른지 설명할 수 있는가? B+Tree의 구조, 클러스터드 인덱스와 세컨더리 인덱스의 차이, 커버링 인덱스가 디스크 접근을 줄이는 원리, 복합 인덱스의 최좌선 규칙까지 정리합니다.

관련도 92%

락의 종류와 데드락 — 동시성 제어의 실체 (4편)

MVCC가 읽기-쓰기 충돌을 해결한다면, 락은 쓰기-쓰기 충돌을 해결한다. InnoDB의 레코드 락, 갭 락, 넥스트키 락이 각각 어떤 문제를 방지하는지, 데드락은 왜 발생하고 어떻게 감지되는지 추적합니다.

관련도 90%

트랜잭션과 MVCC — 동시에 읽고 쓸 수 있는 이유 (3편)

READ COMMITTED와 REPEATABLE READ의 차이를 코드 없이 설명할 수 있는가? InnoDB의 MVCC가 Undo Log 체인과 Read View로 어떻게 동작하는지, 격리 수준별로 어떤 이상 현상이 허용되는지 추적합니다.

관련도 89%