본문으로 건너뛰기
yutils

메모리 할당은 어떻게 동작할까?

stack vs heap, malloc/free 의 내부, fragmentation 이 왜 중요한가, RAII vs GC, 모든 언어의 'new Object()' 뒤에 있는 실제 메커니즘 — 코드가 실제로 지불하는 cost model 로 설명.

약 9분 읽기

모든 프로그램은 메모리를 쓴다. 그러나 let x = 42 가 어디에 박히는지, new Object() 가 얼마를 지불하는지 대부분 모른 채 작성한다. 이 가이드는 stack vs heap 의 실제 메커니즘, malloc/free 가 OS 와 어떻게 대화하는지, 왜 fragmentation 이 long-running 서버를 죽이는지, RAII (C++/Rust) 와 GC (Java/Go/JS) 의 trade-off 를 정리한다.

Stack vs Heap — 두 메모리 영역

프로세스 가상 주소 공간 (Linux x86-64):

    0xFFFFFFFFFFFFFFFF
    ┌─────────────────┐
    │   kernel space  │  (커널 전용, 사용자 X)
    ├─────────────────┤
    │   stack (↓)     │  ← rsp (현재 함수 frame)
    │                 │
    │      ...        │  ← 자유 공간
    │                 │
    │   heap (↑)      │  ← brk pointer
    ├─────────────────┤
    │   bss + data    │  (전역 변수)
    ├─────────────────┤
    │   text (코드)    │
    └─────────────────┘
    0x0000000000000000

Stack 은 위에서 아래로 자라고, heap 은 아래에서 위로. 둘이 만나면 stack overflow / out-of-memory.

Stack — 빠르고 자동 정리

void foo() {
  int x = 42;          // stack
  char buf[1024];      // stack
  Point p = {1, 2};    // stack
}  // 함수 끝 — rsp 복원, 모든 변수 사라짐 (자동)
  • 할당 비용 0 — rsp register 를 sub 명령으로 내리기만 하면 끝. 한 cycle.
  • 해제 자동 — 함수 return 시 rsp 복원 = 모든 local 변수 사라짐. free() 호출 X.
  • 크기 제한 — Linux default 8 MB. 큰 array 박으면 stack overflow.
  • scope 한정 — 함수 return 후엔 그 stack frame 못 씀 (return value 로 reference 넘기면 UB).

Heap — 동적 크기·수명

// C
void* ptr = malloc(1024);    // heap 에 1024 byte 할당
free(ptr);                    // 명시적 해제

// C++
auto* p = new Point(1, 2);   // heap
delete p;                     // 명시적 해제

// Rust — RAII
let s = String::from("hi");  // heap (String 의 내부 buffer)
// scope 끝나면 Drop trait 자동 호출 → 해제

// Go / Java / JS — GC
let obj = {x: 1};            // heap
// 사용 끝났는지 runtime 이 추적 → GC 가 해제

Heap 은 OS 의 page (4 KB unit) 단위로 받아온 영역을 사용자 라이브러리가 잘게 나눠준다. 그 라이브러리가 malloc 의 구현체 — glibc 의 ptmalloc, Google 의 tcmalloc, Facebook 의 jemalloc 등.

malloc 의 내부 — free list

malloc(1024) 호출 시:

1. allocator 내부 "free list" 검색 — 1024 byte 이상 free chunk 있나?
2. 있으면 → 그 chunk 잘라서 반환 (수십 ns)
3. 없으면 → OS 에 sbrk() 또는 mmap() 으로 새 page 요청 → free list 추가 → 반환

free(ptr) 호출 시:
1. ptr 의 chunk metadata (size 등) 확인
2. 인접 free chunk 와 merge (coalesce) — fragmentation 방지
3. free list 에 추가

→ malloc/free 는 시스템콜 X (대부분). 단순 user-space 라이브러리 작업.

Chunk metadata 는 보통 chunk 앞 16 byte. malloc(1024) 호출해도 실제로 1040 byte 사용 — overhead.

Fragmentation — 메모리 누수 아닌데 메모리 부족

초기 heap (free):
[████████████████████████████████]  32 MB free

