---
title: "부른 순서대로 쌓이고 반대로 풀린다"
description: "메서드가 자기를 부르면 호출이 겹겹이 쌓입니다. 쌓이는 자리에는 한계가 있고, 짧은 코드가 빠른 코드는 아닙니다."
author: "유니얼"
pubDate: 2026-09-08
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-recursion/
---
메서드 안에서 다른 메서드를 부를 수 있습니다.
그럼 자기 자신을 불러도 될까요. 됩니다.

```csharp
int Factorial(int n)
{
    if (n <= 1)
    {
        return 1;
    }

    return n * Factorial(n - 1);
}
```

`5!` 는 `5 × 4!` 이고, `4!` 는 `4 × 3!` 입니다.
**큰 문제를 한 단계 작은 같은 문제로 바꾸는 것**, 이게 재귀입니다.

> **재귀에는 두 부분이 있습니다. 한 단계 줄이는 식과, 더 줄일 수 없는 지점입니다.**

## 종료 조건이 없으면 죽는다

더 줄일 수 없는 지점을 종료 조건이라고 합니다. 위 코드에서는 `n <= 1` 입니다.

이게 없으면 어떻게 될까요.

```csharp
int Factorial(int n)
{
    return n * Factorial(n - 1);    // 종료 조건이 없습니다
}
```

`5` 는 `4` 를 부르고, `4` 는 `3` 을, `3` 은 `2` 를, `1` 은 `0` 을, `0` 은 `-1` 을 부릅니다.
끝나지 않습니다.

앞에서 본 무한 루프와 같은 실수인데 **결과가 다릅니다.**
무한 루프는 프로그램이 계속 돌기만 하고, 이건 `StackOverflowException` 으로 **죽습니다.**

왜 죽는지 보려면 호출이 어떻게 쌓이는지를 봐야 합니다.

## 호출은 쌓였다가 반대로 풀린다

앞에서 메서드를 부르면 돌아올 자리를 어딘가에 쌓아 둔다고 했습니다.
그 어딘가가 스택입니다. 나중에 넣은 것이 먼저 나오는 그 스택 맞습니다.

<x-flow>
<script type="application/json">
{
  "label": "Factorial(3) 이 쌓였다 풀리는 과정",
  "dwell": 1.2,
  "lines": [
    "int Factorial(int n)",
    "{",
    "    if (n <= 1)",
    "    {",
    "        return 1;",
    "    }",
    "    return n * Factorial(n - 1);",
    "}"
  ],
  "cases": [
    {
      "label": "Factorial(3)",
      "path": [
        { "line": 0, "why": "3 으로 들어옵니다.", "state": "n = 3", "frames": ["Main", "Factorial(3)"] },
        { "line": 2, "why": "3 은 1 이하가 아닙니다.", "verdict": "거짓" },
        { "line": 6, "why": "곱하려면 Factorial(2) 의 값이 있어야 합니다. 여기서 멈추고 부릅니다.", "state": "n = 3 · Factorial(2) 를 기다리는 중" },
        { "line": 0, "why": "2 로 다시 들어옵니다. 위의 3 은 아직 안 끝났습니다.", "state": "n = 2", "frames": ["Main", "Factorial(3)", "Factorial(2)"] },
        { "line": 2, "verdict": "거짓" },
        { "line": 6, "why": "또 부릅니다. 자리가 하나 더 쌓입니다.", "state": "n = 2 · Factorial(1) 을 기다리는 중" },
        { "line": 0, "why": "1 로 들어옵니다.", "state": "n = 1", "frames": ["Main", "Factorial(3)", "Factorial(2)", "Factorial(1)"] },
        { "line": 2, "why": "드디어 참입니다. 여기가 종료 조건입니다.", "verdict": "참" },
        { "line": 4, "why": "1 을 돌려주고 이 자리는 사라집니다.", "state": "Factorial(1) = 1", "frames": ["Main", "Factorial(3)", "Factorial(2)"] },
        { "line": 6, "why": "기다리던 자리로 돌아왔습니다. 2 × 1 을 계산합니다.", "state": "Factorial(2) = 2 × 1 = 2", "frames": ["Main", "Factorial(3)"] },
        { "line": 6, "why": "그 위로 또 돌아옵니다. 3 × 2 입니다.", "state": "Factorial(3) = 3 × 2 = 6", "frames": ["Main"] },
        { "line": 7, "why": "처음 부른 자리로 값이 나옵니다.", "state": "결과 = 6", "out": "6" }
      ],
      "skip": [3, 5],
      "note": "부른 순서대로 쌓이고, 값을 돌려줄 때 반대 순서로 풀립니다."
    }
  ]
}
</script>
</x-flow>

내려갈 때는 **계산이 안 끝난 자리가 쌓입니다.**
`Factorial(3)` 은 `3 × ?` 까지 적어 두고 멈춰 있습니다. `?` 를 알아야 끝납니다.

올라올 때 그 `?` 가 하나씩 채워집니다. 가장 나중에 쌓인 것부터 풀립니다.

