다음에 올 것을 만들어가는 기술에 대한 심층 기사.

jemalloc이 중요한 이유: 대규모 메모리 할당

jemalloc은 세계 최대 규모 시스템의 메모리 할당을 담당합니다. 파편화를 줄이는 원리와 Meta가 이를 강화하는 이유를 알아봅니다.

창고에서 크기별 메모리 빈에 빛나는 데이터 블록을 분류하는 로봇 팔

프로그램이 malloc()을 호출할 때마다, 어떤 가상 메모리 조각을 돌려줄지 결정해야 합니다. 작은 프로그램에서는 사소한 결정이지만, 규모가 커지면 엄청난 엔지니어링 문제가 됩니다. 나쁜 할당자는 메모리를 파편화하고, RAM을 낭비하며, 스레드 간 락 경합을 만들고, OS가 페이지를 회수할 때 지연 시간을 튀게 만듭니다. 좋은 할당자는 이 중 어느 것도 하지 않습니다. jemalloc이 바로 좋은 할당자이고, Meta가 최근 이에 대한 지원을 재확인했습니다. 그들의 규모에서는 좋은 할당자와 평범한 할당자의 차이가 하드웨어 비용으로 수십억 달러에 달하기 때문입니다.

대부분의 개발자는 메모리 할당에 대해 거의 생각하지 않습니다. new나 malloc을 호출하면 포인터가 돌아오니까요. 하지만 Meta의 규모에서는 하루 수십억 건의 요청, 수백만 대의 서버, 페타바이트 단위의 RAM이 얽혀 있어서, 할당자의 동작이 하드웨어 효율, 테일 지연 시간, 운영 비용에 직접적이고 측정 가능한 영향을 줍니다.

메모리 할당자가 실제로 하는 일

메모리 할당자는 프로그램과 운영체제 사이에 위치합니다. OS는 메모리를 큰 덩어리(보통 4KB 또는 2MB 페이지)로 제공합니다. 반면 프로그램은 작고 크기가 제각각인 조각을 원합니다. 여기는 24바이트 문자열, 저기는 4096바이트 버퍼처럼요. 할당자의 역할은 OS가 준 페이지를 프로그램이 필요한 조각으로 잘라 나누고, 해제된 조각을 다음 할당을 위해 재활용하는 것입니다.

가장 단순한 방식, 즉 할당할 때마다 OS에 새 페이지를 요청하고 해제할 때 돌려주는 방식은 끔찍하게 느립니다. 시스템 콜에는 오버헤드가 있고, 페이지는 대부분의 할당보다 훨씬 큽니다. 24바이트 문자열 하나를 위해 4KB를 쓰는 셈이니까요.

실제 할당자는 메모리 풀을 유지하고 그 안에서 조각을 나눠 씁니다. 여기서 풀어야 할 과제는 세 가지입니다. 파편화(사용하기엔 너무 작은 할당 사이의 틈)를 최소화하고, 락 경합(여러 스레드가 동시에 할당하는 상황)을 최소화하며, 오버헤드(할당마다 붙는 메타데이터가 할당 자체에 비해 작아야 함)를 최소화하는 것입니다.

jemalloc의 동작 원리

jemalloc은 Jason Evans가 만들었고, 이름의 'je'도 여기서 왔습니다. 원래 FreeBSD를 위해 개발되었고, 이후 Meta(당시 Facebook)가 C와 C++ 서비스의 기본 할당자로 채택했습니다. 이 설계는 세 가지 핵심 과제를 구체적인 기법으로 해결합니다.

스레드 캐시로 경합을 없앱니다. 각 스레드는 작은 할당을 위한 자체 캐시를 가집니다. 스레드가 작은 객체에 대해 malloc()을 호출하면, 할당은 전적으로 스레드의 로컬 캐시에서 처리됩니다. 락도, 원자적 연산도, 경합도 없습니다. 스레드 캐시가 바닥났을 때에만 공유 아레나에서 캐시를 채워 옵니다.

jemalloc allocation flow:
Thread calls malloc(64)
↓
Check thread cache for 64-byte size class
→ Hit: return cached chunk (no lock, no contention)
→ Miss: refill from arena
↓
Arena (shared, but per-thread affinity)
→ Find a partially-full slab for 64-byte objects
→ Carve out a chunk
→ Return to thread cache
↓
Return chunk to caller
Thread caches handle 95%+ of allocations without any locking.

