Skip to Content

8장. 무엇이 메모리에 남는가 — 버퍼풀과 Young/Old LRU

{/* 삽화 자리 — 선반의 앞칸과 뒷칸. 한 번 스쳐간 책은 앞칸으로 승격되지 못하고 뒷칸에서 밀려난다 */}

같은 쿼리, 같은 행 수인데 디스크 읽기가 67회와 3,542회로 갈렸습니다. 53배예요. 바뀐 건 인덱스 순서가 PK 순서와 맞느냐, 그리고 버퍼풀이 데이터보다 작다는 것. 둘이 만나야 청구서가 나옵니다.

면접 실전 질문: ① 버퍼풀이 뭔가요? ② 대용량 풀스캔 한 번이 캐시를 다 날리지 않나요? ③ 인덱스를 걸었는데 어떤 날은 빠르고 어떤 날은 느린 이유는요?


배경 — 7장이 못 푼 질문

7장에서 가설을 하나 세웠다가 못 풀었습니다.

세컨더리 인덱스를 훑으며 왕복할 때, 인덱스 순서가 PK 순서와 맞으면 클러스터드의 같은 페이지를 연달아 두드리니 쌀 것이다 — 그럴듯했죠. 그런데 두 테이블의 페이지 요청이 똑같이 나왔습니다.

이유는 지표를 잘못 골랐기 때문이었습니다. 우리가 센 건 “페이지를 몇 번 달라고 했는가”인데, 그 값은 페이지가 메모리에 있든 없든 똑같습니다. 순서가 갈리는 자리는 그 요청 중 몇 개가 디스크까지 내려가느냐입니다.

7장 실험에서는 물리 read가 전 구간 0이었습니다. 데이터가 전부 버퍼풀에 들어 있었거든요.

그러면 데이터가 다 안 들어가게 만들면 됩니다.

스토리 — 버퍼풀이라는 층

InnoDB는 디스크의 페이지를 메모리에 캐싱합니다. 그 공간이 버퍼풀이에요.

여기서 흔한 오해를 먼저 지웁시다. 버퍼풀은 테이블의 모든 행을 담는 곳이 아닙니다. 실제로 접근된 16KB 페이지만 올라옵니다. 100GB 테이블에 버퍼풀이 8GB라면 자주 조회되는 부분만 상주하는 거죠.

이 층에서 이 책이 세어온 숫자가 둘로 갈립니다.

페이지 요청 (논리) → 버퍼풀에 "이 페이지 줘"라고 한 횟수 ├─ 있으면 (hit) → 메모리에서 꺼낸다. 거의 공짜 └─ 없으면 (miss) → 디스크에서 읽는다 = 물리 read

1장부터 7장까지 우리가 센 건 전부 위쪽입니다. 아래쪽이 실제 청구서고요.

그런데 풀스캔 한 번이 캐시를 다 날리지 않나

버퍼풀이 가득 찼을 때 새 페이지가 들어오면 누군가는 나가야 합니다. 보통 가장 오래 안 쓴 것부터 내보내죠 — LRU입니다.

여기 함정이 있습니다. 1억 행짜리 테이블을 한 번 풀스캔하면 버퍼풀이 그 페이지로 가득 찹니다. 순수 LRU라면 자주 쓰던 페이지가 전부 밀려나요. 배치 한 번, 조사 쿼리 한 번에 서비스 전체가 느려지는 겁니다.

InnoDB는 LRU 리스트를 young과 old 두 구간으로 나눠 이걸 막습니다.

[ LRU 리스트 ] 머리 꼬리 ┌────────── young (약 5/8) ──────┬── old (약 3/8) ──┐ ← 비율은 innodb_old_blocks_pct(기본 37) │ 자주 다시 읽히는 페이지 │ 방금 읽어온 페이지 │ └────────────────────────────────┴──────────────────┘ ▲ 새 페이지 진입 ▲ 여기서 밀려난다

📄 문서 기반 (미검증) — 새로 읽은 페이지는 리스트 머리가 아니라 old 구간의 머리로 들어갑니다. 그리고 innodb_old_blocks_time(기본 1,000ms)이 지난 뒤에 다시 읽혀야 young으로 승격됩니다.

한 번 읽고 마는 스캔 페이지는 old에 잠깐 머물다 그대로 빠져나갑니다. 뒷칸에 놓였다가 다시 손이 안 가서 치워지는 책이죠.

설명은 그럴듯한데, 실제로 작동하는지 재보면 됩니다. 이 장은 두 가지를 잽니다 — 7장이 남긴 순서 가설, 그리고 이 방어막이요.

핵심 ① — 7장이 못 본 것은 디스크 쪽에 있었습니다

