카테고리 : MySQL/기술노트

Buffer Pool LRU 리스트와 young/old sublist 동작 원리

InnoDB Buffer Pool의 LRU 리스트가 young/old sublist를 사용해 대량 스캔과 운영 워크로드를 보호하는 방식을 정리한다.

저자: MySQL 기술 노트 작성: 2026.05.31 약 10분 5,849자
다운로드

1. 왜 Buffer Pool LRU를 별도로 이해해야 하는가

InnoDB Buffer Pool은 데이터 페이지와 인덱스 페이지를 메모리에 보관하는 가장 중요한 캐시 계층이다. 운영자는 보통 Buffer Pool 크기, hit rate, dirty page 비율을 먼저 본다. 그러나 장애 분석이나 성능 저하 원인 분석에서는 “어떤 페이지가 왜 오래 남고, 어떤 페이지가 왜 빨리 밀려나는가”를 이해해야 한다. 이때 핵심이 Buffer Pool의 LRU 리스트와 그 안의 young/old sublist 동작이다.

단순한 LRU라면 최근 접근된 페이지는 리스트 앞쪽에 두고, 오래 접근되지 않은 페이지는 뒤쪽에서 제거하면 된다. 하지만 데이터베이스는 일반 애플리케이션 캐시보다 훨씬 복잡한 접근 패턴을 가진다. 특정 시점에는 OLTP 쿼리가 같은 인덱스 루트·내부 페이지와 hot row 주변 페이지를 반복 접근하고, 다른 시점에는 배치 작업이나 보고서 쿼리가 큰 테이블을 처음부터 끝까지 훑는다. 대량 스캔이 들어올 때마다 단순 LRU가 모든 새 페이지를 즉시 “가장 최근 접근된 페이지”로 승격한다면, 실제 업무 트랜잭션이 반복 사용하던 hot page가 밀려나고 이후 쿼리들이 디스크 I/O를 다시 발생시킨다.

InnoDB는 이 문제를 완화하기 위해 Buffer Pool LRU를 young sublist와 old sublist로 나누어 관리한다. 목적은 명확하다. 반복 접근되는 페이지는 오래 보존하고, 한 번 지나가는 스캔성 페이지는 Buffer Pool 전체를 오염시키지 못하게 제한하는 것이다.

2. Buffer Pool LRU의 기본 구조

InnoDB Buffer Pool은 내부적으로 여러 리스트와 해시 구조를 함께 사용한다. 사용 가능한 빈 페이지는 free list에서 관리되고, 변경되어 아직 디스크에 기록되지 않은 페이지는 flush list에서도 추적된다. LRU 리스트는 “교체 후보”를 관리하는 리스트다. 페이지가 Buffer Pool에 올라오면 LRU 리스트 안에서 위치를 가지며, 메모리가 부족할 때는 리스트의 뒤쪽에서 희생 페이지가 선택된다.

LRU 리스트는 다시 두 영역으로 나뉜다.

  • young sublist: 자주 접근된 것으로 판단되는 페이지가 위치하는 영역이다. 리스트의 앞쪽에 있으며, 일반적으로 오래 살아남을 가능성이 높다.
  • old sublist: 새로 읽혔거나 한 번 지나가는 페이지일 가능성이 있는 영역이다. 리스트의 뒤쪽에 있으며, 교체 후보에 더 가깝다.

InnoDB의 새 페이지는 보통 LRU 리스트의 맨 앞이 아니라 old sublist의 앞쪽, 즉 전체 LRU에서 중간에 가까운 위치로 들어간다. 이후 일정 시간 조건을 만족한 뒤 다시 접근되면 young sublist로 승격될 수 있다. 이 방식은 “처음 읽힌 페이지”와 “반복적으로 필요한 페이지”를 구분하려는 장치다.

flowchart LR
    A[LRU head] --> Y[young sublist\n반복 접근 페이지]
    Y --> M[old sublist 경계\nmidpoint insertion]
    M --> O[old sublist\n신규·스캔성 페이지]
    O --> T[LRU tail\n교체 후보]

    R[새로 디스크에서 읽은 페이지] --> M
    H[일정 시간 이후 재접근] --> Y
    S[대량 테이블 스캔] --> O

위 구조에서 중요한 점은 “LRU 리스트에 들어왔다”가 곧 “장기 보존 대상이 되었다”는 뜻이 아니라는 점이다. 새 페이지는 일단 old 영역에 들어가고, 재접근 패턴을 통해 young 영역으로 올라갈 기회를 얻는다.

