재귀 함수의 콜 스택 동작 원리를 이해하고 올바른 기저 조건 설계와 메모이제이션 최적화를 배운다.
🎯 이 강의에서 배우는 것
재귀(recursion)는 함수가 자기 자신을 호출하는 기법입니다. 단순히 문법적 패턴이 아니라 자기 참조적 사고방식(self-referential thinking)입니다. 재귀로 생각하면 트리 순회, 분할 정복, 백트래킹 같은 복잡한 문제가 놀랍도록 단순해집니다. 이 강의에서 콜 스택 수준의 원리부터 실전 재귀 설계까지 배웁니다.
📚 콜 스택과 재귀 — 팩토리얼로 이해하기
def factorial(n):
if n == 0: # 기저 조건 (Base Case)
return 1
return n * factorial(n - 1) # 귀납 단계 (Inductive Step)
# factorial(4) 호출 시 콜 스택:
# ┌─────────────────────┐ ← 최상단 (가장 나중에 쌓임)
# │ factorial(0): n=0 │ → return 1
# ├─────────────────────┤
# │ factorial(1): n=1 │ → return 1 * 1 = 1
# ├─────────────────────┤
# │ factorial(2): n=2 │ → return 2 * 1 = 2
# ├─────────────────────┤
# │ factorial(3): n=3 │ → return 3 * 2 = 6
# ├─────────────────────┤
# │ factorial(4): n=4 │ → return 4 * 6 = 24
# └─────────────────────┘ ← 최하단 (처음 호출)
print(factorial(4)) # 24
# 각 스택 프레임은 독립적인 n을 가짐 — LEGB의 Local 스코프
# 재귀가 깊어질수록 스택이 쌓임 → 메모리 사용량 O(n)
# 스택 추적 시각화
import sys
def factorial_traced(n, depth=0):
indent = " " * depth
print(f"{indent}→ factorial({n})")
if n == 0:
print(f"{indent}← return 1")
return 1
result = n * factorial_traced(n - 1, depth + 1)
print(f"{indent}← return {result}")
return result
factorial_traced(4)
🛡️ 기저 조건 설계 — RecursionError 방지
import sys
print(sys.getrecursionlimit()) # 기본값: 1000
# 기저 조건 없으면 무한 재귀 → RecursionError
def bad_factorial(n):
return n * bad_factorial(n - 1) # 기저 조건 없음!
# bad_factorial(5) ← RecursionError: maximum recursion depth exceeded
# 기저 조건 설계 원칙:
# 1. 재귀가 반드시 기저 조건을 향해 수렴해야 함
# 2. 기저 조건에서는 재귀 호출 없이 직접 반환
# 3. 각 재귀 호출은 문제를 "작게" 만들어야 함
def count_down(n):
if n <= 0: # 기저 조건: 수렴 보장
print("발사!")
return
print(n)
count_down(n - 1) # n이 줄어드니까 반드시 기저 조건 도달
count_down(5)
# 5, 4, 3, 2, 1, 발사!
# 재귀 깊이 늘리기 (필요할 때만 — 조심해서 사용)
# sys.setrecursionlimit(5000)
🐌 피보나치의 비극 — 재귀의 시간 복잡도
import time
# 순진한 재귀 피보나치: O(2^n) 시간
def fib_naive(n):
if n <= 1:
return n
return fib_naive(n-1) + fib_naive(n-2)
# fib(40): 약 1억 3000만 번 호출!
# fib(50): 천억 번 이상 — 사실상 불가능
# 중복 계산 시각화 (fib(5))
call_count = 0
def fib_count(n):
global call_count
call_count += 1
if n <= 1:
return n
return fib_count(n-1) + fib_count(n-2)
call_count = 0
fib_count(10)
print(f"fib(10) 호출 횟수: {call_count}") # 177번!
# 메모이제이션으로 O(n) 개선
from functools import lru_cache
@lru_cache(maxsize=None) # 무제한 캐시
def fib_cached(n):
if n <= 1:
return n
return fib_cached(n-1) + fib_cached(n-2)
start = time.perf_counter()
print(fib_cached(100)) # 354224848179261915075
print(f"시간: {time.perf_counter()-start:.6f}초") # 거의 0초!
# 반복문으로 O(n) 시간, O(1) 공간
def fib_iterative(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
print(fib_iterative(100)) # 354224848179261915075
🏗️ 재귀 설계 3단계
# 3단계: ① 함수 명세 → ② 기저 조건 → ③ 재귀 단계
# 예제 1: 리스트 합계
# ① sum_list(lst)는 lst의 모든 원소 합을 반환
# ② 기저: lst가 비어있으면 0
# ③ 재귀: 첫 원소 + sum_list(나머지)
def sum_list(lst):
if not lst: # ② 기저 조건
return 0
return lst[0] + sum_list(lst[1:]) # ③ 재귀
print(sum_list([1, 2, 3, 4, 5])) # 15
# 예제 2: 하노이 탑
# ① hanoi(n, from_peg, to_peg, aux_peg) — n개 원판을 from에서 to로 이동
# ② 기저: n == 1 → 직접 이동
# ③ 재귀: n-1개를 aux로, 1개를 to로, n-1개를 to로
def hanoi(n, from_peg, to_peg, aux_peg):
if n == 1: # ② 기저 조건
print(f"원판 1: {from_peg} → {to_peg}")
return
hanoi(n-1, from_peg, aux_peg, to_peg) # n-1개를 보조로
print(f"원판 {n}: {from_peg} → {to_peg}") # 가장 큰 원판 이동
hanoi(n-1, aux_peg, to_peg, from_peg) # n-1개를 목표로
hanoi(3, "A", "C", "B") # 7번 이동으로 해결
# 예제 3: 디렉토리 탐색 (트리 구조 재귀)
import os
def list_files(path, indent=0):
prefix = " " * indent
try:
if os.path.isfile(path):
print(f"{prefix}{os.path.basename(path)}")
elif os.path.isdir(path):
print(f"{prefix}{os.path.basename(path)}/")
for entry in sorted(os.listdir(path)):
list_files(os.path.join(path, entry), indent + 1)
except PermissionError:
print(f"{prefix}[접근 거부]")
# list_files("C:/Users/chung/Documents")
🔄 꼬리 재귀와 반복문 변환
# 꼬리 재귀(tail recursion): 재귀 호출이 마지막 연산
def factorial_tail(n, accumulator=1):
if n == 0:
return accumulator
return factorial_tail(n - 1, n * accumulator) # 꼬리 재귀
# 파이썬은 꼬리 재귀 최적화(TCO, Tail Call Optimization)를 지원 안 함!
# Guido van Rossum이 의도적으로 제외 (스택 트레이스 유지 목적)
# 따라서 스택은 여전히 O(n)으로 쌓임
# 깊은 재귀가 필요하면 명시적 반복문으로 변환:
def factorial_iter(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
# 또는 스택을 직접 시뮬레이션
def hanoi_iter(n, from_peg, to_peg, aux_peg):
# 명시적 스택으로 재귀를 반복문으로 변환
stack = [(n, from_peg, to_peg, aux_peg)]
moves = []
while stack:
n, fr, to, aux = stack.pop()
if n == 1:
moves.append(f"원판 1: {fr} → {to}")
else:
stack.append((n-1, aux, to, fr)) # 마지막 단계를 먼저 push
stack.append((1, fr, to, aux))
stack.append((n-1, fr, aux, to)) # 첫 단계를 나중에 push
return moves
⚠️ 자주 하는 실수
- 기저 조건 누락: 재귀 함수는 반드시 기저 조건이 있어야 합니다. "이 경우에는 무엇을 반환해야 하는가"를 먼저 정의하세요. 기저 조건이 없으면 RecursionError가 발생합니다.
- 재귀가 기저 조건으로 수렴하지 않음: factorial(-1) 처럼 n이 음수면 기저 조건(n==0)에 영원히 도달 못합니다. 입력 유효성 검사나 기저 조건 범위 확장이 필요합니다.
- 순진한 재귀 피보나치를 큰 n에 사용: fib(40) 이상은 실용적이지 않습니다. @lru_cache를 사용하거나 반복문으로 전환하세요.
📝 정리 및 다음 강의 예고
- 재귀 = 기저 조건(Base Case) + 귀납 단계(Inductive Step). 재귀 호출은 반드시 기저 조건을 향해 수렴해야 합니다.
- 콜 스택: 각 재귀 호출은 독립적인 스택 프레임을 가집니다. 파이썬 기본 한도는 1000입니다.
- 중복 재귀(피보나치)는 @lru_cache나 반복문으로 최적화하세요.
- 파이썬은 꼬리 재귀 최적화를 지원하지 않으므로, 깊은 재귀는 반복문으로 변환하세요.
다음 강의: 데코레이터 — 함수를 감싸는 함수. 클로저로 데코레이터를 직접 구현하고, functools.wraps, 인자 있는 데코레이터, 클래스 데코레이터까지 배웁니다.
관련 주제
- 재귀 콜 스택 동작
- 기저 조건(Base Case)
- RecursionError
- 메모이제이션(lru_cache)
- 피보나치·팩토리얼 재귀
- 꼬리 재귀 미지원
- 개발·프로그래밍
- 개발·프로그래밍 강의
- 파이썬 기초 40강 — 처음 배우는 프로그래밍
- 무료강의
- 무료 온라인 강의
- NUGUNA
- 누구나
📚 시리즈 전체 공유
파이썬 기초 40강 — 처음 배우는 프로그래밍
이 강의가 속한 시리즈는 총 32강, 모두 무료입니다. 처음부터 배우려는 동료에게 시리즈 전체를 알려 주세요.
댓글
0/1000
불러오는 중...
