
대표적인 MySQL의 스토리지 엔진은 InnoDB 스토리지 엔진이며, MySQL에서 사용할 수 있는 엔진 중 거의 유일하게 레코드 기반의 잠금을 제공한다. 또한, MySQL 서버의 모든 기능을 InnoDB 스토리지 엔진 만으로 구현할 수 있게 되면서 MyISAM 엔진은 곧 없어질 것으로 예상된다고 한다.
InnoDB 스토리지 엔진은 어떤 구조를 가지고 있기에 다른 스토리지 엔진보다 더 안정적이고 성능도 뛰어날 수 있는 것인지 개략적인 아키텍처를 분석하면서 알아보고자 한다.
MVCC는 Multi-Version Concurrency Control의 약자로 트랜잭션 간의 충돌을 최소화 하기 위해, 일반적으로 레코드 레벨의 트랜잭션을 지원하는 DBMS가 지원하는 기능이다.
MVCC의 가장 큰 목적은 잠금을 사용하지 않는 일관된 읽기를 제공하는 데 있다. 여기서 Multi-Version이라 함은 하나의 레코드에 대해 여러 개의 버전이 동시에 관리 된다는 의미인데, InnoDB는 Undo Log를 통해서 이 기능을 지원한다.
간단하게 말하자면, MVCC는 트랜잭션이 실행되는 동안 각 트랜잭션이 변경한 데이터의 이전 버전을 유지하여 동시에 여러 트랜잭션이 같은 레코드를 락을 최소화하면서도 데이터 일관성이 유지되도록 읽을 수 있게 하는 기능이다.
INSERT INTO orders (order_id, customer_name, order_status) VALUES
(1, '김철수', 'PENDING'),
(2, '이영희', 'SHIPPED');
위와 같은 레코드에 대해서 MVCC가 어떻게 동작하는지 확인해 보도록 하자.
정상적으로 위의 Insert가 수행되었다면, 메모리의 버퍼 풀과 디스크에 레코드가 저장되어 있을 것이다.
UPDATE orders SET order_status = 'CANCELLED' WHERE order_id = 1;
위와 같은 UPDATE 명령어를 수행하게 된다면, 커밋 실행 여부와 관계 없이, InnoDB의 버퍼 풀은 새로운 값인 CANCELLED로 업데이트 된다. 그리고 디스크의 데이터 파일에는 InnoDB의 Write 스레드나 체크포인트로 인해 새로운 값이 업데이트 되어 있을 수도 있고 아닐 수도 있다. (버퍼 풀의 변경 내용은 InnoDB 스토리지 엔진의 백그라운드 스레드에 의해서 기록된다.) 그리고 변경되기 전 값이 언두 로그로 복사된다.
만약, 아직 COMMIT이나 ROLLBACK이 되지 않은 상태에서 다른 사용자가 작업 중인 레코드를 조회하면 어떤 데이터를 가져오게 될까?
정답은 ‘격리 수준에 따라 다르다’이다. 격리 수준이 READ_UNCOMMITTED인 경우에는 커밋됐든 아니든 변경된 상태의 데이터를 반환한다. 다른 READ_COMMITED나 그 이상의 격리 수준에 대해서는 아직 커밋되지 않았기 때문에, InnoDB 버퍼 풀이나 데이터 파일의 내용 대신 Undo Log의 데이터를 반환한다.
이러한 일련의 과정을 MVCC라고 표현한다. 즉, 하나의 레코드에 대해 여러 개의 버전이 유지되고, 필요에 따라 어느 데이터가 보여지는지 달라지는 것이다. 이러한 Undo Log는 무한히 많아질 수 있으며, 특히 트랜잭션이 길어지면 Undo영역의 데이터가 삭제되지 못하고 오랫동안 관리되면서, 언두 영역이 저장되는 테이블스페이스 공간이 많이 늘어날 수 있다.
INSERT INTO orders (order_id, customer_name, order_status) VALUES
(1, '김철수', 'PENDING'),
(2, '이영희', 'SHIPPED');
-- 세션 1: 트랜잭션 시작 및 데이터 변경 (커밋하지 않음)
START TRANSACTION;
UPDATE orders SET order_status = 'CANCELLED' WHERE order_id = 1;
-- 세션 2: 같은 데이터를 읽음
START TRANSACTION;
SELECT * FROM orders WHERE order_id = 1;
-- 결과: 'PENDING' (트랜잭션 시작 시점의 Snapshot 유지)
COMMIT;
-- 세션 1에서 커밋 후 다시 읽음
COMMIT;
SELECT * FROM orders WHERE order_id = 1;
-- 결과: 'CANCELLED' (최신 데이터 반영)
InnoDB 스토리지 엔진은 MVCC를 이용해 잠금을 걸지 않고 일관된 읽기작업을 수행한다. 잠금을 걸지 않기에, 읽기 작업은 다른 트랜잭션이 가지고 있는 잠금을 기다리지 않고, 읽기 작업이 가능하다. 그렇기 때문에 격리 수준이 SERIALIZABLE이 아닌, 다른 격리 수준에 대해서는 SELECT 작업은 다른 트랜잭션의 변경 작업과 관계 없이 바로 실행된다.
이러한 읽기가 가능한 이유는, 데이터가 변경되었더라도 Undo Log를 이용하여 트랜잭션이 시작된 시점에 격리 수준에 따라 알맞은 데이터 버전을 참조할 수 있기 때문이다.
하지만 오랜 기간 실행되는 트랜잭션으로 인해 MySQL 서버가 느려지거나 문제가 발생할 수 있다. Undo Log를 바로바로 정리하지 못하고 계속 유지하기 때문인데, 그렇기에 트랜잭션이 시작되었다면, 가능한 한 빨리 롤백이나 커밋을 통해 트랜잭션을 끝내는 것이 좋다.
InnoDB는 자동 데드락 감지기능을 제공하여, 트랜잭션 간의 데드락을 자동으로 탐지하고 해결한다. 이때, 잠금이 데드락에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프 형태로 관리하는데, InnoDB 엔진의 데드락 감지 스레드를 통해, 주기적으로 해당 그래프를 검사해 데드락 상태의 트랜잭션들을 찾아서 하나를 강제 종료 한다.
InnoDB의 자동 데드락 감지 동작 방식
- 트랜잭션이 락을 획득하려고 할 때, InnoDB는 현재 트랜잭션 간의 대기 그래프(Wait-For Graph) 를 생성함.
- 대기 그래프에서 Cycle이 발생하는지 확인 → 순환이 감지되면 데드락 발생!
- InnoDB는 트랜잭션 중 하나를 강제 롤백하여 데드락을 해소함.
- 롤백된 트랜잭션은 다시 실행될 수 있도록 애플리케이션에서 재시도 가능
어느 트랜잭션을 먼저 강제 종료할 것인지를 결정하는 기준은 Undo Log의 양이며, 로그의 양이 적은 트랜잭션이 강제 종료의 대상이 된다. 그 이유는 해당 로그의 양이 적다는 것은 롤백을 할때, Undo 처리를 해야할 내용이 적다는 것이며, 트랜잭션 강제 롤백으로 인한 서버의 부하도 덜 유발하기 때문이다.
일반적으로는 데드락 감지 스레드가 트랜잭션의 잠금 목록을 검사해서 데드락을 찾는 작업이 부담이 가지 않지만, 데드락 감지 스레드가 잠금 목록을 검사하는 과정에서 새로운 잠금이 계속 추가되면, 잠금 목록이 커지고, 검사 시간이 길어지며, CPU 부하가 증가한다. 이로 인해 서비스 성능이 저하될 수 있다.
그렇기에, 데드락 감지 비활성화 (innodb_deadlock_detect)변수를 제공하며, 해당 변수를 OFF로 하게 되면 더이상 데드락 감지 스레드는 작동하지 않는다. 이 경우에는 데드락 상황이 발생해도 누군가 중재하지 않기에 무한정 대기하게 되지만, innodb_lock_wait_timeout시스템 변수를 통해서 timeout을 걸어 무한 대기를 방지할 수 있다.
버퍼 풀은 InnoDB 스토리지 엔진에서 가장 핵심적인 부분으로, 간단하게 설명하자면 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해두는 공간이다. 추가로 쓰기 작업을 batch로 처리할 수 있게 해주는 버퍼 역할도 같이 한다.
생각해보면, INSERT, UPDATE, DELETE 같은 데이터 변경 쿼리는 디스크 이곳저곳에 존재하는 데이터를 변경한다. 이 작업들은 랜덤한 디스크 작업이므로, 모아서 처리하면 디스크 작업의 횟수를 효율적으로 줄일 수 있을 것이다.
InnoDB의 성능을 최적화하기 위해 가장 중요한 설정 중 하나가 버퍼 풀의 크기(innodb_buffer_pool_size)이다. 버퍼 풀 크기가 크면 디스크 I/O를 줄일 수 있어 성능이 향상된다.
InnoDB의 버퍼 풀의 크기가 냅다 크면 좋은 것은 또 아니고, 버퍼 풀의 크기를 동적으로 조절할 수 있기에, 적절히 적은 값으로 설정해서 조금씩 증가 시키는 방법이 최적이다. (MySQL 서버와 운영체제, 다른 프로그램도 메모리를 사용할 수 있어야하기 때문)
| 서버 RAM 크기 | 추천 버퍼 풀 크기 |
|---|---|
| 4GB | 2GB (~50%) |
| 8GB | 4GB (~50%) |
| 16GB | 12GB (~75%) |
| 32GB | 25GB (~75%) |
버퍼 풀이라는 거대한 메모리 공간을 페이지 크기의 조각으로 쪼개서, InnoDB 엔진이 데이터를 필요로 할 때, 해당 데이터 페이지를 읽어서 각 조각에 저장한다. 각 조각을 관리하기 위해 LRU 리스트, 플러시 리스트, 프리 리스트로 구성된다.
이름 그대로 Free 리스트는 InnoDB 버퍼 풀에서 비어 있는 페이지들의 목록이다.