3. midpoint insertion: 새 페이지를 중간에 넣는 이유

전통적인 LRU에서는 디스크에서 읽은 새 페이지를 리스트 앞쪽에 넣는다. 이 정책은 일반 파일 캐시나 단순 캐시에서는 자연스럽지만, 데이터베이스의 테이블 스캔에는 취약하다.

예를 들어 Buffer Pool이 이미 주요 OLTP 인덱스와 자주 사용하는 데이터 페이지로 채워져 있다고 가정한다. 이때 통계성 쿼리나 배치가 100GB 테이블을 순차적으로 읽으면, 단순 LRU에서는 순차 스캔으로 읽힌 페이지가 계속 리스트 앞쪽에 들어간다. 그 결과 기존 hot page가 뒤로 밀리고, 작업이 끝난 뒤에는 업무 쿼리가 다시 필요한 페이지를 디스크에서 읽어야 한다.

InnoDB의 midpoint insertion은 새 페이지를 old sublist 쪽에 넣어 이 영향을 제한한다. 새 페이지가 곧바로 young sublist에 들어가지 않으므로, 단발성 스캔 페이지가 hot page를 즉시 밀어내는 현상이 줄어든다. 이 정책은 특히 다음과 같은 상황에서 중요하다.

  • 야간 배치가 큰 테이블을 full scan하는 경우
  • 백업, 검증, 통계 수집, 리포팅 쿼리가 많은 페이지를 짧은 시간에 읽는 경우
  • 잘못된 실행 계획으로 인덱스를 타지 못하고 대량 scan이 발생하는 경우
  • 신규 서비스 배포 후 cold cache 상태에서 여러 대형 쿼리가 동시에 실행되는 경우

4. young 승격과 innodb_old_blocks_time

old sublist에 들어온 페이지가 다시 접근되었다고 해서 항상 young sublist로 승격되는 것은 아니다. InnoDB는 innodb_old_blocks_time 설정을 통해 새로 읽힌 직후의 빠른 재접근을 스캔성 접근으로 볼 수 있게 한다.

innodb_old_blocks_time은 페이지가 old sublist에 들어온 뒤, 해당 시간(ms)이 지나기 전에 다시 접근되면 young 승격을 제한하는 기준이다. 기본값은 일반적으로 1000ms다. 즉, 페이지를 읽은 직후 매우 짧은 시간 안에 다시 접근되는 패턴은 순차 스캔이나 read-ahead에 의해 생긴 일시 접근일 수 있으므로, 곧바로 young으로 올리지 않는다.

이 정책은 직관과 반대로 보일 수 있다. “재접근되었으면 중요한 페이지 아닌가?”라고 생각하기 쉽다. 하지만 대량 스캔에서는 같은 페이지 안의 여러 레코드를 처리하면서 짧은 시간 안에 페이지가 여러 번 참조될 수 있다. 그때마다 페이지를 young으로 승격하면 스캔성 페이지가 결국 Buffer Pool을 점령하게 된다. innodb_old_blocks_time은 이러한 순간적 재참조를 필터링한다.

현재 서버의 관련 설정은 다음과 같이 확인할 수 있다.

SELECT VERSION() AS mysql_version;
SHOW VARIABLES LIKE 'innodb_old_blocks%';

실행 결과(MySQL 8.0.46):

mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> SHOW VARIABLES LIKE 'innodb_old_blocks%';

+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| innodb_old_blocks_pct  | 37    |
| innodb_old_blocks_time | 1000  |
+------------------------+-------+
2 rows in set (0.01 sec)

innodb_old_blocks_pct는 LRU 리스트에서 old sublist가 차지하는 목표 비율을 나타낸다. 값이 높을수록 old 영역이 커지고, 낮을수록 young 영역이 상대적으로 커진다. 일반 운영에서는 기본값을 유지하는 경우가 많지만, 대량 스캔 워크로드와 OLTP hot set이 충돌할 때는 관찰 지표와 함께 조정 후보가 될 수 있다.

5. 운영 관점에서 읽어야 할 상태 지표

Buffer Pool LRU 동작은 상태 변수로도 일부 관찰할 수 있다. 특히 young/non-young 관련 카운터는 페이지가 얼마나 자주 young sublist로 이동하는지, 또는 old 영역에 머무르는지를 해석하는 단서가 된다.

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages%';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_%young%';

실행 결과(MySQL 8.0.46):

mysql> SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';

