콘텐츠로 이동

Memory Management

메모리 관리는 운영체제가 프로세스마다 독립적인 가상 주소 공간을 주고, 그 주소를 실제 물리 메모리와 필요하면 디스크 스왑에 연결해 주는 메커니즘이다.

첫 회독에서는 아래 순서만 따라간다.

  1. 1~2.5절에서 가상 메모리가 해결하는 격리와 주소 변환 문제를 잡는다.
  2. 3.13.2절과 3.43.6절에서 page/frame/page table, Copy-on-Write(CoW), mmap을 읽는다. 3.3절 THP는 건너뛴다.
  3. 4.1~4.5절에서 page fault, RSS, swap, working set, reclaim을 연결한다. 4.1절의 NUMA/KSM 보충과 4.6절 zswap/zram은 건너뛴다.
  4. 5~7절에서 회수 정책, cache locality, OOM/cgroup/V8 메모리 경계를 읽는다.
  5. 8.1·8.2·8.4절과 9~10절로 실패 신호와 개념 경계를 확인한 뒤 12절의 첫 회독 체크로 종료한다.

종료 조건은 다음과 같다.

  • 가상 주소와 물리 주소의 차이를 설명할 수 있다.
  • page, frame, page table, MMU, TLB가 주소 변환에서 맡는 역할을 말할 수 있다.
  • 두 프로세스가 같은 가상 주소를 써도 서로 격리되는 이유를 설명할 수 있다.
  • Copy-on-Write가 fork 비용을 어떻게 미루는지 Redis BGSAVE 예시로 설명할 수 있다.
  • minor page fault와 major page fault의 차이를 디스크 I/O 여부로 구분할 수 있다.
  • VmSize, RSS, VmSwap의 차이를 설명하고 컨테이너 OOM에서 무엇을 먼저 볼지 고를 수 있다.
  • swap이 완충재이지만 성능 장치가 아닌 이유를 말할 수 있다.
  • LRU와 LFU의 차이를 Redis maxmemory-policy 선택 기준으로 연결할 수 있다.
  • 배열 순회가 연결 리스트 순회보다 cache locality에 유리한 이유를 설명할 수 있다.
  • heapUsed가 낮아도 RSS 때문에 OOMKilled가 날 수 있는 이유를 설명할 수 있다.

선택 심화 절: 3.3절과 8.3절(THP/Huge Pages), 4.1절의 NUMA/KSM 보충, 4.6절(zswap/zram), 11절(관찰 스니펫), 12절의 선택 심화 체크다. 이 절들은 설정 변경 목록이 아니라 TLB reach, 원격 메모리, 압축 swap, 관찰 비용의 trade-off를 확인하는 자료다.

이 문서에서 다루는 핵심 질문은 세 가지다.

  1. 왜 두 프로세스가 같은 주소 0x1000을 써도 서로의 데이터를 덮어쓰지 않는가?
  2. heapUsed는 낮은데 컨테이너가 OOMKilled로 죽을 수 있는가?
  3. 메모리가 부족할 때 OS, Node.js, Redis, cgroups는 각각 어떤 기준으로 버티거나 포기하는가?

process-thread.mdx에서 프로세스가 독립 주소 공간을 가진다고 배웠다면, 이 문서는 그 주소 공간이 실제로 어떻게 만들어지고, 어떤 숫자로 관찰되며, 어디서 성능 문제가 되는지를 설명한다.


아래 용어는 뒤에서 반복된다. 처음에는 내부 구현보다 “무엇을 구분하기 위한 말인가”를 잡으면 된다.

용어첫 이해왜 필요한가
가상 주소(Virtual Address)프로세스가 자기 메모리라고 생각하며 사용하는 주소프로세스마다 같은 주소를 써도 서로 격리되게 한다
물리 주소(Physical Address)RAM 칩 위의 실제 위치를 가리키는 주소CPU와 메모리 컨트롤러가 실제 데이터를 찾는 기준이다
페이지(Page)가상 주소 공간을 고정 크기로 자른 조각. 보통 4KB메모리를 작은 단위로 매핑·보호·교체하기 위해 필요하다
프레임(Frame)물리 메모리를 페이지와 같은 크기로 자른 조각페이지가 실제 RAM 어디에 올라와 있는지 표현한다
페이지 테이블(Page Table)가상 페이지 번호를 물리 프레임 번호로 바꾸는 표프로세스별 메모리 지도를 만든다
MMU(Memory Management Unit)CPU 안에서 주소 변환과 권한 검사를 수행하는 하드웨어앱이 커널이나 다른 프로세스 메모리를 마음대로 읽지 못하게 한다
TLB(Translation Lookaside Buffer)최근 주소 변환 결과를 저장하는 작은 CPU 캐시페이지 테이블 조회 비용을 줄인다
페이지 폴트(Page Fault)접근한 가상 페이지가 현재 매핑되지 않았거나 RAM에 없을 때 발생하는 예외OS가 필요한 페이지를 올리거나 잘못된 접근을 막는 계기가 된다
RSS(Resident Set Size)프로세스 페이지 중 실제 RAM에 올라와 있는 양컨테이너 OOM과 더 직접적으로 연결된다
VmSize프로세스가 예약한 가상 주소 공간 전체 크기실제 RAM 사용량이 아니라 “주소로 잡아 둔 범위”다
스왑(Swap)RAM에서 밀려난 페이지를 임시로 두는 디스크 공간RAM보다 큰 working set을 버티게 하지만 느리다
OOM Killer메모리가 부족할 때 커널이 프로세스를 골라 종료하는 메커니즘시스템 전체 정지를 피하기 위한 최후의 방어다
cgroups memory limit컨테이너나 서비스 그룹에 부여한 메모리 상한한 컨테이너가 노드 전체 메모리를 먹지 못하게 한다
V8 HeapNode.js에서 JavaScript 객체가 주로 사는 V8 엔진의 힙heapUsed와 GC 지연을 이해하는 기준이다
Copy-on-Write(CoW)공유하던 페이지를 쓰기 시점에만 복사하는 최적화fork()와 Redis BGSAVE의 메모리 급증을 설명한다
THP(Transparent Huge Pages)커널이 4KB 페이지를 자동으로 2MB 큰 페이지로 묶는 기능TLB miss는 줄이지만 CoW와 latency spike를 키울 수 있다

첫 회독에서 MMU와 TLB 내부 회로를 알 필요는 없다. “CPU가 메모리에 접근할 때 가상 주소를 물리 주소로 바꾸고, 그 결과를 잠깐 캐시한다” 정도면 이 문서를 읽을 수 있다.


2.5. 선행 기술의 한계 — 가상 메모리가 등장한 이유

섹션 제목: “2.5. 선행 기술의 한계 — 가상 메모리가 등장한 이유”

가상 메모리는 RAM을 더 크게 보이게 하는 트릭만이 아니다. 더 근본적으로는 여러 프로그램을 동시에 실행할 때 생긴 세 가지 실패를 줄이기 위해 등장했다.

직접 물리 주소 모델의 실패와 가상 메모리의 응답

격리 부재

프로그램이 물리 주소를 직접 다루면 포인터 버그 하나가 다른 프로세스나 커널 데이터를 덮어쓸 수 있다.

프로세스별 페이지 테이블과 MMU 권한 검사로 잘못된 접근을 해당 프로세스 안에서 막는다.

RAM 크기 = 프로그램 상한

필요한 모든 코드와 데이터를 RAM에 한 번에 올려야 하면, RAM보다 큰 프로그램이나 working set을 다루기 어렵다.

필요한 페이지를 접근 시점에 올리는 demand paging과 swap으로 물리 RAM 한계를 완화한다.

재배치 비용

프로그램을 빈 물리 메모리 위치에 올릴 때마다 주소를 다시 맞추면 실행과 공유가 복잡해진다.

프로세스는 같은 가상 주소를 쓰고, OS가 각 프로세스의 물리 매핑만 다르게 유지한다.

