
메모리 관리: 연속 할당과 분산 할당
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.
미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

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

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

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

메모리가 부족한 환경에서는 이런 상황이 발생할 수 있습니다. 가장 싼 AWS 인스턴스(t2.micro, 램 1GB) 하나에 웹 서버, DB, 로그 수집기까지 모두 올린다고 생각해보자. 결과는 뻔하다. 서버가 뻗는다.
로그를 뒤져보면 이런 메시지를 볼 수 있습니다.
Out of memory: Killed process 1234 (node) total-vm:850MB, anon-rss:780MB
Out of Memory (OOM) Killer가 프로세스들을 학살했다는 뜻이다. 수학적으로 이상해 보인다. 웹 서버 100MB, DB 300MB, 로그 50MB. 계산하면 450MB인데 왜 1GB가 모자라다는 걸까?
이런 상황을 들여다보다 보면 의문이 생긴다. "8GB 램으로 크롬 탭 100개를 어떻게 켜는 거야? 각 탭이 평균 200MB씩만 먹어도 20GB는 필요한데?" 알고 보니 문제는 '얼마나 쓰느냐'가 아니라 '어떻게 넣느냐'였다. 메모리는 무한한 공간이 아니라, 아주 까다로운 테트리스판이었던 겁니다.
초창기 컴퓨터의 메모리 관리는 단순했습니다. "프로그램 크기만큼 연속된 공간을 준다." 제 머릿속 모델도 이랬습니다. 100MB짜리 프로그램이 오면 메모리의 어딘가에 100MB짜리 빈 공간을 찾아서 통째로 넣는 거죠.
도서관 책꽂이에 책을 꽂는 것과 같습니다.
깔끔하죠? 구현도 쉽습니다. 프로그램마다 시작 주소(Base Address)와 크기(Limit)만 기억하면 됩니다. CPU가 "주소 5번 읽어줘"라고 하면 OS는 "A 프로그램의 시작이 1번이니까 실제로는 1+5=6번 칸이네"라고 계산합니다.
그런데 A 프로그램이 종료됩니다. 1~3번 자리가 비었죠. 이 상태에서 책 4권짜리 D 프로그램이 들어옵니다.
현재 상태:
총 빈 공간은 93칸이나 됩니다. 그런데 D 프로그램(4칸)을 넣을 수가 없습니다. 왜냐하면 13번 구멍은 3칸밖에 안 되거든요. 11100번 구멍에는 들어가지만, 그러면 1~3번 구멍이 영원히 사용되지 않습니다.
이것이 바로 외부 단편화입니다. 메모리는 충분히 남아있는데, 조각조각 찢어져 있어서 못 쓰는 상황. 제 서버 로그에서 봤던 "Free Memory Available but Allocation Failed"가 정확히 이 상황이었습니다.
처음엔 이해가 안 갔습니다. "그럼 B 프로그램을 뒤로 밀면 되잖아? 14번에 D를 넣고, B를 59번으로 옮기고..." 그런데 실제로는 이게 엄청난 비용입니다. 프로그램을 메모리에서 다른 곳으로 옮기려면, 프로그램 실행을 멈추고, 수백 MB를 통째로 복사하고, 내부의 모든 포인터 주소를 다시 계산해야 합니다. 이걸 메모리 압축(Compaction)이라고 하는데, 너무 느려서 현실적이지 않습니다.
외부 단편화만 있는 게 아닙니다. 내부 단편화도 있습니다. OS가 메모리를 관리할 때, 보통 고정된 크기 단위(예: 4KB)로 나눠서 줍니다. 그런데 프로그램이 정확히 4KB를 쓰는 경우는 드뭅니다.
예를 들어, 3.2KB짜리 프로그램이 오면 OS는 4KB짜리 블록을 통째로 줍니다. 그럼 0.8KB가 낭비되죠. 이게 내부 단편화입니다. 할당된 공간 내부에 빈 공간이 생기는 거죠.
제가 이해한 방식은 이렇습니다. 외부 단편화는 "책꽂이 사이사이의 빈틈", 내부 단편화는 "책이 책꽂이보다 작아서 남는 공간". 둘 다 공간 낭비지만, 원인이 다릅니다.
이 문제를 해결하려면 패러다임을 바꿔야 합니다. "왜 프로그램을 통째로 한 곳에 둬야 하지? 찢어서 여기저기 쑤셔 넣으면 안 되나?"
책을 찢어서(죄송합니다, 비유입니다) 낱장으로 보관하는 것과 같습니다.
이렇게 하면 빈 공간이 1칸이라도 남아있으면 무조건 프로그램을 실행할 수 있습니다. 외부 단편화 문제가 완전히 사라집니다. 이 '찢어서 저장하기' 기술이 바로 현대 OS의 핵심인 페이징(Paging)과 세그먼테이션(Segmentation)입니다.
그런데 여기서 새로운 문제가 생깁니다. "프로그램이 흩어져 있으면, CPU가 '주소 5번 읽어줘'라고 할 때 어떻게 찾지?" 이때 OS는 페이지 테이블(Page Table)이라는 주소록을 만듭니다.
| 논리 주소 (Logical) | 물리 주소 (Physical) |
|---|---|
| 0번 페이지 | 1번 칸 |
| 1번 페이지 | 2번 칸 |
| 2번 페이지 | 3번 칸 |
| 3번 페이지 | 11번 칸 |
CPU가 "논리 주소 3번"을 요청하면, OS는 페이지 테이블을 보고 "아, 실제로는 물리 주소 11번이구나"라고 변환합니다. 이걸 주소 변환(Address Translation)이라고 합니다.
이 개념이 처음엔 복잡했는데, "전화번호부"라고 생각하니까 와닿았습니다. "철수"라는 이름(논리 주소)을 전화번호부(페이지 테이블)에서 찾으면 "010-1234-5678"(물리 주소)가 나오는 거죠.
분산 할당 이전, 연속 할당 시절에도 OS는 나름의 전략이 있었습니다. 빈 공간이 여러 개 있을 때, 어느 구멍에 프로그램을 넣을 것인가? 이게 메모리 할당 알고리즘입니다.
처음 발견한 충분한 크기의 구멍에 넣습니다. 가장 빠릅니다. 앞에서부터 쭉 훑다가 "오, 여기 들어가네?" 싶으면 바로 넣는 거죠.
메모리: [빈10칸][A][빈5칸][B][빈20칸]
15칸 필요 -> 빈20칸에 할당 (첫 번째로 충분한 공간)
빠르지만, 앞쪽에 작은 구멍들이 쌓입니다. 마치 테트리스에서 아래쪽부터 채우다 보면 위쪽이 지저분해지는 것과 같습니다.
딱 맞는 크기의 구멍을 찾습니다. 모든 구멍을 다 검사해서 "15칸 필요한데 16칸짜리 구멍이 있네? 여기가 제일 낭비가 적겠다"라고 판단합니다.
메모리: [빈10칸][A][빈16칸][B][빈20칸]
15칸 필요 -> 빈16칸에 할당 (가장 적게 남음)
이론적으론 공간 효율이 좋아 보이지만, 실제로는 아주 작은 쓸모없는 구멍들이 대량 발생합니다. 15칸을 16칸에 넣으면 1칸짜리 구멍이 남는데, 이건 나중에 거의 쓸모가 없습니다.
가장 큰 구멍에 넣습니다. "남는 공간을 크게 만들면, 나중에 또 쓸 수 있지 않을까?"라는 발상입니다.
메모리: [빈10칸][A][빈16칸][B][빈20칸]
15칸 필요 -> 빈20칸에 할당 (가장 큼)
직관적으로는 말이 안 되는데, 의외로 성능이 괜찮습니다. 남는 공간(5칸)이 꽤 커서 나중에 재활용이 가능하거든요.
저는 처음에 "Best Fit이 당연히 최고 아닌가?"라고 생각했습니다. 그런데 실험 결과를 보니 First Fit과 Best Fit이 비슷했고, Worst Fit도 나쁘지 않았습니다. 이론과 실제이 다르다는 걸 받아들였습니다. 메모리 관리는 수학 문제가 아니라, 통계와 확률의 문제였던 거죠.
현대 리눅스 커널이 사용하는 Buddy System이라는 알고리즘이 있습니다. 이건 진짜 천재적인 발상이었습니다.
메모리를 2의 거듭제곱 크기로만 관리합니다. 1KB, 2KB, 4KB, 8KB, 16KB... 이런 식이죠. 만약 6KB짜리 프로그램이 오면? 8KB 블록을 줍니다. 2KB가 내부 단편화로 낭비되지만, 대신 관리가 엄청나게 쉬워집니다.
동작 방식:
해제할 때도 쉽습니다. 32KB 블록이 반환되면, "짝(Buddy)"인 옆의 32KB 블록도 비어있는지 확인합니다. 둘 다 비어있으면 합쳐서(Coalesce) 64KB 블록으로 만듭니다. 이걸 재귀적으로 반복합니다.
이 방식이 왜 좋은가? 단편화가 자동으로 줄어듭니다. 작은 블록들이 모이면 큰 블록이 되니까요. 마치 테트리스에서 한 줄이 완성되면 사라지는 것과 비슷합니다.
저는 이 개념을 처음 봤을 때 "왜 2의 거듭제곱이지?"라고 의아했습니다. 그런데 비트 연산으로 계산하면 엄청 빠르다는 걸 깨달았습니다. 주소 계산이 그냥 비트 시프트로 끝나거든요. 결국 이거였다, 성능 최적화.
메모리 관리의 또 다른 마법은 스와핑입니다. 서버가 터지기 직전에 저는 swapfile이라는 걸 설정해서 급한 불을 껐습니다.
램(책상)이 꽉 찼는데 새로운 책을 펴야 한다면? 안 보는 책을 잠시 책장(하드디스크)으로 치워버리면 됩니다. 이걸 Swap Out이라고 합니다. 나중에 그 책이 필요하면 다시 책상으로 가져옵니다. (Swap In).
리눅스는 LRU(Least Recently Used) 방식으로 희생자를 선택합니다. "가장 오래 안 쓴 페이지를 내보낸다." 합리적이죠. 안 쓰는 애를 내보내는 게 손해가 적으니까요.
하드디스크(SSD 포함)는 램보다 수십만 배 느립니다.
스와핑이 일어난다는 건, 책상에서 공부하다 말고 도서관 서고까지 뛰어가서 책을 바꿔온다는 뜻입니다. 서버가 엄청나게 느려집니다. OOM으로 죽는 것보단 낫지만, 결코 좋은 상태는 아닙니다.
제 서버에서 스와핑이 자주 일어나면 top 명령어에서 wa (wait I/O) 값이 치솟습니다. 이게 보이면 "아, 램이 부족하구나"라고 바로 알 수 있습니다.
이론만 봐선 와닿지 않았습니다. 직접 C 코드를 짜보고 나서야 정리해본다는 느낌이 들었습니다.
#include <stdio.h>
#include <stdlib.h>
int main() {
// 동적 메모리 할당: OS에게 100바이트 요청
int *arr = (int *)malloc(100 * sizeof(int));
if (arr == NULL) {
printf("메모리 할당 실패!\n");
return 1;
}
// 메모리 사용
for (int i = 0; i < 100; i++) {
arr[i] = i * i;
}
// 메모리 해제: OS에게 반납
free(arr);
return 0;
}
malloc()을 호출하면, OS는 힙(Heap) 영역에서 메모리를 찾아서 줍니다. 내부적으로는 아까 말한 First Fit, Best Fit 같은 알고리즘을 씁니다. free()를 호출하면 그 공간을 다시 "빈 구멍"으로 표시합니다.
만약 free()를 까먹으면?
int main() {
for (int i = 0; i < 1000000; i++) {
int *leak = (int *)malloc(1024);
// free(leak); <- 이거 안 하면?
}
return 0;
}
이 프로그램은 1GB(1024 * 1,000,000 바이트)를 할당하고 절대 반납하지 않습니다. OS 입장에서는 "이 프로그램이 1GB를 쓰고 있네"라고 인식합니다. 실제론 안 쓰는데도요. 이게 메모리 누수입니다.
Java나 Python은 가비지 컬렉터가 알아서 정리해주지만, C/C++이나 잘못 짠 Node.js 코드는 이런 일이 벌어집니다. OS가 아무리 관리를 잘해도 답이 없습니다. 테트리스 블록이 안 사라지고 계속 쌓이는 거니까요.
제가 애용하는 도구가 valgrind입니다. 메모리 누수를 자동으로 찾아줍니다.
gcc -g memory_leak.c -o leak
valgrind --leak-check=full ./leak
출력:
==12345== HEAP SUMMARY:
==12345== in use at exit: 1,024,000,000 bytes in 1,000,000 blocks
==12345== total heap usage: 1,000,000 allocs, 0 frees, 1,024,000,000 bytes allocated
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 1,024,000,000 bytes in 1,000,000 blocks
"1GB가 손실됐다"라고 명확히 알려줍니다. 이 도구 없었으면 저는 디버깅에 몇 주를 날렸을 겁니다.
실제로는 top이나 htop 명령어로 메모리 상태를 실시간으로 감시합니다.
htop
중요한 컬럼:
예를 들어, 크롬 브라우저는 VIRT가 2GB인데 RES가 500MB일 수 있습니다. "2GB를 예약했지만, 실제론 500MB만 쓰고 있다"는 뜻입니다. 이게 가능한 이유는 메모리 오버커밋(Overcommit) 때문입니다.
리눅스는 기본적으로 "일단 약속은 해주고, 실제로 쓸 때 확인한다"는 전략을 씁니다. 크롬이 "10GB 줘"라고 하면 OS는 "오케이"라고 대답합니다. 실제로 물리 메모리는 8GB밖에 없는데도요.
왜? 대부분의 프로그램은 할당받은 메모리를 다 쓰지 않기 때문입니다. 마치 호텔이 방을 120% 예약받는 것과 같습니다. "다들 체크인하진 않겠지?"라는 도박이죠.
/proc/sys/vm/overcommit_memory 설정:
저는 프로덕션 서버에선 0을 씁니다. 1로 하면 OOM Killer가 난동을 부릴 수 있고, 2로 하면 메모리가 남아도 할당을 거부하거든요.
리눅스 서버를 세팅할 때, 물리 메모리의 2배 정도를 스왑으로 잡아두는 게 안전장치로 좋습니다.
# 2GB 스왑 파일 생성
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# 재부팅 후에도 유지
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
이 명령어 한 줄로 저는 밤잠을 설치는 일을 줄였습니다. OOM Killer가 와도 일단 스왑으로 버티니까요.
저는 메모리 사용량이 80%를 넘으면 슬랙으로 알림을 보내는 스크립트를 씁니다.
#!/bin/bash
USAGE=$(free | grep Mem | awk '{print ($3/$2) * 100}')
THRESHOLD=80
if (( $(echo "$USAGE > $THRESHOLD" | bc -l) )); then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠️ Memory usage: ${USAGE}%\"}" \
YOUR_SLACK_WEBHOOK_URL
fi
이걸 cron에 등록해서 5분마다 실행합니다. 서버가 터지기 전에 미리 알 수 있으니까요.
OOM이 발생하면 /var/log/syslog 또는 dmesg에 기록됩니다.
dmesg | grep -i "killed process"
출력:
Out of memory: Killed process 5678 (java) total-vm:2048MB
이 로그를 보고 "아, Java 프로세스가 2GB나 먹다가 죽었구나"라고 파악할 수 있습니다.
메모리 관리를 공부하면서 이해했다는 느낌이 든 포인트들을 정리해본다면:
연속 할당: 구현은 쉽지만, 외부 단편화(빈 공간 낭비) 때문에 효율이 똥망입니다. 도서관 책꽂이처럼 깔끔하지만, 비어있는 칸을 못 씁니다.
분산 할당: 프로그램을 쪼개서 여기저기 넣습니다. (페이징, 세그먼테이션). 현대 OS의 표준. 테트리스 블록을 낱개로 쪼개서 빈틈에 쑤셔 넣는 것과 같습니다.
할당 알고리즘: First Fit, Best Fit, Worst Fit 각각 장단점이 있지만, 실제로는 큰 차이가 없습니다. 이론과 실전은 다릅니다.
Buddy System: 2의 거듭제곱으로 관리하면 단편화가 줄어들고 계산이 빠릅니다. 리눅스 커널이 쓰는 이유가 있었습니다.
Swapping: 램 없으면 하드디스크라도 빌려 씁니다. (느리지만 안 죽는 게 어디냐). 그러나 자주 일어나면 성능이 개판됩니다.
메모리 누수: OS가 아무리 잘해도, 코드가 free()를 안 하면 답이 없습니다. Valgrind 같은 도구로 잡아야 합니다.
메모리 오버커밋: 리눅스는 기본적으로 "일단 약속은 해준다". 실제로 메모리가 부족하면 그때 OOM Killer가 나타납니다.
결국 메모리 관리는 한정된 자원을 최대한 효율적으로 쪼개 쓰는 예술입니다. 메모리가 부족한 환경에서는 이 원리를 이해하고 있으면 훨씬 잘 버틸 수 있습니다. 8GB 램으로 크롬 탭 100개를 어떻게 켜는지, 이제는 완전히 와닿았다고 말할 수 있습니다.