C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례

💡 핵심 개념: 스택은 “지금 이 함수 안에서만 쓰고 나가면 치우는 책상 위 메모지”, 힙은 “필요할 때 창고에서 꺼내 쓰고, 안 쓰면 직접 반납해야 하는 큰 상자”에 가깝습니다. 메모지(스택)는 빠르지만 양이 한정되어 있고, 창고(힙)는 크지만 관리 비용이 있습니다.

들어가며: 스택 오버플로우로 프로그램을 크래시시킨 이야기

“왜 갑자기 죽지?” - 재귀 함수의 함정

알고리즘 문제를 풀다가, 재귀 함수로 피보나치를 구현했습니다. 작은 입력값(n=10)에서는 잘 작동했는데, n=100000을 입력하자마자 프로그램이 아무 에러 메시지 없이 죽었습니다. 이 예제에서 fibonacci는 호출될 때마다 스택에 4KB짜리 배열(cache[1000])을 하나씩 쌓습니다. n-1, n-2로 두 갈래로 재귀하므로 호출 횟수가 기하급수적으로 늘고, 그만큼 스택에 쌓이는 메모리도 폭발합니다. main에서 fibonacci(100000)을 부르면 수십만 번 이상 호출되면서 스택 한도를 넘어서서 스택 오버플로우로 프로그램이 종료됩니다. cache 배열은 실제로 사용되지 않지만, “함수마다 스택을 얼마나 쓰는지”를 보여주기 위해 넣은 것입니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o fib fib.cpp && ./fib
#include <iostream>
int fibonacci(int n) {
    int cache[1000];  // 4KB 배열
    
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
    std::cout << fibonacci(100000);  // ❌ 크래시!
    return 0;
}

fibonacci가 호출될 때마다 int cache[1000](약 4KB)이 스택에 쌓이고, n-1과 n-2로 두 갈래 재귀라 호출 수가 기하급수적으로 늘어납니다. fibonacci(100000)은 수십만 번 이상 호출되며 스택 한도를 넘어 스택 오버플로우로 종료됩니다. 실행 결과: ./fib 실행 시 스택 오버플로우로 프로그램이 종료된다(환경에 따라 메시지 없이 죽거나 “Segmentation fault” 등). 디버깅 과정:

  1. ❌ 로직 오류? → 코드는 정상
  2. ❌ 메모리 누수? → Valgrind로 확인했지만 아님
  3. ✅ 스택 오버플로우(stack overflow—스택 메모리 한도를 넘어서서 데이터가 덮어쓰이거나 프로그램이 종료되는 현상)! 원인:
  • 각 재귀 호출마다 4KB 할당

  • 100000번 호출 = 400MB 필요

  • 스택 크기 (기본 1-8MB) 초과 → 크래시 이 경험으로 스택과 힙의 차이를 뼈저리게 배웠습니다.
    C++에서는 “어디에 메모리가 잡히는지”를 모르면 재귀 깊이, 대용량 버퍼, 전역/지역 변수 선택에서 실수가 나오기 쉽습니다. 이 글에서 스택·힙·전역 메모리의 특성을 정리해 두면, 이후 스마트 포인터(#6-3)와 RAII(#6-4)를 이해할 때도 도움이 됩니다.
    “크기가 작고 수명이 스코프 안이면 스택, 크거나 수명이 길면 힙”을 기본으로 두며, 재귀·큰 배열은 스택 한도를 넘지 않도록 주의하면 됩니다. 이 글을 읽으면:

  • 스택과 힙의 동작 원리를 명확히 이해할 수 있습니다.

  • 스택 오버플로우를 예방하는 방법을 배웁니다.

  • 언제 스택을 쓰고 언제 힙을 써야 하는지 판단할 수 있습니다.

  • 메모리 구조를 이해하여 성능 최적화를 할 수 있습니다.

같은 뿌리에서 나오는 다른 증상들

피보나치 예제는 교과서적인 경우지만, 실무에서 스택·힙을 헷갈려 생기는 문제는 조금 다른 얼굴로 나타납니다. 큰 이미지 버퍼를 지역 배열로 잡는 경우는 2장의 “사례 1”에서 따로 다루고, 여기서는 그 밖에 자주 보는 세 가지를 먼저 짚어 둡니다.

파서의 재귀 깊이: 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와 Clang은 이런 단순한 경우에 -Wreturn-local-addr, -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++ 실전 가이드 #5: 컴파일 과정 분석에서 링커와 오브젝트 파일을 다뤘다.


C++ 메모리 구조 전체 그림

C++ 프로그램이 실행될 때, 운영체제는 다음과 같은 메모리 영역을 할당합니다. 비유하면 건물의 층별 용도처럼, 코드(Text)·전역 데이터(Data/BSS)·힙·스택이 각각 다른 “층”에 자리 잡으며, 주소가 낮은 쪽에서 높은 쪽으로 올라갑니다.

아래 다이어그램은 프로세스 메모리 레이아웃을 단순화한 것입니다. 힙은 높은 주소 쪽으로, 스택은 낮은 주소 쪽으로 자라며 가운데 빈 공간을 나눠 씁니다. 실제 64비트 리눅스에서는 스택 아래에 가드 페이지가 있고 스택 크기 한도(ulimit -s)도 따로 있어서, 두 영역이 만나기 훨씬 전에 스택 한도에 걸려 크래시가 납니다.

flowchart TB
  subgraph addr["주소 방향 (낮음 → 높음)"]
    direction TB
    T[코드 Text] 
    D[전역/정적 Data, BSS]
    H[힙 Heap ↑]
    free[빈 공간]
    S[스택 Stack ↓]
  end
  T --> D --> H --> free --> S
  style H fill:#e1f5fe
  style S fill:#fff3e0
영역용도수명·특징
스택지역 변수, 함수 인자, 반환 주소함수 반환 시 자동 해제, 크기 제한(보통 1~8MB)
힙new/malloc으로 할당수동 해제 또는 스마트 포인터, 크기 제한이 상대적으로 큼
graph TB
    HIGH["높은 주소 0xFFFFFFFF..."]
    
    STACK["Stack 스택br/━━━━━━━━━━━━━━━br/지역 변수, 매개변수, 호출 정보br/자동 할당/해제 · 1-8MBbr/↓ 아래로 성장"]
    
    UNUSED["⋮br/미사용 공간br/⋮"]
    
    HEAP["Heap 힙br/━━━━━━━━━━━━━━━br/new/malloc 동적 할당br/수동 관리 delete/freebr/↑ 위로 성장"]
    
    BSS["BSS 영역br/━━━━━━━━━━━━━━━br/초기화 안 된 전역/정적br/int global_var; → 0으로 초기화"]
    
    DATA["Data 영역br/━━━━━━━━━━━━━━━br/초기화된 전역/정적br/int global_var = 42;"]
    
    TEXT["Text 영역br/━━━━━━━━━━━━━━━br/실행 코드 (기계어)br/읽기 전용"]
    
    LOW["낮은 주소 0x00000000..."]
    
    HIGH --> STACK
    STACK --> UNUSED
    UNUSED --> HEAP
    HEAP --> BSS
    BSS --> DATA
    DATA --> TEXT
    TEXT --> LOW
    
    style HIGH fill:#f5f5f5,stroke:#666,stroke-width:2px,color:#333
    style STACK fill:#e3f2fd,stroke:#1976d2,stroke-width:3px,color:#000
    style UNUSED fill:#fafafa,stroke:#999,stroke-width:1px,stroke-dasharray: 5 5,color:#666
    style HEAP fill:#fff3e0,stroke:#f57c00,stroke-width:3px,color:#000
    style BSS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
    style DATA fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#000
    style TEXT fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
    style LOW fill:#f5f5f5,stroke:#666,stroke-width:2px,color:#333

각 영역의 역할

영역용도크기수명
Text실행 코드컴파일 시 결정프로그램 종료까지
Data초기화된 전역 변수컴파일 시 결정프로그램 종료까지
BSS초기화 안 된 전역 변수컴파일 시 결정프로그램 종료까지
Heap동적 할당 메모리런타임에 가변명시적 해제까지
Stack지역 변수, 함수 호출런타임에 가변스코프 종료까지

스택 크기가 제한된 이유: 스택은 한 번에 하나의 스레드가 사용하며, 함수 호출이 깊어질수록 아래로 쌓입니다. 따라서 OS는 스택에 “보통 1~8MB” 정도만 할당해 두며, 그 한도를 넘기면 스택 오버플로우(크래시)가 납니다. 재귀를 많이 쓰거나, 함수 안에서 큰 배열을 지역 변수로 선언하면 이 한도에 쉽게 닿을 수 있으므로, 큰 버퍼나 재귀 깊이는 힙이나 반복문으로 처리하는 편이 안전합니다.

변수가 어느 영역에 놓이는지 직접 확인하기

표로만 보면 추상적이니, 한 프로그램 안에서 변수마다 어느 영역에 들어가는지 짚어 보겠습니다.

#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);
    return 0;
}
변수영역설명
global_varData초기화된 전역
uninitialized_globalBSS실행 파일에는 크기만 기록되고, 로딩 시 0으로 채워짐
param, local, heap_ptrStackfoo의 스택 프레임
*heap_ptr (99)Heapnew로 할당
xStackmain의 스택 프레임