현대 OS의 해법은 “프로세스에게는 자기만의 연속된 메모리처럼 보이게 하고, 실제 배치는 OS가 숨긴다”는 것이다. 이 덕분에 한 Node.js worker가 잘못된 주소를 건드리면 보통 그 프로세스만 SIGSEGV로 죽고, 다른 worker의 heap은 보호된다. 컨테이너의 메모리 제한도 이 격리 위에 cgroups라는 두 번째 제한을 얹은 것이다.

가상 메모리는 공짜가 아니다. 주소 변환에는 TLB와 페이지 테이블 조회 비용이 있고, 필요한 페이지가 디스크에 있으면 major page fault가 발생한다. 대략적인 감각만 잡으면 충분하다.

사건대략적 비용 감각해석
TLB hitCPU 캐시 수준최근 변환 결과가 있어 빠르다
TLB miss수십~수백 cycle 가능페이지 테이블을 더 찾아야 한다
minor page fault디스크 I/O 없음매핑만 보강하거나 이미 RAM에 있는 페이지를 연결한다
major page faultSSD에서도 us~ms 단위 가능디스크에서 페이지를 읽고 원래 명령을 재시도한다
swap thrashing요청 지연이 초 단위로 튈 수 있음실제 작업보다 페이지 교체에 시간을 쓴다

출처로 더 깊게 보려면 OSTEP Virtual Memory, Multics Virtual Memory, LWN Huge Pages and TLB costs가 좋은 출발점이다.


3. 가상 주소에서 물리 메모리까지

섹션 제목: “3. 가상 주소에서 물리 메모리까지”

3-1. 주소 공간은 프로세스별 메모리 지도다

섹션 제목: “3-1. 주소 공간은 프로세스별 메모리 지도다”

가상 주소는 프로세스가 사용하는 주소다. 물리 주소는 RAM의 실제 위치다. 프로세스는 물리 주소를 직접 보지 않고, 자기 가상 주소 공간 안에서 code, heap, stack, shared library 같은 영역을 본다.

같은 가상 주소가 다른 물리 프레임으로 가는 흐름
flowchart TD
A["프로세스 A: VA 0x1000"] --> PTA["A의 페이지 테이블"]
B["프로세스 B: VA 0x1000"] --> PTB["B의 페이지 테이블"]
PTA --> PFA["물리 프레임 5"]
PTB --> PFB["물리 프레임 12"]
C["프로세스 A: VA 0x3000"] --> PTA
PTA --> Swap["present=0: swap 또는 아직 로드 안 됨"]
Swap --> Fault["page fault 후 처리"]

주소 변환은 보통 다음 순서로 생각하면 된다.

  1. CPU가 가상 주소 0x1000을 읽거나 쓰려 한다.
  2. MMU가 현재 프로세스의 페이지 테이블을 기준으로 주소를 변환한다.
  3. 페이지가 존재하고 권한이 맞으면 물리 프레임에 접근한다.
  4. 매핑이 없거나 권한이 틀리면 page fault 예외가 발생한다.
  5. 커널은 페이지를 올리거나, 접근 위반이면 프로세스에 SIGSEGV 같은 신호를 보낸다.

작은 관찰은 /proc에서 가능하다.

Terminal window
cat /proc/self/maps | head

예상 출력의 형태:

55a1b2c00000-55a1b2c01000 r-xp 00000000 fd:01 123456 /usr/bin/cat
7f8a3d400000-7f8a3d600000 r--p 00000000 fd:01 234567 /lib/x86_64-linux-gnu/libc.so.6
7ffc12345000-7ffc12366000 rw-p 00000000 00:00 0 [stack]

각 줄은 가상 주소 범위다. 여기서 보이는 주소는 “이 프로세스가 보는 지도”이지, RAM의 실제 위치가 아니다.

반례: “프로세스가 독립이면 메모리를 전혀 공유하지 않는다”는 말은 틀리다. 읽기 전용 code page나 shared library page는 여러 프로세스가 같은 물리 프레임을 공유할 수 있다. 중요한 것은 쓰기 가능한 영역이 권한과 페이지 테이블로 보호되고, 쓰기 시점에는 CoW 같은 규칙이 적용된다는 점이다.

3-2. 페이지, 프레임, 페이지 테이블

섹션 제목: “3-2. 페이지, 프레임, 페이지 테이블”

OS는 메모리를 보통 4KB 단위로 나눈다.

이름위치
Page가상 주소 공간프로세스가 보는 고정 크기 조각
Frame물리 메모리실제 RAM의 같은 크기 조각
VPN(Virtual Page Number)가상 주소 앞부분몇 번째 가상 페이지인지
PFN(Page Frame Number)물리 주소 앞부분몇 번째 물리 프레임인지
Offset주소 뒷부분페이지 안에서 몇 번째 바이트인지

주소 변환은 학습용으로 이렇게 쪼갤 수 있다.

가상 주소: [ VPN ][ offset ]
|
v
페이지 테이블 조회
|
v
물리 주소: [ PFN ][ offset ]

예를 들어 페이지 크기가 4KB라면 offset은 0~4095 범위다. 가상 주소 0x1234의 offset이 0x234라면, 페이지 테이블은 앞부분 VPN이 어느 PFN으로 가는지만 알려 주고, offset은 그대로 유지된다.

Terminal window
getconf PAGE_SIZE
# 4096

페이지 테이블 항목에는 PFN만 들어가지 않는다. 보통 present bit, read/write permission, user/kernel 접근 가능 여부, dirty bit, accessed bit 같은 정보도 함께 들어간다. 그래서 가상 메모리는 주소 변환 도구이면서 권한 검사 도구다.

3-3. 선택 심화 - TLB와 Huge Pages는 왜 나오는가

섹션 제목: “3-3. 선택 심화 - TLB와 Huge Pages는 왜 나오는가”

메모리 접근마다 페이지 테이블을 처음부터 찾으면 너무 느리다. 그래서 CPU는 최근 변환 결과를 TLB에 저장한다. TLB는 “이 VPN은 방금 PFN 12로 갔다” 같은 작은 캐시다.

문제는 TLB가 매우 작다는 점이다. 페이지 크기가 4KB이고 TLB가 1536개 entry를 가진다고 가정하면, 한 번에 직접 커버하는 메모리는 약 6MB다.

4KB * 1536 = 6MB
2MB * 1536 = 3GB

Huge Page는 페이지 크기를 2MB 또는 1GB처럼 크게 잡아 TLB 한 entry가 더 넓은 메모리를 커버하게 한다. 큰 배열, 머신러닝 텐서, in-memory DB처럼 working set이 크고 접근이 비교적 균일한 워크로드에서는 TLB miss를 줄일 수 있다.

하지만 큰 페이지는 항상 좋은 선택이 아니다.

상황더 나은 선택이유
큰 working set을 순차·균일하게 접근Huge Pages 또는 THP 검토TLB reach가 늘어 miss가 줄 수 있다
작은 객체가 많이 생기고 사라짐4KB page 유지2MB 단위 내부 낭비와 compaction 비용이 커질 수 있다
Redis BGSAVE, fork 기반 snapshotTHP 비활성화 쪽CoW 단위가 2MB로 커져 쓰기 한 번이 큰 복사를 만들 수 있다
latency p99가 중요한 DBTHP never 또는 madvise 검토khugepaged compaction이 순간 stall을 만들 수 있다

THP(Transparent Huge Pages)는 애플리케이션이 요청하지 않아도 커널이 자동으로 2MB 페이지를 만들려는 기능이다. 처음에는 “자동 최적화”처럼 보이지만, Redis나 일부 DB에서는 p99 latency spike와 CoW 비용 때문에 비활성화를 권장하는 경우가 많다. 반대로 대용량 순차 계산에서는 이득이 날 수 있으므로 “DB는 무조건 off, 모든 서버는 무조건 on”처럼 외우면 안 된다.

짧은 관찰:

Terminal window
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never
grep -i huge /proc/meminfo

첫 회독에서는 THP 설정을 바꾸기보다 “TLB miss를 줄이려 큰 페이지를 쓰지만, 큰 페이지를 만들고 쪼개는 비용이 있다”는 경계를 잡으면 된다.

3-4. Copy-on-Write는 fork 비용을 미루는 기술이다

섹션 제목: “3-4. Copy-on-Write는 fork 비용을 미루는 기술이다”