+---------------------------------------+-------+
| Variable_name                         | Value |
+---------------------------------------+-------+
| Innodb_buffer_pool_read_ahead_rnd     | 0     |
| Innodb_buffer_pool_read_ahead         | 0     |
| Innodb_buffer_pool_read_ahead_evicted | 0     |
| Innodb_buffer_pool_read_requests      | 15361 |
| Innodb_buffer_pool_reads              | 1025  |
+---------------------------------------+-------+
5 rows in set (0.00 sec)

mysql> SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages%';

+----------------------------------+-------+
| Variable_name                    | Value |
+----------------------------------+-------+
| Innodb_buffer_pool_pages_data    | 1171  |
| Innodb_buffer_pool_pages_dirty   | 0     |
| Innodb_buffer_pool_pages_flushed | 194   |
| Innodb_buffer_pool_pages_free    | 2921  |
| Innodb_buffer_pool_pages_misc    | 4     |
| Innodb_buffer_pool_pages_total   | 4096  |
+----------------------------------+-------+
6 rows in set (0.00 sec)

mysql> SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_%young%';

Empty set (0.00 sec)

대표적으로 Innodb_buffer_pool_reads는 Buffer Pool에서 찾지 못해 디스크에서 읽은 횟수다. Innodb_buffer_pool_read_requests는 논리 읽기 요청 수다. 두 값을 함께 보면 대략적인 hit ratio를 계산할 수 있지만, hit ratio 하나만으로 LRU 문제가 해결되지는 않는다. 예를 들어 전체 hit ratio가 높아도 특정 시간대의 배치가 young 영역을 흔들어 업무 쿼리의 tail latency를 악화시킬 수 있다.

Innodb_buffer_pool_pages_data, Innodb_buffer_pool_pages_free, Innodb_buffer_pool_pages_dirty 같은 값은 Buffer Pool 공간의 사용 상태를 보여준다. free page가 거의 없고 dirty page가 증가하며, 동시에 디스크 read가 늘어난다면 LRU 교체와 flush 압력이 함께 발생하고 있을 가능성을 의심해야 한다.

MySQL 8.0에서는 INFORMATION_SCHEMA.INNODB_BUFFER_PAGE_LRU를 통해 Buffer Pool LRU 페이지를 더 세부적으로 볼 수 있다. 다만 이 뷰는 페이지 단위 정보를 많이 노출하므로 운영 서버에서 무분별하게 조회하면 오히려 부담을 줄 수 있다. 작은 범위로 제한해 확인하는 것이 안전하다.

SHOW TABLES FROM information_schema LIKE 'INNODB_BUFFER_PAGE_LRU';
SELECT POOL_ID, LRU_POSITION, SPACE, PAGE_NUMBER, PAGE_TYPE
FROM information_schema.INNODB_BUFFER_PAGE_LRU
ORDER BY POOL_ID, LRU_POSITION
LIMIT 10;

실행 결과(MySQL 8.0.46):

mysql> SHOW TABLES FROM information_schema LIKE 'INNODB_BUFFER_PAGE_LRU';

+-------------------------------------------------------+
| Tables_in_information_schema (INNODB_BUFFER_PAGE_LRU) |
+-------------------------------------------------------+
| INNODB_BUFFER_PAGE_LRU                                |
+-------------------------------------------------------+
1 row in set (0.00 sec)

mysql> SELECT POOL_ID, LRU_POSITION, SPACE, PAGE_NUMBER, PAGE_TYPE
    -> FROM information_schema.INNODB_BUFFER_PAGE_LRU
    -> ORDER BY POOL_ID, LRU_POSITION
    -> LIMIT 10;

+---------+--------------+------------+-------------+------------+
| POOL_ID | LRU_POSITION | SPACE      | PAGE_NUMBER | PAGE_TYPE  |
+---------+--------------+------------+-------------+------------+
|       0 |            0 |          0 |           7 | SYSTEM     |
|       0 |            1 |          0 |           3 | SYSTEM     |
|       0 |            2 |          0 |           2 | INODE      |
|       0 |            3 |          0 |           4 | IBUF_INDEX |
|       0 |            4 |          0 |           6 | SYSTEM     |
|       0 |            5 |          0 |           5 | TRX_SYSTEM |
|       0 |            6 | 4294967279 |           3 | RSEG_ARRAY |
|       0 |            7 | 4294967278 |           3 | RSEG_ARRAY |
|       0 |            8 | 4294967278 |         132 | SYSTEM     |
|       0 |            9 | 4294967278 |         131 | SYSTEM     |
+---------+--------------+------------+-------------+------------+
10 rows in set (0.01 sec)

