본문 바로가기

스택 오버플로우

@iamrain2025. 10. 1. 10:04

프로그램이 실행될 때, 운영체제는 해당 프로세스를 위해 고유한 메모리 공간을 할당한다. 이 공간은 크게 코드, 데이터, 힙, 그리고 스택(Stack) 영역으로 나뉜다. 이 중 스택 영역은 함수의 호출과 반환, 지역 변수 저장 등 프로그램의 실행 흐름을 관리하는 데 핵심적인 역할을 한다. 하지만 스택은 크기가 제한된 공간이므로, 잘못 사용하면 할당된 공간을 넘어서는 스택 오버플로우(Stack Overflow) 에러가 발생하여 프로그램이 비정상적으로 종료될 수 있다.

1. 스택 메모리(Stack Memory)의 동작 원리

스택은 이름 그대로 접시를 쌓는 것처럼 데이터를 쌓는 LIFO(Last-In, First-Out) 방식의 자료구조다.

  • 스택 프레임 (Stack Frame):
    함수가 호출될 때마다, 해당 함수의 실행에 필요한 정보들을 담은 하나의 묶음인 스택 프레임이 생성되어 스택의 맨 위에 쌓인다(push). 이 프레임에는 다음 정보들이 포함된다.
    • 반환 주소 (Return Address): 함수 실행이 끝난 뒤 돌아가야 할 코드의 주소.
    • 매개변수 (Parameters): 함수에 전달된 인자들.
    • 지역 변수 (Local Variables): 함수 내에서 선언된 모든 변수.
    • 저장된 레지스터 (Saved Registers): 함수 호출 전의 레지스터 상태(e.g., Base Pointer).
  • 동작 과정:
    1. main 함수에서 funcA를 호출하면, funcA의 스택 프레임이 스택에 쌓임.
    2. funcA가 다시 funcB를 호출하면, funcB의 스택 프레임이 funcA의 프레임 위에 쌓임.
    3. funcB의 실행이 끝나면, funcB의 스택 프레임이 스택에서 제거되고(pop), 반환 주소를 참조하여 funcA의 다음 코드로 돌아감.
    4. funcA의 실행이 끝나면, funcA의 스택 프레임도 제거됨.

2. 스택 오버플로우 (Stack Overflow)란?

  • 정의: 스택 영역에 할당된 메모리 크기를 초과하여 데이터가 계속해서 쌓일 때 발생하는 런타임 에러. 스택 포인터가 스택의 경계를 넘어설 때 발생한다.
  • 결과:
    • 프로그램 비정상 종료: 대부분의 운영체제는 스택 경계를 침범하는 메모리 접근을 감지하고, 프로세스를 강제로 종료시킨다. (예: Segmentation Fault, Access Violation)
    • 정의되지 않은 동작 (Undefined Behavior): 스택의 다른 변수나 반환 주소를 훼손하여 프로그램이 예기치 않게 동작할 수 있다.
    • 보안 취약점: 악의적인 사용자가 입력 값을 조작하여 스택 버퍼를 오버플로우시킨 뒤, 함수의 반환 주소를 임의의 코드(악성 코드) 주소로 덮어쓰는 스택 버퍼 오버플로우 공격에 악용될 수 있다.

3. 스택 오버플로우의 주요 원인

A. 과도한 재귀 호출 (Excessive Recursion)

가장 흔하고 대표적인 원인. 재귀 함수가 종료 조건에 도달하지 못하거나, 종료 조건까지의 깊이가 너무 깊을 경우 발생한다.

  • 원리: 재귀 함수가 자기 자신을 호출할 때마다 새로운 스택 프레임이 계속해서 쌓인다. 함수가 반환되기 전까지는 스택 프레임이 제거되지 않으므로, 재귀 깊이가 스택의 한계를 초과하면 오버플로우가 발생한다.
  • 예시: 종료 조건이 없는 피보나치 함수 int fib(int n) { return fib(n-1) + fib(n-2); }

B. 스택에 너무 큰 지역 변수 할당 (Large Local Variables on Stack)

스택의 크기는 운영체제나 컴파일러 설정에 따라 제한적이다(일반적으로 1MB ~ 8MB). 함수 내에서 이 크기를 초과하는 배열이나 객체를 지역 변수로 선언하면, 단 하나의 스택 프레임만으로도 스택 오버플로우가 발생할 수 있다.

  • 원리: 함수가 호출될 때, 컴파일러는 해당 함수의 모든 지역 변수를 저장할 수 있는 크기의 스택 프레임을 확보하려고 시도한다. 이 크기가 가용 스택 공간보다 크면 오버플로우가 발생한다.
  • 예시: void myFunction() { int large_array[1000000]; } (4바이트 * 1,000,000 = 4MB)

4. 계층 구조 관점에서의 동작