malloc 패턴:
- 1 KB 할당, 1 KB 할당, 1 KB 할당, ...
- 짝수 번째만 해제

결과:
[██░░██░░██░░██░░██░░██░░██░░██░░]  16 MB free (50%)
    ↑   ↑   ↑   ↑   ↑   ↑
    1KB 구멍들

이제 malloc(2 KB) 요청:
→ 16 MB free 인데 2 KB 연속 공간 없음 → 실패 또는 mmap 으로 새 page

External fragmentation — 전체 free 는 충분한데 연속 공간 부족. 장수 서버에서 메모리 사용량이 계속 늘어 보이는 주요 원인 (실제로 누수 X, fragmentation).

대응: jemalloc (size class 별 분리), arena 별 할당 (thread-local), 주기적 restart.

알아두면 좋은 비용

연산대략 비용
stack 변수 할당~ 1 ns (rsp sub)
malloc (hot, free list hit)~ 20-50 ns
malloc (new page, mmap)~ 1-10 μs (시스템콜)
free~ 20-50 ns
GC minor (young gen)~ 1-10 ms (pause)
GC major (full collection)~ 100 ms - 수 초 (pause)

RAII vs GC — 해제 책임의 두 갈래

RAII (C++, Rust)

{
  let s = String::from("hello");  // heap 할당
  let v = vec![1, 2, 3];          // heap 할당
}  // scope 끝
   // Drop trait 자동 호출 → 컴파일 타임에 박힌 free 호출
  • 해제 시점 = scope 끝. 결정적·예측 가능.
  • GC pause 없음.
  • cost = 단순 free 호출 (수십 ns).
  • 그러나 ownership / borrow checker 의 정신적 비용 (Rust)

GC (Java, Go, JS, Python)

let obj = {x: 1};
// runtime 이 reachability 추적 → 어느 시점에 unreachable 판정 → 해제
  • 해제 시점 = GC 가 정함. 비결정적.
  • 주기적 pause (mark-and-sweep, generational).
  • 프로그래머는 free 잊을 수 없음 — 안전.
  • throughput vs latency trade-off (GC 알고리즘 선택).

GC 의 구체 동작은 how-garbage-collection-works 가이드 참조.

실용 패턴 — 빠른 코드를 위한 메모리 의식

1. Stack 우선

가능하면 heap 대신 stack. 작은 array 는 fixed size 로:

// 느림 (heap)
let v = Vec::with_capacity(8);   // malloc

// 빠름 (stack, smallvec 또는 array)
let arr: [i32; 8] = [0; 8];

2. Pre-allocate

// Java — append 마다 ArrayList 가 grow → 여러 번 malloc + copy
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) list.add(i);

// 좋음 — capacity 미리 박음
List<Integer> list = new ArrayList<>(1_000_000);

3. Object pool

Hot path 에서 같은 object 를 반복 할당·해제하면 pool 로 재사용. Go 의 sync.Pool, Netty 의 PooledByteBuf.

4. Arena allocator

Request 단위로 메모리 할당 → request 끝나면 arena 통째로 해제. 개별 free 호출 0. Rust 의 bumpalo, Nginx 의 ngx_pool_t.

흔한 함정

  • Use after free — 해제된 pointer 사용. C/C++ 의 UB. heap-buffer-overflow detector (AddressSanitizer) 로 잡음.
  • Double free — 같은 pointer 두 번 free. crash 또는 silent corruption.
  • Memory leak — free 안 함. 장수 서버에서 누적.
  • Stack overflow — 큰 array 를 stack 에 박거나, 깊은 재귀.
  • Fragmentation — 위 참조. 누수처럼 보이지만 누수 아님.

마무리

메모리 할당의 cost 는 작지 않다. malloc 50 ns 가 hot path 에서 반복되면 누적된다. stack 우선 / pre-allocate / arena / pool — 네 패턴이 대부분의 hot path 를 커버한다.

그리고 GC 언어를 쓴다고 메모리 의식이 불필요하지 않다 — allocation 패턴이 GC pause 의 빈도·길이를 결정한다. how-garbage-collection-works 에서 그 메커니즘 정리.

가이드 목록으로