본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Heap: 런타임에 크기가 결정되는 동적 메모리 영역

Heap은 프로세스 메모리 레이아웃에서 프로그램 실행 중 동적으로 할당되고 해제되는 메모리 영역이다. 스택(Stack)과 달리 크기 제한이 느슨하고 함수 호출과 무관하게 생존하지만, 관리 책임이 개발자나 가비지 컬렉터(GC)에게 넘어간다는 차이가 있다. 이 문서는 자료구조로서의 힙(Heap 트리)이 아니라 운영체제/런타임 관점의 메모리 영역을 다룬다.

송민성6분 읽기

1. 개념

프로세스가 실행되면 운영체제는 가상 메모리 공간에 여러 영역을 나눠 할당한다. 대표적으로 코드(Text), 데이터(Data), 힙(Heap), 스택(Stack) 영역이 있다.

  • 스택(Stack): 함수 호출 시 자동으로 할당/해제되는 지역 변수, 매개변수 저장 공간. 크기가 고정적이고 생존 범위가 함수 스코프에 묶인다.
  • 힙(Heap): 프로그래머(또는 런타임)가 명시적으로 요청해서 할당받는 메모리. malloc, new, JavaScript 객체 생성 등이 여기 해당한다. 함수가 끝나도 메모리가 유지되며, 명시적으로 해제하거나 GC가 회수할 때까지 살아있다.

메모리 주소 관점에서 힙은 보통 낮은 주소에서 높은 주소로 증가하고, 스택은 반대로 높은 주소에서 낮은 주소로 증가한다. 두 영역이 서로를 향해 자라다가 충돌하면 메모리 부족(OOM) 또는 스택 오버플로우(Stack Overflow)가 발생한다.

2. 왜 사용하는가

스택만으로는 다음 문제를 해결할 수 없다.

  1. 컴파일 타임에 크기를 모르는 데이터: 사용자 입력에 따라 배열 크기가 달라지는 경우, 스택에 고정 크기로 잡을 수 없다.
  2. 함수 스코프를 벗어나는 생존 기간: 함수가 반환된 뒤에도 데이터를 참조해야 하는 경우(예: 링크드 리스트 노드, 캐시 객체).
  3. 큰 데이터 구조: 스택은 보통 몇 MB 수준으로 제한되어 있어 대용량 데이터를 담기 부적합하다.

힙은 이런 요구를 만족시키기 위해 존재하며, 대신 할당/해제 비용과 메모리 단편화라는 대가를 치른다.

3. 동작 원리

할당 요청 흐름 (Linux 기준)

프로세스가 malloc을 호출하면 C 표준 라이브러리(glibc)의 메모리 할당자가 다음을 수행한다.

  1. 이미 확보해둔 힙 영역에 여유 공간이 있으면 그 안에서 블록을 잘라준다 (free list 탐색).
  2. 여유 공간이 부족하면 brk/sbrk 시스템 콜로 힙의 끝(program break)을 늘리거나, 큰 할당은 mmap 시스템 콜로 별도의 메모리 매핑 영역을 만든다.
  3. 해제(free) 시 해당 블록을 다시 free list에 등록한다. 운영체제에 즉시 반환하지 않고 재사용을 위해 보유하는 경우가 많다.

단편화(Fragmentation)

할당과 해제가 반복되면 메모리 블록 사이에 작은 빈 공간들이 흩어지는 외부 단편화(external fragmentation)가 발생한다. 이를 줄이기 위해 first-fit, best-fit, buddy system 같은 할당 전략이 쓰인다.

GC 환경(V8, JVM 등)

Node.js/브라우저의 V8 엔진이나 JVM은 힙을 개발자가 직접 해제하지 않고 가비지 컬렉터가 도달 불가능(unreachable)한 객체를 자동으로 회수한다. V8은 힙을 New Space(단기 생존 객체)와 Old Space(장기 생존 객체)로 나누어 세대별 GC(Generational GC)를 수행한다.

4. 코드 예제

TypeScript(Node.js) — V8 힙 사용량 관찰

typescript
// heap-demo.ts function allocateObjects(count: number): object[] { const arr: object[] = []; for (let i = 0; i < count; i++) { arr.push({ id: i, payload: new Array(1000).fill(i) }); } return arr; } function printMemory(label: string): void { const usage = process.memoryUsage(); console.log( `[${label}] heapUsed: ${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB, ` + `heapTotal: ${(usage.heapTotal / 1024 / 1024).toFixed(2)} MB` ); } printMemory('시작'); let data: object[] | null = allocateObjects(100_000); printMemory('할당 후'); data = null; // 참조 해제, GC 대상이 됨 if (global.gc) { global.gc(); // node --expose-gc heap-demo.js 로 실행해야 동작 } printMemory('참조 해제 후');
shell
# --expose-gc 플래그로 강제 GC 트리거 가능하게 실행 node --expose-gc heap-demo.js

