C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
들어가며: 재귀 함수가 메시지 없이 죽는 이유
스택은 함수가 실행되는 동안만 쓰고 반환하면 자동으로 정리되는 메모리이고, 힙은 프로그램이 명시적으로 요청하고 반납하는 메모리입니다. 스택은 빠르지만 크기가 작게 제한되어 있고, 힙은 크지만 할당할 때마다 비용이 들고 해제를 책임져야 합니다. 이 차이를 가장 직접적으로 보여 주는 것이 재귀 함수의 스택 오버플로우입니다.
// g++ -std=c++17 -O0 -o fib fib.cpp && ./fib
#include <iostream>
int fibonacci(int n) {
volatile int cache[1000]; // 프레임마다 약 4KB (volatile: 최적화로 지워지지 않게)
cache[0] = n;
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
std::cout << fibonacci(100000); // 스택 오버플로우로 종료
}
이 프로그램은 대개 “Segmentation fault” 한 줄, 환경에 따라서는 아무 메시지 없이 종료됩니다. 여기서 중요한 것은 스택 사용량을 결정하는 것이 호출 횟수가 아니라 호출 깊이라는 점입니다. fibonacci(n - 1)이 끝까지 내려가는 동안 프레임이 n개 쌓이고, 반환하면 그 프레임은 다시 쓰입니다. 그래서 필요한 스택은 대략 “깊이 100,000 × 프레임 약 4KB = 약 400MB”이고, 리눅스 기본 8MB나 Windows 기본 1MB를 훨씬 넘습니다. 두 갈래 재귀로 호출 횟수가 기하급수적으로 늘어나는 것은 실행 시간의 문제이지 스택 크기의 문제가 아닙니다. 스택이 충분했다면 이 함수는 죽지 않는 대신 끝나지 않았을 것입니다.
배열을 volatile로 두고 값을 하나 쓴 이유는, 쓰이지 않는 지역 배열은 최적화 빌드에서 통째로 사라져 프레임이 작아지기 때문입니다. 실제 코드에서는 프레임 크기가 최적화 수준에 따라 달라진다는 점도 기억해 둘 만합니다.
C++에서는 “메모리가 어디에 잡히는지”를 모르면 재귀 깊이, 대용량 버퍼, 전역·지역 변수 선택에서 실수가 나오기 쉽습니다. 이 글은 스택·힙·전역 메모리의 특성과 할당자가 하는 일을 다루고, 이후 스마트 포인터(#6-3)와 RAII(#6-4)로 이어집니다. 앞선 컴파일 과정 분석(#5)에서 링커와 오브젝트 파일을 다뤘다면, 여기서는 실행 중인 프로그램의 메모리를 봅니다.
같은 뿌리에서 나오는 다른 증상들
피보나치 예제는 교과서적인 경우지만, 실무에서 스택·힙을 헷갈려 생기는 문제는 조금 다른 얼굴로 나타납니다. 큰 이미지 버퍼를 지역 배열로 잡는 경우는 뒤의 “큰 배열” 사례에서 다루고, 여기서는 그 밖에 자주 보는 세 가지를 먼저 짚어 둡니다.
첫째는 파서의 재귀 깊이입니다. JSON이나 XML 파서를 재귀 하강 방식으로 짜면 코드가 문법 구조와 1:1로 대응해 읽기 좋습니다. 문제는 중첩 깊이를 입력이 결정한다는 점입니다. [[[[...]]]]처럼 수만 단계로 중첩된 입력 하나만 들어와도 호출 스택이 그만큼 깊어지고, 각 프레임에 파서 상태가 지역 변수로 올라가 있으면 한도에 금방 닿습니다. 외부 입력을 받는 파서라면 이것은 곧 서비스 거부 공격 경로이기도 합니다.
// 중첩 한 단계마다 스택 프레임 하나 + 지역 상태
void parseValue(Lexer& lx) {
if (lx.peek() == '[') {
lx.next();
while (lx.peek() != ']') parseValue(lx); // 입력이 깊이를 결정
lx.next();
}
// ...
}
// 해결 1) 깊이 제한을 둔다
void parseValue(Lexer& lx, int depth) {
if (depth > 512) throw std::runtime_error("nesting too deep");
// ... parseValue(lx, depth + 1);
}
// 해결 2) 명시적 스택(힙에 있는 컨테이너)으로 바꿔 반복문으로 처리
void parseIterative(Lexer& lx) {
std::vector<Frame> stack; // 프레임 정보가 힙에 쌓임
stack.push_back(Frame{});
while (!stack.empty()) { /* 여는 괄호면 push, 닫는 괄호면 pop */ }
}
널리 쓰이는 JSON 라이브러리들이 최대 중첩 깊이 옵션을 두는 것도 같은 이유입니다. 깊이 제한은 코드 변경이 적고, 명시적 스택은 깊이 한도를 힙 크기까지 늘려 줍니다.
둘째는 지역 객체에 대한 참조 반환입니다. 스택 변수의 주소를 반환하는 실수는 포인터에서만 생기지 않습니다. 참조로 반환해도 똑같습니다.
// config는 함수가 끝나면 소멸 → 호출자는 댕글링 참조를 받음
const std::string& getConfig() {
std::string config = loadFromFile();
return config;
}
// 값으로 반환 (복사 생략 또는 이동으로 비용이 거의 없음)
std::string getConfig() { return loadFromFile(); }
이런 코드는 디버그 빌드에서 우연히 값이 남아 있어 멀쩡해 보이다가, 최적화 빌드에서 그 스택 영역이 다른 호출에 재사용되면서 엉뚱한 값이나 크래시로 드러나는 경우가 많습니다. GCC의 -Wreturn-local-addr, Clang의 -Wreturn-stack-address 경고는 이런 단순한 경우를 잡아 주므로 경고를 켜 두는 것만으로도 상당수를 막을 수 있습니다. return s.c_str();처럼 지역 std::string의 내부 버퍼 포인터를 돌려주는 패턴은 컴파일러가 잘 잡지 못하는 같은 부류의 실수입니다.
셋째는 분기가 많은 함수의 new/delete 짝입니다. new로 만든 객체를 함수 끝에서 delete하는 코드는 처음 작성할 때는 맞습니다. 나중에 누군가 중간에 return을 추가하거나, 호출하는 함수가 예외를 던지게 바뀌면 그 경로에서 delete가 빠집니다.
// 조기 return과 예외 경로에서 resp가 누수
void handleRequest(Request& req) {
Response* resp = new Response();
if (!validate(req)) return; // 누수
process(req, resp); // 여기서 예외가 나면 누수
delete resp;
}
// 소유권을 unique_ptr에 맡기면 어느 경로로 나가도 해제됨
void handleRequest(Request& req) {
auto resp = std::make_unique<Response>();
if (!validate(req)) return;
process(req, resp.get());
}
요청마다 한 번씩 새는 누수는 개별로는 작아서 테스트에서는 드러나지 않고, 트래픽을 받는 서버에서 메모리 사용량이 시간에 비례해 계속 오르는 형태로 나타납니다. 이 패턴은 메모리 누수(#6-2)에서 더 자세히 다룹니다.
C++ 메모리 구조 전체 그림
프로그램이 실행되면 운영체제는 프로세스의 가상 주소 공간에 코드(Text), 전역 데이터(Data/BSS), 힙, 스택 영역을 배치합니다. 아래는 전통적인 레이아웃을 단순화한 그림입니다. 힙은 높은 주소 쪽으로, 스택은 낮은 주소 쪽으로 자랍니다. 실제 64비트 리눅스에서는 공유 라이브러리와 mmap 영역이 그 사이에 끼어 있고, 스택 아래에 가드 페이지가 있으며 스택 크기 한도(ulimit -s)도 따로 있어서, 두 영역이 만나기 훨씬 전에 스택 한도에 걸려 크래시가 납니다.
graph TB
HIGH["높은 주소"]
STACK["Stack 스택<br/>지역 변수, 매개변수, 반환 주소<br/>자동 할당/해제, 보통 1~8MB<br/>아래로 성장"]
UNUSED["미사용 공간<br/>(실제로는 mmap 영역, 공유 라이브러리)"]
HEAP["Heap 힙<br/>new/malloc 동적 할당<br/>delete/free 또는 스마트 포인터로 해제<br/>위로 성장"]
BSS["BSS<br/>초기값 없는 전역/정적 변수<br/>int global_var; → 0으로 초기화"]
DATA["Data<br/>초기값 있는 전역/정적 변수<br/>int global_var = 42;"]
TEXT["Text<br/>실행 코드 (기계어), 읽기 전용"]
LOW["낮은 주소"]
HIGH --> STACK
STACK --> UNUSED
UNUSED --> HEAP
HEAP --> BSS
BSS --> DATA
DATA --> TEXT
TEXT --> LOW
| 영역 | 용도 | 크기 | 수명 |
|---|---|---|---|
| Text | 실행 코드 | 링크 시 결정 | 프로그램 종료까지 |
| Data | 초기값 있는 전역·정적 변수 | 링크 시 결정 | 프로그램 종료까지 |
| BSS | 초기값 없는 전역·정적 변수 | 링크 시 결정 | 프로그램 종료까지 |
| Heap | 동적 할당 메모리 | 런타임에 가변 | 해제할 때까지 |
| Stack | 지역 변수, 함수 호출 정보 | 런타임에 가변, 한도 있음 | 스코프 종료까지 |
스택 크기가 작게 제한되는 이유는 스레드마다 스택을 하나씩 따로 두기 때문입니다. 스레드가 수백 개인 프로그램에서 스택마다 수백 MB를 예약할 수는 없으므로, OS와 런타임은 스레드당 수 MB 수준의 한도를 둡니다. 재귀를 많이 쓰거나 함수 안에서 큰 배열을 지역 변수로 선언하면 이 한도에 쉽게 닿으므로, 큰 버퍼나 깊은 재귀는 힙이나 반복문으로 처리하는 편이 안전합니다.
변수가 어느 영역에 놓이는지 직접 확인하기
#include <iostream>
int global_var = 42; // Data 영역 (초기값이 있는 전역)
int uninitialized_global; // BSS 영역 (0으로 초기화됨)
void foo(int param) { // param: 스택 (또는 레지스터)
int local = 10; // 스택
int* heap_ptr = new int(99); // heap_ptr 자체는 스택, *heap_ptr(99)는 힙
std::cout << "param: " << param << ", local: " << local << "\n";
delete heap_ptr;
}
int main() {
int x = 5; // main의 스택 프레임
foo(x);
}
| 변수 | 영역 | 설명 |
|---|---|---|
global_var | Data | 초기화된 전역 |
uninitialized_global | BSS | 실행 파일에는 크기만 기록되고, 로딩 시 0으로 채워짐 |
param, local, heap_ptr | Stack | foo의 스택 프레임 (최적화 시 레지스터에만 있을 수도 있음) |
*heap_ptr (99) | Heap | new로 할당 |
x | Stack | main의 스택 프레임 |
주의할 점은 heap_ptr입니다. “포인터 변수”와 “포인터가 가리키는 대상”은 서로 다른 영역에 있습니다. foo가 끝나면 스택에 있던 heap_ptr은 사라지지만 힙의 int는 그대로 남으므로, delete를 빠뜨리면 그 int에 도달할 방법이 없어집니다. 메모리 누수가 바로 이 상태입니다.
주소를 출력해 보면 영역 차이가 숫자로 드러납니다.
#include <iostream>
int global_var = 42;
int main() {
int stack_var = 10;
int* heap_var = new int(20);
std::cout << "전역 변수 주소: " << &global_var << "\n";
std::cout << "스택 변수 주소: " << &stack_var << "\n";
std::cout << "힙 변수 주소: " << heap_var << "\n";
delete heap_var;
}
x86-64 리눅스에서 PIE(위치 독립 실행 파일, 요즘 배포판의 기본값)로 빌드하면 대략 다음과 같은 모양이 나옵니다. 아래 값은 모양을 보여 주기 위한 예시이며, ASLR 때문에 실행할 때마다 달라집니다.
전역 변수 주소: 0x55d4c3a2e010
스택 변수 주소: 0x7ffd8b1c2a4c
힙 변수 주소: 0x55d4c4f1b2b0
전역 변수는 실행 파일이 올라간 영역(0x55..., PIE를 끄면 0x404... 근처)에 있고, 힙은 그 뒤쪽 brk 영역에서 시작하므로 앞자리가 비슷합니다. 스택은 사용자 공간 최상단 근처(0x7ff...)에 있습니다. 큰 블록(glibc 기본값으로 128KB 이상)을 요청하면 할당자가 brk 영역 대신 mmap으로 따로 매핑하므로, 그 포인터는 0x7f...처럼 공유 라이브러리 근처 주소로 나옵니다. “힙은 한 덩어리”라는 그림이 단순화라는 점을 여기서 확인할 수 있습니다.
스택 메모리: 빠르고 자동이지만 제한적
스택 메모리의 동작 원리
void function() {
int a = 10; // 4바이트
double b = 3.14; // 8바이트
char c[100]; // 100바이트
// 함수 종료 시 자동으로 모두 해제
}
세 변수는 모두 함수 안의 지역 변수라 function의 스택 프레임에 자리를 잡고, 함수가 반환하면 프레임 전체가 한꺼번에 정리됩니다. 개발자가 해제할 필요가 없습니다.
graph TB
subgraph BEFORE["1. 함수 호출 전"]
M1["main 프레임"]
end
subgraph DURING["2. function 실행 중"]
SP["스택 포인터 (RSP)"]
C["c: char[100]"]
B["b: double"]
A["a: int"]
RET["반환 주소 (8 bytes)"]
M2["main 프레임"]
SP -.-> C
C --> B
B --> A
A --> RET
RET --> M2
end
subgraph AFTER["3. function 반환 후"]
SP2["스택 포인터가 원래 위치로"]
M3["main 프레임"]
SP2 -.-> M3
end
BEFORE --> DURING
DURING --> AFTER
레지스터 수준에서 본 스택 프레임
위 그림이 실제로 어떻게 만들어지는지 x86-64 기준으로 보면 “스택 할당은 왜 공짜에 가까운가”가 분명해집니다. 스택은 두 레지스터로 다룹니다. RSP(스택 포인터)는 현재 스택의 맨 위 주소이고 push/pop/call/ret이 자동으로 조정합니다. RBP(베이스 포인터)는 현재 함수 프레임의 기준점으로, 프레임 포인터를 쓰는 빌드에서는 지역 변수를 [rbp-4], [rbp-16]처럼 이 기준에서 떨어진 거리로 접근합니다.
function()이 호출될 때 일어나는 일은 다음과 같습니다. 오프셋은 설명을 위한 값이고, 실제 배치와 정렬 패딩은 컴파일러가 정합니다.
call function ; 반환 주소를 push하고 function으로 점프
; 프롤로그
push rbp ; 호출자의 프레임 기준점 저장
mov rbp, rsp ; 이 함수의 기준점 설정
sub rsp, 128 ; a, b, c[100]을 한꺼번에 확보 (16바이트 정렬에 맞춰 여유 포함)
; 본문
mov DWORD PTR [rbp-4], 10 ; a = 10
; b, c도 rbp 기준 오프셋으로 접근
; 에필로그
mov rsp, rbp ; 확보했던 공간을 한 번에 되돌림 (= "해제")
pop rbp ; 호출자의 기준점 복원
ret ; 저장해 둔 반환 주소로 복귀
높은 주소
┌──────────────┐
│ main 변수들 │
├──────────────┤
│ 반환 주소 │ ← call이 저장
├──────────────┤
│ 이전 rbp │ ← rbp가 가리키는 곳
├──────────────┤
│ a, b, c[100] │ ← rbp 기준 음수 오프셋
└──────────────┘ ← rsp
낮은 주소
여기서 두 가지를 읽을 수 있습니다. 첫째, 지역 변수가 몇 개든 공간 확보는 sub rsp, N 한 번이고 해제도 mov rsp, rbp 한 번입니다. 변수마다 할당 비용이 따로 드는 것이 아닙니다. 둘째, “해제”는 메모리를 지우는 것이 아니라 RSP를 되돌리는 것뿐이라, 이전 값은 다음 호출이 덮어쓸 때까지 그 자리에 남아 있습니다. 앞에서 본 댕글링 참조가 디버그 빌드에서 “우연히” 옛 값을 읽는 이유가 이것입니다.
-O2 이상에서는 -fomit-frame-pointer가 기본이라 RBP를 쓰지 않고 RSP 기준으로 접근하는 경우가 많고, 잠깐 쓰는 값은 아예 레지스터에만 두고 스택에 올리지 않기도 합니다. 그래서 디스어셈블리가 위 모양과 다르게 보여도 이상한 것이 아닙니다. 대신 프레임 포인터가 없으면 일부 프로파일러가 호출 스택을 복원하기 어려워지므로, 성능 분석용 빌드에서는 -fno-omit-frame-pointer를 다시 켜는 경우가 많습니다.
스택의 장점
첫째, 빠릅니다. 위에서 본 것처럼 스택 공간은 함수 진입 시 스택 포인터를 한 번 옮기는 것으로 확보되므로, 지역 변수 하나를 선언하는 데 따로 드는 비용이 사실상 없습니다. 반면 new는 할당자가 크기에 맞는 블록을 찾고 관리 정보를 갱신해야 하므로 호출마다 비용이 있습니다.
둘째, 해제가 자동입니다. 스택에 선언한 객체는 스코프를 벗어날 때 소멸자가 호출됩니다. std::string이나 std::vector는 내부 버퍼를 힙에 두더라도 객체 자체가 스택에 있으면, 스코프를 벗어날 때 소멸자가 그 버퍼까지 해제합니다. 그래서 new/delete를 직접 쓰지 않아도 됩니다.
void function() {
int x = 10;
std::string name = "Alice";
std::vector<int> numbers = {1, 2, 3};
// 함수 종료 시 소멸자가 차례로 호출되어 모두 정리됨
}
셋째, 캐시 효율이 좋습니다. 스택의 맨 위 부분은 방금 쓴 메모리라 CPU 캐시에 남아 있을 가능성이 높습니다.
스택의 단점: 크기 제한
void badFunction() {
int hugeArray[1000000]; // 약 4MB
// 스택 한도에 따라 스택 오버플로우
}
4MB는 리눅스 기본 8MB에는 들어가지만 Windows 기본 1MB는 넘고, 이미 깊이 호출된 상태라면 리눅스에서도 넘칠 수 있습니다. 기본 스택 크기는 대략 다음과 같습니다.
- Linux: 메인 스레드 8MB가 흔함 (
ulimit -s로 확인, 단위 KB) - Windows: 1MB (MSVC
/STACK링커 옵션 또는 MinGW-Wl,--stack으로 변경) - macOS: 메인 스레드 8MB, 보조 스레드 512KB
스택의 단점: 수명이 스코프에 묶임
int* badFunction() {
int x = 42;
return &x; // 댕글링 포인터
}
x는 스택에 있는 지역 변수라 함수가 끝나면 스택 프레임과 함께 사라집니다. 그 주소를 반환하면 호출 측에서 받은 포인터는 이미 무효가 된 메모리를 가리키는 댕글링 포인터가 되어, 역참조 시 정의되지 않은 동작이 됩니다. 함수 밖에서 써야 하는 값은 값으로 반환하거나 힙에 할당해 소유권을 넘깁니다.
스택 오버플로우 사례
큰 배열
void processImage() {
unsigned char image[4096 * 4096 * 4]; // 64MB
}
64MB 배열을 스택에 두면 일반적인 스택 크기를 크게 넘어서, 함수 진입 직후 크래시할 가능성이 높습니다. 이미지처럼 큰 데이터는 힙에 둡니다.
void processImage() {
std::vector<unsigned char> image(4096 * 4096 * 4); // 버퍼는 힙에
}
std::vector 객체 자체는 스택에 있어도 실제 데이터는 힙에 있고, 스코프를 벗어나면 소멸자가 해제합니다.
깊은 재귀
void recursiveFunction(int n) {
int localVar[1000]; // 약 4KB
if (n > 0) {
recursiveFunction(n - 1);
}
}
int main() {
recursiveFunction(10000); // 약 40MB 필요
}
호출마다 4KB 스택이 쌓이므로 깊이 10000이면 약 40MB가 필요해 기본 한도를 넘습니다. 해결 방법은 세 가지입니다.
반복문으로 바꾸면 프레임 하나 안에서 루프를 돌므로 localVar가 한 번만 할당됩니다.
void iterativeFunction(int n) {
int localVar[1000];
for (int i = 0; i < n; i++) {
// 같은 프레임을 계속 사용
}
}
큰 지역 데이터를 힙으로 옮기면 스택에는 포인터(8바이트)만 쌓입니다. 다만 재귀 깊이 자체는 그대로이므로, 프레임에 남는 반환 주소와 레지스터 저장 공간까지 없어지지는 않습니다.
void recursiveFunction(int n) {
auto localVar = std::make_unique<int[]>(1000);
if (n > 0) {
recursiveFunction(n - 1);
}
}
스택 크기를 늘리는 방법은 임시 조치입니다.
# Linux: 현재 셸에서 실행하는 프로세스의 메인 스레드 스택 한도 (단위 KB)
ulimit -s 16384 # 16MB
# Windows, MinGW
g++ -Wl,--stack,16777216 program.cpp # 16MB (단위 바이트)
# Windows, MSVC
cl program.cpp /link /STACK:16777216
ulimit -s는 KB 단위이고 메인 스레드에 적용됩니다. std::thread로 만든 스레드의 스택 크기는 플랫폼 기본값(glibc에서는 보통 ulimit -s 값, macOS의 보조 스레드는 512KB)을 따르므로, 스레드에서 도는 재귀는 따로 확인해야 합니다. 근본적인 해결은 큰 데이터를 힙으로 옮기거나 재귀를 반복으로 바꾸는 것입니다.
크래시 전에 스택 사용량 확인하기
스택 오버플로우는 입력이 커질 때에만 터지기 때문에, 크래시를 기다리기보다 빌드 단계에서 함수별 스택 사용량을 보는 편이 빠릅니다.
# 함수별 스택 사용량을 myfile.su 파일로 출력 (GCC)
g++ -fstack-usage -c myfile.cpp
cat myfile.su # 예: myfile.cpp:12:6:void processImage() 67108880 static
# 프레임이 지정한 크기를 넘는 함수에 경고 (GCC)
g++ -Wstack-usage=65536 -c myfile.cpp
# 실행 중 오버플로우를 위치와 함께 보고 (GCC/Clang)
g++ -fsanitize=address -g -o p p.cpp && ./p
-fstack-usage는 재귀 호출 깊이까지 계산해 주지는 않습니다. 프레임 하나의 크기를 알려 줄 뿐이므로, “프레임 크기 × 예상 최대 깊이”를 직접 곱해 한도와 비교해야 합니다. 64MB 이미지 버퍼처럼 프레임 하나가 이미 한도를 넘는 경우는 이 출력만 봐도 바로 드러납니다. 실제로 크래시가 난 뒤라면 아래 디버깅 절의 GDB 방법으로 코어 덤프의 백트레이스를 봅니다.
힙 메모리: 유연하지만 관리가 필요
힙 메모리의 특징
#include <iostream>
int main() {
int* ptr = new int(42); // ptr(8바이트)은 스택, int(4바이트)는 힙
std::cout << *ptr << std::endl;
delete ptr; // 힙 메모리 해제
}
new int(42)로 힙에 4바이트가 할당되고, ptr은 그 주소를 담은 스택 변수입니다. 사용이 끝난 뒤 delete를 정확히 한 번 호출해야 하며, 빠뜨리면 누수, 두 번 호출하면 이중 해제가 됩니다.
graph LR
subgraph Stack["Stack 영역"]
PTR["ptr<br/>주소값, 8 bytes"]
end
subgraph Heap["Heap 영역"]
VAL["int<br/>값 42, 4 bytes"]
end
PTR -->|가리킴| VAL
할당자는 new 한 번에 무슨 일을 하나
스택이 RSP를 옮기는 것으로 끝나는 반면, new int(42)는 operator new를 거쳐 보통 malloc에 도달하고, 할당자가 몇 단계의 일을 합니다. glibc의 ptmalloc, jemalloc, tcmalloc, Windows의 힙 관리자는 세부가 모두 다르지만 뼈대는 비슷합니다.
먼저 할당자는 블록 앞에 관리 정보를 둡니다. 사용자에게 돌려주는 주소 바로 앞에 블록 크기 같은 메타데이터를 기록하는데, glibc 64비트에서는 청크 앞에 8바이트 크기 필드가 있고 최소 청크가 32바이트라서, new int 하나(4바이트)도 실제로는 수십 바이트를 차지합니다. 작은 객체를 수백만 개 new하면 데이터보다 관리 비용이 더 커지는 이유입니다. delete에 크기를 넘기지 않아도 되는 것도 이 메타데이터 덕분입니다.
다음으로 빈 블록을 찾습니다. 해제된 블록들은 목록(free list)으로 관리됩니다. 교과서에서는 첫 번째로 맞는 블록을 고르는 First Fit, 크기가 가장 딱 맞는 블록을 고르는 Best Fit 같은 전략을 설명하는데, 실제 할당자는 크기별로 목록을 나눠 두고(glibc의 bin, jemalloc의 size class) 작은 요청은 해당 크기 목록에서 바로 꺼냅니다. glibc 2.26부터 들어간 스레드별 캐시(tcache)는 락 없이 처리되는 가장 빠른 경로입니다.
블록이 크면 쪼개고, 해제되면 합칩니다. 128바이트 빈 블록에서 32바이트를 요청하면 블록을 나눠 남은 부분을 다시 목록에 넣습니다(split). 반대로 해제된 블록 옆 블록도 비어 있으면 둘을 합쳐 큰 빈 블록으로 만듭니다(coalescing). 합칠 수 없으면 빈 공간이 잘게 쪼개져, 총량은 충분한데 연속된 큰 블록을 못 주는 외부 단편화가 생깁니다.
256B 블록 네 개를 할당한 뒤 두 번째와 네 번째를 해제
┌──────┬──────┬──────┬──────┐
│ 사용 │ 빈칸 │ 사용 │ 빈칸 │ 빈 공간 합계 512B
└──────┴──────┴──────┴──────┘
→ 512B 연속 블록 요청은 이 구간에서 만족시킬 수 없음
(빈칸끼리 인접하지 않아 합칠 수도 없음)
마지막으로, 가진 메모리가 모자라면 OS에 요청합니다. 리눅스에서는 brk로 힙 끝을 늘리거나 mmap으로 새 영역을 받고, Windows에서는 VirtualAlloc을 씁니다. glibc는 기본적으로 128KB 이상의 큰 요청을 mmap으로 따로 처리합니다. 시스템 콜은 비싸지만 매번 일어나지는 않으므로, 평소 new의 비용은 블록을 찾고 쪼개고 합치는 단계가 대부분을 차지합니다. 멀티스레드 프로그램에서는 여기에 공유 구조를 보호하는 동기화 비용이 더해질 수 있습니다.
오래 도는 서버에서 할당 패턴이 불규칙하면 단편화 때문에 프로세스의 상주 메모리가 실제 사용량보다 크게 유지되는 일이 흔합니다. 누수 검사 도구로 보면 새는 것이 없는데 메모리가 줄지 않는다면, 할당자를 jemalloc이나 tcmalloc으로 바꾸거나 아래 객체 풀처럼 크기가 같은 객체를 모아 관리하는 방법을 검토할 만합니다.
힙의 장점
크기를 런타임에 정할 수 있고, 스택 한도와 무관하게 시스템 메모리(가상 주소 공간과 OS 정책) 범위 안에서 큰 블록을 받을 수 있습니다.
std::vector<int> huge(250'000'000); // int 4바이트 기준 약 1GB
수명을 스코프 밖으로 넘길 수 있습니다. new로 만든 객체는 함수가 끝나도 남아 있으므로, 소유권을 호출자에게 넘길 수 있습니다. 소유권은 raw 포인터보다 std::unique_ptr로 넘기는 편이 해제 책임이 분명합니다.
std::unique_ptr<int> createData() {
return std::make_unique<int>(42); // 함수 밖에서도 유효
}
다형성을 쓰기 쉽습니다. 실행 시점에 타입이 정해지는 객체를 한 컨테이너에 담으려면 기반 클래스 포인터로 다뤄야 하므로 보통 힙에 할당합니다.
struct Animal {
virtual ~Animal() = default; // 기반 클래스 포인터로 삭제하려면 필수
virtual void speak() const = 0;
};
struct Dog : Animal { void speak() const override { std::cout << "Woof\n"; } };
struct Cat : Animal { void speak() const override { std::cout << "Meow\n"; } };
std::vector<std::unique_ptr<Animal>> animals;
animals.push_back(std::make_unique<Dog>());
animals.push_back(std::make_unique<Cat>());
for (const auto& animal : animals) {
animal->speak();
}
Animal의 소멸자가 virtual이 아니면 unique_ptr<Animal>이 Dog를 삭제할 때 정의되지 않은 동작이 됩니다. class로 선언하고 public:을 빠뜨리면 speak가 private이 되어 호출 자체가 컴파일되지 않으므로, 이런 인터페이스는 struct로 쓰거나 접근 지정자를 명시합니다.
힙의 단점
할당마다 비용이 있습니다. 앞에서 본 할당자 작업 때문에, 최신 할당자의 빠른 경로라도 스택처럼 공짜는 아닙니다. 루프 안에서 반복 할당하면 이 비용이 그대로 누적됩니다.
해제를 책임져야 합니다. 예외나 조기 반환으로 delete에 도달하지 않으면 누수가 되므로, 실무에서는 std::unique_ptr, std::vector 같은 RAII 타입으로 감싸 해제를 소멸자에 맡깁니다.
단편화가 생깁니다. 크기가 제각각인 블록을 오래 할당·해제하면 빈 공간이 잘게 흩어져, 총량은 충분해도 큰 연속 블록을 주지 못하거나 프로세스 메모리가 줄지 않습니다.
동적 배열 할당
// C 스타일 (C++ 코드에서는 지양)
int* arr1 = static_cast<int*>(std::malloc(100 * sizeof(int)));
std::free(arr1);
// new[] / delete[]
int* arr2 = new int[100];
delete[] arr2; // 배열은 반드시 delete[]
// 현대 C++ (권장)
std::vector<int> arr3(100); // 자동 관리
new[]로 할당했으면 delete[]로, new로 할당했으면 delete로, malloc으로 할당했으면 free로 해제해야 합니다. 짝이 어긋나면 정의되지 않은 동작입니다.
2차원 동적 배열
행과 열 크기가 런타임에 정해지는 행렬은 int m[rows][cols]로 선언할 수 없습니다(가변 길이 배열은 표준 C++이 아닙니다). 흔히 쓰는 방법은 두 가지입니다.
#include <vector>
// 방법 1: vector의 vector - 쓰기 쉽지만 행마다 따로 할당
void method1(size_t rows, size_t cols) {
std::vector<std::vector<int>> matrix(rows, std::vector<int>(cols));
matrix[3][5] = 42;
}
// 방법 2: 1차원 버퍼 하나에 행 우선으로 배치 - 할당 1번, 메모리 연속
void method2(size_t rows, size_t cols) {
std::vector<int> matrix(rows * cols);
auto at = [&](size_t i, size_t j) -> int& { return matrix[i * cols + j]; };
at(3, 5) = 42;
}
방법 1은 문법이 자연스럽고 행마다 길이를 다르게 할 수도 있지만, 행 수만큼 힙 할당이 일어나고 행들이 메모리 여기저기에 흩어집니다. 앞에서 본 할당자 비용이 행 수만큼 곱해지고, 행을 넘어갈 때마다 캐시 미스가 날 수 있습니다. 방법 2는 할당이 한 번이고 전체가 연속이라, 이미지 처리나 수치 계산처럼 행렬 전체를 순회하는 코드에서 유리합니다. 인덱스 계산을 직접 해야 하는 불편은 위처럼 작은 접근 함수나 클래스로 감싸면 사라집니다. C++23의 std::mdspan은 이런 1차원 버퍼 위에 다차원 인덱싱을 얹어 주는 표준 도구입니다.
delete와 delete[]를 헷갈리는 실수
int* arr = new int[100];
delete arr; // delete[] 대신 delete
new int[100]으로 배열을 할당했는데 delete만 쓰면 표준상 정의되지 않은 동작입니다. int처럼 소멸자가 없는 타입은 많은 구현에서 우연히 블록 전체가 해제되어 문제없어 보이지만, 이것은 보장이 아닙니다. 소멸자가 있는 타입은 많은 구현에서 new[]가 블록 앞에 원소 개수를 따로 기록해 두기 때문에, delete가 그 오프셋을 무시하고 해제하면 할당자에 잘못된 주소를 넘겨 힙이 손상되거나 크래시가 나기 쉽습니다. 소멸자도 첫 원소에 대해서만 호출됩니다.
Visual Studio 디버그 힙이나 AddressSanitizer(alloc-dealloc-mismatch)는 이 실수를 바로 보고합니다. 검사 없는 릴리스 빌드에서는 조용히 넘어가다가 나중에 엉뚱한 곳에서 크래시가 나므로, 원인과 증상이 멀리 떨어져 찾기 어렵습니다. 가장 좋은 예방책은 배열을 직접 new[]하지 않는 것입니다.
auto array = std::make_unique<int[]>(100); // 소멸 시 delete[]가 호출됨
std::vector<int> v(100); // 대부분의 경우 이쪽이 더 편함
스택 vs 힙 성능 비교
#include <chrono>
#include <iostream>
int main() {
const int iterations = 1'000'000;
volatile int sink = 0;
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < iterations; i++) {
int x = i; // 스택 (실제로는 레지스터)
sink = x;
}
auto mid = std::chrono::steady_clock::now();
for (int i = 0; i < iterations; i++) {
int* x = new int(i); // 힙
sink = *x;
delete x;
}
auto end = std::chrono::steady_clock::now();
using us = std::chrono::microseconds;
std::cout << "Stack: " << std::chrono::duration_cast<us>(mid - start).count() << "us\n";
std::cout << "Heap: " << std::chrono::duration_cast<us>(end - mid).count() << "us\n";
}
이 측정은 해석에 주의해야 합니다. 스택 쪽 루프는 사실상 volatile 변수에 값을 쓰는 비용만 남고, x는 스택에 올라가지도 않을 수 있습니다. 반대로 Clang은 짝이 맞는 new/delete를 최적화로 제거하는 경우가 있어서, 결과가 빌드 옵션과 컴파일러에 따라 크게 달라집니다. 이 코드로 확인할 수 있는 것은 “스택이 힙보다 몇 배 빠르다”는 고정된 배수가 아니라 “new/delete 한 쌍에는 빈 반복보다 눈에 띄는 비용이 있다”는 사실입니다. 자신의 환경에서 직접 돌려 보고, 정밀한 비교가 필요하면 Google Benchmark 같은 도구를 쓰는 편이 좋습니다.
힙이 느린 이유는 앞의 할당자 절에서 본 그대로입니다. 빈 블록을 찾고, 메타데이터를 관리하고, 멀티스레드 환경에서 동기화하고, 가끔은 OS에 메모리를 요청합니다. 할당된 블록이 메모리 여기저기에 흩어져 캐시 지역성이 떨어지는 것도 간접적인 비용입니다.
언제 스택, 언제 힙
기본은 스택(자동 저장 기간)입니다. 크기가 작고 컴파일 시점에 정해지며, 수명이 스코프 안에서 끝나는 값은 지역 변수로 둡니다. int, double 같은 기본 타입, 작은 구조체, std::string·std::vector 객체 자체가 여기에 해당합니다. 루프 안에서 반복해서 만드는 임시 값도 스택에 두는 편이 할당 비용이 없습니다.
int x = 42;
double y = 3.14;
std::string name = "Alice"; // 객체는 스택, 짧은 문자열은 SSO로 힙 할당 없음
for (int i = 0; i < 1'000'000; i++) {
int temp = i * 2; // 할당 비용 없음
}
힙이 필요한 경우는 네 가지 정도입니다. 크기가 커서 스택 한도에 부담이 되는 경우(수십 KB 이상이면 따져 볼 만하고, 수백 KB 이상이면 거의 항상 힙), 수명이 스코프를 넘어서는 경우, 크기를 런타임에 정해야 하는 경우, 그리고 다형성 객체를 기반 클래스 포인터로 다뤄야 하는 경우입니다.
// 큰 버퍼
std::vector<int> largeArray(1'000'000); // 약 4MB, 힙
// 스코프를 넘는 수명
std::unique_ptr<Data> loadData() {
auto data = std::make_unique<Data>();
// ... 데이터 로드 ...
return data;
}
// 런타임 크기: int arr[size];는 표준 C++이 아님(일부 컴파일러 확장)
int size = 0;
std::cin >> size;
std::vector<int> arr(size);
// 다형성
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
힙을 쓸 때도 new/delete를 직접 쓰기보다 std::vector, std::string, std::unique_ptr처럼 해제를 소멸자에 맡기는 타입을 쓰는 것이 기본입니다. 이렇게 하면 “힙에 둘지”와 “누가 해제할지”를 따로 고민할 필요가 없어집니다.
RAII로 힙 메모리 다루기
버퍼를 소유하는 클래스
#include <cstdint>
#include <memory>
class BufferManager {
std::unique_ptr<std::uint8_t[]> data_;
std::size_t size_;
public:
explicit BufferManager(std::size_t n)
: data_(std::make_unique<std::uint8_t[]>(n)), size_(n) {}
std::uint8_t* data() { return data_.get(); }
std::size_t size() const { return size_; }
};
void processData() {
BufferManager buffer(1024 * 1024); // 1MB, 함수 종료 시 자동으로 delete[]
}
생성자에서 자원을 얻고 소멸자에서 해제하는 RAII(Resource Acquisition Is Initialization) 패턴입니다. 예외가 나도 스택 되감기(stack unwinding) 과정에서 소멸자가 호출되므로 누수가 없습니다. make_unique<T[]>(n)은 원소를 값 초기화(0으로 채움)하므로, 곧바로 덮어쓸 큰 버퍼라면 C++20의 make_unique_for_overwrite로 초기화 비용을 줄일 수 있습니다.
공유 소유권
#include <memory>
#include <vector>
struct Resource { /* ... */ };
std::vector<std::shared_ptr<Resource>> cache;
void shareResource() {
auto res = std::make_shared<Resource>();
cache.push_back(res); // 참조 카운트 2
} // res 소멸, 카운트 1: cache가 계속 보유
shared_ptr은 참조 카운팅으로 마지막 소유자가 사라질 때 해제합니다. 두 객체가 서로를 shared_ptr로 가리키는 순환 참조가 생기면 카운트가 0이 되지 않으므로, 한쪽을 weak_ptr로 바꿔 끊어야 합니다.
예외 안전한 할당
// 예외 시 누수
void badAlloc() {
int* data = new int[1000];
processData(data); // 예외 발생 가능
delete[] data; // 도달하지 못할 수 있음
}
// 스마트 포인터
void goodAlloc() {
auto data = std::make_unique<int[]>(1000);
processData(data.get());
}
// std::vector (대부분 이쪽이 가장 단순)
void bestAlloc() {
std::vector<int> data(1000);
processData(data.data());
}
C API와 함께 쓰기: 커스텀 삭제자
#include <cstdio>
#include <cstdlib>
#include <memory>
struct FreeDeleter { void operator()(void* p) const { std::free(p); } };
struct FileCloser { void operator()(std::FILE* f) const { if (f) std::fclose(f); } };
void useCResources() {
std::unique_ptr<void, FreeDeleter> mem(std::malloc(1024));
std::unique_ptr<std::FILE, FileCloser> file(std::fopen("log.txt", "w"));
} // 소멸 시 free, fclose가 자동으로 호출됨
unique_ptr의 두 번째 템플릿 인자로 삭제자를 지정하면 malloc/free, fopen/fclose 같은 C API에도 RAII를 적용할 수 있습니다.
자주 발생하는 메모리 오류
스택 오버플로우는 대개 Segmentation fault (core dumped)로 나타납니다. 큰 배열을 힙으로 옮기고, 재귀 깊이를 제한하거나 반복문으로 바꾸고, 그래도 필요하면 임시로 스택 크기를 늘립니다.
댕글링 포인터는 이미 사라진 객체를 가리키는 포인터입니다. 스택 변수의 주소를 반환하는 경우가 가장 흔합니다.
#include <iostream>
int* getPointer() {
int x = 42;
return &x; // 스택 변수의 주소 반환
}
int main() {
int* ptr = getPointer();
std::cout << *ptr; // 정의되지 않은 동작
}
값이 우연히 42처럼 보일 수 있지만 보장되지 않으며, 최적화나 다음 함수 호출에 따라 다른 값이 되거나 크래시할 수 있습니다. 함수 밖에서 쓸 값은 값으로 반환하는 것이 가장 간단하고, 정말 힙 객체가 필요하면 std::unique_ptr로 소유권을 넘깁니다.
스택 메모리에 delete를 쓰는 것도 정의되지 않은 동작입니다.
void badFunction() {
int x = 42;
delete &x; // new로 얻은 주소가 아님
}
할당자가 관리하지 않는 주소를 해제하려 하므로 힙 메타데이터가 깨지거나 즉시 중단됩니다. delete는 new로 얻은 주소에만 씁니다.
이중 해제는 같은 포인터를 두 번 delete하는 것입니다.
int* ptr = new int(42);
delete ptr;
delete ptr; // 이중 해제: 정의되지 않은 동작
glibc는 많은 경우 free(): double free detected in tcache 2 같은 메시지와 함께 프로세스를 중단하지만, 항상 잡히는 것은 아닙니다. delete 뒤에 ptr = nullptr;을 두면 두 번째 delete nullptr는 아무 일도 하지 않으므로 방어가 되지만, 같은 주소를 가진 다른 포인터 복사본까지 막아 주지는 못합니다. 근본적인 해결은 소유자를 하나로 정하는 스마트 포인터입니다.
use-after-free는 해제한 메모리를 계속 쓰는 것입니다.
int* ptr = new int(42);
delete ptr;
*ptr = 100; // use-after-free: 정의되지 않은 동작
해제된 블록은 곧 다른 할당에 재사용되므로, 이 쓰기는 전혀 다른 객체를 망가뜨릴 수 있습니다. 증상이 원인과 멀리 떨어진 곳에서 나타나기 때문에 AddressSanitizer 같은 도구 없이는 찾기 어렵습니다.
초기화하지 않은 포인터를 역참조하면 임의의 주소에 쓰게 됩니다.
int* ptr; // 값이 정해지지 않음
*ptr = 42; // 정의되지 않은 동작
포인터는 선언할 때 nullptr이나 유효한 주소로 초기화하는 습관을 들이고, 컴파일러 경고(-Wall의 -Wuninitialized, -Wmaybe-uninitialized)를 켜 둡니다.
메모리 디버깅 도구
Valgrind
# Ubuntu/Debian
sudo apt install valgrind
# 누수와 잘못된 메모리 접근 검사 (memcheck가 기본 도구)
valgrind --leak-check=full ./my_program
definitely lost는 도달할 수 없게 된 누수, Invalid read/Invalid write는 해제된 메모리나 범위 밖 접근을 뜻합니다. Valgrind는 프로그램을 가상 CPU에서 해석하며 실행하므로 다시 컴파일할 필요가 없는 대신 매우 느리고, 리눅스에서 주로 쓰입니다. 최신 macOS, 특히 Apple Silicon은 공식 지원되지 않으므로 macOS에서는 AddressSanitizer나 Xcode의 Instruments를 씁니다.
AddressSanitizer (ASan)
g++ -g -fsanitize=address -fno-omit-frame-pointer -o program program.cpp
./program
컴파일 시점에 검사 코드를 삽입하는 방식이라 Valgrind보다 훨씬 가볍습니다. Clang 문서는 일반적인 속도 저하를 약 2배로 소개하므로, 테스트 스위트 전체를 돌리는 CI에서 자주 사용합니다. heap-use-after-free, heap-buffer-overflow, stack-buffer-overflow, alloc-dealloc-mismatch 같은 보고에 할당·해제·접근 위치의 스택 트레이스가 함께 나옵니다. 리눅스에서는 LeakSanitizer가 함께 켜져 종료 시 누수도 보고합니다.
UndefinedBehaviorSanitizer (UBSan)
g++ -g -fsanitize=undefined -o program program.cpp
부호 있는 정수 오버플로우, 잘못된 시프트, 정렬되지 않은 포인터 접근, null 포인터 역참조 같은 정의되지 않은 동작을 실행 중에 탐지합니다. ASan과 함께 켤 수 있습니다(-fsanitize=address,undefined).
GDB로 스택 오버플로우 분석
ulimit -c unlimited
./program # 크래시 후 코어 덤프 생성 (systemd 환경에서는 coredumpctl로 조회)
gdb ./program core
(gdb) bt # 백트레이스: 같은 함수가 수만 번 반복되면 무한/깊은 재귀
(gdb) frame 5 # 특정 프레임으로 이동
(gdb) info locals # 지역 변수 확인
스택 오버플로우의 백트레이스는 같은 함수가 끝없이 반복되는 모양이라 알아보기 쉽습니다. bt -20처럼 개수를 제한하면 가장 바깥쪽 프레임부터 볼 수 있습니다.
할당을 줄이는 패턴
객체 풀
루프 안에서 같은 타입의 객체를 반복해서 new/delete하면 할당자 비용과 단편화가 쌓입니다. 객체 풀은 만들어 둔 객체를 재사용합니다.
#include <memory>
#include <vector>
struct Object { int id = 0; std::vector<int> data; };
class ObjectPool {
std::vector<std::unique_ptr<Object>> pool_; // 모든 객체의 소유자
std::vector<Object*> available_; // 지금 쓸 수 있는 객체
public:
Object* acquire() {
if (available_.empty()) {
pool_.push_back(std::make_unique<Object>());
return pool_.back().get();
}
Object* obj = available_.back();
available_.pop_back();
return obj;
}
void release(Object* obj) {
obj->id = 0;
obj->data.clear(); // 용량은 유지되어 다음 사용 때 재할당이 줄어듦
available_.push_back(obj);
}
};
반환된 객체의 상태를 초기화하지 않으면 다음 사용자가 이전 데이터를 보게 되므로, release에서 상태를 정리합니다. 풀 자체는 스레드 안전하지 않으므로 여러 스레드에서 쓰려면 락이나 스레드별 풀이 필요합니다.
선형(arena) 할당자
파싱이나 한 프레임의 렌더링처럼 “한꺼번에 만들고 한꺼번에 버리는” 객체가 많을 때는, 큰 버퍼에서 순서대로 잘라 주고 마지막에 한 번에 되돌리는 할당자가 효과적입니다.
#include <cstddef>
#include <cstdint>
#include <memory>
class ArenaAllocator {
std::unique_ptr<std::byte[]> buffer_;
std::size_t size_;
std::size_t offset_ = 0;
public:
explicit ArenaAllocator(std::size_t n)
: buffer_(std::make_unique<std::byte[]>(n)), size_(n) {}
void* allocate(std::size_t n, std::size_t align = alignof(std::max_align_t)) {
std::size_t aligned = (offset_ + align - 1) & ~(align - 1); // align은 2의 거듭제곱
if (aligned + n > size_) return nullptr; // 공간 부족
offset_ = aligned + n;
return buffer_.get() + aligned;
}
void reset() { offset_ = 0; } // 전체를 한 번에 "해제"
};
개별 해제가 없으므로 할당은 덧셈 몇 번으로 끝납니다. 대신 reset() 시점에 이 영역에 만든 객체의 소멸자는 호출되지 않으므로, 소멸자가 하는 일이 없는 타입에 쓰거나 소멸자를 직접 호출해야 합니다. 표준 라이브러리의 std::pmr::monotonic_buffer_resource(C++17)가 같은 아이디어를 컨테이너와 함께 쓸 수 있게 제공합니다.
스마트 포인터 선택
unique_ptr은 단일 소유권을 표현하고 raw 포인터와 크기·비용이 같으므로 기본 선택입니다. shared_ptr은 소유자가 여럿이고 마지막 소유자를 미리 알 수 없을 때만 쓰며, 참조 카운트의 원자적 증감 비용이 있습니다. weak_ptr은 shared_ptr이 관리하는 객체를 소유하지 않고 관찰할 때, 특히 순환 참조를 끊을 때 씁니다.
같이 보면 좋은 글
- C++ 메모리 누수: 흔한 5가지 패턴과 Valgrind·ASan으로 찾는 방법
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ RAII: 생성자에서 획득하고 소멸자에서 해제하는 자원 관리 클래스 만들기
- Valgrind Memcheck로 C++ 메모리 누수 찾아내기