본문으로 건너뛰기
기술스택 · stack/javascript/재귀와 콜 스택: 트리 모양 데이터 다루기

JavaScript · 6장 함수 심화

재귀와 콜 스택: 트리 모양 데이터 다루기

답글에 또 답글이 달리는 댓글, 메뉴 안의 메뉴, 폴더 안의 폴더처럼 몇 단계까지 들어갈지 모르는 데이터가 있어요. 반복문 몇 개를 겹쳐서는 끝까지 닿을 수 없죠. 이런 데이터는 함수가 자기 자신을 부르는 재귀로 다뤄요.

#6.1

01. 자기 자신을 부르는 함수

재귀 함수는 두 부분으로 이뤄져요. 더 쪼갤 필요 없이 바로 답할 수 있는 멈추는 조건과, 문제를 조금 작게 만들어 자기 자신에게 맡기는 재귀 호출이에요. 1부터 n까지의 합은 "n + (1부터 n−1까지의 합)"이고, n이 1이면 그냥 1이에요.

function sumTo(n) {
  if (n === 1) return 1;       // 멈추는 조건
  return n + sumTo(n - 1);     // 조금 작은 문제를 자기에게 맡겨요
}

console.log(sumTo(4));   // 10
console.log(sumTo(100)); // 5050

함수를 부르면 자바스크립트는 그 호출의 정보(매개변수, 지역 변수, 돌아갈 자리)를 콜 스택에 한 칸 쌓아요. sumTo(4)는 sumTo(3)의 답을 기다리느라 끝나지 못하고, sumTo(3)은 sumTo(2)를 기다려요. 멈추는 조건에 닿으면 그때부터 위에서부터 하나씩 답을 돌려주며 칸이 빠져요.

①sumTo(4)를 부르면 sumTo(3), sumTo(2), sumTo(1)이 차례로 콜 스택에 쌓여요. sumTo(1)이 1을 돌려주면 위에서부터 2 + 1 = 3, 3 + 3 = 6, 4 + 6 = 10으로 답이 내려가며 칸이 하나씩 빠져요.

멈추는 조건을 빠뜨리거나 조건에 절대 닿지 않으면, 칸이 끝없이 쌓이다가 스택이 넘쳐요. 브라우저는 보통 만 개 안팎에서 멈추고 RangeError를 던져요.

function forever(n) {
  return n + forever(n - 1);   // 멈추는 조건이 없어요
}
forever(3);

02. 트리 데이터: 댓글과 메뉴

합계 같은 문제는 반복문이 더 간단해요. 재귀가 진짜 빛나는 곳은 같은 모양이 안에 또 들어 있는 데이터, 즉 트리예요. 답글 목록 replies 안의 각 댓글이 또 replies를 가지는 댓글 데이터를 보세요. "댓글 목록의 개수 = 각 댓글 1개 + 그 댓글의 답글 목록의 개수"라서 재귀로 그대로 옮겨져요.

const comments = [
  { id: 1, text: '좋은 글이에요', replies: [
    { id: 2, text: '감사해요', replies: [] },
    { id: 3, text: '저도요', replies: [{ id: 4, text: '+1', replies: [] }] },
  ] },
  { id: 5, text: '질문 있어요', replies: [] },
];

function countAll(list) {
  let n = 0;
  for (const c of list) n += 1 + countAll(c.replies); // 빈 배열이면 0이라 여기서 멈춰요
  return n;
}
console.log(countAll(comments)); // 5

// 들여쓰기한 목록으로 펼치기: 깊이를 함께 넘겨요
function flatten(list, depth = 0, out = []) {
  for (const c of list) {
    out.push('  '.repeat(depth) + c.text);
    flatten(c.replies, depth + 1, out);
  }
  return out;
}
console.log(flatten(comments).join('\n'));

여기서는 멈추는 조건이 따로 없어 보이지만, 답글이 없는 댓글의 replies는 빈 배열이라 반복문이 한 번도 돌지 않고 끝나요. 그게 멈추는 조건이에요. 트리를 다루는 재귀는 대개 이렇게 "자식이 없으면 끝"이 멈추는 조건이 돼요.

03. 너무 깊다면 반복문으로

화면에 보이는 메뉴 · 댓글은 깊어야 몇십 단계라 재귀로 충분해요. 하지만 사용자가 만든 데이터처럼 깊이를 믿을 수 없거나 수천 단계가 될 수 있다면 콜 스택이 넘칠 수 있어요. 그때는 "아직 볼 항목" 배열을 직접 들고 반복문으로 돌아요. 콜 스택 대신 내 배열을 스택으로 쓰는 거예요.

