1. 실행 모델과 호출 스택
doSomething();을 작성하면 컴퓨터가 프로그램의 다른 장소로 단순히 "뛴 다음" 일을 하고 돌아오는 것처럼 느껴집니다. 하지만 정확히 어떻게 돌아오는가? 어디에 '어디로 돌아가야 하는지'를 기억하고 있는가? 그리고 왜 함수 내부의 로컬 변수는 return 후에 사라지는가—마치 현실에서 "꺼져버리는" 것처럼 보입니다?
지금부터 간단하고 실용적인 실행 모델을 구성할 것입니다. 이 모델은 재귀를 다룰 때 특히 유용하지만, 지금은 세부사항에 들어가지 않습니다.
초보 수준에서는 다음 정도만 알면 충분합니다: 프로그램의 실행은 위에서 아래로 진행되는 하나의 "실행 경로"이며, 때때로 함수로 "분기"합니다. 이러한 분기들이 미로처럼 빠져나올 수 없게 되지 않도록 실행 흐름에는 특별한 메모리 구조—바로 호출 스택이 있습니다.
호출 스택: 접시 더미와 LIFO 규칙
"스택(stack)"이라는 단어는 말 그대로 "더미"입니다. 각 함수 호출이 테이블 위에 접시 하나를 올려놓는다고 상상해보세요. 접시에는 종이가 하나 붙어 있습니다: "나는 함수 foo()의 호출입니다. 여기는 내 로컬 변수, 여기 매개변수, 그리고 여기는 돌아갈 주소입니다." 함수가 끝나면 접시가 치워집니다.
중요한 점은 바로 마지막에 올려진 접시가 먼저 치워진다는 것입니다. 이 규칙을 LIFO라고 부릅니다: Last In, First Out—"나중에 들어간 것이 먼저 나온다".
이 규칙은 함수 호출에 딱 맞습니다: 만약 main()이 f()를 호출하고, f()가 g()를 호출했다면, 먼저 끝나는 것은 g(), 그 다음에 f(), 그리고 마지막으로 main()입니다.
스택 프레임: 접시에 무엇이 들어있는가
이 섹션에서는 접시를 조금 더 자세히 "분해"해 보겠습니다. 프로세서나 레지스터 수준은 아니고, 코드 작성에 도움이 되는 모델 수준입니다.
스택 프레임은 한 함수 호출에 속하는 데이터입니다: 그 함수의 매개변수, 로컬 변수, 그리고 가장 중요한 것—복귀 지점(return 후 어디서 실행을 이어갈지)입니다.
스택 프레임을 표로 나타내면 편리합니다:
| 스택 프레임 부분 | 무엇인가 | 왜 필요한가 |
|---|---|---|
| 매개변수 | 함수로 전달된 것 | 함수가 무엇으로 동작해야 하는지 알기 위해 |
| 로컬 변수 | {} 내부에서 선언된 것 | 함수의 임시 작업 공간 |
| 복귀 지점 | 호출한 함수 내의 "주소" | 호출이 끝난 뒤에 실행을 계속하기 위해 |
| 서비스용 | "내부적 사항" | 지금은 모델화할 필요가 없습니다 |
핵심 아이디어: 각 함수 호출에는 고유한 스택 프레임이 있다. 같은 함수를 두 번 호출하면 로컬 변수도 두 개의 서로 다른 집합이 됩니다.
미니 데모: 함수 진입과 종료 순서
지금 가장 솔직한 실험을 해보겠습니다: 단순히 start/end를 출력해서 어떤 순서로 나타나는지 확인합니다. 이 예는 작지만 LIFO 규칙을 잘 보여줍니다.
#include <iostream>
void h() {
std::cout << "h: start\n"; // h: start
std::cout << "h: end\n"; // h: end
}
void g() {
std::cout << "g: start\n"; // g: start
h();
std::cout << "g: end\n"; // g: end
}
void f() {
std::cout << "f: start\n"; // f: start
g();
std::cout << "f: end\n"; // f: end
}
int main() {
f();
}
실행하면 대략 다음과 같은 출력이 나옵니다:
f: start
g: start
h: start
h: end
g: end
f: end
이것이 바로 "접시 더미"가 작동하는 모습입니다: h()가 끝나기 전까지는 g()가 정확히 h(); 줄에서 "일시 정지"되고, f()는 g(); 줄에서 정지합니다.
복귀 지점: 왜 호출한 함수가 호출 지점에서 "멈추는"가
많은 초보자가 처음에 가지는 이상한 생각은 "함수들이 마치 병렬로 동작하는 것 같다"는 것입니다. 이는 자연스러운 착각입니다—뇌가 에너지를 절약하려고 세상을 이렇게 그려줍니다. 하지만 단일 스레드 코드에서는 훨씬 단순합니다: 병렬성이 없다, 실행은 엄격히 순차적입니다.
다음 코드 줄이 실행될 때:
int b = inner(a + 1);
호출하는 함수(예: outer)는 inner(...)가 끝난 뒤 어떤 작업을 계속해야 하는지를 기억해야 합니다—즉 결과를 b에 대입하고 다음 줄들을 실행해야 합니다. 그래서 스택 프레임에는 복귀 지점이 저장됩니다.
예를 보겠습니다:
#include <iostream>
int inner(int x) {
std::cout << "inner: x=" << x << '\n'; // inner: x=11
return x * 2;
}
int outer(int a) {
std::cout << "outer: before\n"; // outer: before
int b = inner(a + 1);
std::cout << "outer: after\n"; // outer: after
return b + 3;
}
int main() {
std::cout << outer(10) << '\n'; // 25
}
여기서 outer는 문자 그대로 inner 호출에서 "멈춰 있다가" return을 기다리고, 값을 받아서 계속 실행합니다.
로컬 변수의 생명: 호출이 끝날 때까지 산다
지금 중요한 점은 많은 버그가 "유령"처럼 보이게 만드는 원인입니다: 로컬 변수는 "영원히" 존재하지 않고, 스택 프레임이 살아 있는 동안만 존재합니다. 함수가 return을 하면 해당 호출의 프레임이 스택에서 내려가고, 그 호출의 로컬 변수들은 더 이상 존재하지 않습니다.
C++ 표준 용어로 말하면 이는 automatic storage duration와 연관됩니다—블록 범위 내에서 존재하고 블록을 벗어나면 파괴되는 전형적인 로컬 변수들의 특성입니다.
예시:
#include <iostream>
int add10(int x) {
int y = x + 10; // 지역 변수
return y;
}
int main() {
std::cout << add10(1) << '\n'; // 11
std::cout << add10(5) << '\n'; // 15
}
여기에는 "호출 사이에 기억되는 메모리"가 없습니다: y는 add10()에 진입할 때마다 새로 생성됩니다. 누적되거나 나이 들거나 이전 생을 기억하지 않습니다.
같은 함수의 두 호출은 서로 다른 로컬 변수 공간을 가진다
이 점은 자명해 보이지만, "내 변수 값이 7이었는데 왜 다시 0이지?" 같은 상황에서 혼란을 일으킵니다. 답은: 그것은 다른 변수이고 다른 스택 프레임에 속해 있다는 것입니다.
작은 트릭을 해보겠습니다: 로컬 변수의 값을 출력해봅시다. (아직 포인터를 깊게 다루지 않았으므로 주소로 들어가진 않겠습니다—로직 자체는 주소 없이도 분명히 보입니다.)
#include <iostream>
void demo(int x) {
int local = x * 10;
std::cout << "local=" << local << '\n'; // local=20 (그다음 local=50)
}
int main() {
demo(2);
demo(5);
}
주소를 보지 않더라도 아이디어는 같습니다: 각 호출은 자체적인 local을 생성합니다.
2. 실용적 기법: 들여쓰기와 함께한 호출 추적
이 섹션에서는 강사와 디버거가 좋아하는 기법을 해보겠습니다: 함수 진입/종료를 출력해서 호출 깊이를 눈으로 확인합니다. 이것은 거의 "수동 스택"인데 마법은 없습니다.
두 개의 작은 함수 trace_enter와 trace_exit를 만들고, 깊이 값은 참조로 전달되는 depth 변수에 저장합니다(체인에 속한 모든 호출이 변경을 볼 수 있도록).
#include <iostream>
#include <string>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
주의: depth는 "스택의 일부"가 아니라 단지 우리의 교육용 손전등입니다. 우리는 프로그램 내부에서 이미 일어나고 있는 것을 강조하고 있을 뿐입니다.
3. 미니 프로젝트: 콘솔 할 일 목록과 얇은 main
이제부터 예제들을 하나의 작은 애플리케이션으로 묶을 것입니다. 확장하기 쉬운 간단한 애플리케이션입니다. 복잡한 것은 만들지 않겠습니다: 문자열 목록(std::vector<std::string>)과 명령 add/list/exit. 주된 목적은 함수 호출이 스택에서 어떻게 "살아 있는지" 보고, main()을 얇게 유지하는 이유를 이해하는 것입니다.
앱의 골격
골격부터 시작합니다:
#include <iostream>
#include <string>
#include <vector>
void run_app() {
std::vector<std::string> tasks;
std::cout << "TodoApp started\n"; // TodoApp started
}
int main() {
run_app();
}
아직 지루하지만—좋은 뼈대입니다: main()는 비즈니스 로직을 직접 처리하지 않고 제어를 run_app()에 넘깁니다.
명령을 추가하고 추적으로 스택 보기
이제 메뉴 표시, 명령 읽기, 명령 처리 함수를 만들고 트레이싱을 추가해서 run_app()가 다른 함수들을 어떻게 호출하는지 보겠습니다.
#include <iostream>
#include <string>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
void print_menu(int& depth) {
trace_enter("print_menu", depth);
std::cout << "Commands: add, list, exit\n";
trace_exit("print_menu", depth);
}
std::string read_command(int& depth) {
trace_enter("read_command", depth);
std::string cmd;
std::getline(std::cin, cmd);
trace_exit("read_command", depth);
return cmd;
}
요지는 간단합니다: 들여쓰기된 출력을 보면 호출의 중첩을 "엑스레이"로 보는 것과 같은 효과가 있습니다.
모두 연결하여 run_app에서 스택 프레임 관찰하기
이제 체인을 연결하겠습니다: main() → run_app() → print_menu() → read_command(). 재귀가 없어도 이미 스택은 동작하며 중요합니다.
#include <iostream>
#include <string>
#include <vector>
void trace_enter(const std::string& name, int& depth) {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "-> " << name << '\n';
++depth;
}
void trace_exit(const std::string& name, int& depth) {
--depth;
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << "<- " << name << '\n';
}
void print_menu(int& depth) {
trace_enter("print_menu", depth);
std::cout << "Commands: add, list, exit\n"; // Commands: add, list, exit
trace_exit("print_menu", depth);
}
std::string read_command(int& depth) {
trace_enter("read_command", depth);
std::string cmd;
std::getline(std::cin, cmd);
trace_exit("read_command", depth);
return cmd;
}
void run_app() {
int depth = 0;
trace_enter("run_app", depth);
std::vector<std::string> tasks;
print_menu(depth);
std::cout << "Enter command: "; // Enter command:
std::string cmd = read_command(depth);
std::cout << "You typed: " << cmd << '\n'; // 예: You typed: add
trace_exit("run_app", depth);
}
int main() {
run_app();
}
만약 add를 입력하면 대략 다음과 같은 화면을 보게 됩니다:
-> run_app
-> print_menu
Commands: add, list, exit
<- print_menu
Enter command: -> read_command
<- read_command
You typed: add
<- run_app
이것은 스택의 시각화입니다: 우리가 read_command() 안에 있는 동안 이전 함수들은 사라지지 않고—자신의 복귀 지점에서 "계속되기를 기다립니다".
4. return이 실제로 의미하는 것
여기서 잠시 멈추어 return이 하는 일을 말로 정리해봅시다. return은 값을 "텔레포트"시키지 않습니다. 현재 함수를 종료하고 그 프레임을 스택에서 제거한 다음, 저장된 복귀 지점에 따라 호출한 함수에서 실행을 이어가게 합니다.
실무적으로 두 가지를 의미합니다. 첫째, 현재 함수에서 로컬이었던 모든 것은 더 이상 존재하지 않습니다: 변수들은 프레임과 함께 사라집니다. 둘째, 호출한 함수가 다시 "활성" 상태가 되어 호출 다음 줄부터 실행을 이어갑니다.
그래서 로컬 변수는 "임시적"이라고 이해해야 합니다: 함수 종료 후에는 해당 변수를 사용하려 해도 안 됩니다. 자동 저장 기간(automatic storage duration)과 범위 탈출에 따라 로컬 객체는 파괴된다는 점은 실행 모델의 기본 구성 요소 중 하나입니다.
5. 중첩 호출 시 스택의 시각적 도식
완전히 이해를 고정하기 위해 간단한 도식을 그려보겠습니다. 이것은 "정확한 바이트 레이아웃"이 아니라 의미를 위한 것입니다.
flowchart TD
A["main()"] --> B["run_app()"]
B --> C["print_menu()"]
B --> D["read_command()"]
그리고 우리가 read_command() 내부에 있을 때 "더미"는 대략 이렇게 보입니다:
[ top ]
read_command frame
run_app frame
main frame
[ bottom ]
read_command()가 return하면 그 프레임이 제거되고 우리는 다시 run_app()에 "있게" 됩니다.
6. 흔한 오류들
오류 #1: 호출한 함수가 "병렬로" 계속 실행될 것으로 기대하는 것.
때때로 사람들은 run_app()이 read_command()가 실행되는 동안에 "다른 일을 할 수 있다"라고 생각합니다. 단일 스레드 코드에서는 그렇지 않습니다: 호출한 함수는 return까지 일시 정지합니다. "하나의 실행 경로"와 "복귀 지점"을 염두에 두면 혼동이 줄어듭니다.
오류 #2: 로컬 변수가 호출 사이에 값을 "기억"한다고 생각하는 것.
로컬 변수는 특정 스택 프레임에 속합니다. 새로운 호출은 새로운 프레임—새로운 로컬 변수를 만듭니다. 호출 사이에 상태를 유지해야 한다면 그 변수는 더 상위(예: run_app())에 있거나 호출들 사이에 전달되는 매개변수/컨테이너여야 합니다.
오류 #3: 호출 순서와 종료 순서를 혼동하는 것.
호출은 "안쪽으로", 종료는 "바깥으로" 진행됩니다. 따라서 체인 main → run_app → read_command에서는 먼저 read_command가 종료되고 그 다음에 run_app이 이어지며 마지막에 main이 종료됩니다. 이 규칙이 바로 LIFO—"나중에 들어온 것이 먼저 나온다"—입니다. 주문 외우듯 반복하면 이해하기 쉽습니다.
오류 #4: return을 단순한 "출구"로 사용하면서 그 결과를 이해하지 못하는 것.
조기 return은 코드를 단순화하지만, 그것이 함수를 즉시 종료하고 프레임을 제거하며 로컬 변수를 파괴한다는 점을 기억해야 합니다. 만약 return 전에 필요한 상태(예: 상위에 저장되는 컨테이너에 결과를 기록하는 것)를 업데이트하지 않았다면, 나중에는 되돌릴 수 없습니다.
오류 #5: 스택을 "어딘가 있는 전역 마법 메모리"로 설명하려고 하는 것.
때때로 초보자는 "변수들이 어딘가에 그냥 놓여 있다"고 말합니다. 더 유용한 모델은 각 호출이 매개변수, 로컬 변수, 복귀 지점을 가진 자신의 스택 프레임을 가지고 있다는 것입니다. 이 모델은 실행 순서, 변수의 생명주기, 중첩 호출의 예측 가능한 동작을 모두 설명합니다.
GO TO FULL VERSION