버퍼풀을 64MB로 줄였습니다. rt_ordered 95MB, rt_random 108MB라 둘 다 안 들어갑니다.

# 1G 미만으로 줄이려면 인스턴스와 청크도 같이 낮춰야 한다. # 버퍼풀 최소 크기 = chunk_size × instances 이기 때문이다. - --innodb-buffer-pool-size=64M - --innodb-buffer-pool-instances=1 - --innodb-buffer-pool-chunk-size=8M

7장과 같은 쿼리를 매 회차 콜드 상태에서 돌렸습니다. 500MB짜리 다른 테이블을 두 번 훑어 대상 페이지를 확실히 밀어낸 뒤에요.

테이블실제 행 수페이지 요청 (논리)물리 read행당 물리 read시간
rt_ordered5,91718,71667~780.0139.4 ms
rt_random5,91722,1782,575~3,5420.5028.9 ms

✅ 실측 (MySQL 8.0.46 / Docker / Apple M1 / macOS, 2026-08)

  • 환경 — 버퍼풀 64MB, 적응형 해시 인덱스 끔(7장과 조건을 맞췄습니다). rt_ordered 95MB, rt_random 108MB로 둘 다 버퍼풀에 안 들어갑니다.
  • 통제 — 매 회차 앞에서 500MB짜리 다른 테이블을 두 번 훑어 대상 페이지를 밀어냈습니다. 버퍼풀을 비운 게 아니라 남의 페이지로 채워 밀어낸 겁니다. 리드어헤드로 당겨온 페이지는 양쪽 모두 0이었어요 — rt_ordered가 싼 게 리드어헤드 덕이 아니라는 뜻입니다.
  • 값 — 논리 요청은 회차 간 동일했고, 물리 read는 rt_ordered 6778 / rt_random 2,5753,542로 흔들려 범위로 적었습니다. 시간은 EXPLAIN ANALYZE의 actual time 최솟값입니다.
  • 재현 — BP_SIZE=64M BP_INSTANCES=1 BP_CHUNK=8M docker compose up -dCOUNTS=10000 RUNS=2 ./ch08/coldscan.sh. 전체 소스: docs/book/code/db-index/ch08/coldscan.sh

물리 read가 70회 안팎 대 3,000회 안팎입니다. 40배가 넘어요. 시간도 9.4ms 대 28.9ms로 3.1배 벌어졌습니다.

그런데 페이지 요청은 1.18배밖에 안 다릅니다(18,716 대 22,178). 7장에서 두 값이 똑같이 나왔던 게 우연이 아니었던 거예요. 논리 요청은 순서에 거의 둔감하고, 순서가 청구되는 건 디스크 쪽입니다.

왜 이만큼 갈리는지는 산수로 나옵니다. 이 테이블의 클러스터드 인덱스는 4,967페이지고 페이지당 101행이 들어갑니다.

순서 일치 : 5,917행이 연달아 있으니 5,917 ÷ 101 = 59페이지 + 세컨더리 리프 약 13장 = 72장 (실측 67~78) 무작위 : 5,917행이 4,967페이지에 흩뿌려진다 4,967 × (1 − e^(−5,917/4,967)) = 3,458장 (실측 2,575~3,542)

서로 다른 페이지를 몇 장 건드리느냐의 차이입니다. 재축출도 얹히지만 지배적인 건 이 기하예요. 이 책이 계속 말해온 것 — 단위는 행이 아니라 페이지 — 이 여기서 디스크 청구서로 나타납니다.

7장의 가설은 옳았습니다. 다만 7장의 지표로는 볼 수 없었던 거고요.

짚어둘 게 하나 있습니다. 두 테이블은 순서만 다른 게 아닙니다 — rt_random이 13% 큽니다(7장에서 본 충전율 64% 때문이에요). 다만 13%짜리 요인이 40배를 만들 수는 없습니다.

여는 글의 자릿수 차이가 여기서 설명됩니다

여는 글에서 열어둔 숫자가 있었습니다. 제 노트북은 200만 행을 504밀리초에 읽었는데 운영에서는 같은 성격의 스캔이 41.17초였죠. 82배 차이였습니다.

82배는 4.4배 × 18배입니다. 4.4배는 테이블 크기 차이고, 18배가 초당 읽은 양의 차이예요 — 노트북 988MB 대 운영 54MB. 같은 페이지 하나가 메모리에 있으면 공짜에 가깝고, 디스크에서 가져와야 하면 밀리초를 잡아먹습니다.

이 장의 표가 그 환율의 방향을 보여줍니다. 다만 절대값은 아니에요.

