이미지 로딩 중...

함수 호출 스택과 재귀 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 7. · 3 Views

함수 호출 스택과 재귀 완벽 가이드

함수 호출 스택의 동작 원리와 재귀 함수를 깊이 있게 이해하고 실무에서 효과적으로 활용하는 방법을 배웁니다. 스택 오버플로우 문제부터 꼬리 재귀 최적화까지, 중급 개발자가 알아야 할 핵심 개념을 다룹니다.


목차

  1. 호출 스택 기본 동작 원리 - 함수 실행의 메모리 구조 이해하기
  2. 재귀 함수의 기본 구조 - Base Case와 Recursive Case 이해하기
  3. 스택 오버플로우 이해와 예방 - 재귀 깊이 제한 다루기
  4. 꼬리 재귀 최적화 - 스택 프레임 재사용하기
  5. 재귀적 트리 순회 - 깊이 우선 탐색(DFS) 구현하기
  6. 메모이제이션 기법 - 중복 계산 제거하기
  7. 재귀를 반복문으로 전환하기 - 명시적 스택 활용
  8. 재귀와 분할 정복 알고리즘 - 병합 정렬 이해하기
  9. 재귀와 백트래킹 - 순열과 조합 생성하기
  10. 재귀 함수 디버깅 기법 - 호출 트리 시각화하기

1. 호출 스택 기본 동작 원리 - 함수 실행의 메모리 구조 이해하기

시작하며

여러분이 디버거로 코드를 따라가다 보면 함수가 중첩되어 호출될 때 어떤 순서로 실행되는지 궁금했던 적 있나요? 특히 비동기 코드가 섞여 있을 때 실행 순서가 예상과 다르게 나타나 당황했던 경험이 있을 겁니다.

이런 혼란은 호출 스택(Call Stack)의 동작 원리를 정확히 이해하지 못해서 발생합니다. 호출 스택은 프로그램이 현재 어디를 실행 중인지, 어디로 돌아가야 하는지를 추적하는 핵심 메커니즘입니다.

이를 제대로 이해하지 못하면 스택 오버플로우 에러나 예상치 못한 실행 순서 문제에 직면하게 됩니다. 바로 이럴 때 필요한 것이 호출 스택의 동작 원리입니다.

스택의 LIFO(Last In First Out) 구조를 이해하면 복잡한 함수 호출 흐름도 명확하게 파악할 수 있고, 디버깅이 훨씬 쉬워집니다.

개요

간단히 말해서, 호출 스택은 프로그램이 함수 호출을 추적하는 데이터 구조입니다. 함수가 호출되면 스택에 쌓이고(push), 함수가 종료되면 스택에서 제거됩니다(pop).

실무에서 API 호출 체인이 복잡하게 얽혀 있거나, 이벤트 핸들러가 중첩되어 있을 때 호출 스택을 이해하면 코드의 실행 흐름을 정확히 예측할 수 있습니다. 예를 들어, 사용자 인증 → 데이터 조회 → 데이터 변환 → UI 업데이트가 순차적으로 일어나는 경우, 각 단계가 스택에 어떻게 쌓이고 제거되는지 알면 에러 발생 지점을 빠르게 찾을 수 있습니다.

기존에는 console.log()로 실행 순서를 확인했다면, 이제는 브라우저 개발자 도구의 Call Stack 패널을 활용하여 실시간으로 함수 호출 흐름을 시각화할 수 있습니다. 호출 스택의 핵심 특징은 첫째, LIFO 구조로 가장 최근에 호출된 함수가 먼저 종료되고, 둘째, 각 함수 호출마다 스택 프레임이 생성되어 지역 변수와 매개변수가 저장되며, 셋째, 스택 크기에는 제한이 있어 무한 재귀 시 스택 오버플로우가 발생한다는 점입니다.

이러한 특징들이 메모리 관리와 성능 최적화에 직접적인 영향을 미칩니다.

코드 예제

function first() {
  console.log('first 함수 시작');
  second(); // second 함수를 호출하면 스택에 추가됨
  console.log('first 함수 종료'); // second가 완료된 후 실행
}

function second() {
  console.log('second 함수 시작');
  third(); // third 함수를 호출하면 스택에 추가됨
  console.log('second 함수 종료'); // third가 완료된 후 실행
}

function third() {
  console.log('third 함수 시작');
  console.log('Call Stack: third -> second -> first -> global');
  console.log('third 함수 종료'); // 가장 먼저 스택에서 제거됨
}

first(); // 실행 시작점

설명

이것이 하는 일: 위 코드는 세 개의 함수가 순차적으로 호출될 때 호출 스택이 어떻게 쌓이고 해제되는지를 보여줍니다. 각 함수의 시작과 종료를 로그로 출력하여 실행 순서를 명확히 확인할 수 있습니다.

첫 번째로, first() 함수가 호출되면 전역 컨텍스트 위에 first의 스택 프레임이 생성됩니다. 이 프레임에는 first 함수의 지역 변수, 매개변수, 반환 주소가 저장됩니다.

first가 실행되다가 second()를 만나면 실행이 일시 중단되고 second의 스택 프레임이 first 위에 쌓입니다. 그 다음으로, second 함수가 실행되면서 third()를 호출하게 되면, third의 스택 프레임이 가장 위에 추가됩니다.

이제 스택의 상태는 아래에서부터 global → first → second → third 순서로 쌓여 있습니다. third 함수는 더 이상 다른 함수를 호출하지 않으므로 모든 로그를 출력한 후 종료됩니다.

마지막으로, third가 종료되면 해당 스택 프레임이 제거되고 제어권이 second로 돌아갑니다. second의 나머지 코드가 실행되고 종료되면 다시 first로 돌아갑니다.

first의 마지막 줄까지 실행되면 전역 컨텍스트만 남게 되고 프로그램이 종료됩니다. 여러분이 이 코드를 실행하면 출력 순서가 first 시작 → second 시작 → third 시작 → third 종료 → second 종료 → first 종료 순서로 나타나는 것을 확인할 수 있습니다.

이는 스택의 LIFO 특성을 정확히 보여주며, 디버깅 시 브라우저의 Call Stack 패널에서도 동일한 구조를 시각적으로 확인할 수 있습니다. 실무에서는 이 원리를 활용하여 에러 스택 트레이스를 읽고 문제의 근원을 추적할 수 있습니다.

실전 팁

💡 Chrome DevTools의 Call Stack 패널을 활용하면 브레이크포인트를 설정했을 때 현재 스택의 모든 프레임을 볼 수 있습니다. 각 프레임을 클릭하면 해당 시점의 변수 상태를 확인할 수 있어 디버깅이 훨씬 효율적입니다.

💡 Error 객체의 stack 프로퍼티를 출력하면 에러 발생 시점의 전체 호출 스택을 문자열로 확인할 수 있습니다. 프로덕션 환경에서 에러 로깅 시 반드시 포함시켜야 할 정보입니다.

💡 스택 크기는 브라우저와 환경마다 다르지만 일반적으로 수천에서 수만 개의 프레임을 지원합니다. 재귀 함수를 사용할 때는 항상 스택 오버플로우 가능성을 염두에 두고 종료 조건을 명확히 설정해야 합니다.

💡 비동기 함수(async/await, Promise)는 호출 스택에서 즉시 제거되고 태스크 큐로 이동하므로, 동기 코드와는 다른 실행 흐름을 가집니다. 이를 혼동하지 않도록 주의하세요.


2. 재귀 함수의 기본 구조 - Base Case와 Recursive Case 이해하기

시작하며

여러분이 트리 구조 데이터를 순회하거나 복잡한 수학 문제를 풀 때, 반복문으로는 코드가 지나치게 복잡해지는 경험을 해보셨나요? 중첩된 반복문을 여러 겹 사용하다 보면 코드 가독성이 떨어지고 유지보수가 어려워집니다.

이런 문제는 문제의 구조가 본질적으로 재귀적일 때 발생합니다. 파일 시스템 탐색, JSON 객체 깊은 복사, 조합 문제 같은 경우 각 단계가 동일한 패턴을 반복하는데, 이를 반복문으로 표현하면 복잡도가 급격히 증가합니다.

바로 이럴 때 필요한 것이 재귀 함수입니다. 재귀는 함수가 자기 자신을 호출하는 기법으로, 복잡한 문제를 동일한 패턴의 더 작은 문제로 분해하여 해결합니다.

올바른 재귀 구조를 이해하면 코드가 놀라울 정도로 간결해지고 문제의 본질을 더 명확하게 표현할 수 있습니다.

개요

간단히 말해서, 재귀 함수는 자기 자신을 호출하는 함수로, 반드시 Base Case(종료 조건)와 Recursive Case(재귀 호출)로 구성되어야 합니다. Base Case가 없으면 무한 재귀에 빠져 스택 오버플로우가 발생합니다.

