yield 키워드
yield
라는 단어를 찾아보면 “산출/생산하다”, “양도하다” 등의 뜻을 가지고 있습니다.
프로그래밍에서 yield
키워드가 사용되는 방식은 이 단어의 “생산하다” 또는 “산출하다”는 의미와 가장 관련이 깊습니다. 이 키워드를 사용할 때, 메서드는 값을 “산출”하거나 “생산”하여 호출자에게 반환합니다.
C#의 yield
키워드는 반복자(Iterator)를 구현하는 메서드에서 반환 값을 지연시키는 기능을 제공합니다.
메서드가 반복문 내에서 결과를 지연하여 반복적으로 값을 “산출”하는 키워드가 바로 yield
입니다.
반복자(Iterator)란?
반복자는 컬렉션(예: 배열, 리스트 등)에서 요소를 하나씩 차례로 꺼내서 처리할 수 있게 해주는 객체 또는 메서드입니다.
C#에서 foreach
문을 사용할 때, 그 내부적으로 반복자가 동작하고 있다고 생각하면 됩니다.
yield의 기본 개념
yield
키워드는 반복자(Iterator)를 구현하는 메서드에서 반환 값을 지연시키는 기능을 제공합니다.
이를 통해 복잡한 상태 관리 없이 간결하고 효율적인 반복자 메서드를 구현할 수 있습니다.
단순히 컬렉션에서 값을 차례대로 꺼내는 데에는 상관 없겠지만…
특정 조건에 따라 꺼내고 싶다면, 일일이 메서드를 작성해서 상태를 관리해야 합니다. 하지만 yield
를 사용하면 이런 복잡한 작업을 간단하게 처리할 수 있습니다.
또한, 많은 데이터를 처리할 때, 모든 데이터를 한 번에 메모리에 올리지 않고, 지연된 실행(Lazy Evaluation)을 통해 필요할 때마다 하나씩 처리할 때 유용합니다.
‘yield return’과 ‘yield break’
yield return
: 이 키워드를 사용하면 호출된 메서드에서 값을 반환하면서 현재 위치를 기억해 둡니다. 다음 호출 시에는 그 다음 요소를 반환합니다.yield break
: 반복을 중지하고 메서드 실행을 끝내는 역할을 합니다.
예제1: 간단한 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
internal class Program
{
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
static void Main(string[] args)
{
foreach (var number in GetNumbers())
{
Console.WriteLine(number); // 출력: 1, 2, 3
}
}
}
위 예제에서 GetNumbers()
메서드는 yield return
키워드를 사용하여 1, 2, 3을 순차적으로 반환합니다.
이로 인해, foreach
루프는 GetNumbers()
에서 반환된 값을 하나씩 받아 출력합니다.
yield return 1
이 실행되면,1
이 반환되고 메서드의 상태는 기억됩니다.- 다시 호출되면 그 다음
yield return 2
가 실행되고, 메서드는 이 상태를 기억합니다. - 이 과정이 모든 요소가 반환될 때까지 계속됩니다.
예제2: yield break
yield break
예제는 공식 MS 문서를 보면 좋을 것 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Console.WriteLine(string.Join(" ", TakeWhilePositive(new int[] {2, 3, 4, 5, -1, 3, 4})));
// Output: 2 3 4 5
IEnumerable<int> TakeWhilePositive(IEnumerable<int> numbers)
{
foreach (int n in numbers)
{
if (n > 0)
{
yield return n;
}
else
{
yield break;
}
}
}
위 예제에서 컬렉션을 2, 3, 4, 5, -1, 3, 4
로 구성하였고, yield return
을 통해서 n > 0
인 데이터만 반화합니다.
이때, n이 음수인 데이터를 만나면 yield break
를 통해 반복문이 종료되고, 이후 양수인 3, 4
가 출력되지 않는 모습을 볼 수 있습니다.
yield의 동작 원리
yield
를 사용하면 컴파일러에 의해 자동으로 상태 기계를(State Machine) 생성하여 반복자 메서드를 구현합니다. 이 상태 기계는 메서드가 중단된 위치와 메서드 내부의 모든 지역 변수를 유지합니다.
그 결과, yield
는 다음 호출이 있을 때 이전 상태를 복원하고, 다음에 실행될 코드를 지정할 수 있습니다.
상태 기계의 구성 요소 추측
- 필드: 반복자의 현재 상태를 나타내는 변수들을 필드로 가집니다. 예를 들어, 반복자의 현재 위치를 나타내는 정수형 필드와 메서드 내에서 유지되어야 하는 지역 변수가 포함될 수 있습니다.
- MoveNext() 메서드: 이 메서드는 반복자에서 다음 요소를 반환하기 위해 호출됩니다. 이 메서드는
switch
문이나 상태 관리 변수를 사용하여, 이전 호출의 위치에서 다시 실행을 시작합니다. - Current 프로퍼티: 현재 반환된 요소를 저장하고 반환하는 데 사용됩니다.
- Reset() 메서드: 이 메서드는 반복자의 상태를 초기화할 때 사용됩니다. 일반적으로 잘 사용되지 않지만,
IEnumerator
인터페이스에 정의되어 있습니다.
예를 들어, 다음과 같은 yield
메서드를 작성했다고 가정해 보겠습니다.
1
2
3
4
5
6
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
이 코드는 컴파일러에 의해 다음과 같은 상태 기계로 변환될 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 상태 기계 예시
public class GetNumbersEnumerable : IEnumerable<int>
{
public IEnumerator<int> GetEnumerator()
{
return new GetNumbersEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public static IEnumerable<int> Create()
{
return new GetNumbersEnumerable();
}
}
// Enumerator
public class GetNumbersEnumerator : IEnumerator<int>
{
private int state = 0;
private int current;
public int Current => current;
object IEnumerator.Current => Current;
public bool MoveNext()
{
switch (state)
{
case 0:
current = 1;
state = 1;
return true;
case 1:
current = 2;
state = 2;
return true;
case 2:
current = 3;
state = -1; // End of enumeration
return true;
default:
return false;
}
}
public void Reset()
{
state = 0;
}
public void Dispose() { }
}
internal class Program
{
static void Main(string[] args)
{
foreach (var number in GetNumbersEnumerable.Create())
{
Console.WriteLine(number); // 출력: 1, 2, 3
}
}
}
state
필드: 이 필드는 현재 반복자의 상태를 추적합니다.MoveNext()
메서드는 이 상태에 따라 적절한yield return
위치로 이동합니다.current
필드: 이 필드는 현재 반환된 값을 저장하며,Current
프로퍼티를 통해 반환됩니다.MoveNext()
메서드: 이 메서드는 반복자의 상태를 업데이트하며, 다음 반환값을 준비합니다.switch
문을 사용하여, 반복자 메서드에서yield return
이 호출된 위치로 이동합니다.
실제 상태가 유지되는지 확인하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Program
{
public static void Main()
{
var iterator = InfiniteSequence().GetEnumerator();
Console.WriteLine(iterator.Current); // 아직 시작 전이라 초기 상태
iterator.MoveNext();
Console.WriteLine(iterator.Current); // 0
iterator.MoveNext();
Console.WriteLine(iterator.Current); // 1
iterator.MoveNext();
Console.WriteLine(iterator.Current); // 2
// 두 번째 반복자 생성
var newIterator = InfiniteSequence().GetEnumerator();
newIterator.MoveNext();
Console.WriteLine(newIterator.Current); // 0, 새로운 반복자이므로 처음부터 시작
newIterator.MoveNext();
Console.WriteLine(newIterator.Current); // 1
}
public static IEnumerable<int> InfiniteSequence()
{
int i = 0;
while (true)
{
yield return i++;
}
}
}
실제 원래 사용하던 yield
를 통해 매서드를 구현해 봅시다.
위 코드를 실행해보면 아래와 같이 ‘0, 0, 1, 2, 0, 1’이 출력 될 것입니다.
InfiniteSequence
메서드는 yield
를 사용하여 무한 시퀀스를 생성하게 됩니다.
이때, iterator
는 이 메서드에서 반환된 반복자 객체로, MoveNext()
를 호출할 때마다 시퀀스의 다음 값을 생성하며 상태를 유지합니다.
하지만, 동일한 InfiniteSequence
메서드를 호출해서 새로운 반복자 객체를 생성하게 되면, 이 객체는 처음부터 시작하며, 이전 iterator
객체의 상태를 공유하지 않습니다.
따라서, yield
는 특정 호출에 대해 상태를 유지하지만, 이 상태는 반복자 객체마다 독립적입니다.
즉, yield
로 만들어진 Enumerator
객체는 각각 자신의 상태 기계를 가지고 있고, 이는 독립적으로 동작하고 있다는 것을 알 수 있습니다.
yield의 활용 예제 (장점)
yield는 복잡한 반복문을 간단한 반복자 메서드로 만들면서 복잡하고 비효율적인 반복문 작성을 단순하게 만들어주고, 지연된 실행(Lazy Evaluation)을 통해 결과를 미리 메모리에 올려놓지 않고, 필요한 순간에 하나씩 계산하여 반환할 수 있습니다.
예제3: 복잡한 반복자와 메모리 효율
예를 들어, 피보나치 수열을 출력하는 코드를 작성할 때, 반복문으로 작성하려면 반복문과 변수가 한 코드내에 작성되어야 하고, 이 코드를 여러 군데에서 사용한다면, 함수로 따로 빼야 하는데, 함수로 빼게 되면 아마 다음과 같은 코드가 만들어지면서, 메모리를 많이 쓰는 코드가 될 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class FibonacciSequence
{
public List<int> GenerateFibonacci(int count)
{
var result = new List<int>();
int previous = 0, current = 1;
for (int i = 0; i < count; i++)
{
result.Add(previous);
int next = previous + current;
previous = current;
current = next;
}
return result;
}
}
class Program
{
static void Main()
{
var fibonacci = new FibonacciSequence();
var sequence = fibonacci.GenerateFibonacci(10);
foreach (var number in sequence)
{
Console.WriteLine(number); // 출력: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
}
}
}
위 예제는 피보나치 수열의 모든 항목을 List<int>
에 저장한 후 반환합니다. 이 경우 전체 수열을 메모리에 저장하므로, 수열의 크기가 커지면 메모리 사용량도 증가합니다.
심지어는 수열을 미리 계산하여 리스트에 저장하고, 그 후에 하나씩 꺼내보고 싶다면, 반복문에서 리스트의 요소를 하나씩 반환하는, 두 번의 작업이 발생합니다.
만약, 상태를 저장하고 지연된 실행(Lazy Evaluation)을 가능하게 하는 코드를 만들면 된다고 생각하신다면, 맞습니다. 그걸 간단하게 해주는 코드가 yield
인 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Program
{
public static void Main()
{
foreach (var number in Fibonacci(5))
{
Console.WriteLine(number);
}
}
public static IEnumerable<int> Fibonacci(int count)
{
int previous = 0, current = 1;
for (int i = 0; i < count; i++)
{
yield return previous; // 현재의 피보나치 수를 반환
int next = previous + current;
previous = current;
current = next;
}
}
}
위 예제는 일반 반복문 함수처럼 작성하되, yield return
을 사용했습니다.
상태 기계(State Machine)를 프로그래머가 직접 전부 작성하지 않고 간단하게 만들 수 있으며…
또한, 지연된 실행(Lazy Evaluation)을 통해 호출 시점에서 다음 요소를 계산하고 반환하므로, 필요한 만큼만 데이터를 생성하여 메모리를 효율적으로 사용 할 수 있습니다.
주의사항
- 성능 고려: yield는 상태 기계를 생성하므로, 상태 기계가 필요 없는 코드에서는 오히려 필요 없는 연산이 추가 될 수 있습니다. 만약, 성능에 민감한 코드라면, 프로그래머가 직접 작성하는 것이 더 좋을 수 있습니다.
- 디버깅의 어려움: yield는 컴파일러가 상태 기계를 생성하므로, 좀 더 자세한 상태 확인이 어려울 수 있습니다.
정리
장점
- 간단함: 상태 기계가 필요한 복잡한 반복문 코드를 간단한 반복자 메서드로 쉽게 작성할 수 있습니다.
- 메모리 절약: 출력할 데이터 컬렉션을 미리 메모리에 올려놓지 않고, 지연된 실행(Lazy Evaluation)을 통해 필요한 순간에 하나씩 계산하여 반환할 수 있습니다.
결론
C#의 yield
키워드는 상태 기계가 필요한 복잡한 반복문 코드를 간결하고 효율적으로 구현할 수 있게 해주는 강력한 도구입니다.
지연된 실행(lazy execution)을 통해 메모리와 성능을 최적화할 수 있으며, 복잡한 상태 관리를 단순화할 수 있습니다.
이를 적절히 활용하면 읽기 쉽고, 유지보수성이 좋은 코드를 작성할 수 있습니다.