콘텐츠로 이동

Memory Management

운영체제가 프로세스마다 독립적인 가상 주소 공간을 제공하고, 물리 메모리와 디스크를 투명하게 연결해 프로그램이 메모리를 안전하고 효율적으로 사용할 수 있게 해주는 메커니즘이다.


  • 프로세스 격리: 한 프로세스가 다른 프로세스의 메모리를 침범하지 못하게 막아 시스템 안정성을 보장한다.
  • 메모리 초과 실행: 물리 메모리(RAM)보다 큰 프로그램도 실행할 수 있게 해준다. 예전에 4GB RAM으로 8GB짜리 프로그램을 돌릴 수 있었던 이유가 바로 이것이다.
  • 실무 영향: Node.js/Nest.js 서버가 OOM(Out Of Memory)으로 죽거나, Docker 컨테이너가 OOMKilled 상태로 종료되는 현상은 모두 메모리 관리와 직결된다. Redis 캐시의 maxmemory-policy 설정도 OS 페이지 교체 알고리즘에서 비롯된 개념이다.
  • 성능 최적화: 배열이 연결 리스트보다 빠른 이유, DB 인덱스가 B+Tree를 쓰는 이유도 메모리 접근 패턴(캐시 지역성)으로 설명된다.

호텔을 상상해 보자. 호텔에는 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이 부족하면 일부를 디스크에 내리고 필요할 때 다시 올린다 → 물리 메모리보다 큰 프로그램 실행
Terminal window
# Linux에서 프로세스의 가상 메모리 맵 확인
cat /proc/self/maps

예상 출력:

55a1b2c00000-55a1b2c01000 r-x​p00000000 fd:01 123456 /usr/bin/c​at
7f8a3d400000-7f8a3d600000 r--​p00000000 fd:01 234567 /lib/x86_64/lib​c.so.6
7ffc12345000-7ffc12366000 rw-​p00000000 00:00 0 [st​ack]
  • 각 줄이 가상 주소 범위(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 (새 복사본 생성)

BGSAVE(백그라운드 스냅샷 저장) 시 Redis는 fork()로 자식 프로세스를 생성하고, 자식이 RDB 파일을 디스크에 기록한다. 이때:

  1. fork() 직후에는 메모리를 거의 추가로 사용하지 않는다
  2. 부모(Redis 서버)가 클라이언트 요청을 처리하며 데이터를 변경할수록 CoW가 발동해 페이지가 복사된다
  3. 쓰기가 많은 시간대에 BGSAVE하면 used_memory_rss(OS가 인식하는 실제 점유 메모리)가 급증한다
Terminal window
# 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)이 레이턴시 스파이크를 유발하기 때문이다.

Terminal window
# THP 상태 확인
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never ← always가 기본값
# 데이터베이스 서버에서 THP 비활성화
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo 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 발생)
Terminal window
# Linux에서 기본 페이지 크기 확인
getconf PAGE_SIZE

예상 출력:

4096
Terminal window
# 프로세스 메모리 사용 상세 조회
cat /pro​c/$$/status | grep -E "Vm(RSS|Size|Swap)"

예상 출력:

VmSize: 16384 kB ← 가상 메모리 크기 (할당된 전체)
VmRSS: 4096 kB ← Resident Set Size (실제 RAM에 올라온 크기)
VmSwap: 512 kB ← 스왑에 있는 크기

VmSize가 크고 VmRSS가 작다면 가상 주소는 많이 잡았지만 실제 물리 메모리는 적게 쓰는 것이다.

구분페이징세그멘테이션
블록 크기고정 (4KB)가변 (코드/데이터/스택 크기)
단편화내부 단편화 가능외부 단편화 발생
논리적 의미없음 (순수 기계적 분할)있음 (코드/힙/스택)
현대 OS 사용주로 사용보조적으로 병용

현대 Linux/macOS는 세그멘테이션 개념(코드 영역, 힙, 스택 구분)을 논리 구조로 유지하면서, 실제 물리 메모리 관리는 페이징으로 처리한다.


책상 위에 자주 쓰는 책 몇 권만 올려두는 상황을 생각해 보자. 특정 책이 필요한데 책상에 없으면, 책장(디스크)까지 가서 가져와야 한다. 이 “책장에서 가져오는 이벤트”가 바로 페이지 폴트다.

  1. CPU가 가상 주소에 접근한다
  2. MMU가 페이지 테이블을 확인한다
  3. present 비트가 0이면 → Page Fault 예외 발생
  4. OS의 페이지 폴트 핸들러가 실행된다
  5. 디스크(스왑)에서 해당 페이지를 RAM으로 로드한다
  6. 페이지 테이블을 업데이트한다
  7. 원래 명령어를 재실행한다
