---
title: "yield return 은 만들다 말고 멈춘다"
description: "배열과 List 와 문자열은 안이 전혀 다른데 같은 foreach 로 돕니다. 무엇을 갖추면 돌 수 있는지 봅니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#"]
canonical: https://blog.unialgames.com/posts/csharp-ienumerable-yield/
---
앞에서 `foreach` 로 여러 가지를 돌았습니다.

```csharp
foreach (int n in 배열) { ... }
foreach (string s in 리스트) { ... }
foreach (char c in "안녕") { ... }
foreach (var kv in 사전) { ... }
```

넷은 안이 전혀 다르게 생겼습니다. 배열은 붙어 있는 칸이고, `Dictionary` 는 해시로 흩어져 있고,
문자열은 문자를 담은 배열입니다. 그런데 `foreach` 한 문법으로 다 돕니다.

`foreach` 편에서 "무엇을 돌 수 있나" 를 다루면서 목록만 주고 넘어갔습니다. 이제 그 조건이 무엇인지 봅니다.

> **`foreach` 는 문법이 아니라 약속입니다. `GetEnumerator()` 하나만 있으면 돌 수 있습니다.**

## foreach 는 이렇게 풀린다

컴파일러는 `foreach` 를 보면 `while` 로 바꿔 씁니다.

