재귀 vs 반복
재귀(recursion)는 함수가 자기 자신을 호출해 문제를 더 작은 같은 문제로 풀어 가는 방식이고, 반복(iteration)은 반복문으로 같은 일을 되풀이하는 방식입니다. 둘 다 같은 계산을 표현할 수 있지만 컴퓨터 안에서 쓰는 자원이 다릅니다. 재귀는 호출마다 호출 스택에 프레임을 쌓고, 반복은 변수 몇 개만 갱신합니다.
그래서 재귀는 깊어지면 스택 오버플로우의 위험이 있고, 부분 문제가 겹치는 문제(피보나치)에서는 같은 계산을 지수적으로 되풀이할 수 있습니다. 이 토픽에서는 호출 스택이 쌓이고 풀리는 모습, 피보나치의 중복 계산, 꼬리 재귀가 최적화 지원 여부에 따라 스택을 쓰는 방식을 직접 확인합니다.
핵심 개념
기저 사례와 재귀 단계
재귀는 더 호출하지 않고 답을 돌려주는 기저 사례와, 더 작은 입력으로 자신을 호출하는 재귀 단계로 이루어집니다. 기저 사례가 없으면 끝나지 않습니다.
핵심 개념
호출 스택 프레임
함수를 호출할 때마다 보통 매개변수와 지역 변수, 돌아갈 위치를 담은 프레임이 스택에 쌓이고, 반환하면 꺼내집니다. 재귀 깊이만큼 프레임이 쌓입니다.
핵심 개념
스택 오버플로우
호출 스택은 크기가 한정되어 있습니다. 재귀가 너무 깊어지거나 기저 사례가 없으면 스택이 가득 차 오류가 납니다.
핵심 개념
중복 계산
fib(n)이 fib(n - 1)과 fib(n - 2)를 각각 호출하면 같은 부분 문제를 여러 번 풉니다. 호출 수가 지수적으로 늘어납니다.
핵심 개념
꼬리 호출 최적화
재귀 호출이 함수의 마지막 연산이면 이전 프레임을 재사용해 스택 증가 없이 실행할 수 있습니다. 지원하는 언어와 그렇지 않은 언어가 있습니다.
핵심 개념
반복과 명시적 스택
깊이가 큰 입력은 반복문으로 바꾸거나, 호출 스택 대신 직접 만든 스택 자료구조에 상태를 쌓아 같은 일을 합니다.
재귀와 호출 스택의 배경
프로그래밍 언어에서는 LISP가 재귀를 핵심 도구로 삼았습니다. 매카시(John McCarthy)는 1960년 논문 "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I"에서 재귀 함수로 프로그램을 표현하는 방식을 소개했습니다. ALGOL 60 보고서는 프로시저 본문 안에서 자기 이름을 호출하는 것(재귀)을 허용했습니다.
꼬리 호출을 점프처럼 효율적으로 처리할 수 있다는 점은 스틸(Guy L. Steele)이 1977년 논문 "Debunking the 'Expensive Procedure Call' Myth"에서 논했습니다. 언어 표준이 이를 요구하는 예로 Scheme이 있고, 반대로 자바(javac, HotSpot 기준)와 CPython은 꼬리 호출 최적화를 하지 않습니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
재귀와 반복의 차이는?
재귀는 자기 호출로 문제를 줄이며 호출마다 스택 프레임을 쓰고, 반복은 반복문으로 변수를 갱신합니다. 같은 계산이면 반복의 추가 공간이 보통 더 작습니다.
면접 질문
스택 오버플로우는 왜 나고 어떻게 피하나요?
깊은 재귀나 기저 사례 누락으로 프레임이 호출 스택을 가득 채우기 때문입니다. 반복문이나 명시적 스택으로 바꾸고, 가능하면 깊이를 줄입니다.
면접 질문
재귀 피보나치가 비효율적인 이유와 개선은?
같은 부분 문제를 중복 계산해 호출 수가 지수적으로 늘어납니다. 메모이제이션으로 저장하거나 반복문으로 이전 두 값만 들고 가면 덧셈을 상수 시간으로 볼 때 n에 비례합니다.
면접 질문
꼬리 호출 최적화란?
재귀 호출이 함수의 마지막 연산이면 호출 뒤에 할 일이 없으므로 이전 프레임을 재사용해 스택을 늘리지 않는 최적화입니다. 자바는 지원하지 않습니다.
활용 분야
함께 보면 좋은 토픽
참고 자료
Python — 재귀와 반복 팩토리얼, 호출 횟수 세기
def fact(n): return 1 if n <= 1 else n * fact(n - 1) # 재귀: 호출마다 프레임이 쌓인다 def fact_iter(n): result = 1 # 반복: 깊이에 따라 늘어나는 프레임이 없다 for i in range(2, n + 1): result *= i return result print(fact(5), fact_iter(5)) # 120 120 calls = 0 def fib(n): global calls calls += 1 return n if n < 2 else fib(n - 1) + fib(n - 2) print(fib(5), calls) # 5 15 calls = 0 print(fib(25), calls) # 75025 242785
같은 팩토리얼을 재귀와 반복으로 쓴 예입니다. 재귀 fact(n)은 n × fact(n - 1)을 계산하려고 fact(n - 1)을 호출하고, 그 결과가 돌아올 때까지 현재 호출의 n을 스택 프레임에 들고 기다립니다. fact(5)는 fact(1)에서 멈출 때까지 프레임이 5개 쌓이고, 반환하며 하나씩 꺼내집니다. 반복 버전은 result와 i 두 변수만 갱신하므로 입력이 커져도 추가 공간이 O(1)입니다. 시각화의 호출 스택 탭이 보여 주는 과정과 같습니다. 아래쪽은 메모이제이션 없는 재귀 피보나치에서 호출 횟수를 센 결과입니다. fib(5)에서 15번, fib(25)에서 242,785번 호출됩니다. fib(n)이 fib(n - 1)과 fib(n - 2)를 각각 호출하므로 같은 값을 여러 번 다시 계산하고, n이 1 늘 때 호출 수가 대략 1.6배씩 늘어나 지수적으로 커집니다. 시각화의 중복 계산 탭이 보여 주는 호출 트리와 같습니다.
Python — 메모이제이션, 반복, 재귀 한도
import sys from functools import lru_cache @lru_cache(maxsize=None) def fib_memo(n): return n if n < 2 else fib_memo(n - 1) + fib_memo(n - 2) print(fib_memo(50), fib_memo.cache_info().misses) # 12586269025 51 def fib_iter(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a print(fib_iter(50)) # 12586269025 print(sys.getrecursionlimit()) # 1000 def fact_tail(n, acc=1): # 꼬리 재귀: 곱셈을 호출 전에 끝낸다 return acc if n <= 1 else fact_tail(n - 1, acc * n) print(fact_tail(5)) # 120 try: fact_tail(5000) except RecursionError: print("RecursionError") # 꼬리 재귀여도 파이썬은 깊이가 자란다 print(fact_iter(5000) > 0) # True: 반복은 한도가 없다
중복 계산은 두 가지 방법으로 없앱니다. lru_cache는 계산한 인수와 결과를 저장해 fib_memo(50)에서 0부터 50까지 서로 다른 값 51개를 한 번씩만 계산합니다(cache_info의 misses가 51). 반복 버전 fib_iter는 이전 두 값만 들고 앞으로 나아가 변수 두 개로 같은 값을 얻습니다. CPython에는 재귀 깊이의 기본 한도가 있어 sys.getrecursionlimit()이 1000을 돌려줍니다. 깊이가 한도를 넘으면 RecursionError가 납니다. fact_tail은 곱셈을 호출 전에 acc에 끝내 둔 꼬리 재귀이지만, CPython은 꼬리 호출 최적화를 하지 않아 fact_tail(5000)에서도 같은 오류가 납니다. 반복문 fact_iter는 스택 깊이가 늘지 않으므로 5000에서도 계산됩니다. 시각화의 꼬리 호출 탭이 보여 주는 비교와 같습니다.
Java — 재귀, 반복, 메모이제이션과 스택 오버플로우
static long fact(int n) { return n <= 1 ? 1 : n * fact(n - 1); } static long factIter(int n) { long r = 1; for (int i = 2; i <= n; i++) r *= i; return r; } static long[] memo = new long[91]; static long fibMemo(int n) { if (n < 2) return n; if (memo[n] != 0) return memo[n]; // 이미 계산한 값 return memo[n] = fibMemo(n - 1) + fibMemo(n - 2); } static long depth(int n) { return depth(n + 1); } // 기저 사례가 없는 재귀 // fact(5) = factIter(5) = 120, fibMemo(50) = 12586269025 // try { depth(0); } catch (StackOverflowError e) { ... } → StackOverflowError
자바에서도 구조는 같습니다. 재귀 fact와 반복 factIter는 같은 값을 내고, fibMemo는 배열 memo에 계산한 값을 저장해 같은 n을 두 번 계산하지 않습니다. 이 저장 배열이 곧 메모이제이션입니다. 기저 사례가 없거나 깊이가 너무 깊은 재귀는 호출 스택이 가득 차 StackOverflowError를 던집니다. 스택 크기는 JVM 설정에 따라 다르므로 정확한 깊이는 실행 환경마다 다릅니다. 자바는 꼬리 호출 최적화를 하지 않으므로 꼬리 재귀로 고쳐도 깊이가 줄지 않고, 깊은 입력은 반복문이나 명시적 스택으로 바꿔야 합니다.
Q1
재귀 호출이 너무 깊어질 때 발생할 수 있는 문제는?
Q2
꼬리 재귀 최적화(Tail Call Optimization)의 효과는? (지원하는 언어·컴파일러 기준)
Q3
메모이제이션 없는 단순 재귀 피보나치가 비효율적인 이유는?