주의할 점은 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;
    return 0;
}

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바이트 할당
    
    // 함수 종료 시 자동으로 모두 해제
}

위 코드에서 int a, double b, char c[100]은 모두 함수 안의 지역 변수이므로 스택에 할당됩니다. a는 4바이트, b는 8바이트, c는 100바이트를 차지하며, 함수가 끝나면 이 블록 전체가 스택에서 제거됩니다. 즉 스택은 “함수 진입 시 자동으로 할당, 함수 반환 시 자동으로 해제”되는 구조라서, 개발자가 직접 해제할 필요가 없습니다.

스택 프레임 (Stack Frame) 시각화:

graph TB
    subgraph BEFORE[1️⃣ 함수 호출 전]
        M1["main 프레임br/━━━━━━━━━━━━br/main 변수들"]
    end
    
    subgraph DURING[2️⃣ function 호출 후]
        SP["⬅ Stack Pointer SP"]
        C["c char 100br/100 bytes"]
        B["b doublebr/8 bytes"]
        A["a intbr/4 bytes"]
        RET["return addressbr/반환 주소 8 bytes"]
        M2["main 프레임br/━━━━━━━━━━━━br/main 변수들"]
        
        SP -.-> C
        C --> B
        B --> A
        A --> RET
        RET --> M2
    end
    
    subgraph AFTER[3️⃣ function 종료 후]
        SP2["⬅ Stack Pointer 위로 이동"]
        M3["main 프레임br/━━━━━━━━━━━━br/main 변수들br/✅ 스택 프레임 자동 정리"]
        
        SP2 -.-> M3
    end
    
    BEFORE --> DURING
    DURING --> AFTER
    
    style BEFORE fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
    style DURING fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style AFTER fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
    style M1 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style C fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
    style B fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
    style A fill:#ffe0b2,stroke:#f57c00,stroke-width:2px
    style RET fill:#ffccbc,stroke:#d84315,stroke-width:2px
    style M2 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
    style M3 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px

레지스터 수준에서 본 스택 프레임