종료 조건이 없으면 **내려가기만 하고 올라오지 않습니다.**
쌓이는 자리는 무한하지 않습니다. 한계에 닿으면 `StackOverflowException` 입니다.
보통 수천에서 수만 번 깊이에서 터집니다.

## 반복문으로도 되는데 왜

`Factorial` 은 반복문으로도 씁니다.

```csharp
int Factorial(int n)
{
    int result = 1;

    for (int i = 2; i <= n; i++)
    {
        result *= i;
    }

    return result;
}
```

이쪽이 더 낫습니다. 쌓이는 것이 없고, 깊이 걱정도 없습니다.

**단순히 줄어드는 문제는 반복문이 낫습니다.**
재귀가 어울리는 것은 갈래가 여럿으로 뻗는 구조입니다.
폴더 안에 폴더가 있고 그 안에 또 폴더가 있는 것, 트리 모양의 데이터 같은 것들입니다.
그런 걸 반복문으로 쓰려면 결국 스택을 직접 만들어서 쌓아야 합니다.
그럴 거면 이미 있는 호출 스택을 쓰는 편이 짧고 읽힙니다.

## 짧은 코드가 빠른 코드는 아니다

재귀의 진짜 함정은 깊이가 아니라 **중복 계산**입니다. 피보나치가 유명한 예입니다.

```csharp
int Fib(int n)
{
    if (n <= 1) return n;

    return Fib(n - 1) + Fib(n - 2);
}
```

세 줄입니다. 문제 정의를 그대로 옮겨 적은 것처럼 깔끔합니다.
그런데 `Fib(5)` 를 부르면 무슨 일이 일어나는지 보면 이렇습니다.

`Fib(5)` 는 `Fib(4)` 와 `Fib(3)` 을 부릅니다.
`Fib(4)` 는 다시 `Fib(3)` 과 `Fib(2)` 를 부릅니다.
**`Fib(3)` 을 두 번 계산합니다.** 그리고 그 안에서 `Fib(2)` 를 또 여러 번 계산합니다.

<x-calc>
<script type="application/json">
{
  "label": "Fib(n) 을 계산하려면 몇 번 부르나",
  "inputs": [
    { "id": "n", "label": "n", "options": [5, 10, 20, 30, 40], "default": 20 }
  ],
  "bars": [
    { "label": "재귀로", "expr": "2 * Math.round(Math.pow(1.6180339887, n + 1) / Math.sqrt(5)) - 1", "unit": "번" },
    { "label": "반복문으로", "expr": "n", "unit": "번", "accent": true }
  ],
  "verdict": { "expr": "bars[0] > bars[1] * 10", "yes": "재귀 쪽이 압도적으로 많습니다", "no": "아직 비슷합니다" },
  "note": "재귀 쪽은 호출 횟수, 반복문 쪽은 반복 횟수입니다. n 이 하나 커질 때마다 재귀는 약 1.6 배씩 늘어납니다. n 이 40 이면 수억 번입니다."
}
</script>
</x-calc>

`n` 을 40 으로 올려 보세요. 재귀 쪽은 수억 번입니다.
반복문으로 쓰면 40 번입니다.

**코드가 짧다고 빠른 것은 아닙니다.** 이 경우는 짧아서 오히려 감춰집니다.
세 줄짜리 코드가 수억 번을 도는 것이 눈에 안 보입니다.

이미 계산한 값을 어딘가에 저장해 두고 다시 쓰면 이 문제는 사라집니다.
`Dictionary` 가 그런 저장에 어울립니다.
이렇게 계산 결과를 기억해 두는 방식을 메모이제이션이라고 부릅니다.

## 재귀를 쓸 때 확인할 것

반복문에서 "무엇이 이 조건을 거짓으로 만드는가" 를 확인했습니다.
재귀에서는 세 가지입니다.

- **종료 조건이 있는가.** 없으면 죽습니다.
- **매번 그 조건에 가까워지는가.** `Factorial(n - 1)` 처럼 줄어들어야 합니다.
  `Factorial(n)` 을 다시 부르면 종료 조건이 있어도 못 만납니다.
- **얼마나 깊어지는가.** 수만 번 깊어질 수 있으면 반복문으로 바꿉니다.

## 정리

- 재귀는 큰 문제를 한 단계 작은 같은 문제로 바꾸는 것입니다.
- 더 줄일 수 없는 지점이 종료 조건입니다. 없으면 `StackOverflowException` 으로 죽습니다.
- 호출할 때마다 자리가 쌓이고, 값을 돌려줄 때 반대 순서로 풀립니다.
- 쌓이는 자리에는 한계가 있습니다. 무한 루프와 달리 프로그램이 죽습니다.
- 단순히 줄어드는 문제는 반복문이 낫습니다. 재귀는 갈래가 뻗는 구조에 어울립니다.
- 피보나치처럼 같은 값을 다시 계산하는 재귀는 짧지만 아주 느립니다.
- 종료 조건이 있는가, 매번 가까워지는가, 얼마나 깊어지는가를 확인합니다.