위 쿼리는 “실제 페이지가 LRU상 어느 위치에 있는지”를 샘플링하는 데 사용할 수 있다. 운영 장애 중에는 이 정보를 전체 덤프하는 방식보다, 특정 tablespace 또는 page type을 좁혀서 보는 접근이 더 안전하다.

6. 대량 스캔과 LRU 오염의 전형적인 흐름

다음 흐름은 현장에서 자주 보는 패턴이다.

sequenceDiagram
    participant OLTP as OLTP 쿼리
    participant BP as InnoDB Buffer Pool
    participant Batch as 배치/리포트 쿼리
    participant Disk as 스토리지

    OLTP->>BP: hot index/data page 반복 접근
    BP-->>OLTP: 메모리 hit
    Batch->>Disk: 대형 테이블 순차 읽기
    Disk-->>BP: 새 페이지 유입
    BP->>BP: old sublist에 midpoint insertion
    Batch->>BP: 짧은 시간 내 스캔성 재접근
    BP->>BP: innodb_old_blocks_time 조건으로 young 승격 제한
    OLTP->>BP: 기존 hot page 접근
    BP-->>OLTP: young 영역에 남은 페이지 hit

이 설계가 없다면 배치가 읽은 페이지는 계속 LRU 앞쪽에 들어가고, OLTP가 쓰던 페이지는 뒤쪽으로 밀려난다. InnoDB의 young/old sublist는 대량 스캔을 완전히 없애지는 못하지만, 스캔이 Buffer Pool 전체를 빠르게 오염시키는 것을 완충한다.

운영자가 주의할 점은 이 기능이 “모든 scan 문제를 해결하는 자동 방어막”은 아니라는 것이다. 테이블 크기가 Buffer Pool보다 훨씬 크고 scan이 지속적으로 반복되면 old 영역만으로 보호하기 어렵다. 또한 scan 쿼리가 실제로 같은 페이지를 시간 간격을 두고 반복 접근하면 young 승격이 발생할 수 있다. 따라서 실행 계획, 인덱스 설계, 배치 시간대, replica 분리, 리소스 제한을 함께 봐야 한다.

7. 설정 변경 판단 기준

innodb_old_blocks_pctinnodb_old_blocks_time은 동적으로 변경할 수 있는 운영 변수다. 그러나 이 값을 자주 바꾸는 방식은 권장되지 않는다. 먼저 워크로드를 이해하고, 변경 목적을 명확히 해야 한다.

SELECT @@GLOBAL.innodb_old_blocks_pct AS old_blocks_pct,
       @@GLOBAL.innodb_old_blocks_time AS old_blocks_time_ms;
SET GLOBAL innodb_old_blocks_time = 1000;
SELECT @@GLOBAL.innodb_old_blocks_time AS old_blocks_time_ms_after;

실행 결과(MySQL 8.0.46):

mysql> SELECT @@GLOBAL.innodb_old_blocks_pct AS old_blocks_pct,
    ->        @@GLOBAL.innodb_old_blocks_time AS old_blocks_time_ms;

+----------------+--------------------+
| old_blocks_pct | old_blocks_time_ms |
+----------------+--------------------+
|             37 |               1000 |
+----------------+--------------------+
1 row in set (0.00 sec)

mysql> SET GLOBAL innodb_old_blocks_time = 1000;

Query OK, 0 rows affected (0.00 sec)

mysql> SELECT @@GLOBAL.innodb_old_blocks_time AS old_blocks_time_ms_after;

+--------------------------+
| old_blocks_time_ms_after |
+--------------------------+
|                     1000 |
+--------------------------+
1 row in set (0.00 sec)

변경을 검토할 수 있는 상황은 다음과 같다.

  • 대량 스캔 직후 OLTP 쿼리의 물리 read가 급증한다.
  • 배치 시간대 이후에도 Buffer Pool hit 패턴이 회복되는 데 오래 걸린다.
  • 인덱스 개선이나 쿼리 분리만으로 scan을 줄이기 어렵다.
  • read replica나 Aurora reader로 분석성 쿼리를 분리했지만, 특정 인스턴스 내부에서는 여전히 캐시 오염이 문제다.

반대로 다음 상황에서는 LRU 설정보다 다른 원인을 먼저 봐야 한다.

  • Buffer Pool 자체가 working set보다 지나치게 작다.
  • 쿼리가 부적절한 실행 계획으로 불필요한 랜덤 I/O를 만든다.
  • dirty page flush가 병목인데 이를 read cache 문제로 오해한다.
  • 스토리지 latency, CPU saturation, row lock wait가 주원인이다.