위 그림이 실제로 어떻게 만들어지는지 x86-64 기준으로 보면 “스택 할당은 왜 공짜에 가까운가”가 분명해집니다. 스택은 두 레지스터로 다룹니다.

  • RSP (Stack Pointer): 현재 스택의 맨 위 주소. push/pop/call/ret이 자동으로 조정합니다.
  • RBP (Base Pointer): 현재 함수 프레임의 기준점. 프레임 포인터를 쓰는 빌드에서는 지역 변수를 [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를 다시 켜는 경우가 많습니다.

스택의 장점

1. 속도가 매우 빠름 스택에 변수 하나 할당하는 것은 스택 포인터를 조금 옮기는 것에 가까워서 수 CPU 사이클이면 끝납니다. 반면 new는 힙에서 빈 공간을 찾으며, 할당하며, 포인터를 돌려주는 과정이 들어가 수십~수백 사이클이 걸릴 수 있습니다. 따라서 자주 호출되는 경로에서는 스택에 작은 객체를 두는 편이 유리합니다.

// 스택 할당 (1 CPU 사이클)
int x = 42;
// 힙 할당 (수십~수백 CPU 사이클)
int* y = new int(42);

int x = 42는 스택 포인터만 조금 옮기는 수준이라 매우 빠르고, new int(42)는 힙에서 블록을 찾고 메타데이터를 쓰는 과정이 들어가 수십~수백 배 더 오래 걸립니다. 2. 자동 메모리 관리 스택에 선언한 변수는 함수가 끝날 때 스택 프레임이 정리되면서 함께 사라집니다. int, std::string, std::vector 모두 function 안에서 선언되었으므로, 함수를 벗어나는 순간 메모리가 자동으로 해제됩니다. std::string과 std::vector는 내부적으로 힙을 쓸 수 있지만, 그 객체 자체는 스택에 있기 때문에 스코프를 벗어나면 소멸자가 호출되어 내부 자원까지 정리됩니다. 따라서 new/delete를 직접 쓰지 않아도 됩니다.

void function() {
    int x = 10;
    std::string name = "Alice";
    std::vector<int> numbers = {1, 2, 3};
    
    // 함수 종료 시 자동으로 모두 해제
    // delete 불필요!
}

3. 캐시 친화적 스택은 메모리가 연속적이고 지역성이 높아 CPU 캐시 효율이 좋습니다.

스택의 단점

1. 크기 제한

void badFunction() {
    int hugeArray[1000000];  // 4MB
    // ❌ 스택 오버플로우 가능!
}

지역 변수 hugeArray는 스택에 할당되는데, 4MB는 대부분 환경의 기본 스택 크기(1~8MB)에 가깝거나 넘어서 스택 오버플로우를 일으킬 수 있습니다. 이렇게 큰 버퍼는 std::vector 등으로 힙에 두는 것이 안전합니다. 기본 스택 크기:

  • Linux: 8MB (ulimit -s로 확인)
  • Windows: 1MB (링커 옵션으로 변경 가능)
  • macOS: 8MB 2. 수명이 스코프에 제한됨
int* badFunction() {
    int x = 42;
    return &x;  // ❌ 댕글링 포인터!
}
// x는 함수 종료 시 소멸됨

x는 스택에 있는 지역 변수라 함수가 끝나면 스택 프레임과 함께 사라집니다. 그 주소를 반환하면 호출 측에서 받은 포인터는 이미 해제된 메모리를 가리키는 댕글링 포인터가 되어, 역참조 시 정의되지 않은 동작이 됩니다.

스택 오버플로우 실전 사례

사례 1: 큰 배열

겪은 크래시:

void processImage() {
    unsigned char image[4096 * 4096 * 4];  // 64MB!
    // ❌ 스택 크기 초과 → 즉시 크래시
}

64MB 배열을 스택에 두면 일반적인 스택 크기(1~8MB)를 크게 넘어서, 함수 진입 직후 스택 오버플로우로 크래시할 가능성이 높습니다. 이미지처럼 큰 데이터는 반드시 힙에 할당해야 합니다. 해결법:

void processImage() {
    std::vector<unsigned char> image(4096 * 4096 * 4);
    // ✅ 힙에 할당
}

std::vector는 내부 버퍼를 힙에 할당하므로 64MB처럼 큰 크기도 스택 한도와 무관하게 쓸 수 있습니다. 벡터 객체 자체는 스택에 있어도, 실제 데이터는 힙에 있고 스코프를 벗어나면 소멸자가 자동으로 해제합니다.

사례 2: 깊은 재귀

겪은 문제:

void recursiveFunction(int n) {
    int localVar[1000];  // 4KB
    
    if (n > 0) {
        recursiveFunction(n - 1);
    }
}
int main() {
    recursiveFunction(10000);  // ❌ 40MB 필요 → 크래시!
    return 0;
}

호출마다 4KB 스택이 쌓이므로 10000번이면 약 40MB가 필요합니다. 스택은 보통 1~8MB라 한도를 넘어 스택 오버플로우가 납니다. 재귀 깊이를 줄이거나, 큰 지역 데이터는 힙으로 옮겨야 합니다. 해결법 1: 반복문 사용:

void iterativeFunction(int n) {
    int localVar[1000];
    
    for (int i = 0; i < n; i++) {
        // 반복문은 스택 프레임 재사용
    }
}

반복문은 한 번의 스택 프레임 안에서 루프만 도므로, localVar가 n번 쌓이지 않고 한 번만 할당됩니다. 재귀를 반복문으로 바꾸면 스택 사용량이 크게 줄어듭니다. 해결법 2: 힙 사용:

void recursiveFunction(int n) {
    auto localVar = std::make_unique<int[]>(1000);
    
    if (n > 0) {
        recursiveFunction(n - 1);
    }
}

4KB 배열을 std::make_unique<int[]>(1000)으로 힙에 두면, 스택에는 포인터(8바이트)만 쌓입니다. 재귀 깊이가 깊어도 스택 사용량이 작게 유지되어 스택 오버플로우를 피할 수 있습니다. 해결법 3: 스택 크기 증가 (임시 방편):

# Linux
ulimit -s 16384  # 16MB
# Windows (링커 옵션)
g++ -Wl,--stack,16777216 program.cpp  # 16MB

ulimit -s는 프로세스의 스택 크기 한도를 바이트 단위로 설정합니다. Linux에서는 16384(KB)로 16MB를 주는 예시입니다. 근본 해결은 큰 데이터를 힙으로 옮기는 것이며, 스택 확대는 임시 조치로만 쓰는 것이 좋습니다. ulimit -s는 메인 스레드에만 적용되고, 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 이미지 버퍼처럼 프레임 하나가 이미 한도를 넘는 경우는 이 출력만 봐도 바로 드러납니다. 실제로 크래시가 난 뒤라면 아래 8장의 GDB 방법으로 코어 덤프의 백트레이스를 보면 됩니다.


힙 메모리: 유연하지만 수동 관리 필요

힙 메모리의 특징

int main() {
    // 스택에 포인터 변수 (8바이트)
    int* ptr = new int(42);  // 힙에 int (4바이트) 할당
    
    std::cout << *ptr << std::endl;  // 42 출력
    
    delete ptr;  // 힙 메모리 해제 (필수!)
    return 0;
}

new int(42)로 힙에 4바이트가 할당되고, ptr은 스택에 있는 8바이트 포인터입니다. delete ptr로 그 힙 블록을 해제해야 하며, 해제하지 않으면 메모리 누수가 됩니다. 사용이 끝난 뒤 반드시 한 번만 delete를 호출해야 합니다. 메모리 레이아웃:

graph LR
    subgraph Stack[Stack 영역]
        PTR["ptrbr/━━━━━━━━br/주소값br/8 bytes"]
    end
    
    subgraph Heap[Heap 영역]
        VAL["intbr/━━━━━━━━br/value: 42br/4 bytes"]
    end
    
    PTR -->|가리킴| VAL
    
    style Stack fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style Heap fill:#fff3e0,stroke:#f57c00,stroke-width:2px
    style PTR fill:#bbdefb,stroke:#1976d2,stroke-width:2px
    style VAL fill:#ffe0b2,stroke:#f57c00,stroke-width:2px

할당자는 new 한 번에 무슨 일을 하나

스택이 RSP를 옮기는 것으로 끝나는 반면, new int(42)는 operator new를 거쳐 보통 malloc에 도달하고, 할당자가 몇 단계의 일을 합니다. glibc의 ptmalloc, jemalloc, tcmalloc, Windows의 힙 관리자는 세부가 모두 다르지만 뼈대는 비슷합니다.

1. 블록 앞에 관리 정보를 둔다. 할당자는 사용자에게 돌려주는 주소 바로 앞에 블록 크기 같은 메타데이터를 기록합니다. glibc 64비트에서는 청크 앞에 8바이트 크기 필드가 있고 최소 청크가 32바이트라서, new int 하나(4바이트)도 실제로는 수십 바이트를 차지합니다. 작은 객체를 수백만 개 new하면 데이터보다 관리 비용이 더 커지는 이유입니다. delete에 크기를 넘기지 않아도 되는 것도 이 메타데이터 덕분입니다.

2. 빈 블록을 찾습니다. 해제된 블록들은 목록(free list)으로 관리됩니다. 교과서에서는 첫 번째로 맞는 블록을 고르는 First Fit, 크기가 가장 딱 맞는 블록을 고르는 Best Fit 같은 전략을 설명하는데, 실제 할당자는 크기별로 목록을 나눠 두고(glibc의 bin, jemalloc의 size class) 작은 요청은 해당 크기 목록에서 바로 꺼냅니다. glibc 2.26부터 들어간 스레드별 캐시(tcache)는 락 없이 처리되는 가장 빠른 경로입니다.

3. 크면 쪼개고, 해제되면 합칩니다. 128바이트 빈 블록에서 32바이트를 요청하면 블록을 나눠 남은 부분을 다시 목록에 넣습니다(split). 반대로 해제된 블록 옆 블록도 비어 있으면 둘을 합쳐 큰 빈 블록으로 만듭니다(coalescing). 합치지 않으면 빈 공간이 잘게 쪼개져, 총량은 충분한데 연속된 큰 블록을 못 주는 외부 단편화가 생깁니다.

256B 블록 네 개를 할당한 뒤 두 번째와 네 번째를 해제
┌──────┬──────┬──────┬──────┐
│ 사용 │ 빈칸 │ 사용 │ 빈칸 │   빈 공간 합계 512B
└──────┴──────┴──────┴──────┘
→ 512B 연속 블록 요청은 이 구간에서 만족시킬 수 없음
  (빈칸끼리 인접하지 않아 합칠 수도 없음)

4. 모자라면 OS에 요청합니다. 가진 메모리가 부족하면 리눅스에서는 brk로 힙 끝을 늘리거나 mmap으로 새 영역을 받고, Windows에서는 VirtualAlloc을 씁니다. glibc는 기본적으로 128KB 이상의 큰 요청을 mmap으로 따로 처리합니다. 시스템 콜은 비싸지만 매번 일어나지는 않으므로, 평소 new의 비용은 2~3단계가 대부분을 차지합니다.

오래 도는 서버에서 할당 패턴이 불규칙하면 단편화 때문에 프로세스의 상주 메모리가 실제 사용량보다 크게 유지되는 일이 흔합니다. 누수 검사 도구로 보면 새는 것이 없는데 메모리가 줄지 않는다면, 할당자를 jemalloc이나 tcmalloc으로 바꾸거나 9장의 객체 풀처럼 크기가 같은 객체를 모아 관리하는 방법을 검토할 만합니다.

힙의 장점

1. 크기 제한 없음 (시스템 메모리까지)

// 1GB 배열도 가능
std::vector<int> huge(250000000);  // 1GB

std::vector는 요소를 힙에 할당하므로, 시스템 메모리 범위 안에서 큰 크기도 할당할 수 있습니다. 2.5억 개 int는 약 1GB이며, 스택이 아니라 힙을 쓰므로 스택 한도와 무관합니다. 2. 수명 제어 가능

int* createData() {
    int* data = new int(42);
    return data;  // ✅ 함수 밖에서도 사용 가능
}
int main() {
    int* ptr = createData();
    std::cout << *ptr << std::endl;
    delete ptr;
    return 0;
}

new로 할당한 메모리는 함수가 끝나도 힙에 남아 있으므로, 포인터를 반환해 호출자가 사용할 수 있습니다. 단, 호출 측에서 delete ptr로 해제해야 하며, 스마트 포인터(std::unique_ptr)를 쓰면 해제를 자동으로 할 수 있습니다. 3. 다형성 구현

class Animal { virtual void speak() = 0; };
class Dog : public Animal { void speak() override { std::cout << "Woof\n"; } };
class Cat : public Animal { void speak() 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 (auto& animal : animals) {
    animal->speak();  // 다형성!
}

파생 클래스 객체(Dog, Cat)는 크기가 다르고 실행 시점에 타입이 정해지므로 힙에 할당하는 것이 자연스럽습니다. std::unique_ptr<Animal>로 소유권을 관리하면 animals가 소멸될 때 각 동물 객체가 자동으로 해제됩니다.

힙의 단점

1. 느림

// 스택 할당: 함수 진입 시 스택 포인터를 한 번 옮기는 것으로 끝 (지역 변수 전체를 한꺼번에)
// 힙 할당: 할당자가 빈 블록을 찾고 메타데이터를 갱신 (new/delete 한 쌍에 보통 수십 ns)

스택 할당은 사실상 비용이 없습니다. 컴파일러가 함수 프롤로그에서 스택 포인터를 필요한 만큼 한 번 옮길 뿐이고, 변수 하나를 선언할 때마다 따로 일이 생기지 않습니다. 힙 할당은 할당자가 크기에 맞는 빈 블록을 찾고 관리 정보를 갱신해야 하므로 호출마다 비용이 있습니다. glibc의 tcache처럼 스레드별 캐시가 있는 최신 할당자는 빠른 경로가 꽤 빠르지만, 루프 안에서 반복 할당하면 이 비용이 그대로 누적됩니다. 2. 수동 관리 필요

int* ptr = new int(42);
// ... 복잡한 로직 ...
delete ptr;  // 잊으면 메모리 누수!

new로 할당한 메모리는 delete를 호출하기 전까지 프로세스가 끝날 때까지 남습니다. 예외나 early return으로 delete에 도달하지 않으면 누수가 되므로, 실무에서는 std::unique_ptr 등 RAII로 감싸는 것이 안전합니다. 3. 메모리 단편화

graph LR
    B1["사용 중"]
    F1["빈 공간br/⚠️"]
    B2["사용 중"]
    F2["빈 공간br/⚠️"]
    B3["사용 중"]
    
    B1 --- F1
    F1 --- B2
    B2 --- F2
    F2 --- B3
    
    NOTE["작은 빈 공간들이 흩어져br/큰 메모리 할당 불가"]
    
    style B1 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
    style B2 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
    style B3 fill:#4caf50,stroke:#2e7d32,stroke-width:2px,color:#fff
    style F1 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
    style F2 fill:#ffcdd2,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
    style NOTE fill:#fff9c4,stroke:#f57f17,stroke-width:2px

동적 배열 할당

// C 스타일 (사용 지양)
int* arr1 = (int*)malloc(100 * sizeof(int));
free(arr1);
// C++ 스타일 (기본)
int* arr2 = new int[100];
delete[] arr2;  // ⚠️ delete[] 사용 필수!
// 현대 C++ (권장)
std::vector<int> arr3(100);  // ✅ 자동 메모리 관리

배열은 new[]로 할당했으면 반드시 delete[]로 해제해야 합니다. delete만 쓰면 정의되지 않은 동작이 되고, 소멸자가 있는 타입이라면 첫 원소의 소멸자만 호출됩니다. std::vector는 내부에서 힙을 쓰고 소멸 시 자동으로 해제하므로 직접 new/delete를 쓰지 않아도 됩니다.

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 vs delete[] 실수

문제의 코드:

int* arr = new int[100];
delete arr;  // ❌ delete[] 대신 delete 사용

new int[100]으로 배열을 할당했는데 delete만 쓰면 표준상 정의되지 않은 동작입니다. int처럼 소멸자가 없는 타입은 많은 구현에서 우연히 블록 전체가 해제되어 문제없어 보이지만, 이것은 보장이 아닙니다. 소멸자가 있는 타입은 new[]가 블록 앞에 원소 개수를 따로 기록해 두기 때문에, delete가 그 오프셋을 무시하고 해제하면 힙 메타데이터를 잘못 해석해 크래시가 나기 쉽습니다. 결과:

  • 소멸자가 있는 타입: 첫 원소의 소멸자만 호출되고, 잘못된 주소를 해제하려다 힙이 손상될 수 있음
  • Visual Studio 디버그 힙이나 AddressSanitizer(alloc-dealloc-mismatch)에서는 바로 보고됨
  • 검사 없는 Release 빌드에서는 조용히 넘어가다가 나중에 엉뚱한 곳에서 크래시 (더 위험!) 올바른 사용:
int* single = new int(42);
delete single;  // ✅ 단일 객체
int* array = new int[100];
delete[] array;  // ✅ 배열
// 가장 좋은 방법
auto array = std::make_unique<int[]>(100);  // ✅ 자동 관리

단일 객체는 delete, 배열은 delete[]로 짝을 맞춰야 합니다. std::make_unique<int[]>(100)을 쓰면 배열도 RAII로 관리되어 스코프를 벗어날 때 자동으로 올바르게 해제됩니다.


스택 vs 힙 성능 비교

직접 측정한 벤치마크

같은 횟수만큼 “스택에 int 하나 할당”과 “힙에 int 할당 후 해제”를 반복해 걸린 시간을 재면, 힙이 스택보다 수십 배 이상 느린 것을 확인할 수 있습니다. 루프 안에서 new/delete를 반복하는 것은 실전에서 피하는 것이 좋습니다.

#include <chrono>
#include <iostream>
int main() {
    const int iterations = 1000000;
    
    // 스택 할당 측정
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; i++) {
        int x = 42;  // 스택
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto stack_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
    
    // 힙 할당 측정
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < iterations; i++) {
        int* x = new int(42);  // 힙
        delete x;
    }
    end = std::chrono::high_resolution_clock::now();
    auto heap_time = std::chrono::duration_cast<std::chrono::microseconds>(end - start).count();
    
    std::cout << "Stack: " << stack_time << "μs\n";
    std::cout << "Heap: " << heap_time << "μs\n";
    std::cout << "Heap is " << (heap_time / stack_time) << "x slower\n";
    
    return 0;
}

같은 횟수로 스택 할당(int x = 42)과 힙 할당(new/delete)을 반복해 시간을 재는 코드입니다. 주의할 점은 스택 쪽 루프가 사실상 빈 루프라는 것입니다. x를 쓰지 않으니 컴파일러가 대입을 지우고, -O2 이상에서는 루프 자체가 사라져 0μs가 나올 수 있습니다. Clang은 짝이 맞는 new/delete까지 제거하기도 합니다. 따라서 아래 배수는 “스택이 힙보다 몇 배 빠르다”가 아니라 “new/delete 한 쌍이 빈 반복보다 얼마나 비싼가”에 가깝습니다. 이 글에서 측정한 결과는 다음과 같으며, 빌드 옵션에 따라 크게 달라집니다.

이 글의 측정 결과 (Intel i7, Linux):

Stack: 1,234μs
Heap: 89,567μs
Heap is 72x slower

왜 힙이 느린가?

  1. 시스템 콜 가능성: 할당자가 가진 메모리가 부족하거나 큰 블록을 요청할 때 brk/mmap으로 OS에 요청 (매번은 아님)
  2. 메모리 검색: 사용 가능한 블록 찾기
  3. 메타데이터 관리: 할당 크기, 상태 정보 저장
  4. 동기화 오버헤드: 멀티스레드 환경에서 락 필요

실전 선택 가이드: 언제 스택? 언제 힙?

스택 사용 (기본 선택)

✅ 스택을 사용해야 하는 경우:

  1. 작은 크기 (수백 바이트 이하)
    int x = 42;
    double y = 3.14;
    std::string name = “Alice”;  // 작은 문자열
  2. 수명이 명확한 지역 변수
   void function() {
       int counter = 0;
       // counter는 함수 내에서만 사용
   }
  1. 성능이 중요한 경우
   // 루프 안에서 반복 할당
   for (int i = 0; i < 1000000; i++) {
       int temp = i * 2;  // ✅ 스택 (빠름)
   }

힙 사용

✅ 힙을 사용해야 하는 경우:

  1. 큰 크기 (수 KB 이상)
   // ❌ 스택
   // int largeArray[1000000];  // 4MB - 위험!
   
   // ✅ 힙
   std::vector<int> largeArray(1000000);  // 4MB - 안전
  1. 수명이 스코프를 넘어서는 경우
   std::unique_ptr<Data> loadData() {
       auto data = std::make_unique<Data>();
       // ... 데이터 로드 ...
       return data;  // ✅ 힙 메모리는 함수 밖에서도 유효
   }
  1. 크기를 런타임에 결정
   int size;
   std::cin >> size;
   
   // ❌ 스택 (C++ 표준 아님, 일부 컴파일러만 지원)
   // int arr[size];
   
   // ✅ 힙
   std::vector<int> arr(size);
  1. 다형성 필요
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>());
    shapes.push_back(std::make_unique<Rectangle>());