Minor Page Fault (소프트 폴트):
- 페이지가 이미 메모리에 있지만 프로세스 페이지 테이블에 매핑이 없는 경우
- 예: 공유 라이브러리를 처음 접근할 때 (다른 프로세스가 이미 RAM에 올려둔 경우)
- 디스크 I/O 없음 → 빠름
Major Page Fault (하드 폴트):
- 페이지가 실제로 디스크에 있어서 RAM으로 로드해야 하는 경우
- 디스크 I/O 필요 → 느림 (수십~수백 ms)
Terminal window
# 프로세스의 페이지 폴트 횟수 확인
/usr/bin/time -v ls /tmp 2>&1 | grep -E "Major|Minor"

예상 출력:

Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 312

페이지 폴트가 너무 자주 발생하면 CPU가 실제 작업보다 페이지 교체에 대부분의 시간을 쓰는 상태, 즉 쓰레싱이 일어난다.

[정상 상태]
CPU → 연산 → 연산 → 연산 → 페이지폴트 → 연산 → 연산 ...
(연산 비율 높음)
[쓰레싱 상태]
CPU → 페이지폴트 → 페이지폴트 → 페이지폴트 → ...
(디스크 I/O 비율 압도적)

증상: CPU 사용률이 낮은데 시스템이 느리고, 디스크 I/O가 폭증한다.

왜 쓰레싱이 발생하는가? 근본 원인은 워킹 셋(Working Set) 이 물리 메모리보다 클 때다. 워킹 셋이란 프로세스가 일정 시간 내에 실제로 접근하는 페이지 집합이다. 10개 프로세스가 각각 500MB의 워킹 셋을 가지면 총 5GB가 필요한데, RAM이 4GB라면 OS는 끊임없이 페이지를 스왑 아웃/인 해야 한다. 프로세스 A의 페이지를 쫓아내면 곧 A가 그 페이지를 다시 요청하고, 이것이 무한 반복된다. 해결책은 프로세스 수를 줄이거나(스케일다운), 메모리를 늘리거나(스케일업), 워킹 셋을 줄이는 것(코드 최적화)이다.

Terminal window
# 스왑 사용량 실시간 모니터링 (쓰레싱 진단)
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 1500

si (swap in), so (swap out) 값이 지속적으로 높으면 쓰레싱 징후다.


3-4. 페이지 교체 알고리즘 (Page Replacement Algorithm)

섹션 제목: “3-4. 페이지 교체 알고리즘 (Page Replacement Algorithm)”

책상 위에 5권만 올려둘 수 있는 상황에서 6번째 책이 필요하다. 어떤 책을 치울 것인가? 이 결정 기준이 페이지 교체 알고리즘이다.

“가장 오래 전에 쓴 페이지를 교체한다”

시간 흐름에 따른 동작:

접근 순서: 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 교체
  • 장점: 최근성 기반으로 직관적, 대부분의 워크로드에서 우수
  • 단점: 구현 비용 (모든 접근마다 타임스탬프 갱신)

“접근 횟수가 가장 적은 페이지를 교체한다”

페이지별 접근 횟수:
A: 5회
B: 3회
C: 1회 ← 가장 적음 → C 교체
D: 4회
  • 장점: 자주 쓰는 데이터를 오래 유지 (정적 인기 데이터에 유리)
  • 단점: 오래 전에 많이 쓰고 지금은 안 쓰는 데이터가 남아있을 수 있음 (Aging 문제)

“가장 먼저 들어온 페이지를 교체한다”

구현이 단순하지만 성능이 가장 낮다. Belady’s Anomaly(프레임 수를 늘렸는데 페이지 폴트가 오히려 증가하는 현상)가 발생할 수 있다.

알고리즘기준장점단점사용처
LRU최근 사용 시간대부분 워크로드에서 우수구현 비용, 접근 패턴이 바뀌면 비효율Linux 커널, Redis allkeys-lru
LFU접근 빈도수자주 쓰는 데이터 장기 유지Aging 문제, 신규 데이터 불리Redis allkeys-lfu
FIFO입장 순서구현 단순성능 최악, Belady’s Anomaly거의 미사용
OPT미래 예측이론상 최적미래를 알아야 함 (구현 불가)성능 비교 기준선

OS 페이지 교체 알고리즘과 Redis 캐시 축출 정책은 같은 개념의 다른 적용이다.

