Go 런타임의 심장부: 메모리 할당자(Memory Allocator) 완전 해부
Go 언어를 사용하는 엔지니어라면 하루에도 수백 번씩 make()나 new()를 호출합니다. 하지만 그 이면에서 실제로 어떤 일이 벌어지는지 깊게 고민해보신 적 있나요?
“그냥 가비지 컬렉터(GC)가 알아서 해주겠지”라고 생각하고 넘어가는 경우가 많습니다. 하지만 트래픽이 몰리는 고성능 백엔드 시스템을 운영하다 보면, 결국 메모리 할당 패턴 이 시스템의 레이턴시(Latency)와 처리량(Throughput)을 결정짓는 병목이 되는 순간이 반드시 옵니다.
오늘은 최근 Hacker News에서 화제가 된 Go 런타임의 메모리 할당자(Allocator)에 대한 분석 글을 바탕으로, Go가 어떻게 그토록 빠른 동시성을 지원하면서도 메모리를 효율적으로 관리하는지, 그리고 시니어 엔지니어로서 우리가 이 구조에서 무엇을 배워야 하는지 뜯어보겠습니다.
왜 OS에게 직접 달라고 하지 않는가?
가장 먼저 드는 의문은 이것입니다. “메모리가 필요하면 OS한테 malloc이나 mmap으로 달라고 하면 되잖아?”
문제는 비용 입니다. 시스템 콜(System Call)은 비쌉니다. 유저 모드에서 커널 모드로의 컨텍스트 스위칭(Context Switching)은 고성능 애플리케이션에서 치명적인 오버헤드입니다. 수천 개의 고루틴이 동시에 메모리를 요청하는데 그때마다 커널의 문을 두드린다면, Go의 장점인 동시성 성능은 박살이 날 겁니다.
그래서 Go 런타임은 Warehouse Manager(창고 관리자) 전략을 취합니다. OS로부터 미리 거대한 메모리 덩어리(Arena)를 할당받아 놓고, 내부적으로 잘게 쪼개서 고루틴들에게 나눠주는 방식입니다. 이는 tcmalloc(Thread-Caching Malloc)의 설계 철학을 계승한 것입니다.
메모리 계층 구조: Arena, Span, Page
Go 런타임은 메모리를 관리하기 위해 몇 가지 추상화 계층을 둡니다.
- Arena: 힙 메모리의 가장 큰 단위입니다. 64비트 시스템에서는 보통 64MB 크기입니다. 재미있는 점은 Go가 64MB를 요청한다고 해서 OS가 즉시 물리 메모리 64MB를 내어주는 건 아니라는 겁니다. 가상 주소 공간만 예약(Reserve)하고, 실제로 쓸 때 커밋(Commit)합니다.
- Page: Go 내부에서 사용하는 메모리 페이지 단위는 8KB 입니다. (OS의 일반적인 4KB 페이지와 다릅니다.)
- Span: 하나 이상의 연속된 페이지를 묶은 단위입니다. 여기가 핵심입니다. 각 Span은 특정 크기의 객체들만 담습니다.

위 그림처럼 계층화된 구조 덕분에 단편화(Fragmentation)를 줄일 수 있습니다. 예를 들어 32바이트 객체가 필요하면, 32바이트 전용 Span에서 빈 슬롯 하나를 쏙 빼주기만 하면 됩니다. 복잡한 계산 없이 비트맵(bitmap) 확인만으로 할당이 끝나죠.
락(Lock) 전쟁을 피하는 기술: 3단계 캐싱
제가 이 글에서 가장 강조하고 싶은 부분은 바로 Locking Problem 을 해결하는 방식입니다. 수천 개의 고루틴이 동시에 메모리를 달라고 아우성칠 때, 글로벌 락(Global Lock) 하나로 통제한다면 그게 바로 병목입니다.
Go는 이를 3단계 계층으로 우아하게 풀어냅니다.