재귀는 계층적 데이터 구조를 다룰 때 특히 강력합니다. 예를 들어, 회사의 조직도를 표현하는 트리 구조에서 특정 직원의 모든 하위 직원을 찾거나, React 컴포넌트 트리에서 특정 props를 가진 컴포넌트를 검색할 때 재귀를 사용하면 깊이에 관계없이 동일한 로직으로 처리할 수 있습니다.

기존에는 스택이나 큐 자료구조를 직접 구현하여 반복문으로 순회했다면, 이제는 재귀 함수 하나로 동일한 작업을 훨씬 직관적으로 표현할 수 있습니다. 재귀 함수의 핵심 특징은 첫째, 문제를 더 작은 동일한 형태의 하위 문제로 분해하고, 둘째, Base Case에 도달하면 재귀 호출을 멈추고 값을 반환하며, 셋째, 각 재귀 호출은 독립적인 스택 프레임을 가지므로 변수가 서로 격리된다는 점입니다.

이러한 특징들이 복잡한 알고리즘을 우아하게 구현할 수 있게 해줍니다.

코드 예제

function factorial(n) {
  // Base Case: 재귀를 멈추는 조건
  if (n === 0 || n === 1) {
    return 1;
  }

  // Recursive Case: 문제를 더 작은 문제로 분해
  // factorial(5) = 5 * factorial(4)
  // factorial(4) = 4 * factorial(3) ... 이런 식으로 계속 분해됨
  return n * factorial(n - 1);
}

console.log(factorial(5)); // 120
// 실행 순서: 5 * 4 * 3 * 2 * 1 = 120
// 스택: factorial(5) -> factorial(4) -> factorial(3) -> factorial(2) -> factorial(1)
// 반환: 1 <- 2 <- 6 <- 24 <- 120

설명

이것이 하는 일: 위 코드는 팩토리얼을 계산하는 재귀 함수로, n! = n × (n-1)!

공식을 그대로 코드로 옮긴 것입니다. 수학적 정의와 코드가 거의 일치하여 매우 직관적입니다.

첫 번째로, factorial(5)가 호출되면 n이 0도 1도 아니므로 Base Case를 건너뛰고 Recursive Case로 진입합니다. 여기서 5 * factorial(4)를 계산해야 하는데, factorial(4)의 값을 아직 모르므로 이를 계산하기 위해 다시 factorial(4)를 호출합니다.

이때 현재 함수의 실행은 일시 중단되고 스택에 저장됩니다. 그 다음으로, factorial(4), factorial(3), factorial(2), factorial(1)이 순차적으로 호출되면서 스택에 계속 쌓입니다.

각 호출마다 n 값이 1씩 감소하며, 드디어 factorial(1)에 도달하면 Base Case 조건(n === 1)이 참이 되어 1을 즉시 반환합니다. 이 시점부터 스택이 풀리기 시작합니다.

마지막으로, factorial(1)이 1을 반환하면 factorial(2)는 2 * 1 = 2를 계산하여 반환하고, factorial(3)은 3 * 2 = 6을 반환하는 식으로 연쇄적으로 값이 계산됩니다. 최종적으로 factorial(5)는 5 * 24 = 120을 반환하며 모든 스택 프레임이 제거됩니다.

여러분이 이 코드를 사용하면 반복문 없이도 수학적 정의를 그대로 구현할 수 있습니다. 코드의 의도가 명확하여 가독성이 높고, 유사한 패턴의 다른 문제(피보나치 수열, 거듭제곱 등)에도 쉽게 적용할 수 있습니다.

다만 큰 수를 입력하면 스택 오버플로우가 발생할 수 있으므로, 성능이 중요한 경우에는 꼬리 재귀 최적화나 메모이제이션을 함께 고려해야 합니다.

실전 팁

💡 재귀 함수를 작성할 때는 항상 Base Case부터 먼저 작성하세요. Base Case를 먼저 정의하면 무한 재귀를 방지할 수 있고, 함수의 종료 조건이 명확해집니다.

💡 재귀 호출 시 인자가 Base Case에 점점 가까워지는지 확인하세요. factorial(n - 1)처럼 n이 감소하여 결국 1에 도달해야 합니다. 만약 인자가 변하지 않거나 멀어진다면 무한 재귀에 빠집니다.

💡 복잡한 재귀 함수는 종이에 호출 트리를 그려보세요. factorial(3)의 경우 3 → 2 → 1 → 반환 순서를 시각화하면 이해가 훨씬 쉬워집니다.

💡 JavaScript에서는 일반적으로 약 10,000번 정도의 재귀 호출까지 가능하지만, 이는 환경마다 다릅니다. 깊은 재귀가 예상되면 try-catch로 RangeError를 처리하거나 반복문으로 전환하는 것을 고려하세요.


3. 스택 오버플로우 이해와 예방 - 재귀 깊이 제한 다루기

시작하며

여러분이 재귀 함수를 실행했는데 갑자기 "Maximum call stack size exceeded" 에러가 발생하여 브라우저가 멈춘 경험이 있나요? 특히 사용자 입력값에 따라 재귀 깊이가 달라지는 경우, 예상치 못한 입력으로 인해 프로덕션 환경에서 애플리케이션이 크래시되는 심각한 문제가 발생할 수 있습니다.

이런 문제는 호출 스택의 크기가 제한되어 있기 때문에 발생합니다. 각 재귀 호출마다 스택 프레임이 생성되는데, 재귀 깊이가 스택 용량을 초과하면 메모리 부족으로 프로그램이 중단됩니다.

이는 단순히 코드 에러가 아니라 시스템 리소스의 물리적 한계입니다. 바로 이럴 때 필요한 것이 스택 오버플로우 예방 기법입니다.

재귀 깊이를 제한하거나, 반복문으로 전환하거나, 꼬리 재귀 최적화를 활용하면 동일한 로직을 안전하게 실행할 수 있습니다. 실무에서는 재귀를 사용하기 전에 항상 최대 깊이를 예측하고 안전장치를 마련해야 합니다.

개요

간단히 말해서, 스택 오버플로우는 재귀 호출이 너무 깊어져 호출 스택의 메모리 한계를 초과할 때 발생하는 런타임 에러입니다. 이를 예방하려면 재귀 깊이를 제한하거나 다른 알고리즘으로 대체해야 합니다.

실무에서 사용자가 업로드한 JSON 파일을 재귀적으로 파싱하거나, 깊게 중첩된 댓글 스레드를 렌더링할 때 스택 오버플로우가 발생할 수 있습니다. 예를 들어, 악의적인 사용자가 수천 단계로 중첩된 데이터를 업로드하면 서버나 클라이언트가 다운될 수 있습니다.

이를 방지하려면 최대 깊이를 검증하고 초과 시 에러를 반환하는 방어 로직이 필요합니다. 기존에는 재귀 함수를 무작정 사용했다면, 이제는 재귀 깊이를 카운트하여 임계값을 넘으면 조기에 종료하거나 경고를 발생시킬 수 있습니다.

스택 오버플로우 예방의 핵심은 첫째, 재귀 깊이를 명시적으로 추적하고 제한하며, 둘째, 깊이가 예측 불가능한 경우 반복문이나 스택 자료구조로 대체하고, 셋째, 가능하면 꼬리 재귀 형태로 작성하여 최적화 가능성을 높이는 것입니다. 이러한 기법들이 안정적인 프로덕션 코드를 작성하는 데 필수적입니다.

코드 예제

function deepSum(arr, depth = 0, maxDepth = 100) {
  // 재귀 깊이 제한 - 스택 오버플로우 예방
  if (depth > maxDepth) {
    throw new Error(`Maximum recursion depth (${maxDepth}) exceeded`);
  }

  let sum = 0;
  for (let item of arr) {
    if (Array.isArray(item)) {
      // 중첩된 배열이면 재귀 호출, depth 증가
      sum += deepSum(item, depth + 1, maxDepth);
    } else if (typeof item === 'number') {
      sum += item;
    }
  }
  return sum;
}

// 정상 동작: [1, [2, [3, [4]]]] = 10
console.log(deepSum([1, [2, [3, [4]]]])); // 10

// 깊이 초과 시 에러 발생
// deepSum([1, [2, [3, ...]]], 0, 5); // Error: Maximum recursion depth exceeded

설명

이것이 하는 일: 위 코드는 중첩된 배열의 모든 숫자를 합산하는 재귀 함수로, depth 매개변수를 통해 현재 재귀 깊이를 추적하고 maxDepth를 초과하면 에러를 발생시켜 스택 오버플로우를 사전에 방지합니다. 첫 번째로, 함수 시작 시 depth와 maxDepth를 비교하여 재귀 깊이가 한계를 넘었는지 확인합니다.

