메모리 관리가 운영 문제로 드러나는 순간
가상 메모리, 페이징, 페이지 폴트, 교체 알고리즘이 어떻게 프로세스 격리와 성능을 만든는지 설명한다. Redis, Docker, Node.js, THP 사례를 통해 운영 중 만나는 메모리 장애와 선택 기준까지 연결한다.
Script Companion
오디오와 함께 스크립트 보기
- 01
메모리 관리는 단순히 운영체제 내부 구현이 아니라, 서버가 죽고 캐시가 흔들리고 레이턴시가 튀는 순간 바로 드러나는 기반 개념이다. 한 프로세스가 다른 프로세스의 메모리를 침범하지 못하게 막는 프로세스 격리, 물리 메모리보다 큰 프로그램을 실행하게 해주는 가상화, 그리고 한정된 RAM을 어떤 기준으로 나눠 쓸지 결정하는 정책이 모두 여기에 들어 있다. Node.js나 Nest.js 서버가 OOM으로 종료되거나 Docker 컨테이너가 OOMKilled 상태가 되는 현상도 같은 맥락에서 봐야 한다.
- 02
가상 메모리, Virtual Memory의 핵심은 각 프로세스가 자기만의 독립적인 주소 공간을 가진 것처럼 보이게 하는 것이다. 두 프로세스가 같은 가상 주소 0x1000을 사용하더라도 실제 물리 RAM의 위치는 완전히 다를 수 있고, 운영체제가 그 변환을 투명하게 처리한다. 호텔 손님이 자기 방 번호만 알면 되고 실제 배관과 배선은 건물 관리자가 처리하는 것처럼, 프로세스는 가상 주소를 쓰고 운영체제와 하드웨어가 물리 주소와 스왑 위치를 관리한다.
- 03
가상 메모리는 격리만 위한 장치가 아니다. RAM이 부족하면 일부 페이지를 디스크로 내리고, 필요할 때 다시 올려 물리 메모리보다 큰 프로그램도 실행할 수 있게 한다. 이때 실제로 RAM에 올라와 있는 크기인 RSS와, 프로세스가 확보한 가상 주소 공간의 크기는 다르게 보일 수 있다. 그래서 VmSize가 크고 VmRSS가 작다면 주소 공간은 크게 잡았지만 실제 물리 메모리는 적게 쓰는 상황으로 해석할 수 있다. 이 차이를 모르면 메모리 사용량을 과대평가하거나 반대로 위험 신호를 놓치기 쉽다.
- 04
Copy-on-Write는 fork()에서 중요한 최적화다. 자식 프로세스를 만들 때 운영체제는 부모의 메모리 페이지를 즉시 복사하지 않고, 부모와 자식이 같은 물리 페이지를 공유하게 둔다. 어느 한쪽이 해당 페이지에 쓰기를 시도할 때만 페이지를 복사해 별도 물리 프레임을 할당한다. Redis BGSAVE가 이 원리를 잘 보여준다. fork() 직후에는 메모리를 거의 추가로 쓰지 않지만, 부모인 Redis 서버가 클라이언트 요청을 처리하며 데이터를 변경할수록 CoW가 발생해 used_memory_rss가 급증할 수 있다.
- 05
Redis BGSAVE에서 Transparent Huge Pages, THP가 특히 문제가 되는 이유는 복사 단위가 커지기 때문이다. 일반적인 페이지 단위가 4KB라면, THP가 활성화된 상태에서는 2MB Huge Page 단위로 CoW가 일어날 수 있다. 단 하나의 바이트 수정도 2MB 전체 복사로 이어지는 셈이다. 그래서 Redis가 THP 비활성화를 강하게 권고하는 핵심 배경이 여기에 있다. 쓰기가 많은 시간대에 BGSAVE를 실행하면 CoW와 THP가 결합해 실제 점유 메모리가 크게 늘고, 운영자는 이를 단순한 Redis 메모리 증가가 아니라 페이지 단위 복사의 결과로 봐야 한다.
- 06
페이징은 가상 메모리와 물리 메모리를 같은 크기의 고정 블록으로 나눠 관리하는 방식이다. 가상 주소 공간의 블록은 페이지, 물리 메모리의 블록은 프레임이라고 부르고, 페이지 테이블은 VPN에서 PFN으로 이어지는 매핑을 가진다. MMU라는 하드웨어가 매 메모리 접근마다 이 변환을 수행한다. 반대로 세그멘테이션은 코드, 데이터, 스택처럼 논리적 의미가 있는 가변 크기 영역으로 나누는 방식이다. 현대 Linux와 macOS는 이런 논리 구조를 유지하면서 실제 물리 메모리 관리는 주로 페이징으로 처리한다.
- 07
페이지 크기 4KB는 내부 단편화와 페이지 테이블 크기 사이의 절충이다. 페이지가 너무 작으면 페이지 테이블 항목이 너무 많아져 메모리를 낭비하고, 너무 크면 실제로 몇 KB만 필요한데 남은 공간이 낭비된다. 대용량 메모리 서버에서는 Huge Pages가 더 효율적인 경우가 있다. 예를 들어 64GB RAM을 4KB 페이지로 관리하면 약 1,600만 개의 페이지 테이블 항목이 필요하지만, 2MB 페이지를 쓰면 약 3만 2천 개로 줄어든다. TLB 엔트리 1,536개 기준으로도 4KB 페이지는 약 6MB만 커버하지만 2MB 페이지는 3GB까지 커버한다.
- 08
THP를 켤지 끌지는 워크로드에 따라 달라진다. 큰 working set을 균일하게 접근하는 수치 계산, 머신러닝 학습, in-memory DB에서는 THP always가 유리할 수 있다. 반대로 Redis, MongoDB, PostgreSQL의 shared_buffers 외 영역처럼 sparse한 접근과 수명이 짧은 객체가 많은 경우에는 THP never가 권장된다. 2MB 단위 CoW와 khugepaged compaction이 p99 latency 스파이크를 만들 수 있기 때문이다. JVM heap처럼 일부 mmap 영역만 큰 페이지가 필요하면 THP madvise와 MADV_HUGEPAGE로 명시 요청한 영역만 Huge Page를 쓰는 선택지도 있다.
- 09
페이지 폴트는 CPU가 접근한 페이지가 RAM에 없을 때 발생하는 예외다. MMU가 페이지 테이블을 확인했는데 present 비트가 0이면 운영체제의 페이지 폴트 핸들러가 실행되고, 필요한 페이지를 스왑에서 RAM으로 올린 뒤 페이지 테이블을 갱신하고 원래 명령어를 다시 실행한다. 다만 major fault가 0이라고 항상 안심할 수는 없다. Linux는 NUMA 페이지 마이그레이션, KSM, THP compaction에서 발생하는 stall을 page fault 카운터에 잡지 않는다. p99 latency 스파이크가 있는데 major fault가 없다면 dTLB-load-misses, iTLB-load-misses나 compact, thp 계열 지표를 의심해야 한다.
- 10
쓰레싱은 페이지 폴트가 너무 자주 발생해 CPU가 실제 작업보다 페이지 교체에 대부분의 시간을 쓰는 상태다. 증상은 CPU 사용률은 낮은데 시스템은 느리고, 디스크 I/O가 폭증하는 형태로 나타난다. 근본 원인은 워킹 셋이 물리 메모리보다 큰 것이다. 10개 프로세스가 각각 500MB의 워킹 셋을 가지면 총 5GB가 필요한데 RAM이 4GB라면 운영체제는 계속 스왑 아웃과 스왑 인을 반복한다. 해결 방향도 명확하다. 프로세스 수를 줄이거나, 메모리를 늘리거나, 워킹 셋을 줄여야 한다.
- 11
페이지 교체 알고리즘은 RAM에 새 페이지를 올려야 할 때 무엇을 내보낼지 정하는 기준이다. LRU는 가장 오래 전에 쓴 페이지를 교체하므로 대부분의 워크로드에서 직관적이고 우수하지만, 모든 접근마다 최근성을 관리해야 하는 비용이 있다. LFU는 접근 횟수가 적은 페이지를 교체하므로 정적 인기 데이터에 유리하지만, 오래전에 많이 쓰였고 지금은 안 쓰는 데이터가 남는 Aging 문제가 있다. FIFO는 가장 먼저 들어온 페이지를 내보내 구현은 단순하지만 성능이 낮고, 프레임 수를 늘렸는데 페이지 폴트가 늘어나는 Belady's Anomaly가 생길 수 있다.
- 12
Redis maxmemory-policy는 운영체제의 페이지 교체 개념이 캐시에 적용된 사례다. 모든 키가 캐시라면 allkeys-lru나 allkeys-lfu를 고려하고, 세션이나 임시 데이터만 축출하고 싶다면 volatile-lru가 맞을 수 있다. 특정 데이터가 항상 인기 있고 잘 변하지 않으면 allkeys-lfu가 유리하지만, 새로 출시된 인기 상품은 빈도 카운터가 0부터 시작해 축출될 수 있다. Redis 4.0+의 lfu-log-factor와 lfu-decay-time은 이 문제를 완화하는 장치다. 반대로 소셜 피드나 실시간 채팅 메시지처럼 정말 최근성이 중요한 워크로드에서는 LRU가 여전히 우위다.
- 13
캐시 지역성은 배열이 연결 리스트보다 빠른 이유와 DB B+Tree 인덱스의 구조를 설명해준다. 시간 지역성은 최근 접근한 데이터를 가까운 미래에 다시 접근할 가능성이 높다는 뜻이고, 공간 지역성은 방금 접근한 주소 근처의 데이터도 곧 접근할 가능성이 높다는 뜻이다. CPU는 arr[0]에 접근할 때 보통 64바이트 캐시 라인을 통째로 올리기 때문에 arr[1] 접근이 빨라질 수 있다. B+Tree도 범위 검색에서 리프 노드들이 연결 리스트로 이어지고, 물리적으로 인접한 블록에 저장되어 Spatial Locality를 활용할 수 있다.
- 14
OOM Killer는 물리 메모리와 스왑이 모두 소진될 때 Linux 커널이 프로세스를 강제 종료하는 자가 방어 메커니즘이다. 커널은 각 프로세스에 oom_score 0부터 1000까지의 점수를 계산하고 가장 높은 프로세스를 종료한다. 문제는 메모리를 많이 쓰는 프로세스가 가장 중요한 서비스일 수 있다는 점이다. PostgreSQL처럼 중요한 프로세스는 많은 메모리를 사용해 oom_score가 높아질 수 있으므로, 필요하면 oom_score_adj를 낮게 설정해야 한다. Docker에서는 --memory 제한을 넘으면 cgroups가 해당 cgroup 안에서 OOM Killer를 실행하고, PID 1이 종료되면 컨테이너 전체가 죽는다.
- 15
Node.js와 Nest.js에서는 운영체제의 가상 메모리 위에 V8 엔진의 힙 관리가 더해진다. process.memoryUsage()가 무엇을 의미하는지 알아야 메모리 누수를 진단할 수 있고, 컨테이너 환경에서는 Node.js --max-old-space-size를 컨테이너 limit보다 낮게 설정하는 판단도 필요하다. Prometheus에서는 prom-client의 collectDefaultMetrics()가 nodejs_heap_size_used_bytes와 nodejs_heap_size_total_bytes를 자동 수집하므로 힙 사용률 알람을 만들 수 있다. heapUsed와 heapTotal의 비율이 85%에 이르면 Major GC 빈도가 급증할 수 있고, 95%를 넘으면 V8이 힙 확장을 포기하고 OOM으로 종료될 수 있다.
- 16
트러블슈팅 관점에서 보면 증상은 서로 달라도 원리는 이어진다. Docker 컨테이너가 OOMKilled로 갑자기 종료되면 --memory 제한을 넘었고 cgroups 안의 OOM Killer가 메인 프로세스를 죽였을 가능성을 본다. Redis에서 OOM command not allowed가 나오면 maxmemory에 도달했는데 maxmemory-policy가 noeviction이라 쓰기 명령을 거부한 상황이다. EC2 인스턴스 전체가 느리고 CPU는 놀고 있는데 디스크 I/O가 높다면 쓰레싱을 의심한다. NestJS의 Map이나 object 캐시가 만료 정책 없이 커지면 Old Generation에 장기 생존 객체가 쌓여 Major GC가 늘어날 수 있다.
같은 레이어