fork()는 부모와 거의 같은 자식 프로세스를 만든다. 부모 메모리가 10GB라면 자식 생성 때 10GB를 전부 복사해야 할까? 실제로 그렇게 하면 너무 느리다. Linux는 Copy-on-Write(CoW)를 사용한다.

1. 부모 프로세스 heap에 100MB 데이터가 있다.
2. fork()를 호출한다.
3. 부모와 자식은 처음에 같은 물리 페이지를 읽기 전용으로 공유한다.
4. 자식이나 부모가 어떤 페이지에 쓰기를 시도한다.
5. 커널이 그 페이지 하나만 복사하고, 쓰기 요청을 다시 수행한다.

그림으로 보면 이렇다.

[fork 직후]
부모 VPN 0 ───┐
├─> 물리 프레임 5 (read-only shared)
자식 VPN 0 ───┘
[자식이 쓰기 시도]
부모 VPN 0 ─────> 물리 프레임 5
자식 VPN 0 ─────> 물리 프레임 9 (복사본)

Redis BGSAVE가 좋은 worked example이다. Redis는 자식 프로세스를 fork해서 RDB snapshot을 디스크에 쓴다. fork 직후에는 대부분의 메모리를 공유하므로 추가 RSS가 작다. 하지만 부모 Redis가 계속 write traffic을 받으면 수정된 page마다 CoW가 발생해 RSS가 증가한다.

Terminal window
redis-cli INFO stats | grep current_cow_size
# current_cow_size:12582912

current_cow_size가 커진다는 것은 snapshot 중에 쓰기 때문에 복사된 page가 늘었다는 뜻이다. THP가 켜져 있으면 4KB가 아니라 2MB 단위로 복사가 일어날 수 있어, 같은 쓰기량에서도 RSS와 latency가 더 크게 흔들릴 수 있다.

판단 기준:

  • snapshot 시간대에 write traffic이 높고 current_cow_size가 계속 증가하면, snapshot 시간을 옮기거나 메모리 headroom을 늘린다.
  • Redis 서버에서 p99 spike와 AnonHugePages 증가가 같이 보이면 THP를 의심한다.
  • CoW는 fork를 빠르게 만들지만, fork 이후 쓰기가 많으면 비용을 뒤로 미룬 것뿐이다.

3-5. mmap은 파일을 주소 공간으로 끌어오는 방법이다

섹션 제목: “3-5. mmap은 파일을 주소 공간으로 끌어오는 방법이다”

mmap()은 파일이나 장치를 프로세스의 가상 주소 공간 일부처럼 매핑하는 시스템 콜이다. read()가 “파일에서 bytes를 읽어 유저 버퍼로 복사한다”에 가깝다면, mmap()은 “파일의 특정 범위를 가상 주소에 붙여 두고 메모리처럼 접근한다”에 가깝다.

read() 흐름:
파일 -> 커널 page cache -> 유저 버퍼
mmap() 흐름:
파일 -> page cache
-> 프로세스 가상 주소 공간에 매핑

학습용으로는 두 가지 차이를 기억하면 된다.

  1. mmap()도 실제 파일 전체를 즉시 RAM에 올리는 것이 아니다. 접근한 page가 필요할 때 page fault를 통해 올라온다.
  2. mmap()된 파일 page는 여러 프로세스가 공유할 수 있다. 같은 shared library가 여러 프로세스에 보이는 것도 이 모델과 연결된다.

대용량 파일 예시:

10GB 로그 파일에서 앞부분 4KB만 읽는다.
readFile:
런타임이 파일 전체를 읽으려 하면 10GB 버퍼와 긴 I/O가 필요하다.
stream:
작은 chunk 단위로 읽어 RSS 급증을 피한다.
mmap:
10GB 주소 범위를 매핑할 수 있지만, 실제 RAM에는 접근한 page만 올라온다.

Node.js에서는 직접 mmap()을 자주 쓰기보다 Buffer, native addon, database engine, OS page cache를 통해 간접적으로 만난다. 큰 파일 처리는 보통 readFile()보다 stream이 안전하다. 여기서의 핵심은 API 이름이 아니라 “가상 주소 공간을 크게 잡아도 실제 RSS는 접근한 page와 회수 정책에 따라 달라진다”는 점이다.

반례: mmap()은 항상 빠른 선택이 아니다. 무작위로 드문드문 접근하면 page fault가 흩어지고, 파일이 network filesystem 위에 있으면 fault가 원격 I/O 지연으로 바뀔 수 있다. 또한 매핑된 주소에 접근하는 순간 예외가 날 수 있으므로, 파일 truncate나 권한 문제 같은 실패 모드도 고려해야 한다.

3-6. 세그멘테이션은 논리 구분, 페이징은 관리 단위다

섹션 제목: “3-6. 세그멘테이션은 논리 구분, 페이징은 관리 단위다”

초기 OS 교재에는 세그멘테이션(segmentation)과 페이징(paging)이 함께 나온다. 둘은 메모리를 나누는 방식이 다르다.

구분세그멘테이션페이징
나누는 기준code, data, stack 같은 논리 영역4KB 같은 고정 크기
크기가변고정
장점영역의 의미와 권한을 표현하기 쉽다할당·회수·교체 단위가 단순하다
주요 문제외부 단편화내부 단편화와 page table 비용

현대 Linux/x86-64에서 일반 애플리케이션 메모리 관리는 주로 페이징 중심이다. 그래도 code, heap, stack 같은 말이 사라진 것은 아니다. /proc/self/maps에서 [heap], [stack], shared library 영역이 보이는 이유는 프로세스 가상 주소 공간 안에 논리 영역이 있고, 그 영역의 실제 page들이 page table로 관리되기 때문이다.

정리하면 이렇게 읽으면 된다.

논리 모델:
이 주소 범위는 code, 이 범위는 heap, 이 범위는 stack이다.
실제 관리:
각 범위는 4KB page들로 쪼개지고, page table이 물리 frame 또는 swap과 연결한다.

세그멘테이션을 깊게 외울 필요는 없다. 다만 “heap이 커진다”, “stack overflow가 난다”, “code page는 read-only로 공유된다” 같은 표현은 논리 영역의 언어이고, RSS·page fault·swap은 페이징의 언어라는 점을 구분하면 된다.


4-1. Page fault는 항상 장애가 아니다

섹션 제목: “4-1. Page fault는 항상 장애가 아니다”

Page fault는 CPU가 접근한 가상 페이지가 현재 바로 사용할 수 없는 상태일 때 발생하는 예외다. 이름 때문에 항상 에러처럼 보이지만, 정상 실행 과정에서도 자주 발생한다.

종류의미비용 감각예시
Minor page fault페이지가 RAM에는 있지만 현재 프로세스 page table에 아직 연결되지 않음빠름. 디스크 I/O 없음shared library 첫 접근, CoW page 매핑
Major page fault페이지를 디스크에서 읽어 와야 함느림. I/O와 context switch 동반swap에서 다시 올림, memory-mapped file 첫 접근
Protection fault권한이 없는 페이지에 접근보통 프로세스 오류read-only page에 write, null pointer dereference

작은 관찰:

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

minor fault가 많다는 사실만으로 장애라고 볼 수 없다. 반대로 major fault가 0이어도 p99 latency가 튈 수 있다. NUMA page migration, THP compaction, memory pressure stall은 page fault 카운터만으로 다 보이지 않는다. 그래서 “fault 숫자 하나”보다 RSS, swap in/out, pressure, latency를 같이 본다.

선택 심화 보충: NUMA는 CPU socket마다 가까운 메모리 영역이 달라지는 구조를 말한다. 어떤 task가 다른 socket의 메모리를 자주 읽으면 fault 수가 늘지 않아도 latency가 커질 수 있다. KSM(Kernel Samepage Merging)은 여러 프로세스의 같은 내용 페이지를 합쳐 메모리를 아끼는 기능이다. 첫 회독에서는 이 문단을 건너뛰고, 대형 다중 socket 장비에서 원격 메모리 지연이나 page migration 신호를 볼 때 돌아온다.

Linux에서 자주 보는 지표는 세 가지다.

Terminal window
cat /proc/$$/status | grep -E "Vm(Size|RSS|Swap)"