LRU 리스트는 위와 같은 구조를 띄고 있는데, 엄밀한 의미로의 LRU는 아니고 MRU(Most Recently Used) 구조가 합쳐져 있다.
New Sublist → MRU
Old Sublist → LRU처음 한번 읽힌 데이터 페이지가 자주 사용된다면 MRU 방향으로 승급해 오래 살아남게 되고, 사용되지 않는 페이지는 새롭게 디스크에서 읽히는 데이터 페이지들에 밀려 LRU 끝으로 밀려나 결국 버퍼 풀에서 제거된다.
플러시 리스트는 디스크에 반영되지 않은 Dirty Page를 관리하며, 특정 조건이 충족되면 Dirty Page를 디스크에 기록하여 데이터 무결성을 보장한다. 후술하는 리두 로그와 관련이 되어 있으며, InnoDB는 데이터가 변경되면 리두 로그에 기록하고 버퍼 풀의 페이지에도 변경 내용을 반영한다.

InooDB는 데이터 캐시 기능도 하지만, 쓰기 버퍼링 기능도 수행한다. 만약 버퍼 풀의 데이터가 변경될 경우, 앞서 말했듯이 디스크에 바로 쓰지 않고 리두 로그에 기록한다.
리두 로그는 고정 크기 파일을 연결하여 순환 고리처럼 동작하며, 일정 공간이 차면 이전 로그를 덮어쓰는 방식으로 관리한다. 이때, 데이터 변경이 계속 발생되면 리두 로그가 덮어질 가능성이 있어 그렇기에 당장 재사용 가능한 공간과 그렇지 않은 공간을 분리하는 것이 필요한데 그 중 재사용이 불가능한 공간을 활성 리두 로그라고 한다.
이렇게 리두 로그 공간은 계속 재사용 되지만 매번 기록될 때마다 로그 포지션은 계속 증가된 값 (LSN - Log Sequence Number)을 이는 갖게 되는데 체크포인트와 관련되서 필요한 값이다.
체크포인트라고 함은 InnoDB가 안전하게 데이터를 디스크에 기록한 지점으로, 체크포인트 이벤트가 발생하면 체크포인트 LSN보다 작은 리두 로그 엔트리와 연결된 더티 페이지는 모두 디스크로 동기화된다.
리두 로그와 버퍼 풀 동기화 과정
- 데이터 변경이 발생하면 버퍼 풀의 더티 페이지가 생성됨.
- 변경 사항이 즉시 리두 로그에 기록되지만, 실제 데이터 파일에는 반영되지 않음.
- 체크포인트 이벤트 발생 → 리두 로그에서 특정 시점(Checkpoint LSN) 이전의 데이터는 디스크로 동기화.
- 체크포인트 이후의 리두 로그만 활성 상태로 유지하며, 더 이상 필요 없는 로그는 덮어씌워짐.
버퍼 풀은 메모리 캐시이므로 당연히, 시스템 장애가 발생하면 데이터가 손실될 수 있다. MySQL은 ACID를 보장해야하므로 (특히 Durablity) Dirty Page가 유실되지 않도록 리두 로그라는 WAL방식을 사용하는 것이 중요하다.
또한, 체크포인트를 사용해서 리두 로그를 관리하는데 장애 발생 시 체크포인트 이후의 변경 사항을 리두 로그를 이용해 복구가 가능하다.
내구성 뿐 아니라 리두 로그를 통해서 성능 최적화를 이끌어 낼 수 있는데, 데이터 페이지를 디스크에 직접적으로 기록한다면 랜덤 I/O로 인해 저하될 수 있다. 하지만, 리두 로그는 순차적인 로그 파일 형태로 저장되므로, 디스크에 빠르게 기록할 수 있다.
버퍼 풀에서 Dirty Page를 디스크에 저장하는 과정을 플러시 라고 한다. 급작스럽게 많은 페이지가 디스크에 저장된다면, 디스크 기록이 폭증해서 MySQL 서버의 성능에 영향을 미칠 수 있다. 그렇기에, InnoDB 스토리지 엔진은 성능 영향 없이 디스크와 동기화하기 위해서 플러시 기능들을 백그라운드로 실행한다.
버퍼 풀은 아무래도 페이지에 대한 캐싱 역할을 담당하다 보니 쿼리의 성능과 밀접하게 연결되어 있다. 서버가 재 시작 한다면, 모든 요청들이 버퍼 풀에서 Miss될 것이고, 쿼리 성능이 매우 저하될 것이다.
그래서 MySQL 5.6 이후 버전에서는 서버 재시작 후에도 버퍼 풀 상태를 유지하는 기능을 지원한다. 이를 통해 MySQL이 재시작되었을 때 캐시가 초기화되지 않고 기존 상태를 복구하여 성능 저하를 방지할 수 있다.
SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;
innodb_buffer_pool_dump_now = ON → MySQL 종료 시 버퍼 풀 상태 저장innodb_buffer_pool_load_now = ON → MySQL 시작 시 저장된 버퍼 풀 상태 로드실제 버퍼 풀의 백업 파일의 크기를 보면 실제 버퍼 풀의 크기보다 훨씬 작은 것을 볼 수 있는데, 이는 버퍼 풀의 LRU 리스트에서 적재된 데이터 페이지의 메타 정보만 가져와서 저장하기 때문이다. (그래서 백업이 매우 빠르다.)
하지만, 해당 백업된 버퍼풀의 내용을 다시 복구하는 과정은 조금 시간이 걸릴 수도 있는데, 백업된 내용에서 각 테이블의 데이터 페이지를 다시 디스크에서 읽어와야하기 때문이다.
InnoDB는 데이터를 디스크에 기록할 때 Double Write Buffer를 활용하여 데이터 손상을 방지한다. 이는 데이터 페이지의 부분적 손상을 방지하는 중요한 기능이다.
InnoDB의 리두 로그는 공간의 낭비를 막기 위해 변경된 내용만 기록한다. 그렇기에, InnoDB에서 디스크 파일로 Dirty page를 플러시 할 때, 일부만 기록되는 문제가 발생하면 그 페이지의 내용은 복구할 수 없을 수도 있다. 이를 위해서 Double-Write기법을 사용한다.

