1. 서론
프로그램을 작성할 때, 마치 프로그램이 “위에서 아래로 그냥 실행되는” 것처럼 느껴집니다. 하지만 함수가 등장하고, 특히 한 함수가 다른 함수를 호출하면 실용적인 문제가 생깁니다: 각 함수의 지역 변수를 서로 방해하지 않게 어디에 저장할 것인가? 호출 컨텍스트를 자동으로 생성하고 동일하게 자동으로 제거하는 메커니즘이 필요합니다.
여기서 등장하는 것이 바로 스택입니다. 스택은 호출 동작에 이상적으로 맞는 모델(실제로는 종종 메모리 구조)이죠: 함수를 호출하면 그 컨텍스트를 “쌓고”, 완료하면 “내립니다”. 언어 설계자가 낭만적이어서가 아니라 편리하고 빠르며 예측 가능하기 때문입니다.
식당의 접시 더미를 상상해 보세요. 새 접시를 위에 올리고, 역시 위에서만 꺼냅니다. 중간에서 접시를 빼면 식당이 서커스가 됩니다. 함수 호출은 바로 그 접시 더미와 같습니다.
scope와 지역 객체의 수명 경계
프로그래밍에서는 “지금 필요하지 않은 것”이 지금 존재하지 않기를 원합니다. 단지 머릿속에서뿐만 아니라 실제 실행에서도요. scope는 첫 번째 방어선으로, 이름이 어디에서 접근 가능한지를 정의합니다. 그러나 오늘의 맥락에서는 더 중요합니다: 대부분의 지역 객체에 대해 scope는 객체가 생성되고 파괴되는 시점과 일치합니다.
가장 단순한 상황을 보겠습니다: 중첩 블록.
#include <iostream>
int main() {
int outer = 10;
{
int inner = 20;
std::cout << outer << " " << inner << '\n'; // 10 20
} // inner은 여기서 소멸합니다
std::cout << outer << '\n'; // 10
}
블록 안에서 inner가 생성됩니다. } 바로 다음에 이 변수는 더 이상 지역 객체로 존재하지 않습니다. 단지 “보이지 않게 되는” 것이 아니라 언어 모델에서 실제로 존재가 끝납니다(automatic 객체에 대해). 이것이 실용적인 의미입니다: 블록을 벗어나면 그 블록의 지역 자동 객체는 수명이 끝난다.
중요한 점: outer는 더 오래 살아있습니다. 그 이유는 그 변수가 외부 블록에 있기 때문입니다.
2. 호출 스택: 함수 호출 시에 무슨 일이 일어나는가
초보자는 함수 호출을 거의 텔레포트처럼 여깁니다: “함수로 뛰어갔다가 다시 돌아온다”. 하지만 그 “도약”에는 짐이 있습니다: 매개변수, 지역 변수, 반환 주소(어디로 돌아갈지) 등이죠. 이 모든 것을 합쳐서 스택 프레임(stack frame)으로 생각할 수 있습니다.
핵심 아이디어: 각 함수 호출은 자신만의 지역 변수 집합을 가진다. 함수를 두 번 호출하면 독립적인 두 집합이 생깁니다. 함수가 자기 자신을 호출하면(재귀) 동시에 살아 있는 호출 레벨 수만큼 집합이 존재합니다.
간단한 데모:
#include <iostream>
void demo() {
int x = 0; // 지역 automatic 변수
++x;
std::cout << x << '\n';
}
int main() {
demo(); // 1
demo(); // 1
}
출력 설명: 두 번 모두 1이 출력됩니다. 왜냐하면 x는 demo()에 들어갈 때마다 새로 생성되기 때문입니다.
개념을 더 체감하고 싶다면 트레이싱을 해볼 수 있습니다:
#include <iostream>
void foo() {
int a = 1;
std::cout << "foo: a=" << a << '\n'; // foo: a=1
}
void bar() {
int b = 2;
std::cout << "bar: b=" << b << '\n'; // bar: b=2
foo();
std::cout << "bar end\n"; // bar end
}
int main() {
bar();
}
bar()가 실행되는 동안 그 안에서 foo()를 호출하면, 어떤 시점에는 bar와 foo의 지역 데이터가 동시에 존재하지만, 서로 다른 스택 프레임에 있습니다.
이를 도식으로 그려볼 수 있습니다:
flowchart TD
A["main() frame"] --> B["bar() frame"]
B --> C["foo() frame"]
C --> B
B --> A
여기서 화살표는 “영구적인 제어 전달”이 아니라 “중첩 관계”를 나타냅니다: foo()는 bar() 실행 내부에서 존재합니다.
4. scope에서의 종료: }와 조기 return
함수를 막 배우면 return을 “함수에서 나가는 것”으로만 이해하기 쉽습니다. 하지만 객체 수명 모델에서는 더 중요한 관점이 있습니다: return은 함수의 블록에서 나가는 것, 즉 그 함수의 scope가 끝나는 것입니다. 따라서 모든 지역 automatic 객체는 어떤 방식으로든 함수에서 나갈 때 파괴됩니다: 함수 끝까지 실행되든, 조기 return을 만나든 마찬가지입니다.
조기 종료 예시:
#include <iostream>
#include <string>
void greet(bool loud) {
std::string msg = "hello";
if (!loud) {
std::cout << msg << '\n'; // hello
return; // 조기 종료
}
std::cout << msg << "!!!\n"; // hello!!!
}
int main() {
greet(false);
greet(true);
}
여기서 중요한 것은 msg가 문자열이라는 사실(문자열은 복잡한 타입의 좋은 예)이 아니라, greet를 벗어나면 살아남지 않는다는 점입니다. 조기 return이 언어를 속여서 지역 변수를 메모리에 남겨두지 않습니다. scope를 벗어난 순간 지역 automatic 객체는 수명이 끝납니다.
이 점 때문에 많은 사람이 안심합니다: 조기 return을 사용해도 변수가 치우치지 않을까 걱정할 필요가 없습니다. 조기 반환은 중첩된 if를 줄여 주고, 지역 객체의 수명은 여전히 올바르게 관리됩니다.
중첩 블록을 도구로 사용하기: “무거운” 변수의 수명 줄이기
학습 초반에는 보통 이렇게 작성합니다: “함수 맨 위에 다 선언해 놓자”, 이는 자연스러운 발상입니다. 하지만 시간이 지나면 변수들이 불필요하게 오래 살아서 문제가 됩니다: 더 이상 필요 없는데도 존재하는 것이죠. int 같은 타입에서는 거의 문제가 안 되지만, std::vector나 큰 문자열 같은 객체에서는 특히 큰 함수에서 영향을 줍니다.
중첩 블록 {}을 사용하면 “이 데이터는 여기서만 필요하다”라고 명시할 수 있습니다.
사용자 명령 라인을 읽고 임시로 토큰으로 분해하는 상황을 가정해 봅시다. 토큰은 명령 처리하는 동안만 필요하므로 블록으로 수명을 제한할 수 있습니다.
5. 실전 예: CLI 애플리케이션과 변수의 수명
예제가 공중에 떠 있지 않게, 미니 애플리케이션을 만들어 봅시다. 간단한 할 일 목록 TodoLite를 구현합니다: 작업을 추가하고 목록을 보여줄 수 있습니다.
데이터 모델(매우 단순):
#include <string>
struct Task {
std::string title;
bool done = false;
};
이제 작업을 출력하는 함수:
#include <iostream>
#include <vector>
void print_tasks(const std::vector<Task>& tasks) {
std::cout << "Tasks: " << tasks.size() << '\n';
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i].title << '\n';
}
}
이제 한 줄의 명령을 처리합시다. 입력 라인을 읽고 std::istringstream으로 파싱하며, 토큰은 중첩 블록 안에서만 유지합니다.
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
void process_line(std::vector<Task>& tasks, const std::string& line) {
std::string cmd;
{ // <-- 파서와 임시 문자열의 수명을 제한
std::istringstream iss(line);
iss >> cmd;
if (cmd == "add") {
std::string title;
std::getline(iss, title); // 나머지 라인을 가져옴
if (!title.empty() && title[0] == ' ') title.erase(0, 1);
tasks.push_back(Task{title, false});
std::cout << "Added: " << title << '\n';
}
} // <-- iss와 title(있었다면)은 여기서 소멸
if (cmd == "list") {
// cmd는 블록 밖에 선언되어 여전히 살아 있음
print_tasks(tasks);
}
}
변수 수명의 “구조”에 주목하세요:
cmd는 블록 밖에서 선언되어 블록 후에도 필요합니다(예: "list" 검사용).
iss와 title은 블록 안에서만 살아 있습니다. 파싱이 실제로 필요한 곳에서만 존재하죠.
이것은 매우 성숙한 기법이며, 겉으로 보기에는 단순합니다: 단지 불필요한 {}을 추가하는 것이죠. 그러나 큰 함수에서는 어느 시점이 끝나는지, 그리고 그 시점에 따라 임시 변수가 함께 끝난다는 점에서 가독성이 크게 향상됩니다.
최소한의 main()을 모아봅시다:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks;
std::string line;
while (std::getline(std::cin, line)) {
if (line == "exit") break;
process_line(tasks, line);
}
}
지역 변수 tasks와 line은 main() 전체 동안 살아 있습니다. 왜냐하면 그들의 scope가 main 전체이기 때문입니다. 반면 파싱을 위한 임시 객체들은 설계한 대로 짧게 살아납니다.
6. 재귀와 스택: 왜 깊은 재귀가 자원에 의해 제한되는가
재귀는 종종 마법처럼 보입니다: 함수가 자기 자신을 호출하면 모든 것이 잘 작동합니다. 하지만 스택은 그 비용을 보여줍니다: 각 재귀 호출은 새로운 스택 프레임을 생성하므로 새로운 지역 변수와 새로운 컨텍스트가 생깁니다.
깊이를 출력하는 간단한 예:
#include <iostream>
void depth(int d) {
int local = d;
std::cout << "d=" << local << '\n';
if (d > 0) {
depth(d - 1);
}
}
int main() {
depth(3);
}
출력은 다음과 같습니다:
d=3
d=2
d=1
d=0
이것이 중요한 이유는 동시에 여러 local이 존재한다는 점입니다: d=3용 하나, d=2용 하나 등. 이들은 서로 덮어쓰지 않습니다. 각각이 자신만의 스택 프레임에서 살아 있기 때문입니다.
depth(3)를 실행하는 동안 스택을 머릿속으로 이렇게 상상할 수 있습니다:
top -> depth(0) frame
depth(1) frame
depth(2) frame
bottom depth(3) frame
그리고 depth(0)가 끝나면 최상단 프레임이 제거되고 제어가 depth(1)로 돌아갑니다. 이것이 바로 LIFO입니다.
실용적인 결론(당황하지 마세요): 재귀는 아름답고 유용하지만 스택을 “먹습니다”. 따라서 깊은 재귀는 리소스 한계에 부딪힙니다. 시스템 세부 사항까지 들어가지 않더라도, 사고의 수준에서는 “각 호출 = 한 겹”이라는 점을 이해하면 충분합니다.
7. 요약: 무엇이 언제 끝나는가
피곤해졌을 때 짧은 “지도”가 있으면 좋습니다. 아래는 함수를 작성할 때 기억해 두면 유용한 간단한 표입니다.
| 코드에서의 사건 | 지역 변수(automatic)에 무슨 일이 일어나는가 |
|---|---|
| 블록 진입 { | 실행이 선언 지점에 도달하면 블록 내부에 선언된 변수들이 생성됩니다 |
| 종료: } | 그 블록을 scope로 갖는 변수들이 소멸합니다 |
| 함수 호출 f() | 그 함수의 지역 변수를 위한 새로운 스택 프레임이 생성됩니다 |
| 함수 반환 return | 함수는 자신의 scope를 벗어나며 지역 변수들이 소멸합니다 |
| 재귀 호출 | 함수가 동일하더라도 추가 스택 프레임이 생성됩니다 |
용어 automatic storage duration와 dynamic storage duration는 이렇게 구분됩니다: 지역 자동 객체는 블록 경계 내에서 살아 있고, “dynamic”은 스택 프레임에 묶여 있지 않습니다. 오늘은 첫 번째에 집중했습니다.
8. 흔한 실수
실수 #1: 변수가 더 이상 사용되지 않을 때 바로 “사라진다”고 생각하는 것.
초보자는 종종 “이미 x를 출력했으니 더 이상 필요 없고, 따라서 사라진다”고 합니다. 아니요: 지역 변수의 소멸은 마지막 사용 시점이 아니라 scope의 경계에 묶여 있습니다. x가 함수 시작에 선언되었다면 그 함수가 끝날 때까지 살아 있습니다.
실수 #2: 모든 변수를 함수 시작 부분에 선언하는 것.
코드가 “구조화되어 보이긴” 하지만 실제로는 흐려집니다: 함수 끝부분을 읽을 때 왜 위에서 선언된 변수가 아직 살아 있는지 알기 어렵습니다. 중첩 블록 {}은 임시 객체의 수명을 줄이고 코드 단계를 더 명확히 해 줍니다.
실수 #3: 지역 변수가 함수 호출 간에 값을 유지할 것이라고 기대하는 것.
지역 automatic 변수라면 각 호출마다 새로 생성됩니다. 이것은 버그가 아니라 기본 보장입니다. 호출 간 상태가 필요하면 다른 저장 기간을 사용해야 합니다(이전 강의에서 다룸). 일반 지역 변수로 이를 얻으려 하면 체에 수프를 담으려는 것과 같습니다.
실수 #4: 코드가 짧다고 해서 재귀 비용을 과소평가하는 것.
짧은 코드가 작은 비용을 의미하지는 않습니다. 재귀의 각 레벨은 또 다른 스택 프레임과 지역 데이터 집합입니다. 따라서 재귀는 코드 가독성 때문에 선택하되, 깊이가 매우 커질 가능성이 없는 경우에만 사용해야 합니다.
GO TO FULL VERSION