실전 판단 플로우차트

크기가 1KB 미만인가?
  ├─ Yes → 스택 사용
  └─ No → 다음 질문
수명이 스코프 내인가?
  ├─ Yes → 스택 고려 (크기 주의)
  └─ No → 힙 사용
성능이 매우 중요한가?
  ├─ Yes → 스택 사용 (가능하면)
  └─ No → 힙 사용 (안전)

완전한 메모리 관리 예제

예제 1: RAII 기반 리소스 관리 (권장 패턴)

문제 시나리오: 파일 핸들, 소켓, 동적 메모리 등 여러 리소스를 관리할 때 예외 발생 시 해제를 누락하는 경우가 많습니다. 해결: RAII(Resource Acquisition Is Initialization)로 생성자에서 획득, 소멸자에서 해제합니다.

#include <memory>
class BufferManager {
    std::unique_ptr<uint8_t[]> data_;
    size_t size_;
public:
    explicit BufferManager(size_t n) : data_(std::make_unique<uint8_t[]>(n)), size_(n) {}
    uint8_t* data() { return data_.get(); }
    size_t size() const { return size_; }
};
void processData() {
    BufferManager buffer(1024 * 1024);  // 1MB, 함수 종료 시 자동 delete[]
}

std::make_unique로 힙에 할당하며, 객체 소멸 시 unique_ptr이 자동으로 delete[]를 호출합니다. 예외 발생 시에도 스택 언와인딩으로 소멸자가 호출되어 누수가 없습니다.

