Memory Management
메모리 관리 (Memory Management)
섹션 제목: “메모리 관리 (Memory Management)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”운영체제가 프로세스마다 독립적인 가상 주소 공간을 제공하고, 물리 메모리와 디스크를 투명하게 연결해 프로그램이 메모리를 안전하고 효율적으로 사용할 수 있게 해주는 메커니즘이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”- 프로세스 격리: 한 프로세스가 다른 프로세스의 메모리를 침범하지 못하게 막아 시스템 안정성을 보장한다.
- 메모리 초과 실행: 물리 메모리(RAM)보다 큰 프로그램도 실행할 수 있게 해준다. 예전에 4GB RAM으로 8GB짜리 프로그램을 돌릴 수 있었던 이유가 바로 이것이다.
- 실무 영향: Node.js/Nest.js 서버가 OOM(Out Of Memory)으로 죽거나, Docker 컨테이너가
OOMKilled상태로 종료되는 현상은 모두 메모리 관리와 직결된다. Redis 캐시의maxmemory-policy설정도 OS 페이지 교체 알고리즘에서 비롯된 개념이다. - 성능 최적화: 배열이 연결 리스트보다 빠른 이유, DB 인덱스가 B+Tree를 쓰는 이유도 메모리 접근 패턴(캐시 지역성)으로 설명된다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”3-1. 가상 메모리 (Virtual Memory)
섹션 제목: “3-1. 가상 메모리 (Virtual Memory)”비유로 이해하기
섹션 제목: “비유로 이해하기”호텔을 상상해 보자. 호텔에는 101호, 102호, 103호… 방 번호가 붙어 있다. 각 손님은 자기 방 번호를 기준으로 생각한다(“내 방은 205호”). 하지만 실제 건물 구조, 복도 배선, 배관은 손님이 신경 쓰지 않아도 된다. 건물 관리자(OS)가 “205호 손님이 화장실을 쓰면 3층 동쪽 배관으로 연결”한다는 것을 알고 처리한다.
가상 메모리도 동일하다. 각 프로세스는 “내 메모리 주소는 0x0000 ~ 0xFFFF” 같은 자기만의 독립적인 주소 공간을 가진 것처럼 본다. 실제로 그 주소가 물리 RAM의 어느 위치에 있는지, 심지어 디스크(스왑)에 있는지는 OS가 투명하게 처리한다.
[프로세스 A] [프로세스 B]가상주소 0x1000 → 물리주소 0x5000 (RAM)가상주소 0x2000 → 물리주소 0x8000 (RAM)가상주소 0x3000 → 디스크 스왑 영역 (아직 RAM에 없음)
[프로세스 B]가상주소 0x1000 → 물리주소 0x6000 (RAM) ← A의 0x1000과 물리 주소 다름!- 두 프로세스가 같은 가상 주소(0x1000)를 가져도 물리 주소는 완전히 다르다 → 프로세스 격리
- RAM이 부족하면 일부를 디스크에 내리고 필요할 때 다시 올린다 → 물리 메모리보다 큰 프로그램 실행
실행 가능한 데모
섹션 제목: “실행 가능한 데모”# Linux에서 프로세스의 가상 메모리 맵 확인cat /proc/self/maps예상 출력:
55a1b2c00000-55a1b2c01000 r-xp00000000 fd:01 123456 /usr/bin/cat7f8a3d400000-7f8a3d600000 r--p00000000 fd:01 234567 /lib/x86_64/libc.so.67ffc12345000-7ffc12366000 rw-p00000000 00:00 0 [stack]- 각 줄이 가상 주소 범위(region)이다
[stack],[heap]등이 프로세스마다 동일한 이름으로 보이지만, 물리 주소는 다 다르다
3-1-1. Copy-on-Write (CoW) — fork()의 메모리 최적화
섹션 제목: “3-1-1. Copy-on-Write (CoW) — fork()의 메모리 최적화”fork() 시스템 콜로 자식 프로세스를 생성하면, OS는 부모의 메모리 페이지를 즉시 복사하지 않는다. 대신 부모와 자식이 동일한 물리 페이지를 공유하고, 어느 한 쪽이 해당 페이지에 쓰기를 시도할 때에만 그 페이지를 복사해 별도 물리 프레임을 할당한다. 이것이 Copy-on-Write다.
[fork() 직후 — 페이지 공유]부모 VPN 0 ───┐ ├─→ 물리 PFN 5 (읽기 공유, 쓰기 보호)자식 VPN 0 ───┘
[부모가 PFN 5에 쓰기 발생]부모 VPN 0 ──────→ 물리 PFN 5 (원본, 자식 유지)자식 VPN 0 ──────→ 물리 PFN 9 (새 복사본 생성)Redis BGSAVE와 CoW
섹션 제목: “Redis BGSAVE와 CoW”BGSAVE(백그라운드 스냅샷 저장) 시 Redis는 fork()로 자식 프로세스를 생성하고, 자식이 RDB 파일을 디스크에 기록한다. 이때:
fork()직후에는 메모리를 거의 추가로 사용하지 않는다- 부모(Redis 서버)가 클라이언트 요청을 처리하며 데이터를 변경할수록 CoW가 발동해 페이지가 복사된다
- 쓰기가 많은 시간대에 BGSAVE하면
used_memory_rss(OS가 인식하는 실제 점유 메모리)가 급증한다
# BGSAVE 중 CoW 메모리 크기 모니터링redis-cli INFO stats | grep current_cow_size# current_cow_size:12582912 ← CoW로 복사된 바이트 (12MB)THP(Transparent Huge Pages)가 활성화된 상태에서 BGSAVE를 실행하면 CoW 단위가 4KB가 아닌 2MB가 되어, 단 하나의 바이트 수정도 2MB 전체를 복사한다. Redis가 THP 비활성화를 강력히 권고하는 핵심 이유다.
📖 출처: Redis Persistence — redis.io | Linux fork(2) — man7.org
3-2. 페이징 (Paging)과 세그멘테이션 (Segmentation)
섹션 제목: “3-2. 페이징 (Paging)과 세그멘테이션 (Segmentation)”비유로 이해하기
섹션 제목: “비유로 이해하기”도서관 책 정리를 생각해 보자. 책 크기가 제각각이라 선반에 꽂기 어렵다면, 모든 책을 “같은 크기의 상자”에 나눠 담는 방식이 페이징이다. 반면 “소설은 소설끼리, 과학책은 과학책끼리” 논리적 의미로 묶는 방식이 세그멘테이션이다.
페이징 원리
섹션 제목: “페이징 원리”물리 메모리와 가상 메모리를 동일한 크기의 고정 블록으로 나눈다.
- 가상 주소 공간의 블록 → 페이지 (Page)
- 물리 메모리의 블록 → 프레임 (Frame)
- 일반적으로 크기: 4KB (Linux 기본값)
가상 주소: [페이지 번호(VPN)] [오프셋] ↓ (페이지 테이블 조회)물리 주소: [프레임 번호(PFN)] [오프셋]페이지 테이블 (Page Table): VPN → PFN 매핑 테이블. MMU(Memory Management Unit)라는 하드웨어가 매 메모리 접근마다 이 변환을 수행한다.
왜 4KB인가? 페이지 크기는 내부 단편화와 페이지 테이블 크기 사이의 트레이드오프다. 페이지가 너무 작으면(1KB) 페이지 테이블 항목이 너무 많아져 메모리를 낭비하고, 너무 크면(1MB) 실제로 수 KB만 필요한 상황에서 나머지 공간이 낭비된다(내부 단편화). 4KB는 1990년대 워크로드 분석을 기반으로 결정된 값이며, 현대의 대용량 메모리 서버에서는 Huge Pages(2MB 또는 1GB) 가 더 효율적인 경우가 많다.
Huge Pages와 THP (Transparent Huge Pages): 4KB 페이지를 사용하면 64GB RAM을 관리하는 데 약 1,600만 개의 페이지 테이블 항목이 필요하다. TLB(가상→물리 주소 변환 캐시)의 크기는 수백~수천 개뿐이므로 TLB 미스가 빈번하게 발생한다. Huge Pages(2MB)를 사용하면 같은 메모리를 3만 2천 개의 항목으로 관리할 수 있어 TLB 미스가 극적으로 줄어든다.
Linux의 THP(Transparent Huge Pages) 는 애플리케이션 코드 수정 없이 커널이 자동으로 2MB 페이지를 할당하는 기능이다. 그러나 데이터베이스(MongoDB, Redis, PostgreSQL)에서는 THP를 비활성화하는 것이 권장된다. THP가 백그라운드에서 페이지를 합치거나 쪼개는 작업(compaction, splitting)이 레이턴시 스파이크를 유발하기 때문이다.
# THP 상태 확인cat /sys/kernel/mm/transparent_hugepage/enabled# [always] madvise never ← always가 기본값
# 데이터베이스 서버에서 THP 비활성화echo never > /sys/kernel/mm/transparent_hugepage/enabledecho never > /sys/kernel/mm/transparent_hugepage/defrag📖 더 보기: Transparent Huge Pages: Why We Disable It for Databases — PingCAP — THP가 DB 성능에 미치는 부정적 영향과 비활성화 이유
페이지 테이블 예시:VPN 0 → PFN 5 (present=1, RAM에 있음)VPN 1 → PFN 12 (present=1)VPN 2 → --- (present=0, 디스크에 있음 → Page Fault 발생)실행 가능한 데모
섹션 제목: “실행 가능한 데모”# Linux에서 기본 페이지 크기 확인getconf PAGE_SIZE예상 출력:
4096# 프로세스 메모리 사용 상세 조회cat /proc/$$/status | grep -E "Vm(RSS|Size|Swap)"예상 출력:
VmSize: 16384 kB ← 가상 메모리 크기 (할당된 전체)VmRSS: 4096 kB ← Resident Set Size (실제 RAM에 올라온 크기)VmSwap: 512 kB ← 스왑에 있는 크기VmSize가 크고 VmRSS가 작다면 가상 주소는 많이 잡았지만 실제 물리 메모리는 적게 쓰는 것이다.
세그멘테이션 vs 페이징
섹션 제목: “세그멘테이션 vs 페이징”| 구분 | 페이징 | 세그멘테이션 |
|---|---|---|
| 블록 크기 | 고정 (4KB) | 가변 (코드/데이터/스택 크기) |
| 단편화 | 내부 단편화 가능 | 외부 단편화 발생 |
| 논리적 의미 | 없음 (순수 기계적 분할) | 있음 (코드/힙/스택) |
| 현대 OS 사용 | 주로 사용 | 보조적으로 병용 |
현대 Linux/macOS는 세그멘테이션 개념(코드 영역, 힙, 스택 구분)을 논리 구조로 유지하면서, 실제 물리 메모리 관리는 페이징으로 처리한다.
3-3. 페이지 폴트 (Page Fault)
섹션 제목: “3-3. 페이지 폴트 (Page Fault)”비유로 이해하기
섹션 제목: “비유로 이해하기”책상 위에 자주 쓰는 책 몇 권만 올려두는 상황을 생각해 보자. 특정 책이 필요한데 책상에 없으면, 책장(디스크)까지 가서 가져와야 한다. 이 “책장에서 가져오는 이벤트”가 바로 페이지 폴트다.
- CPU가 가상 주소에 접근한다
- MMU가 페이지 테이블을 확인한다
- present 비트가 0이면 → Page Fault 예외 발생
- OS의 페이지 폴트 핸들러가 실행된다
- 디스크(스왑)에서 해당 페이지를 RAM으로 로드한다
- 페이지 테이블을 업데이트한다
- 원래 명령어를 재실행한다
Minor vs Major Page Fault
섹션 제목: “Minor vs Major Page Fault”Minor Page Fault (소프트 폴트): - 페이지가 이미 메모리에 있지만 프로세스 페이지 테이블에 매핑이 없는 경우 - 예: 공유 라이브러리를 처음 접근할 때 (다른 프로세스가 이미 RAM에 올려둔 경우) - 디스크 I/O 없음 → 빠름
Major Page Fault (하드 폴트): - 페이지가 실제로 디스크에 있어서 RAM으로 로드해야 하는 경우 - 디스크 I/O 필요 → 느림 (수십~수백 ms)# 프로세스의 페이지 폴트 횟수 확인/usr/bin/time -v ls /tmp 2>&1 | grep -E "Major|Minor"예상 출력:
Major (requiring I/O) page faults: 0Minor (reclaiming a frame) page faults: 312쓰레싱 (Thrashing)
섹션 제목: “쓰레싱 (Thrashing)”페이지 폴트가 너무 자주 발생하면 CPU가 실제 작업보다 페이지 교체에 대부분의 시간을 쓰는 상태, 즉 쓰레싱이 일어난다.
[정상 상태]CPU → 연산 → 연산 → 연산 → 페이지폴트 → 연산 → 연산 ... (연산 비율 높음)
[쓰레싱 상태]CPU → 페이지폴트 → 페이지폴트 → 페이지폴트 → ... (디스크 I/O 비율 압도적)증상: CPU 사용률이 낮은데 시스템이 느리고, 디스크 I/O가 폭증한다.
왜 쓰레싱이 발생하는가? 근본 원인은 워킹 셋(Working Set) 이 물리 메모리보다 클 때다. 워킹 셋이란 프로세스가 일정 시간 내에 실제로 접근하는 페이지 집합이다. 10개 프로세스가 각각 500MB의 워킹 셋을 가지면 총 5GB가 필요한데, RAM이 4GB라면 OS는 끊임없이 페이지를 스왑 아웃/인 해야 한다. 프로세스 A의 페이지를 쫓아내면 곧 A가 그 페이지를 다시 요청하고, 이것이 무한 반복된다. 해결책은 프로세스 수를 줄이거나(스케일다운), 메모리를 늘리거나(스케일업), 워킹 셋을 줄이는 것(코드 최적화)이다.
# 스왑 사용량 실시간 모니터링 (쓰레싱 진단)vmstat 1 5예상 출력:
procs -----------memory---------- ---swap-- -----io---- r b swpd free buff cache si so bi bo 0 2 12345 1024 256 20480 512 768 2048 1024 ← si/so 높으면 쓰레싱 0 3 13000 512 256 19000 620 890 3000 1500si (swap in), so (swap out) 값이 지속적으로 높으면 쓰레싱 징후다.
3-4. 페이지 교체 알고리즘 (Page Replacement Algorithm)
섹션 제목: “3-4. 페이지 교체 알고리즘 (Page Replacement Algorithm)”비유로 이해하기
섹션 제목: “비유로 이해하기”책상 위에 5권만 올려둘 수 있는 상황에서 6번째 책이 필요하다. 어떤 책을 치울 것인가? 이 결정 기준이 페이지 교체 알고리즘이다.
LRU (Least Recently Used)
섹션 제목: “LRU (Least Recently Used)”“가장 오래 전에 쓴 페이지를 교체한다”
시간 흐름에 따른 동작:
접근 순서: A B C D A B E A B C D E
페이지 프레임 3개:A → [A]B → [A B]C → [A B C]D → [B C D] ← A가 가장 오래됨 → A 교체A → [C D A] ← B가 가장 오래됨 → B 교체B → [D A B] ← C가 가장 오래됨 → C 교체E → [A B E] ← D가 가장 오래됨 → D 교체- 장점: 최근성 기반으로 직관적, 대부분의 워크로드에서 우수
- 단점: 구현 비용 (모든 접근마다 타임스탬프 갱신)
LFU (Least Frequently Used)
섹션 제목: “LFU (Least Frequently Used)”“접근 횟수가 가장 적은 페이지를 교체한다”
페이지별 접근 횟수:A: 5회B: 3회C: 1회 ← 가장 적음 → C 교체D: 4회- 장점: 자주 쓰는 데이터를 오래 유지 (정적 인기 데이터에 유리)
- 단점: 오래 전에 많이 쓰고 지금은 안 쓰는 데이터가 남아있을 수 있음 (Aging 문제)
FIFO (First In, First Out)
섹션 제목: “FIFO (First In, First Out)”“가장 먼저 들어온 페이지를 교체한다”
구현이 단순하지만 성능이 가장 낮다. Belady’s Anomaly(프레임 수를 늘렸는데 페이지 폴트가 오히려 증가하는 현상)가 발생할 수 있다.
비교표
섹션 제목: “비교표”| 알고리즘 | 기준 | 장점 | 단점 | 사용처 |
|---|---|---|---|---|
| LRU | 최근 사용 시간 | 대부분 워크로드에서 우수 | 구현 비용, 접근 패턴이 바뀌면 비효율 | Linux 커널, Redis allkeys-lru |
| LFU | 접근 빈도수 | 자주 쓰는 데이터 장기 유지 | Aging 문제, 신규 데이터 불리 | Redis allkeys-lfu |
| FIFO | 입장 순서 | 구현 단순 | 성능 최악, Belady’s Anomaly | 거의 미사용 |
| OPT | 미래 예측 | 이론상 최적 | 미래를 알아야 함 (구현 불가) | 성능 비교 기준선 |
Redis maxmemory-policy와의 연결
섹션 제목: “Redis maxmemory-policy와의 연결”OS 페이지 교체 알고리즘과 Redis 캐시 축출 정책은 같은 개념의 다른 적용이다.
# Redis 메모리 제한 및 정책 설정redis-cli CONFIG SET maxmemory 1gbredis-cli CONFIG SET maxmemory-policy allkeys-lruRedis maxmemory-policy 옵션:noeviction → 축출 안 함, 메모리 초과 시 쓰기 에러 반환allkeys-lru → 모든 키 중 LRU 방식으로 축출 (가장 일반적)allkeys-lfu → 모든 키 중 LFU 방식으로 축출 (Redis 4.0+)volatile-lru → TTL 있는 키 중 LRUvolatile-lfu → TTL 있는 키 중 LFUallkeys-random → 무작위 축출선택 기준:
- 캐시 용도(모든 키가 캐시):
allkeys-lru또는allkeys-lfu - 세션/임시 데이터만 축출하고 싶음:
volatile-lru - 특정 데이터가 항상 인기 있고 변하지 않음:
allkeys-lfu - 워크로드가 자주 바뀌거나 최근 데이터가 중요:
allkeys-lru
3-5. 캐시 지역성 (Cache Locality)
섹션 제목: “3-5. 캐시 지역성 (Cache Locality)”비유로 이해하기
섹션 제목: “비유로 이해하기”냉장고에서 재료를 꺼내 요리하는 상황을 생각해 보자. 요리할 때 자주 쓰는 소금, 간장, 기름은 가스레인지 바로 옆 선반에 두면 편하다(Temporal Locality). 또한 김치찌개를 만들 때 김치 가져오면서 옆에 있는 두부, 돼지고기도 같이 가져오면 효율적이다(Spatial Locality).
CPU 캐시도 동일하게 동작한다. RAM 접근은 CPU 연산보다 수백 배 느리기 때문에, CPU는 최근에 쓴 데이터와 그 주변 데이터를 캐시에 미리 올려둔다.
Temporal Locality (시간 지역성)
섹션 제목: “Temporal Locality (시간 지역성)”“최근에 접근한 데이터는 가까운 미래에 또 접근할 가능성이 높다”
// 루프 변수 i는 매 반복마다 접근 → Temporal Localityfor (let i = 0; i < 1000000; i++) { sum += arr[i]; // i는 반복적으로 접근}Spatial Locality (공간 지역성)
섹션 제목: “Spatial Locality (공간 지역성)”“방금 접근한 주소 근처의 데이터도 곧 접근할 가능성이 높다”
// arr[0], arr[1], arr[2]... 순차 접근 → Spatial Localityfor (let i = 0; i < arr.length; i++) { sum += arr[i];}CPU는 arr[0]에 접근할 때 주변 64바이트(캐시 라인)를 통째로 캐시에 올린다. 다음에 arr[1]에 접근하면 이미 캐시에 있어서 빠르다.
배열 vs 연결 리스트 성능 차이
섹션 제목: “배열 vs 연결 리스트 성능 차이”// 배열: 메모리 연속 저장 → Spatial Locality 최고const arr = [1, 2, 3, 4, 5, 6, 7, 8];// 메모리: [1][2][3][4][5][6][7][8] ← 붙어 있음// arr[0] 접근 → 캐시 라인에 arr[0]~arr[7]이 전부 올라옴
// 연결 리스트: 각 노드가 메모리 곳곳에 흩어짐class Node { constructor(val) { this.val = val; this.next = null; }}// 메모리: [1]→(ptr)→[2]→(ptr)→[3]...// 각 노드의 주소: 0x1000, 0x5840, 0x2f00, 0xa200 (제각각)// node.next 접근마다 다른 캐시 라인 → 캐시 미스 연속 발생# 실제 성능 차이 측정 (Node.js)node -e "const N = 10_000_000;const arr = new Int32Array(N).fill(1);
console.time('Array sum');let s = 0;for (let i = 0; i < N; i++) s += arr[i];console.timeEnd('Array sum');// Array sum: ~20ms (캐시 친화적)
// 연결 리스트는 구현 생략 (JS에서 native 없음)// 동일 데이터 크기의 연결 리스트는 보통 5~10배 느림"예상 출력:
Array sum: 18.234msDB B+Tree 인덱스와의 연결
섹션 제목: “DB B+Tree 인덱스와의 연결”B+Tree는 리프 노드들이 연결 리스트로 이어져 있다. 범위 검색(BETWEEN, >, <) 시 리프 노드를 순서대로 탐색하는데, 리프 노드들이 물리적으로 인접한 블록에 저장되어 있어 Spatial Locality를 활용할 수 있다.
B+Tree 리프 노드:[1|2|3|4] → [5|6|7|8] → [9|10|11|12](페이지 A) (페이지 B) (페이지 C) ↓페이지 A 읽을 때 OS가 인접 페이지 B도 미리 읽음 (Read-ahead)→ BETWEEN 1 AND 12 쿼리가 매우 빠름3-5-1. OOM Killer — 메모리 부족 시 Linux의 자가 방어 메커니즘
섹션 제목: “3-5-1. OOM Killer — 메모리 부족 시 Linux의 자가 방어 메커니즘”비유로 이해하기
섹션 제목: “비유로 이해하기”배가 항구에 접안하려는데 자리가 없다. 항구 관리자(OOM Killer)는 규칙에 따라 가장 “덜 중요한” 배를 강제로 내보내 자리를 만든다. 어떤 배를 내보낼지는 배의 크기(메모리 사용량), 얼마나 오래 정박했는지(프로세스 수명), VIP 여부(oom_score_adj) 등을 종합해 결정한다.
원리 — oom_score 알고리즘
섹션 제목: “원리 — oom_score 알고리즘”Linux 커널은 물리 메모리와 스왑이 모두 소진될 때 OOM Killer를 실행한다. 커널은 모든 프로세스에 대해 oom_score(0~1000) 를 계산하고, 가장 높은 점수의 프로세스를 종료한다.
oom_score 계산 요소:1. 메모리 사용량 (RSS): 많이 쓸수록 점수 높음 ← 가장 큰 영향2. 프로세스 수명: 오래 실행될수록 점수 낮음 (중요한 프로세스일 가능성)3. 루트 권한: root 프로세스는 점수 3% 감소4. nice 값: nice 높으면 (낮은 우선순위) 점수 높음5. oom_score_adj: 수동 조정 (-1000 ~ +1000)# 현재 프로세스의 oom_score 확인cat /proc/$(pgrep node)/oom_score# 예상 출력: 342 (0에 가까울수록 살아남을 가능성 높음)
# oom_score_adj 확인 (조정값)cat /proc/$(pgrep node)/oom_score_adj# 예상 출력: 0 (기본값, 조정 없음)
# 중요한 프로세스를 OOM Killer로부터 보호 (-1000 = 거의 불사신)echo -900 > /proc/$(pgrep node)/oom_score_adj
# systemd 서비스에서 영구 설정# [Service]# OOMScoreAdjust=-900
# 전체 프로세스의 oom_score 상위 10개 확인 (OOMKill 위험 프로세스)for pid in $(ls /proc | grep -E '^[0-9]+$'); do score=$(cat /proc/$pid/oom_score 2>/dev/null) comm=$(cat /proc/$pid/comm 2>/dev/null) [ -n "$score" ] && echo "$score $pid $comm"done | sort -rn | head -10# 예상 출력:# 850 12345 node# 720 23456 postgres# 400 34567 nginx📖 더 보기: Linux OOM Killer: A Detailed Guide — Last9 — oom_score 계산 원리, oom_score_adj 튜닝, 컨테이너 환경에서의 동작 방식 (중급)
왜 OOM Killer가 “잘못된” 프로세스를 죽이는가? oom_score는 완벽하지 않다. 메모리를 많이 쓰는 프로세스가 가장 중요한 서비스일 수 있다. 예를 들어 PostgreSQL은 많은 메모리를 사용하여 oom_score가 높지만, 종료되면 전체 서비스가 마비된다. 이 때문에 중요 프로세스는 반드시 oom_score_adj를 낮게 설정해야 한다.
실무 연결 — Docker/K8s OOMKilled: Docker --memory 제한을 초과하면 cgroups가 해당 cgroup 내에서 OOM Killer를 실행한다. kubectl describe pod에서 OOMKilled Reason이 나타나면 컨테이너의 메모리 limit을 상향하거나, Node.js --max-old-space-size로 V8 힙을 컨테이너 limit보다 낮게 설정해야 한다.
3-6. Node.js/Nest.js와 V8 메모리 관리 연결
섹션 제목: “3-6. Node.js/Nest.js와 V8 메모리 관리 연결”V8 Heap 구조와 Node.js 메모리의 관계
섹션 제목: “V8 Heap 구조와 Node.js 메모리의 관계”Node.js 프로세스의 메모리는 OS의 가상 메모리 위에서 동작하며, V8 엔진이 그 위에 자체 힙 관리를 추가한다. process.memoryUsage()가 반환하는 값이 정확히 무엇을 의미하는지 알아야 메모리 이슈를 진단할 수 있다.
[OS 가상 주소 공간] ├── [V8 Heap] ← JavaScript 객체, 클로저 │ ├── Young Generation (New Space): 새로 생성된 객체 │ │ └── 수명 짧은 객체 대부분 여기서 GC됨 (Minor GC, ~1ms) │ └── Old Generation (Old Space): 2번 이상 Minor GC 생존한 객체 │ └── Major GC 발생 시 Stop-the-World (수십~수백ms) ├── [External Memory] ← Node.js Buffer, C++ 바인딩 객체 └── [Stack] ← 함수 호출 스택, 지역 변수// process.memoryUsage() 각 필드의 의미const mem = process.memoryUsage();console.log({ rss: mem.rss, // Resident Set Size: OS에서 실제 물리 RAM 점유량 heapTotal: mem.heapTotal, // V8이 OS에서 예약한 힙 총 크기 heapUsed: mem.heapUsed, // V8이 실제 사용 중인 힙 (GC 대상) external: mem.external, // Buffer 등 C++ 레이어 메모리 arrayBuffers: mem.arrayBuffers, // SharedArrayBuffer, ArrayBuffer 포함});메모리 누수 탐지 패턴 (Node.js 20+ 컨테이너 환경):
# Node.js 20+: 컨테이너 cgroups 기반 자동 힙 제한# 컨테이너 메모리 512MB → V8이 자동으로 old_space_size를 조정docker run -m 512m node:20 node -e " const v8 = require('v8'); const heapStats = v8.getHeapStatistics(); console.log('heap_size_limit:', Math.round(heapStats.heap_size_limit / 1024 / 1024) + 'MB');"# Node.js 20: heap_size_limit: ~409MB (컨테이너 제한의 80%)# Node.js 16 이전: heap_size_limit: 1400MB (컨테이너 무시)
# 메모리 누수 여부 빠른 확인node --max-old-space-size=400 --expose-gc -e " setInterval(() => { global.gc(); const { heapUsed } = process.memoryUsage(); console.log(new Date().toISOString(), 'heap:', Math.round(heapUsed/1024/1024) + 'MB'); }, 5000);" dist/main.js# GC 후에도 heapUsed가 계속 증가하면 누수 의심예상 출력 (정상):
2026-04-08T10:00:00.000Z heap: 120MB2026-04-08T10:00:05.000Z heap: 118MB ← GC 후 감소 (정상)2026-04-08T10:00:10.000Z heap: 121MB예상 출력 (누수):
2026-04-08T10:00:00.000Z heap: 120MB2026-04-08T10:01:00.000Z heap: 180MB ← 계속 증가 (누수!)2026-04-08T10:02:00.000Z heap: 240MB📖 더 보기: Node.js 20+ Memory Management in Containers — Red Hat Developer — 컨테이너 환경에서 Node.js 20의 cgroups 기반 자동 힙 제한 동작 설명 (중급)
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”Node.js 서버 메모리 모니터링
섹션 제목: “Node.js 서버 메모리 모니터링”// Nest.js에서 메모리 사용량 주기적 로깅import { Injectable, Logger } from '@nestjs/common';import { Cron } from '@nestjs/schedule';
@Injectable()export class MemoryMonitorService { private readonly logger = new Logger(MemoryMonitorService.name);
@Cron('*/30 * * * * *') // 30초마다 checkMemoryUsage() { const mem = process.memoryUsage(); this.logger.log({ rss: `${Math.round(mem.rss / 1024 / 1024)}MB`, // Resident Set Size heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`, // 실제 사용 힙 heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)}MB`, // 할당된 힙 external: `${Math.round(mem.external / 1024 / 1024)}MB`, // C++ 오브젝트 });
// 힙 사용률이 80% 넘으면 경고 const heapUsageRatio = mem.heapUsed / mem.heapTotal; if (heapUsageRatio > 0.8) { this.logger.warn(`힙 사용률 위험: ${(heapUsageRatio * 100).toFixed(1)}%`); } }}Docker 컨테이너 메모리 제한
섹션 제목: “Docker 컨테이너 메모리 제한”services: api: image: my-nestjs-app deploy: resources: limits: memory: 512M # 최대 512MB reservations: memory: 256M # 최소 보장 256MB environment: - NODE_OPTIONS=--max-old-space-size=400 # Node.js 힙 제한 (컨테이너 제한보다 낮게)Redis 메모리 정책 운영
섹션 제목: “Redis 메모리 정책 운영”# 현재 메모리 상태 확인redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
# 예상 출력:# used_memory_human: 256.00M# maxmemory_human: 1.00G# mem_fragmentation_ratio: 1.23 ← 1.5 이상이면 파편화 심함Prometheus 기반 선제 메모리 모니터링
섹션 제목: “Prometheus 기반 선제 메모리 모니터링”장애가 터진 후 대응하는 것이 아니라, 메모리가 임계값에 도달하기 전에 알람을 받는 것이 핵심이다. prom-client의 기본 메트릭(collectDefaultMetrics())은 nodejs_heap_size_used_bytes와 nodejs_heap_size_total_bytes를 자동으로 수집하므로 별도 계측 코드 없이 힙 사용률 알람을 구성할 수 있다.
// main.ts — prom-client 기본 메트릭 수집import { collectDefaultMetrics, Registry } from "prom-client";const register = new Registry();collectDefaultMetrics({ register });// nodejs_heap_size_used_bytes, nodejs_heap_size_total_bytes 자동 노출groups: - name: nodejs_memory rules: # 힙 사용률 85% 초과 → Warning - alert: NodeHeapWarning expr: > nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes > 0.85 for: 2m labels: severity: warning annotations: summary: "Node.js 힙 사용률 {{ $value | humanizePercentage }}" description: "heapUsed가 heapTotal의 85%를 초과. GC 빈도 증가 예상."
# 힙 사용률 95% 초과 → Critical (OOMKill 임박) - alert: NodeHeapCritical expr: > nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes > 0.95 for: 1m labels: severity: critical annotations: summary: "Node.js 힙 임계치 초과 — OOMKill 위험" description: "즉시 메모리 누수 여부 확인 또는 Pod 재시작 검토."임계값 근거: heapUsed/heapTotal 85%는 Major GC(Stop-the-World) 빈도가 급증하는 경험적 기준이다. 95%를 넘으면 V8이 힙 확장을 포기하고 프로세스가 OOM으로 종료될 수 있다.
📖 출처: prom-client — github.com/siimon/prom-client | Alerting rules — prometheus.io
5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”| OS 개념 | 실무 적용 |
|---|---|
| 가상 메모리 | Docker 컨테이너 메모리 격리 (--memory 옵션) |
| 페이지 폴트 | Node.js cold start 시 처음 코드 실행이 느린 이유 |
| LRU 알고리즘 | Redis allkeys-lru 정책으로 캐시 운영 |
| LFU 알고리즘 | Redis allkeys-lfu로 인기 데이터 장기 유지 |
| 캐시 지역성 | TypeORM 쿼리에서 SELECT * 대신 필요한 컬럼만 조회 (캐시 효율) |
| OOM Killer | ECS/Kubernetes에서 컨테이너가 갑자기 재시작되는 원인 진단 |
| 쓰레싱 | EC2 인스턴스 스왑 사용률 급증 시 스케일업 판단 |
| Copy-on-Write | Redis BGSAVE 중 used_memory_rss 급증 원인 이해 및 THP 비활성화 근거 |
5.5. 새 메모리 기술 분석 프레임워크
섹션 제목: “5.5. 새 메모리 기술 분석 프레임워크”새로운 메모리 관련 기술(Redis, JVM GC, WASM 선형 메모리, eBPF map 등)을 처음 만났을 때, 아래 4가지 질문을 순서대로 적용하면 문서를 읽기 전에 핵심 구조를 예측할 수 있다.
| 질문 | 확인 내용 | 기술별 예시 |
|---|---|---|
| Q1. 할당 단위는? | 메모리를 어떤 단위로 나누는가 | OS: 4KB 페이지 / Redis: 객체 단위(jemalloc) / JVM: Eden 영역 객체 / WASM: 64KB 페이지 |
| Q2. 회수·축출 정책은? | 메모리를 언제, 어떻게 돌려주는가 | OS: LRU 페이지 교체 / Redis: allkeys-lru / JVM: GC(Mark-Sweep-Compact) / Rust: 소유권 컴파일타임 해제 |
| Q3. 격리 경계는? | 어디까지가 하나의 메모리 도메인인가 | OS: 프로세스 가상 주소 공간 / 컨테이너: cgroups v2 / K8s: Pod limits / JVM: -Xmx 힙 상한 |
| Q4. 실패 모드는? | 메모리가 부족하거나 잘못 쓰이면 어떻게 되는가 | OS: OOMKill / Redis: OOM command not allowed / JVM: OutOfMemoryError / WASM: 선형 메모리 초과 → trap |
사용법 예시 — Node.js V8 힙을 처음 분석할 때:
Q1. 할당 단위: V8 힙은 Young/Old Generation으로 구분. 객체 단위 할당.Q2. 회수 정책: Minor GC(Scavenge, ~1ms) + Major GC(Mark-Compact, Stop-the-World 수십~수백ms)Q3. 격리 경계: --max-old-space-size로 Old Space 상한 설정. 컨테이너 cgroups와 별도 존재.Q4. 실패 모드: 힙 초과 시 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed → 프로세스 크래시 (OOMKill이 아니라 Node.js 내부 abort)이 4가지 질문은 이 문서의 핵심 개념(할당 단위=페이지, 회수 정책=LRU/LFU, 격리 경계=가상 주소 공간/cgroups, 실패 모드=OOMKill/쓰레싱)에서 직접 파생된 것이다. OS 메모리 관리를 이해하면 어떤 기술의 메모리 시스템도 같은 렌즈로 분석할 수 있다.
6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”가상 메모리 vs 스왑 (Swap)
섹션 제목: “가상 메모리 vs 스왑 (Swap)”| 구분 | 가상 메모리 | 스왑 |
|---|---|---|
| 정의 | 프로세스에게 보이는 추상 주소 공간 | 물리 RAM의 보조 저장소 역할을 하는 디스크 공간 |
| 관계 | 상위 개념 | 가상 메모리 구현 수단 중 하나 |
| 위치 | 개념 | 실제 디스크 파티션 또는 파일 |
페이징 vs 세그멘테이션
섹션 제목: “페이징 vs 세그멘테이션”| 구분 | 페이징 | 세그멘테이션 |
|---|---|---|
| 단위 | 고정 크기 (4KB) | 가변 크기 (논리 단위) |
| 단편화 | 내부 단편화 | 외부 단편화 |
| 현대 사용 | 주 메커니즘 | 보조 (논리 구조 표현) |
LRU vs LFU (캐시 축출 정책)
섹션 제목: “LRU vs LFU (캐시 축출 정책)”| 상황 | 추천 정책 | 이유 |
|---|---|---|
| 소셜 피드 (최신 게시물이 중요) | LRU | 최근성 중심 |
| 상품 카탈로그 (인기 상품이 중요) | LFU | 빈도 중심 |
| 일반 캐시 (워크로드 모름) | LRU | 범용적으로 안전 |
malloc vs mmap
섹션 제목: “malloc vs mmap”| 구분 | malloc | mmap |
|---|---|---|
| 정의 | C 표준 라이브러리의 힙 메모리 할당 | OS 직접 호출로 가상 메모리 영역 매핑 |
| 크기 | 소~중간 크기 | 대용량 (파일 매핑, 공유 메모리) |
| Node.js 관련 | V8 힙이 malloc 사용 | Buffer가 내부적으로 mmap 사용 가능 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”케이스 1: Docker 컨테이너가 갑자기 종료됨 (OOMKilled)
섹션 제목: “케이스 1: Docker 컨테이너가 갑자기 종료됨 (OOMKilled)”증상/에러 메시지:
docker inspect <container_id> | grep -A5 "State"# "OOMKilled": true# "ExitCode": 137
# 또는 kubectl describe pod <pod-name># Reason: OOMKilled# Last State: Terminated: Reason: OOMKilled원인:
컨테이너가 설정된 메모리 제한(--memory)을 초과하면, Linux 커널의 cgroups가 해당 cgroup 내에서 OOM Killer를 실행한다. OOM Killer는 oom_score가 가장 높은 프로세스(주로 가장 메모리를 많이 쓰는 것)를 강제 종료한다. 컨테이너의 PID 1(메인 프로세스)이 종료되면 컨테이너 전체가 죽는다.
해결 방법:
# 1. 컨테이너 메모리 제한 상향docker run --memory=1g --memory-swap=2g my-app
# 2. Node.js 힙 제한을 컨테이너 제한보다 낮게 설정# (예: 컨테이너 512MB → Node.js 힙 400MB)NODE_OPTIONS=--max-old-space-size=400 node dist/main.js
# 3. 메모리 누수 확인 (heapUsed가 지속 증가하면 누수)node --inspect dist/main.js# Chrome DevTools → Memory → Heap Snapshot으로 분석
# 4. 메모리 사용량 실시간 확인docker stats <container_id># CONTAINER MEM USAGE / LIMIT MEM %# abc123 480MiB / 512MiB 93.75% ← 위험 수준케이스 2: Redis 서버에서 OOM command not allowed 에러
섹션 제목: “케이스 2: Redis 서버에서 OOM command not allowed 에러”증상/에러 메시지:
(error) OOM command not allowed when used memory > 'maxmemory'.원인:
Redis maxmemory에 도달했고 maxmemory-policy가 noeviction으로 설정되어 있어, 메모리를 늘릴 수 없어 쓰기 명령을 거부한다.
해결 방법:
# 1. 현재 메모리 상태 확인redis-cli INFO memory | grep used_memory_human
# 2. 정책을 LRU로 변경 (즉시 적용)redis-cli CONFIG SET maxmemory-policy allkeys-lru
# 3. maxmemory 상향 (런타임 변경 가능)redis-cli CONFIG SET maxmemory 2gb
# 4. 불필요한 키 수동 정리redis-cli --scan --pattern "session:*" | xargs redis-cli DEL
# 5. 메모리 파편화 해소 (Redis 4.0+)redis-cli MEMORY PURGE케이스 3: EC2 인스턴스 전체가 느려짐 (쓰레싱 의심)
섹션 제목: “케이스 3: EC2 인스턴스 전체가 느려짐 (쓰레싱 의심)”증상/에러 메시지:
# vmstat으로 확인vmstat 1
# 출력:# procs ----memory---- ---swap-- -----io----# r b swpd free si so bi bo# 8 5 8192 512 980 1200 4000 3800# si(swap in) + so(swap out) 지속적으로 높음# b (blocked 프로세스) 많음# r (runqueue) 높은데 CPU 사용률은 낮음원인: 물리 메모리가 부족해 OS가 RAM ↔ 디스크 스왑을 반복한다. 디스크 I/O가 병목이 되어 프로세스들이 I/O 대기 상태(blocked)가 되고, CPU는 놀고 있어도 시스템 전체가 느려진다.
해결 방법:
# 1. 메모리 사용 프로세스 파악ps aux --sort=-%mem | head -20
# 2. 스왑 사용 프로세스별 확인for pid in $(ls /proc | grep -E '^[0-9]+$'); do swap=$(cat /proc/$pid/status 2>/dev/null | grep VmSwap | awk '{print $2}') [ "$swap" -gt "0" ] 2>/dev/null && echo "$pid: ${swap}kB"done | sort -t: -k2 -rn | head -10
# 3. 즉각적인 조치: 불필요한 프로세스 종료 또는 EC2 인스턴스 타입 업그레이드# 4. 장기적: 애플리케이션 메모리 최적화 또는 스케일아웃
# 5. swappiness 값 조정 (스왑 사용 공격성 낮추기)cat /proc/sys/vm/swappiness # 기본값: 60sudo sysctl vm.swappiness=10 # 낮을수록 RAM 선호케이스 6: NestJS에서 Map/배열 캐시 무한 증가
섹션 제목: “케이스 6: NestJS에서 Map/배열 캐시 무한 증가”증상:
서버 시작 직후: heapUsed 150MB트래픽 증가 후: heapUsed 800MB → p99 응답 2000ms (Major GC Stop-the-World)재시작하면 일시 회복 → 수 시간 내 재발원인:
NestJS 서비스에서 인메모리 캐시로 Map 또는 일반 object를 사용할 때, 만료(eviction) 정책 없이 키가 무한정 쌓이는 패턴이다. V8의 Old Generation(Old Space)에 장기 생존 객체들이 누적되어 Major GC(Stop-the-World) 빈도가 증가한다.
해결 방법:
// ❌ 위험: TTL 없는 인메모리 캐시 → 서버 재시작까지 메모리 반환 없음@Injectable()export class UserCacheService { private cache = new Map<number, User>(); // 무제한 증가!
async getUser(id: number): Promise<User> { if (this.cache.has(id)) return this.cache.get(id)!; const user = await this.userRepo.findOne(id); this.cache.set(id, user); // 삭제 없이 계속 추가 return user; }}
// ✅ 해결 1: LRU 캐시로 최대 크기 제한 (npm install lru-cache)import { LRUCache } from "lru-cache";
@Injectable()export class UserCacheService { private cache = new LRUCache<number, User>({ max: 1000, // 최대 1000개 항목 ttl: 1000 * 60 * 5, // 5분 TTL });
async getUser(id: number): Promise<User> { const cached = this.cache.get(id); if (cached) return cached; const user = await this.userRepo.findOne(id); this.cache.set(id, user); return user; }}
// ✅ 해결 2: NestJS CacheModule + Redis (다중 인스턴스 환경 권장)// npm install @nestjs/cache-manager cache-manager-redis-store진단 명령어:
# Major GC 빈도와 Stop-the-World 시간 측정node --trace-gc dist/main.js 2>&1 | grep "Mark-Compact"# [45234:0x7f...] 12345 ms: Mark-Compact 2048.3 (2100.1) -> 1800.1 (1900.2) MB, 450.2ms# 450ms: Major GC로 인한 멈춤 시간 → p99 레이턴시에 직접 반영됨케이스 4: Node.js 프로세스 메모리 누수 (Heap 지속 증가)
섹션 제목: “케이스 4: Node.js 프로세스 메모리 누수 (Heap 지속 증가)”증상:
서버 시작 직후: heapUsed 150MB1시간 후: heapUsed 400MB6시간 후: heapUsed 900MB → OOM 또는 성능 저하원인: 이벤트 리스너, 클로저, 전역 객체에 데이터가 축적되어 GC가 회수하지 못하는 상황.
해결 방법:
# 1. 힙 스냅샷 비교 (V8 Inspector)node --inspect dist/main.js# Chrome 브라우저: chrome://inspect# Memory 탭 → Take snapshot → 시간 경과 후 재스냅샷 → Comparison
# 2. --expose-gc 플래그로 GC 강제 실행 후 메모리 확인node --expose-gc -e "global.gc();console.log('After GC:', process.memoryUsage().heapUsed);"
# 3. 흔한 누수 패턴 확인# - setInterval에서 외부 객체 계속 참조# - EventEmitter에 리스너 누적 (maxListeners 경고 확인)# - 전역 Map/Array에 계속 push하고 지우지 않음케이스 5: 간헐적 레이턴시 스파이크 — Transparent Huge Pages(THP) 원인
섹션 제목: “케이스 5: 간헐적 레이턴시 스파이크 — Transparent Huge Pages(THP) 원인”증상:
# API 응답 시간이 간헐적으로 수백 ms 스파이크# p50: 5ms, p99: 500ms (정상 시 p99: 30ms)# dmesg에서 확인:dmesg | grep -i "compaction"# [12345.678] compaction_alloc: stalling for 100ms원인:
THP(Transparent Huge Pages)가 활성화된 상태에서 커널의 khugepaged 데몬이 4KB 페이지들을 2MB Huge Page로 합치는 compaction 작업을 수행한다. 이 과정에서 메모리 할당이 일시적으로 멈추면서(stall) 애플리케이션의 레이턴시가 급증한다. 연속된 2MB 물리 메모리 영역을 확보하기 위해 기존 페이지를 이동해야 하기 때문이다.
해결 방법:
# 1. THP 비활성화 (데이터베이스, Redis 서버에 권장)echo never > /sys/kernel/mm/transparent_hugepage/enabledecho never > /sys/kernel/mm/transparent_hugepage/defrag
# 2. 영구 설정 (/etc/rc.local 또는 systemd 서비스)cat > /etc/systemd/system/disable-thp.service << 'EOF'[Unit]Description=Disable Transparent Huge Pages[Service]Type=oneshotExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled && echo never > /sys/kernel/mm/transparent_hugepage/defrag"[Install]WantedBy=multi-user.targetEOF
sudo systemctl enable disable-thp
# 3. 현재 Huge Pages 사용 현황 확인grep -i huge /proc/meminfo# AnonHugePages: 2048 kB ← THP로 할당된 메모리# HugePages_Total: 0 ← 명시적 Huge Pages📖 더 보기: Transparent Hugepages: Measuring the Performance Impact — alexandrnikitin.github.io — THP 활성화/비활성화 시 실제 벤치마크 비교
7. 체크리스트
섹션 제목: “7. 체크리스트”- 가상 메모리가 왜 필요한지 두 가지 이유를 말할 수 있다 (격리, 물리 메모리 초과)
- 페이지 테이블이 무엇이고 MMU가 어떤 역할을 하는지 설명할 수 있다
- Minor Page Fault와 Major Page Fault의 차이를 설명할 수 있다
- 쓰레싱이 발생하는 조건과 증상(
vmstat의 si/so)을 설명할 수 있다 - LRU와 LFU 알고리즘의 차이와 각각 어떤 상황에 유리한지 말할 수 있다
- Redis
maxmemory-policy옵션 중 3가지 이상을 설명할 수 있다 - 배열이 연결 리스트보다 순회 속도가 빠른 이유를 캐시 지역성으로 설명할 수 있다
- Docker 컨테이너가
OOMKilled로 종료될 때 원인과 해결 방법을 말할 수 있다 -
docker stats,vmstat,/proc/self/status로 메모리 상태를 확인할 수 있다 - Node.js에서
process.memoryUsage()의 각 필드(rss, heapUsed, heapTotal)를 설명할 수 있다
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 한 줄 설명 |
|---|---|
| Virtual Memory | 프로세스마다 독립적인 가상 주소 공간을 제공하는 메커니즘 |
| Paging | 고정 크기(4KB) 블록으로 메모리를 나눠 관리하는 방식 |
| Page Table | 가상 주소(VPN) → 물리 주소(PFN) 매핑 테이블 |
| MMU | 하드웨어 주소 변환 장치. CPU와 RAM 사이에서 가상→물리 주소 변환 |
| Page Fault | 접근한 페이지가 RAM에 없을 때 발생하는 예외 |
| Minor Page Fault | 디스크 I/O 없이 처리되는 페이지 폴트 |
| Major Page Fault | 디스크에서 페이지를 읽어야 하는 페이지 폴트 |
| Thrashing | 과도한 페이지 교체로 성능이 급락하는 상태 |
| LRU | Least Recently Used. 가장 오래 안 쓴 것 교체 |
| LFU | Least Frequently Used. 가장 적게 쓴 것 교체 |
| Temporal Locality | 최근 접근 데이터를 다시 접근하는 경향 |
| Spatial Locality | 인접 메모리를 곧 접근하는 경향 |
| Cache Line | CPU가 메모리를 읽는 최소 단위 (보통 64바이트) |
| OOM Killer | 메모리 부족 시 Linux 커널이 프로세스를 강제 종료하는 메커니즘 |
| cgroups | Linux 컨테이너 자원 제한 메커니즘. Docker 메모리 제한의 기반 |
| Swap | RAM 부족 시 사용하는 디스크 임시 저장 공간 |
| RSS | Resident Set Size. 실제로 RAM에 올라와 있는 메모리 크기 |
| Copy-on-Write | fork() 시 부모-자식이 페이지를 공유하다 쓰기 발생 시에만 복사 |
8.3. 메모리 관리 심화
섹션 제목: “8.3. 메모리 관리 심화”mmap과 파일 매핑
섹션 제목: “mmap과 파일 매핑”mmap()은 파일이나 디바이스를 프로세스의 가상 주소 공간에 직접 매핑하는 시스템 콜이다. read()/write() 대신 메모리 접근으로 파일을 다룰 수 있어 대용량 파일 처리에 효율적이다.
read() 방식: mmap() 방식:파일 → 커널 버퍼 → 유저 버퍼 파일 → 가상 주소 공간 직접 매핑(데이터 복사 2회) (데이터 복사 0~1회)// Node.js에서 대용량 파일 처리 - mmap 유사 동작// Buffer는 내부적으로 mmap 또는 malloc을 사용const fs = require("fs");
// 작은 파일: readFileSync (일반 read 시스템 콜)const small = fs.readFileSync("small.txt");
// 대용량 파일: createReadStream (스트리밍, 버퍼 최소화)const stream = fs.createReadStream("large-file.csv", { highWaterMark: 64 * 1024, // 64KB 청크});언제 중요한가?
- PostgreSQL: 데이터 파일을 mmap으로 매핑해 페이지 캐시를 활용
- Node.js Buffer: 큰 Buffer 할당 시 내부적으로
mmap()으로 메모리 확보 - 공유 메모리: 여러 프로세스가 같은 물리 페이지를 다른 가상 주소로 접근
zswap과 zram — 압축 스왑
섹션 제목: “zswap과 zram — 압축 스왑”일반 스왑은 RAM ↔ 디스크 간 데이터 이동이라 느리다. 이를 개선하는 두 가지 기술이 있다.
| 기술 | 원리 | 위치 | 특징 |
|---|---|---|---|
| zswap | 스왑 아웃 직전에 RAM에서 압축 저장 | RAM (압축) | 디스크 접근 횟수 감소 |
| zram | RAM의 일부를 압축 블록 디바이스로 사용 | RAM (압축) | 디스크 스왑 완전 대체 가능 |
# zswap 활성화 여부 확인cat /sys/module/zswap/parameters/enabled# Y: 활성화
# zram 상태 확인 (Raspberry Pi, Android에서 주로 사용)cat /proc/swaps# /dev/zram0 partition 1048572 0 -2
# 압축률 확인 (zswap)cat /sys/kernel/debug/zswap/stored_pagescat /sys/kernel/debug/zswap/pool_total_size실무 적용: 메모리가 부족한 소형 EC2(t3.micro 등)에서 zswap을 활성화하면 스왑 발생 시 디스크 I/O 없이 RAM 내 압축으로 처리되어 응답 시간 저하를 줄일 수 있다.
cgroups v2와 메모리 제한의 원리
섹션 제목: “cgroups v2와 메모리 제한의 원리”Docker의 --memory 옵션과 Kubernetes의 resources.limits.memory는 모두 Linux cgroups(Control Groups) 를 사용한다. cgroups v2는 단일 계층 구조로 더 정밀한 메모리 제어를 제공한다.
# cgroups v2 메모리 제한 구조 확인 (컨테이너 내부)cat /sys/fs/cgroup/memory.max# 536870912 (512MB)
cat /sys/fs/cgroup/memory.current# 268435456 (현재 256MB 사용 중)
# 메모리 압박(pressure) 모니터링cat /sys/fs/cgroup/memory.pressure# some avg10=0.00 avg60=0.00 avg300=0.00 total=0# full avg10=5.23 avg60=1.02 avg300=0.34 total=1234567# full avg10이 높으면 메모리 부족으로 모든 태스크가 지연됨cgroups v2의 memory.oom.group: 컨테이너 내 어떤 프로세스라도 OOM이 발생하면 그룹 전체를 함께 종료한다. 컨테이너의 서브 프로세스가 OOM으로 죽을 때 좀비가 되지 않도록 보장한다.
# Kubernetes에서 메모리 Request vs Limit의 의미resources: requests: memory: "256Mi" # cgroups memory.low: 이 양은 보장 limits: memory: "512Mi" # cgroups memory.max: 이 초과 시 OOMKillrequests만 설정하고 limits을 설정하지 않으면, 노드의 메모리를 무제한으로 사용하다가 다른 Pod의 메모리를 빼앗을 수 있다. Production 환경에서는 반드시 limits 설정 필수.
📖 더 보기: Linux cgroups v2 memory controller — kernel.org — cgroups v2 메모리 컨트롤러의 공식 파라미터 설명 (중급)
📖 더 보기: mmap(2) — Linux man pages — mmap 시스템 콜의 플래그, 반환값, 사용 패턴 완전 정리 (중급)
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 Operating Systems: Three Easy Pieces - Virtual Memory (무료 PDF) — OS 가상 메모리의 표준 교과서. 그림과 예제가 풍부하며 무료로 제공 (입문)
- 📖 Redis 공식 문서 - Key Eviction — Redis LRU/LFU/LRM 정책의 정확한 동작 방식과 maxmemory-policy 옵션 전체 설명 (중급)
- 📖 Docker 공식 문서 - Resource Constraints —
--memory,--memory-swap등 Docker 메모리 제한 옵션과 OOM 처리 방식 (입문) - 📖 Linux OOM Killer와 Docker cgroups 심화 — Medium — DevOps 관점에서 OOM Killer, cgroups v2, Docker 메모리 제한의 상호작용 설명 (중급)
- 📖 Understanding Linux Memory — Brendan Gregg — RSS, VSZ, PSS, Shared 등 Linux 메모리 측정 지표와 관련 도구 총정리 (중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”실습 1: 내 시스템의 가상 메모리 구조 확인
섹션 제목: “실습 1: 내 시스템의 가상 메모리 구조 확인”# 현재 셸 프로세스의 가상 메모리 맵cat /proc/self/maps | head -20예상 출력:
55a1b2c00000-55a1b2c01000 r-xp00000000 fd:01 123456 /usr/bin/bash55a1b2e00000-55a1b2e01000 r--p00200000 fd:01 123456 /usr/bin/bash7f8a3d400000-7f8a3d600000 r--p00000000 fd:01 999999 /lib/x86_64-linux-gnu/libc.so.67ffc12345000-7ffc12366000 rw-p00000000 00:00 0 [stack]실습 2: 페이지 크기 및 메모리 사용량 확인
섹션 제목: “실습 2: 페이지 크기 및 메모리 사용량 확인”# 페이지 크기 확인getconf PAGE_SIZE
# 시스템 전체 메모리 현황free -h예상 출력:
4096
total used free shared buff/cache availableMem: 7.7G 3.2G 512M 256M 4.0G 4.2GSwap: 2.0G 256M 1.7G실습 3: 실시간 메모리 및 스왑 사용률 모니터링
섹션 제목: “실습 3: 실시간 메모리 및 스왑 사용률 모니터링”# 1초 간격으로 5회 측정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 1 0 26112 524288 65536 4194304 0 0 10 5 500 1000 15 5 78 2 0 0 26112 523776 65536 4194816 0 0 0 0 400 800 10 3 87 0si(swap in),so(swap out)이 지속 0이면 스왑 미사용 → 정상b(blocked 프로세스)가 높으면 I/O 대기 중
실습 4: Node.js 메모리 사용량 실시간 확인
섹션 제목: “실습 4: Node.js 메모리 사용량 실시간 확인”setInterval(() => { const mem = process.memoryUsage(); console.log({ rss: `${(mem.rss / 1024 / 1024).toFixed(1)}MB`, heapUsed: `${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB`, heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)}MB`, external: `${(mem.external / 1024 / 1024).toFixed(1)}MB`, });}, 1000);node memory-check.js예상 출력:
{ rss: '38.2MB', heapUsed: '5.8MB', heapTotal: '8.1MB', external: '0.4MB' }{ rss: '38.2MB', heapUsed: '5.9MB', heapTotal: '8.1MB', external: '0.4MB' }실습 5: Docker 컨테이너 메모리 제한 테스트
섹션 제목: “실습 5: Docker 컨테이너 메모리 제한 테스트”# 메모리를 100MB로 제한하고 컨테이너 실행docker run --rm --memory=100m --name mem-test node:20-alpine \ node -e " const arr = []; setInterval(() => { arr.push(Buffer.alloc(10 * 1024 * 1024)); // 10MB씩 할당 console.log('할당:', arr.length * 10, 'MB'); }, 500); "예상 출력:
할당: 10 MB할당: 20 MB...할당: 90 MBKilled ← OOMKilled로 컨테이너 종료# 종료 원인 확인docker inspect mem-test --format '{{.State.OOMKilled}}'# true10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”운영체제는 가상 메모리와 페이징으로 프로세스를 격리하고, LRU/LFU 알고리즘으로 한정된 물리 메모리를 효율적으로 관리하며, 이 원리는 Redis 캐시 축출 정책부터 Docker OOM 트러블슈팅까지 직접 연결된다.