스택 · 힙 · 메모리
스택과 힙 메모리 나눠 보기
함수 호출에 따라 쌓이고 비워지는 스택과 동적으로 할당되는 힙의 차이를 이해하고 메모리 관리 원리를 살펴봅니다.
무료 공개 · 최근 수정
RecursionError가 가리키는 한계선
Python으로 알고리즘 문제를 풀거나 트리 순회 함수를 작성하다가 다음 에러 메시지를 마주치는 순간이 있습니다.
RecursionError: maximum recursion depth exceeded while calling a Python object코드가 무한 루프에 빠져 CPU를 100% 점유하며 멈추는 것도 아니고, 시스템 메모리가 완전히 고갈되어 운영체제가 프로세스를 강제 종료한 것도 아닙니다. 인터프리터는 정해진 깊이를 넘어서는 순간 스스로 실행을 멈추고 예외를 던집니다.
이 에러가 발생하는 근본적인 이유는 컴퓨터가 프로그램을 실행할 때 사용하는 메모리 공간이 단일한 덩어리가 아니라, 역할과 수명에 따라 엄격하게 나뉜 구역으로 관리되기 때문입니다. 특히 함수가 호출될 때마다 실행 문맥을 차곡차곡 기록하는 영역인 호출 스택은 무한히 늘어날 수 없는 고정된 한계를 지닙니다. 반면 대규모 리스트나 객체를 담는 힙 영역은 메모리가 허용하는 한 유연하게 늘어납니다. 프로그램이 메모리를 어디에, 어떤 규칙으로 올려두고 정리하는지 파악하면 예기치 못한 비정상 종료를 방지하고 안정적인 애플리케이션을 작성할 수 있습니다.
함수 호출마다 쌓이고 사라지는 스택 프레임
함수가 실행을 시작할 때 운영체제는 해당 호출만을 위한 독립된 작업 메모리를 할당합니다. 이 독립 공간을 스택 프레임(Stack Frame)이라고 부릅니다.
스택 프레임 내부에는 함수 내부에서 선언된 지역 변수, 전달받은 매개변수, 연산 중간값, 그리고 함수가 종료된 뒤 제어권을 넘겨줄 복귀 주소(Return Address)가 차례대로 기록됩니다. 스택은 이름 그대로 접시를 쌓아 올리듯 후입선출(LIFO; Last In First Out) 방식으로 동작합니다. 함수 A가 함수 B를 호출하면 A의 프레임 위에 B의 프레임이 새로 얹히고, B가 종료되면 B의 프레임이 메모리에서 즉시 통째로 사라집니다.
이 특성 덕분에 스택에서의 할당과 해제는 극도로 빠릅니다. CPU 내부의 스택 포인터(SP) 레지스터 값을 더하거나 빼는 단순 산술 연산 하나만으로 메모리 공간의 확보와 회수가 즉각 완료됩니다. 개발자가 별도로 메모리 해제 코드를 작성하지 않아도, 함수 실행이 끝나 중괄호를 벗어나거나 return 문을 만나는 순간 지역 변수의 수명은 완전히 끝납니다.
그러나 스택의 빠른 속도 뒤편에는 엄격한 크기 제약이 따릅니다. 운영체제는 스레드 하나당 보통 1MB에서 8MB 안팎의 제한된 스택 용량만을 부여합니다. 만약 종료 조건을 잘못 지정한 재귀 함수가 수천 번 넘게 제자리 호출을 반복하면 스택 영역에 할당된 한계를 순식간에 벗어나게 됩니다. 이를 스택 오버플로(Stack Overflow)라고 부르며, Python 인터프리터는 이로 인해 프로세스 전체가 비정상 강제 종료(Crash)되는 파국을 막기 위해 RecursionError라는 안전장치를 미리 발동합니다.
주소 공간을 가로지르는 스택과 힙의 상호작용
가상 메모리 주소 체계 안에서 스택과 힙은 서로 반대 방향을 향해 자라나며 공간을 효율적으로 나누어 씁니다.
그림: 호출 스택 프레임 누적과 동적 힙 객체 참조 연결 구조
메모리 배치도를 살펴보면 프로세스 주소 공간의 가장 높은 번지에서 시작한 호출 스택은 함수가 호출될 때마다 더 낮은 주소를 향해 아래로 내려옵니다. 반대로 코드와 전역 데이터가 위치한 저위 주소 바로 위에서 시작하는 동적 힙 공간은 런타임 요구에 맞춰 위쪽으로 자라납니다.
두 영역 사이의 빈 가상 주소 공간을 완충 지대로 두어, 스택과 힙이 서로 충돌하지 않으면서 필요에 따라 유연하게 팽창하도록 돕습니다. 여기서 주목할 점은 스택 프레임에 담긴 지역 변수가 실제 거대한 데이터 덩어리를 직접 품고 있는 것이 아니라는 사실입니다. 프레임 안의 변수는 힙 영역에 생성된 실제 객체의 시작 메모리 번지(포인터 또는 참조값)만을 간직하고 있습니다.
수명이 자유로운 힙과 동적 메모리 관리
함수의 실행이 끝난 뒤에도 데이터가 살아남아 여러 함수 사이를 오가야 하거나, 컴파일 시점에 크기를 미리 가늠할 수 없는 가변 배열을 다루어야 할 때는 힙(Heap) 공간을 사용합니다.
스택이 정해진 규칙에 따라 엄격하게 쌓이고 지워지는 자동화된 공간이라면, 힙은 개발자의 의도나 런타임 조건에 따라 원하는 크기만큼 쪼개어 쓰는 거대한 자유 창고입니다. malloc이나 new, 또는 Python에서 리스트나 클래스 인스턴스를 생성할 때 운영체제는 힙의 빈 영역을 탐색해 블록을 떼어줍니다.
수명이 함수 블록에 종속되지 않으므로 힙에 올라간 객체는 명시적으로 회수하기 전까지 메모리에 계속 남아 있습니다. 그렇기 때문에 힙을 사용할 때는 다 쓴 메모리를 제때 치우는 관리 정책이 필수적입니다. 언어 계열에 따라 메모리를 정리하는 철학은 크게 갈립니다.
Python이나 자바, Go 같은 언어는 런타임 백그라운드에서 더 이상 참조되지 않는 객체를 자동으로 찾아내어 수거하는 가비지 컬렉션(Garbage Collection) 시스템을 운용합니다. Python의 경우 객체 헤더에 해당 객체를 가리키는 참조 변수의 수를 세어두는 참조 카운팅(Reference Counting)을 기본 축으로 삼고, 서로를 맞물려 가리키는 순환 참조를 해소하기 위해 세대별 가비지 컬렉터를 보조로 가동합니다. 반면 시스템 프로그래밍 언어인 Rust는 런타임 추적 비용을 들이지 않는 대신, 컴파일러가 소유권(Ownership) 규칙을 엄격히 검증하여 변수 스코프가 닫히는 지점에 메모리 반환 코드를 자동으로 삽입합니다.
[참고] 메모리 단편화 현상
힙 공간에서 다양한 크기의 객체를 반복해서 할당하고 해제하면 메모리 사이사이에 작은 빈틈들이 생겨납니다. 총 여유 공간은 충분하더라도 연속된 큰 블록을 잡지 못해 할당에 실패하는 현상을 메모리 단편화(Memory Fragmentation)라고 부릅니다.
sys.getrecursionlimit으로 확인하는 스택 경계
Python 인터프리터가 기본적으로 허용하는 재귀 호출의 깊이 한계는 표준 라이브러리인 sys 모듈을 통해 확인할 수 있습니다.
다음 예제는 시스템에 설정된 재귀 한도를 출력하고, 종료 조건이 누락된 재귀 함수를 호출하여 실제로 스택 제한에 도달했을 때 예외가 발생하는 순간을 확인합니다.
import sys
default_limit = sys.getrecursionlimit()
print(f"기본 재귀 한도: {default_limit}")
call_count = 0
def recursive_dive():
global call_count
call_count += 1
# 종료 조건 없이 자기 자신을 반복 호출하여 스택 프레임을 계속 누적
recursive_dive()
try:
recursive_dive()
except RecursionError as error:
print(f"예외 발생 확인: {error}")
print(f"실제 누적된 함수 호출 횟수: {call_count}")위 코드를 실행하면 터미널 콘솔에 다음과 같은 형식의 결과가 출력됩니다.
예시 출력:
기본 재귀 한도: 1000
예외 발생 확인: maximum recursion depth exceeded while calling a Python object
실제 누적된 함수 호출 횟수: 999출력 결과를 보면 기본 재귀 한도가 1,000으로 지정되어 있으며, 한도(1,000) 직전에서 스택 안전 경계에 도달해 예외가 던져졌음을 알 수 있습니다. 프레임마다 이전 호출 위치와 매개변수가 스택 메모리에 차곡차곡 보관되어 있었기에, 인터프리터는 한계를 감지하자마자 수백 줄에 달하는 상세한 호출 추적(Traceback)을 사용자에게 남겨줄 수 있습니다.
알고리즘의 특성상 깊은 탐색이 불가피하다면 sys.setrecursionlimit(5000)처럼 한도를 인위적으로 올릴 수도 있습니다. 그러나 운영체제가 스레드에 내어준 물리적 스택 한계(보통 수 메가바이트)를 넘어서도록 설정을 지나치게 높이면, Python의 예외 처리 단계를 건너뛰고 C 수준의 세그멘테이션 오류(Segmentation Fault)를 일으키며 프로그램이 경고 없이 강제 종료될 수 있습니다. 깊은 재귀가 예상되는 문제는 재귀 대신 자료구조 선택의 첫걸음에서 다룬 명시적인 반복문과 힙 기반 스택 자료구조를 활용해 루프로 변환하는 것이 안전한 엔지니어링 접근법입니다.
스레드 격리와 메모리 누수 방지
스택과 힙의 구조적 차이는 동시성 프로그래밍과 장기 실행 시스템을 설계할 때 가장 핵심적인 고려 대상이 됩니다.
프로세스와 스레드 구분하기에서 살펴보았듯, 동일한 프로세스 안에서 동작하는 복수의 스레드들은 저마다 독자적인 호출 스택을 배정받습니다. 스레드 A가 어떤 깊이로 함수를 호출하든 스레드 B의 스택 프레임에는 아무런 간섭을 주지 않으므로, 지역 변수는 동기화 잠금 없이도 스레드 안전(Thread-safe)하게 유지됩니다.
반면 힙 공간은 프로세스 내부의 모든 스레드가 단 하나의 주소 영역을 완전히 공유합니다. 스레드 하나가 힙에 객체를 생성하면 다른 스레드가 그 주소 포인터를 넘겨받아 즉시 조작할 수 있는 편리함이 있지만, 둘 이상의 스레드가 힙의 동일한 데이터를 동시에 수정하려 들면 경쟁 상태가 발생합니다.
더불어 가비지 컬렉터가 작동하는 환경이라 하더라도 힙 메모리 관리에 방심할 수는 없습니다. 전역 리스트나 장기 생존 캐시 딕셔너리에 객체 참조를 등록해 둔 채 지우지 않는다면, 참조 카운트가 결코 0으로 떨어지지 않아 GC 수거 대상에서 제외됩니다. 쓰지 않는 객체가 힙에 영원히 쌓여 시스템 메모리를 점진적으로 잠식하는 논리적 메모리 누수(Memory Leak)가 일어납니다. 빠른 할당과 자동 수거가 보장되는 스택과 달리, 힙에 머무는 데이터는 참조의 수명주기를 면밀히 추적하고 명시적으로 끊어내는 습관이 필요합니다.
참고 문서
설명이 어렵거나 잘못된 부분을 발견하셨나요?
문서 수정 의견 보내기