재귀 함수
기저 조건과 더 작은 입력으로 이어지는 재귀 호출을 팩토리얼·피보나치 예제로 추적하고 반복문과 비교합니다.
함수가 자기 자신을 호출하는 특별한 형태의 함수, 즉 재귀 함수(Recursive Function)를 다룹니다.
재귀는 처음에는 다소 어렵게 느껴질 수 있지만, 특정 종류의 문제를 간결하게 표현할 수 있는 프로그래밍 기법입니다.
재귀 함수란 무엇인가?
재귀를 읽을 때는 전체 호출을 한꺼번에 펼치기보다, 함수가 받는 유효 입력, 종료 조건, 그리고 모든 재귀 경로에서 감소하는 값을 먼저 확인합니다.
아래 그림은 이 재귀 계약과 factorial(5)가 0! = 1까지 내려간 뒤 결과를 조합해 올라오는 흐름을 함께 정리한 것입니다.
VALID INPUT · BASE · PROGRESS
재귀 계약은 모든 호출 경로가 유효한 기저 조건에 닿게 한다
자기 호출만으로는 재귀가 완성되지 않습니다. 입력의 유효 범위와 결과 표현 범위를 먼저 정하고, 기저 조건을 둔 뒤, 모든 재귀 분기에서 감소하는 척도를 증명해야 합니다.
factorial(n)은 음이 아닌 정수에서 정의한다
수학적 정의역은 n ≥ 0입니다. 이 std::int64_t 구현은 결과까지 표현 가능한 0 ≤ n ≤ 20만 받고, 음수와 n > 20은 곱셈 전에 각각 입력 오류와 범위 오류로 거부합니다.
n == 0 → 1, 다음 호출은 n - 1
기저 조건은 0! = 1을 반환합니다. 척도 n은 재귀할 때마다 1씩 감소하고 아래로 제한되므로, 유효 입력에서 모든 경로가 유한 번 안에 기저 조건에 도달합니다.
하강할 때 연산을 보류하고, 0! = 1부터 역순으로 조합한다
factorial(5)
└─ 5 × factorial(4)
└─ 4 × factorial(3)
└─ 3 × factorial(2)
└─ 2 × factorial(1)
└─ 1 × factorial(0)
└─ 1 // base
각 프레임은 자신의 곱셈을 보류한 채 더 작은 입력의 결과를 기다립니다.
0! = 1
1! = 1 × 1 = 1
2! = 2 × 1 = 2
3! = 3 × 2 = 6
4! = 4 × 6 = 24
5! = 5 × 24 = 120
가장 안쪽 호출부터 반환되어 최종 결과 120을 만듭니다.
signed 오버플로우는 깊이 문제가 아니다
21!은 std::int64_t 범위를 넘습니다. 검사 없이 signed 곱셈을 수행하면 C++에서 정의되지 않은 동작이므로, 재귀 전에 범위를 막거나 더 큰 수 표현을 사용합니다.
스택 한계는 구현과 실행 환경에 달려 있다
깊은 카운트다운이나 편향 트리처럼 결과는 작아도 호출이 길게 이어지는 코드가 스택을 소진할 수 있습니다. 허용 깊이는 스레드 스택, 프레임 크기, 컴파일러와 빌드 설정에 따라 달라집니다.
검토 순서: 유효 입력 → 결과 표현 범위 → 기저 조건 → 모든 경로의 감소 척도 → 최악 호출 깊이. 이 다섯 항목을 통과해야 계산식의 정확성을 논할 수 있습니다.
재귀 함수는 자기 자신을 호출하여 작업을 수행하는 함수입니다.
마치 거울이 거울을 비추는 것처럼, 함수가 자신을 반복적으로 호출하며 문제를 해결해 나가는 방식입니다.
재귀 함수는 무한 루프에 빠지지 않기 위해 반드시 두 가지 요소를 포함해야 합니다.
기저 조건 (Base Case): 재귀 호출을 멈추는 조건입니다.
이 조건이 만족되면 함수는 더 이상 자신을 호출하지 않고, 결과를 반환하며 재귀 호출의 연속을 종료합니다.
모든 재귀 함수는 적어도 하나 이상의 기저 조건을 가져야 합니다.
재귀 단계 (Recursive Step): 함수가 자기 자신을 호출하는 부분입니다.
이때, 함수는 원래 문제보다 더 작고 간단한 버전의 문제를 해결하기 위해 자신을 호출해야 합니다.
이렇게 호출된 더 작은 문제의 해답들이 모여 최종 문제의 해답을 구성합니다.
재귀는 특히 다음과 같은 문제들을 해결하는 데 유용합니다.
- 프랙탈 구조: 자기 유사성(self-similarity)을 가진 구조.
- 트리 또는 그래프 탐색: 계층적이거나 연결된 데이터 구조. 순환 가능한 그래프에서는 같은 정점을 다시 방문하지 않도록
visited집합 같은 별도 종료 장치가 필요합니다. - 분할 정복(Divide and Conquer) 알고리즘: 문제를 더 작은 하위 문제로 분할하여 해결하는 방식 (예: 퀵 정렬, 병합 정렬).
재귀 함수의 예시: 팩토리얼 계산
가장 고전적이고 이해하기 쉬운 재귀 함수의 예시는 팩토리얼(Factorial) 계산입니다.
음이 아닌 정수 n에 대해 n!은 n * (n-1) * (n-2) * ... * 1로 정의됩니다. 음수는 이 예제 함수의 유효 입력이 아닙니다.
5! = 5 * 4 * 3 * 2 * 1- 또한,
5! = 5 * (4!)와 같이 정의할 수도 있습니다. 4! = 4 * (3!)1! = 1(기저 조건)0! = 1(기저 조건)
이 정의를 바탕으로 팩토리얼 함수를 재귀적으로 구현해 봅시다.
#include <cstdint>
#include <iostream>
#include <stdexcept>
// 이 구현의 계약: 0 <= n <= 20
std::int64_t factorial(int n) {
if (n < 0) {
throw std::invalid_argument("factorial requires n >= 0");
}
if (n > 20) {
throw std::overflow_error("factorial exceeds the int64_t example range");
}
// 기저 조건
if (n == 0) {
return 1;
}
// 재귀 단계: n이 모든 호출에서 1씩 감소한다.
std::cout << "factorial(" << n << ") 호출: "
<< n << " * factorial(" << n - 1 << ")\n";
return n * factorial(n - 1);
}
int main() {
int num = 5;
const std::int64_t result = factorial(num);
std::cout << num << "! = " << result << '\n';
/* 출력 결과 (호출 과정):
factorial(5) 호출: 5 * factorial(4)
factorial(4) 호출: 4 * factorial(3)
factorial(3) 호출: 3 * factorial(2)
factorial(2) 호출: 2 * factorial(1)
factorial(1) 호출: 1 * factorial(0)
5! = 120
*/
std::cout << "0! = " << factorial(0) << '\n'; // 출력: 0! = 1
return 0;
}factorial(5)는 5 → 4 → 3 → 2 → 1 → 0 순으로 호출되고, 0! = 1에서 멈춥니다. 이후 1 → 2 → 6 → 24 → 120 순으로 결과가 조합됩니다. 결과를 먼저 변수에 계산한 뒤 출력하므로 호출 추적과 최종 결과의 출력 순서도 코드에 적은 순서와 일치합니다.
이 구현은 정확히 64비트인 std::int64_t를 사용합니다. 20!은 들어가지만 21!은 들어가지 않으므로, 입력 경계에서 거부하여 signed 정수 오버플로우가 발생하기 전에 계산을 끝냅니다.
재귀 함수 예시: 피보나치 수열
피보나치 수열은 재귀의 또 다른 좋은 예시입니다.
피보나치 수열은 음이 아닌 정수 n에 대해 F(n) = F(n-1) + F(n-2)로 정의되며, F(0) = 0, F(1) = 1이 기저 조건입니다.
#include <cstdint>
#include <iostream>
#include <stdexcept>
// 이 구현의 계약: 0 <= n <= 92
std::int64_t fibonacci(int n) {
if (n < 0) {
throw std::invalid_argument("fibonacci requires n >= 0");
}
if (n > 92) {
throw std::overflow_error("fibonacci exceeds the int64_t example range");
}
if (n == 0) { // 첫 번째 기저 조건
return 0;
} else if (n == 1) { // 두 번째 기저 조건
return 1;
} else { // 재귀 단계
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
int main() {
for (int i = 0; i < 10; ++i) {
std::cout << "fibonacci(" << i << ") = " << fibonacci(i) << std::endl;
}
/* 출력:
fibonacci(0) = 0
fibonacci(1) = 1
fibonacci(2) = 1
fibonacci(3) = 2
fibonacci(4) = 3
fibonacci(5) = 5
fibonacci(6) = 8
fibonacci(7) = 13
fibonacci(8) = 21
fibonacci(9) = 34
*/
return 0;
}피보나치 함수는 factorial보다 더 복잡한 재귀 호출 구조를 가집니다.
fibonacci(5)를 계산하기 위해 fibonacci(4)와 fibonacci(3)을 호출하고, fibonacci(4)는 다시 fibonacci(3)과 fibonacci(2)를 호출하는 등, 동일한 계산이 여러 번 반복될 수 있습니다.
이는 재귀 함수의 잠재적인 비효율성을 보여줍니다. 반환형 std::int64_t의 표현 한계는 F(92)이지만, 위 순진한 재귀 구현으로 큰 입력을 계산하는 것은 지수적으로 늘어나는 중복 호출 때문에 실용적이지 않습니다. 큰 입력에는 반복·메모이제이션을 사용하고, F(93) 이상에는 임의 정밀도 정수 같은 다른 표현도 필요합니다.
재귀 함수 사용 시 주의사항
재귀 함수는 강력하고 우아하지만, 잘못 사용하면 심각한 문제를 야기할 수 있습니다.
스택 오버플로우 (Stack Overflow): 일반적인 구현에서 최적화되지 않은 함수 호출은 복귀 위치와 지역 상태를 호출 프레임에 보관합니다.
재귀 호출이 너무 깊거나 기저 조건에 도달하지 못해 프레임이 계속 누적되면, 스택 공간을 소진하는 등 구현과 실행 환경에 따른 비정상 종료로 이어질 수 있습니다.
허용 깊이는 운영 체제, 스레드 스택 크기, 컴파일러와 빌드 설정, 각 호출 프레임의 크기에 따라 달라집니다. 다만 위 factorial 구현에서 factorial(10000)은 깊게 재귀하기 전에 결과 범위 검사에서 거부되므로 스택 오버플로우 예가 아닙니다. 입력 크기만큼 내려가는 깊은 카운트다운이나 편향 트리 순회가 더 정확한 예입니다.
비효율성 (Inefficiency): 위 피보나치 수열 예시처럼, 동일한 서브 문제를 여러 번 반복해서 계산하는 경우 재귀 함수는 비효율적일 수 있습니다.
이 경우, 반복문(Iteration)으로 구현하거나, 메모이제이션(Memoization)(계산 결과를 저장하여 중복 계산을 피하는 기법)을 적용하여 성능을 개선할 수 있습니다.
#include <cstdint>
#include <stdexcept>
std::int64_t fibonacciIterative(int n) {
if (n < 0) {
throw std::invalid_argument("fibonacci requires n >= 0");
}
if (n > 92) {
throw std::overflow_error("fibonacci exceeds the int64_t example range");
}
if (n == 0) return 0;
if (n == 1) return 1;
std::int64_t a = 0;
std::int64_t b = 1;
std::int64_t result = 0;
for (int i = 2; i <= n; ++i) {
result = a + b;
a = b;
b = result;
}
return result;
}기저 조건의 누락 또는 오류: 기저 조건이 없거나 잘못되면 재귀가 종료되지 않습니다. 일반적인 구현에서는 호출 상태가 누적되어 스택 소진 등 비정상 종료로 이어질 수 있지만, 정확한 결과는 최적화와 실행 환경에 따라 달라집니다.
재귀 vs 반복
계산 가능성의 관점에서는 재귀를 명시적 상태와 스택을 쓰는 반복으로 모사하거나 반복을 재귀로 표현할 수 있습니다. 그러나 이것이 언제나 단순한 for 문으로 바뀌거나, 같은 자원 사용량·예외 안전성·관찰 가능한 동작을 자동으로 보존한다는 뜻은 아닙니다.
둘 중 어떤 것을 사용할지는 문제의 특성, 코드의 가독성, 그리고 성능 고려 사항에 따라 달라집니다.
다음 판단 기준은 재귀를 유지할지, 반복문이나 메모이제이션으로 바꿀지 빠르게 가르는 기준입니다.
BASE · PROGRESS · DEPTH · REUSE
구현 전략은 문제 모양보다 종료와 비용 계약으로 고른다
먼저 재귀가 끝나는지 증명하고, 그다음 깊이와 중복 계산을 줄입니다. 재귀·반복·명시적 스택·메모이제이션/DP는 같은 장식의 변형이 아니라 서로 다른 자원과 순서 계약을 드러내는 선택입니다.
기저 조건과 모든 경로의 감소 척도를 먼저 증명
한 분기라도 같은 상태로 돌아오거나 기저 조건을 건너뛰면 구현 전략을 고를 단계가 아닙니다. 입력 계약과 상태 전이를 먼저 고칩니다.
평균이 아니라 최악 호출 깊이를 계산
균형 트리는 얕아도 편향 트리는 입력 크기만큼 깊어질 수 있습니다. 실행 환경의 스택 한계를 설계 상수처럼 가정하지 않습니다.
같은 상태를 다시 계산하는지 확인
순진한 피보나치처럼 동일한 F(k)가 반복되면 호출 문법보다 결과 재사용 정책이 성능을 좌우합니다.
숨은 상태와 방문 순서를 명시할지 결정
순환 가능한 그래프에는 visited가 필요합니다. 명시적 스택으로 DFS를 옮길 때는 자식을 넣는 순서가 출력·방문 순서를 바꿀 수 있습니다.
| 관찰한 조건 | 우선 선택 | 함께 지킬 계약 |
|---|---|---|
| 기저 조건이 없거나 어떤 경로의 척도도 줄지 않음 | 계약 재설계 | 재귀·반복으로 옮기기 전에 종료 가능한 상태 전이를 만듭니다. |
| 깊이가 작게 제한되고 구조가 자연스럽고 중복이 적음 | 재귀 | 유효 입력, 기저 조건, 최악 깊이를 코드와 테스트에 남깁니다. |
| 선형 진행이며 깊이가 입력 크기와 함께 커짐 | 반복 | 누산기와 루프 불변식으로 보류 중인 계산을 명시합니다. |
| 트리·그래프 탐색의 깊이와 제어 순서를 직접 관리해야 함 | 명시적 스택 | DFS 순서를 보존하려면 push 순서를 정하고, 그래프에는 visited를 둡니다. |
| 동일한 하위 문제가 반복됨 | 메모 / DP | top-down 캐시 또는 bottom-up 표의 키, 수명, 결과 범위를 정합니다. |
기저 조건 없음 · 진행하지 않는 경로
종료 가능한 상태 전이를 만든 뒤 구현 전략을 고릅니다.
깊이 제한 · 자연스러운 구조 · 적은 중복
유효 입력, 기저 조건, 최악 깊이를 코드와 테스트에 남깁니다.
선형 진행 · 입력과 함께 커지는 깊이
누산기와 루프 불변식으로 보류 중인 계산을 명시합니다.
깊이와 방문 순서를 직접 관리
push 순서를 고정하고 순환 가능한 그래프에는 visited를 둡니다.
반복되는 동일 하위 문제
top-down 캐시 또는 bottom-up 표의 키, 수명, 결과 범위를 정합니다.
꼬리 재귀도 상수 스택을 보장하지 않는다
C++ 언어는 tail-call optimization을 보장하지 않습니다. 최적화 여부는 컴파일러, ABI, 디버그·릴리스 설정과 함수 모양에 따라 달라지므로 깊이 안전성이 필요하면 반복이나 명시적 스택으로 직접 표현합니다. 이론적으로 재귀와 반복을 서로 모사할 수 있다는 사실도 단순 변환, 같은 자원 사용량, 같은 예외·출력 순서를 자동으로 보장하지 않습니다.
순서도 계약입니다. 재귀를 반복이나 명시적 스택으로 옮긴 뒤에는 결과값뿐 아니라 방문·출력 순서까지 테스트합니다. 피보나치처럼 값이 커지는 계산은 중복 제거와 별개로 음수 입력 정책과 정수 범위도 유지해야 합니다.
-
재귀
- 장점: 재귀적으로 정의된 문제(예: 트리 순회, 프랙탈)를 간결하고 우아하게 표현할 수 있습니다. 코드가 직관적이고 이해하기 쉬울 수 있습니다.
- 단점: 스택 오버플로우 위험, 반복문보다 느리거나 비효율적일 수 있음. C++는 꼬리 호출 최적화를 보장하지 않으므로 tail-recursive 형태라도 스택을 쓰지 않는다고 가정해서는 안 됩니다. 실제 최적화 여부는 컴파일러와 빌드 설정에 따라 달라집니다.
-
반복
- 장점: 일반적으로 재귀보다 빠르고 메모리 효율적일 수 있으며, 재귀 깊이에 비례해 호출 프레임이 늘어나는 위험을 피합니다.
- 단점: 특정 문제(예: 트리 순회)에서는 재귀보다 코드가 복잡해지거나 직관적이지 않을 수 있습니다.
핵심: 재귀 함수를 사용할 때는 항상 기저 조건을 명확히 설정하고, 각 재귀 호출이 원래 문제보다 더 작은 문제로 나아가는지 확인하여 무한 재귀에 빠지지 않도록 주의해야 합니다.
성능이 중요한 경우에는 재귀 대신 반복문을 고려하거나, 메모이제이션 같은 최적화 기법을 적용해야 합니다.
재귀를 선택할 때는 아름다운 코드보다 먼저 유효 입력, 기저 조건, 모든 경로의 문제 축소, 최악 호출 깊이, 중복 계산을 확인해야 합니다. 순환 가능한 그래프라면 visited 상태도 종료 계약의 일부입니다.