예상 출력:

VmSize: 16384 kB
VmRSS: 4096 kB
VmSwap: 512 kB
지표해석할 때 조심할 점
VmSize프로세스가 예약한 가상 주소 공간큰 값이어도 실제 RAM을 다 쓰는 것은 아니다
VmRSS실제 RAM에 올라와 있는 page 크기컨테이너 memory limit과 더 직접적으로 연결된다
VmSwapswap으로 밀려난 page 크기지속 증가하면 working set이 RAM을 넘었을 가능성이 있다

반례: VmSize가 20GB라고 해서 20GB RAM을 쓰는 것은 아니다. 큰 파일을 mmap()하거나 넓은 주소 공간을 예약하면 VmSize가 커질 수 있다. 컨테이너 OOM을 볼 때는 RSS, cgroup memory.current, Node rss, swap 지표를 같이 봐야 한다.

4-3. Swap은 완충재이지 성능 장치가 아니다

섹션 제목: “4-3. Swap은 완충재이지 성능 장치가 아니다”

Swap은 RAM이 부족할 때 덜 쓰는 page를 디스크에 내려 두는 공간이다. swap 덕분에 순간적인 메모리 압박을 버틸 수 있지만, 디스크는 RAM보다 훨씬 느리다.

정상과 위험 신호를 구분하면 다음과 같다.

관찰해석
VmSwap이 조금 있고 si/so가 거의 0과거에 밀려난 page가 있지만 지금은 조용할 수 있다
siso가 계속 높음RAM과 swap 사이를 계속 왕복한다
CPU 사용률은 낮은데 응답이 느림프로세스가 I/O wait 또는 page fault를 기다릴 수 있다
b blocked process가 높음디스크 I/O나 memory reclaim에 막힌 작업이 많다

관찰 명령은 짧게 충분하다.

Terminal window
vmstat 1 5

예상 출력에서 볼 부분:

procs -----------memory---------- ---swap-- -----io----
r b swpd free buff cache si so bi bo
0 3 13000 512 256 19000 620 890 3000 1500

si(swap in), so(swap out)가 지속적으로 높고 b가 함께 오르면 쓰레싱(thrashing)을 의심한다. 쓰레싱은 CPU가 실제 일을 하기보다 page를 쫓아내고 다시 가져오는 데 시간을 쓰는 상태다.

4-4. Working set이 RAM보다 크면 쓰레싱이 온다

섹션 제목: “4-4. Working set이 RAM보다 크면 쓰레싱이 온다”

쓰레싱의 원인은 “전체 프로세스의 VmSize가 크다”가 아니라 working set이 물리 메모리보다 큰 상황이다. working set은 일정 시간 동안 실제로 접근하는 page 집합이다.

작은 수치 예시:

프로세스 A working set: 1.2GB
프로세스 B working set: 1.0GB
프로세스 C working set: 900MB
커널과 page cache가 필요한 여유: 700MB
서버 RAM: 3GB
필요한 활성 메모리 = 3.8GB
부족분 = 800MB

이때 OS는 A의 page를 내렸다가 곧 A가 다시 접근하고, B의 page를 내렸다가 곧 B가 다시 접근하는 식으로 반복할 수 있다. 해결은 세 방향뿐이다.

방향예시언제 적합한가
Working set 줄이기캐시 크기 제한, 쿼리 결과 streaming, 불필요한 객체 참조 제거앱이 실제로 과도하게 잡고 있을 때
동시 실행 줄이기worker 수 축소, batch concurrency 제한각 작업은 정상인데 동시에 너무 많이 돌 때
메모리 늘리기instance size 상향, pod limit 상향working set이 비즈니스 요구상 줄기 어려울 때

Swap이나 swappiness 튜닝은 보조 조치다. working set이 물리 RAM을 계속 초과하면 튜닝만으로 본질을 해결할 수 없다.

4-5. Reclaim과 memory pressure는 “아직 죽지 않았지만 느린” 상태다

섹션 제목: “4-5. Reclaim과 memory pressure는 “아직 죽지 않았지만 느린” 상태다”

메모리 부족은 OOM으로만 나타나지 않는다. 커널은 OOM Killer를 실행하기 전에 page cache를 줄이고, 덜 쓰는 anonymous page를 swap으로 내리고, slab cache를 회수하는 식으로 최대한 버틴다. 이 과정을 reclaim이라고 부른다.

학습용으로는 메모리를 세 부류로 나누면 읽기 쉽다.

부류예시회수 난이도
File-backed page파일 내용, shared library, page cache원본 파일이 있으므로 비교적 회수 쉬움
Anonymous pageheap, stack, malloc된 객체원본 파일이 없어서 swap 없이는 회수 어려움
Kernel memoryslab, socket buffer, page table커널 내부 상태라 회수 조건이 더 까다로움

free -h에서 buff/cache가 크다고 바로 문제는 아니다. Linux는 남는 RAM을 page cache로 써서 파일 I/O를 빠르게 하고, 앱이 RAM을 필요로 하면 일부를 회수한다. 위험 신호는 cache가 크다는 사실이 아니라 회수가 따라가지 못해 latency가 튀거나 swap in/out이 지속되는 상황이다.

짧은 수치 예시:

서버 RAM 8GB
- 앱 RSS: 4.5GB
- page cache: 2.5GB
- free: 300MB
- available: 2.6GB
해석:
free는 작지만 available이 충분하면 즉시 위험하지 않을 수 있다.
커널이 page cache를 줄여 앱에 줄 여지가 있기 때문이다.

반대로 아래는 위험하다.

서버 RAM 8GB
- 앱 RSS: 7.2GB
- page cache: 300MB
- free: 100MB
- available: 250MB
- swap si/so 지속 증가
해석:
회수 가능한 cache가 거의 없고 anonymous page가 RAM을 채웠다.
working set 축소, 동시성 제한, 메모리 증설 중 하나가 필요하다.

Linux PSI(Pressure Stall Information)는 “메모리 때문에 태스크가 얼마나 기다렸는가”를 보여준다. 모든 환경에서 바로 쓰지는 않아도, 개념적으로는 OOM 이전의 느려짐을 보는 지표다.

Terminal window
cat /proc/pressure/memory
# some avg10=0.20 avg60=0.05 avg300=0.01 total=...
# full avg10=0.00 avg60=0.00 avg300=0.00 total=...

some은 일부 작업이 메모리 때문에 기다렸다는 뜻이고, full은 모든 runnable 작업이 막힌 시간을 뜻한다. p99 latency가 나빠지는데 OOM이 없고 major fault도 낮다면 pressure와 reclaim 계열 지표를 같이 본다.

4-6. 선택 심화 - zswap과 zram은 swap의 비용을 낮추는 완충 장치다

섹션 제목: “4-6. 선택 심화 - zswap과 zram은 swap의 비용을 낮추는 완충 장치다”

swap이 느린 이유는 RAM보다 디스크가 느리기 때문이다. zswap과 zram은 이 비용을 줄이기 위해 page를 압축한다.

기술원리장점경계
zswapswap으로 나가기 전 RAM 안의 압축 pool에 먼저 저장디스크 I/O를 줄인다RAM 일부를 압축 pool로 쓰므로 CPU와 메모리를 소비한다
zramRAM 일부를 압축 block device처럼 사용디스크 없는 장비나 작은 VM에서 유용실제 RAM 한계를 없애지는 못한다

작은 서버에서 100MB page가 40MB로 압축된다면 디스크로 내보낼 page가 줄어 응답 저하가 완화될 수 있다. 하지만 압축과 해제에도 CPU가 든다. CPU도 부족한 서버에서 zswap을 켜면 memory stall은 줄어도 CPU contention이 늘 수 있다.

판단 기준:

  • 작은 VM, 개발 장비, edge 장비처럼 RAM이 작고 디스크가 느리면 zswap/zram이 완충재가 될 수 있다.
  • DB나 latency-sensitive workload에서는 압축 비용과 tail latency를 측정해야 한다.
  • working set이 지속적으로 RAM을 초과하면 압축 swap은 시간을 벌 뿐이며, 앱 메모리 구조나 capacity를 바꿔야 한다.

