C++ 스택 오버플로우 크래시: 깊은 재귀·큰 지역 배열 진단과 스택 크기 조정
이 글의 핵심
스택 오버플로우는 포인터를 쓰지 않았는데도 세그폴트로 나타나는 경우가 많아 원인을 찾기 어렵습니다. 무한·깊은 재귀, 큰 지역 배열, 상호 재귀, VLA·alloca, 기본 스택이 작은 스레드 같은 원인과, 재귀를 명시적 스택으로 바꾸거나 힙으로 옮기는 해결법, 그리고 GCC의 -fstack-usage·-Wstack-usage로 빌드 단계에서 미리 찾는 방법을 정리합니다.
들어가며: “재귀 함수를 호출했더니 프로그램이 크래시…”
스택 오버플로우(Stack Overflow)는 스택 메모리가 부족해서 발생하는 크래시입니다. 주로 무한 재귀, 큰 지역 변수, 깊은 재귀 호출이 원인입니다.
// ❌ 무한 재귀
void foo() {
foo(); // 종료 조건 없음 → 스택 오버플로우
}
int main() {
foo(); // 크래시
}
// Segmentation Fault (Linux)
// Stack Overflow (Windows)
포인터를 잘못 쓰지 않았는데도 세그폴트가 나는 이유는 스택의 끝이 가드 페이지로 보호되어 있기 때문입니다. 운영체제는 스레드 스택 바로 아래에 접근이 금지된 페이지를 하나 두고, 스택이 한계를 넘어 그 페이지를 건드리면 하드웨어 예외가 발생합니다. Linux에서는 이것이 SIGSEGV로 전달되어 Segmentation fault (core dumped)가 찍히고, Windows에서는 0xC00000FD(STATUS_STACK_OVERFLOW) 예외로 프로그램이 종료됩니다. 즉 스택 오버플로우는 잘못된 포인터 접근과 같은 신호로 나타나므로, 에러 메시지만으로는 둘을 구분할 수 없고 백트레이스를 봐야 합니다.
더 곤란한 점은 스택이 넘친 상태에서는 복구가 사실상 불가능하다는 것입니다. 시그널 핸들러조차 실행할 스택이 없으므로, 로그를 남기려면 sigaltstack으로 별도 스택을 준비해야 합니다. C++ 예외처럼 catch로 잡아 계속 실행하는 방식은 쓸 수 없으니, 오버플로우가 나기 전에 막는 것이 유일한 대책입니다.
스택 오버플로우란?
스택 메모리
스택은 함수 호출 시 지역 변수와 반환 주소를 저장하는 메모리 영역입니다.
void foo() {
int x = 42; // 스택에 저장
bar();
} // x 자동 해제
void bar() {
double y = 3.14; // 스택에 저장
} // y 자동 해제
스택 크기 제한:
- Linux: 기본 8MB
- Windows: 기본 1MB
- macOS: 기본 8MB (메인 스레드), 보조 스레드는 512KB
스택이 이렇게 작은 이유는 스레드마다 별도의 스택이 필요하기 때문입니다. 스레드 수천 개가 각자 수백 MB의 스택을 예약하면 가상 주소 공간과 커밋 메모리가 금방 바닥나므로, 운영체제는 작은 기본값을 두고 큰 데이터는 힙을 쓰도록 유도합니다. 스택 할당이 빠른 것도 이 제약 덕분입니다. 함수에 들어갈 때 스택 포인터를 프레임 크기만큼 옮기기만 하면 되므로, 크기 제한만 지키면 스택은 가장 싸고 캐시 친화적인 메모리입니다.
스택 오버플로우 발생
스택 메모리:
[함수1 지역 변수]
[함수2 지역 변수]
[함수3 지역 변수]
...
[함수10000 지역 변수] ← 스택 한계 초과 → 크래시!
4가지 주요 원인
원인 1: 무한 재귀
// ❌ 종료 조건 없음
int factorial(int n) {
return n * factorial(n - 1); // 무한 재귀
}
int main() {
factorial(5); // 크래시
}
해결:
// ✅ 종료 조건 추가
int factorial(int n) {
if (n <= 1) return 1; // 종료 조건
return n * factorial(n - 1);
}
원인 2: 큰 지역 변수
// ❌ 스택에 큰 배열
void process() {
int bigArray[1000000]; // 4MB → 스택 한계 초과
// ...
}
int main() {
process(); // 크래시
}
해결:
// ✅ 힙 할당
void process() {
std::vector<int> bigArray(1000000); // 힙에 할당
// ...
}
// 또는
void process() {
auto bigArray = std::make_unique<int[]>(1000000);
// ...
}
큰 지역 배열은 “선언만 했는데 죽는다”는 점에서 당황스럽습니다. 배열을 쓰기 전에, 함수에 들어가는 순간 스택 포인터가 4MB 아래로 이동하기 때문입니다. Windows의 1MB 기본 스택에서는 이 함수가 호출되자마자 종료되고, Linux의 8MB에서는 멀쩡히 돌다가 이 함수를 조금 더 깊은 호출 경로에서 부르는 순간에만 터집니다. 그래서 “Linux에서 개발할 땐 됐는데 Windows 빌드에서만 크래시”라는 보고가 이 원인에서 자주 나옵니다. std::vector로 바꾸면 스택에는 포인터·크기·용량 세 개(보통 24바이트)만 남고 데이터는 힙으로 갑니다. 크기가 컴파일 타임에 고정되어 있고 스레드 간에 공유하지 않는다면 static int bigArray[1000000];로 정적 영역에 두는 방법도 있지만, 재진입이나 멀티스레드에서 안전하지 않으므로 주의해야 합니다.
원인 3: 깊은 재귀
// ❌ 재귀 깊이 제한 없음 (연결 리스트 길이만큼 깊어짐)
size_t length(const ListNode* node) {
if (!node) return 0;
return 1 + length(node->next); // 노드 100만 개 → 호출 100만 단계
}
// ⚠️ 이 코드는 깊이 문제가 아님
int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main() {
fibonacci(50); // 재귀 깊이는 최대 50 → 스택은 안전, 하지만 호출이 수백억 번이라 매우 느림
// (또한 fib(47)부터 int 범위를 넘음)
}
두 함수는 자주 함께 언급되지만 문제가 전혀 다릅니다. length는 호출 깊이가 입력 크기에 비례해서 노드 수만 많으면 스택이 넘칩니다. 반면 fibonacci(50)은 한 번에 스택에 쌓이는 프레임이 최대 50개뿐이라 스택 오버플로우와는 무관하고, 문제는 호출 횟수가 지수적으로 늘어나는 시간 복잡도입니다. 아래의 반복문·메모이제이션 해결책은 둘 다에 쓸 수 있지만, 스택 문제를 진단할 때는 “한 시점에 쌓이는 최대 깊이”가 입력에 따라 얼마나 커지는지를 기준으로 봐야 합니다.
해결:
// ✅ 반복문으로 전환
int fibonacci(int n) {
if (n <= 1) return n;
int prev = 0, curr = 1;
for (int i = 2; i <= n; ++i) {
int next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
// ✅ 또는 메모이제이션
std::unordered_map<int, int> memo;
int fibonacci(int n) {
if (n <= 1) return n;
auto it = memo.find(n);
if (it != memo.end()) return it->second;
int result = fibonacci(n - 1) + fibonacci(n - 2);
memo[n] = result;
return result;
}
원인 4: 중첩 함수 호출
// ❌ 깊은 호출 체인
void a() { b(); }
void b() { c(); }
void c() { d(); }
// ... (1000개 함수)
void z() {
int bigArray[10000]; // 각 함수마다 큰 지역 변수
}
int main() {
a(); // 크래시
}
해결: 지역 변수를 힙으로 이동.
재귀 깊이 제한
재귀 깊이 카운터
// ✅ 깊이 제한
int factorial(int n, int depth = 0) {
const int MAX_DEPTH = 1000;
if (depth > MAX_DEPTH) {
throw std::runtime_error("Recursion too deep");
}
if (n <= 1) return 1;
return n * factorial(n - 1, depth + 1);
}
깊이 제한의 상한값은 “스택 크기 ÷ 한 프레임 크기”로 대략 계산할 수 있지만, 프레임 크기는 최적화 수준과 컴파일러에 따라 달라지고 호출자가 이미 쓴 스택도 알 수 없으므로 여유를 크게 두어야 합니다. 이 방식의 장점은 크래시(복구 불가능) 대신 예외(복구 가능) 로 실패를 바꾼다는 점입니다. 외부 입력을 재귀로 처리하는 코드라면 이 차이가 서비스 전체가 죽느냐, 요청 하나만 실패하느냐를 가릅니다.
반복문으로 전환
// 재귀 버전
int sum(int n) {
if (n <= 0) return 0;
return n + sum(n - 1);
}
// ✅ 반복문 버전
int sum(int n) {
int result = 0;
for (int i = 1; i <= n; ++i) {
result += i;
}
return result;
}
스택 크기 조정
Linux: ulimit
# 현재 스택 크기 확인 (KB)
ulimit -s
# 스택 크기 늘리기 (16MB)
ulimit -s 16384
# 무제한 (비권장)
ulimit -s unlimited
ulimit -s는 그 셸에서 새로 시작하는 프로세스의 메인 스레드에만 적용됩니다. 이미 실행 중인 프로세스에는 영향이 없고, systemd 서비스라면 LimitSTACK=, Docker라면 --ulimit stack=...처럼 실행 환경마다 따로 설정해야 합니다. 개발 머신에서 ulimit을 늘려 문제를 “해결”했다가 운영 서버의 기본값에서 다시 크래시가 나는 경우가 흔하므로, 스택 크기 조정은 증상을 가리는 임시 조치로 보는 편이 맞습니다.
Windows: 링커 옵션
Visual Studio:
프로젝트 속성 → 링커 → 시스템 → 스택 예약 크기
기본: 1MB → 16MB로 변경
# 또는 링커 플래그
cl /F16777216 main.cpp # 16MB 스택
CMake
# Linux (glibc 메인 스레드는 ulimit 값을 따르므로 효과 없음, musl에서는 스레드 기본 크기에 반영)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,stack-size=16777216")
# Windows
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} /STACK:16777216")
힙 할당으로 전환
큰 배열을 힙으로
// ❌ 스택 (4MB)
void process() {
int bigArray[1000000];
// ...
}
// ✅ 힙 (vector)
void process() {
std::vector<int> bigArray(1000000);
// ...
}
// ✅ 힙 (unique_ptr)
void process() {
auto bigArray = std::make_unique<int[]>(1000000);
// ...
}
재귀를 명시적 스택으로
// ❌ 재귀 (스택 오버플로우 가능)
void traverse(TreeNode* node) {
if (!node) return;
process(node);
traverse(node->left);
traverse(node->right);
}
// ✅ 명시적 스택 (힙 사용)
void traverse(TreeNode* node) {
std::stack<TreeNode*> stack;
stack.push(node);
while (!stack.empty()) {
TreeNode* current = stack.top();
stack.pop();
if (!current) continue;
process(current);
stack.push(current->right);
stack.push(current->left);
}
}
명시적 스택으로 바꾸면 깊이 제한이 스택 크기(수 MB)에서 힙 크기(수 GB)로 옮겨 갑니다. left보다 right를 먼저 넣는 이유는 스택이 후입선출이라, 나중에 넣은 left가 먼저 꺼내져야 재귀 버전과 같은 전위 순회 순서가 되기 때문입니다. 대신 치르는 비용도 있습니다. 재귀 버전에서는 “돌아온 뒤 할 일”을 호출 스택이 자동으로 기억해 주지만, 명시적 스택에서는 후위 순회처럼 자식 처리 후에 부모를 처리해야 하는 경우 (노드, 방문 상태) 쌍을 직접 저장해야 해서 코드가 꽤 복잡해집니다. 저는 트리 깊이가 입력에 의해 결정되는지부터 확인하고, 균형 트리처럼 깊이가 log n으로 제한된다면 읽기 쉬운 재귀를 그대로 두는 편입니다. 균형이 보장되지 않는 이진 탐색 트리에 정렬된 데이터를 넣으면 사실상 연결 리스트가 되어 깊이가 n이 된다는 점은 꼭 기억해야 합니다.
꼬리 재귀 최적화
꼬리 재귀란?
꼬리 재귀는 재귀 호출이 함수의 마지막 연산인 경우입니다. 컴파일러가 반복문으로 최적화해 스택을 사용하지 않습니다.
// ❌ 꼬리 재귀 아님 (재귀 호출 후 곱셈)
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // 재귀 호출 후 n을 곱함
}
// ✅ 꼬리 재귀 (재귀 호출이 마지막)
int factorialTail(int n, int acc = 1) {
if (n <= 1) return acc;
return factorialTail(n - 1, n * acc); // 재귀 호출이 마지막
}
컴파일러 최적화 확인
# -O2 이상에서 꼬리 재귀 최적화
g++ -O2 -S main.cpp
# 어셈블리 확인 (jmp로 변환되면 최적화됨)
cat main.s | grep -A 10 factorialTail
꼬리 재귀는 이론적으로 깔끔한 해결책이지만 C++에서는 믿고 쓰기 어렵습니다. 표준이 꼬리 호출 제거를 요구하지 않으므로 컴파일러와 최적화 수준에 따라 결과가 달라지고, 디버그 빌드(-O0)에서는 거의 적용되지 않아 디버그 빌드에서만 스택 오버플로우가 나는 현상이 생깁니다. 또 함수 안에 std::string이나 std::vector 같은 지역 객체가 있으면 재귀 호출 뒤에 소멸자가 실행되어야 하므로 겉보기에는 마지막 호출이어도 꼬리 호출이 아닙니다. Clang의 [[clang::musttail]] 속성은 꼬리 호출을 강제하고 불가능하면 컴파일 에러를 내지만 이식성이 없습니다. 스택 안전성이 필요하다면 반복문이나 명시적 스택으로 바꾸는 것이 확실합니다.
놓치기 쉬운 원인과 미리 찾는 도구
상호 재귀
void parseValue(Node& n);
void parseArray(Node& n) { for (auto& c : n.children) parseValue(c); }
void parseValue(Node& n) { if (n.isArray()) parseArray(n); /* ... */ }
함수 하나만 보면 재귀가 보이지 않지만 parseValue → parseArray → parseValue로 서로를 부릅니다. JSON·XML 파서, 식 평가기처럼 문법을 따라 내려가는 코드에서 흔하고, 백트레이스에 두 함수가 번갈아 수천 번 찍히는 것으로 알아볼 수 있습니다. 신뢰할 수 없는 입력을 파싱한다면 중첩 깊이 상한(예: 512)을 두고 넘으면 에러를 돌려주는 것이 사실상 필수입니다. [[[[...]]]]처럼 괄호만 깊게 중첩한 입력 하나로 서버를 죽일 수 있기 때문입니다.
가변 길이 배열(VLA)과 alloca
void process(int n) {
int buf[n]; // ❌ GCC·Clang 확장, C++ 표준 아님
std::vector<int> safe(n); // ✅
}
VLA는 n에 따라 스택 사용량이 호출마다 달라져서, 작은 입력으로는 잘 돌다가 큰 입력에서만 터집니다. alloca도 같습니다. GCC·Clang에서 -Wvla를 켜면 사용 위치를 경고해 줍니다.
스레드의 스택은 더 작을 수 있다
메인 스레드에서는 멀쩡하던 재귀가 워커 스레드로 옮기면 터지는 경우가 있습니다. 새 스레드의 스택 크기는 메인 스레드의 ulimit -s와 별개로 정해지고(glibc는 보통 ulimit -s 값을 기본으로 쓰지만, musl 기반 Alpine은 기본 128KiB 수준으로 훨씬 작습니다), 컨테이너 이미지를 바꾸기만 해도 증상이 생길 수 있습니다. std::thread에는 스택 크기를 지정하는 API가 없으므로, 필요하면 pthread_attr_setstacksize(POSIX)나 Boost.Thread의 attributes::set_stack_size를 쓰고, Windows에서는 CreateThread의 dwStackSize나 링커 /STACK으로 조정합니다.
빌드할 때 미리 찾기
g++ -O2 -fstack-usage -Wstack-usage=65536 -Wvla -c parser.cpp
# parser.su: 함수별 스택 프레임 크기
# parser.cpp:2:5:int f(int) 4000016 static
# warning: stack usage is 4000016 bytes [-Wstack-usage=]
-fstack-usage는 함수마다 스택 프레임 크기를 .su 파일로 남기고, -Wstack-usage=N은 N바이트를 넘는 함수를 경고합니다. 위 결과는 약 40KB짜리 구조체 100개를 지역 배열로 잡은 함수에 대해 GCC가 내는 출력 형태로, 한 프레임이 약 4MB라 Windows 기본 스택(1MB)에서는 호출 즉시 오버플로우입니다. CI에서 -Wstack-usage=를 켜 두면 이런 함수가 코드 리뷰에서 빠져나가지 않습니다. 재귀 깊이는 이 정적 분석으로 알 수 없으므로, 재귀 함수는 앞의 깊이 카운터나 명시적 스택 방식과 함께 봐야 합니다.
실전 사례 분석
사례 1: JSON 파싱 스택 오버플로우
증상: 깊게 중첩된 JSON 파싱 시 크래시.
// ❌ 재귀 파싱
Json parse(const std::string& str, size_t& pos) {
if (str[pos] == '{') {
Json obj;
// ... 재귀적으로 파싱
return obj;
}
// ...
}
// 중첩 깊이 10000 → 크래시
해결:
// ✅ 명시적 스택
Json parse(const std::string& str) {
std::stack<Json> stack;
// ... 반복문으로 파싱
return stack.top();
}
사례 2: 디렉토리 순회
증상: 깊은 디렉토리 구조에서 크래시.
// ❌ 재귀 순회
void traverseDir(const std::filesystem::path& dir) {
for (const auto& entry : std::filesystem::directory_iterator(dir)) {
if (entry.is_directory()) {
traverseDir(entry.path()); // 재귀
} else {
processFile(entry.path());
}
}
}
해결:
// ✅ 명시적 스택
void traverseDir(const std::filesystem::path& root) {
std::stack<std::filesystem::path> stack;
stack.push(root);
while (!stack.empty()) {
auto dir = stack.top();
stack.pop();
for (const auto& entry : std::filesystem::directory_iterator(dir)) {
if (entry.is_directory()) {
stack.push(entry.path());
} else {
processFile(entry.path());
}
}
}
}
사실 디렉터리 순회에서는 파일 시스템의 경로 길이 제한 때문에 깊이가 수천 단계에 이르는 경우가 드뭅니다. 실무에서 더 흔한 원인은 심볼릭 링크 순환입니다. entry.is_directory()는 링크를 따라가므로, 상위 디렉터리를 가리키는 링크가 하나만 있어도 재귀 버전은 무한 재귀로 크래시하고, 명시적 스택 버전은 크래시 대신 끝없이 돌며 메모리를 채웁니다. 즉 명시적 스택은 스택 오버플로우를 막을 뿐 무한 순회 자체를 막지는 못합니다. 표준 라이브러리의 std::filesystem::recursive_directory_iterator는 기본적으로 디렉터리 심볼릭 링크를 따라가지 않으므로, 직접 구현하기보다 이것을 쓰는 편이 안전합니다. 권한이 없는 디렉터리에서 filesystem_error 예외가 나는 문제는 directory_options::skip_permission_denied로 처리할 수 있습니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 스택 오버플로우인지 일반 세그폴트인지 어떻게 구분하나요?
A. 디버거에서 크래시 시점의 백트레이스(bt)를 확인했을 때 같은 함수 프레임이 수천 개 이상 반복된다면 재귀 깊이로 인한 스택 오버플로우일 가능성이 높습니다. AddressSanitizer를 켜면 stack-overflow on address처럼 원인을 직접 표시해 주고, Windows에서는 예외 코드 0xC00000FD(STATUS_STACK_OVERFLOW)로 구분할 수 있습니다. 반복 프레임 없이 특정 지점에서 바로 죽는다면 큰 지역 배열이나 포인터 오류를 먼저 의심하면 됩니다.