
InnoDB 스토리지 엔진 아키텍처
InnoDB 스토리지 엔진 아키텍처 정리
InnoDB는 MySQL의 기본 스토리지 엔진이자, MySQL이 제공하는 스토리지 엔진 중 레코드 단위 잠금을 지원하는 거의 유일한 엔진이다. 이 특성 때문에 동시성 처리가 많은 환경에서도 비교적 안정적으로 동작할 수 있다.
프라이머리 키 기반 클러스터링
InnoDB의 모든 테이블은 기본적으로 프라이머리 키 값의 순서대로 디스크에 정렬되어 저장된다. 즉 프라이머리 키 자체가 클러스터링 인덱스다. 이 구조 덕분에 프라이머리 키를 이용한 레인지 스캔이 매우 빠르지만, 반대로 프라이머리 키가 자주 바뀌거나 랜덤한 값(UUID 등)이면 페이지 분할 비용이 커진다.
외래키 지원
InnoDB는 다른 엔진과 달리 외래키 제약을 지원한다. 다만 부모 테이블과 자식 테이블 양쪽 모두에 인덱스가 필요하고, 변경할 때마다 양쪽에 데이터가 존재하는지 확인해야 하므로 여러 테이블에 걸친 잠금이 발생할 수 있다.
대량 데이터 적재 등 외래키 검증 비용이 부담스러운 작업에서는 시스템 변수 foreign_key_checks를 OFF로 설정해 일시적으로 검증을 끌 수 있다. 다만 작업이 끝난 뒤에는 다시 켜고 데이터 일관성을 별도로 확인해야 한다.
MVCC
MVCC(Multi Version Concurrency Control)는 레코드를 변경하더라도 조회 쿼리에는 잠금이 걸리지 않도록 하는 메커니즘이다. 이름 그대로 하나의 레코드가 여러 버전을 동시에 가질 수 있게 한다.
동작 흐름은 다음과 같다.
- 레코드를 변경하면 변경된 값은 InnoDB 버퍼 풀에 반영된다.
- 변경 직전의 값은 언두 로그에 백업된다.
- 트랜잭션이 커밋되면 버퍼 풀의 값이 디스크에 반영되고, 롤백되면 언두 로그의 값이 다시 적용된다.
다른 트랜잭션이 이 레코드를 조회할 때 어느 쪽을 보게 되는지는 격리 수준이 결정한다.
| 격리 수준 | 조회 대상 |
|---|---|
| READ UNCOMMITTED | 버퍼 풀 (커밋되지 않은 변경 포함) |
| READ COMMITTED, REPEATABLE READ, SERIALIZABLE | 언두 로그 (변경 전 값) |
운영 관점에서 주의할 점은 트랜잭션이 길어지면 그동안의 변경분이 언두 로그에 계속 쌓인다는 것이다. 장시간 열려 있는 트랜잭션은 언두 영역을 비대하게 만들고, 결과적으로 다른 쿼리의 응답 시간까지 늘어뜨릴 수 있다.
자동 데드락 감지
InnoDB는 잠금 대기 상태를 wait-for 그래프 형태로 관리하면서 교착 상태를 자동으로 감지한다. 데드락이 감지되면 둘 중 한 트랜잭션을 강제 종료하는데, 기준은 언두 로그의 양이 적은 쪽이다. 롤백 비용이 적은 트랜잭션을 희생시켜야 전체 부하가 작기 때문이다.
데드락 감지 스레드는 동시 처리가 매우 많은 환경에서는 그 자체로 CPU를 적지 않게 소비할 수 있다. 이 비용이 부담이라면 innodb_deadlock_detect를 끄고, 대신 innodb_lock_wait_timeout을 설정해 일정 시간 잠금이 풀리지 않으면 자동 롤백되도록 만들 수도 있다. 다만 이 경우 데드락이 즉시 감지되지 않으므로, 타임아웃 동안 해당 자원이 묶이는 점은 감수해야 한다.
InnoDB 버퍼 풀
InnoDB 버퍼 풀은 InnoDB에서 가장 핵심적인 메모리 영역이다. 디스크의 데이터 파일과 인덱스를 메모리에 캐싱하는 역할을 하면서, 동시에 쓰기 작업을 모아 일괄 처리하는 쓰기 버퍼 역할도 한다.
버퍼 풀 크기 설정
버퍼 풀의 크기를 정할 때는 운영체제, 다른 MySQL 내부 영역, 클라이언트 스레드가 사용할 메모리까지 함께 고려해야 한다. MySQL 자체가 메모리를 크게 잡아먹는 경우는 많지 않지만, 클라이언트 세션마다 사용하는 레코드 버퍼는 예외다. 레코드 버퍼는 각 세션이 테이블의 레코드를 읽고 쓸 때 사용하는 버퍼인데, 별도로 사이즈를 설정할 수 있는 항목이 아니라 필요한 만큼 자동으로 할당되고 해제된다. 즉 정확한 사용량을 계산할 수가 없으며, 동시 연결과 테이블 수가 많을수록 이 부분이 부풀어 오를 수 있다.
처음 운영을 시작한다면 물리 메모리의 50% 정도를 버퍼 풀에 할당하고, 운영하면서 조금씩 늘려가는 방식을 권장한다. 시스템 변수는 innodb_buffer_pool_size이며, 동적으로 변경할 수 있다.
다만 버퍼 풀 크기 변경은 운영 중 영향이 큰 작업이므로 한가한 시간대에 진행하는 것이 안전하다. 키울 때는 상대적으로 영향이 적지만, 줄일 때는 서비스에 미치는 충격이 크니 가급적 줄이지 않는 방향으로 운영하는 것이 좋다. 내부적으로 128MB 단위의 청크로 관리되므로, 변경값도 그 배수로 맞추는 것이 좋다.
버퍼 풀 인스턴스
버퍼 풀은 과거에는 전체에 대한 단일 mutex로 관리되어 동시성이 높은 환경에서 내부 잠금 경합이 심했다. 이를 줄이기 위해 버퍼 풀을 여러 인스턴스로 나눠 각각 독립적인 잠금으로 관리하도록 개선됐다.
innodb_buffer_pool_size가 1GB 이하라면 인스턴스 수는 1로 강제된다.- 그 이상이라면
innodb_buffer_pool_instances로 조정할 수 있다. - 40GB 이하 버퍼 풀이라면 기본값(8 인스턴스)이 무난하다.
- 그 이상이라면 인스턴스 하나당 약 5GB가 되도록 인스턴스 수를 늘리는 것이 권장된다.
버퍼 풀 구조
버퍼 풀은 페이지 단위로 메모리를 관리하며, 페이지의 교체 정책으로 LRU(Least Recently Used) 리스트를 사용한다. 정확히는 단순 LRU가 아니라 midpoint insertion 방식의 변형 LRU다.
- 새로 디스크에서 적재된 페이지는 LRU 리스트의 맨 앞이 아닌 중간 지점에 삽입된다. 이 위치를 기준으로 리스트가 young 영역(자주 사용되는 페이지)과 old 영역(덜 사용되는 페이지)으로 나뉜다.
- 적재된 뒤 일정 시간이 지나서도 다시 접근되는 페이지만 young 영역으로 승격한다.
- old 영역 끝까지 밀려난 페이지는 버퍼 풀에서 제거된다.
이 구조는 풀 스캔처럼 일시적으로 많은 페이지를 적재하는 작업이 자주 쓰이는 페이지를 버퍼 풀에서 밀어내는 버퍼 풀 오염을 막기 위한 설계다.
버퍼 풀과 리두 로그
버퍼 풀의 페이지는 변경 여부에 따라 두 종류로 나뉜다.
- 클린 페이지: 디스크에서 읽은 그대로, 아직 변경되지 않은 페이지.
- 더티 페이지:
INSERT,UPDATE,DELETE로 변경된 페이지. 언젠가는 디스크에 반영되어야 한다.
더티 페이지가 디스크로 즉시 기록되지 않고 버퍼 풀에 모여 있다가 일괄 처리될 수 있는 것은, 변경 내용 자체가 리두 로그에 먼저 기록되어 영속성이 보장되기 때문이다. 즉 버퍼 풀의 쓰기 버퍼링은 리두 로그가 뒷받침해야 안전하게 동작한다.
이 때문에 버퍼 풀과 리두 로그의 크기 균형이 중요하다.
- 리두 로그가 너무 작은 경우: 더티 페이지를 충분히 누적할 수 없어 강제 체크포인트가 자주 발생한다. 결과적으로 쓰기 I/O가 잦아지고, 쓰기 버퍼링이 제 역할을 하지 못한다.
- 리두 로그가 너무 큰 경우: 비정상 종료 시 복구해야 할 변경분이 많아져 크래시 리커버리 시간이 매우 길어진다.
대략적인 가이드로는, 버퍼 풀이 100GB 이하라면 리두 로그 전체 크기를 5~10GB 정도에서 시작해 운영 부하에 맞춰 조금씩 늘려가는 방식이 권장된다. 버퍼 풀과 리두 로그의 크기가 같을 필요는 없다. 리두 로그는 변경분(diff)만 보관하므로, 데이터 변경량이 많아도 버퍼 풀보다 훨씬 적은 공간으로 충분한 경우가 일반적이다.
언두 로그
언두 로그는 트랜잭션이 변경하기 직전의 데이터를 별도로 저장해 둔 백업이다. InnoDB는 이 로그를 두 가지 목적으로 사용한다.
- 트랜잭션 롤백: 트랜잭션이 실패하거나 롤백 요청을 받으면 언두 로그를 읽어 원상태로 되돌린다.
- 격리 수준 보장(MVCC): 다른 트랜잭션이 같은 레코드를 조회할 때, 격리 수준에 따라 변경 전 값을 언두 로그에서 읽어 돌려준다.
장시간 열려 있는 트랜잭션은 그동안의 모든 변경분을 언두 로그에 누적시킨다. 이 영역이 비대해지면 일반 쿼리의 조회 성능까지 영향을 받기 시작하므로, 운영 중에는 장기 트랜잭션이 발생하지 않도록 모니터링하는 것이 중요하다.
리두 로그
리두 로그는 ACID 중 D(Durability, 영속성) 를 책임지는 안전장치다. 하드웨어 장애나 비정상 종료로 서버가 죽었을 때, 아직 디스크에 반영되지 못한 변경분을 잃지 않도록 보존하는 역할을 한다.
대부분의 DBMS는 디스크의 데이터 구조를 읽기 성능에 맞춰 최적화한다. 이 구조에 변경분을 그때그때 기록하면 쓰기 비용이 크기 때문에, 별도의 쓰기 친화적인 자료구조에 변경 사실만 우선 기록해두는 것이 리두 로그다. 비정상 종료가 발생하면 리두 로그를 다시 읽어 종료 직전 상태로 데이터 파일을 복구한다.
이상적으로는 트랜잭션이 커밋될 때마다 리두 로그가 즉시 디스크로 기록되어야 한다. 다만 이는 모든 커밋이 디스크 I/O를 기다리게 만들기 때문에 부하가 커진다. 동기화 시점은 시스템 변수 innodb_flush_log_at_trx_commit로 제어한다.
| 값 | 동작 | 트레이드오프 |
|---|---|---|
1 (기본값) | 커밋마다 디스크에 기록 | 가장 안전, 가장 느림 |
2 | 커밋마다 OS 버퍼까지만 기록, 1초마다 디스크 동기화 | MySQL만 죽을 때는 데이터 보존, OS 충돌 시 1초어치 손실 가능 |
0 | 1초마다 한 번씩만 기록 | 가장 빠름, 비정상 종료 시 1초어치 손실 가능 |
운영 환경에서는 일반적으로 1을 유지하고, 부하 테스트나 대량 데이터 적재처럼 영속성보다 처리량이 더 중요한 작업에서만 한시적으로 완화한다.
댓글
아직 댓글이 없습니다.