
스래싱: 페이지 폴트의 악순환
컴퓨터가 멈췄다. 마우스는 움직이는데 클릭이 안 된다. 하드디스크는 쉴 새 없이 긁고 있다. 이것이 스래싱이다.

컴퓨터가 멈췄다. 마우스는 움직이는데 클릭이 안 된다. 하드디스크는 쉴 새 없이 긁고 있다. 이것이 스래싱이다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

2020년, 내가 막 스타트업을 시작했을 때였다. 4GB RAM에 HDD 장착된 노트북으로 Chrome 탭 20개, Slack, VSCode, Docker까지 돌리고 있었다. 어느 순간부터 컴퓨터가 완전히 얼어붙었다. 마우스 커서는 움직였지만 클릭을 해도 아무 반응이 없었고, "드르륵... 드르륵..." 하는 하드디스크 읽는 소리만 요란하게 들렸다. 작업 관리자를 켜보려 Ctrl+Alt+Del을 눌렀지만 5분이 지나도 화면은 뜨지 않았다.
그때는 몰랐다. 이게 바로 스래싱(Thrashing)이라는 현상이었다는 걸. CPU는 100% 사용률을 보이지 않았고, 오히려 20% 정도만 쓰고 있었다. 그런데도 컴퓨터는 사실상 죽어있었다. 도대체 무슨 일이 벌어진 걸까?
처음에는 단순히 "메모리 부족" 문제라고 생각했다. 그래서 불필요한 프로그램들을 종료하려 했다. 하지만 스래싱 상황에서는 프로그램을 종료하는 것조차 불가능했다. 왜냐하면 프로그램을 종료하려면 그 프로그램을 메모리에 로드해야 하는데, 메모리에 로드하려면 다른 프로그램을 쫓아내야 하고, 쫓아낸 프로그램이 곧바로 다시 필요해지는 악순환이 반복됐기 때문이다.
가상 메모리(Virtual Memory)는 원래 좋은 기술이다. RAM보다 큰 프로그램도 실행할 수 있게 해주고, 여러 프로그램이 같은 물리 메모리를 공유하면서도 마치 독립적인 메모리를 가진 것처럼 작동하게 해준다. 운영체제는 페이지 테이블(Page Table)을 통해 가상 주소를 물리 주소로 변환하고, 물리 메모리에 없는 페이지가 필요하면 디스크에서 가져온다. 이 과정을 페이지 폴트(Page Fault)라고 한다.
문제는 페이지 폴트가 너무 자주 발생할 때다. 마치 도서관에 좌석은 10개밖에 없는데 학생이 100명이 와서 자리 싸움을 하는 것과 같다. A학생이 앉으려면 B학생을 내보내야 하고, B학생이 다시 들어오려면 C학생을 내보내야 하고... 결국 아무도 제대로 공부를 못 하고 자리 이동만 하느라 시간을 다 보내는 상황이 된다.
스래싱을 제대로 이해한 건 Peter Denning의 1968년 논문을 읽고 나서였다. 그는 워킹 셋 모델(Working Set Model)이라는 개념을 제안했다. 프로그램은 특정 시간 동안 특정 페이지들을 집중적으로 참조한다는 것이다. 이 자주 참조되는 페이지들의 집합을 워킹 셋(Working Set)이라고 부른다.
예를 들어, for 루프로 배열을 순회하는 코드를 생각해보자:
int sum = 0;
for (int i = 0; i < 10000; i++) {
sum += array[i];
}
이 코드가 실행되는 동안에는:
sum과 i가 담긴 스택 페이지array가 담긴 데이터 페이지들이 페이지들이 워킹 셋을 구성한다. 루프가 돌아가는 동안 이 페이지들은 계속 재참조된다. 이것이 바로 참조의 지역성(Locality of Reference)이다.
참조의 지역성은 두 가지로 나뉜다:
운영체제가 프로세스에게 워킹 셋을 수용할 만큼의 물리 메모리(프레임)를 할당해주면 페이지 폴트가 거의 발생하지 않는다. 하지만 물리 메모리가 부족해서 워킹 셋보다 적은 프레임만 할당하면? 페이지 폴트 캐스케이드(Page Fault Cascade)가 발생한다.
프로세스 A: 워킹 셋 10개 페이지, 할당받은 프레임 3개
프로세스 B: 워킹 셋 8개 페이지, 할당받은 프레임 2개
프로세스 C: 워킹 셋 12개 페이지, 할당받은 프레임 4개
결과: 모든 프로세스가 계속 페이지 폴트 발생
→ 디스크 I/O 폭증
→ CPU는 I/O 대기 상태
→ CPU 사용률 하락
→ OS가 "CPU가 놀고 있네? 프로세스 더 추가하자!"
→ 멀티프로그래밍 정도(Degree of Multiprogramming) 증가
→ 페이지 폴트 더 증가
→ 스래싱 악화
이게 스래싱의 악순환이다. CPU 사용률이 떨어지니까 운영체제는 착각한다. "CPU가 놀고 있으니 프로세스를 더 실행시켜야겠다!" 하지만 실제로는 모든 프로세스가 디스크 I/O를 기다리며 대기 중인 상태다. 프로세스를 더 추가하면 상황은 더 악화된다.
일반적으로는 프로세스가 많을수록 CPU 사용률이 높아진다. 한 프로세스가 I/O를 기다리는 동안 다른 프로세스가 CPU를 쓸 수 있기 때문이다. 하지만 이 그래프는 어느 지점을 넘어서면 급격히 꺾인다.
CPU 사용률
^
| 정상 구간 스래싱 구간
100%| ******** *
| * * *
| * * *
| * * *
| * *
|*________________*___________> 멀티프로그래밍 정도
0 임계점
임계점을 넘어서면 물리 메모리가 부족해져서 페이지 교체가 빈번해진다. CPU는 실제 작업(instruction execution)을 하지 못하고 페이지 교체 작업만 하느라 시간을 보낸다. 이 상태가 스래싱이다.
페이지 교체 정책(Page Replacement Policy)도 영향을 미친다. FIFO(First-In-First-Out) 알고리즘은 가장 먼저 들어온 페이지를 교체한다. 간단하지만 Belady의 이상 현상(Belady's Anomaly)이 발생할 수 있다. 프레임을 늘렸는데 오히려 페이지 폴트가 증가하는 역설적인 상황이다.
반면 LRU(Least Recently Used) 알고리즘은 가장 오래 전에 사용된 페이지를 교체한다. 참조의 지역성을 활용하는 방식이라 더 효율적이다. 하지만 스래싱 상황에서는 어떤 알고리즘을 쓰든 소용없다. 워킹 셋을 수용할 물리 메모리가 없으면 무조건 스래싱이 발생한다.
2021년, 우리 서비스의 백엔드 서버에서 이상한 현상이 발생했다. 새벽 3시에 모니터링 알림이 울렸다. "서버 응답 없음." SSH로 접속을 시도했지만 연결조차 안 됐다. 결국 서버 재부팅을 했고, 로그를 확인했다.
$ dmesg | grep -i "killed process"
[12345.678901] Out of memory: Killed process 2345 (node) total-vm:4GB, anon-rss:3.8GB, file-rss:0kB
[12389.123456] Out of memory: Killed process 3456 (postgres) total-vm:2GB, anon-rss:1.9GB, file-rss:0kB
OOM Killer(Out-Of-Memory Killer)가 작동한 것이었다. Linux 커널은 물리 메모리가 완전히 고갈되면 프로세스들을 강제로 종료시킨다. 우선순위를 계산해서 가장 "죽여도 될 것 같은" 프로세스를 골라서 SIGKILL을 보낸다. 잔인하지만 시스템 전체가 얼어붙는 것을 막기 위한 최후의 수단이다.
OOM Killer의 희생양이 되지 않으려면 메모리 압박(Memory Pressure) 상황을 사전에 감지해야 한다. vmstat 명령어로 모니터링할 수 있다:
$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 2 524288 12340 8192 102400 120 180 2400 1200 500 800 15 10 50 25 0
4 3 589824 8192 8192 102400 340 420 5600 3200 700 1200 12 8 40 40 0
6 5 655360 4096 8192 102400 780 920 12000 7800 1100 2000 8 5 20 67 0
주목할 컬럼:
위 예시에서 시간이 지날수록 si와 so 값이 증가하고, wa 값도 67%까지 치솟는다. 전형적인 스래싱 전조 증상이다.
free 명령어로도 스왑 공간 사용량을 확인할 수 있다:
$ free -h
total used free shared buff/cache available
Mem: 7.8G 7.2G 100M 256M 500M 200M
Swap: 2.0G 1.9G 100M
Swap 사용량이 거의 100%에 육박하면 위험 신호다. 특히 available 컬럼이 중요하다. 이건 "새 프로그램을 실행할 때 사용 가능한 메모리"를 의미한다. 이 값이 전체 메모리의 10% 이하로 떨어지면 곧 스래싱이 발생할 가능성이 높다.
top 명령어로 실시간 모니터링도 가능하다:
$ top
top - 03:24:15 up 10 days, 4:32, 1 user, load average: 8.45, 7.23, 6.11
Tasks: 312 total, 4 running, 308 sleeping, 0 stopped, 0 zombie
%Cpu(s): 12.5 us, 8.3 sy, 0.0 ni, 15.2 id, 64.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7984.0 total, 102.4 used, 200.5 free, 7681.1 buff/cache
MiB Swap: 2048.0 total, 1945.6 used, 102.4 free. 195.2 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
2345 app 20 0 4.1g 3.8g 12m D 15.3 48.9 123:45.67 node
3456 postgres 20 0 2.1g 1.9g 8m D 8.7 24.5 89:12.34 postgres
%CPU가 낮은데 load average가 8 이상으로 치솟고, wa(wait)가 64%에 달한다. 그리고 프로세스 상태가 D (uninterruptible sleep, 디스크 I/O 대기)인 것들이 많다. 스래싱의 명확한 증거다.
가장 확실한 방법이다. 돈으로 해결할 수 있다면 그게 최선이다. 우리는 서버 메모리를 8GB에서 32GB로 업그레이드했다. 그 후로 스래싱은 다시 발생하지 않았다.
동시에 실행되는 프로세스 수를 줄여야 한다. Docker 컨테이너를 무분별하게 띄우지 말고, 꼭 필요한 것만 실행한다. Node.js 클러스터 모드의 워커 프로세스 수도 조절한다.
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
// CPU 코어 수만큼 워커 생성 (기존)
// const numWorkers = os.cpus().length;
// 메모리 고려해서 워커 수 제한 (개선)
const totalMemory = os.totalmem();
const availableMemory = os.freemem();
const numWorkers = availableMemory > 4 * 1024 * 1024 * 1024
? os.cpus().length
: Math.max(2, Math.floor(os.cpus().length / 2));
console.log(`Starting ${numWorkers} workers`);
for (let i = 0; i < numWorkers; i++) {
cluster.fork();
}
} else {
// 워커 프로세스 로직
require('./app');
}
운영체제는 각 프로세스의 워킹 셋을 추정하고, 그만큼의 프레임을 보장해줘야 한다. 만약 물리 메모리가 부족해서 모든 프로세스의 워킹 셋을 수용할 수 없다면, 일부 프로세스를 통째로 스왑아웃시켜야 한다. 일부 프로세스가 완전히 멈추더라도, 나머지 프로세스라도 제대로 실행되는 게 낫다.
Linux에서는 nice와 ionice 명령어로 프로세스 우선순위를 조정할 수 있다:
# CPU 우선순위 낮춤
$ nice -n 19 ./heavy_process
# I/O 우선순위 낮춤 (idle class)
$ ionice -c 3 ./heavy_process
각 프로세스의 페이지 폴트 발생 빈도를 모니터링한다. 페이지 폴트가 너무 자주 발생하면 (예: 초당 100번 이상) 그 프로세스에 프레임을 더 할당한다. 반대로 페이지 폴트가 거의 안 발생하면 (예: 초당 5번 미만) 프레임을 회수한다. 이렇게 동적으로 조정하면 스래싱을 예방할 수 있다.
같은 스래싱 상황이라도 HDD와 SSD의 체감 차이는 엄청나다. HDD는 기계식 디스크라서 랜덤 I/O 성능이 끔찍하게 느리다. 페이지 폴트가 발생하면 디스크 헤드가 물리적으로 이동해야 하는데, 이 seek time이 10ms 정도 걸린다. 초당 100번 페이지 폴트가 발생하면 1초 중 1초를 디스크 헤드 이동에만 쓰는 셈이다.
반면 SSD는 랜덤 I/O도 빠르다. seek time이 없으니까. 같은 페이지 폴트 빈도라도 SSD에서는 10배 이상 빠르게 처리된다. 그래서 SSD를 쓰면 스래싱 상황에서도 "느리긴 한데 일단 되긴 한다" 수준으로 버틸 수 있다. HDD였다면 완전히 얼어붙었을 상황에서도 말이다.
하지만 SSD라고 해서 스래싱을 방치해도 된다는 뜻은 아니다. SSD는 쓰기 횟수 제한이 있다. 스래싱으로 스왑 영역에 계속 쓰기를 하면 SSD 수명이 급격히 단축된다. 게다가 SSD도 I/O는 CPU 연산보다 수천 배 느리다. 스래싱은 여전히 성능 재앙이다.
스래싱을 겪으면서 깨달은 게 있다. 시스템에는 한계가 있다는 것. 4GB RAM으로 Chrome 탭 50개를 동시에 띄울 수는 없다. 8GB 메모리 서버로 동시접속자 10만 명을 감당할 수는 없다. 운영체제가 아무리 똑똑해도, 가상 메모리가 아무리 마법 같아도, 물리적 한계는 어쩔 수 없다.
스타트업 초기에 나는 리소스 부족을 "최적화"로 극복하려고 했다. 코드를 튜닝하고, 캐시를 추가하고, 알고리즘을 개선했다. 하지만 어느 순간 깨달았다. 이건 코드 문제가 아니라 하드웨어 문제라는 걸. RAM 16GB를 추가하는 게 2주간 최적화 작업을 하는 것보다 효과적이었다.
물론 최적화도 중요하다. 메모리를 낭비하는 코드는 고쳐야 한다. 하지만 근본적인 리소스 부족은 리소스 추가로 해결해야 한다. 그게 가장 빠르고, 가장 확실하고, 가장 정직한 방법이다. 스래싱은 시스템이 우리에게 보내는 신호다. "더 이상은 무리야. 도움이 필요해."