Python — tracemalloc으로 힙 할당 추적

python
import tracemalloc def allocate_large_list(): return [i * 2 for i in range(1_000_000)] tracemalloc.start() snapshot_before = tracemalloc.take_snapshot() big_list = allocate_large_list() snapshot_after = tracemalloc.take_snapshot() stats = snapshot_after.compare_to(snapshot_before, "lineno") for stat in stats[:3]: print(stat) tracemalloc.stop()

Shell — 실행 중인 프로세스의 힙 영역 확인 (Linux)

shell
# 프로세스 PID의 메모리 맵에서 heap 영역 확인 pid=$(pgrep -n node) grep -A1 "heap" /proc/$pid/maps # 요약된 메모리 사용량 확인 cat /proc/$pid/status | grep -E "VmData|VmRSS"

5. 시간 복잡도 또는 성능 특성

  • 할당(`malloc`/`new`): 일반적인 구현에서 free list에 적합한 블록이 있으면 상수 시간에 가깝지만, 최악의 경우(적합한 블록을 찾기 위해 리스트를 순회해야 할 때) O(n)이 될 수 있다. 정확한 시간은 할당자 구현(ptmalloc, jemalloc, tcmalloc 등)에 따라 다르다.
  • 해제(`free`): 인접 블록과의 병합(coalescing) 작업이 포함되면 상수 시간을 넘어설 수 있다.
  • 스택 할당과 비교: 스택은 포인터(stack pointer) 이동만으로 할당이 끝나 힙보다 훨씬 빠르다. 다만 정확한 배수 차이는 하드웨어와 할당자 구현에 따라 달라 일반화된 수치로 제시하기 어렵다.
  • GC 환경의 힙: GC의 전체 정지(Stop-The-World) 시간은 힙 크기와 살아있는 객체 수에 비례해서 늘어나는 경향이 있으며, 세대별 GC는 Young Generation 수집을 짧게, Old Generation 수집을 상대적으로 길게 가져가는 방식으로 이 비용을 분산시킨다.

6. 실무 사용 사례

  • Node.js 메모리 제한 조정: --max-old-space-size 플래그로 V8 Old Space 힙 크기 상한을 조정해 대용량 배치 작업에서 OOM을 방지한다.
shell
node --max-old-space-size=4096 batch-job.js
  • 메모리 누수 디버깅: 힙 스냅샷(heap snapshot)을 Chrome DevTools나 --inspect로 떠서 Detached DOM, 클로저에 잡힌 대형 객체 등을 추적한다.
  • 컨테이너 메모리 제한과의 상호작용: Kubernetes/Docker의 메모리 limit이 V8 힙 상한보다 작으면 V8이 여유가 있다고 판단해도 컨테이너가 먼저 OOMKilled 될 수 있어, 컨테이너 limit과 런타임 힙 설정을 함께 맞춰야 한다.

7. 주의할 점

  • 메모리 누수(Memory Leak): 참조를 계속 들고 있는 클로저, 전역 캐시, 이벤트 리스너 미해제 등으로 힙이 계속 늘어나는 문제. GC가 있어도 참조가 살아있으면 회수되지 않는다.
  • 댕글링 포인터/Use-After-Free: C/C++처럼 수동 메모리 관리 언어에서 free한 메모리를 다시 참조하면 정의되지 않은 동작(Undefined Behavior)이 발생한다.
  • 스택과 힙의 혼동: 자바스크립트에서 원시 타입(number, string 등 작은 값)은 스택에 가깝게 처리되고 객체/배열은 힙에 할당된다는 식으로 단순화해서 이해하는 경우가 많은데, 실제 V8 내부 구현은 이보다 복잡하므로 "무조건 객체는 힙, 원시값은 스택"이라고 단정하는 것은 부정확하다.
  • 단편화로 인한 실질 메모리 낭비: free로 반환해도 운영체제에 즉시 돌려주지 않아 pstop에서 보이는 RSS(Resident Set Size)가 실제 사용량보다 커 보일 수 있다.

8. 핵심 정리

Heap은 런타임에 크기가 결정되는 데이터를 담기 위한 동적 메모리 영역으로, 스택보다 유연하지만 할당/해제 비용과 단편화, 누수 위험을 동반한다. C/C++에서는 개발자가 직접 malloc/free로 관리하고, Node.js나 JVM 같은 GC 언어에서는 런타임이 도달 불가능한 객체를 자동으로 회수한다. 실무에서는 힙 크기 설정, 메모리 누수 탐지, 컨테이너 메모리 제한과의 정합성을 함께 고려해야 한다.

© 2026 Tyler Song