예제 2: shared_ptr로 공유 소유권

문제 시나리오: 여러 객체가 같은 리소스를 공유해야 합니다. 누가 마지막에 해제할지 알 수 없습니다.

#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);  // 참조 카운트 증가
    // 여러 곳에서 shared_ptr 보유해도 마지막 참조가 사라질 때만 해제됨
}

shared_ptr은 참조 카운팅으로 “마지막 참조가 사라질 때” 해제합니다. 순환 참조가 생기면 weak_ptr로 끊어야 합니다.

예제 3: 예외 안전한 메모리 할당

문제 시나리오: new 후 예외가 발생하면 delete에 도달하지 않아 누수됩니다.

// ❌ 나쁜 예: 예외 시 누수
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());  // 예외 안전
}

std::vector와 unique_ptr은 예외가 발생해도 스택 언와인딩 시 소멸자가 호출되어 메모리가 해제됩니다.

예제 4: 커스텀 삭제자

문제 시나리오: malloc/free, fopen/fclose 등 C API와 연동할 때 사용합니다.

#include <memory>
#include <cstdlib>
struct FreeDeleter { void operator()(void* p) const { std::free(p); } };
void useMallocMemory() {
    std::unique_ptr<void, FreeDeleter> ptr(std::malloc(1024));
    // 소멸 시 자동으로 free(ptr.get()) 호출
}