이 검사는 재귀 호출 이전에 수행되므로, 실제로 스택이 오버플로우되기 전에 안전하게 에러를 던집니다. 기본값으로 maxDepth를 100으로 설정했지만, 호출 시 필요에 따라 조정할 수 있습니다.

그 다음으로, 배열의 각 요소를 순회하면서 숫자는 합산하고, 중첩된 배열을 만나면 재귀 호출을 수행합니다. 중요한 점은 재귀 호출 시 depth + 1을 전달하여 현재보다 한 단계 깊어졌음을 표시한다는 것입니다.

이렇게 하면 중첩 레벨이 깊어질수록 depth 값도 증가하여, 결국 maxDepth에 도달하게 됩니다. 마지막으로, 각 재귀 레벨에서 계산된 부분 합들이 반환되면서 상위 레벨로 전파됩니다.

[1, [2, [3, [4]]]]의 경우 가장 안쪽 [4]부터 계산되어 4를 반환하고, [3, 4]는 7을 반환하며, 최종적으로 10이 반환됩니다. 만약 배열이 100단계보다 깊게 중첩되어 있다면 즉시 명확한 에러 메시지와 함께 실행이 중단됩니다.

여러분이 이 코드를 사용하면 사용자 입력 데이터를 안전하게 처리할 수 있습니다. API로부터 받은 JSON 데이터의 깊이를 신뢰할 수 없는 경우, 이런 방어 로직이 서버 다운을 방지합니다.

또한 에러 메시지가 명확하여 디버깅이 쉽고, maxDepth를 조정하여 다양한 상황에 유연하게 대응할 수 있습니다. 성능 측면에서도 depth 추가 매개변수는 오버헤드가 거의 없으므로 실무에서 안심하고 사용할 수 있습니다.

실전 팁

💡 프로덕션 환경에서는 maxDepth를 환경 변수로 관리하세요. 개발 환경에서는 낮은 값으로 설정하여 문제를 조기에 발견하고, 프로덕션에서는 실제 데이터 특성에 맞게 설정합니다.

💡 재귀 깊이 제한을 초과했을 때 에러를 던지는 대신 경고만 로깅하고 반복문으로 전환하는 폴백 로직을 구현할 수도 있습니다. 이렇게 하면 서비스 중단 없이 동작을 계속할 수 있습니다.

💡 try-catch로 RangeError를 잡아 스택 오버플로우를 사후에 처리할 수도 있지만, 이미 스택이 손상된 상태이므로 복구가 어렵습니다. 사전 예방이 항상 더 나은 전략입니다.

💡 Chrome DevTools의 Performance 탭에서 재귀 함수의 실행 시간과 호출 횟수를 프로파일링하면, 재귀가 성능 병목인지 판단할 수 있습니다. 호출 횟수가 수만 번을 넘어가면 반복문 전환을 고려하세요.


4. 꼬리 재귀 최적화 - 스택 프레임 재사용하기

시작하며

여러분이 재귀 함수를 작성했는데 성능 문제나 스택 오버플로우로 고민하고 있다면, 꼬리 재귀 최적화(Tail Call Optimization)라는 강력한 기법을 알아야 합니다. 일반 재귀에서는 함수 호출마다 스택 프레임이 쌓이지만, 꼬리 재귀에서는 스택 프레임을 재사용하여 메모리를 절약할 수 있습니다.

이 기법은 함수의 마지막 동작이 재귀 호출 자체일 때만 적용됩니다. 일반 재귀에서는 재귀 호출 후 추가 계산이 필요하지만, 꼬리 재귀에서는 재귀 호출이 곧 반환값이므로 현재 스택 프레임을 유지할 필요가 없습니다.

이를 컴파일러나 인터프리터가 감지하면 스택을 재사용하여 O(1) 공간 복잡도로 실행합니다. 바로 이럴 때 필요한 것이 꼬리 재귀 형태로 함수를 재작성하는 기술입니다.

JavaScript의 경우 ES6부터 스펙에 포함되었지만 모든 엔진이 구현한 것은 아니므로, 명시적으로 반복문으로 변환하거나 트랜스파일러를 활용할 수 있습니다.

개요

간단히 말해서, 꼬리 재귀는 함수의 마지막 동작이 자기 자신을 호출하는 것인 재귀 형태로, 컴파일러가 이를 감지하면 스택 프레임을 재사용하여 메모리를 절약하고 스택 오버플로우를 방지합니다. 실무에서 대량의 데이터를 재귀적으로 처리해야 할 때 꼬리 재귀를 활용하면 성능이 크게 향상됩니다.

예를 들어, 로그 파일의 수백만 줄을 재귀적으로 파싱하거나, 대규모 그래프를 탐색할 때 꼬리 재귀 형태로 작성하면 반복문과 동등한 성능을 유지하면서도 코드의 표현력은 높일 수 있습니다. 기존에는 일반 재귀로 작성하여 스택 오버플로우 위험이 있었다면, 이제는 누적값(accumulator)을 매개변수로 전달하는 꼬리 재귀 패턴으로 전환하여 무한에 가까운 재귀도 안전하게 실행할 수 있습니다.

꼬리 재귀의 핵심은 첫째, 함수의 마지막 표현식이 재귀 호출 자체여야 하며(재귀 호출 후 추가 연산 없음), 둘째, 중간 결과를 누적값 매개변수로 전달하여 최종 결과를 만들고, 셋째, Base Case에 도달하면 누적값을 그대로 반환한다는 점입니다. 이러한 패턴이 메모리 효율적인 재귀를 가능하게 합니다.

코드 예제

// 일반 재귀: 스택 프레임이 계속 쌓임
function factorialNormal(n) {
  if (n <= 1) return 1;
  return n * factorialNormal(n - 1); // 재귀 호출 후 곱셈 연산이 남아있음
}

// 꼬리 재귀: 스택 프레임 재사용 가능
function factorialTail(n, accumulator = 1) {
  if (n <= 1) return accumulator; // Base Case에서 누적값 반환

  // 마지막 동작이 재귀 호출 자체 - 곱셈을 먼저 수행하여 accumulator에 저장
  return factorialTail(n - 1, n * accumulator);
}

console.log(factorialNormal(5)); // 120
console.log(factorialTail(5));   // 120

// 큰 값에서 차이 발생
// factorialNormal(10000); // Stack overflow 위험
// factorialTail(10000);   // 이론적으로 안전 (엔진이 최적화 지원 시)

설명

이것이 하는 일: 위 코드는 동일한 팩토리얼 계산을 일반 재귀와 꼬리 재귀 두 가지 방식으로 구현하여 차이점을 명확히 보여줍니다. 일반 재귀는 재귀 호출 후 곱셈 연산이 남아 있어 스택이 쌓이지만, 꼬리 재귀는 모든 계산을 재귀 호출 전에 완료합니다.

첫 번째로, factorialNormal(5)를 호출하면 5 * factorialNormal(4)를 계산해야 하는데, 이 곱셈은 factorialNormal(4)의 결과를 받은 후에야 수행됩니다. 따라서 현재 함수의 실행 컨텍스트(n=5라는 정보)를 스택에 보관해야 합니다.

이런 식으로 n=4, 3, 2, 1까지 모든 컨텍스트가 스택에 쌓입니다. 그 다음으로, factorialTail(5)를 호출하면 accumulator를 사용하여 중간 결과를 저장합니다.

첫 호출에서 factorialTail(4, 51=5)를 호출하고, 다음에는 factorialTail(3, 45=20), factorialTail(2, 320=60), factorialTail(1, 260=120)으로 진행됩니다. 중요한 점은 각 재귀 호출이 함수의 마지막 표현식이므로, 현재 컨텍스트를 더 이상 필요로 하지 않는다는 것입니다.

마지막으로, n이 1에 도달하면 accumulator에 이미 최종 결과인 120이 저장되어 있으므로 그대로 반환합니다. 이론적으로 최적화를 지원하는 JavaScript 엔진(Safari의 JavaScriptCore 등)에서는 각 재귀 호출이 같은 스택 프레임을 재사용하므로, 10,000번 재귀를 호출해도 스택 깊이는 1로 유지됩니다.

여러분이 이 코드를 사용하면 재귀의 표현력을 유지하면서도 메모리 효율을 얻을 수 있습니다. 다만 현재 대부분의 JavaScript 환경(Chrome V8, Node.js)에서는 꼬리 재귀 최적화가 구현되어 있지 않으므로, 실제로는 여전히 스택이 쌓입니다.

따라서 중요한 경우 Babel 플러그인으로 반복문으로 트랜스파일하거나, 처음부터 반복문으로 작성하는 것이 안전합니다. 하지만 함수형 프로그래밍 언어(Scheme, Haskell 등)로 이식하거나, 향후 JavaScript 엔진이 최적화를 구현할 가능성을 고려하면 꼬리 재귀 형태로 작성하는 습관은 유용합니다.