언두 로그는 트랜잭션이 변경한 데이터의 이전 버전을 저장하는 로그이다.
MySQL 5.5 이전 버전의 서버에서는 한 번 증가한 언두 로그 공간은 다시 줄어들지 않았다고 한다.
예를 들어서, 1억 건의 레코드가 저장된 100GB 크기의 테이블을 DELETE로 삭제한다고 하면, MySQL 서버는 이 테이블에서 레코드를 한 건 삭제하고 언두 로그에 삭제되기 전 값을 저장한다. 결국 100GB의 언두 로그 공간을 가지게 되며, 굉장히 비효율적일 것이다.
이 뿐 아니라, 한 트랜잭션이 오래 걸리는 경우에도 문제가 생길 수 있는데, 추가적인 예로 A 트랜잭션이 수행되고 B, C 트랜잭션이 수행된다고 하자. 만약 A 트랜잭션이 하루종일 수행된다고 하면 스냅샷을 유지해야 하기에 B, C 트랜잭션이 만들어낸 언두 로그는 삭제될 수 없다.
이는 큰 문제가 아니라고 느낄 수 있지만 로그가 많이 쌓인 레코드에 대해 조회 쿼리가 발생하면, 언두 로그의 이력을 필요한 만큼 스캔해야만 필요한 레코드를 찾을 수 있기에 쿼리의 성능이 떨어지게 된다.
하지만, MySQL 5.5 버전 이후로는 언두 로그 공간의 재활용이 가능하고 MySQL 서버가 필요한 시점에 공간을 줄여주기도 해서 개선이 되었다. 그렇다고 하더라도 트랜잭션이 장시간 유지되는 것은 성능에 좋지않다.