unique_ptr의 두 번째 템플릿 인자로 삭제자를 지정하면 C API와 연동 시 RAII를 적용할 수 있습니다.


자주 발생하는 메모리 관련 에러

에러 1: 스택 오버플로우

증상:

Segmentation fault (core dumped)

원인: 스택 크기 초과 해결법:

  1. 큰 배열은 힙 사용
  2. 재귀 깊이 제한
  3. 스택 크기 증가 (임시)

에러 2: 댕글링 포인터

2시간 동안 찾은 버그:

int* getPointer() {
    int x = 42;
    return &x;  // ❌ 스택 변수의 주소 반환
}
int main() {
    int* ptr = getPointer();
    std::cout << *ptr;  // ❌ 정의되지 않은 동작!
    return 0;
}

getPointer가 반환되면 x가 있던 스택 프레임은 무효가 되는데, 그 주소를 ptr이 받아 역참조하면 이미 해제된 메모리를 읽는 정의되지 않은 동작(UB)이 됩니다. 값이 우연히 42처럼 보일 수 있지만, 보장되지 않으며 최적화에 따라 크래시할 수도 있습니다. 문제: x는 함수 종료 시 소멸, ptr은 유효하지 않은 메모리를 가리킴 해결법:

std::unique_ptr<int> getPointer() {
    return std::make_unique<int>(42);  // ✅ 힙 사용
}

std::make_unique<int>(42)는 힙에 int를 할당하고 그 소유권을 unique_ptr에 넘깁니다. 반환된 포인터는 힙 메모리를 가리키므로 함수가 끝난 뒤에도 유효하며, unique_ptr이 소멸될 때 자동으로 delete가 호출됩니다.

에러 3: 스택과 힙 혼동

void badFunction() {
    int x = 42;
    delete &x;  // ❌ 스택 메모리를 delete → 크래시!
}

x는 스택에 있는 지역 변수이므로 new로 할당된 것이 아닙니다. 스택 주소에 delete를 쓰면 할당자가 관리하지 않는 메모리를 해제하려다 힙 메타데이터가 깨지거나 즉시 크래시할 수 있습니다. delete는 반드시 new로 얻은 주소에만 사용해야 합니다. 규칙:

  • new로 할당한 것만 delete
  • 스택 변수는 자동 해제

에러 4: 이중 해제 (Double Free)

문제 시나리오: 같은 포인터를 두 번 delete하면 힙 메타데이터가 손상되어 크래시합니다.

int* ptr = new int(42);
delete ptr;
delete ptr;  // ❌ 이중 해제 → 크래시!

해결법:

// ✅ delete 후 nullptr 대입 (C++11 이전 관례)
delete ptr;
ptr = nullptr;
// 두 번째 delete는 nullptr에 대해 안전 (아무것도 안 함)
// ✅ 더 좋은 방법: 스마트 포인터 사용
auto ptr = std::make_unique<int>(42);
// delete 호출 불필요, 자동 해제

에러 5: use-after-free

문제 시나리오: delete 후에도 포인터를 사용하는 경우. 디버그 빌드에서는 “조용히” 동작하다가 릴리즈에서 크래시할 수 있습니다.

int* ptr = new int(42);
delete ptr;
*ptr = 100;  // ❌ use-after-free! 정의되지 않은 동작

해결법:

auto ptr = std::make_unique<int>(42);
// ptr이 소멸되면 더 이상 접근 불가
// 또는 delete 후 ptr = nullptr; 하고 사용 전 검사

에러 6: 예외로 인한 누수

문제 시나리오: new와 delete 사이에 예외가 발생하면 delete에 도달하지 않습니다.

void process() {
    int* data = new int[1000];
    mayThrow();  // 예외 발생!
    delete[] data;  // 실행되지 않음
}

해결법: 스마트 포인터 또는 std::vector 사용 (위 예제 3 참고).

에러 7: 초기화되지 않은 포인터

문제 시나리오: 선언만 하고 할당하지 않은 포인터를 역참조하면 랜덤 메모리 접근으로 크래시합니다.

int* ptr;  // ❌ 초기화 안 됨
*ptr = 42;  // 크래시!

해결법:

int* ptr = nullptr;  // ✅ 명시적 초기화
if (ptr) *ptr = 42;  // 사용 전 검사
// 또는 스마트 포인터
auto ptr = std::make_unique<int>(42);

메모리 디버깅 팁

Valgrind: 메모리 누수·오류 탐지

설치 (Linux/macOS):

# Ubuntu/Debian
sudo apt install valgrind
# macOS (Homebrew)
brew install valgrind

사용법:

# 메모리 누수 검사
valgrind --leak-check=full ./my_program
# 오류 메모리 접근 검사
valgrind --tool=memcheck ./my_program

해석: definitely lost는 메모리 누수, Invalid read는 use-after-free나 댕글링 포인터를 의미합니다.

AddressSanitizer (ASan): 빠른 메모리 오류 검사

장점: 컴파일 시점에 검사 코드를 삽입하는 방식이라, 모든 명령을 가상 CPU에서 해석하는 Valgrind보다 훨씬 가볍습니다. Clang 문서는 일반적인 속도 저하를 약 2배로 소개합니다. 그래서 테스트 스위트 전체를 돌리는 CI에서 자주 사용합니다. 사용법:

# 컴파일 시 -fsanitize=address 옵션
g++ -g -fsanitize=address -fno-omit-frame-pointer -o program program.cpp
# 실행
./program

해석: heap-use-after-free는 이미 해제된 메모리를 읽는 오류입니다. 스택 트레이스로 정확한 위치를 알 수 있습니다.

UndefinedBehaviorSanitizer (UBSan)

용도: 정수 오버플로우, 부동소수점 오류, null 포인터 역참조 등 정의되지 않은 동작 탐지.

g++ -g -fsanitize=undefined -o program program.cpp

GDB로 스택 오버플로우 디버깅

재현 시나리오: 재귀 함수가 크래시할 때 어디서 문제인지 확인합니다.

# 코어 덤프 생성
ulimit -c unlimited
./program  # 크래시 후 core 파일 생성
# GDB로 분석
gdb ./program core
(gdb) bt        # 백트레이스
(gdb) frame 5   # 특정 프레임으로 이동
(gdb) info locals  # 지역 변수 확인

스택 크기 확인:

# Linux: 현재 스택 한도
ulimit -s
# macOS
ulimit -s

디버깅 체크리스트

도구용도언제 사용
Valgrind누수, use-after-free로컬 개발, CI
AddressSanitizer힙 오버플로우, use-after-free빠른 검사, CI
UBSan정의되지 않은 동작엣지 케이스 검증
GDB크래시 분석코어 덤프 분석

프로덕션 메모리 패턴

패턴 1: 객체 풀 (Object Pool)

문제: 루프 안에서 반복 new/delete는 성능 저하와 단편화를 유발합니다. 해결: 미리 할당한 객체를 acquire/release로 재사용합니다.