5-1. Page replacement는 “무엇을 내보낼까”의 문제다

섹션 제목: “5-1. Page replacement는 “무엇을 내보낼까”의 문제다”

RAM은 한정되어 있으므로 새 page를 올릴 때 기존 page를 내보내야 할 수 있다. 이때 어떤 page를 희생할지 고르는 기준이 page replacement 정책이다.

정책기준장점경계
LRU(Least Recently Used)가장 오래 사용하지 않은 것을 내보냄최근성이 중요한 일반 워크로드에 직관적1회성 full scan에 오염될 수 있다
LFU(Least Frequently Used)가장 적게 사용한 것을 내보냄오래 인기 있는 데이터를 잘 보존새로 뜬 hot data에 불리할 수 있다
FIFO먼저 들어온 것을 먼저 내보냄구현이 단순프레임 수를 늘렸는데 fault가 늘어나는 Belady’s anomaly 가능
OPT앞으로 가장 늦게 쓸 것을 내보냄이론상 최적미래를 알아야 하므로 실제 구현 불가

LRU를 작은 예제로 보면 이렇다.

프레임 3개, 접근 순서: A B C D A B E
A -> [A]
B -> [A B]
C -> [A B C]
D -> [B C D] A가 가장 오래됨
A -> [C D A] B가 가장 오래됨
B -> [D A B] C가 가장 오래됨
E -> [A B E] D가 가장 오래됨

이 예제에서 LRU는 “최근에 안 쓴 것”을 버린다. 하지만 야간 batch가 전체 데이터를 한 번 순회하면, 실제로는 인기 없는 데이터가 “방금 접근한 데이터”로 둔갑할 수 있다. 이것이 cache pollution이다.

5-2. Redis maxmemory-policy는 같은 문제의 다른 얼굴이다

섹션 제목: “5-2. Redis maxmemory-policy는 같은 문제의 다른 얼굴이다”

Redis의 maxmemory-policy도 OS page replacement와 같은 질문을 던진다. “메모리가 꽉 찼을 때 어떤 key를 내보낼 것인가?”

Redis 정책의미좋은 상황위험한 상황
noeviction더 이상 쓰지 않고 에러 반환캐시가 아니라 원본성 데이터에 가까울 때쓰기 트래픽이 바로 에러로 번진다
allkeys-lru모든 key 중 오래 안 쓴 key 축출최근성이 중요한 일반 캐시full scan traffic에 오염될 수 있다
allkeys-lfu모든 key 중 적게 쓴 key 축출장기 인기 데이터가 뚜렷할 때신상품·뉴스처럼 새 hot key에 불리할 수 있다
volatile-lruTTL 있는 key 중 LRU 축출일부 key만 캐시로 취급할 때TTL 없는 key가 많으면 축출 여지가 부족하다

정책 선택 worked example:

상품 카탈로그 캐시
- 상위 1% 상품이 조회의 60%
- 야간 batch가 전체 상품을 1회씩 조회
- maxmemory는 working set보다 약간 작음
allkeys-lru:
batch가 지나간 상품이 "최근 사용"으로 표시됨
인기 상품 일부가 밀려남
아침 hit rate 92% -> 74%, DB QPS 400 -> 1800
allkeys-lfu:
batch는 1회 접근이라 빈도 낮음
인기 상품은 누적 빈도 높음
아침 hit rate 약 91% 유지

이 사례의 결론은 “항상 LFU를 쓰라”가 아니다. 소셜 피드, 채팅, 실시간 알림처럼 최신 데이터가 중요하면 LRU가 더 자연스럽다. 반대로 장기 인기 상품, 설정, 지역 코드처럼 빈도가 안정적인 데이터는 LFU가 cache pollution에 강하다.

정책을 바꾸기 전에는 evicted_keys, hit rate, DB fallback QPS를 본다. evicted_keys가 거의 없고 working set이 충분히 들어간다면 정책 차이는 장애 원인이 아닐 수 있다.


6. 캐시 지역성과 자료구조 선택

섹션 제목: “6. 캐시 지역성과 자료구조 선택”

6-1. CPU cache는 메모리를 줄 단위로 가져온다

섹션 제목: “6-1. CPU cache는 메모리를 줄 단위로 가져온다”

CPU는 RAM보다 훨씬 빠르게 연산한다. 그래서 최근에 접근한 데이터와 그 주변 데이터를 L1/L2/L3 cache에 올려 둔다. 보통 cache line은 64바이트 단위다.

두 가지 지역성을 구분한다.

이름예시
Temporal locality방금 쓴 데이터를 곧 다시 쓸 가능성loop counter, hot object, 자주 읽는 설정
Spatial locality방금 쓴 주소 근처를 곧 쓸 가능성배열 순회, 연속된 파일 block, B+Tree leaf range scan

배열 순회가 연결 리스트보다 빠른 이유는 이 지역성과 연결된다.

const arr = new Int32Array([1, 2, 3, 4, 5, 6, 7, 8]);
let sum = 0;
for (let i = 0; i < arr.length; i++) {
sum += arr[i];
}

Int32Array의 정수 하나는 4바이트다. cache line이 64바이트라면 한 번 메모리에서 가져올 때 정수 16개가 같이 들어올 수 있다. 다음 arr[i + 1] 접근은 이미 cache에 있을 가능성이 높다.

연결 리스트는 다르다.

노드 A: 0x1000 -> 노드 B: 0x7f20 -> 노드 C: 0x3100

각 노드가 힙 곳곳에 흩어져 있으면 다음 포인터를 따라갈 때마다 새로운 cache line을 기다릴 수 있다. 이 때문에 같은 Big-O O(n)이라도 배열 순회가 연결 리스트 순회보다 훨씬 빠를 수 있다.

반례: 배열이 항상 이기는 것은 아니다. 중간 삽입·삭제가 많고 순회보다 구조 변경이 핵심이면 연결 구조가 나을 수 있다. 메모리 관점의 판단은 “내 workload가 순차 접근인가, 임의 접근인가, 삽입·삭제가 많은가”를 같이 본다.

6-2. DB B+Tree도 페이지와 지역성을 사용한다

섹션 제목: “6-2. DB B+Tree도 페이지와 지역성을 사용한다”

B+Tree 인덱스는 leaf node를 page 단위로 저장하고, leaf끼리 순서대로 연결한다. 범위 검색은 이 구조 덕분에 빠르다.

[1|2|3|4] -> [5|6|7|8] -> [9|10|11|12]
page A page B page C

BETWEEN 1 AND 12 같은 범위 쿼리는 page A를 읽은 뒤 page B, C를 순차적으로 읽는다. OS와 DB는 이런 패턴에서 read-ahead를 적용하기 쉽다. 반대로 무작위 key를 계속 찍는 workload는 cache hit가 낮고 I/O가 흩어진다.

이 문서의 목표는 DB 인덱스 튜닝이 아니라 같은 사고를 보는 것이다. OS page, CPU cache line, DB page, CDN chunk는 모두 “작게 나누면 관리가 세밀하지만 lookup이 많고, 크게 묶으면 lookup은 줄지만 낭비와 쪼개기 비용이 생긴다”는 trade-off를 가진다.


7-1. OOM Killer는 시스템 전체를 살리기 위한 최후의 선택이다

섹션 제목: “7-1. OOM Killer는 시스템 전체를 살리기 위한 최후의 선택이다”

OOM(Out Of Memory)은 커널이 더 이상 필요한 메모리를 마련하지 못하는 상태다. Linux는 물리 메모리와 swap이 부족하면 OOM Killer를 실행해 프로세스 하나 이상을 종료한다.

커널은 각 프로세스에 oom_score를 계산한다. 범위는 대체로 0~1000이며 높을수록 죽을 가능성이 크다.

요소영향
RSS가 큼score 증가. 가장 직접적인 요인
oom_score_adj가 큼score 증가. 운영자가 죽기 쉽게 만든 것
oom_score_adj가 음수score 감소. 중요한 프로세스를 보호
root/system process일반적으로 약간 보호될 수 있음

관찰은 짧게 한다.

Terminal window
cat /proc/$(pgrep node | head -1)/oom_score
cat /proc/$(pgrep node | head -1)/oom_score_adj