Terminal window
# Redis 메모리 제한 및 정책 설정
redis-cli CON​FIG SET maxmemory 1gb
redis-cli CON​FIG SET maxmemory-policy allkeys-lru
Redis maxmemory-policy 옵션:
noeviction → 축출 안 함, 메모리 초과 시 쓰기 에러 반환
allkeys-lru → 모든 키 중 LRU 방식으로 축출 (가장 일반적)
allkeys-lfu → 모든 키 중 LFU 방식으로 축출 (Redis 4.0+)
volatile-lru → TTL 있는 키 중 LRU
volatile-lfu → TTL 있는 키 중 LFU
allkeys-random → 무작위 축출

선택 기준:

  • 캐시 용도(모든 키가 캐시): allkeys-lru 또는 allkeys-lfu
  • 세션/임시 데이터만 축출하고 싶음: volatile-lru
  • 특정 데이터가 항상 인기 있고 변하지 않음: allkeys-lfu
  • 워크로드가 자주 바뀌거나 최근 데이터가 중요: allkeys-lru

냉장고에서 재료를 꺼내 요리하는 상황을 생각해 보자. 요리할 때 자주 쓰는 소금, 간장, 기름은 가스레인지 바로 옆 선반에 두면 편하다(Temporal Locality). 또한 김치찌개를 만들 때 김치 가져오면서 옆에 있는 두부, 돼지고기도 같이 가져오면 효율적이다(Spatial Locality).

CPU 캐시도 동일하게 동작한다. RAM 접근은 CPU 연산보다 수백 배 느리기 때문에, CPU는 최근에 쓴 데이터와 그 주변 데이터를 캐시에 미리 올려둔다.

“최근에 접근한 데이터는 가까운 미래에 또 접근할 가능성이 높다”

// 루프 변수 i는 매 반복마다 접근 → Temporal Locality
for (let i = 0; i < 1000000; i++) {
sum += arr[i]; // i는 반복적으로 접근
}

“방금 접근한 주소 근처의 데이터도 곧 접근할 가능성이 높다”

// arr[0], arr[1], arr[2]... 순차 접근 → Spatial Locality
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}

CPU는 arr[0]에 접근할 때 주변 64바이트(캐시 라인)를 통째로 캐시에 올린다. 다음에 arr[1]에 접근하면 이미 캐시에 있어서 빠르다.

// 배열: 메모리 연속 저장 → 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 접근마다 다른 캐시 라인 → 캐시 미스 연속 발생
Terminal window
# 실제 성능 차이 측정 (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.234ms

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) 등을 종합해 결정한다.

Linux 커널은 물리 메모리와 스왑이 모두 소진될 때 OOM Killer를 실행한다. 커널은 모든 프로세스에 대해 oom_score(0~1000) 를 계산하고, 가장 높은 점수의 프로세스를 종료한다.

oom_score 계산 요소:
1. 메모리 사용량 (RSS): 많이 쓸수록 점수 높음 ← 가장 큰 영향
2. 프로세스 수명: 오래 실행될수록 점수 낮음 (중요한 프로세스일 가능성)
3. 루트 권한: root 프로세스는 점수 3% 감소
4. nice 값: nice 높으면 (낮은 우선순위) 점수 높음
5. oom_score_adj: 수동 조정 (-1000 ~ +1000)
Terminal window
# 현재 프로세스의 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+ 컨테이너 환경):

Terminal window
# 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: 120MB
2026-04-08T10:00:05.000Z heap: 118MB ← GC 후 감소 (정상)
2026-04-08T10:00:10.000Z heap: 121MB

예상 출력 (누수):

2026-04-08T10:00:00.000Z heap: 120MB
2026-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 기반 자동 힙 제한 동작 설명 (중급)


// 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-compose.yml
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 힙 제한 (컨테이너 제한보다 낮게)
Terminal window
# 현재 메모리 상태 확인
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_bytesnodejs_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 자동 노출
prometheus/alerts/nodejs-memory.yml
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


OS 개념실무 적용
가상 메모리Docker 컨테이너 메모리 격리 (--memory 옵션)
페이지 폴트Node.js cold start 시 처음 코드 실행이 느린 이유
LRU 알고리즘Redis allkeys-lru 정책으로 캐시 운영
LFU 알고리즘Redis allkeys-lfu로 인기 데이터 장기 유지
캐시 지역성TypeORM 쿼리에서 SELECT * 대신 필요한 컬럼만 조회 (캐시 효율)
OOM KillerECS/Kubernetes에서 컨테이너가 갑자기 재시작되는 원인 진단
쓰레싱EC2 인스턴스 스왑 사용률 급증 시 스케일업 판단
Copy-on-WriteRedis 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 메모리 관리를 이해하면 어떤 기술의 메모리 시스템도 같은 렌즈로 분석할 수 있다.


