본문 바로가기

C#

IEnumerable, IEnumerator를 반환타입으로 가지는 메서드 파고들기

코루틴과 yield의 마법

유니티를 접하면서 코루틴을 처음 써보면 대기하는 것 같아 신기하다. 분명히 코드는 한줄한줄 순서대로 실행되는데 어떻게 yield에서 대기해놓고 다른곳을 실행할 수 있을까?

 

IEnumerator와 yield가 정확히 어떻게 동작하는지는 찾아보면, 대략 MoveNext()를 호출할 때마다 다음 yield를 만날 때까지 실행해서 값을 반환한다는 식으로 이해된다. 약간 더 깊이 파고들면, 프로그래밍 언어에서 함수 호출을 하는 방식을 생각해볼 수 있다. 함수를 호출하면 스택에 반환될 주소와 함수 내부의 지역변수들이 추가된다. 그런데 만약 함수에서 값을 반환해버리면 스택이 없어질테고, 다음에 "중간부터 다시 시작" 할 수는 없을 것 같다.

 

어떻게 이런 일이 가능한 것일까?


IEnumerable/IEnumerator를 구현하는 클래스

먼저 IEnumerable/IEnumerator를 구현하는 클래스부터 살펴보자.(IEnumerable은 IEnumerator를 가지는 클래스이므로 열거한다는 개념적으로는 그게 그거라고 생각할 수 있다.) C#에서는 enumerate, 즉 열거한다고 표현하지만 python이나 javascript등 yield를 다른 언어들에서는 흔히 이런 함수를 generator라고 부른다. "값을 생성"하는 무언가라는 점에서 열거한다는 표현보다 다른 언어의 generator, 생성기라는 표현이 더 직관적으로 알기 쉽다.

 

public class Generator100 : IEnumerable<int>
{
    public class MyEnumerator : IEnumerator<int>
    {
        private int _current;
        int IEnumerator<int>.Current => _current;
        object IEnumerator.Current => _current;
        void IDisposable.Dispose()
        {
        }
        bool IEnumerator.MoveNext()
        {
            if (_current < 100)
            {
                _current++;
                return true;

            }
            return false;
        }
        void IEnumerator.Reset()    // 원래는 구현이 있어야 하지만, 구현하지 않음
        {
        }
    }
    IEnumerator<int> IEnumerable<int>.GetEnumerator() => new MyEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => new MyEnumerator();
}

foreach (var a in new Generator100())	// 1부터 100까지 출력됨
{
    Console.WriteLine(a);
}

위의 클래스는 실제로 작동하는 1부터 100까지의 수를 생성하는 생성기 클래스이다. 아랫쪽의 foreach문의 결과로 볼 수 있는 것처럼 위의 클래스는 MoveNext에서 false를 반환할 때까지 1부터 100까지의 수를 순차적으로 반환하게 된다.

 

코드는 비교적 간단하다. 단순히 클래스의 멤버변수 _current가 있고, MoveNext를 호출할 때마다_current를 증가시킨다. 그리고 _current가 100이 되면 MoveNext에서 false를 반환한다.

 

호출하는쪽에서는 MoveNext에서 false가 반환될 때 까지 MoveNext를 계속 호출한다.

IEnumerator를 생각하지 않고, 단순히 숫자 100개를 생성하는 클래스라고 생각하면 이해가 쉬울 것이다.

.


IEnumerable을 반환하는 메서드

그런데 1부터 100까지의 수를 생성하려면 굳이 클래스를 만들지 않아도 된다.

 

IEnumerable<int> Enumerate100()
{
    int a = 0;
    for (int i = 0; i < 100; i++)
    {
        a += i;
        yield return a;
    }
}

foreach (var a in Enumerate100())
{
    Console.WriteLine(a);
}

위와 같은 코드도 클래스 버전과 동일하게 작동한다.

foreach문의 실행 순서를 잘 따라가보면, Enumerate100 메서드에서 yield를 만날때마다 메서드에서 빠져나와서 콘솔에 출력을 하고, 다시 들어가서 이어서 실행하는 것 처럼 보인다. 어떻게 가능한 것일까?

 

해답은 간단하다. 클래스로 구현했을 때와 동일한 일이 벌어진다..

 

일단 C# 컴파일러는 익명 클래스를 하나 만든다. 그리고 그 클래스 안에 메서드 안에서 사용하는 지역변수들을 필드로 선언한다. 그리고는 yield문을 만나면 yield문 앞뒤로 메서드를 조각조각낸다. 그리고 yield와 yield 사이를 state로 간주한다. 그리고 그 state들간을 이동시키는 상태 기계를 만든다. 흔히 FSM이라고 하는 그것이다. 그리고 MoveNext가 호출될때마다현재 상태에 해당하는 부분만 실행시켜 답을 돌려준다.

IEnumerable 안에서 벌어지는 일

 

그림으로 표현하면 대략 위와 같다.

위의 Enumerate100() 메서드가 실제로는 대충 아래와 같은 느낌의 클래스로 변환되는 것이다. (실제로 정확히 이런게 나온다는 뜻은 아니다. 물론 C#코드 레벨에서 일어나는 일도 아니다. 이 작업은 IL에서 이루어진다.)

public class ConvertIntoThis
{
    private int _state;

    private int i;
    private int a;

    public void Init()
    {
        _state = 0;
        i = 0;
        a = 0;
    }

    public bool Run(ref int result)
    {
        if (_state == 0)
        {
            a += i;

            result = a;        // yield에 해당

            i++;
            if (i < 100)
                _state = 0;
            else
                _state = 1;

            return true;

        }
        else
        {
            return false;
        }
    }
}

위의 클래스로부터 값을 뽑아내려면 대략 아래와 같은 느낌의 작업을 하게 된다.

 

var obj = new ConvertIntoThis();
obj.Init();
int result = 0;
while (obj.Run(ref result))
{
    Console.WriteLine(result);
}

 

실제로 위 코드의 출력은 Enumerate100() 메서드를 foreach로 순회했을 때와 완벽하게 같게 나온다.

 

 

마무리..

IEnumerable/IEnumerator를 사용할 때 실제로 어떤 일이 벌어지는지 알아보았다.

어떤 값을 생성하는 생성기 클래스를 정의하는 것과 똑같은 일이 벌어지며, 이것이 바로 IEnumerable이 동작하는 원리이다.

 

맨 마지막 코드블럭에서 볼 수 있듯이, IEnumerable을 사용하면 실제로 어떤 클래스의 인스턴스를 생성하게 된다. 당연하게도 이 작업은 힙에의 메모리 할당 및 가비지가 발생하고, IEnumerable이 단순 루프보다 성능이 절대로 좋을 수 없는 한 가지 이유가 된다.