<x-compare>
<script type="application/json">
{
  "tabs": [
    {
      "label": "내가 쓴 코드",
      "code": "foreach (int n in 목록)\n{\n    Console.WriteLine(n);\n}"
    },
    {
      "label": "컴파일된 결과",
      "code": "var e = 목록.GetEnumerator();\nwhile (e.MoveNext())\n{\n    int n = e.Current;\n    Console.WriteLine(n);\n}",
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

`GetEnumerator()` 로 열거자를 하나 받고, `MoveNext()` 로 한 칸 옮기고, `Current` 로 지금 것을 읽습니다.
셋뿐입니다.

여기서 나오는 사실이 두 가지 있습니다.

**하나.** 앞에서 "도는 동안 고치면 터진다" 고 했는데, 터지는 곳은 `MoveNext()` 입니다.
열거자는 만들어질 때 컬렉션의 상태를 기억해 두고, `MoveNext()` 마다 달라졌는지 확인합니다.

**둘.** 앞에서 "읽기 전용이다" 라고 한 것도 여기서 나옵니다. `Current` 는 읽기만 되는 프로퍼티입니다.
`n` 에 대입해도 그건 사본이라 원본이 안 바뀝니다.

## 인터페이스를 안 붙여도 된다

`foreach` 가 요구하는 것은 **이름이 맞는 메서드**뿐입니다. `IEnumerable` 을 구현했는지는 안 봅니다.

```csharp
class 카운터
{
    public 열거자 GetEnumerator() => new 열거자();
}

class 열거자
{
    private int n = 0;
    public int Current => n;
    public bool MoveNext() => ++n <= 3;
}

foreach (int x in new 카운터())
    Console.WriteLine(x);       // 1, 2, 3
```

`카운터` 는 아무 인터페이스도 안 붙였는데 돕니다.

그렇다고 인터페이스가 쓸모없는 것은 아닙니다.
`IEnumerable<T>` 를 붙여야 LINQ 를 쓸 수 있고, 다른 사람이 그 타입을 받을 수 있습니다.
붙이는 편이 낫습니다. 다만 **`foreach` 가 도는 이유는 인터페이스가 아니라 메서드 이름**입니다.

## 직접 만들면 이렇게 귀찮다

`1, 2, 3` 을 내놓는 것을 제대로 만들면 클래스 두 개에 메서드 다섯 개가 나옵니다.
상태(지금 몇 번째인지)를 필드로 들고 있어야 하고, 처음으로 되감는 `Reset` 도 있어야 하고, `IDisposable` 도 붙습니다.

세 개를 내놓는 데 그만큼 쓰는 것은 말이 안 됩니다. 그래서 문법이 하나 생겼습니다.

## yield return 은 그 전부를 대신한다

```csharp
IEnumerable<int> 셋() 
{
    yield return 1;
    yield return 2;
    yield return 3;
}

foreach (int x in 셋())
    Console.WriteLine(x);       // 1, 2, 3
```

이게 끝입니다. 컴파일러가 위에서 손으로 쓴 그 클래스를 만들어 줍니다.
상태를 기억하는 필드도, `MoveNext` 도 전부 자동으로 생깁니다.

## 진짜 중요한 것은 멈춘다는 점이다

`return` 은 메서드를 끝냅니다. `yield return` 은 **값을 하나 내놓고 그 자리에서 멈춥니다.**
다음에 `MoveNext()` 가 오면 멈춘 자리부터 다시 갑니다.

그래서 실행 순서가 왔다 갔다 합니다.

<x-flow>
<script type="application/json">
{
  "label": "만들다 멈추고, 부르면 다시 간다",
  "lines": [
    "IEnumerable<int> 셋()",
    "{",
    "    Console.WriteLine(\"1 준비\");",
    "    yield return 1;",
    "    Console.WriteLine(\"2 준비\");",
    "    yield return 2;",
    "}",
    "",
    "foreach (int x in 셋())",
    "    Console.WriteLine(x);"
  ],
  "dwell": 1.4,
  "cases": [
    {
      "path": [
        { "line": 8, "why": "셋() 을 불렀지만 안의 코드는 아직 한 줄도 안 돕니다.", "frames": ["foreach"] },
        { "line": 2, "why": "첫 MoveNext 가 오자 그제서야 시작합니다.", "frames": ["foreach", "셋()"], "out": "1 준비" },
        { "line": 3, "why": "1 을 내놓고 여기서 멈춥니다. 메서드가 끝난 게 아니라 멈춘 것입니다.", "frames": ["foreach", "셋() 멈춤"] },
        { "line": 9, "why": "제어가 foreach 쪽으로 돌아옵니다.", "frames": ["foreach"], "out": "1" },
        { "line": 4, "why": "다음 MoveNext. 멈춘 자리 바로 다음 줄부터 갑니다.", "frames": ["foreach", "셋()"], "out": "2 준비" },
        { "line": 5, "why": "2 를 내놓고 또 멈춥니다.", "frames": ["foreach", "셋() 멈춤"] },
        { "line": 9, "why": "다시 foreach 쪽.", "frames": ["foreach"], "out": "2" },
        { "line": 6, "why": "더 내놓을 게 없으면 MoveNext 가 false 를 돌려주고 끝납니다.", "frames": ["foreach"] }
      ],
      "note": "1 준비 / 1 / 2 준비 / 2 순서입니다. 준비를 다 하고 나서 찍는 게 아닙니다."
    }
  ]
}
</script>
</x-flow>

`셋()` 을 부른 줄에서 아무 일도 안 일어나는 것에 주목할 만합니다.
`yield` 가 든 메서드는 **부른다고 실행되지 않습니다.** 돌기 시작해야 실행됩니다.

이걸 지연 실행이라고 합니다. 그래서 이런 것도 됩니다.

```csharp
IEnumerable<int> 무한()
{
    int n = 0;
    while (true)
        yield return n++;
}

foreach (int x in 무한())
{
    if (x > 5) break;
    Console.Write(x + " ");     // 0 1 2 3 4 5
}
```

`while (true)` 인데 안 멈춥니다. 필요한 만큼만 만들고 멈춰 있기 때문입니다.
전부 미리 만들었다면 이 코드는 영원히 안 끝났을 겁니다.

## 그래서 생기는 함정

**안 돌면 아무 일도 안 일어납니다.**

```csharp
IEnumerable<int> 저장하고내놓기(List<int> 값들)
{
    foreach (int v in 값들)
    {
        파일에저장(v);          // 부수 효과
        yield return v;
    }
}

저장하고내놓기(목록);          // 아무것도 저장 안 됩니다
```

부르기만 하고 안 돌면 한 줄도 실행되지 않습니다.
그래서 `yield` 가 든 메서드에는 **부수 효과를 넣지 않는 편**이 안전합니다. 값을 내놓는 일만 시킵니다.

**돌 때마다 다시 계산합니다.**

```csharp
var 결과 = 무거운계산();
foreach (var x in 결과) { ... }   // 계산 한 번
foreach (var x in 결과) { ... }   // 또 계산
```

`결과` 는 계산해 둔 값이 아니라 "부르면 계산해 주는 것" 입니다.
두 번 이상 쓸 것이면 `ToList()` 로 한 번 굳혀 둡니다.

## Unity 의 코루틴이 이것이다

```csharp
IEnumerator 페이드아웃()
{
    for (float t = 1f; t > 0f; t -= Time.deltaTime)
    {
        투명도설정(t);
        yield return null;      // 다음 프레임까지 멈춤
    }
}
```

코루틴은 특별한 기능이 아닙니다. `IEnumerator` 를 돌려주는 메서드이고,
유니티가 프레임마다 `MoveNext()` 를 한 번씩 불러 줄 뿐입니다.

`yield return null` 은 "값 없이 한 번 멈춘다" 는 뜻입니다.
`yield return new WaitForSeconds(1f)` 는 내놓은 값을 보고 유니티가 언제 다시 부를지 정하는 것입니다.

멈췄다가 그 자리에서 다시 가는 성질 하나가 그 전체를 떠받치고 있습니다.

## 정리

- `foreach` 는 `GetEnumerator()` / `MoveNext()` / `Current` 세 개로 풀립니다
- 인터페이스가 아니라 **메서드 이름**이 맞으면 돕니다. 그래도 붙이는 편이 낫습니다
- `yield return` 은 값을 내놓고 **그 자리에서 멈춥니다.** 다음에 부르면 이어서 갑니다
- `yield` 가 든 메서드는 부른다고 실행되지 않습니다. 돌아야 실행됩니다
- 그래서 무한 수열도 되고, 반대로 안 돌면 아무 일도 안 일어납니다
- 유니티 코루틴이 이것입니다