이 실험의 물리 read 한 번을 시간으로 환산하면 (28.9 − 9.4)ms ÷ 3,000회 ≈ 6.5µs입니다. 그런데 NVMe SSD의 16KB 랜덤 읽기는 보통 50~100µs예요. 열 배쯤 빠릅니다.

Docker Desktop for Mac은 데이터파일을 리눅스 VM의 가상 디스크에 얹고 그 뒤에 호스트 페이지 캐시가 있습니다. InnoDB가 “물리 read”라 부른 것의 상당수가 진짜 디스크가 아니라 그 캐시에 닿은 겁니다. 운영의 41.17초를 페이지로 나누면 페이지당 0.3ms니 이 실험보다 40배 넘게 비쌉니다.

비율(40배)은 유효하고, 마이크로초 값은 이 환경 고유값입니다.

순차인데 왜 54MB/s였나

1장에서 미뤄둔 질문이 하나 더 있습니다. 운영의 그 스캔은 초당 54MB였는데, 순차 읽기치고 낮다고 했죠.

이 장의 표가 후보를 줍니다. 논리적으로 순차인 스캔이 물리적으로는 랜덤일 수 있습니다.

클러스터드 인덱스 스캔은 리프 사슬 순서로 갑니다(3장). 그게 파일 위 물리 순서와 같다는 보장은 없어요 — 3장에서 정렬해 만든 인덱스조차 체인의 5%가 건너뛰었습니다. 삽입과 삭제가 몇 달 쌓인 운영 테이블이라면 더 어긋나 있었을 겁니다.

거기에 그 테이블은 JSON 컬럼을 들고 있었습니다. 값이 커서 오버플로로 빠졌다면(5장) 리프 한 장을 읽을 때마다 다른 자리로 한 번 더 튀어야 하고요.

📄 추정 — 셋 다 그럴듯하지만 운영 DB를 계측할 수 없어 무엇이 지배적이었는지는 못 가렸습니다.

핵심 ② — 방어막은 절반을 지킵니다

두 번째 실험입니다. 핫 워킹셋을 만들어 놓고 대용량 풀스캔을 한 번 돌린 뒤, 살아남았는지 셌습니다.

  1. rt_ordered 인덱스를 다섯 번 훑어 버퍼풀에 올립니다. 한 번만 훑으면 old에 남아요 — 승격 조건이 “1초 뒤 재접근”이라, 회차 사이가 1초 넘게 벌어지는 스캔을 다섯 번 돌려 확실히 young으로 올렸습니다.
  2. 버퍼풀에 올라온 그 테이블의 페이지 수를 셉니다.
  3. 500MB짜리 다른 테이블을 한 번 훑습니다 — 버퍼풀보다 8배 큽니다.
  4. 다시 셉니다.
innodb_old_blocks_time스캔 전스캔 후살아남은 비율
1,000 (기본값)1,06860556.6%
0 (방어 해제)1,06800.0%

1,068장이라는 수는 7장에서 잰 idx_txno 리프 1,063장과 맞물립니다 — 인덱스가 통째로 올라온 겁니다. 64MB 버퍼풀의 26%예요.

✅ 실측 (같은 환경. 버퍼풀 페이지 수는 information_schema.INNODB_BUFFER_PAGE이고 클러스터드와 세컨더리를 합산한 값입니다. 이 뷰 조회 자체가 버퍼풀 전체를 훑으니 운영에서는 돌리지 마세요. 전체 소스: docs/book/code/db-index/ch08/scanresist.sh)

방어를 끄면 한 장도 안 남습니다. 8배 큰 테이블을 한 번 훑었을 뿐인데요. old_blocks_time=0이면 창 자체가 없어져서, 스캔이 한 페이지 안의 행들을 훑는 그 재접근만으로 즉시 승격됩니다.

켜두면 절반 넘게 버팁니다. 완벽하진 않아요 — 43%는 밀려났으니까요. 하지만 “전부 날아간다”와 “절반이 남는다”는 서비스 입장에서 완전히 다른 이야기입니다.

45초짜리 풀스캔이 서비스를 안 죽인 이유

여는 글의 그 로그 테이블로 돌아가 봅시다. 인덱스가 없던 몇 달 동안, 누군가 조사할 때마다 2.2GB 풀스캔이 돌았습니다.

그때마다 버퍼풀이 로그 페이지로 가득 찼다면 결제 경로도 매번 같이 느려졌어야 합니다. 적어도 결제 지연으로 보고가 올라온 적은 없었습니다. 다만 그때 결제 경로 응답시간을 따로 재두지는 않았어요 — 부재의 관찰이지 측정이 아닙니다.