반례: OOM Killer가 죽인 프로세스가 “가장 덜 중요한 프로세스”라고 보장되지는 않는다. 메모리를 많이 쓰는 PostgreSQL이나 Redis가 서비스의 핵심일 수 있다. 그래서 중요한 프로세스는 systemd OOMScoreAdjust, Kubernetes QoS, request/limit 설계로 보호하고, 애플리케이션별 메모리 상한을 별도로 둔다.

7-2. 컨테이너 OOM은 host OOM과 다르게 읽는다

섹션 제목: “7-2. 컨테이너 OOM은 host OOM과 다르게 읽는다”

Docker와 Kubernetes의 memory limit은 cgroups로 구현된다. cgroup은 프로세스 묶음에 CPU, memory, I/O 같은 자원 한도를 거는 Linux 기능이다. 컨테이너가 512MiB limit을 넘으면 host 전체 RAM이 남아 있어도 해당 cgroup 안에서 OOM이 발생할 수 있다.

Terminal window
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events

예상 형태:

memory.max 536870912
memory.current 501219328
memory.events
low 0
high 12
max 3
oom 1
oom_kill 1

판단 기준:

증상먼저 의심할 것
docker inspect나 Pod 상태에 OOMKilledcgroup limit 초과
host에는 free memory가 있는데 컨테이너만 죽음컨테이너 limit이 낮거나 앱 RSS가 높음
heapUsed는 낮은데 RSS가 limit에 가까움Buffer, native addon, code page, stack, allocator fragmentation
memory.eventsoom_kill 증가cgroup 내부 OOM Killer가 실제로 프로세스를 죽임

컨테이너에서 중요한 것은 Node.js heap limit을 cgroup limit보다 낮게 두는 것이다. 예를 들어 컨테이너 limit이 512MB라면 V8 old space를 400MB 전후로 제한해 RSS 전체가 limit에 닿기 전에 Node.js 내부 OOM이나 GC pressure로 신호가 나오게 한다.

다만 최신 Node.js/V8은 cgroup 제한을 어느 정도 인식해 heap 기본값을 조정할 수 있다. 그래서 무조건 --max-old-space-size를 넣는 것이 아니라, 먼저 현재 런타임이 컨테이너 limit을 어떻게 읽는지 확인하고 RSS 여유분을 남기는 방향으로 설정한다.

7-3. V8 Heap은 프로세스 RSS의 일부일 뿐이다

섹션 제목: “7-3. V8 Heap은 프로세스 RSS의 일부일 뿐이다”

Node.js 메모리는 OS 가상 주소 공간 위에 V8이 자체 heap을 만든 구조다.

[Node.js 프로세스 RSS]
├── V8 Heap
│ ├── Young Generation: 새 객체, minor GC
│ └── Old Generation: 오래 산 객체, major GC
├── External Memory: Buffer, ArrayBuffer, native addon
├── Code / shared libraries
├── Stack
└── allocator fragmentation / 기타 native 영역

allocator fragmentation은 할당기가 빈 조각을 충분히 재사용하지 못해 실제 유효 데이터보다 RSS가 커지는 현상이다. JavaScript 객체 수가 그대로인데 RSS만 오른다면 V8 heap 누수뿐 아니라 native allocator와 Buffer 계열도 같이 봐야 한다.

process.memoryUsage()는 각 층을 구분하는 출발점이다.

const mem = process.memoryUsage();
console.log({
rss: Math.round(mem.rss / 1024 / 1024) + "MB",
heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + "MB",
heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + "MB",
external: Math.round(mem.external / 1024 / 1024) + "MB",
arrayBuffers: Math.round(mem.arrayBuffers / 1024 / 1024) + "MB",
});
필드장애 해석
rssOS가 본 실제 RAM 상주 크기cgroup OOM과 가장 가깝다
heapTotalV8이 확보한 heap 총량V8이 확장해 둔 JavaScript heap 용량
heapUsedV8 heap 중 실제 사용 중인 양JS 객체 누수와 직접 연결
externalV8 밖 native memoryBuffer, C++ binding, addon 누수 가능
arrayBuffersArrayBuffer/Buffer 계열업로드, 파일 처리, binary payload와 연결

중요한 반례: heapUsed가 안정적이어도 RSS가 계속 오르면 JavaScript 객체 누수가 아닐 수 있다. Buffer.alloc(), image 처리 native library, DB driver native buffer, allocator fragmentation이 RSS를 키울 수 있다. 반대로 heapUsed가 GC 후에도 계단식으로 오르면 JS object retention을 의심한다.

7-4. Node.js 누수는 GC 후 기준선으로 본다

섹션 제목: “7-4. Node.js 누수는 GC 후 기준선으로 본다”

메모리 누수는 “순간적으로 높다”가 아니라 “회수할 기회가 있었는데도 기준선이 계속 올라간다”에 가깝다.

짧은 관찰 스니펫:

setInterval(() => {
const { rss, heapUsed, external } = process.memoryUsage();
console.log({
rss: Math.round(rss / 1024 / 1024),
heapUsed: Math.round(heapUsed / 1024 / 1024),
external: Math.round(external / 1024 / 1024),
});
}, 5000);

누수 패턴 예:

정상:
10:00 heapUsed 120MB
10:05 heapUsed 145MB
10:10 heapUsed 122MB GC 후 감소
누수 의심:
10:00 heapUsed 120MB
10:05 heapUsed 180MB
10:10 heapUsed 240MB
10:15 heapUsed 310MB GC 후에도 기준선 상승

NestJS에서 흔한 원인은 무제한 Map 캐시다.

// 위험: 최대 크기와 TTL이 없어 요청이 많을수록 계속 쌓인다.
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;
}
}

학습 포인트는 “Map을 쓰지 말라”가 아니다. 인메모리 캐시는 상한, TTL, eviction policy, 다중 인스턴스 일관성이 없으면 메모리 관리 주체가 애플리케이션 코드가 된다는 점이다. 작은 단일 인스턴스 cache는 maxttl이 있는 LRU cache로 제한하고, 여러 서버가 같은 cache를 봐야 하면 Redis 같은 외부 cache를 검토한다.

7-5. Buffer는 heapUsed보다 RSS를 먼저 키울 수 있다

섹션 제목: “7-5. Buffer는 heapUsed보다 RSS를 먼저 키울 수 있다”

Node.js에서 파일 업로드, 이미지 처리, 압축, 암호화, 네트워크 proxy 코드를 다루면 Buffer를 많이 만난다. Buffer는 JavaScript 객체처럼 보이지만 데이터 본체는 V8 heap 밖의 external memory에 잡히는 경우가 많다.

작은 예시:

const buffers = [];
setInterval(() => {
buffers.push(Buffer.alloc(10 * 1024 * 1024));
const mem = process.memoryUsage();
console.log({
rss: Math.round(mem.rss / 1024 / 1024) + "MB",
heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + "MB",
external: Math.round(mem.external / 1024 / 1024) + "MB",
});
}, 1000);

예상되는 패턴:

rss 80MB -> 180MB -> 280MB
heapUsed 5MB -> 6MB -> 6MB
external 40MB -> 140MB -> 240MB

이 경우 heapUsed만 보면 누수가 없어 보인다. 하지만 컨테이너는 RSS 기준으로 limit에 닿을 수 있다. 파일 업로드를 한 번에 Buffer로 모으거나, 응답 body를 모두 메모리에 쌓거나, 이미지 변환 결과를 배열에 보관하면 이런 패턴이 나온다.

선택 기준:

상황더 안전한 방식
큰 파일 업로드를 받아 S3로 전달stream으로 backpressure를 유지
작은 JSON payload메모리에 올려도 보통 괜찮음
이미지/압축 변환동시성 제한과 임시 파일/stream 검토
외부 API 응답이 수백 MB 가능최대 크기 제한과 streaming parser 검토

핵심은 --max-old-space-size가 V8 old space를 제한할 뿐, RSS 전체를 직접 제한하지 않는다는 점이다. 컨테이너 limit을 설계할 때는 V8 heap, external memory, code, stack, native library, page table, allocator fragmentation의 여유를 모두 남겨야 한다.