크기 클래스로 파편화를 줄입니다. jemalloc은 요청된 바이트 수를 정확히 할당하지 않고, 가장 가까운 크기 클래스로 올림합니다. 크기 클래스는 8, 16, 32, 48, 64, 80, 96, 112, 128, 160, 192, 224, 256 등으로 신중하게 고르며, 크기가 커질수록 간격도 넓어집니다. 즉 50바이트 할당은 64바이트 조각을 받아 23%가 낭비됩니다. 얼핏 나빠 보이지만 실제로는 좋은 설계입니다. 같은 크기 클래스의 64바이트 조각들은 모두 서로 바꿔 쓸 수 있으므로, 클래스 내부에서는 외부 파편화가 전혀 없기 때문입니다.

슬랩이 같은 크기의 할당을 정리합니다. 각 슬랩은 같은 크기 클래스의 조각으로 나뉜 연속된 메모리 영역입니다. 64바이트 객체를 위한 슬랩에는 64바이트 조각만 들어 있습니다. 이렇게 하면 가장 고약한 형태의 파편화가 사라집니다. 서로 다른 크기의 활성 할당 사이에 끼어서, 해제된 메모리를 재사용할 수 없는 상황 말입니다.

파편화 문제

메모리 파편화는 장기 실행 서비스를 조용히 망가뜨리는 주범입니다. 막 시작된 서버는 메모리를 효율적으로 사용합니다. 할당이 촘촘하게 붙어 있으니까요. 하지만 며칠, 몇 주 동안 여러 크기의 할당과 해제가 반복되면 힙은 치즈처럼 구멍투성이가 됩니다. 전체 여유 메모리는 2GB나 되는데, 가장 큰 연속 여유 영역은 64KB뿐일 수 있습니다.

외부 파편화(할당 사이에 쓸 수 없는 틈)와 내부 파편화(올림 때문에 할당 내부에서 낭비되는 공간) 모두 중요하지만, 외부 파편화가 더 심각합니다. 내부 파편화는 크기 클래스 간격에 의해 상한이 정해지며, 최악의 경우에도 할당마다 약 25% 정도입니다. 반면 외부 파편화는 상한이 없고 시간이 지날수록 커집니다.

jemalloc의 슬랩 기반 방식은 작은 할당(대다수를 차지하는 영역)에서 외부 파편화를 대부분 없앱니다. 슬랩 안의 모든 객체가 같은 크기이므로, 하나를 해제하면 같은 크기 클래스의 다음 할당에 정확히 맞는 빈자리가 생깁니다. 쓸 수 없는 틈이 없습니다.

큰 할당(보통 14KB 이상)에는 jemalloc이 다른 전략을 씁니다. 큰 할당은 자체 페이지를 받고, 해제된 페이지는 OS에 반환되거나 다른 크기 클래스에 재사용됩니다. 여기서는 여전히 파편화가 생길 수 있지만, 큰 할당은 상대적으로 드뭅니다.

jemalloc vs. glibc malloc vs. tcmalloc

리눅스 생태계의 주요 할당자 세 가지는 저마다 다른 트레이드오프를 선택합니다.

  • glibc malloc (ptmalloc2)는 대부분의 리눅스 시스템에서 기본값입니다. 확장성을 위해 아레나를 사용하지만 jemalloc보다 크기 클래스가 적어서, 장기 실행 서비스에서 파편화가 더 많이 생깁니다. 주된 장점은 기본값이라 설정이 필요 없다는 것입니다.
  • tcmalloc (Google)은 스레드 캐싱 방식의 malloc으로, 원래 Google의 C++ 서비스를 위해 개발되었습니다. 스레드 로컬 캐싱이 뛰어나고 오버헤드가 낮습니다. 짧게 살아 있는 할당이 많은 워크로드에 적합하며, 파편화가 중요한 고메모리 압박 워크로드에는 덜 맞습니다.
  • jemalloc은 지속적인 부하에서 낮은 파편화와 예측 가능한 동작을 최적화합니다. 다른 둘보다 정교한 크기 클래스와 슬랩 관리를 사용합니다. 트레이드오프는 할당당 오버헤드가 약간 더 높다는 점(메타데이터가 더 많음)이며, 대신 시간이 지나도 메모리 효율이 더 좋습니다.

