포스트

페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까

메모리가 꽉 찼을 때 어떤 페이지를 내보낼까? FIFO·LRU·Clock 알고리즘과 벨라디 이상현상, working set과 thrashing을 시각화합니다.

페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까

“한 페이지를 잘못 쫓아내면, 시스템 전체가 멈춘다”

물리 메모리는 꽉 찼는데 새 페이지를 들여야 합니다(페이지 폴트). 빈 프레임이 없으니, 커널은 지금 들어 있는 페이지 중 하나를 골라 내쫓아야(evict) 합니다. 이 “누구를 쫓아낼까”를 정하는 규칙이 페이지 교체 알고리즘입니다.

사소해 보이지만 아닙니다. 방금 쫓아낸 페이지를 다음 순간 또 쓰면, 즉시 다시 폴트가 나서 디스크를 또 긁습니다. 이 선택을 계속 틀리면 시스템은 일은 안 하고 페이지를 디스크와 메모리 사이로 퍼 나르기만 합니다 — 이게 thrashing이고, CPU 사용률은 바닥인데 디스크만 100%인 그 악명 높은 상태입니다. 이 글은 OPT부터 리눅스가 실제로 쓰는 근사 LRU까지, 무엇을 최적화하려 애쓰는지를 따라갑니다.

목표는 단 하나: 폴트(특히 major fault)를 줄여라

교체 알고리즘의 성패는 앞으로 안 쓸 페이지를 정확히 쫓아내는가에 달렸습니다. 쫓아낸 페이지를 다시 안 쓰면 손해가 없고, 곧 다시 쓰면 디스크를 왕복하는 major fault가 터집니다. major fault 한 번은 수 ms — CPU 명령 수백만 개에 해당하는 시간입니다.

미래를 모르니, 모든 실전 알고리즘은 과거로 미래를 추측합니다. 그 베팅이 바로 지역성(locality)입니다 — 최근 쓴 페이지는 곧 또 쓸 확률이 높다(시간 지역성). 좋은 알고리즘일수록 “이론적 최적”에 가깝게 이 베팅을 잘합니다.

알고리즘의 진화 — OPT → FIFO → LRU → Clock

알고리즘희생자 선택 기준장점치명적 약점
OPT (Belady)미래에 가장 늦게 쓸 페이지폴트 수 이론적 최소미래를 알아야 함 → 구현 불가(하한선 측정용)
FIFO가장 먼저 들어온 페이지구현 초간단(큐)지역성 무시 + 벨라디 이상현상
LRU가장 오래 안 쓴 페이지OPT에 근접(지역성 활용)매 접근마다 갱신 → 비용 과다
ClockLRU를 reference bit로 근사싸고 충분히 좋음LRU의 근사일 뿐

FIFO와 벨라디 이상현상. FIFO는 “프레임을 늘리면 폴트가 준다”는 당연한 직관을 깨뜨립니다. 참조열 1 2 3 4 1 2 5 1 2 3 4 5를 FIFO로 돌리면 프레임 3개일 때 9번, 4개일 때 10번 폴트가 납니다 — 메모리를 늘렸는데 더 나빠졌습니다. 들어온 순서는 “다시 쓸지”와 무관하기 때문입니다. LRU·OPT 계열(스택 알고리즘)은 이 이상현상이 없습니다.

현실 체크 — “정확한 LRU는 왜 아무도 안 쓰나.” 진짜 LRU를 구현하려면 메모리 접근이 일어날 때마다 그 페이지를 리스트 맨 앞으로 옮기거나 타임스탬프를 찍어야 합니다. 접근은 1초에 수십억 번 일어납니다. 그 모든 접근에 자료구조 갱신을 끼워 넣는 건 하드웨어로도 소프트웨어로도 감당이 안 됩니다. 그래서 현실은 LRU를 싸게 흉내 냅니다 — 그게 Clock입니다.

Clock(2차 기회) 알고리즘 — LRU를 reference bit 하나로 흉내내기

