꼬리 재귀와 재귀 깊이 제한: sys.setrecursionlimit 완전 이해

Python의 재귀 깊이 제한(1000), RecursionError 원인, 꼬리 재귀 최적화(TCO) 부재, 트램폴린 기법, 반복문 변환, lru_cache 활용 등 실전 해결책을 설명합니다.

· 6 min read · PALDYN Team

지난 글에서 파이프 패턴으로 데이터를 변환하는 방법을 살펴봤다. 함수형 프로그래밍에서는 재귀가 자주 등장하는데, Python에서는 재귀를 쓸 때 반드시 알아야 할 제약이 있다. 이번 글에서는 Python의 재귀 깊이 제한과 이를 우회하는 방법들을 정리한다.

Python의 재귀 깊이 제한

CPython은 기본적으로 재귀 깊이를 1000으로 제한한다. 1001번째 재귀 호출 시 RecursionError가 발생한다.

재귀 호출 스택

import sys
print(sys.getrecursionlimit())   # 1000

def count_down(n):
    if n == 0:
        return "done"
    return count_down(n - 1)

count_down(999)    # OK
count_down(1000)   # RecursionError: maximum recursion depth exceeded

재귀 깊이 제한이 있는 이유는 C 스택을 보호하기 위해서다. Python의 각 함수 호출은 C 스택 프레임을 사용하므로, 무제한으로 늘리면 운영체제 스택 오버플로가 발생할 수 있다.

꼬리 재귀(Tail Recursion)와 Python

꼬리 재귀는 함수의 마지막 연산이 재귀 호출인 경우다. 이론적으로 컴파일러/인터프리터가 꼬리 재귀를 최적화(TCO: Tail Call Optimization)하면 스택 프레임을 재사용해 O(1) 공간으로 실행할 수 있다.

# 일반 재귀 — 각 호출이 스택에 쌓임
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)   # 곱셈이 남아 있어 꼬리 재귀 아님

# 꼬리 재귀 스타일 — 누산기 사용
def factorial_tail(n, acc=1):
    if n <= 1:
        return acc
    return factorial_tail(n - 1, n * acc)   # 마지막 연산이 재귀 호출

그러나 Python은 TCO를 의도적으로 구현하지 않았다. Guido van Rossum은 스택 트레이스가 불분명해진다는 이유로 TCO 추가를 거부했다. 꼬리 재귀 스타일로 작성해도 스택은 여전히 쌓인다.

해결 방법들

재귀 깊이 문제 해결 전략

방법 1: sys.setrecursionlimit

import sys
sys.setrecursionlimit(10000)

빠르고 간단하지만 C 스택 오버플로 위험이 있다. 운영체제 스택 크기(보통 1~8MB)를 초과하면 프로세스가 크래시한다. 절대 무작정 늘리지 말고, 실제로 필요한 만큼만 설정해야 한다.

방법 2: 반복문으로 변환 (가장 권장)

# 재귀 → 반복문
def factorial_iter(n: int) -> int:
    result = 1
    while n > 1:
        result *= n
        n -= 1
    return result

factorial_iter(100000)   # 문제 없음

# 피보나치 반복문 버전
def fib_iter(n: int) -> int:
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

대부분의 재귀 알고리즘은 반복문으로 변환할 수 있다. 성능도 좋고 스택 걱정도 없다.

방법 3: 트램폴린(Trampoline) 기법

꼬리 재귀 함수를 수정하지 않고 실행 방식만 바꾸는 기법이다. 재귀 호출 대신 thunk(호출 가능한 객체)를 반환하고, 루프에서 계속 실행한다.

def trampoline(f, *args):
    v = f(*args)
    while callable(v):
        v = v()
    return v

def factorial_tramp(n, acc=1):
    if n <= 1:
        return acc
    return lambda: factorial_tramp(n - 1, n * acc)   # thunk 반환

result = trampoline(factorial_tramp, 10000)
print(result)   # 매우 큰 숫자, 스택 오버플로 없음

방법 4: lru_cache로 재귀 깊이 간접 감소

lru_cache가 있으면 동일 인자에 대한 재계산을 건너뛰므로, 피보나치 같은 중복 호출 구조에서는 실제 스택 깊이가 줄어든다.

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

# fib(900)은 깊이 900이지만, 이미 계산된 것은 재귀 없이 반환

단, fib(5000) 처럼 처음 호출할 때는 여전히 깊이 5000의 재귀가 발생한다.

방법 5: 명시적 스택으로 DFS/BFS 구현

트리 순회, 그래프 탐색처럼 재귀가 자연스러운 경우도 명시적 스택을 사용하면 깊이 제한을 완전히 피할 수 있다.

def dfs_iterative(tree):
    stack = [tree]
    result = []
    while stack:
        node = stack.pop()
        result.append(node.value)
        for child in reversed(node.children):
            stack.append(child)
    return result

현재 재귀 깊이 확인

import sys

def current_depth():
    frame = sys._getframe()
    depth = 0
    while frame:
        frame = frame.f_back
        depth += 1
    return depth

sys.getframe()으로 현재 프레임을 얻고, f_back으로 호출 스택을 거슬러 올라가 깊이를 측정할 수 있다.

다음 글에서는 함수형 프로그래밍에서 반복 계산을 피하는 메모이제이션 패턴을 자세히 다룬다.


지난 글: 파이프 패턴: 데이터를 흘리는 함수형 파이프라인

다음 글: 메모이제이션 패턴: 계산 결과 캐싱 전략


읽어주셔서 감사합니다. 😊