구분가상 메모리스왑
정의프로세스에게 보이는 추상 주소 공간물리 RAM의 보조 저장소 역할을 하는 디스크 공간
관계상위 개념가상 메모리 구현 수단 중 하나
위치개념실제 디스크 파티션 또는 파일
구분페이징세그멘테이션
단위고정 크기 (4KB)가변 크기 (논리 단위)
단편화내부 단편화외부 단편화
현대 사용주 메커니즘보조 (논리 구조 표현)
상황추천 정책이유
소셜 피드 (최신 게시물이 중요)LRU최근성 중심
상품 카탈로그 (인기 상품이 중요)LFU빈도 중심
일반 캐시 (워크로드 모름)LRU범용적으로 안전
구분mallocmmap
정의C 표준 라이브러리의 힙 메모리 할당OS 직접 호출로 가상 메모리 영역 매핑
크기소~중간 크기대용량 (파일 매핑, 공유 메모리)
Node.js 관련V8 힙이 malloc 사용Buffer가 내부적으로 mmap 사용 가능

케이스 1: Docker 컨테이너가 갑자기 종료됨 (OOMKilled)

섹션 제목: “케이스 1: Docker 컨테이너가 갑자기 종료됨 (OOMKilled)”

증상/에러 메시지:

Terminal window
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(메인 프로세스)이 종료되면 컨테이너 전체가 죽는다.

해결 방법:

Terminal window
# 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-policynoeviction으로 설정되어 있어, 메모리를 늘릴 수 없어 쓰기 명령을 거부한다.

해결 방법:

Terminal window
# 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 인스턴스 전체가 느려짐 (쓰레싱 의심)”

증상/에러 메시지:

Terminal window
# 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는 놀고 있어도 시스템 전체가 느려진다.

해결 방법:

Terminal window
# 1. 메모리 사용 프로세스 파악
ps aux --sort=-%mem | head -20
# 2. 스왑 사용 프로세스별 확인
for pid in $(ls /pro​c | grep -E '^[0-9]+$'); do
swap=$(cat /pro​c/$pid/status 2>/dev/null | grep Vm​Swap | 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 /pro​c/sys/vm/swappiness # 기본값: 60
sudo sys​ctl 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

진단 명령어:

Terminal window
# 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 150MB
1시간 후: heapUsed 400MB
6시간 후: heapUsed 900MB → OOM 또는 성능 저하

원인: 이벤트 리스너, 클로저, 전역 객체에 데이터가 축적되어 GC가 회수하지 못하는 상황.

해결 방법:

Terminal window
# 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) 원인”

증상:

Terminal window
# 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 물리 메모리 영역을 확보하기 위해 기존 페이지를 이동해야 하기 때문이다.

해결 방법:

Terminal window
# 1. THP 비활성화 (데이터베이스, Redis 서버에 권장)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo 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=oneshot
ExecStart=/bin/sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled && echo never > /sys/kernel/mm/transparent_hugepage/defrag"
[Install]
WantedBy=multi-user.target
EOF
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 활성화/비활성화 시 실제 벤치마크 비교


  • 가상 메모리가 왜 필요한지 두 가지 이유를 말할 수 있다 (격리, 물리 메모리 초과)
  • 페이지 테이블이 무엇이고 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)를 설명할 수 있다

키워드한 줄 설명
Virtual Memory프로세스마다 독립적인 가상 주소 공간을 제공하는 메커니즘
Paging고정 크기(4KB) 블록으로 메모리를 나눠 관리하는 방식
Page Table가상 주소(VPN) → 물리 주소(PFN) 매핑 테이블
MMU하드웨어 주소 변환 장치. CPU와 RAM 사이에서 가상→물리 주소 변환
Page Fault접근한 페이지가 RAM에 없을 때 발생하는 예외
Minor Page Fault디스크 I/O 없이 처리되는 페이지 폴트
Major Page Fault디스크에서 페이지를 읽어야 하는 페이지 폴트
Thrashing과도한 페이지 교체로 성능이 급락하는 상태
LRULeast Recently Used. 가장 오래 안 쓴 것 교체
LFULeast Frequently Used. 가장 적게 쓴 것 교체
Temporal Locality최근 접근 데이터를 다시 접근하는 경향
Spatial Locality인접 메모리를 곧 접근하는 경향
Cache LineCPU가 메모리를 읽는 최소 단위 (보통 64바이트)
OOM Killer메모리 부족 시 Linux 커널이 프로세스를 강제 종료하는 메커니즘
cgroupsLinux 컨테이너 자원 제한 메커니즘. Docker 메모리 제한의 기반
SwapRAM 부족 시 사용하는 디스크 임시 저장 공간
RSSResident Set Size. 실제로 RAM에 올라와 있는 메모리 크기
Copy-on-Writefork() 시 부모-자식이 페이지를 공유하다 쓰기 발생 시에만 복사

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()으로 메모리 확보
  • 공유 메모리: 여러 프로세스가 같은 물리 페이지를 다른 가상 주소로 접근