7-6. GC pause와 OOM은 같은 문제가 아니다

섹션 제목: “7-6. GC pause와 OOM은 같은 문제가 아니다”

메모리 장애를 볼 때 “느림”과 “죽음”을 구분해야 한다. V8 heap이 꽉 차면 GC가 자주 돌고, major GC는 stop-the-world pause를 만들 수 있다. 반면 cgroup limit을 넘으면 커널이 프로세스를 SIGKILL로 종료할 수 있다.

GC pressure:
heapUsed가 heapTotal에 가까워짐
major GC 빈도 증가
p99 latency가 튐
프로세스는 살아 있음
cgroup OOM:
RSS가 memory.max에 가까워짐
커널이 프로세스를 kill
ExitCode 137 또는 OOMKilled
프로세스가 재시작됨

판단을 잘못하면 대응도 틀린다.

  • GC pause가 문제인데 컨테이너 limit만 올리면, 프로세스는 더 오래 살아도 p99는 계속 나쁠 수 있다.
  • RSS OOM이 문제인데 heap snapshot만 보면, Buffer/native memory 원인을 놓칠 수 있다.
  • --max-old-space-size를 너무 낮게 잡으면 OOMKilled는 줄어도 V8 heap OOM이나 GC pressure가 빨리 온다.
  • 너무 높게 잡으면 V8이 컨테이너 limit 근처까지 커져 native memory 여유가 사라진다.

실무 감각으로는 컨테이너 limit에서 V8 old space를 뺀 나머지가 OS/V8 metadata, Buffer, stack, code, native library, page table, allocator fragmentation의 완충 공간이다. 512MB 컨테이너에 old space를 500MB로 주는 설정은 숫자상 거의 여유가 없다.


운영 절차를 외우기보다 증상에서 어떤 개념을 의심할지 연결한다.

증상의미먼저 볼 지표
컨테이너가 ExitCode 137, OOMKilledcgroup memory limit 초과로 killmemory.current, memory.events, docker stats, Node rss
heapUsed가 계속 증가JS 객체가 GC로 회수되지 않음heap snapshot, GC 후 기준선, listener/cache 증가
heapUsed는 낮은데 rss만 증가native/external memory 또는 fragmentationexternal, arrayBuffers, Buffer 사용량, native addon
CPU가 낮은데 서버가 느림swap thrashing 또는 I/O waitvmstat si/so, b, disk I/O
p99만 간헐적으로 수백 ms 증가GC stop-the-world, THP compaction, memory pressureGC log, /proc/vmstat의 compact/thp, latency timestamp
Redis OOM command not allowedmaxmemory 도달 + noeviction 정책INFO memory, maxmemory-policy, evicted_keys
Redis BGSAVE 중 RSS 급증CoW page 복사 증가current_cow_size, write traffic, THP 상태

8-1. Docker OOMKilled를 읽는 최소 흐름

섹션 제목: “8-1. Docker OOMKilled를 읽는 최소 흐름”
  1. OOMKilled가 true인지 확인한다.
  2. 컨테이너 limit과 memory.current가 얼마나 가까웠는지 본다.
  3. Node rssheapUsed를 비교한다.
  4. heapUsed가 높으면 JS heap 누수·상한을 본다.
  5. rss만 높으면 Buffer/native/external memory를 본다.
  6. limit이 workload보다 낮으면 limit 상향 또는 worker/concurrency 축소를 검토한다.

명령어는 환경마다 다르지만 학습용 핵심은 이 분기다.

Terminal window
docker inspect <container_id> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
docker stats <container_id>

Redis가 아래 에러를 내면 Redis 내부 maxmemory 정책 문제다.

(error) OOM command not allowed when used memory > 'maxmemory'.

확인할 질문:

  • 이 Redis는 cache인가, 원본성 저장소인가?
  • maxmemory-policynoeviction인가?
  • TTL 없는 key가 많아 volatile-* 정책이 축출할 key를 못 찾는가?
  • evicted_keys가 증가하는데 hit rate가 떨어지는가?
  • 실제 working set이 maxmemory보다 큰가?

cache라면 보통 allkeys-lru 또는 workload에 따라 allkeys-lfu를 검토한다. 원본성 데이터라면 단순 축출은 데이터 손실이므로 메모리 증설, 데이터 모델 조정, key 수명 정책을 먼저 본다.

8-3. 선택 심화 - THP latency spike를 읽는 최소 흐름

섹션 제목: “8-3. 선택 심화 - THP latency spike를 읽는 최소 흐름”

THP 문제는 “평균은 괜찮은데 p99가 가끔 튄다”는 모습으로 나타날 수 있다.

Terminal window
cat /sys/kernel/mm/transparent_hugepage/enabled
grep -E "compact|thp" /proc/vmstat | head
grep -i huge /proc/meminfo

판단 기준:

  • Redis, MongoDB, latency-sensitive DB에서 THP가 always이고 p99 spike가 있으면 never 또는 madvise를 실험한다.
  • 실험은 production에서 바로 바꾸기보다 staging 또는 canary에서 p99, CPU, TLB miss, compaction counter를 같이 본다.
  • 큰 순차 계산 workload라면 THP가 오히려 이득일 수 있으므로 DB 권고를 모든 워크로드에 일반화하지 않는다.

8-4. 메모리 지표를 한 장으로 읽는 순서

섹션 제목: “8-4. 메모리 지표를 한 장으로 읽는 순서”

장애 중에는 지표가 많아서 헷갈린다. 아래 순서는 “어느 층의 문제인가”를 빠르게 좁히기 위한 학습용 판단 흐름이다.

1. 프로세스가 죽었는가?
- 예: OOMKilled, ExitCode 137, kernel log의 killed process
- 아니오: latency, GC, swap, reclaim으로 이동
2. 죽었다면 누가 죽였는가?
- cgroup memory.events oom_kill 증가: 컨테이너 limit
- host dmesg OOM: host 전체 memory pressure
- Node FATAL ERROR: V8 heap limit
3. 살아 있지만 느리다면 어디서 기다리는가?
- GC log: major GC pause
- vmstat si/so: swap 왕복
- pressure memory full/some: reclaim stall
- /proc/vmstat compact/thp: THP compaction
4. 어떤 메모리가 늘었는가?
- heapUsed: JS object
- external/arrayBuffers: Buffer/native
- RSS만 증가: native, fragmentation, shared mapping, page cache 영향
- VmSize만 큼: 주소 예약 또는 mmap일 수 있음

이 흐름은 완전한 런북이 아니라 사고 순서다. 핵심은 “메모리 문제”를 한 단어로 묶지 않고, 주소 공간, RSS, heap, external, cgroup, swap, pressure를 분리해서 보는 것이다.


9. 메모리 기술을 읽는 네 가지 질문

섹션 제목: “9. 메모리 기술을 읽는 네 가지 질문”

새 메모리 기술을 만났을 때 아래 네 질문을 적용하면 세부 구현을 몰라도 구조가 보인다.

질문OS 예시Node.js 예시Redis 예시
할당 단위는?page/frame 4KB, huge page 2MBV8 object, Buffer, old spacekey/value object, allocator chunk
회수 정책은?page replacement, reclaim, swapGC minor/major, object reachabilitymaxmemory-policy, TTL
격리 경계는?process address spaceprocess와 V8 heap limitRedis process, database/key namespace
실패 모드는?OOM kill, thrashing, SIGSEGVfatal heap OOM, long GC pauseOOM command error, eviction storm

이 질문은 cgroups, JVM GC, WASM linear memory, eBPF map에도 그대로 적용된다. 중요한 것은 용어를 많이 외우는 것이 아니라 “누가 메모리를 나누고, 누가 회수하며, 어디까지 격리하고, 실패하면 어떤 신호가 나오는가”를 찾는 것이다.