실전 팁

💡 현재 환경이 꼬리 재귀 최적화를 지원하는지 테스트하려면 큰 값(예: 100,000)으로 꼬리 재귀 함수를 호출해보세요. 스택 오버플로우가 발생하지 않으면 최적화가 적용된 것입니다.

💡 Babel의 "babel-plugin-transform-tail-recursion" 플러그인을 사용하면 꼬리 재귀를 자동으로 while 루프로 변환하여 모든 환경에서 최적화 혜택을 받을 수 있습니다.

💡 꼬리 재귀를 작성할 때는 항상 accumulator 매개변수의 초기값을 신중히 설정하세요. 팩토리얼은 1, 합계는 0, 문자열 연결은 빈 문자열이 적절합니다.

💡 함수형 프로그래밍에서는 꼬리 재귀가 표준 패턴이므로, FP 스타일 코드를 작성할 때는 습관적으로 꼬리 재귀 형태를 고려하세요. reduce 함수를 재귀로 구현할 때 특히 유용합니다.


5. 재귀적 트리 순회 - 깊이 우선 탐색(DFS) 구현하기

시작하며

여러분이 파일 시스템, DOM 트리, 조직도 같은 계층적 데이터를 다룰 때, 모든 노드를 빠짐없이 방문해야 하는 상황이 자주 발생합니다. 반복문으로 이를 구현하려면 스택 자료구조를 명시적으로 관리해야 하고, 코드가 복잡해지며 실수하기 쉽습니다.

이런 문제는 트리나 그래프 같은 재귀적 자료구조의 본질적 특성 때문입니다. 각 노드가 자식 노드들을 포함하는 구조는 자연스럽게 재귀적이므로, 재귀 함수로 표현하면 구조와 코드가 완벽히 일치하여 직관적입니다.

바로 이럴 때 필요한 것이 재귀적 트리 순회입니다. 깊이 우선 탐색(DFS)은 재귀의 가장 대표적인 응용 사례로, 한 경로를 끝까지 탐색한 후 다음 경로로 이동하는 방식입니다.

호출 스택 자체가 탐색 경로를 기억하므로, 별도의 스택 자료구조 없이도 백트래킹이 자동으로 이루어집니다.

개요

간단히 말해서, 깊이 우선 탐색(DFS)은 트리나 그래프에서 한 분기를 최대한 깊게 탐색한 후 다른 분기로 이동하는 알고리즘으로, 재귀로 구현하면 코드가 매우 간결해집니다. 실무에서 React 컴포넌트 트리를 분석하거나, JSON 데이터에서 특정 키를 찾거나, 파일 시스템에서 특정 확장자의 파일을 모두 찾을 때 DFS를 활용합니다.

예를 들어, 대규모 설정 객체에서 "password" 같은 민감한 키가 있는지 검사하여 보안 문제를 사전에 발견할 수 있습니다. 기존에는 BFS(너비 우선 탐색)나 반복문 기반 스택으로 구현했다면, 이제는 재귀적 DFS로 동일한 작업을 훨씬 적은 코드로 표현할 수 있습니다.

DFS의 핵심 특징은 첫째, 한 경로를 끝까지 탐색한 후 백트래킹하며, 둘째, 호출 스택이 탐색 경로를 자동으로 기억하고, 셋째, 메모리 사용량이 트리의 높이에 비례한다는 점입니다(BFS는 너비에 비례). 이러한 특징들이 특정 유형의 문제에서 BFS보다 효율적입니다.

코드 예제

// 트리 노드 구조
const fileSystem = {
  name: 'root',
  type: 'folder',
  children: [
    {
      name: 'documents',
      type: 'folder',
      children: [
        { name: 'report.pdf', type: 'file' },
        { name: 'notes.txt', type: 'file' }
      ]
    },
    {
      name: 'images',
      type: 'folder',
      children: [
        { name: 'photo.jpg', type: 'file' }
      ]
    },
    { name: 'config.json', type: 'file' }
  ]
};

// DFS로 모든 파일 경로 찾기
function findAllFiles(node, path = '') {
  const currentPath = path + '/' + node.name;

  // Base Case: 파일이면 경로 반환
  if (node.type === 'file') {
    return [currentPath];
  }

  // Recursive Case: 폴더면 자식들을 재귀적으로 탐색
  const files = [];
  if (node.children) {
    for (let child of node.children) {
      // 각 자식의 결과를 배열에 합침
      files.push(...findAllFiles(child, currentPath));
    }
  }
  return files;
}

console.log(findAllFiles(fileSystem));
// ["/root/documents/report.pdf", "/root/documents/notes.txt",
//  "/root/images/photo.jpg", "/root/config.json"]

설명

이것이 하는 일: 위 코드는 파일 시스템 트리를 재귀적으로 순회하여 모든 파일의 전체 경로를 배열로 반환합니다. 폴더를 만나면 더 깊이 들어가고, 파일을 만나면 경로를 기록하는 전형적인 DFS 패턴입니다.

첫 번째로, 함수가 호출되면 현재 노드의 경로를 구성합니다. path 매개변수는 부모로부터 전달받은 경로이고, 여기에 현재 노드의 이름을 추가하여 currentPath를 만듭니다.

예를 들어, root → documents → report.pdf 순서로 탐색하면 경로가 점진적으로 구축됩니다. 그 다음으로, 현재 노드가 파일인지 폴더인지 검사합니다.

파일이면 더 이상 자식이 없으므로 Base Case로 현재 경로를 배열에 담아 즉시 반환합니다. 폴더라면 children 배열의 각 자식에 대해 재귀적으로 findAllFiles를 호출합니다.

이때 currentPath를 path 인자로 전달하여, 자식 노드가 부모의 경로를 기반으로 자신의 경로를 구성할 수 있게 합니다. 마지막으로, 각 자식의 재귀 호출이 반환한 파일 경로 배열들을 스프레드 연산자(...)로 하나의 배열로 합칩니다.

documents 폴더는 2개의 파일 경로를 반환하고, images 폴더는 1개를 반환하며, config.json은 1개를 반환하여 최종적으로 4개 경로가 모입니다. 탐색 순서는 왼쪽 자식부터 깊게 들어가므로 documents의 모든 파일을 먼저 찾고 그 다음 images로 이동합니다.

여러분이 이 코드를 사용하면 중첩 깊이에 관계없이 동일한 로직으로 모든 파일을 찾을 수 있습니다. 실제 파일 시스템, React 컴포넌트 트리, 회사 조직도 등 어떤 트리 구조에도 적용 가능합니다.

조건을 추가하여 특정 확장자 필터링(.txt만 찾기), 크기 제한(100KB 이상만), 경로 패턴 매칭(/private/ 제외) 등으로 쉽게 확장할 수 있습니다. 성능 측면에서도 각 노드를 정확히 한 번씩만 방문하므로 O(n) 시간 복잡도로 효율적입니다.

실전 팁

💡 DFS는 메모리가 트리 높이에 비례하므로, 매우 깊은 트리(예: 수백 단계 중첩)에서는 스택 오버플로우 위험이 있습니다. 이런 경우 반복문 + 명시적 스택으로 전환하세요.

💡 순회 순서가 중요한 경우 Pre-order(부모 → 자식), In-order(왼쪽 자식 → 부모 → 오른쪽 자식), Post-order(자식 → 부모) 중 적절한 것을 선택하세요. 위 코드는 Pre-order입니다.

💡 순환 참조가 있는 그래프에서는 Set으로 방문한 노드를 추적해야 무한 재귀를 방지할 수 있습니다. node.id를 키로 사용하는 visited Set을 관리하세요.

💡 대규모 트리를 탐색할 때는 조건을 만족하는 첫 번째 노드만 찾으면 되는 경우가 많습니다. 이때 찾으면 즉시 반환하여 불필요한 탐색을 줄이세요(조기 반환 최적화).


6. 메모이제이션 기법 - 중복 계산 제거하기

시작하며

여러분이 피보나치 수열을 재귀로 구현했는데, 40번째 항을 계산하는 데 몇 초나 걸리는 경험을 해보셨나요? 재귀 함수는 우아하지만, 동일한 입력에 대해 중복 계산을 반복하면 성능이 지수적으로 나빠집니다.

이런 문제는 재귀 트리에서 동일한 하위 문제가 여러 번 계산되기 때문입니다. 예를 들어 fib(5)를 계산하려면 fib(4)와 fib(3)이 필요한데, fib(4)를 계산할 때도 fib(3)이 다시 필요하므로 fib(3)이 중복 계산됩니다.

입력이 커질수록 중복이 기하급수적으로 증가합니다. 바로 이럴 때 필요한 것이 메모이제이션(Memoization)입니다.