핵심 아이디어: 페이지마다 참조 비트(reference bit) 1개만 둡니다. CPU가 그 페이지를 건드리면 하드웨어(MMU)가 이 비트를 1로 세팅합니다. 페이지들을 원형 큐에 배치하고 시계바늘을 돌립니다.

  • 바늘이 가리킨 페이지의 ref 비트가 1이면 → “최근에 썼구나, 한 번 봐준다” → 비트를 0으로 지우고 그냥 통과(2차 기회).
  • ref 비트가 0이면 → “한 바퀴 도는 동안 안 썼네” → 이 페이지를 교체.

아래 애니메이션에서 파란 바늘이 돌며 ref=1인 P0·P1에 2차 기회(1→0)를 주고 통과한 뒤, ref=0인 P2를 희생시켜 새 페이지(P9)를 적재합니다.

Clock 시계바늘 회전 P0 ref 1 ref 0 P1 ref 1 ref 0 P2ref 0 P9신규 ✕ 교체! P3ref 1 P4ref 1 P5ref 1 ref 1 → 2차 기회(비트만 0으로) · ref 0 → 교체

이게 거의 공짜인 이유: 알고리즘이 하는 일은 바늘을 한 칸 옮기고 비트 하나를 보는 것뿐이고, 비트 세팅은 메모리 접근 때 하드웨어가 자동으로 해줍니다. 소프트웨어는 매 접근에 아무 일도 안 합니다. 그래서 “정확한 LRU의 99% 효과를 1% 비용으로” 얻습니다. 변형으로 수정 비트(dirty bit)까지 보는 버전이 있어, 깨끗한(수정 안 된) 페이지를 먼저 쫓아냅니다 — 디스크에 다시 쓸 필요가 없어 더 싸기 때문입니다.

리눅스의 현실: 단일 시계가 아니라 두 개의 LRU 리스트

실제 리눅스는 단순 Clock이 아니라 페이지를 active 리스트와 inactive 리스트 두 개로 관리합니다.

flowchart LR
  NEW["새 페이지"] --> INACT["inactive LRU<br/>(곧 회수 후보)"]
  INACT -->|"다시 참조됨<br/>(승격)"| ACT["active LRU<br/>(자주 쓰는 페이지)"]
  ACT -->|"오래 안 쓰임<br/>(강등)"| INACT
  INACT -->|"끝까지 안 쓰임"| EVICT["회수 / 스왑아웃"]

새 페이지는 일단 inactive로 들어가고, 한 번 더 참조되면 active로 승격됩니다. 메모리 압박이 오면 커널은 inactive 리스트의 꼬리부터 회수합니다. 이렇게 두 단계로 나누면, “딱 한 번 훑고 마는” 대용량 스트리밍(예: 큰 파일 한 번 읽기)이 자주 쓰는 작업 집합을 active에서 밀어내는 사고를 막습니다 — scan resistance. (최신 커널은 이를 더 정교화한 MGLRU(다세대 LRU)로 진화했습니다.)

Thrashing: 워킹셋이 메모리를 넘는 순간

교체 알고리즘이 아무리 똑똑해도, 진짜로 자주 쓰는 페이지들의 합(working set)이 물리 메모리보다 크면 답이 없습니다. 무엇을 쫓아내든 곧 다시 필요하니까요. 시스템은 일은 안 하고 페이지만 퍼 나르고, CPU 사용률이 역설적으로 폭락합니다. 이게 thrashing입니다.

아래는 다중프로그래밍 정도(메모리 수요)를 키울 때 CPU 이용률의 변화입니다. 최적 지점까지는 올라가다가, 워킹셋 합이 메모리를 넘는 순간 절벽처럼 무너집니다.

CPU 이용률 다중프로그래밍 정도 / 워킹셋 수요 → 최적 지점 THRASHING CPU는 노는데 디스크만 100%

현실 체크 — “swap이 나쁜 게 아니라 thrashing이 나쁘다.” 스왑이 켜져 있다고 느린 게 아닙니다. 거의 안 쓰는 페이지를 스왑으로 내려 RAM을 페이지 캐시에 더 주는 건 이득입니다. 문제는 자주 쓰는 페이지가 스왑을 왕복할 때(thrashing)입니다. vmstat의 si/so(스왑 in/out)가 지속적으로 높으면 thrashing 신호 — 답은 알고리즘 튜닝이 아니라 메모리를 늘리거나, 동시 작업을 줄이거나, 워킹셋을 줄이는 것입니다. 그래서 컨테이너에 메모리 한도를 둘 때 cgroup 한도가 너무 빡빡하면 OOM 대신 thrashing부터 옵니다.

