복잡한 문제 앞에서 "일단 쪼개보자"라는 생각, 해보신 적 있으신가요? 재귀와 분할 정복은 바로 그 사고법을 코드로 옮긴 것입니다.

개념 정의

재귀(Recursion)는 함수가 자기 자신을 호출하는 프로그래밍 기법입니다. 단순해 보이지만, 이 안에는 "큰 문제를 같은 구조의 작은 문제로 바꾼다" 는 핵심 아이디어가 담겨 있습니다.

JAVA
// 팩토리얼: n! = n × (n-1)!
public static int factorial(int n) {
    if (n <= 1) return 1;        // 기저 조건(Base Case)
    return n * factorial(n - 1); // 재귀 호출
}

재귀 함수에는 반드시 두 가지가 필요합니다.

  • 기저 조건(Base Case): 재귀를 멈추는 조건. 이게 없으면 무한 루프에 빠집니다.
  • ** 재귀 단계(Recursive Step)**: 문제를 더 작은 단위로 줄여서 자기 자신을 호출하는 부분.

콜 스택과 재귀의 관계

재귀 호출이 일어날 때마다 JVM의 콜 스택에 새로운 ** 스택 프레임 **이 쌓입니다. 각 프레임에는 매개변수, 지역 변수, 복귀 주소가 저장됩니다.

PLAINTEXT
factorial(4) 호출 시 콜 스택:

| factorial(1) |  ← 기저 조건, 반환 시작
| factorial(2) |
| factorial(3) |
| factorial(4) |
|    main()    |

Java 스택 크기 제한

Java의 기본 스레드 스택 크기는 보통 512KB~1MB입니다. 재귀 깊이가 너무 깊으면 StackOverflowError가 발생합니다.

JAVA
// 이러면 터집니다
public static void infiniteRecursion(int n) {
    infiniteRecursion(n + 1); // 기저 조건이 없음!
}

스택 크기는 JVM 옵션으로 조절할 수 있습니다.

BASH
java -Xss4m MyProgram  # 스택 크기를 4MB로 설정

하지만 스택 크기를 늘리는 것은 임시 방편입니다. 재귀 깊이가 수만 이상이라면 ** 반복문으로 변환 **하는 것이 바람직합니다.

왜 재귀가 필요한가

반복문으로도 대부분의 문제를 풀 수 있습니다. 그런데 왜 굳이 재귀를 쓸까요?

  1. ** 트리나 그래프 탐색 **: 구조 자체가 재귀적입니다. DFS를 반복문으로 짜면 직접 스택을 관리해야 합니다.
  2. ** 분할 정복 알고리즘 **: Merge Sort, Quick Sort 등은 재귀로 표현하면 매우 자연스럽습니다.
  3. ** 코드 가독성 **: 수학적 정의를 그대로 코드로 옮길 수 있습니다.

분할 정복(Divide and Conquer)

분할 정복은 재귀를 활용하는 대표적인 알고리즘 설계 패턴입니다.

세 단계

  1. ** 분할(Divide)**: 원래 문제를 같은 구조의 더 작은 하위 문제로 나눕니다.
  2. ** 정복(Conquer)**: 하위 문제를 재귀적으로 풀어냅니다. 충분히 작으면 직접 풀니다.
  3. ** 결합(Combine)**: 하위 문제의 해답을 합쳐서 원래 문제의 해답을 만듭니다.

Merge Sort로 이해하기

Merge Sort는 분할 정복의 교과서적인 예시입니다.

JAVA
public static void mergeSort(int[] arr, int left, int right) {
    if (left >= right) return; // 기저 조건: 원소가 1개 이하

    int mid = left + (right - left) / 2;

    // 분할 + 정복
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);

    // 결합
    merge(arr, left, mid, right);
}

private static void merge(int[] arr, int left, int mid, int right) {
    int[] temp = new int[right - left + 1];
    int i = left, j = mid + 1, k = 0;

    // 두 부분 배열을 비교하며 병합
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp[k++] = arr[i++];
        } else {
            temp[k++] = arr[j++];
        }
    }

    // 남은 원소 복사
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];

    // 원래 배열에 복사
    System.arraycopy(temp, 0, arr, left, temp.length);
}

분할 정복의 시간 복잡도 분석

분할 정복 알고리즘의 시간 복잡도는 ** 마스터 정리(Master Theorem)**로 분석할 수 있습니다.

PLAINTEXT
T(n) = aT(n/b) + O(n^d)

a: 하위 문제의 개수
b: 문제를 나누는 비율
d: 결합 단계의 복잡도 지수

Merge Sort의 경우 a=2, b=2, d=1이므로 T(n) = O(n log n)입니다.

또 다른 분할 정복 예제: 거듭제곱

a^n을 구할 때 단순 반복은 O(n)이지만, 분할 정복을 쓰면 O(log n)입니다.

JAVA
// 빠른 거듭제곱: O(log n)
public static long power(long base, long exp, long mod) {
    if (exp == 0) return 1;

    long half = power(base, exp / 2, mod);
    long result = (half * half) % mod;

    if (exp % 2 == 1) {
        result = (result * base) % mod;
    }
    return result;
}