이미 계산한 결과를 캐시에 저장하여, 같은 입력이 들어오면 계산 없이 캐시에서 즉시 반환합니다. 이 단순한 기법으로 지수 시간 복잡도를 선형으로 줄일 수 있습니다.

개요

간단히 말해서, 메모이제이션은 함수의 계산 결과를 캐시에 저장하여 동일한 입력에 대한 중복 계산을 제거하는 최적화 기법입니다. 특히 재귀 함수에서 성능을 극적으로 향상시킵니다.

실무에서 복잡한 데이터 변환, 권한 검증, API 응답 가공 같은 무거운 연산을 재귀적으로 수행할 때 메모이제이션이 필수적입니다. 예를 들어, 사용자 권한 트리를 재귀적으로 검사하는데 같은 사용자가 여러 요청을 보내면, 첫 계산 결과를 캐싱하여 이후 요청은 즉시 처리할 수 있습니다.

기존에는 매번 처음부터 다시 계산했다면, 이제는 Map이나 객체를 사용하여 입력-결과 쌍을 저장하고 재사용할 수 있습니다. 메모이제이션의 핵심은 첫째, 함수가 순수 함수여야 한다는 점(같은 입력 → 같은 출력), 둘째, 캐시 키를 정확히 설정해야 하며(복잡한 입력은 JSON.stringify 활용), 셋째, 메모리와 속도의 트레이드오프를 고려해야 한다는 점입니다.

이러한 조건들이 충족되면 성능이 비약적으로 향상됩니다.

코드 예제

// 메모이제이션 없는 버전 - 지수 시간 복잡도 O(2^n)
function fibSlow(n) {
  if (n <= 1) return n;
  return fibSlow(n - 1) + fibSlow(n - 2);
}

// 메모이제이션 버전 - 선형 시간 복잡도 O(n)
function fibFast(n, memo = {}) {
  // 캐시에 이미 있으면 즉시 반환
  if (n in memo) return memo[n];

  // Base Case
  if (n <= 1) return n;

  // 계산 결과를 캐시에 저장
  memo[n] = fibFast(n - 1, memo) + fibFast(n - 2, memo);
  return memo[n];
}

console.time('slow');
console.log(fibSlow(35)); // 9227465 - 수 초 소요
console.timeEnd('slow');

console.time('fast');
console.log(fibFast(35)); // 9227465 - 1ms 미만
console.timeEnd('fast');

// 더 큰 값도 빠르게 계산 가능
console.log(fibFast(100)); // 354224848179262000000

설명

이것이 하는 일: 위 코드는 피보나치 수열을 계산하는 두 가지 재귀 함수를 비교합니다. fibSlow는 중복 계산이 발생하여 입력이 커지면 매우 느리지만, fibFast는 memo 객체에 결과를 저장하여 각 숫자를 최대 한 번만 계산합니다.

첫 번째로, fibSlow(5)를 호출하면 fibSlow(4)와 fibSlow(3)을 호출하고, fibSlow(4)는 다시 fibSlow(3)과 fibSlow(2)를 호출합니다. 여기서 fibSlow(3)이 중복 계산됩니다.

fibSlow(35)의 경우 동일한 값이 수백만 번 재계산되어 수 초가 걸립니다. 재귀 트리가 이진 트리 형태로 확장되므로 시간 복잡도가 O(2^n)입니다.

그 다음으로, fibFast(5)를 호출하면 먼저 5가 memo에 있는지 확인합니다. 없으므로 fibFast(4)와 fibFast(3)을 계산해야 합니다.

fibFast(4)를 계산하는 과정에서 fibFast(3)이 계산되고 memo[3]에 저장됩니다. 그 후 원래 fibFast(5)가 fibFast(3)을 호출할 때는 memo[3]에서 즉시 값을 가져오므로 재계산이 발생하지 않습니다.

마지막으로, 모든 하위 문제의 결과가 memo에 저장되면서 각 숫자 n에 대해 정확히 한 번만 계산이 수행됩니다. fibFast(100)을 호출해도 100개의 숫자만 계산하면 되므로 밀리초 단위로 완료됩니다.

memo 객체는 재귀 호출 간에 공유되므로(참조 전달), 깊은 재귀에서도 캐시가 유지됩니다. 여러분이 이 코드를 사용하면 복잡한 재귀 문제를 실용적인 시간 내에 해결할 수 있습니다.

다이내믹 프로그래밍(Dynamic Programming) 문제의 대부분이 이 패턴을 따르며, 최장 공통 부분수열(LCS), 배낭 문제(Knapsack), 최단 경로 등에 적용됩니다. 실무에서는 lodash의 _.memoize 같은 라이브러리 함수를 사용하여 어떤 함수든 자동으로 메모이제이션할 수 있습니다.

메모리 사용량은 O(n)으로 증가하지만, 대부분의 경우 속도 향상이 메모리 비용을 충분히 정당화합니다.

실전 팁

💡 매개변수가 여러 개인 함수는 JSON.stringify로 모든 매개변수를 문자열로 변환하여 캐시 키로 사용하세요. 예: memo[JSON.stringify([x, y, z])].

💡 LRU(Least Recently Used) 캐시를 구현하면 메모리 사용량을 제한하면서도 자주 사용되는 결과는 유지할 수 있습니다. lru-cache 같은 npm 패키지를 활용하세요.

💡 함수가 부수효과(side effect)를 가지면 메모이제이션을 사용하면 안 됩니다. API 호출, DOM 조작, 랜덤 값 생성 등은 같은 입력이어도 결과가 달라지므로 캐싱이 부적합합니다.

💡 React의 useMemo와 useCallback 훅도 메모이제이션 기법입니다. 컴포넌트 렌더링 최적화에 동일한 원리를 적용하여 불필요한 재계산을 방지합니다.


7. 재귀를 반복문으로 전환하기 - 명시적 스택 활용

시작하며

여러분이 재귀 함수를 작성했는데 스택 오버플로우 문제로 프로덕션에 배포할 수 없는 상황에 처한 적 있나요? 또는 성능이 중요한 부분에서 재귀 호출 오버헤드를 제거해야 하는 경우도 있습니다.

재귀의 우아함은 유지하고 싶지만 실행 환경의 제약 때문에 어쩔 수 없이 반복문으로 전환해야 할 때가 있습니다. 이런 문제는 재귀가 호출 스택이라는 시스템 자원에 의존하기 때문에 발생합니다.

호출 스택 크기는 우리가 제어할 수 없고, 깊은 재귀는 필연적으로 한계에 부딪힙니다. 하지만 스택의 개념 자체는 유지하면서 직접 구현하면 메모리 제한을 우회할 수 있습니다.

바로 이럴 때 필요한 것이 명시적 스택을 사용한 반복문 전환입니다. 재귀 함수의 로직을 그대로 유지하면서, 호출 스택 대신 배열이나 스택 자료구조를 사용하여 동일한 동작을 구현합니다.

이렇게 하면 스택 오버플로우 없이 무한에 가까운 깊이도 처리할 수 있습니다.

개요

간단히 말해서, 명시적 스택을 사용한 반복문 전환은 재귀 함수의 호출 스택을 직접 관리하는 배열로 대체하여, 스택 오버플로우 없이 동일한 로직을 실행하는 기법입니다. 실무에서 사용자 입력에 따라 깊이가 결정되는 트리 순회(예: 파일 시스템 탐색, 무한 스크롤 댓글 로딩)에서 이 기법이 필수적입니다.

예를 들어, 수십만 개의 파일이 깊게 중첩된 디렉토리를 스캔해야 할 때, 재귀로는 불가능하지만 명시적 스택으로는 안정적으로 처리할 수 있습니다. 기존에는 재귀 함수를 포기하고 완전히 다른 알고리즘으로 재작성했다면, 이제는 재귀 로직을 기계적으로 반복문으로 변환하여 동일한 결과를 얻을 수 있습니다.

명시적 스택 전환의 핵심은 첫째, 재귀 호출 대신 스택에 데이터를 push하고, 둘째, 함수 반환 대신 스택에서 pop하여 다음 항목을 처리하며, 셋째, 지역 변수는 스택에 함께 저장한다는 점입니다. 이러한 패턴이 모든 재귀를 반복문으로 변환할 수 있게 해줍니다.

코드 예제

// 재귀 버전: DFS로 트리의 모든 값 수집
function collectValuesRecursive(node) {
  const values = [node.value];
  if (node.children) {
    for (let child of node.children) {
      values.push(...collectValuesRecursive(child));
    }
  }
  return values;
}