체인지 버퍼는 세컨더리 인덱스 변경 사항을 임시로 저장하고, 나중에 디스크에 반영하는 버퍼이다.
RDBMS에서 레코드가 INSERT, UPDATE, DELETE 될 때에는 데이터 파일을 변경하는 작업 뿐 아니라 해당 테이블에 포함된 인덱스를 업데이트하는 작업도 필요하다. 이런 인덱스 업데이트 작업도 RANDOM I/O로 수행되므로 비용이 있는 작업이다.
그래서 InnoDB는 변경해야 할 인덱스 페이지가 버퍼 풀에 있다면 바로 업데이트를 수행하지만, 디스크로부터 읽어와서 업데이트 해야 한다면 이를 즉시 수행하지 않고, 임시 공간(Change Buffer)에 저장해두고 나중에 처리하여 성능을 향상시킨다.
사용자에게 결과를 전달하기 전에 반드시 중복 여부를 체크해야 하는 유니크 인덱스는 체인지 버퍼를 사용할 수 없다.
앞서 언급한 것처럼 InnoDB에서는 데이터 변경 내용을 로그로 먼저 기록한다. (WAL).
DBMS는 ACID도 중요하지만 성능도 중요하기에 데이터 파일 뿐 아니라 리두 로그를 버퍼링 할 수 있는 InnoDB 버퍼 풀 공간이나, 로그 버퍼와 같은 자료 구조도 가지고 있다.
DBMS에서의 리두 로그 권장사항은 트랜잭션이 커밋되면 즉시 기록되는 것이다. 당연히 그렇게 해야만 직전까지의 트랜잭션 커밋 내용이 리두 로그에 기록될 수 있고 이를 통한 복구가 가능하기 때문이다. 하지만, 트랜잭션이 커밋될 때마다 디스크에 기록하는 작업은 아무래도 성능상의 이슈가 있을 수 있다.
그래서 innodb_flush_log_at_trx_commit변수를 통해서 어느 주기로 리두 로그를 디스크에 동기화 할지 결정할 수 있다.
| 값 | 설명 |
|---|---|
| 0 | 트랜잭션 커밋 시 로그 버퍼에만 저장, 디스크 플러시는 주기적으로 수행 (가장 빠르지만 데이터 손실 위험) |
| 1 | 트랜잭션 커밋 시 로그를 디스크에 즉시 플러시 (가장 안전한 설정) |
| 2 | 트랜잭션 커밋 시 로그 버퍼에 저장, 디스크 플러시는 일정 주기마다 수행 (1보다는 빠르고 안정적) |