이 표가 그 이유예요. 한 번 읽고 마는 스캔 페이지는 old 구간에 머물다 승격되지 못한 채 빠져나갑니다. 핫 워킹셋은 young에 남아 있고요.

그래서 그 45초는 조사하는 사람만 아팠던 문제였습니다. 장애를 막는 일이 아니라, 장애를 조사하는 사람의 시간을 되찾는 일이었던 거죠.

(📄 운영 관찰 인용 — 인덱스는 공짜가 아닙니다. 측정이 아닙니다)

그래도 무너지는 자리

방어막이 만능은 아닙니다.

스캔이 같은 페이지를 1초가 지난 뒤에 다시 건드리면 그 페이지가 young으로 올라갑니다. 1초라는 창은 “한 번 훑고 지나가는 접근”을 걸러내라고 있는 건데, 조인이나 정렬이 끼면 한 스캔이 같은 페이지를 몇 초 간격으로 되돌아와 볼 수 있어요. 그러면 스캔 페이지가 승격됩니다.

그리고 방어막이 지키는 건 캐시지 디스크가 아닙니다. 스캔이 도는 동안 대역폭과 IOPS는 다른 쿼리와 그대로 나눠 씁니다. 버퍼풀은 멀쩡한데 서비스가 느려질 수 있어요.

스캔이 길면 old 구간 자체가 회전합니다. old가 버퍼풀의 3/8이니, 그보다 훨씬 큰 스캔은 old 안에서 자기가 자기를 밀어냅니다. 위 실험에서 43%가 밀려난 것도 그 몫으로 보이고요 — 정확히 갈라내지는 못했습니다.

정리

  • 버퍼풀은 테이블 전체가 아니라 접근된 16KB 페이지만 담습니다. 페이지 요청 중 버퍼풀에 없는 것만 디스크로 내려가고, 그게 물리 read입니다.
  • 7장이 못 푼 순서 가설을 여기서 갚았습니다. 버퍼풀을 데이터보다 작게 만들자 물리 read가 70회 안팎 대 3,000회 안팎으로 40배 넘게 갈렸습니다. 반면 논리 요청은 1.19배 차이뿐이었어요. 가설은 옳았고, 7장의 지표로 볼 수 없었을 뿐입니다.
  • 시간도 9.4ms 대 28.9ms로 3.1배 벌어졌습니다. 여는 글이 열어둔 “노트북 504ms 대 운영 41초”의 환율이 이겁니다.
  • 순수 LRU라면 대용량 스캔 한 번에 캐시가 전멸합니다. InnoDB는 LRU를 young/old로 나눠 막습니다 — 새 페이지는 old로 들어오고, 1초 뒤 다시 읽혀야 young으로 올라갑니다.
  • 실측: 방어를 끄면 핫 워킹셋이 0% 남고, 기본 설정에서는 56.6%가 살아남았습니다.
  • 그래서 인덱스 없는 테이블의 45초 풀스캔이 결제 경로를 안 죽였습니다. 조사하는 사람만 아팠던 문제였어요.

생각해볼 질문

  1. 이 실험에서 물리 read 한 번은 약 6.5µs였습니다. 그런데 NVMe SSD의 16KB 랜덤 읽기는 보통 50~100µs예요. 왜 이 실험이 열 배나 빠를까요? (힌트: 이 MySQL은 어디서 돌고 있나요?)
  2. 방어막이 56.6%를 지켰습니다. 그럼 버퍼풀을 두 배로 키우면 생존율은 어떻게 될까요? 그리고 스캔하는 테이블이 두 배 커지면요?
  3. 이 장에서 rt_random이 비쌌던 건 무작위 순서로 넣었기 때문입니다. 그 무작위 삽입 자체는 쓰기 쪽에 무엇을 청구했을까요? (13·14장)

여기까지가 제2부입니다. 페이지가 무엇인지(5장), 테이블이 사실 인덱스라는 것(6장), 세컨더리가 PK를 들고 다시 내려간다는 것(7장), 그리고 그 페이지가 메모리에 있느냐가 값을 정한다는 것(8장)을 봤습니다.

이제 도구는 다 갖췄습니다. 그런데 인덱스를 걸어놓고도 안 타는 일이 생깁니다. 옵티마이저는 무엇을 보고 그런 판단을 내릴까요. 그리고 우리는 그걸 어떻게 알아챌까요.

그런데 7장과 8장 내내 우리는 FORCE INDEX로 옵티마이저를 묶어놨습니다. 손을 놓으면 옵티마이저는 무엇을 보고 판단할까요.

9장. EXPLAIN 읽는 법 — type·key·rows·filtered

Last updated on