// 반복문 + 명시적 스택 버전: 스택 오버플로우 없음
function collectValuesIterative(root) {
  const values = [];
  const stack = [root]; // 명시적 스택 초기화

  while (stack.length > 0) {
    // 스택에서 노드 꺼내기 (재귀의 함수 호출에 해당)
    const node = stack.pop();
    values.push(node.value);

    // 자식들을 스택에 추가 (재귀 호출에 해당)
    // 역순으로 push하여 왼쪽부터 처리되도록 함
    if (node.children) {
      for (let i = node.children.length - 1; i >= 0; i--) {
        stack.push(node.children[i]);
      }
    }
  }

  return values;
}

const tree = {
  value: 1,
  children: [
    { value: 2, children: [{ value: 4 }, { value: 5 }] },
    { value: 3 }
  ]
};

console.log(collectValuesRecursive(tree)); // [1, 2, 4, 5, 3]
console.log(collectValuesIterative(tree));  // [1, 2, 4, 5, 3]

설명

이것이 하는 일: 위 코드는 트리의 모든 노드 값을 DFS 순서로 수집하는 작업을 재귀와 반복문 두 가지 방식으로 구현합니다. 재귀 버전은 호출 스택을 암묵적으로 사용하고, 반복문 버전은 배열을 명시적 스택으로 사용하여 동일한 결과를 얻습니다.

첫 번째로, 재귀 버전은 각 노드에서 자식들을 재귀적으로 호출합니다. collectValuesRecursive(node)가 호출되면 현재 노드의 값을 배열에 넣고, 각 자식에 대해 재귀 호출하여 반환된 배열들을 스프레드 연산자로 합칩니다.

호출 스택은 자동으로 관리되지만, 깊이가 깊으면 스택 오버플로우 위험이 있습니다. 그 다음으로, 반복문 버전은 stack 배열을 명시적으로 관리합니다.

초기에 루트 노드를 스택에 넣고, while 루프가 스택이 빌 때까지 반복됩니다. 각 반복에서 스택에서 노드를 pop(재귀의 함수 시작)하여 값을 기록하고, 자식 노드들을 스택에 push(재귀 호출)합니다.

중요한 점은 자식들을 역순으로 push하는 것인데, 이는 스택의 LIFO 특성 때문에 왼쪽 자식이 먼저 처리되도록 하기 위함입니다. 마지막으로, 스택이 비면 모든 노드를 방문한 것이므로 루프가 종료되고 수집된 값들을 반환합니다.

실행 흐름을 추적하면, 초기 stack=[1] → stack=[3,2] → stack=[3,5,4] → stack=[3,5] → stack=[3] → stack=[] 순서로 변화하며, 각 단계에서 pop된 노드의 값이 values에 추가됩니다. 최종 결과는 [1,2,4,5,3]으로 재귀 버전과 정확히 일치합니다.

여러분이 이 코드를 사용하면 트리 깊이에 관계없이 안정적으로 순회할 수 있습니다. 메모리 사용량은 여전히 O(h) (h는 트리 높이)이지만, 이는 힙 메모리에 할당되므로 호출 스택보다 훨씬 큰 용량을 사용할 수 있습니다.

실제로 수십만 노드의 트리도 처리 가능합니다. 이 패턴은 모든 재귀 함수에 기계적으로 적용할 수 있으며, 컴파일러의 재귀 최적화가 내부적으로 수행하는 것과 동일한 변환입니다.

디버깅도 더 쉬운데, 스택 내용을 언제든 출력하여 현재 상태를 확인할 수 있기 때문입니다.

실전 팁

💡 재귀를 반복문으로 전환할 때는 각 재귀 호출의 컨텍스트(지역 변수, 매개변수)를 객체로 만들어 스택에 저장하세요. 예: stack.push({ node, depth, path }).

💡 BFS(너비 우선 탐색)로 전환하려면 스택 대신 큐를 사용하세요. 배열의 shift() 메서드로 앞에서 꺼내면 FIFO가 됩니다(성능을 위해 실제로는 링 버퍼나 연결 리스트 사용).

💡 복잡한 재귀(여러 재귀 호출, 조건부 재귀 등)는 상태 머신 패턴으로 전환하면 명확합니다. 각 상태(State)를 enum으로 정의하고 switch 문으로 처리하세요.

💡 성능 측정을 통해 재귀와 반복문 버전을 비교하세요. 현대 JavaScript 엔진은 단순 재귀를 매우 효율적으로 최적화하므로, 깊이가 얕으면(수백 이하) 재귀가 오히려 빠를 수 있습니다.


8. 재귀와 분할 정복 알고리즘 - 병합 정렬 이해하기

시작하며

여러분이 대량의 데이터를 정렬해야 하는데, 기본 정렬 알고리즘으로는 성능이 부족한 경험을 해보셨나요? 버블 정렬이나 삽입 정렬은 구현이 간단하지만 O(n²) 시간 복잡도로 인해 수천 개 이상의 데이터에서는 실용적이지 않습니다.

이런 문제는 전체 데이터를 한 번에 처리하려고 하기 때문입니다. 큰 문제를 작은 문제로 나누고, 각각을 해결한 후 결과를 합치는 분할 정복(Divide and Conquer) 전략을 사용하면 효율성이 비약적으로 향상됩니다.

바로 이럴 때 필요한 것이 병합 정렬(Merge Sort)입니다. 병합 정렬은 재귀와 분할 정복을 결합한 대표적인 알고리즘으로, O(n log n) 시간 복잡도를 보장하여 대규모 데이터 정렬에 적합합니다.

배열을 반으로 나누어 각각 정렬한 후 병합하는 과정이 재귀적으로 반복됩니다.

개요

간단히 말해서, 병합 정렬은 배열을 재귀적으로 반으로 나누어 각각 정렬한 후 병합하는 분할 정복 알고리즘으로, 항상 O(n log n) 시간 복잡도를 보장합니다. 실무에서 대용량 로그 분석, 데이터베이스 인덱스 정렬, 외부 정렬(디스크 기반 정렬) 등에 병합 정렬이 활용됩니다.

예를 들어, 여러 서버에서 수집된 타임스탬프가 있는 로그를 시간 순으로 정렬할 때, 각 서버의 로그를 개별 정렬한 후 병합하면 효율적입니다. 기존에는 Array.sort()를 블랙박스처럼 사용했다면, 이제는 병합 정렬을 이해하여 정렬 알고리즘의 작동 원리와 성능 특성을 정확히 파악할 수 있습니다.

병합 정렬의 핵심은 첫째, 배열을 크기 1이 될 때까지 재귀적으로 분할하고(Base Case), 둘째, 정렬된 두 배열을 병합하여 더 큰 정렬된 배열을 만들며, 셋째, 재귀 트리의 높이가 log n이고 각 레벨에서 O(n) 작업을 수행하여 총 O(n log n)이 된다는 점입니다. 이러한 특징들이 안정적이고 예측 가능한 성능을 제공합니다.

코드 예제

function mergeSort(arr) {
  // Base Case: 길이 1 이하면 이미 정렬됨
  if (arr.length <= 1) return arr;

  // Divide: 배열을 반으로 분할
  const mid = Math.floor(arr.length / 2);
  const left = arr.slice(0, mid);
  const right = arr.slice(mid);

  // Conquer: 각 부분을 재귀적으로 정렬
  const sortedLeft = mergeSort(left);
  const sortedRight = mergeSort(right);

  // Combine: 정렬된 두 배열을 병합
  return merge(sortedLeft, sortedRight);
}