스택 오버플로우가 발생할 때, 시스템 내부에서는 다음과 같은 과정이 일어난다.

  1. 사용자 코드: 무한 재귀 함수를 호출하거나 큰 지역 변수를 선언한다.
  2. 컴파일러/런타임: 함수 호출 시 스택 포인터(SP) 레지스터를 계속해서 낮은 주소 방향으로 이동시켜 스택 프레임을 쌓아 나간다.
  3. 운영체제 (OS Kernel): 프로세스가 생성될 때, 커널은 스택을 위한 특정 크기의 가상 메모리 영역을 할당한다. 그리고 이 영역의 끝(경계) 너머에 가드 페이지(Guard Page)라는 접근 금지 페이지를 설정해 둔다.
  4. 하드웨어 (CPU/MMU):
    • 스택 포인터가 계속 이동하다가 마침내 할당된 스택 영역을 벗어나 가드 페이지에 접근을 시도하는 순간, MMU(메모리 관리 장치)는 유효하지 않은 주소에 접근했음을 감지하고 CPU에 페이지 폴트(Page Fault) 예외를 발생시킨다.
    • CPU는 즉시 현재 작업을 중단하고, 미리 등록된 커널의 페이지 폴트 핸들러로 제어를 넘긴다.
  5. 운영체제 (OS Kernel)의 대응:
    • 페이지 폴트 핸들러는 이 접근이 유효한 메모리 요청(예: 스왑된 페이지를 가져오는 것)인지, 아니면 권한 없는 주소에 대한 잘못된 접근인지 확인한다.
    • 가드 페이지 접근은 명백히 잘못된 접근이므로, 커널은 해당 프로세스에 세그멘테이션 폴트(SIGSEGV) 시그널을 보낸다.
    • 이 시그널을 받은 프로세스는 기본 동작으로 즉시 실행을 중단하고 코어 덤프를 남기며 종료된다. 이것이 우리가 '스택 오버플로우' 에러로 마주하는 현상의 실체다.

5. 해결 방안

A. 재귀 호출 최적화

  • 종료 조건 검토: 재귀 함수에 명확하고 도달 가능한 종료 조건이 있는지 반드시 확인하는 것이 가장 기본.
  • 반복문으로 변경 (Iteration): 재귀는 코드를 직관적으로 만들 수 있지만, 스택 오버플로우의 위험이 항상 존재한다. forwhile 같은 반복문을 사용하면 스택 프레임을 추가로 사용하지 않으므로 훨씬 안전하다. 동적 계획법에서 재귀 기반의 메모이제이션(Memoization)을 반복문 기반의 타뷸레이션(Tabulation)으로 바꾸는 것이 좋은 예.
  • 꼬리 재귀 최적화 (Tail Recursion Optimization): 재귀 호출이 함수의 가장 마지막 연산일 경우(return func(...)), 컴파일러가 이를 루프로 변환하여 스택 프레임을 재사용하도록 최적화할 수 있다. 다만, 모든 컴파일러나 모든 상황에서 보장되는 기능은 아니다.

B. 큰 데이터는 힙(Heap)에 할당

스택에 할당하기에 너무 큰 데이터는 힙(Heap) 영역에 동적으로 할당해야 한다. 힙은 스택보다 훨씬 큰 메모리 공간(시스템의 가용 메모리까지)을 사용할 수 있다.

  • 변경 전: void func() { int large_array[1000000]; }
  • 변경 후 (C++ 스타일):
    • void func() { std::vector<int> large_array(1000000); } (vector는 내부적으로 힙을 사용)
    • void func() { auto large_array = std::make_unique<int[]>(1000000); } (스마트 포인터 사용)
  • 변경 후 (C 스타일): int* large_array = new int[1000000]; ... delete[] large_array;

C. 스택 크기 늘리기

알고리즘적으로 문제가 없지만 정당하게 많은 스택 공간이 필요한 경우, 링커 옵션을 통해 프로그램의 기본 스택 크기를 늘릴 수 있다. 하지만 이는 임시방편일 뿐, 근본적인 설계 문제를 가릴 수 있으므로 신중하게 사용해야 한다.

  • Visual Studio: 프로젝트 속성 -> 링커 -> 시스템 -> 스택 예약 크기 (/STACK)
  • GCC/Clang: -Wl,--stack,size_in_bytes

6. 요약

스택 오버플로우는 프로그램이 사용하는 스택 메모리 영역의 한계를 초과하여 발생하는 런타임 에러입니다. 스택은 함수가 호출될 때마다 지역 변수나 반환 주소 등을 저장하는 '스택 프레임'을 쌓는 공간인데, 이 공간이 가득 차서 더 이상 쌓을 곳이 없을 때 오버플로우가 발생합니다.

 

가장 흔한 원인은 두 가지입니다. 첫째는 재귀 함수가 종료 조건 없이 무한히 호출되어 스택 프레임이 계속 쌓이는 경우이고, 둘째는 함수 내에서 너무 큰 배열과 같은 지역 변수를 선언하여 하나의 스택 프레임이 할당된 스택 공간을 초과하는 경우입니다.

이를 해결하기 위한 방법은 원인에 따라 다릅니다.

  • 재귀 호출이 원인이라면, 먼저 종료 조건이 올바른지 확인하고, 가능하다면 반복문으로 코드를 변경하는 것이 가장 안전하고 확실한 방법입니다.
  • 큰 지역 변수가 원인이라면, 해당 변수를 스택이 아닌 힙(Heap)에 동적으로 할당하는 것이 근본적인 해결책입니다. C++에서는 std::vector나 스마트 포인터를 사용하는 것이 좋은 예입니다.
  • 정당한 이유로 스택 공간이 더 필요하다면, 최후의 수단으로 링커 옵션을 통해 프로그램의 기본 스택 크기를 늘려주는 방법도 고려할 수 있습니다.

결론적으로 스택 오버플로우는 제한된 스택 공간의 특성을 이해하고, 재귀의 깊이를 조절하거나 큰 데이터는 힙을 활용하는 방식으로 예방해야 하는 중요한 문제입니다.

'Computer Science' 카테고리의 다른 글

Dedicated Server와 Listen Server  (0) 2025.11.05
메모리 단편화 Memory Fragmentation  (0) 2025.10.14
페이지 폴트 Page Fault  (0) 2025.10.13
해시 테이블 (Hash Table)  (0) 2025.09.24
스택, 큐, 연결 리스트  (0) 2025.09.04
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차