일반 스왑은 RAM ↔ 디스크 간 데이터 이동이라 느리다. 이를 개선하는 두 가지 기술이 있다.

기술원리위치특징
zswap스왑 아웃 직전에 RAM에서 압축 저장RAM (압축)디스크 접근 횟수 감소
zramRAM의 일부를 압축 블록 디바이스로 사용RAM (압축)디스크 스왑 완전 대체 가능
Terminal window
# 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_pages
cat /sys/kernel/debug/zswap/pool_total_size

실무 적용: 메모리가 부족한 소형 EC2(t3.micro 등)에서 zswap을 활성화하면 스왑 발생 시 디스크 I/O 없이 RAM 내 압축으로 처리되어 응답 시간 저하를 줄일 수 있다.

Docker의 --memory 옵션과 Kubernetes의 resources.limits.memory는 모두 Linux cgroups(Control Groups) 를 사용한다. cgroups v2는 단일 계층 구조로 더 정밀한 메모리 제어를 제공한다.

Terminal window
# 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: 이 초과 시 OOMKill

requests만 설정하고 limits을 설정하지 않으면, 노드의 메모리를 무제한으로 사용하다가 다른 Pod의 메모리를 빼앗을 수 있다. Production 환경에서는 반드시 limits 설정 필수.

📖 더 보기: Linux cgroups v2 memory controller — kernel.org — cgroups v2 메모리 컨트롤러의 공식 파라미터 설명 (중급)

📖 더 보기: mmap(2) — Linux man pages — mmap 시스템 콜의 플래그, 반환값, 사용 패턴 완전 정리 (중급)



실습 1: 내 시스템의 가상 메모리 구조 확인

섹션 제목: “실습 1: 내 시스템의 가상 메모리 구조 확인”
Terminal window
# 현재 셸 프로세스의 가상 메모리 맵
cat /pro​c/self/maps | head -20

예상 출력:

55a1b2c00000-55a1b2c01000 r-x​p00000000 fd:01 123456 /usr/bin/bash
55a1b2e00000-55a1b2e01000 r--​p00200000 fd:01 123456 /usr/bin/bash
7f8a3d400000-7f8a3d600000 r--​p00000000 fd:01 999999 /lib/x86_64-linux-gnu/lib​c.so.6
7ffc12345000-7ffc12366000 rw-​p00000000 00:00 0 [st​ack]

실습 2: 페이지 크기 및 메모리 사용량 확인

섹션 제목: “실습 2: 페이지 크기 및 메모리 사용량 확인”
Terminal window
# 페이지 크기 확인
getconf PAGE_SIZE
# 시스템 전체 메모리 현황
free -h

예상 출력:

4096
total used free shared buff/cache available
Mem: 7.7G 3.2G 512M 256M 4.0G 4.2G
Swap: 2.0G 256M 1.7G

실습 3: 실시간 메모리 및 스왑 사용률 모니터링

섹션 제목: “실습 3: 실시간 메모리 및 스왑 사용률 모니터링”
Terminal window
# 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 0
  • si(swap in), so(swap out)이 지속 0이면 스왑 미사용 → 정상
  • b(blocked 프로세스)가 높으면 I/O 대기 중

실습 4: Node.js 메모리 사용량 실시간 확인

섹션 제목: “실습 4: Node.js 메모리 사용량 실시간 확인”
memory-check.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);
Terminal window
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 컨테이너 메모리 제한 테스트”
Terminal window
# 메모리를 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 MB
Killed ← OOMKilled로 컨테이너 종료
Terminal window
# 종료 원인 확인
docker inspect mem-test --format '{​{.State.OOMKilled}}'
# true

운영체제는 가상 메모리와 페이징으로 프로세스를 격리하고, LRU/LFU 알고리즘으로 한정된 물리 메모리를 효율적으로 관리하며, 이 원리는 Redis 캐시 축출 정책부터 Docker OOM 트러블슈팅까지 직접 연결된다.