function merge(left, right) {
  const result = [];
  let i = 0, j = 0;

  // 두 배열을 비교하며 작은 값부터 result에 추가
  while (i < left.length && j < right.length) {
    if (left[i] <= right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }

  // 남은 요소들 추가
  return result.concat(left.slice(i)).concat(right.slice(j));
}

const unsorted = [64, 34, 25, 12, 22, 11, 90];
console.log(mergeSort(unsorted)); // [11, 12, 22, 25, 34, 64, 90]

설명

이것이 하는 일: 위 코드는 병합 정렬 알고리즘을 구현하여 배열을 오름차순으로 정렬합니다. mergeSort 함수는 분할과 재귀를 담당하고, merge 함수는 정렬된 두 배열을 하나로 합치는 역할을 합니다.

첫 번째로, mergeSort([64, 34, 25, 12, 22, 11, 90])이 호출되면 배열 길이가 1보다 크므로 중간 지점(인덱스 3)을 기준으로 left=[64, 34, 25]과 right=[12, 22, 11, 90]으로 분할됩니다. 각 부분에 대해 mergeSort를 재귀 호출하면, 다시 반으로 나뉘는 과정이 반복됩니다.

최종적으로 모든 배열이 길이 1이 될 때까지 분할되며, 예를 들어 [64]는 더 이상 분할할 수 없으므로 그대로 반환됩니다. 그 다음으로, 분할이 완료되면 병합 단계가 시작됩니다.

가장 작은 단위인 [64]와 [34]를 merge 함수로 병합하면 [34, 64]가 됩니다. 이 과정에서 merge는 두 배열의 요소를 하나씩 비교하여 작은 값을 먼저 result 배열에 추가합니다.

left[0]=64와 right[0]=34를 비교하면 34가 작으므로 34를 먼저 추가하고, 그 다음 64를 추가하여 [34, 64]가 완성됩니다. 마지막으로, 이렇게 병합된 [34, 64]와 [25]를 다시 병합하면 [25, 34, 64]가 되고, 우측 부분도 동일한 과정으로 [11, 12, 22, 90]이 됩니다.

최종적으로 [25, 34, 64]와 [11, 12, 22, 90]을 병합하여 [11, 12, 22, 25, 34, 64, 90]이 반환됩니다. 재귀 트리를 그려보면 log₂(7) ≈ 3 레벨로 나뉘며, 각 레벨에서 모든 요소를 한 번씩 처리하므로 총 O(n log n) 작업이 수행됩니다.

여러분이 이 코드를 사용하면 데이터 크기에 관계없이 안정적인 성능을 얻을 수 있습니다. 퀵 정렬은 평균 O(n log n)이지만 최악의 경우 O(n²)인 반면, 병합 정렬은 항상 O(n log n)을 보장하여 예측 가능합니다.

또한 안정 정렬(stable sort)이므로 동일한 값의 상대적 순서가 유지되어, 다중 필드 정렬(예: 이름 순 → 나이 순)에 적합합니다. 단점은 O(n) 추가 공간이 필요하다는 것이지만, 대부분의 경우 시간 복잡도가 더 중요하므로 합리적인 트레이드오프입니다.

실전 팁

💡 JavaScript의 Array.sort()는 V8 엔진에서 Tim Sort(병합 정렬과 삽입 정렬의 하이브리드)를 사용합니다. 작은 배열에는 삽입 정렬이, 큰 배열에는 병합 정렬이 자동 선택됩니다.

💡 In-place 병합 정렬을 구현하면 추가 공간을 O(1)로 줄일 수 있지만 복잡도가 높아집니다. 대부분의 경우 표준 병합 정렬로 충분하므로 최적화는 프로파일링 후 결정하세요.

💡 병합 정렬은 병렬화가 쉽습니다. 좌우 부분 정렬을 별도 스레드에서 수행할 수 있어, Web Workers나 멀티스레드 환경에서 성능 향상이 가능합니다.

💡 연결 리스트를 정렬할 때는 병합 정렬이 최선입니다. 배열과 달리 중간 접근이 O(n)이라 퀵 정렬이 비효율적이지만, 병합 정렬은 O(1) 공간으로 구현 가능합니다.


9. 재귀와 백트래킹 - 순열과 조합 생성하기

시작하며

여러분이 게임 개발이나 데이터 분석에서 모든 가능한 조합을 생성해야 하는 상황을 마주한 적 있나요? 예를 들어, 5명 중 3명을 뽑는 모든 경우의 수를 찾거나, 퍼즐 게임에서 가능한 모든 이동 경로를 탐색해야 할 때, 중첩 반복문으로는 경우의 수가 변할 때마다 코드를 수정해야 하는 문제가 있습니다.

이런 문제는 경우의 수가 입력에 따라 동적으로 변하기 때문에 발생합니다. 5명 중 3명을 뽑을 때는 3중 반복문이 필요하지만, 4명을 뽑을 때는 4중 반복문이 필요하므로 일반화된 코드를 작성하기 어렵습니다.

바로 이럴 때 필요한 것이 백트래킹(Backtracking)입니다. 백트래킹은 재귀를 활용하여 가능한 모든 경로를 탐색하되, 조건을 만족하지 않으면 즉시 되돌아가는(backtrack) 기법입니다.

현재 선택을 시도하고, 재귀적으로 다음 선택을 진행하며, 완료되면 현재 선택을 취소하여 다른 경로를 탐색합니다.

개요

간단히 말해서, 백트래킹은 재귀로 모든 가능한 선택을 탐색하면서, 조건을 만족하지 않으면 되돌아가 다른 경로를 시도하는 알고리즘 기법입니다. 순열, 조합, N-Queen 문제 등에 활용됩니다.

실무에서 일정 스케줄링, 자원 할당, 추천 시스템의 조합 생성 등에 백트래킹이 사용됩니다. 예를 들어, 4명의 팀원을 3개의 프로젝트에 배정하되 특정 제약(각 프로젝트당 최소 1명)을 만족하는 모든 경우를 찾을 때, 백트래킹으로 체계적으로 탐색할 수 있습니다.

기존에는 모든 경우를 하드코딩하거나 라이브러리에 의존했다면, 이제는 백트래킹 패턴을 이해하여 다양한 조합 문제를 직접 해결할 수 있습니다. 백트래킹의 핵심은 첫째, 현재 상태에서 가능한 선택을 시도하고(choose), 둘째, 재귀적으로 다음 단계를 탐색하며(explore), 셋째, 탐색이 끝나면 선택을 취소하여 다른 경로를 시도한다는(unchoose) 점입니다.

이러한 "선택-탐색-취소" 패턴이 모든 백트래킹 알고리즘의 기본 구조입니다.

코드 예제

// n개 중 k개를 뽑는 조합 생성
function combinations(arr, k) {
  const result = [];

  function backtrack(start, current) {
    // Base Case: k개를 모두 선택했으면 결과에 추가
    if (current.length === k) {
      result.push([...current]); // 복사본 저장 중요!
      return;
    }

    // Recursive Case: 현재 위치부터 끝까지 시도
    for (let i = start; i < arr.length; i++) {
      // Choose: 현재 요소 선택
      current.push(arr[i]);

      // Explore: 다음 단계 탐색 (i+1부터 시작하여 중복 방지)
      backtrack(i + 1, current);

      // Unchoose: 선택 취소하여 다른 경로 시도
      current.pop();
    }
  }

  backtrack(0, []);
  return result;
}

console.log(combinations([1, 2, 3, 4], 2));
// [[1,2], [1,3], [1,4], [2,3], [2,4], [3,4]]

console.log(combinations(['A', 'B', 'C'], 2));
// [['A','B'], ['A','C'], ['B','C']]

설명

이것이 하는 일: 위 코드는 배열에서 k개의 요소를 뽑는 모든 조합을 생성합니다. 백트래킹의 전형적인 구조인 "선택-탐색-취소" 패턴을 따르며, 중복을 방지하기 위해 start 인덱스를 관리합니다.

첫 번째로, combinations([1,2,3,4], 2)를 호출하면 backtrack(0, [])이 시작됩니다. 첫 번째 반복에서 i=0일 때 arr[0]=1을 current에 추가하여 [1]이 되고, backtrack(1, [1])을 재귀 호출합니다.

이제 start=1이므로 2부터 선택할 수 있으며, 1은 다시 선택되지 않아 중복이 방지됩니다. 그 다음으로, backtrack(1, [1]) 내부에서 i=1일 때 2를 선택하여 current=[1,2]가 되고, current.length === 2이므로 Base Case에 도달하여 [1,2]를 result에 추가합니다.

중요한 점은 [...current]로 복사본을 저장하는 것인데, current 배열은 계속 수정되므로 참조를 저장하면 모든 결과가 동일해집니다. 이후 current.pop()으로 2를 제거하여 current=[1]로 되돌립니다.

마지막으로, i=2로 진행하여 3을 선택하면 [1,3]이 저장되고, i=3에서는 [1,4]가 저장됩니다. 모든 i 값을 시도한 후 첫 번째 재귀가 종료되고, 1도 pop되어 current=[]가 됩니다.

이제 i=1로 돌아가 2를 첫 번째 선택으로 하여 [2,3], [2,4]를 생성하고, 마지막으로 3을 선택하여 [3,4]를 생성합니다. 탐색 트리를 그려보면 각 분기가 한 선택을 나타내고, 리프 노드에서 조합이 완성됩니다.

여러분이 이 코드를 사용하면 n과 k 값에 관계없이 모든 조합을 자동으로 생성할 수 있습니다. 이를 변형하여 순열(permutation)도 쉽게 구현 가능한데, start 대신 0부터 시작하고 visited 배열로 중복을 관리하면 됩니다.

실무에서는 사용자가 선택 가능한 옵션 조합, AB 테스트 변형 생성, 게임 아이템 조합 등에 활용됩니다. 시간 복잡도는 O(C(n,k) × k)로 조합 수에 비례하지만, 이는 문제의 본질적 특성이므로 피할 수 없습니다.

중요한 것은 백트래킹이 불필요한 경로를 조기에 차단하여(가지치기, pruning) 효율을 높일 수 있다는 점입니다.

실전 팁

💡 백트래킹에서 배열을 result에 추가할 때 반드시 복사본([...current] 또는 current.slice())을 저장하세요. 원본 배열은 계속 수정되므로 참조를 저장하면 모든 결과가 마지막 상태로 덮어씌워집니다.

💡 조기 종료(pruning)를 추가하여 성능을 향상시키세요. 예를 들어, 남은 요소 수가 필요한 선택 수보다 적으면 더 이상 탐색할 필요가 없습니다: if (arr.length - i < k - current.length) break;

💡 순열과 조합의 차이를 명확히 하세요. 조합은 순서 무관([1,2]와 [2,1]은 같음)이므로 start 인덱스로 중복 방지하고, 순열은 순서 중요하므로 visited 배열로 사용 여부를 추적합니다.

💡 대규모 조합(예: 100개 중 50개)은 결과 수가 천문학적이므로, 실제로는 제약 조건을 추가하여 유효한 조합만 생성하거나, 첫 N개만 찾는 등의 제한을 두세요.


10. 재귀 함수 디버깅 기법 - 호출 트리 시각화하기

시작하며

여러분이 복잡한 재귀 함수를 작성했는데 예상과 다른 결과가 나오거나, 무한 재귀에 빠져서 어디가 문제인지 찾기 어려운 경험을 해보셨나요? 재귀 함수는 호출 흐름이 여러 레벨로 중첩되어 있어서, 일반적인 디버깅 방법으로는 실행 과정을 추적하기 어렵습니다.

이런 문제는 재귀 함수의 실행이 시간적으로 분산되어 있고, 각 호출의 컨텍스트가 스택에 숨겨져 있기 때문입니다. console.log()를 무작정 찍어도 출력이 뒤섞여서 어떤 호출 레벨인지, 어떤 값이 전달되었는지 파악하기 힘듭니다.

바로 이럴 때 필요한 것이 재귀 호출 트리 시각화입니다. 각 재귀 호출의 깊이, 입력 매개변수, 반환값을 들여쓰기와 함께 로깅하면 전체 실행 흐름을 한눈에 볼 수 있습니다.

또한 호출 횟수를 카운팅하여 성능 문제나 중복 계산을 발견할 수 있습니다.

개요

간단히 말해서, 재귀 함수 디버깅은 각 호출의 깊이, 입력, 반환값을 들여쓰기와 함께 로깅하여 실행 흐름을 시각화하는 기법입니다. 이를 통해 로직 오류와 성능 문제를 빠르게 발견할 수 있습니다.

실무에서 복잡한 재귀 알고리즘(트리 순회, 백트래킹, 분할 정복)을 개발할 때 이런 디버깅 기법이 필수적입니다. 예를 들어, 파일 시스템 권한 검사를 재귀적으로 수행하는데 특정 경로에서만 실패한다면, 호출 트리를 출력하여 어떤 경로로 진입했는지 추적할 수 있습니다.

기존에는 브레이크포인트를 수십 번 걸면서 단계별로 확인했다면, 이제는 한 번의 실행으로 전체 호출 트리를 텍스트로 확인하여 훨씬 빠르게 문제를 파악할 수 있습니다. 재귀 디버깅의 핵심은 첫째, depth 매개변수로 현재 재귀 깊이를 추적하고, 둘째, 들여쓰기를 통해 호출 계층을 시각화하며, 셋째, 입구와 출구에서 로그를 남겨 입력과 반환값을 기록한다는 점입니다.

이러한 패턴이 복잡한 재귀도 이해 가능하게 만듭니다.

코드 예제

// 디버깅 도우미: 들여쓰기 생성
function indent(depth) {
  return '  '.repeat(depth);
}

// 디버깅이 추가된 팩토리얼
function factorialDebug(n, depth = 0) {
  console.log(`${indent(depth)}→ factorial(${n}) 호출`);

  // Base Case
  if (n <= 1) {
    console.log(`${indent(depth)}← factorial(${n}) = 1 반환 (Base Case)`);
    return 1;
  }

  // Recursive Case
  console.log(`${indent(depth)}  ${n} * factorial(${n - 1}) 계산 중...`);
  const result = n * factorialDebug(n - 1, depth + 1);

  console.log(`${indent(depth)}← factorial(${n}) = ${result} 반환`);
  return result;
}

console.log('=== 팩토리얼 디버깅 시작 ===');
factorialDebug(4);
console.log('=== 팩토리얼 디버깅 종료 ===');

/* 출력:
=== 팩토리얼 디버깅 시작 ===
→ factorial(4) 호출
  4 * factorial(3) 계산 중...
  → factorial(3) 호출
    3 * factorial(2) 계산 중...
    → factorial(2) 호출
      2 * factorial(1) 계산 중...
      → factorial(1) 호출
      ← factorial(1) = 1 반환 (Base Case)
      ← factorial(2) = 2 반환
    ← factorial(3) = 6 반환
  ← factorial(4) = 24 반환
=== 팩토리얼 디버깅 종료 ===
*/

설명

이것이 하는 일: 위 코드는 팩토리얼 함수에 디버깅 로그를 추가하여 각 재귀 호출의 시작, 계산 과정, 반환을 들여쓰기와 함께 출력합니다. depth 매개변수로 현재 재귀 깊이를 추적하고, 이를 기반으로 들여쓰기 수준을 결정합니다.

첫 번째로, factorialDebug(4, 0)이 호출되면 depth=0이므로 들여쓰기 없이 "→ factorial(4) 호출"을 출력합니다. → 기호는 함수 진입을 의미하며, 이후 4 * factorial(3)을 계산해야 하므로 해당 내용을 로깅하고 factorialDebug(3, 1)을 재귀 호출합니다.

이때 depth + 1을 전달하여 다음 레벨임을 표시합니다. 그 다음으로, factorialDebug(3, 1)이 실행되면 depth=1이므로 두 칸 들여쓰기가 적용됩니다.

출력을 보면 " → factorial(3) 호출"처럼 앞에 공백이 추가되어 계층 구조가 시각적으로 드러납니다. 이런 식으로 factorial(2), factorial(1)까지 진행되며, 각 레벨마다 들여쓰기가 증가하여 트리 구조처럼 표현됩니다.

마지막으로, factorial(1)에서 Base Case에 도달하면 "← factorial(1) = 1 반환"을 출력합니다. ← 기호는 함수 반환을 의미하며, 이제 스택이 풀리면서 상위 레벨로 돌아갑니다.

factorial(2)는 2 * 1 = 2를 계산하여 "← factorial(2) = 2 반환"을 출력하고, factorial(3)은 3 * 2 = 6을 반환합니다. 최종적으로 factorial(4)는 4 * 6 = 24를 반환하며 전체 실행이 종료됩니다.

여러분이 이 코드를 사용하면 재귀 함수의 실행 과정을 완벽하게 이해할 수 있습니다. 들여쓰기 패턴을 보면 어떤 호출이 어떤 호출 내부에서 발생했는지 즉시 알 수 있고, 반환값이 어떻게 전파되는지도 명확합니다.

실무에서는 조건문을 추가하여 특정 입력에 대해서만 디버깅 로그를 활성화하거나(if (n === targetValue)), 환경 변수로 디버그 모드를 제어할 수 있습니다(if (process.env.DEBUG)). 또한 호출 횟수를 전역 카운터로 추적하면 성능 문제를 정량적으로 파악할 수 있습니다.

이 패턴은 어떤 재귀 함수에도 적용 가능하며, 디버깅이 끝나면 로그 코드를 제거하거나 주석 처리하면 됩니다.

실전 팁

💡 프로덕션 코드에서는 조건부 로깅을 사용하세요. const DEBUG = process.env.DEBUG === 'true'; if (DEBUG) console.log(...); 형태로 환경 변수로 제어하면 배포 시 성능 영향을 없앨 수 있습니다.

💡 Chrome DevTools의 Call Stack 패널과 함께 사용하면 시너지가 큽니다. 브레이크포인트를 Base Case에 설정하고 Call Stack을 확인하면, 로그와 시각적 스택을 동시에 볼 수 있습니다.

💡 복잡한 재귀는 Graphviz 같은 도구로 호출 그래프를 그려보세요. 각 호출을 노드로, 호출 관계를 엣지로 표현하면 중복 계산 패턴이 시각적으로 드러납니다.

💡 재귀 호출 횟수를 추적하려면 전역 변수나 클로저를 사용하세요. let callCount = 0; 을 함수 외부에 선언하고 각 호출마다 callCount++를 하면, 성능 분석에 유용한 통계를 얻을 수 있습니다.

이상으로 "함수 호출 스택과 재귀 이해하기" 카드 뉴스 콘텐츠를 완성했습니다. 각 개념은 실무에서 바로 활용할 수 있는 깊이로 작성되었으며, 친근한 블로그 스타일로 독자의 이해를 돕도록 구성했습니다.


#JavaScript#CallStack#Recursion#MemoryManagement#StackOverflow#CS

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.