8. Aurora MySQL에서의 해석 차이

Aurora MySQL도 MySQL 호환 InnoDB 계층과 Buffer Pool을 사용하므로 young/old sublist 개념은 여전히 중요하다. 다만 Aurora는 스토리지 계층이 일반 MySQL의 로컬 디스크 기반 InnoDB와 다르다. 데이터는 분산 스토리지에 저장되고, 인스턴스의 Buffer Pool은 각 DB 인스턴스 메모리에 존재한다.

운영 관점의 차이는 다음과 같다.

  • writer와 reader는 각각 별도의 Buffer Pool을 가진다. reader에서 대량 분석 쿼리를 실행해도 writer의 Buffer Pool을 직접 오염시키지는 않는다.
  • failover 후 새 writer가 된 인스턴스의 Buffer Pool warm 상태가 기존 writer와 다를 수 있다. failover 직후 성능 변동을 LRU 문제만으로 해석하면 안 된다.
  • Aurora의 스토리지 계층은 로그·페이지 관리 방식이 다르지만, SQL 실행이 페이지를 필요로 하고 인스턴스 메모리 캐시에 의존한다는 점은 같다.
  • Performance Insights, CloudWatch 지표, Enhanced Monitoring을 함께 봐야 한다. MySQL 내부 상태 변수만으로 Aurora 스토리지 지연과 인스턴스 캐시 문제를 완전히 분리하기 어렵다.

Aurora에서 분석성 쿼리를 reader로 분리하는 것은 Buffer Pool 오염을 격리하는 좋은 운영 전략이다. 다만 reader 내부에서도 여러 업무가 섞이면 동일한 LRU 문제가 발생할 수 있으므로, reader 역할 분리와 쿼리 스케줄링은 여전히 필요하다.

9. 흔한 오해와 주의점

첫째, Buffer Pool hit ratio가 높으면 LRU 문제도 없다고 단정하면 안 된다. 전체 평균 hit ratio는 짧은 시간대의 캐시 오염, 특정 테이블의 eviction, p95/p99 latency 악화를 가릴 수 있다. 시간대별 지표와 쿼리별 응답 시간을 함께 봐야 한다.

둘째, innodb_old_blocks_time을 크게 올리면 항상 좋다는 생각도 위험하다. 너무 큰 값은 실제로 반복 사용되는 페이지의 young 승격을 지연시킬 수 있다. 스캔 오염을 줄이는 대신 hot set 적응 속도를 늦출 수 있으므로, 변경 전후를 비교해야 한다.

셋째, INNODB_BUFFER_PAGE_LRU를 운영 서버에서 자주 전체 조회하는 것은 피해야 한다. 페이지 단위 메타데이터를 대량으로 읽는 진단 자체가 서버에 부담을 줄 수 있다. 필요한 컬럼과 조건, LIMIT를 사용해 샘플링하듯 확인한다.

넷째, LRU 튜닝은 쿼리 튜닝의 대체재가 아니다. 잘못된 인덱스, 통계 불일치, 부적절한 조인 순서로 인해 full scan이 발생한다면 먼저 실행 계획과 스키마를 고쳐야 한다. LRU 정책은 피해를 줄이는 장치이지, 비효율적 SQL을 정상화하는 장치가 아니다.

10. 운영 점검 체크리스트

  • Innodb_buffer_pool_reads
  • innodb_old_blocks_pct, innodb_old_blocks_time
  • 운영 서버에서 INNODB_BUFFER_PAGE_LRU

11. 결론

Buffer Pool LRU의 young/old sublist는 InnoDB가 OLTP hot page와 대량 스캔 페이지를 구분하기 위한 핵심 장치다. 새 페이지를 곧바로 LRU head에 넣지 않고 old sublist에 삽입하며, innodb_old_blocks_time을 통해 짧은 시간의 스캔성 재접근을 young 승격으로 오해하지 않게 한다.

운영자는 이 구조를 알아야 대량 배치 이후의 캐시 오염, 물리 read 증가, failover 후 성능 변동, Aurora reader 분리 효과를 더 정확히 해석할 수 있다. 다음 단계에서는 Buffer Pool의 dirty page, flush list, checkpoint 동작을 함께 이해해야 한다. LRU가 “무엇을 메모리에 남길 것인가”의 문제라면, flush와 checkpoint는 “변경된 페이지를 언제 어떻게 영속화할 것인가”의 문제이기 때문이다.