함수를 여러 개 만들면 같은 이름의 변수가 여기저기 등장합니다. 이때 어느 변수가 어디까지 보이고 언제 사라지는지를 정하는 규칙이 스코프(scope, 유효 범위)와 수명(lifetime, 값이 살아 있는 기간)입니다. 이 규칙을 모르면 이미 사라진 변수를 읽거나, 파괴된 메모리를 가리키는 주소를 돌려주는 정의되지 않은 동작(undefined behavior)에 빠집니다. 재귀는 스코프와 수명이 호출마다 어떻게 쌓이는지 알아야 안전하게 쓰는 기법입니다.
13.1 지역 변수와 블록 스코프
함수 안에서 선언한 변수를 지역 변수(local variable)라고 합니다. 함수가 호출될 때 생겨 함수가 끝나면 사라지고, 다른 함수에서는 이름이 같아도 완전히 별개입니다.
#include <stdio.h>
void func(void) {
int x = 10; // func의 지역 변수
printf("func의 x: %d\n", x);
}
int main(void) {
int x = 99; // main의 x, func의 x와 무관
func();
printf("main의 x: %d\n", x);
return 0;
}
두 x는 서로 다른 메모리입니다.
func의 x: 10
main의 x: 99
스코프는 함수만이 아니라 중괄호 블록마다 나뉩니다. 안쪽 블록에서 같은 이름을 다시 선언하면 바깥 것을 잠시 가립니다.
int main(void) {
int n = 1;
{
int n = 5; // 이 블록 안에서만 유효
printf("안쪽 n: %d\n", n);
}
printf("바깥 n: %d\n", n);
return 0;
}
안쪽 n: 5
바깥 n: 1
주의. 안쪽 n이 바깥 n을 덮는 것을 섀도잉(shadowing)이라 합니다. 안쪽 블록이 끝나면 그 n은 사라지고 바깥 n이 다시 보입니다. 의도치 않은 섀도잉은 흔한 버그의 원인이니, -Wshadow 경고를 켜 두면 미리 걸러 줍니다.
13.2 전역 변수와 그 대가
함수 바깥에 선언한 변수는 전역 변수(global variable)입니다. 선언 지점부터 파일 전체에서 보이고, 프로그램이 시작할 때 생겨 끝날 때까지 살아 있습니다. 초기화하지 않으면 0으로 시작합니다.
#include <stdio.h>
int total = 0; // 전역 변수
void add(int x) {
total += x; // 어느 함수에서든 접근
}
int main(void) {
add(10);
add(20);
printf("합계: %d\n", total);
return 0;
}
합계: 30
함정. 전역 변수는 아무 함수나 값을 바꿀 수 있어, 언제 어디서 변했는지 추적하기 어렵습니다. 매개변수와 반환값으로 주고받고, 전역은 꼭 필요할 때만 씁니다.
13.3 static 지역 변수와 수명
지역 변수는 함수가 끝나면 사라지지만, static을 붙이면 스코프는 그대로 함수 안이되 수명은 프로그램 전체가 됩니다. 함수가 끝나도 값이 남아 다음 호출에서 이어지는 것입니다. 몇 번 불렸는지 스스로 세는 카운터가 대표적입니다.
#include <stdio.h>
void counter(void) {
static int count = 0; // 딱 한 번만 초기화
count++;
printf("%d번째 호출\n", count);
}
int main(void) {
counter();
counter();
counter();
return 0;
}
1번째 호출
2번째 호출
3번째 호출
static int count = 0; 의 0 대입은 프로그램 전체에서 딱 한 번만 실행되고, 매 호출마다 0으로 되돌아가지 않아 값이 누적됩니다. 주의. static을 빼면 count는 매번 새로 0에서 생겨 항상 1만 찍힙니다. static 변수는 상태를 함수 안에 숨기므로, 여러 스레드가 동시에 부르면 값이 꼬일 수 있습니다.
13.4 재귀 함수의 구조
재귀(recursion)는 함수가 자기 자신을 호출하는 기법입니다. 큰 문제를 같은 모양의 작은 문제로 줄일 때 자연스럽고, 팩토리얼 5! = 5x4x3x2x1이 대표적입니다.
#include <stdio.h>
int factorial(int n) {
if (n <= 1) return 1; // 기저 조건
return n * factorial(n - 1); // 자기 자신 호출
}
int main(void) {
printf("5! = %d\n", factorial(5));
return 0;
}
5! = 120
모든 재귀에는 두 부분이 있습니다. 더는 자신을 부르지 않고 즉시 답을 내는 기저 조건(base case)과, 문제를 한 단계 줄여 자신을 부르는 재귀 단계입니다. factorial(5)는 factorial(4), factorial(3)으로 내려가다 factorial(1)이 1을 돌려주며 멈춘 뒤, 곱셈이 거꾸로 되감깁니다.
함정. 기저 조건을 빠뜨리면 재귀가 멈추지 않습니다. if (n <= 1)을 지우면 factorial(0)이 factorial(-1), factorial(-2)로 끝없이 내려가 호출 스택이 넘치고 스택 오버플로(stack overflow)로 죽습니다. 재귀에서 가장 먼저 확인할 것은 반드시 멈추는가입니다.
13.5 피보나치와 반복 계산
기저 조건이 둘일 수도 있습니다. 피보나치 수열 1, 1, 2, 3, 5, 8 ...은 앞의 두 수를 더해 만들어지므로 재귀 단계도 자신을 두 번 부릅니다.
#include <stdio.h>
int fib(int n) {
if (n <= 2) return 1; // n이 1이나 2면 1
return fib(n - 1) + fib(n - 2);
}
int main(void) {
for (int i = 1; i <= 7; i++)
printf("%d ", fib(i));
printf("\n");
return 0;
}
1 1 2 3 5 8 13
주의. 이 방식은 읽기는 쉽지만 심하게 느립니다. 같은 값을 몇 번이고 다시 계산해, n이 40 근처만 되어도 호출 횟수가 수억 번으로 폭발합니다. 재귀가 늘 최선은 아니라, 이럴 때는 반복문이나 메모이제이션(이미 구한 값 저장)으로 바꿉니다.
13.6 호출 스택과 지역 변수 주소 반환
재귀가 왜 스택을 넘치게 하는지는 호출 스택(call stack)으로 설명됩니다. 함수를 부를 때마다 지역 변수와 매개변수를 담은 스택 프레임(stack frame)이 쌓이고, 함수가 끝나면 사라집니다. 재귀는 끝나기 전에 자신을 또 부르므로 프레임을 계속 쌓아, 기저 조건이 없으면 스택이 바닥납니다.
이 수명 규칙에서 가장 위험한 실수가, 곧 사라질 지역 변수의 주소를 함수 밖으로 돌려주는 것입니다.
#include <stdio.h>
int *makeValue(void) {
int local = 42; // 함수가 끝나면 사라짐
return &local; // 사라질 변수의 주소를 반환
}
int main(void) {
int *p = makeValue();
printf("%d\n", *p); // 정의되지 않은 동작
return 0;
}
함정. local은 makeValue가 끝나는 순간 파괴되므로, 돌려받은 주소 p는 이미 사라진 메모리를 가리킵니다. 이 p를 역참조하는 *p는 정의되지 않은 동작이라, 운 좋게 42가 찍히다가도 다른 호출이 그 자리를 덮으면 엉뚱한 값이 나오거나 죽습니다. gcc가 -Wall로 경고는 주지만 에러는 아니라 지나치기 쉽습니다.
돌려주고 싶다면 주소가 아니라 int 값 자체를 return하거나, static 지역 변수를 쓰거나, 호출하는 쪽이 마련한 변수의 주소를 넘겨받습니다. static을 붙인 변수는 프로그램 내내 살아 있어 안전하지만, 모든 호출이 같은 자리를 공유합니다.
13.7 정리
스코프는 이름이 보이는 범위, 수명은 값이 살아 있는 기간입니다. 지역 변수는 스코프와 수명이 모두 좁아 안전하고, static은 수명만 늘리며, 전역은 둘 다 넓혀 편한 만큼 위험을 키웁니다. 재귀는 멈추는 기저 조건과 프레임이 쌓이는 호출 스택, 이 둘만 붙들면 낯섦 뒤의 단순한 규칙이 보입니다.