1. mcache (Per-P, Lock-Free)
Go 스케줄러의 각 P(Processor)는 자신만의 로컬 캐시인 mcache를 가집니다. 여기서 메모리를 할당받을 때는 락이 전혀 필요 없습니다. P는 한 번에 하나의 고루틴만 실행하니까요. 대부분의 작은 객체 할당은 여기서 끝납니다. 이 설계가 Go의 높은 동시성 성능의 비결 중 하나입니다.
2. mcentral (Per-SpanClass, Partial Lock)
mcache가 비어있다면? mcentral로 갑니다. 여기는 모든 P가 공유하므로 락이 필요합니다. 하지만 영리하게도 SpanClass별로 mcentral이 따로 존재합니다. 즉, 32바이트 객체를 찾는 고루틴과 64바이트 객체를 찾는 고루틴은 서로 락을 두고 경쟁하지 않습니다. 락의 범위를 쪼개서 경합(Contention)을 최소화한 것이죠.
3. mheap (Global, Heavy Lock)
여기까지 없다면 최후의 보루인 mheap으로 갑니다. 여기서부터는 진짜 비싼 작업입니다. OS에게 새로운 Arena를 요청하거나 대규모 페이지 관리를 수행합니다. 하지만 앞단에서 대부분 처리가 되므로 여기까지 오는 빈도는 낮습니다.
Tiny Allocator: 티끌 모아 태산
현업에서 bool이나 int8 같은 작은 변수들을 무수히 많이 생성할 때가 있습니다. Go는 16바이트 미만의 포인터 없는 객체들을 위해 Tiny Allocator 를 별도로 운영합니다. 1바이트짜리 bool 하나 때문에 8바이트 슬롯을 쓰는 건 낭비니까요. 이런 작은 값들을 16바이트 블록 하나에 오밀조밀하게 채워 넣습니다. 이런 디테일이 모여 전체 메모리 효율을 만듭니다.
Hacker News의 반응과 나의 생각
이 주제에 대해 Hacker News에서도 흥미로운 토론이 있었습니다.
한 유저는 “이건 그냥 잡학(trivia) 아니냐? 실제 애플리케이션 개발에 무슨 도움이 되나?”라고 냉소적인 반응을 보였습니다. 저는 이 의견에 강하게 반대 합니다.
우리가 작성하는 코드의 성능 특성을 이해하려면 아래 내용들을 알아야 합니다.
- Zero-Copy 네트워킹: 왜 Go의
io.Copy가 빠른지 이해하려면 메모리 구조를 알아야 합니다. - GC 튜닝:
GOGC파라미터를 조절하거나 메모리 누수를 잡을 때,mcache와mheap사이의 흐름을 모르면 장님 코끼리 만지기 식의 튜닝이 됩니다. - False Sharing: CPU 캐시 라인과 Go의 메모리 정렬 방식을 이해해야 극강의 성능 최적화가 가능합니다.
다른 유저가 지적했듯, Go의 할당자는 리눅스의 hugepage를 인식하여 TLB 미스를 줄이는 등 OS 레벨의 최적화도 포함하고 있습니다. 단순히 “알아서 된다”고 믿는 것과 “어떻게 된다”를 아는 것은 장애 상황에서 대응 속도를 가릅니다.
결론: 추상화 뒤에 숨지 마라
Go의 메모리 할당자는 “빠른 경로(Fast Path)는 락 없이, 느린 경로(Slow Path)는 정교한 락으로” 라는 시스템 설계의 정석을 보여줍니다.
시니어 엔지니어라면 make() 한 줄을 적을 때도, 이것이 mcache에서 끝날지, mheap까지 가서 글로벌 락을 잡을지, 혹은 이 객체가 스택에 남을지 힙으로 탈출(Escape)할지 직관적으로 느껴져야 합니다.
Go는 쉬운 언어입니다. 하지만 그 쉬움은 런타임 엔지니어들의 피와 땀으로 만들어진 복잡한 추상화 위에 서 있습니다. 그 추상화를 걷어내고 내부를 들여다보는 순간, 여러분의 엔지니어링 레벨은 한 단계 도약할 것입니다.
References: