본문으로 건너뛰기 C++ Stack Overflow | '스택 오버플로우' 가이드

C++ Stack Overflow | '스택 오버플로우' 가이드

C++ Stack Overflow | '스택 오버플로우' 가이드

이 글의 핵심

C++ Stack Overflow: "스택 오버플로우" 가이드. Stack Overflow란?·발생 원인.

Stack Overflow란?

함수를 호출할 때마다 지역 변수, 매개변수, 복귀 주소를 담을 스택 프레임이 콜 스택 위에 쌓입니다. 이 콜 스택은 힙과 달리 운영체제가 스레드마다 미리 정해둔 고정 크기(보통 리눅스에서 8MB 안팎)만 사용할 수 있는데, 함수 호출이 종료 없이 계속 쌓이거나 한 프레임 안에서 너무 큰 지역 변수를 잡으면 이 한계를 초과하게 됩니다. 이렇게 스택 메모리 한계를 넘어서는 순간 운영체제가 프로그램을 즉시 강제 종료시키는 것이 스택 오버플로우이며, 아래 두 예제는 그 대표적인 원인인 무한 재귀와 과도하게 큰 지역 배열을 보여줍니다.

// ❌ 무한 재귀
void func() {
    func();  // 스택 오버플로우
}

// ❌ 큰 지역 변수
void func() {
    int arr[1000000];  // 스택 부족
}

발생 원인

스택 오버플로우로 이어지는 원인은 크게 네 가지 패턴으로 정리할 수 있습니다. 종료 조건 없는 무한 재귀, 입력이 커질수록 스택이 선형으로 쌓이는 깊은 재귀, 함수 하나가 통째로 큰 배열을 스택에 올려버리는 경우, 그리고 A가 B를, B가 C를 호출하는 식으로 여러 함수를 거치며 스택이 조금씩 쌓이는 과도한 호출 체인이 그것입니다. 아래 네 가지 코드 조각은 각 패턴을 최소한의 형태로 보여줍니다.

// 1. 무한 재귀
void infiniteRecursion() {
    infiniteRecursion();
}

// 2. 깊은 재귀
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  // n이 크면 위험
}

// 3. 큰 지역 변수
void largeArray() {
    int arr[10000000];  // 스택에 너무 큼
}

// 4. 과도한 함수 호출
void a() { b(); }
void b() { c(); }
void c() { d(); }
// ... 계속

실전 예시

이론적인 원인을 알았으니, 이제 실제로 어떤 코드가 위험하고 어떻게 고쳐야 하는지 네 가지 실전 사례로 살펴보겠습니다.

예시 1: 재귀 제한

badFactorialn이 1 이하가 되어도 계속 자기 자신을 호출하는 종료 조건 누락 버그를 담고 있어, 언젠가는 반드시 스택 오버플로우로 이어집니다. factorial처럼 if (n <= 1) return 1;이라는 명확한 종료 조건을 추가하면 재귀 깊이가 n에 비례해서만 늘어나 안전해지지만, 그래도 n이 매우 크면 여전히 위험할 수 있습니다. 가장 근본적인 해결책은 factorialIterative처럼 재귀를 아예 반복문으로 바꾸는 것인데, 이렇게 하면 스택 사용량이 입력 크기와 무관하게 항상 일정하게 유지됩니다.

#include <iostream>

// ❌ 무한 재귀
int badFactorial(int n) {
    return n * badFactorial(n - 1);  // 종료 조건 없음
}

// ✅ 종료 조건
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// ✅ 반복문 사용
int factorialIterative(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

int main() {
    std::cout << factorial(10) << std::endl;
    std::cout << factorialIterative(10) << std::endl;
}

예시 2: 큰 배열 처리

재귀와 무관하게, 함수 하나에서 큰 지역 배열을 선언하는 것만으로도 스택 오버플로우를 유발할 수 있습니다. 100만 개짜리 int 배열은 약 4MB를 차지하는데, 이는 리눅스의 기본 스레드 스택 크기(보통 8MB)의 절반에 달하는 양이라 다른 함수 호출과 겹치면 쉽게 한계를 넘습니다. std::make_unique<int[]>로 힙에 할당하거나 std::vector<int>를 사용하면 같은 크기의 데이터를 스택이 아닌 힙에 두게 되어, 스택 크기 제한과 무관하게 안전하게 다룰 수 있습니다.

#include <vector>
#include <memory>

// ❌ 스택에 큰 배열
void processLargeData() {
    int data[1000000];  // 4MB (위험)
    // 처리
}

// ✅ 힙 할당
void processLargeData() {
    auto data = std::make_unique<int[]>(1000000);
    // 처리
}

// ✅ 벡터 사용
void processLargeData() {
    std::vector<int> data(1000000);
    // 처리
}

예시 3: 꼬리 재귀 최적화

일반 재귀 sumn + sum(n - 1)처럼 재귀 호출의 결과에 추가 연산을 해야 하기 때문에, 모든 재귀 호출이 반환될 때까지 각 프레임을 스택에 남겨두어야 합니다. sumTail처럼 누산기(accumulator) acc를 매개변수로 넘겨 재귀 호출 자체가 함수의 마지막 동작이 되도록(꼬리 위치) 바꾸면, 이론적으로 컴파일러가 이를 반복문과 동등한 코드로 최적화(꼬리 호출 최적화, TCO)할 여지가 생깁니다. 다만 C++ 표준은 TCO를 보장하지 않으므로, 스택 안전성이 정말 중요하다면 sumIterative처럼 애초에 반복문으로 작성하는 편이 컴파일러 최적화 여부에 기대지 않는 확실한 선택입니다.

// ❌ 일반 재귀 (스택 누적)
int sum(int n) {
    if (n <= 0) return 0;
    return n + sum(n - 1);
}

// ✅ 꼬리 재귀 (최적화 가능)
int sumTail(int n, int acc = 0) {
    if (n <= 0) return acc;
    return sumTail(n - 1, acc + n);
}

// ✅ 반복문
int sumIterative(int n) {
    int result = 0;
    for (int i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

int main() {
    std::cout << sumTail(10000) << std::endl;
    std::cout << sumIterative(10000) << std::endl;
}

예시 4: 깊이 제한

재귀를 완전히 없애기 어려운 상황(예: 트리 순회, 신뢰할 수 없는 외부 입력에 의존하는 재귀)에서는 깊이 카운터를 두어 한계를 넘으면 예외를 던지는 방어적 코드가 유용합니다. 아래 fibonacci 함수는 depth 매개변수로 현재 재귀 깊이를 추적하다가 MAX_DEPTH를 넘으면 std::runtime_error를 던져, 스택이 실제로 고갈되어 프로그램이 강제 종료되기 전에 통제된 방식으로 실패를 알립니다. 다만 순수 재귀 피보나치는 깊이 제한과 별개로 같은 값을 반복 계산하는 비효율이 있으므로, fibonacciMemo처럼 이미 계산한 값을 memo 벡터에 캐싱하는 메모이제이션을 적용하면 호출 횟수 자체가 크게 줄어 성능과 스택 안전성을 동시에 개선할 수 있습니다.

#include <iostream>

// 재귀 깊이 제한
const int MAX_DEPTH = 1000;

int fibonacci(int n, int depth = 0) {
    if (depth > MAX_DEPTH) {
        throw std::runtime_error("재귀 깊이 초과");
    }
    
    if (n <= 1) return n;
    return fibonacci(n - 1, depth + 1) + fibonacci(n - 2, depth + 1);
}

// ✅ 메모이제이션
int fibonacciMemo(int n, std::vector<int>& memo) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    
    memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
    return memo[n];
}

int main() {
    std::vector<int> memo(100, -1);
    std::cout << fibonacciMemo(50, memo) << std::endl;
}

스택 크기 확인

재귀 함수가 실제로 스택을 얼마나 소비하고 있는지 눈으로 확인하고 싶을 때, 지역 변수의 주소를 이용한 간단한 트릭을 쓸 수 있습니다. 스택은 보통 높은 주소에서 낮은 주소 방향으로 자라기 때문에, 처음 호출 시점의 지역 변수 주소(stackStart)와 현재 호출의 지역 변수 주소(&dummy) 사이의 거리를 계산하면 그동안 얼마나 많은 스택이 사용되었는지 대략적으로 추정할 수 있습니다. 아래 recursiveCheck처럼 매 재귀 호출마다 checkStackSize()를 호출해 사용량을 출력해보면, 재귀가 한 단계 깊어질 때마다 스택 사용량이 일정하게 증가하는 패턴을 직접 확인할 수 있어 어느 깊이에서 위험해지는지 감을 잡는 데 도움이 됩니다.

#include <iostream>

void checkStackSize() {
    int dummy;
    static int* stackStart = nullptr;
    
    if (stackStart == nullptr) {
        stackStart = &dummy;
    }
    
    size_t used = std::abs(stackStart - &dummy) * sizeof(int);
    std::cout << "스택 사용: " << used << " bytes" << std::endl;
}

void recursiveCheck(int n) {
    checkStackSize();
    if (n > 0) {
        recursiveCheck(n - 1);
    }
}

자주 발생하는 문제

실무 코드 리뷰에서 스택 오버플로우로 이어지는 버그는 대부분 아래 네 가지 패턴 중 하나에서 비롯됩니다. 각각의 증상과 고치는 방법을 순서대로 짚어보겠습니다.

문제 1: 종료 조건 누락

가장 흔하고 발견하기 쉬운 실수는 재귀 함수에 종료 조건 자체를 빼먹는 것입니다. 아래 countdownn이 얼마나 작아지든 상관없이 계속 자기 자신을 호출하기 때문에, 실행하면 거의 즉시 스택이 고갈됩니다. 이런 함수는 재귀 호출 앞에 if (n <= 0) return;처럼 명확한 종료 조건을 추가하는 것만으로 해결되며, 재귀 함수를 작성할 때는 항상 “이 재귀가 언제 끝나는가”를 가장 먼저 코드로 표현하는 습관을 들이는 것이 좋습니다.

// ❌ 종료 조건 없음
void countdown(int n) {
    std::cout << n << std::endl;
    countdown(n - 1);  // 무한 재귀
}

// ✅ 종료 조건
void countdown(int n) {
    if (n <= 0) return;
    std::cout << n << std::endl;
    countdown(n - 1);
}

문제 2: 상호 재귀

두 개 이상의 함수가 서로를 호출하는 상호 재귀는 종료 조건 누락 버그를 더 알아채기 어렵게 만듭니다. 아래 예제에서 funcAfuncB를, funcB가 다시 funcA를 호출하는데 어느 쪽에도 종료 조건이 없어 두 함수가 번갈아 호출되며 스택이 계속 쌓입니다. bt(backtrace)로 콜 스택을 봤을 때 funcAfuncB가 번갈아 반복되는 패턴이 보인다면 상호 재귀에 종료 조건이 빠졌다는 신호이므로, 두 함수 모두에 같은 기준(n <= 0)으로 조건을 걸어 동시에 종료되도록 맞춰야 합니다.

// ❌ 상호 재귀 (종료 조건 부족)
void funcA(int n);
void funcB(int n);

void funcA(int n) {
    funcB(n);
}

void funcB(int n) {
    funcA(n);
}

// ✅ 종료 조건 추가
void funcA(int n) {
    if (n <= 0) return;
    funcB(n - 1);
}

void funcB(int n) {
    if (n <= 0) return;
    funcA(n - 1);
}

문제 3: 구조체 배열

배열 하나가 아니라 큰 구조체의 배열을 스택에 선언하는 경우도 놓치기 쉬운 함정입니다. 아래 LargeStructint[10000]을 멤버로 가지고 있어 구조체 하나만 해도 약 40KB인데, 이런 구조체를 arr[100]처럼 지역 배열로 선언하면 순식간에 4MB를 스택에서 차지합니다. 구조체 정의만 보고는 얼마나 큰지 가늠하기 어려울 수 있으므로, 큰 멤버 배열을 가진 구조체를 여러 개 스택에 쌓아야 한다면 std::make_unique<LargeStruct[]>로 힙에 할당하는 것을 기본 원칙으로 삼는 것이 안전합니다.

struct LargeStruct {
    int data[10000];
};

// ❌ 스택에 큰 구조체 배열
void func() {
    LargeStruct arr[100];  // 4MB
}

// ✅ 동적 할당
void func() {
    auto arr = std::make_unique<LargeStruct[]>(100);
}

문제 4: 가변 길이 배열

GCC나 Clang 같은 일부 컴파일러는 C99의 가변 길이 배열(VLA)을 확장 기능으로 지원해 int arr[n];처럼 런타임에 크기가 결정되는 배열을 스택에 선언할 수 있게 해줍니다. 하지만 VLA는 C++ 표준이 아니며, 크기가 되는 n이 함수 호출자가 넘긴 값이라 어디까지 커질지 컴파일 시점에 알 수 없다는 근본적인 위험을 안고 있습니다. 크기를 예측할 수 없는 배열이 필요하다면 처음부터 std::vector<int> arr(n);으로 힙에 할당해, 크기가 아무리 커져도 스택 오버플로우와 무관하게 안전하도록 만드는 것이 표준적이고 이식성 있는 해법입니다.

// ❌ VLA (C99, 일부 컴파일러)
void func(int n) {
    int arr[n];  // n이 크면 위험
}

// ✅ 벡터 사용
void func(int n) {
    std::vector<int> arr(n);
}

디버깅 방법

스택 오버플로우가 의심될 때는 근본 원인을 고치기 전에 우선 재현하고 관찰하는 과정이 필요합니다. ulimit -s unlimited로 스택 한계를 임시로 풀어 버그가 다른 증상(예: 무한 루프)인지 진짜 스택 문제인지 구분하고, gdbbacktrace로 크래시 시점의 콜 스택을 확인해 같은 함수가 비정상적으로 깊게 반복되고 있는지 살펴봅니다. AddressSanitizer(-fsanitize=address)를 붙여 컴파일하면 일부 환경에서는 스택 오버플로우 발생 지점을 더 명확한 메시지로 알려주기도 합니다.

# 1. 스택 크기 늘리기 (임시 해결)
# Linux
ulimit -s unlimited

# Windows (링커 옵션)
# /STACK:reserve[,commit]

# 2. 디버거 사용
gdb ./program
(gdb) run
# 스택 오버플로우 발생 시
(gdb) backtrace

# 3. AddressSanitizer
g++ -fsanitize=address -g program.cpp

방지 방법

지금까지 다룬 개별 사례들을 종합하면, 스택 오버플로우를 예방하는 전략은 결국 네 가지로 압축됩니다. 재귀를 반복문으로 바꾸고, 큰 데이터는 힙에 두고, 불가피한 재귀에는 깊이 제한을 걸고, 중복 계산이 많은 재귀는 메모이제이션으로 호출 자체를 줄이는 것입니다. 아래 코드는 이 네 가지 전략을 각각 최소 형태로 정리한 것으로, 새로운 재귀 함수를 작성할 때 체크리스트처럼 점검해볼 수 있습니다.

// 1. 재귀 -> 반복문
int sum(int n) {
    int result = 0;
    for (int i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

// 2. 큰 데이터 -> 힙
void process() {
    auto data = std::make_unique<int[]>(1000000);
}

// 3. 깊이 제한
int recursive(int n, int depth = 0) {
    if (depth > MAX_DEPTH) {
        throw std::runtime_error("깊이 초과");
    }
    // ...
}

// 4. 메모이제이션
std::unordered_map<int, int> cache;

int compute(int n) {
    if (cache.count(n)) {
        return cache[n];
    }
    // 계산
    cache[n] = result;
    return result;
}

스택 vs 힙

스택 오버플로우를 근본적으로 이해하려면 스택과 힙의 성격 차이를 알아야 합니다. 스택은 포인터 하나만 증가·감소시키면 되는 구조라 할당·해제가 매우 빠르지만 크기가 스레드 생성 시점에 고정되어 있고, 힙은 할당자가 빈 블록을 찾아야 해서 상대적으로 느리지만 사용 가능한 메모리 한도까지 유연하게 늘어날 수 있습니다. 아래 예제와 주석에 정리된 기준처럼, 대략 1KB를 넘지 않고 함수 범위 안에서만 쓰이는 데이터는 스택에, 그보다 크거나 함수 밖으로 수명이 이어져야 하는 데이터는 힙에 두는 것이 실무에서 무난한 선택 기준입니다.

// 스택: 빠르지만 제한적
void stackAlloc() {
    int arr[100];  // 빠름
}

// 힙: 느리지만 큰 메모리
void heapAlloc() {
    auto arr = std::make_unique<int[]>(1000000);  // 유연
}

// 선택 기준
// - 작은 데이터 (<1KB): 스택
// - 큰 데이터 (>1KB): 힙
// - 수명이 함수 범위: 스택
// - 수명이 긴 경우: 힙

스택 크기 제한을 알아두기

OS·링커 설정에 따라 기본 스택 크기가 크게 달라집니다. 대략적인 감각은 다음과 같습니다.

환경기본 스택(대략)비고
Linux 스레드(main)ulimit -s (종종 8MiB)pthread 생성 시 pthread_attr_setstacksize로 조정
Windows 스레드링커 /STACK (기본 1MiB 등)실행 파일·스레드 생성 옵션 확인
macOSulimit -s필요 시 늘리되, 근본 해결은 알고리즘·할당 방식

중요: 스택을 무제한으로 키우는 것은 임시 방편입니다. 재귀 깊이나 대형 프레임 문제는 설계를 바꿔 스택 사용량을 줄이는 것이 맞습니다.

재귀 깊이 문제

같은 알고리즘이라도 입력 크기에 따라 호출 깊이가 선형·지수로 늘어납니다. 피보나치의 순수 재귀는 깊이도 크고 호출 수도 폭발하므로, 반복문·메모이제이션·행렬 거듭제곱 등으로 바꾸는 편이 낫습니다.

깊이가 고정되어 있어도(예: 트리 DFS) 최악 깊이가 스택 한계에 근접하면 명시적 스택(std::vector로 노드 스택)으로 바꾸는 것이 안전합니다.

큰 지역 변수와 alloca

VLA(가변 길이 배열)는 C++ 표준이 아니며, 일부 확장으로 허용되더라도 n이 크면 한 번에 스택을 크게 씁니다. alloca도 마찬가지로 스택 사용량이 호출마다 달라져 예측이 어렵습니다. 크기가 런타임에 커질 수 있으면 vectorunique_ptr<T[]>에 두세요.

해결 방법 요약

  1. 힙 할당: std::vector, std::unique_ptr<T[]> — 크고 가변적인 버퍼에 적합.
  2. 반복문으로 전환: 꼬리 재귀 형태로 바꿀 수 있으면 스택 깊이를 없앨 수 있습니다.
  3. 꼬리 재귀: C++ 표준은 꼬리 호출 최적화(TCO)를 보장하지 않습니다. TCO에 의존하기보다 반복문으로 바꾼 버전을 유지보수 기준으로 삼는 것이 안전합니다.
  4. 재귀 깊이 상한 + 에러: 신뢰할 수 없는 입력에서는 깊이 카운터로 조기 실패.
  5. 스택 크기 조정: ulimit -s(Linux), MSVC /STACK(링커) 등은 배포 문서에 명시하며, 근본 원인과는 분리해 다룹니다.

디버깅 (스택 오버플로 의심 시)

  • 증상: SIGSEGV가 스택 끝 근처에서 나거나, 재귀 직후 크래시.
  • gdb / lldb: 크래시 시 bt가 매우 깊거나 같은 함수가 반복되면 재귀 의심. frame 이동하며 지역 변수 크기를 추정합니다.
  • AddressSanitizer: 일부 환경에서 스택 오버플로를 보고하기도 하지만, 보장 도구는 아닙니다. 플랫폼별 가드 페이지 동작에 의존합니다.
  • 컴파일러 경고: 큰 지역 배열에 대한 경고(-Wstack-usage= 등, GCC)를 켜 두면 예방에 도움이 됩니다. 실제로 재현해볼 때는 아래처럼 현재 한계를 먼저 확인한 뒤 임시로 풀어 문제 입력을 다시 실행해보는 순서가 유용합니다.
# Linux: 현재 스택 한계 확인
ulimit -s

# 깊은 재귀 재현 시 (임시)
ulimit -s unlimited
./repro

FAQ

Q1: Stack Overflow는 언제?

A:

  • 무한 재귀
  • 깊은 재귀
  • 큰 지역 변수

Q2: 탐지 방법은?

A:

  • 디버거 backtrace
  • AddressSanitizer
  • 스택 크기 모니터링

Q3: 방지 방법은?

A:

  • 재귀 -> 반복문
  • 힙 할당
  • 깊이 제한

Q4: 스택 크기는?

A:

  • Linux: 보통 8MB
  • Windows: 보통 1MB
  • 변경 가능 (ulimit, 링커)

Q5: 재귀 vs 반복문?

A:

  • 재귀: 간결, 스택 사용
  • 반복문: 빠름, 안전

Q6: Stack Overflow 학습 리소스는?

A:

  • “C++ Primer”
  • “Effective C++”
  • GDB 문서

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, stack-overflow, stack, debugging, recursion 등으로 검색하시면 이 글이 도움이 됩니다.