이 기법은 알고리즘 문제뿐만 아니라 암호화(RSA 등) 에서도 핵심적으로 사용됩니다.

꼬리 재귀(Tail Recursion)

꼬리 재귀란, 재귀 호출이 함수의 마지막 동작 이고, 그 결과를 추가 연산 없이 그대로 반환 하는 형태입니다.

JAVA
// 일반 재귀 — 반환 후 n을 곱하는 연산이 남아 있음
public static int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1); // 곱셈이 남아 있으므로 꼬리 재귀가 아님
}

// 꼬리 재귀 — 결과를 누적하여 전달
public static int factorialTail(int n, int acc) {
    if (n <= 1) return acc;
    return factorialTail(n - 1, n * acc); // 마지막 동작이 재귀 호출
}

꼬리 재귀 최적화(TCO)를 지원하는 언어에서는 스택 프레임을 재사용하여 O(1) 공간으로 동작합니다. 하지만 **Java는 TCO를 공식 지원하지 않습니다 **. 따라서 Java에서는 꼬리 재귀 형태로 작성하더라도 스택이 쌓입니다.

Kotlin의 tailrec 키워드나 Scala의 @tailrec은 컴파일러가 반복문으로 변환해줍니다.

재귀를 반복문으로 변환하기

재귀 깊이가 깊어질 수 있는 경우, 명시적 스택을 사용하여 반복문으로 바꿀 수 있습니다.

JAVA
// 재귀 DFS
public void dfsRecursive(int node, boolean[] visited) {
    visited[node] = true;
    for (int next : graph[node]) {
        if (!visited[next]) {
            dfsRecursive(next, visited);
        }
    }
}

// 반복문 DFS — 명시적 스택 사용
public void dfsIterative(int start) {
    boolean[] visited = new boolean[n];
    Deque<Integer> stack = new ArrayDeque<>();
    stack.push(start);

    while (!stack.isEmpty()) {
        int node = stack.pop();
        if (visited[node]) continue;
        visited[node] = true;

        for (int next : graph[node]) {
            if (!visited[next]) {
                stack.push(next);
            }
        }
    }
}

백엔드/실무에서의 연결

재귀와 분할 정복은 알고리즘 문제에만 쓰이는 게 아닙니다.

파일 시스템 탐색

디렉토리 구조는 트리입니다. 파일을 재귀적으로 탐색하는 건 아주 자연스럽습니다.

JAVA
public static void listFiles(File dir) {
    File[] files = dir.listFiles();
    if (files == null) return;

    for (File file : files) {
        if (file.isDirectory()) {
            listFiles(file); // 재귀 탐색
        } else {
            System.out.println(file.getPath());
        }
    }
}

MapReduce 패러다임

분할 정복의 분산 버전이라고 볼 수 있습니다. 대량의 데이터를 분할(Map)하고, 각각 처리한 결과를 합치는(Reduce) 구조입니다.

JSON/XML 파싱

중첩된 구조의 데이터를 파싱할 때 재귀적 하강 파서(Recursive Descent Parser)가 사용됩니다. Spring의 Jackson 라이브러리 내부에서도 이런 패턴이 활용됩니다.

Spring의 Composite 패턴

CompositeHealthIndicator 같은 클래스는 트리 구조를 재귀적으로 순회하며 건강 상태를 집계합니다.

주의할 점

StackOverflowError를 무시하는 실수

Java에서 재귀 깊이가 수천을 넘으면 StackOverflowError가 발생합니다. 재귀 깊이가 입력 크기에 비례하는 경우, 반복문으로 변환하는 것이 안전합니다. -Xss 옵션으로 스택을 늘리는 것은 임시 방편일 뿐입니다.

기저 조건을 빠뜨려 무한 루프에 빠지는 실수

재귀 함수에서 기저 조건(Base Case)을 빠뜨리거나 잘못 설정하면 무한 재귀에 빠집니다. 재귀 함수를 작성할 때는 "이 호출이 반드시 기저 조건에 가까워지는가"를 먼저 확인해야 합니다.

분할 정복에서 결합(Combine) 단계의 비용을 과소평가

Merge Sort에서 병합 단계가 O(n)이라는 것을 간과하면, 불필요한 배열 복사로 상수 계수가 커질 수 있습니다.


정리

구분재귀반복문
가독성수학적 정의와 유사하여 직관적상태 관리가 명시적
공간콜 스택 O(깊이)추가 공간 필요 없거나 명시적 스택
성능함수 호출 오버헤드 있음일반적으로 더 빠름
적합한 경우트리/그래프, 분할 정복단순 반복, 깊이가 깊은 경우

기억할 포인트:

  • 재귀는 "큰 문제를 같은 구조의 작은 문제로" 바꾸는 사고법입니다.
  • 분할 정복은 ** 분할 → 정복 → 결합** 세 단계로 이루어집니다.
  • Java에서 재귀 깊이가 깊어지면 StackOverflowError를 조심해야 합니다. 깊이가 수만 이상이면 반복문 변환을 고려하세요.
  • 꼬리 재귀는 Java에서 최적화되지 않지만, 개념은 알아두면 다른 언어에서 유용합니다.
댓글 로딩 중...