- 들어가며 — 변수는 어디에 사는가
- 프로세스의 메모리 지도
- 스택 — 프레임, LIFO, 그리고 속도
- 힙 — 동적이고 자유로운 공간
- 포인터와 참조 — 두 세계를 잇는 다리
- 값 의미론 vs 참조 의미론
- 스택 오버플로 vs OOM — 두 가지 죽음
- 재귀 깊이가 중요한 이유
- 힙의 정리 — 소유권, GC, 수동 관리
- 실무에서 마주치는 함정들
- 마치며
- 참고 자료
들어가며 — 변수는 어디에 사는가
프로그래밍을 배우면 어느 순간 "이건 스택에 있고 저건 힙에 있다"는 말을 듣게 됩니다. 처음에는 그냥 넘기지만, 이상하게 자꾸 발목을 잡습니다. 왜 어떤 값은 함수가 끝나면 사라지고, 어떤 값은 남아 있을까. 왜 재귀를 깊게 하면 프로그램이 죽을까. C에서 malloc한 것을 왜 free해야 하고, 자바스크립트에서는 왜 안 해도 될까. 파이썬에서 리스트를 복사했는데 왜 원본까지 바뀔까.
이 모든 질문의 뿌리에 스택과 힙이 있습니다. 이 둘은 언어를 가리지 않고 프로그램이 메모리를 다루는 두 가지 근본 방식입니다. 이름은 자료구조에서 왔지만, 여기서는 "값이 어디에 저장되고 언제 사라지는가"라는 실행 시간의 이야기입니다. 이 글은 그 진실을 밑바닥부터 파헤칩니다.
프로세스의 메모리 지도
프로그램이 실행되면 운영체제는 그 프로세스에 가상 주소 공간을 줍니다. 이 공간은 여러 구역으로 나뉘어 있습니다.
높은 주소
+---------------------------+
| 스택 | <- 아래로 자란다
| | |
| v |
| |
| ^ |
| | |
| 힙 | <- 위로 자란다
+---------------------------+
| BSS (초기화 안 된 전역) |
+---------------------------+
| Data (초기화된 전역) |
+---------------------------+
| Text (코드) |
+---------------------------+
낮은 주소
여기서 흥미로운 점은 스택과 힙이 같은 주소 공간의 양 끝에서 서로를 향해 자란다는 것입니다. 스택은 높은 주소에서 낮은 주소로, 힙은 낮은 주소에서 높은 주소로 커집니다. 이렇게 배치하면 둘이 각자의 방향으로 자라다가, 정말로 메모리가 부족해지는 극단에서만 충돌합니다. 이 글의 주인공은 이 두 구역, 스택과 힙입니다.
스택 — 프레임, LIFO, 그리고 속도
스택은 함수 호출을 관리하는 메모리입니다. 함수를 하나 호출할 때마다 스택에는 스택 프레임(stack frame)이 하나 쌓입니다. 이 프레임 안에는 그 함수의 지역 변수, 매개변수, 그리고 돌아갈 주소(return address)가 들어갑니다.
핵심은 이 쌓임과 걷힘이 철저히 후입선출(LIFO, Last In First Out)이라는 점입니다. 가장 마지막에 호출된 함수가 가장 먼저 끝나고, 그 프레임이 가장 먼저 걷힙니다. 접시를 쌓았다가 위에서부터 걷어내는 것과 똑같습니다.
함수 호출 흐름: main() -> a() -> b()
스택 (위로 쌓임):
+-----------+
| b() | <- 지금 실행 중 (맨 위)
+-----------+
| a() |
+-----------+
| main() |
+-----------+
b()가 끝나면 b 프레임만 걷히고 a()로 돌아간다
이 구조가 스택을 엄청나게 빠르게 만듭니다. 메모리를 할당하고 해제하는 일이 사실상 포인터 하나를 옮기는 것뿐이기 때문입니다. CPU 안에는 스택의 꼭대기를 가리키는 스택 포인터(stack pointer) 레지스터가 있습니다. 프레임을 쌓을 때는 이 포인터를 그만큼 내리고(스택은 아래로 자라니까), 걷을 때는 다시 올립니다. 복잡한 관리 로직도, 빈 공간 탐색도 없습니다. 단지 산술 연산 하나입니다.
void b() {
int y = 20; // b의 프레임에 저장
}
void a() {
int x = 10; // a의 프레임에 저장
b(); // b의 프레임이 위에 쌓임
// b()가 반환되면 y는 즉시 사라진다
}
스택의 또 다른 특징은 크기가 고정되어 있고 대체로 작다는 것입니다. 운영체제는 스레드마다 스택에 정해진 크기(흔히 1~8MB)를 미리 배정합니다. 이 한계가 뒤에서 이야기할 스택 오버플로의 원인이 됩니다. 정리하면 스택은 빠르고, 자동으로 관리되며, 수명이 함수의 생애와 정확히 일치하고, 크기가 제한적입니다.
힙 — 동적이고 자유로운 공간
스택이 함수의 생애에 묶여 있다면, 힙은 그 속박에서 벗어난 공간입니다. 힙에 있는 값은 함수가 끝나도 살아남을 수 있고, 프로그램이 실행 중에 원하는 만큼 크기를 정해 할당할 수 있습니다. 대신 이 자유에는 대가가 따릅니다.
힙에서 메모리를 얻으려면 명시적으로 요청해야 합니다. C에서는 malloc이 그 역할을 합니다.
#include <stdlib.h>
int *make_array(int n) {
// 힙에 정수 n개 공간을 요청
int *arr = malloc(n * sizeof(int));
for (int i = 0; i < n; i++) {
arr[i] = i * i;
}
return arr; // 함수가 끝나도 이 메모리는 살아있다
}
여기서 스택과 결정적으로 다른 점이 드러납니다. make_array가 반환되면 지역 변수 arr(포인터 자체)은 스택에서 사라집니다. 하지만 arr이 가리키던 힙의 배열은 그대로 남습니다. 그래서 호출한 쪽이 그 주소를 받아 계속 쓸 수 있습니다.
힙 할당이 스택보다 느린 이유도 여기 있습니다. 힙 관리자(allocator)는 "요청한 크기에 맞는 빈 공간이 어디 있는가"를 찾아야 합니다. 메모리를 쓰고 반납하는 일이 반복되면 힙은 여기저기 구멍 난 치즈처럼 조각(fragment)나고, 관리자는 자유 목록(free list)이나 크기별 구획 같은 자료구조를 뒤져 적당한 자리를 골라야 합니다. 이 탐색과 부기(bookkeeping)가 스택의 단순한 포인터 이동과는 비교할 수 없는 비용입니다.
스택 할당: 스택 포인터를 옮긴다 (연산 1회) → 매우 빠름
힙 할당: 빈 공간 탐색 + 메타데이터 갱신 → 상대적으로 느림
정리하면 힙은 유연하고(크기와 수명이 자유롭다), 값을 함수 경계 너머로 공유할 수 있게 하지만, 할당이 느리고, 조각화가 생기며, 언젠가 반드시 정리되어야 합니다. 그 "정리"를 누가 하느냐가 언어를 가르는 큰 갈림길인데, 이는 뒤에서 다룹니다.
포인터와 참조 — 두 세계를 잇는 다리
스택과 힙은 어떻게 연결될까요. 답은 포인터(pointer) 혹은 참조(reference)입니다. 힙에 있는 값은 이름이 없습니다. malloc은 그냥 주소를 돌려줄 뿐입니다. 그 주소를 담아 두는 변수, 보통 스택에 있는 그 변수가 바로 포인터입니다.
스택 힙
+-----------+ +---------------------+
| ptr | --------> | 실제 데이터 [42] |
| (주소값) | | (malloc으로 할당) |
+-----------+ +---------------------+
이 변수는 이 데이터는
스택에 산다 힙에 산다
즉 전형적인 구도는 이렇습니다. 포인터라는 작은 값(보통 8바이트 주소)은 스택 프레임 안에 있고, 그것이 가리키는 큰 데이터는 힙에 있습니다. 스택의 작은 손잡이로 힙의 큰 상자를 붙잡고 있는 셈입니다.
이 개념은 언어마다 표현만 다를 뿐 어디에나 있습니다. C의 포인터, C++의 참조와 스마트 포인터, 자바의 객체 참조, 파이썬의 모든 변수, 자바스크립트의 객체. 이들은 하나같이 "실제 데이터는 힙 어딘가에 있고, 변수는 그 위치를 가리킨다"는 같은 뼈대를 공유합니다. 고수준 언어는 이 사실을 감출 뿐 없애지는 못합니다.
포인터를 이해하면 오래 골치 아팠던 현상들이 한 번에 설명됩니다. 두 변수가 같은 힙 객체를 가리키면, 한쪽으로 객체를 바꾸면 다른 쪽에서도 바뀐 것이 보입니다. 데이터가 복사된 게 아니라 주소만 복사되었기 때문입니다. 이 지점이 다음에 이야기할 값 의미론과 참조 의미론의 핵심입니다.
값 의미론 vs 참조 의미론
변수를 다른 변수에 대입하거나 함수에 넘길 때, 무엇이 복사되는가. 이 질문의 답이 값 의미론(value semantics)과 참조 의미론(reference semantics)을 가릅니다.
값 의미론에서는 값 자체가 복사됩니다. 복사본은 원본과 완전히 독립적이어서, 하나를 바꿔도 다른 하나는 그대로입니다. 정수나 실수 같은 원시 타입은 대개 값 의미론을 따르고, 이들은 보통 스택에 그대로 담깁니다.
참조 의미론에서는 값이 아니라 참조(주소)가 복사됩니다. 그래서 복사본과 원본이 같은 힙 객체를 가리키게 되고, 한쪽의 변경이 다른 쪽에 보입니다.
# 파이썬: 정수는 값처럼, 리스트는 참조처럼 동작한다
a = 5
b = a
b += 1
print(a, b) # 5 6 — a는 그대로. 서로 독립적
x = [1, 2, 3]
y = x # 참조를 복사 (같은 리스트를 가리킴)
y.append(4)
print(x) # [1, 2, 3, 4] — x도 바뀌었다!
이 차이는 언어마다 규칙이 달라서 흔한 버그의 원천입니다. 자바는 원시 타입(int, double 등)은 값으로, 객체는 참조로 전달합니다. 자바스크립트도 숫자·불리언은 값으로, 객체·배열은 참조로 다룹니다. C++은 기본이 값 복사이지만 참조(&)와 포인터(*)로 명시적으로 참조 의미론을 선택할 수 있습니다.
실무에서 이 개념을 놓치면 "분명 복사했는데 원본이 바뀌었다"는 당혹스러운 상황을 만납니다. 해결책은 의도를 분명히 하는 것입니다. 진짜 독립된 사본이 필요하면 깊은 복사(deep copy)를 명시적으로 하고, 공유가 의도라면 참조를 그대로 둡니다. 어느 쪽인지 아는 것이 핵심입니다.
스택 오버플로 vs OOM — 두 가지 죽음
스택과 힙은 부족해지는 방식도 다르고, 그 결과 프로그램이 죽는 방식도 다릅니다.
스택 오버플로(stack overflow)는 스택 공간을 다 써버렸을 때 일어납니다. 스택은 크기가 고정되어 있으므로(보통 몇 MB), 프레임을 너무 많이 쌓으면 한계를 넘습니다. 가장 흔한 원인은 끝나지 않는 재귀입니다.
def recurse(n):
return recurse(n + 1) # 종료 조건이 없다
recurse(0)
# RecursionError: maximum recursion depth exceeded (파이썬)
# 다른 언어에서는 segmentation fault로 죽기도 한다
함수를 호출할 때마다 프레임이 쌓이는데 걷히지를 않으니, 스택이 한계에 부딪힙니다. C 같은 저수준 언어에서는 이것이 바로 그 악명 높은 "stack overflow"이자 세그멘테이션 오류의 흔한 원인입니다. 파이썬은 실제 스택이 넘치기 전에 스스로 재귀 깊이를 세어 RecursionError를 내주는 안전장치를 둡니다.
OOM(Out Of Memory)은 힙이 부족할 때 일어납니다. 계속 힙에 할당만 하고 반납하지 않으면(메모리 누수), 혹은 정말로 감당할 수 없이 큰 데이터를 요청하면 힙이 바닥납니다.
스택 오버플로:
원인 - 너무 깊은 호출/재귀로 프레임 과다
한계 - 스레드 스택 크기 (보통 1~8MB)
증상 - 즉각적인 크래시, RecursionError, segfault
OOM (메모리 부족):
원인 - 힙 할당 누적, 메모리 누수, 거대한 데이터
한계 - 가용 물리/가상 메모리 (수 GB 이상)
증상 - 할당 실패, OOM Killer, 서서히 느려지다 죽음
두 죽음의 성격은 다릅니다. 스택 오버플로는 보통 순식간에 결정적으로 터지고, 재현하기도 쉬워서 원인(대개 재귀)이 명확합니다. OOM은 종종 서서히 다가옵니다. 메모리 누수가 몇 시간에 걸쳐 쌓이다가 어느 순간 시스템이 스와핑으로 기어가거나 운영체제의 OOM Killer가 프로세스를 강제 종료합니다. 그래서 OOM은 진단이 더 까다롭고, 프로파일러와 힙 덤프 같은 도구가 필요합니다.
재귀 깊이가 중요한 이유
재귀는 우아하지만 스택의 한계와 정면으로 부딪히는 기법입니다. 재귀 호출 한 번마다 스택 프레임이 하나씩 쌓이므로, 재귀의 깊이가 곧 스택 사용량입니다. 깊이가 스택 한계를 넘으면 프로그램이 죽습니다.
# 이 재귀는 리스트 길이만큼 깊어진다
def sum_list(items):
if not items:
return 0
return items[0] + sum_list(items[1:]) # 깊이 = len(items)
sum_list(list(range(100000))) # 스택이 터진다
리스트가 10만 개면 재귀 깊이도 10만이 되고, 이는 대부분 언어의 기본 스택 한계를 훌쩍 넘깁니다. 해법은 두 가지 방향입니다.
첫째, 반복(iteration)으로 바꾸는 것입니다. 반복문은 프레임을 쌓지 않고 하나의 프레임 안에서 돌므로 스택을 소모하지 않습니다. 위 함수는 단순한 for 루프로 바꾸면 아무리 긴 리스트도 안전합니다.
둘째, 꼬리 호출 최적화(tail call optimization, TCO)입니다. 재귀 호출이 함수의 맨 마지막 동작이면(반환값에 다른 연산이 얹히지 않으면), 컴파일러가 새 프레임을 쌓는 대신 현재 프레임을 재사용할 수 있습니다. 이러면 재귀가 사실상 반복처럼 동작해 스택이 늘지 않습니다.
일반 재귀: 각 호출이 프레임을 쌓는다 → 깊이만큼 스택 소모
꼬리 재귀: 마지막 호출이 프레임을 재사용 → 스택 일정 (TCO 지원 시)
반복: 프레임을 쌓지 않는다 → 스택 항상 일정
주의할 점은 TCO를 모든 언어가 지원하지는 않는다는 것입니다. 스킴이나 일부 함수형 언어는 표준으로 보장하지만, 파이썬은 의도적으로 지원하지 않고(스택 트레이스 가독성을 위해), 자바나 다수의 주류 언어도 기본적으로는 하지 않습니다. 그래서 "이 언어가 TCO를 하는가"를 확인하지 않고 깊은 꼬리 재귀에 의존하면 위험합니다. 안전한 기본 태도는 "깊어질 수 있는 재귀는 반복으로 쓰거나, 명시적 스택 자료구조를 쓴다"입니다.
힙의 정리 — 소유권, GC, 수동 관리
힙에 할당한 메모리는 언젠가 반드시 반납되어야 합니다. 반납하지 않으면 누수가 쌓여 OOM으로 이어집니다. 이 "언제, 누가 반납하는가"의 문제를 언어들은 세 가지 방식으로 풉니다. 이 선택이 각 언어의 성격을 크게 좌우합니다.
1. 수동 관리 (manual, C 계열). 프로그래머가 직접 할당하고 직접 해제합니다. malloc으로 얻은 것은 반드시 free로 돌려줘야 합니다.
char *buf = malloc(1024);
// ... buf 사용 ...
free(buf); // 잊으면 누수, 두 번 하면 크래시
이 방식은 최대의 제어권과 성능을 줍니다. 언제 정확히 메모리가 반납되는지 프로그래머가 완벽히 통제합니다. 대신 실수의 여지가 큽니다. 해제를 잊으면 누수(leak), 이미 해제한 걸 또 쓰면 use-after-free, 두 번 해제하면 double-free. 이런 버그는 보안 취약점의 단골 원인이기도 합니다.
2. 가비지 컬렉션 (GC, 자바·파이썬·자바스크립트·Go). 런타임이 자동으로 "더 이상 아무도 참조하지 않는" 힙 객체를 찾아 회수합니다. 프로그래머는 해제를 신경 쓰지 않습니다.
let obj = { data: [1, 2, 3] };
obj = null; // 이제 아무도 그 객체를 안 가리킴
// GC가 언젠가 알아서 회수한다. free() 같은 건 없다
GC는 메모리 안전을 크게 높입니다. use-after-free나 double-free 같은 부류의 버그가 원천적으로 사라집니다. 대신 대가가 있습니다. GC가 도는 동안 잠깐 프로그램이 멈추거나(stop-the-world 일시정지) 느려질 수 있고, 언제 회수될지 정확한 시점을 프로그래머가 통제하기 어렵습니다. 또 GC 자체가 CPU와 메모리를 씁니다. 실시간성이 중요한 시스템에서 이 예측 불가능성이 문제가 되기도 합니다.
3. 소유권 (ownership, Rust). 러스트는 제3의 길을 택합니다. GC도 없고 수동 free도 없습니다. 대신 컴파일러가 소유권 규칙으로 각 값의 수명을 컴파일 시점에 추적하고, 소유자가 스코프를 벗어나는 순간 자동으로 메모리를 반납하도록 코드를 삽입합니다.
{
let s = String::from("hello"); // s가 힙 문자열의 소유자
// ... s 사용 ...
} // 여기서 s가 스코프를 벗어남 -> 힙 메모리 자동 반납 (drop)
핵심 규칙은 "한 값에는 한 명의 소유자만 있고, 소유자가 사라지면 값도 해제된다"입니다. 여기에 빌림(borrowing) 규칙이 더해져, 컴파일러가 use-after-free나 데이터 경쟁을 컴파일 시점에 잡아냅니다. 결과적으로 러스트는 런타임 GC 없이도 메모리 안전을 얻습니다. 대가는 학습 곡선입니다. 소유권과 빌림 규칙을 만족시키느라 컴파일러와 씨름하는 시간이 필요합니다.
세 방식을 한눈에 비교하면 이렇습니다.
| 항목 | 수동 (C) | GC (자바/파이썬/JS) | 소유권 (Rust) |
|---|---|---|---|
| 해제 시점 | 프로그래머가 명시 | 런타임이 나중에 | 스코프 종료 시 자동 |
| 성능 | 최고, 예측 가능 | GC 오버헤드/일시정지 | 최고에 가깝고 예측 가능 |
| 안전성 | 낮음 (누수, UAF) | 높음 | 높음 (컴파일 시점 보장) |
| 부담 | 수동 관리 실수 | 시점 통제 어려움 | 학습 곡선, 컴파일러 씨름 |
| 대표 언어 | C, C++(일부) | Java, Python, JS, Go | Rust |
정답은 없습니다. 최대 성능과 제어가 필요한 임베디드·시스템 코드는 수동이나 소유권을, 생산성과 안전이 중요한 애플리케이션은 GC를, 안전과 성능을 동시에 원하면 소유권을 택합니다. 무엇을 포기하고 무엇을 얻을지의 문제입니다.
실무에서 마주치는 함정들
지금까지의 개념이 실제 코드에서 어떻게 문제로 나타나는지 짚어봅니다.
댕글링 포인터와 use-after-free. 스택의 지역 변수 주소를 함수 밖으로 반환하면, 그 프레임은 이미 걷혔으므로 반환된 주소는 쓰레기를 가리킵니다. C에서 흔한 실수입니다. 힙에 할당해 반환하거나, 값을 복사해 반환해야 합니다.
메모리 누수는 GC 언어에도 있다. GC가 있다고 누수가 불가능한 것은 아닙니다. 어딘가에서 여전히 참조를 붙잡고 있으면 GC는 그것을 살아있다고 보고 회수하지 않습니다. 캐시에 객체를 넣고 안 비우거나, 이벤트 리스너를 등록만 하고 해제하지 않으면 참조가 계속 남아 누수가 됩니다.
큰 값을 값으로 복사하는 비용. 값 의미론은 안전하지만, 거대한 배열이나 구조체를 함수에 값으로 넘기면 통째로 복사되어 느려집니다. C++에서 큰 객체를 const 참조로 넘기는 이유가 이것입니다. 복사를 피하되 변경은 막는 것입니다.
스택에 큰 배열을 잡지 말 것. 스택은 작습니다. 수 MB짜리 배열을 지역 변수로 스택에 잡으면 그것만으로 스택이 넘칠 수 있습니다. 큰 데이터는 힙에 두는 것이 원칙입니다.
의도치 않은 공유. 참조 의미론 언어에서 객체를 넘기고는 "복사본이니 마음대로 바꿔도 되겠지" 하고 변경하면, 원본까지 바뀝니다. 공유인지 복사인지 늘 의식해야 합니다.
마치며
스택과 힙은 그냥 교과서의 두 단어가 아니라, 프로그램이 매 순간 값을 어디에 두고 언제 버릴지 결정하는 실제 메커니즘입니다. 스택은 함수의 생애에 묶인 빠르고 자동적이지만 작고 고정된 공간이고, 힙은 그 속박을 벗어난 유연하지만 느리고 스스로 정리되지 않는 공간입니다. 이 둘을 포인터가 잇고, 값·참조 의미론이 복사의 규칙을 정하며, 재귀 깊이가 스택의 한계를 시험하고, 소유권·GC·수동 관리가 힙의 정리를 책임집니다.
이 그림이 머릿속에 자리 잡으면, 처음에 던졌던 질문들이 더 이상 미스터리가 아닙니다. 함수가 끝나면 왜 지역 변수가 사라지는지, 재귀를 깊게 하면 왜 죽는지, 리스트를 "복사"했는데 왜 원본이 바뀌는지, 어떤 언어는 왜 free가 필요하고 어떤 언어는 필요 없는지. 모두 스택과 힙, 그리고 그 사이를 오가는 규칙의 이야기입니다. 메모리를 이해한다는 것은 결국 이 두 공간과 그 규칙을 이해하는 것입니다.
참고 자료
- Computer Systems: A Programmer's Perspective (CS:APP): https://csapp.cs.cmu.edu/
- The Rust Programming Language — Ownership: https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- What every programmer should know about memory (Ulrich Drepper): https://people.freebsd.org/~lstewart/articles/cpumemory.pdf
- Python — sys.setrecursionlimit: https://docs.python.org/3/library/sys.html#sys.setrecursionlimit
- Wikipedia — Call stack: https://en.wikipedia.org/wiki/Call_stack
현재 단락 (1/150)
프로그래밍을 배우면 어느 순간 "이건 스택에 있고 저건 힙에 있다"는 말을 듣게 됩니다. 처음에는 그냥 넘기지만, 이상하게 자꾸 발목을 잡습니다. 왜 어떤 값은 함수가 끝나면 사라...