#include <vector>
#include <memory>
struct Object { int id; 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>());
            available_.push_back(pool_.back().get());
        }
        Object* obj = available_.back();
        available_.pop_back();
        return obj;
    }
    void release(Object* obj) { available_.push_back(obj); }
};

게임 엔진, 네트워크 서버에서 자주 사용합니다.

패턴 2: 스택 기반 할당자 (Stack Allocator)

문제: 짧은 수명의 객체가 많을 때 힙 단편화가 심해집니다. 해결: 선형 버퍼에 순차 할당하고 reset()으로 일괄 해제합니다.

class StackAllocator {
    std::unique_ptr<uint8_t[]> buffer_;
    size_t size_, offset_ = 0;
public:
    explicit StackAllocator(size_t n) : buffer_(std::make_unique<uint8_t[]>(n)), size_(n) {}
    void* allocate(size_t n);
    void reset() { offset_ = 0; }  // 전체 해제
};

프레임 렌더링, 파싱 등 “한 번에 처리 후 버림” 패턴에 적합합니다.

패턴 3: 스마트 포인터 선택 가이드

unique_ptr  → 단일 소유권, "이 객체는 나만 가짐"
shared_ptr  → 공유 소유권, "여러 곳에서 참조"
weak_ptr    → shared_ptr 순환 참조 방지

실무 규칙:

  • 기본은 unique_ptr (가장 가볍고 빠름)
  • 공유가 필요할 때만 shared_ptr
  • shared_ptr 순환 참조 시 weak_ptr로 끊기

프로덕션 체크리스트

  • new/delete 직접 사용 최소화
  • 스마트 포인터(unique_ptr, shared_ptr) 사용
  • CI에서 AddressSanitizer 또는 Valgrind 실행
  • 큰 버퍼(1KB 이상)는 힙 사용
  • 재귀 깊이 제한 또는 반복문으로 변경
  • 예외 발생 시에도 리소스 해제 보장 (RAII)

자주 묻는 질문 (FAQ)

스택과 힙의 가장 큰 차이는 무엇인가요?

스택은 자동으로 할당·해제되고 빠르지만 크기가 제한적(1-8MB)입니다. 힙은 수동으로 관리해야 하고 느리지만 시스템 메모리 한도까지 사용할 수 있습니다. 기본적으로 작은 지역 변수는 스택, 큰 데이터나 동적 할당은 힙을 사용합니다.

스택 오버플로우는 어떻게 예방하나요?

  1. 큰 배열(1KB 이상)은 힙에 할당하거나 std::vector 사용, 2) 재귀 함수는 깊이를 제한하거나 반복문으로 변환, 3) 필요시 스택 크기를 늘리는 방법이 있습니다. 가장 안전한 방법은 큰 데이터를 힙에 할당하는 것입니다.

언제 스택을 쓰고 언제 힙을 써야 하나요?

스택: 크기가 작고(수백 바이트 이하), 수명이 스코프 내로 명확하며, 성능이 중요할 때 사용합니다. 힙: 크기가 크거나(1KB 이상), 수명이 스코프를 넘어서거나, 런타임에 크기가 결정되거나, 다형성이 필요할 때 사용합니다.

스택이 힙보다 얼마나 빠른가요?

스택 할당은 함수 진입 시 스택 포인터를 옮기는 것으로 끝나서 변수별 비용이 사실상 없고, 힙 할당은 new/delete 한 쌍마다 할당자 작업이 들어가 보통 수십 ns가 걸립니다. 배수로 말하기보다는 “힙 할당은 호출마다 비용이 있고 스택은 없다”로 이해하는 편이 정확하며, 루프 안에서 반복 할당하는 경우 이 비용이 누적되어 큰 성능 차이를 만듭니다.

스택 변수의 주소를 반환하면 왜 안 되나요?

함수가 종료되면 스택 프레임이 정리되어 그 변수가 차지하던 메모리는 무효가 됩니다. 그 주소를 반환해서 사용하면 댕글링 포인터(dangling pointer)가 되어 정의되지 않은 동작(undefined behavior)이 발생합니다. 함수 밖에서 사용할 데이터는 힙에 할당하거나 값으로 반환해야 합니다.


같이 보면 좋은 글



마무리

핵심 요약

✅ 스택: 빠르고 자동이지만 크기 제한 (1-8MB) ✅ 힙: 유연하고 크지만 수동 관리 필요 ✅ 기본은 스택: 작고 수명이 명확하면 스택 ✅ 큰 데이터는 힙: 1KB 이상이면 힙 고려 ✅ 성능 차이: 스택 할당은 사실상 무료, 힙 할당은 호출마다 할당자 비용 ✅ 스택 오버플로우 주의: 큰 배열, 깊은 재귀 피하기

실무 팁

  1. 기본은 스택 사용 (빠르고 안전)
  2. 1KB 이상은 힙 고려
  3. 재귀 함수는 깊이 제한
  4. 스택 변수 주소 반환 금지
  5. 힙 사용 시 스마트 포인터 필수

초보자를 위한 체크리스트

  • 큰 배열·큰 구조체를 지역 변수로 두지 않았는가? (스택 한도 초과 위험)
  • 재귀 깊이가 입력에 비례해 무한정 커지지 않는가? (필요하면 반복문·명시적 스택)
  • 함수 안에서 만든 지역 배열의 주소를 반환하지 않았는가?
  • 힙을 쓸 때는 new/delete 짝을 맞추거나, 다음 글에서 다루는 스마트 포인터로 넘어갈 준비가 되었는가?

크기가 작고 수명이 스코프 안이면 스택, 크거나 수명이 길면 힙을 쓰며, 재귀·큰 배열은 스택 한도를 넘지 않도록 하세요. 다음으로 메모리 누수(#6-2)를 읽어보면 좋습니다.

다음 글

스택과 힙의 차이를 이해했다면, 이제 힙 메모리를 안전하게 관리하는 방법을 배울 차례입니다. 다음 글: C++ 메모리 누수: 흔한 5가지 패턴과 Valgrind·ASan으로 찾는 방법 - 서버를 다운시킨 메모리 누수 사례와 해결법을 설명합니다.


참고 자료