const comments = [
  { id: 1, text: '좋은 글이에요', replies: [
    { id: 2, text: '감사해요', replies: [] },
    { id: 3, text: '저도요', replies: [{ id: 4, text: '+1', replies: [] }] },
  ] },
  { id: 5, text: '질문 있어요', replies: [] },
];

function countWithLoop(list) {
  const todo = [...list];           // 아직 볼 댓글
  let n = 0;
  while (todo.length > 0) {
    const c = todo.pop();
    n++;
    todo.push(...c.replies);        // 답글은 나중에 볼 목록에 넣어요
  }
  return n;
}
console.log(countWithLoop(comments)); // 5 — 재귀와 같은 답

재귀 쪽이 더 읽기 쉬우니 기본은 재귀로 쓰고, 깊이가 걱정될 때만 이렇게 바꿔요. JSON.stringify · structuredClone처럼 브라우저가 이미 만들어 둔 기능이 트리를 다 돌아 주는 경우도 많으니, 직접 짜기 전에 찾아봐요.

사이트 메뉴가 트리로 되어 있어요. 메뉴 이름을 받아서 홈부터 그 메뉴까지의 경로(화면 위쪽의 "홈 › 상품 › 태블릿" 같은 표시)를 배열로 돌려주는 findPath를 재귀로 완성해 보세요. 없으면 null이에요.

['홈', '상품', '태블릿']
['홈', '고객센터', '자주 묻는 질문']
null
const menu = {
  name: '홈',
  children: [
    { name: '상품', children: [{ name: '노트북', children: [] }, { name: '태블릿', children: [] }] },
    { name: '고객센터', children: [{ name: '자주 묻는 질문', children: [] }] },
  ],
};

function findPath(node, target) {
  // 여기를 채워요
  return null;
}

console.log(findPath(menu, '태블릿'));
console.log(findPath(menu, '자주 묻는 질문'));
console.log(findPath(menu, '없는 메뉴'));
해답 보기

지금 메뉴가 찾는 메뉴면 [이름]을 돌려줘요(멈추는 조건). 아니면 자식마다 재귀로 찾아보고, 찾았으면 그 경로 앞에 내 이름을 붙여 돌려줘요. 모든 자식에서 못 찾으면 null이에요.

function findPath(node, target) {
  if (node.name === target) return [node.name];
  for (const child of node.children) {
    const path = findPath(child, target);
    if (path) return [node.name, ...path];
  }
  return null;
}

04. 실전 포인트

  • 언제 쓰나요? 댓글 · 메뉴 · 폴더 · 조직도 · 카테고리처럼 같은 모양이 안에 반복되는 트리 데이터를 세고, 펼치고, 찾고, 바꿀 때 써요.
  • 대신 쓰는 것: 단순한 합계 · 반복은 반복문이 빠르고 읽기 쉬워요. 깊이를 믿을 수 없으면 배열을 스택으로 쓰는 반복문으로 바꿔요. 서버에서 트리를 평평한 목록(parentId)으로 주는 경우도 많은데, 그대로 다루면 재귀가 필요 없기도 해요.
  • 흔한 실수: 멈추는 조건을 빠뜨리거나 문제를 작게 만들지 않아 RangeError가 나는 것, 재귀 호출의 결과를 return하지 않는 것, 같은 하위 문제를 몇 번씩 다시 계산해 느려지는 것(이건 클로저 글의 캐시로 풀어요)이에요.

정리

  • 재귀 함수는 멈추는 조건과, 문제를 작게 만들어 자기에게 맡기는 호출로 이뤄져요.
  • 호출마다 콜 스택에 칸이 쌓이고, 멈추는 조건에서부터 답이 돌아오며 칸이 빠져요.
  • 트리 데이터는 "자식이 없으면 끝"을 멈추는 조건으로 재귀로 다뤄요.
  • 너무 깊으면 콜 스택이 넘쳐요. 그럴 땐 배열을 스택으로 쓰는 반복문으로 바꿔요.
재귀 함수에 꼭 있어야 하는 두 가지는?

더 부르지 않고 바로 답을 돌려주는 멈추는 조건과, 문제를 조금 작게 만들어 자기 자신을 부르는 재귀 호출이에요. 둘 중 하나가 빠지면 끝나지 않아요.

RangeError: Maximum call stack size exceeded는 무엇을 뜻할까요?

함수 호출이 끝나지 않고 콜 스택에 계속 쌓여 정해진 한도를 넘었다는 뜻이에요. 재귀라면 멈추는 조건이 없거나 그 조건에 닿지 못하는지부터 봐요.

트리를 도는 재귀에서 "멈추는 조건"은 보통 무엇일까요?

자식이 없는 경우예요. 자식 목록이 빈 배열이면 반복문이 돌지 않아 더 깊이 부르지 않고, 그 자리에서 답을 돌려줘요.

SIMPLE · 기술스택 / JavaScript — 6장 함수 심화