선택은 워크로드에 따라 달라집니다. 수명이 짧은 프로세스에서는 세 가지 모두 비슷하게 동작합니다. 혼합된 할당 패턴을 가진 장기 실행 서버(웹 서버, 데이터베이스, 캐시)에서는 jemalloc의 파편화 저항성이 보통 이깁니다. 같은 워크로드에서 glibc malloc보다 RAM을 10~30% 적게 사용하는 경우가 많고, 이는 규모가 커지면 곧바로 하드웨어 절감으로 이어집니다.

Meta가 신경 쓰는 이유

Meta의 규모에서는 C++ 서비스 전반의 메모리 사용량을 10% 줄이는 것만으로도 하드웨어 비용을 수백만 달러 아낄 수 있습니다. 그것도 연간이 아니라 매달입니다. 수백만 대의 서버를 운영하고, 각 서버의 서비스가 하루에 수십억 번씩 메모리를 할당하고 해제한다면, 할당자의 효율은 예산의 한 항목이 됩니다.

Meta가 새롭게 투자하는 jemalloc은 여러 영역에 초점을 맞춥니다. 더 나은 huge page 지원(대용량 메모리 시스템에서 TLB 미스 감소), OS로의 메모리 반환 개선(부하가 줄었을 때 상주 메모리 감소), 그리고 더 나은 프로파일링 도구(메모리가 어디서 쓰이고 어디서 낭비되는지 파악)입니다.

프로파일링 부분이 특히 흥미롭습니다. jemalloc에는 힙 프로파일링 기능이 내장되어 있습니다. 할당을 샘플링하도록 요청하면, 어디서 메모리가 할당되었는지, 얼마나 활성 상태이고 얼마나 해제되었는지, 힙이 얼마나 파편화되었는지를 보여주는 프로파일을 만들어 줍니다. 이 프로파일링은 운영 환경에서 거의 오버헤드가 없어서, 실제 프로덕션 서버에서 지속적으로 돌리기에도 현실적입니다.

프로젝트에서 jemalloc 사용하기

C/C++ 프로그램이라면 jemalloc으로 전환하는 것은 보통 간단합니다. 리눅스에서는 직접 링크하거나, LD_PRELOAD를 써서 실행 시점에 주입하면 됩니다. 코드를 바꿀 필요가 없습니다.

# Install jemalloc
sudo apt install libjemalloc-dev  # Debian/Ubuntu
brew install jemalloc             # macOS
# Use LD_PRELOAD — works with any program, no recompilation
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./my_server
# Or link at compile time
gcc -o my_server my_server.c -ljemalloc
# Enable profiling
export MALLOC_CONF="prof:true,prof_prefix:jeprof"
./my_server
# Then analyze: jeprof --svg ./my_server jeprof.*.heap > heap.svg

jemalloc을 기본으로 쓰는 유명 프로젝트도 여럿 있습니다. Redis, 일부 플랫폼에서 jemalloc을 기본 할당자로 쓰는 Rust, jemalloc이 원래 타깃으로 삼았던 FreeBSD를 대상으로 하는 Firefox, 그리고 많은 게임 엔진이 있습니다.

관리형 메모리를 쓰는 언어(Python, Java, Go)의 경우에는 언어 런타임이 할당을 담당하므로 jemalloc을 직접 적용할 수 없습니다. 하지만 스레드 로컬 캐싱, 크기 클래스, 슬랩 할당 같은 개념은 현대 런타임의 가비지 컬렉터와 메모리 할당자 어디에나 등장합니다. 예를 들어 Go 런타임 할당자는 tcmalloc에서 영감을 받은 설계로, 프로세서(P)별 캐시와 크기 클래스를 씁니다.

보이지 않는 인프라

메모리 할당은 잘 작동할 때는 보이지 않고, 잘못될 때는 재앙이 되는 인프라입니다. 파편화 때문에 서서히 메모리가 새는 서버는 결국 OOM 킬을 당하게 되고, 그 원인은 어떤 애플리케이션 로그에도 나타나지 않습니다. 애플리케이션 계층 아래에서 일어나는 일이니까요.

대부분의 애플리케이션에는 기본 할당자로 충분합니다. 하지만 장기 실행 서버를 운영하거나, 설명되지 않는 메모리 증가를 겪고 있거나, 하드웨어 효율이 중요한 규모에서 일한다면, 할당자를 이해하고 더 나은 것으로 바꾸는 것은 가장 레버리지가 큰 인프라 변경 중 하나입니다. jemalloc은 마법이 아닙니다. 신중한 데이터 구조 설계, 합리적인 트레이드오프, 그리고 대규모에서 중요한 세부 사항에 대한 집요한 관심, 즉 엔지니어링입니다.