격리 부재
프로그램이 물리 주소를 직접 다루면 포인터 버그 하나가 다른 프로세스나 커널 데이터를 덮어쓸 수 있다.
프로세스별 페이지 테이블과 MMU 권한 검사로 잘못된 접근을 해당 프로세스 안에서 막는다.메모리 관리는 운영체제가 프로세스마다 독립적인 가상 주소 공간을 주고, 그 주소를 실제 물리 메모리와 필요하면 디스크 스왑에 연결해 주는 메커니즘이다.
첫 회독에서는 아래 순서만 따라간다.
mmap을 읽는다. 3.3절 THP는 건너뛴다.종료 조건은 다음과 같다.
선택 심화 절: 3.3절과 8.3절(THP/Huge Pages), 4.1절의 NUMA/KSM 보충, 4.6절(zswap/zram), 11절(관찰 스니펫), 12절의 선택 심화 체크다. 이 절들은 설정 변경 목록이 아니라 TLB reach, 원격 메모리, 압축 swap, 관찰 비용의 trade-off를 확인하는 자료다.
이 문서에서 다루는 핵심 질문은 세 가지다.
0x1000을 써도 서로의 데이터를 덮어쓰지 않는가?heapUsed는 낮은데 컨테이너가 OOMKilled로 죽을 수 있는가?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 Heap | Node.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가 메모리에 접근할 때 가상 주소를 물리 주소로 바꾸고, 그 결과를 잠깐 캐시한다” 정도면 이 문서를 읽을 수 있다.
가상 메모리는 RAM을 더 크게 보이게 하는 트릭만이 아니다. 더 근본적으로는 여러 프로그램을 동시에 실행할 때 생긴 세 가지 실패를 줄이기 위해 등장했다.
프로그램이 물리 주소를 직접 다루면 포인터 버그 하나가 다른 프로세스나 커널 데이터를 덮어쓸 수 있다.
프로세스별 페이지 테이블과 MMU 권한 검사로 잘못된 접근을 해당 프로세스 안에서 막는다.필요한 모든 코드와 데이터를 RAM에 한 번에 올려야 하면, RAM보다 큰 프로그램이나 working set을 다루기 어렵다.
필요한 페이지를 접근 시점에 올리는 demand paging과 swap으로 물리 RAM 한계를 완화한다.프로그램을 빈 물리 메모리 위치에 올릴 때마다 주소를 다시 맞추면 실행과 공유가 복잡해진다.
프로세스는 같은 가상 주소를 쓰고, OS가 각 프로세스의 물리 매핑만 다르게 유지한다.현대 OS의 해법은 “프로세스에게는 자기만의 연속된 메모리처럼 보이게 하고, 실제 배치는 OS가 숨긴다”는 것이다. 이 덕분에 한 Node.js worker가 잘못된 주소를 건드리면 보통 그 프로세스만 SIGSEGV로 죽고, 다른 worker의 heap은 보호된다. 컨테이너의 메모리 제한도 이 격리 위에 cgroups라는 두 번째 제한을 얹은 것이다.
가상 메모리는 공짜가 아니다. 주소 변환에는 TLB와 페이지 테이블 조회 비용이 있고, 필요한 페이지가 디스크에 있으면 major page fault가 발생한다. 대략적인 감각만 잡으면 충분하다.
| 사건 | 대략적 비용 감각 | 해석 |
|---|---|---|
| TLB hit | CPU 캐시 수준 | 최근 변환 결과가 있어 빠르다 |
| TLB miss | 수십~수백 cycle 가능 | 페이지 테이블을 더 찾아야 한다 |
| minor page fault | 디스크 I/O 없음 | 매핑만 보강하거나 이미 RAM에 있는 페이지를 연결한다 |
| major page fault | SSD에서도 us~ms 단위 가능 | 디스크에서 페이지를 읽고 원래 명령을 재시도한다 |
| swap thrashing | 요청 지연이 초 단위로 튈 수 있음 | 실제 작업보다 페이지 교체에 시간을 쓴다 |
출처로 더 깊게 보려면 OSTEP Virtual Memory, Multics Virtual Memory, LWN Huge Pages and TLB costs가 좋은 출발점이다.
가상 주소는 프로세스가 사용하는 주소다. 물리 주소는 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 후 처리"]
주소 변환은 보통 다음 순서로 생각하면 된다.
0x1000을 읽거나 쓰려 한다.SIGSEGV 같은 신호를 보낸다.작은 관찰은 /proc에서 가능하다.
cat /proc/self/maps | head예상 출력의 형태:
55a1b2c00000-55a1b2c01000 r-xp 00000000 fd:01 123456 /usr/bin/cat7f8a3d400000-7f8a3d600000 r--p 00000000 fd:01 234567 /lib/x86_64-linux-gnu/libc.so.67ffc12345000-7ffc12366000 rw-p 00000000 00:00 0 [stack]각 줄은 가상 주소 범위다. 여기서 보이는 주소는 “이 프로세스가 보는 지도”이지, RAM의 실제 위치가 아니다.
반례: “프로세스가 독립이면 메모리를 전혀 공유하지 않는다”는 말은 틀리다. 읽기 전용 code page나 shared library page는 여러 프로세스가 같은 물리 프레임을 공유할 수 있다. 중요한 것은 쓰기 가능한 영역이 권한과 페이지 테이블로 보호되고, 쓰기 시점에는 CoW 같은 규칙이 적용된다는 점이다.
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은 그대로 유지된다.
getconf PAGE_SIZE# 4096페이지 테이블 항목에는 PFN만 들어가지 않는다. 보통 present bit, read/write permission, user/kernel 접근 가능 여부, dirty bit, accessed bit 같은 정보도 함께 들어간다. 그래서 가상 메모리는 주소 변환 도구이면서 권한 검사 도구다.
메모리 접근마다 페이지 테이블을 처음부터 찾으면 너무 느리다. 그래서 CPU는 최근 변환 결과를 TLB에 저장한다. TLB는 “이 VPN은 방금 PFN 12로 갔다” 같은 작은 캐시다.
문제는 TLB가 매우 작다는 점이다. 페이지 크기가 4KB이고 TLB가 1536개 entry를 가진다고 가정하면, 한 번에 직접 커버하는 메모리는 약 6MB다.
4KB * 1536 = 6MB2MB * 1536 = 3GBHuge 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 기반 snapshot | THP 비활성화 쪽 | CoW 단위가 2MB로 커져 쓰기 한 번이 큰 복사를 만들 수 있다 |
| latency p99가 중요한 DB | THP never 또는 madvise 검토 | khugepaged compaction이 순간 stall을 만들 수 있다 |
THP(Transparent Huge Pages)는 애플리케이션이 요청하지 않아도 커널이 자동으로 2MB 페이지를 만들려는 기능이다. 처음에는 “자동 최적화”처럼 보이지만, Redis나 일부 DB에서는 p99 latency spike와 CoW 비용 때문에 비활성화를 권장하는 경우가 많다. 반대로 대용량 순차 계산에서는 이득이 날 수 있으므로 “DB는 무조건 off, 모든 서버는 무조건 on”처럼 외우면 안 된다.
짧은 관찰:
cat /sys/kernel/mm/transparent_hugepage/enabled# always [madvise] never
grep -i huge /proc/meminfo첫 회독에서는 THP 설정을 바꾸기보다 “TLB miss를 줄이려 큰 페이지를 쓰지만, 큰 페이지를 만들고 쪼개는 비용이 있다”는 경계를 잡으면 된다.
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가 증가한다.
redis-cli INFO stats | grep current_cow_size# current_cow_size:12582912current_cow_size가 커진다는 것은 snapshot 중에 쓰기 때문에 복사된 page가 늘었다는 뜻이다. THP가 켜져 있으면 4KB가 아니라 2MB 단위로 복사가 일어날 수 있어, 같은 쓰기량에서도 RSS와 latency가 더 크게 흔들릴 수 있다.
판단 기준:
current_cow_size가 계속 증가하면, snapshot 시간을 옮기거나 메모리 headroom을 늘린다.AnonHugePages 증가가 같이 보이면 THP를 의심한다.mmap()은 파일이나 장치를 프로세스의 가상 주소 공간 일부처럼 매핑하는 시스템 콜이다. read()가 “파일에서 bytes를 읽어 유저 버퍼로 복사한다”에 가깝다면, mmap()은 “파일의 특정 범위를 가상 주소에 붙여 두고 메모리처럼 접근한다”에 가깝다.
read() 흐름: 파일 -> 커널 page cache -> 유저 버퍼
mmap() 흐름: 파일 -> page cache -> 프로세스 가상 주소 공간에 매핑학습용으로는 두 가지 차이를 기억하면 된다.
mmap()도 실제 파일 전체를 즉시 RAM에 올리는 것이 아니다. 접근한 page가 필요할 때 page fault를 통해 올라온다.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나 권한 문제 같은 실패 모드도 고려해야 한다.
초기 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은 페이징의 언어라는 점을 구분하면 된다.
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 |
작은 관찰:
/usr/bin/time -v ls /tmp 2>&1 | grep -E "Major|Minor"예상 출력:
Major (requiring I/O) page faults: 0Minor (reclaiming a frame) page faults: 312minor 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에서 자주 보는 지표는 세 가지다.
cat /proc/$$/status | grep -E "Vm(Size|RSS|Swap)"예상 출력:
VmSize: 16384 kBVmRSS: 4096 kBVmSwap: 512 kB| 지표 | 뜻 | 해석할 때 조심할 점 |
|---|---|---|
| VmSize | 프로세스가 예약한 가상 주소 공간 | 큰 값이어도 실제 RAM을 다 쓰는 것은 아니다 |
| VmRSS | 실제 RAM에 올라와 있는 page 크기 | 컨테이너 memory limit과 더 직접적으로 연결된다 |
| VmSwap | swap으로 밀려난 page 크기 | 지속 증가하면 working set이 RAM을 넘었을 가능성이 있다 |
반례: VmSize가 20GB라고 해서 20GB RAM을 쓰는 것은 아니다. 큰 파일을 mmap()하거나 넓은 주소 공간을 예약하면 VmSize가 커질 수 있다. 컨테이너 OOM을 볼 때는 RSS, cgroup memory.current, Node rss, swap 지표를 같이 봐야 한다.
Swap은 RAM이 부족할 때 덜 쓰는 page를 디스크에 내려 두는 공간이다. swap 덕분에 순간적인 메모리 압박을 버틸 수 있지만, 디스크는 RAM보다 훨씬 느리다.
정상과 위험 신호를 구분하면 다음과 같다.
| 관찰 | 해석 |
|---|---|
VmSwap이 조금 있고 si/so가 거의 0 | 과거에 밀려난 page가 있지만 지금은 조용할 수 있다 |
si와 so가 계속 높음 | RAM과 swap 사이를 계속 왕복한다 |
| CPU 사용률은 낮은데 응답이 느림 | 프로세스가 I/O wait 또는 page fault를 기다릴 수 있다 |
b blocked process가 높음 | 디스크 I/O나 memory reclaim에 막힌 작업이 많다 |
관찰 명령은 짧게 충분하다.
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 1500si(swap in), so(swap out)가 지속적으로 높고 b가 함께 오르면 쓰레싱(thrashing)을 의심한다. 쓰레싱은 CPU가 실제 일을 하기보다 page를 쫓아내고 다시 가져오는 데 시간을 쓰는 상태다.
쓰레싱의 원인은 “전체 프로세스의 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을 계속 초과하면 튜닝만으로 본질을 해결할 수 없다.
메모리 부족은 OOM으로만 나타나지 않는다. 커널은 OOM Killer를 실행하기 전에 page cache를 줄이고, 덜 쓰는 anonymous page를 swap으로 내리고, slab cache를 회수하는 식으로 최대한 버틴다. 이 과정을 reclaim이라고 부른다.
학습용으로는 메모리를 세 부류로 나누면 읽기 쉽다.
| 부류 | 예시 | 회수 난이도 |
|---|---|---|
| File-backed page | 파일 내용, shared library, page cache | 원본 파일이 있으므로 비교적 회수 쉬움 |
| Anonymous page | heap, stack, malloc된 객체 | 원본 파일이 없어서 swap 없이는 회수 어려움 |
| Kernel memory | slab, 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 이전의 느려짐을 보는 지표다.
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 계열 지표를 같이 본다.
swap이 느린 이유는 RAM보다 디스크가 느리기 때문이다. zswap과 zram은 이 비용을 줄이기 위해 page를 압축한다.
| 기술 | 원리 | 장점 | 경계 |
|---|---|---|---|
| zswap | swap으로 나가기 전 RAM 안의 압축 pool에 먼저 저장 | 디스크 I/O를 줄인다 | RAM 일부를 압축 pool로 쓰므로 CPU와 메모리를 소비한다 |
| zram | RAM 일부를 압축 block device처럼 사용 | 디스크 없는 장비나 작은 VM에서 유용 | 실제 RAM 한계를 없애지는 못한다 |
작은 서버에서 100MB page가 40MB로 압축된다면 디스크로 내보낼 page가 줄어 응답 저하가 완화될 수 있다. 하지만 압축과 해제에도 CPU가 든다. CPU도 부족한 서버에서 zswap을 켜면 memory stall은 줄어도 CPU contention이 늘 수 있다.
판단 기준:
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이다.
Redis의 maxmemory-policy도 OS page replacement와 같은 질문을 던진다. “메모리가 꽉 찼을 때 어떤 key를 내보낼 것인가?”
| Redis 정책 | 의미 | 좋은 상황 | 위험한 상황 |
|---|---|---|---|
noeviction | 더 이상 쓰지 않고 에러 반환 | 캐시가 아니라 원본성 데이터에 가까울 때 | 쓰기 트래픽이 바로 에러로 번진다 |
allkeys-lru | 모든 key 중 오래 안 쓴 key 축출 | 최근성이 중요한 일반 캐시 | full scan traffic에 오염될 수 있다 |
allkeys-lfu | 모든 key 중 적게 쓴 key 축출 | 장기 인기 데이터가 뚜렷할 때 | 신상품·뉴스처럼 새 hot key에 불리할 수 있다 |
volatile-lru | TTL 있는 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이 충분히 들어간다면 정책 차이는 장애 원인이 아닐 수 있다.
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가 순차 접근인가, 임의 접근인가, 삽입·삭제가 많은가”를 같이 본다.
B+Tree 인덱스는 leaf node를 page 단위로 저장하고, leaf끼리 순서대로 연결한다. 범위 검색은 이 구조 덕분에 빠르다.
[1|2|3|4] -> [5|6|7|8] -> [9|10|11|12] page A page B page CBETWEEN 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를 가진다.
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 | 일반적으로 약간 보호될 수 있음 |
관찰은 짧게 한다.
cat /proc/$(pgrep node | head -1)/oom_scorecat /proc/$(pgrep node | head -1)/oom_score_adj반례: OOM Killer가 죽인 프로세스가 “가장 덜 중요한 프로세스”라고 보장되지는 않는다. 메모리를 많이 쓰는 PostgreSQL이나 Redis가 서비스의 핵심일 수 있다. 그래서 중요한 프로세스는 systemd OOMScoreAdjust, Kubernetes QoS, request/limit 설계로 보호하고, 애플리케이션별 메모리 상한을 별도로 둔다.
Docker와 Kubernetes의 memory limit은 cgroups로 구현된다. cgroup은 프로세스 묶음에 CPU, memory, I/O 같은 자원 한도를 거는 Linux 기능이다. 컨테이너가 512MiB limit을 넘으면 host 전체 RAM이 남아 있어도 해당 cgroup 안에서 OOM이 발생할 수 있다.
cat /sys/fs/cgroup/memory.maxcat /sys/fs/cgroup/memory.currentcat /sys/fs/cgroup/memory.events예상 형태:
memory.max 536870912memory.current 501219328memory.events low 0 high 12 max 3 oom 1 oom_kill 1판단 기준:
| 증상 | 먼저 의심할 것 |
|---|---|
docker inspect나 Pod 상태에 OOMKilled | cgroup limit 초과 |
| host에는 free memory가 있는데 컨테이너만 죽음 | 컨테이너 limit이 낮거나 앱 RSS가 높음 |
heapUsed는 낮은데 RSS가 limit에 가까움 | Buffer, native addon, code page, stack, allocator fragmentation |
memory.events의 oom_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 여유분을 남기는 방향으로 설정한다.
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",});| 필드 | 뜻 | 장애 해석 |
|---|---|---|
rss | OS가 본 실제 RAM 상주 크기 | cgroup OOM과 가장 가깝다 |
heapTotal | V8이 확보한 heap 총량 | V8이 확장해 둔 JavaScript heap 용량 |
heapUsed | V8 heap 중 실제 사용 중인 양 | JS 객체 누수와 직접 연결 |
external | V8 밖 native memory | Buffer, C++ binding, addon 누수 가능 |
arrayBuffers | ArrayBuffer/Buffer 계열 | 업로드, 파일 처리, binary payload와 연결 |
중요한 반례: heapUsed가 안정적이어도 RSS가 계속 오르면 JavaScript 객체 누수가 아닐 수 있다. Buffer.alloc(), image 처리 native library, DB driver native buffer, allocator fragmentation이 RSS를 키울 수 있다. 반대로 heapUsed가 GC 후에도 계단식으로 오르면 JS object retention을 의심한다.
메모리 누수는 “순간적으로 높다”가 아니라 “회수할 기회가 있었는데도 기준선이 계속 올라간다”에 가깝다.
짧은 관찰 스니펫:
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 120MB10:05 heapUsed 145MB10:10 heapUsed 122MB GC 후 감소
누수 의심:10:00 heapUsed 120MB10:05 heapUsed 180MB10:10 heapUsed 240MB10: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는 max와 ttl이 있는 LRU cache로 제한하고, 여러 서버가 같은 cache를 봐야 하면 Redis 같은 외부 cache를 검토한다.
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 -> 280MBheapUsed 5MB -> 6MB -> 6MBexternal 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의 여유를 모두 남겨야 한다.
메모리 장애를 볼 때 “느림”과 “죽음”을 구분해야 한다. 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 프로세스가 재시작됨판단을 잘못하면 대응도 틀린다.
--max-old-space-size를 너무 낮게 잡으면 OOMKilled는 줄어도 V8 heap OOM이나 GC pressure가 빨리 온다.실무 감각으로는 컨테이너 limit에서 V8 old space를 뺀 나머지가 OS/V8 metadata, Buffer, stack, code, native library, page table, allocator fragmentation의 완충 공간이다. 512MB 컨테이너에 old space를 500MB로 주는 설정은 숫자상 거의 여유가 없다.
운영 절차를 외우기보다 증상에서 어떤 개념을 의심할지 연결한다.
| 증상 | 의미 | 먼저 볼 지표 |
|---|---|---|
컨테이너가 ExitCode 137, OOMKilled | cgroup memory limit 초과로 kill | memory.current, memory.events, docker stats, Node rss |
heapUsed가 계속 증가 | JS 객체가 GC로 회수되지 않음 | heap snapshot, GC 후 기준선, listener/cache 증가 |
heapUsed는 낮은데 rss만 증가 | native/external memory 또는 fragmentation | external, arrayBuffers, Buffer 사용량, native addon |
| CPU가 낮은데 서버가 느림 | swap thrashing 또는 I/O wait | vmstat si/so, b, disk I/O |
| p99만 간헐적으로 수백 ms 증가 | GC stop-the-world, THP compaction, memory pressure | GC log, /proc/vmstat의 compact/thp, latency timestamp |
Redis OOM command not allowed | maxmemory 도달 + noeviction 정책 | INFO memory, maxmemory-policy, evicted_keys |
Redis BGSAVE 중 RSS 급증 | CoW page 복사 증가 | current_cow_size, write traffic, THP 상태 |
OOMKilled가 true인지 확인한다.memory.current가 얼마나 가까웠는지 본다.rss와 heapUsed를 비교한다.heapUsed가 높으면 JS heap 누수·상한을 본다.rss만 높으면 Buffer/native/external memory를 본다.명령어는 환경마다 다르지만 학습용 핵심은 이 분기다.
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'.확인할 질문:
maxmemory-policy가 noeviction인가?volatile-* 정책이 축출할 key를 못 찾는가?evicted_keys가 증가하는데 hit rate가 떨어지는가?maxmemory보다 큰가?cache라면 보통 allkeys-lru 또는 workload에 따라 allkeys-lfu를 검토한다. 원본성 데이터라면 단순 축출은 데이터 손실이므로 메모리 증설, 데이터 모델 조정, key 수명 정책을 먼저 본다.
THP 문제는 “평균은 괜찮은데 p99가 가끔 튄다”는 모습으로 나타날 수 있다.
cat /sys/kernel/mm/transparent_hugepage/enabledgrep -E "compact|thp" /proc/vmstat | headgrep -i huge /proc/meminfo판단 기준:
always이고 p99 spike가 있으면 never 또는 madvise를 실험한다.장애 중에는 지표가 많아서 헷갈린다. 아래 순서는 “어느 층의 문제인가”를 빠르게 좁히기 위한 학습용 판단 흐름이다.
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를 분리해서 보는 것이다.
새 메모리 기술을 만났을 때 아래 네 질문을 적용하면 세부 구현을 몰라도 구조가 보인다.
| 질문 | OS 예시 | Node.js 예시 | Redis 예시 |
|---|---|---|---|
| 할당 단위는? | page/frame 4KB, huge page 2MB | V8 object, Buffer, old space | key/value object, allocator chunk |
| 회수 정책은? | page replacement, reclaim, swap | GC minor/major, object reachability | maxmemory-policy, TTL |
| 격리 경계는? | process address space | process와 V8 heap limit | Redis process, database/key namespace |
| 실패 모드는? | OOM kill, thrashing, SIGSEGV | fatal heap OOM, long GC pause | OOM command error, eviction storm |
이 질문은 cgroups, JVM GC, WASM linear memory, eBPF map에도 그대로 적용된다. 중요한 것은 용어를 많이 외우는 것이 아니라 “누가 메모리를 나누고, 누가 회수하며, 어디까지 격리하고, 실패하면 어떤 신호가 나오는가”를 찾는 것이다.
| 구분 | 가상 메모리 | 스왑 |
|---|---|---|
| 수준 | 프로세스에게 보이는 주소 공간 추상화 | RAM 부족 시 page를 내려놓는 디스크 공간 |
| 관계 | 상위 개념 | 가상 메모리를 구현하는 수단 중 하나 |
| 오해 | ”가상이라 실제 메모리가 아니다" | "swap이 있으면 RAM 부족이 해결된다” |
| 정확한 이해 | 주소 변환과 보호까지 포함한다 | 버틸 수는 있지만 느리고 thrashing을 만들 수 있다 |
| 구분 | RSS | V8 heapUsed |
|---|---|---|
| 관찰 주체 | OS/cgroup | V8 엔진 |
| 포함 | V8 heap, native memory, stack, code, shared page 일부 | JavaScript object heap 사용량 |
| 장애 연결 | cgroup OOM, host memory pressure | JS object leak, GC pressure |
| 반례 | shared page 때문에 단순 합산하면 과대평가 가능 | 낮아도 RSS OOM이 날 수 있음 |
| 구분 | OOMKilled | Node.js heap OOM |
|---|---|---|
| 죽이는 주체 | Linux kernel 또는 cgroup OOM killer | V8/Node.js 프로세스 내부 |
| 대표 신호 | ExitCode 137, OOMKilled: true | FATAL 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 |
| stream | chunk 단위로 처리하고 backpressure 적용 가능 | 대용량 파일 전달, 압축, 업로드 proxy | 전체 내용을 랜덤 접근해야 할 때 |
mmap | 주소 공간에 매핑하고 접근한 page만 fault로 올림 | DB engine, shared library, 대용량 파일 일부 접근 | sparse random access, truncation 위험, 직접 제어가 어려운 런타임 |
| 지표 | 뜻 | 오해 방지 |
|---|---|---|
| free | 즉시 비어 있는 RAM | 작아도 Linux에서는 정상일 수 있다 |
| available | 앱에 내줄 수 있다고 추정되는 RAM | free보다 운영 판단에 더 유용하다 |
| buff/cache | page cache와 buffer | 회수 가능하면 문제 아님, 회수가 지연되면 pressure |
| RSS | 특정 프로세스가 실제 RAM에 올린 page | cgroup OOM과 직접 연결되지만 shared page 중복 계산에 주의 |
본문 이해에 필수는 아니지만, 직접 확인하고 싶을 때만 사용한다.
cat /proc/self/maps | headcat /proc/$$/status | grep -E "Vm(Size|RSS|Swap)"getconf PAGE_SIZEfree -hvmstat 1 5관찰 포인트:
maps는 가상 주소 범위다.VmRSS는 실제 RAM에 올라온 page 크기다.si/so가 지속적으로 높으면 swap 왕복을 의심한다.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를 본다.cat /sys/fs/cgroup/memory.maxcat /sys/fs/cgroup/memory.currentcat /sys/fs/cgroup/memory.eventsDocker 환경에서는 다음도 볼 수 있다.
docker inspect <container_id> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'docker stats <container_id>관찰 포인트:
memory.max를 넘으면 컨테이너는 죽을 수 있다.redis-cli INFO stats | grep current_cow_sizeredis-cli INFO stats | grep evicted_keysredis-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을 같이 본다.| 키워드 | 한 줄 설명 |
|---|---|
| Virtual Memory | 프로세스마다 독립적인 가상 주소 공간을 제공하는 메커니즘 |
| Physical Memory | 실제 RAM에 존재하는 메모리 |
| Page | 가상 주소 공간의 고정 크기 조각 |
| Frame | 물리 메모리의 고정 크기 조각 |
| Page Table | VPN을 PFN으로 바꾸는 프로세스별 매핑 표 |
| MMU | 주소 변환과 권한 검사를 수행하는 CPU 하드웨어 |
| TLB | 주소 변환 결과를 저장하는 작은 CPU 캐시 |
| Page Fault | 페이지가 없거나 권한이 맞지 않을 때 발생하는 예외 |
| Minor Page Fault | 디스크 I/O 없이 처리되는 page fault |
| Major Page Fault | 디스크에서 page를 읽어야 하는 page fault |
| RSS | 실제 RAM에 올라온 프로세스 메모리 크기 |
| VmSize | 예약된 가상 주소 공간 크기 |
| Swap | RAM에서 밀려난 page를 두는 디스크 공간 |
| Thrashing | page 교체가 과도해 실제 작업보다 swap 왕복에 시간을 쓰는 상태 |
| LRU | 가장 오래 사용하지 않은 항목을 내보내는 정책 |
| LFU | 가장 적게 사용한 항목을 내보내는 정책 |
| Cache Line | CPU cache가 메모리를 가져오는 기본 단위. 보통 64바이트 |
| OOM Killer | 메모리 부족 시 프로세스를 강제 종료하는 Linux 커널 메커니즘 |
| cgroups | 프로세스 그룹별 자원 사용량을 제한·관찰하는 Linux 기능 |
| V8 Heap | Node.js JavaScript 객체가 주로 할당되는 V8 엔진의 heap |
| Copy-on-Write | 공유 page를 쓰기 시점에만 복사하는 최적화 |
| THP | 커널이 자동으로 2MB huge page를 만들려는 기능 |
| Huge Pages | TLB miss를 줄이기 위해 쓰는 큰 page |
가상 메모리는 프로세스별 주소 공간으로 격리를 만들고, paging과 page replacement로 한정된 RAM을 관리하며, 그 실패 신호는 RSS 증가, swap thrashing, OOMKilled, GC pause, Redis eviction처럼 운영 표면에 직접 드러난다.