구분가상 메모리스왑
수준프로세스에게 보이는 주소 공간 추상화RAM 부족 시 page를 내려놓는 디스크 공간
관계상위 개념가상 메모리를 구현하는 수단 중 하나
오해”가상이라 실제 메모리가 아니다""swap이 있으면 RAM 부족이 해결된다”
정확한 이해주소 변환과 보호까지 포함한다버틸 수는 있지만 느리고 thrashing을 만들 수 있다
구분RSSV8 heapUsed
관찰 주체OS/cgroupV8 엔진
포함V8 heap, native memory, stack, code, shared page 일부JavaScript object heap 사용량
장애 연결cgroup OOM, host memory pressureJS object leak, GC pressure
반례shared page 때문에 단순 합산하면 과대평가 가능낮아도 RSS OOM이 날 수 있음
구분OOMKilledNode.js heap OOM
죽이는 주체Linux kernel 또는 cgroup OOM killerV8/Node.js 프로세스 내부
대표 신호ExitCode 137, OOMKilled: trueFATAL ERROR: Reached heap limit
주된 원인RSS가 limit 초과V8 old space 상한 초과
대응cgroup limit, RSS 구성, native memory 확인heap snapshot, object retention, --max-old-space-size
상황더 맞는 정책이유
최신 피드, 채팅, 최근 검색LRU최근 접근이 미래 접근을 잘 예측한다
상품 카탈로그, 지역 코드, 인기 설정LFU장기 인기 데이터의 빈도가 더 강한 신호다
full scan batch가 자주 섞임LFU 검토LRU cache pollution을 줄일 수 있다
새 hot item이 자주 생김LRU 또는 LFU decay 조정LFU는 신규 item을 늦게 보호할 수 있다
방식메모리 특성좋은 상황피해야 할 상황
readFile파일 내용을 한 번에 버퍼로 올릴 수 있음작은 설정 파일, 작은 JSON크기 제한 없는 업로드·로그·CSV
streamchunk 단위로 처리하고 backpressure 적용 가능대용량 파일 전달, 압축, 업로드 proxy전체 내용을 랜덤 접근해야 할 때
mmap주소 공간에 매핑하고 접근한 page만 fault로 올림DB engine, shared library, 대용량 파일 일부 접근sparse random access, truncation 위험, 직접 제어가 어려운 런타임
지표오해 방지
free즉시 비어 있는 RAM작아도 Linux에서는 정상일 수 있다
available앱에 내줄 수 있다고 추정되는 RAMfree보다 운영 판단에 더 유용하다
buff/cachepage cache와 buffer회수 가능하면 문제 아님, 회수가 지연되면 pressure
RSS특정 프로세스가 실제 RAM에 올린 pagecgroup OOM과 직접 연결되지만 shared page 중복 계산에 주의

11. 선택 부록 — 짧은 관찰 스니펫

섹션 제목: “11. 선택 부록 — 짧은 관찰 스니펫”

본문 이해에 필수는 아니지만, 직접 확인하고 싶을 때만 사용한다.

Linux 프로세스 메모리 표면 보기
Terminal window
cat /proc/self/maps | head
cat /proc/$$/status | grep -E "Vm(Size|RSS|Swap)"
getconf PAGE_SIZE
free -h
vmstat 1 5

관찰 포인트:

  • maps는 가상 주소 범위다.
  • VmRSS는 실제 RAM에 올라온 page 크기다.
  • si/so가 지속적으로 높으면 swap 왕복을 의심한다.
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`,
arrayBuffers: `${(mem.arrayBuffers / 1024 / 1024).toFixed(1)}MB`,
});
}, 5000);

관찰 포인트:

  • heapUsed가 GC 후에도 계속 오르면 JS object retention을 의심한다.
  • rss만 오르면 Buffer/native/external memory를 본다.
컨테이너 memory limit과 OOM 이벤트 보기
Terminal window
cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.events

Docker 환경에서는 다음도 볼 수 있다.

Terminal window
docker inspect <container_id> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
docker stats <container_id>

관찰 포인트:

  • host RAM이 남아 있어도 cgroup memory.max를 넘으면 컨테이너는 죽을 수 있다.
  • ExitCode 137은 SIGKILL 종료와 연결되며, OOMKilled 여부를 함께 확인해야 한다.
Redis CoW와 eviction 표면 보기
Terminal window
redis-cli INFO stats | grep current_cow_size
redis-cli INFO stats | grep evicted_keys
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
redis-cli CONFIG GET maxmemory-policy

관찰 포인트:

  • current_cow_size는 fork snapshot 중 쓰기 때문에 복사된 page 규모를 보여준다.
  • evicted_keys가 늘고 hit rate가 떨어지면 policy와 working set을 같이 본다.

Memory Management 첫 회독 종료 체크

  • 가상 주소와 물리 주소의 차이를 설명할 수 있다.
  • page, frame, page table, MMU, TLB가 주소 변환에서 맡는 역할을 말할 수 있다.
  • 두 프로세스가 같은 가상 주소를 써도 서로 격리되는 이유를 설명할 수 있다.
  • Copy-on-Write가 fork 비용을 어떻게 미루는지 Redis BGSAVE 예시로 설명할 수 있다.
  • minor page fault와 major page fault의 차이를 디스크 I/O 여부로 구분할 수 있다.
  • VmSize, RSS, VmSwap의 차이를 설명하고 컨테이너 OOM에서 무엇을 먼저 볼지 고를 수 있다.
  • swap이 완충재이지만 성능 장치가 아닌 이유를 말할 수 있다.
  • LRU와 LFU의 차이를 Redis maxmemory-policy 선택 기준으로 연결할 수 있다.
  • 배열 순회가 연결 리스트 순회보다 cache locality에 유리한 이유를 설명할 수 있다.
  • heapUsed가 낮아도 RSS 때문에 OOMKilled가 날 수 있는 이유를 설명할 수 있다.

Memory Management 선택 심화 체크

  • THP/Huge Pages가 TLB miss를 줄이지만 CoW와 p99 latency를 악화시킬 수 있음을 말할 수 있다.
  • NUMA 원격 메모리 접근이 page fault 증가 없이도 latency를 키울 수 있음을 설명할 수 있다.
  • zswap/zram이 RAM 한계를 없애는 장치가 아니라 CPU와 압축 메모리를 쓰는 완충재임을 설명할 수 있다.
  • 11절 스니펫에서 값을 바꾸기 전에 관찰할 지표와 예상 trade-off를 말할 수 있다.

키워드한 줄 설명
Virtual Memory프로세스마다 독립적인 가상 주소 공간을 제공하는 메커니즘
Physical Memory실제 RAM에 존재하는 메모리
Page가상 주소 공간의 고정 크기 조각
Frame물리 메모리의 고정 크기 조각
Page TableVPN을 PFN으로 바꾸는 프로세스별 매핑 표
MMU주소 변환과 권한 검사를 수행하는 CPU 하드웨어
TLB주소 변환 결과를 저장하는 작은 CPU 캐시
Page Fault페이지가 없거나 권한이 맞지 않을 때 발생하는 예외
Minor Page Fault디스크 I/O 없이 처리되는 page fault
Major Page Fault디스크에서 page를 읽어야 하는 page fault
RSS실제 RAM에 올라온 프로세스 메모리 크기
VmSize예약된 가상 주소 공간 크기
SwapRAM에서 밀려난 page를 두는 디스크 공간
Thrashingpage 교체가 과도해 실제 작업보다 swap 왕복에 시간을 쓰는 상태
LRU가장 오래 사용하지 않은 항목을 내보내는 정책
LFU가장 적게 사용한 항목을 내보내는 정책
Cache LineCPU cache가 메모리를 가져오는 기본 단위. 보통 64바이트
OOM Killer메모리 부족 시 프로세스를 강제 종료하는 Linux 커널 메커니즘
cgroups프로세스 그룹별 자원 사용량을 제한·관찰하는 Linux 기능
V8 HeapNode.js JavaScript 객체가 주로 할당되는 V8 엔진의 heap
Copy-on-Write공유 page를 쓰기 시점에만 복사하는 최적화
THP커널이 자동으로 2MB huge page를 만들려는 기능
Huge PagesTLB miss를 줄이기 위해 쓰는 큰 page

가상 메모리는 프로세스별 주소 공간으로 격리를 만들고, paging과 page replacement로 한정된 RAM을 관리하며, 그 실패 신호는 RSS 증가, swap thrashing, OOMKilled, GC pause, Redis eviction처럼 운영 표면에 직접 드러난다.