직접 들여다보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 스왑 적극성(0~200, 기본 60). 낮추면 익명 페이지를 덜 내보냄
cat /proc/sys/vm/swappiness
sudo sysctl vm.swappiness=10           # DB 서버에서 흔한 튜닝

# active/inactive LRU 리스트 크기 — 회수 후보가 얼마나 쌓였나
grep -E 'Active|Inactive' /proc/meminfo

# thrashing 실시간 감시: si/so가 계속 0보다 크면 위험
vmstat 1                                # si(swap-in) so(swap-out) 컬럼 주목
sar -B 1                                # pgpgin/out, majflt/s, pgscan(회수 스캔)
free -m                                 # used vs available, swap used

# 최신 커널: 메모리 압박 정도를 직접 수치로 (PSI)
cat /proc/pressure/memory               # some/full avg10 — 압박이 실제 얼마나 멈춤을 유발했나

/proc/pressure/memory(PSI, Pressure Stall Information)는 “메모리 부족으로 프로세스가 멈춰 있던 시간 비율”을 직접 알려줘, thrashing을 정량적으로 잡는 현대적 무기입니다.

면접/리뷰 단골 질문

  • Q. OPT는 왜 못 쓰나? → 미래 참조열을 알아야 하기 때문. 구현은 불가하고, 다른 알고리즘이 얼마나 OPT에 가까운지 재는 하한선으로만 쓴다.
  • Q. 벨라디 이상현상이 뭐고 왜 생기나? → FIFO에서 프레임을 늘렸는데 폴트가 더 느는 현상. 들어온 순서가 미래 사용과 무관하기 때문. LRU/OPT 같은 스택 알고리즘엔 없다.
  • Q. 정확한 LRU 대신 Clock을 쓰는 이유? → 정확한 LRU는 매 메모리 접근마다 자료구조를 갱신해야 해 비용이 비현실적. Clock은 reference bit 1개 + 원형 스캔으로 LRU를 싸게 근사한다(비트 세팅은 HW가 대신).
  • Q. thrashing의 원인과 해결은? → 워킹셋 합 > 물리 메모리. 교체 알고리즘으론 못 고친다. 메모리 증설/동시성 축소/워킹셋 감소가 답. swappiness는 증상 완화일 뿐.
  • Q. 리눅스가 LRU 리스트를 둘로 나눈 이유는? → 한 번만 훑는 대용량 스캔이 자주 쓰는 페이지를 밀어내지 못하게(scan resistance). 새 페이지는 inactive로, 재참조되면 active로 승격.

정리

  • 물리 메모리가 꽉 차면 새 페이지를 위해 희생자를 골라야 한다 = 페이지 교체. 목표는 (major) 폴트 최소화.
  • 알고리즘 계보: OPT(이론 최적·구현 불가) → FIFO(간단하나 벨라디 이상현상) → LRU(좋으나 매 접근 갱신 비용) → Clock(reference bit로 LRU 근사, 사실상 표준).
  • 리눅스는 active/inactive 2-리스트 LRU(+ 최신 MGLRU)로 scan resistance까지 챙긴다.
  • thrashing은 워킹셋 > 메모리일 때 발생 — 무엇을 쫓아내도 곧 다시 필요해 CPU가 논다. 교체 알고리즘으로 못 고치고 메모리·동시성·워킹셋으로 푼다.
  • 진단은 vmstat의 si/so, sar -B의 majflt, 그리고 /proc/pressure/memory(PSI).

다음 글: 페이지 단위 회수까지 봤으니, 이제 한 단계 위 — 유저 공간에서 malloc(16)이 매번 커널을 부르지 않고 큰 덩어리를 잘게 나눠 쓰는 메모리 할당자: malloc·buddy·slab로 올라갑니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까 지금 보는 글
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)