Adaptive Hash Index는 InnoDB가 자주 조회되는 데이터를 감지하여 자동으로 해시 인덱스를 생성하여 성능을 최적화하는 기능이다.
B-Tree Index를 한계를 보완하는 기능으로, 자주 사용되는 칼럼을 해시로 정의하여, B-Tree 를 타지 않고 바로 데이터에 접근할 수 있는 기능이다. 우리가 수동으로 생성할 수 있는 인덱스가 아니라 InnoDB 스토리지 엔진에서 자동으로 자주 액세스되는 패턴을 자동으로 감지하여 생성하는 인덱스이며, 활성화 비활성화만 가능하다.
해시 인덱스는 인덱스 키 값과 해당 인덱스 키 값이 저장된 데이터 페이지 주소의 쌍으로 관리되는데, 이때 인덱스 키 값은 B-Tree 인덱스의 id와 B-Tree 인덱스의 실제 키 값 조합으로 생성된다. 이는 InnoDB 스토리지 엔진 전역을 거쳐 Adaptive Hash Index는 하나만 존재하기에 어떤 인덱스에 속한 것인지도 구분해야하기 때문이다.
마냥 장점만 존재하는 것은 아니고, 성능 향상에 도움이 되지 않는 경우도 존재한다.
단순히 이 기능이 좋다 안좋다고 구분해서 적용하는 것은 어렵고, 현재 서비스 중인 DB에서 어댑티브 해시 인덱스가 효율적으로 사용되고 있는지 확인하여 결정하는 것이 좋을 것이다.
Real MySQL 8.0 (1권)
https://tech.kakao.com/posts/319
https://blog.ex-em.com/1698
https://velog.io/@choiys0212/MySQL-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98#%EC%BF%BC%EB%A6%AC-%ED%8C%8C%EC%84%9C