비동기 - 코루틴 Deep Dive

Deep Dive

지난 글에서는 사용방법이 위주였다면, 이 글에서는 코루틴의 정체에 대해 깊게 파헤쳐보겠습니다.
코루틴을 모르시는 분은 읽기 적합한 글이 아니므로, 이전 글을 읽고 먼저 사용해보시는 것을 권장합니다.
IEnumerator, IEnumerable
코루틴을 사용할 땐, 반환타입을 IEnumerator 로 함수를 선언하며 StartCoroutine 을 통해 호출합니다.
void Start()
{
StartCoroutine(TestCoroutine());
}
IEnumerator TestCoroutine()
{
yield return null;
}하지만, IEnumerator 는 사실 코루틴을 위해서 만들어진 타입이 아닙니다.
원래의 목적은 List, Dictionary, Array 같은 컬렉션을 순회하기 위해 만들어진 인터페이스입니다.
List<int> numbers = new List<int> { 1, 2, 3 };
foreach (int n in numbers)
{
Debug.Log(n);
}foreach 는 List 를 순회하며 Debug.Log 를 실행하는데, foreach 는 어떻게 순회를 할 수 있는걸까요?
IEnumerable
foreach 가 동작하려면 해당 컬렉션은 내부적으로 IEnumerable 이 구현되어있어야 합니다.
IEnumerable 인터페이스의 내부를 한번 살펴보겠습니다.
- Github Link : dotnet/runtime/IEnumerable.cs
public interface IEnumerable
{
IEnumerator GetEnumerator();
}이를 상속받으면, IEnumerator 를 반환타입으로 한 GetEnumerator 라는 함수를 구현해야합니다.
실제로 List 에서 이를 상속받고 구현을 했는지 확인해보겠습니다.
- Github Link : dotnet/runtime/List.cs
public Enumerator GetEnumerator() => new Enumerator(this);
IEnumerator<T> IEnumerable<T>.GetEnumerator() =>
Count == 0 ? SZGenericArrayEnumerator<T>.Empty :
GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable<T>)this).GetEnumerator();List 가 실제로 인터페이스를 상속받고, GetEnumerator 구현이 되어있는 모습을 확인할 수 있습니다.
저희는 실제로 List 가 구현을 했는지 확인하는 것이 목적이었으므로, 코드의 이해는 필요하지 않습니다.
다만 GetEnumerator 는 이름에서 알 수 있듯이, Enumerator 를 반환해야 하는 것을 알 수 있습니다.
그럼 Enumerator 의 정체는 무엇인지 확인해보겠습니다.
IEnumerator
다시 List.cs 의 코드를 보겠습니다.
- Github Link : dotnet/runtime/List.cs
public struct Enumerator : IEnumerator<T>, IEnumerator
{
private readonly List<T> _list;
private readonly int _version;
private int _index;
private T? _current;
internal Enumerator(List<T> list)
{
_list = list;
_version = list._version;
}
public void Dispose()
{
}
public bool MoveNext()
{
.
.
return false;
}
public T Current => _current!;
object? IEnumerator.Current
{
get
{
.
.
return _current;
}
}
void IEnumerator.Reset()
{
.
.
_index = 0;
_current = default;
}
}마찬가지로, 코드 동작구조의 이해가 목적이 아니므로 원본코드의 일부를 제거하였습니다.
여기서 주목해야 할 점은, Enumerator 는 IEnumerator 를 상속받았다는 것입니다.
아하, 그럼 List 의 구조는 아래의 순서로 이해를 해볼 수 있을 것 같습니다.
foreach가 순회하기 위해선, 해당 컬렉션은 내부적으로IEnumerable이 구현되어야 한다.IEnumerable은 반환타입이IEnumerator인GetEnumerator라는 함수를 구현해야 한다.GetEnumerator는Enumerator를 반환한다.Enumerator는IEnumerator를 상속받고 있다.
드디어 IEnumerator 의 차례가 왔습니다. 소스코드 확인해보겠습니다.
- Github Link : dotnet/runtime/IEnumerator.cs
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}함수의 이름들이 굉장히 직관적입니다. 뭔가 퍼즐이 맞춰지는 느낌이 듭니다.
foreach 는 컬렉션의 IEnumerator 의 함수들을 사용하여 순회하고 있었군요.
아마도 foreach 는 아래와 같은 방식으로 동작할 것입니다.
// foreach's Todo
IEnumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
var item = enumerator.Current;
}지금까지 IEnumerator 와 IEnumerable 의 정체를 알아보았습니다.
그렇다면, 유니티에서는 어떤 방식으로 IEnumerator 를 활용하여 코루틴을 만들어냈을까요?
StartCoroutine
StartCoroutine() 의 내부를 따라가보겠습니다.
// L108
public Coroutine StartCoroutine(IEnumerator routine)
{
...
return StartCoroutineManaged2(routine);
}
// L195
extern Coroutine StartCoroutineManaged2(IEnumerator enumerator);StartCoroutineManaged2 는 extern 으로 선언되어 있습니다.
extern 은 실제 구현이 C++ 네이티브 코드에 있다는 의미로, 유니티 엔진 코어는 비공개이기 때문에 그 너머는 확인할 수 없습니다. 다만, 파일 상단의 헤더 선언과 유니티 공식 문서를 통해 내부 동작을 유추할 수 있습니다.
// L19
[NativeHeader("Runtime/Scripting/DelayedCallUtility.h")]“코루틴의 첫 번째 재개 시점부터 실행이 완료될 때까지의 모든 코드는, 유니티 메인 루프 내부의 DelayedCallManager 에서 실행됩니다.”
“코루틴은 C# 컴파일러가 자동으로 생성한 클래스의 인스턴스로 동작합니다. 이 객체는 코루틴의 내부 상태를 추적하며, yield 이후 어느 지점에서 재개해야 하는지를 기억합니다.”
유니티 공식 문서에 따르면, StartCoroutine 이 호출되면 해당 IEnumerator 는 내부적으로 DelayedCallManager 에 등록됩니다. 이후 매 프레임 Current 를 확인하여 재개 시점을 판단하고, 시점이 되면 MoveNext() 를 호출합니다.
foreach 가 MoveNext() 와 Current 로 컬렉션을 순회하듯, 유니티는 동일한 방식으로 실행 흐름을 제어하는 데 IEnumerator 를 재활용한 것입니다.
컴파일
코루틴은 내장 라이브러리로, 외부 의존성이 필요없으며 사용방법도 간단하여 쉽게 사용할 수 있습니다.
하지만 이 글을 통해 편리함 뒤에 숨겨진 코루틴의 민낯을 까보겠습니다.
모든 것은 트레이드오프가 있기 마련이니까요.
위 사이트는 C# 코드를 입력하면 컴파일 된 코드를 확인할 수 있는 사이트입니다.
아래와 같이 간단한 코루틴 코드를 작성해보고, 컴파일 된 코드를 확인해보겠습니다.
- 컴파일 전
// before compiling
using System.Collections;
class Test
{
IEnumerator TestCoroutine()
{
int count = 0;
yield return null;
count++;
yield return null;
count++;
}
}- 컴파일 후
// after compiling
internal class Test
{
[CompilerGenerated]
private sealed class <TestCoroutine>d__0 : IEnumerator<object>, IEnumerator, IDisposable
{
private int <>1__state;
private object <>2__current;
public Test <>4__this;
private int <count>5__1;
object IEnumerator<object>.Current
{
[DebuggerHidden]
get
{
return <>2__current;
}
}
object IEnumerator.Current
{
[DebuggerHidden]
get
{
return <>2__current;
}
}
[DebuggerHidden]
public <TestCoroutine>d__0(int <>1__state)
{
this.<>1__state = <>1__state;
}
[DebuggerHidden]
void IDisposable.Dispose()
{
}
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0:
<>1__state = -1;
<count>5__1 = 0;
<>2__current = null;
<>1__state = 1;
return true;
case 1:
<>1__state = -1;
<count>5__1++;
<>2__current = null;
<>1__state = 2;
return true;
case 2:
<>1__state = -1;
<count>5__1++;
return false;
}
}
bool IEnumerator.MoveNext()
{
//ILSpy generated this explicit interface implementation from .override directive in MoveNext
return this.MoveNext();
}
[DebuggerHidden]
void IEnumerator.Reset()
{
throw new NotSupportedException();
}
}
[NullableContext(1)]
[IteratorStateMachine(typeof(<TestCoroutine>d__0))]
private IEnumerator TestCoroutine()
{
<TestCoroutine>d__0 <TestCoroutine>d__ = new <TestCoroutine>d__0(0);
<TestCoroutine>d__.<>4__this = this;
return <TestCoroutine>d__;
}
}컴파일 된 코드를 이해하는 것이 목적이 아닙니다. 저희가 주목해야할 점은 두 가지입니다.
첫번째 : 상태머신
TestCoroutine() 함수는 <TestCoroutine>d__0 라는 클래스로 변환되었습니다.
동시에 내부에는 <>1__state 라는 상태값이 생겼습니다.
private sealed class <TestCoroutine>d__0
{
private int <>1__state;
}이 상태값을 기반으로 MoveNext() 가 호출될 때마다 switch 문으로 분기합니다.
private bool MoveNext()
{
switch (<>1__state)
{
default:
return false;
case 0: // 처음 실행
<>1__state = -1;
<count>5__1 = 0;
<>2__current = null;
<>1__state = 1; // 다음 상태로
return true; // yield return null
case 1: // 첫번째 yield 이후
<>1__state = -1;
<count>5__1++;
<>2__current = null;
<>1__state = 2; // 다음 상태로
return true; // yield return null
case 2: // 두번째 yield return 이후
<>1__state = -1;
<count>5__1++;
return false; // 코루틴 종료
}
}함수가 진짜로 멈추는 것이 아니라, 코루틴을 상태머신으로 전환하여 상태값을 저장한 후 return true 하는 것이고, 다음 MoveNext() 호출 시 저장된 상태값에 맞는 case 부터 이어서 실행합니다.
두번째 : 로컬 변수의 클래스 필드화
왜 컴파일러가 클래스로 변환했는지 눈치채셨나요?
private sealed class <TestCoroutine>d__0
{
private int <count>5__1;
}일반적인 함수는 실행이 끝나면 스택에서 사라지기 때문에, 함수 안의 로컬 변수들도 함께 사라집니다. 하지만 코루틴은 yield return 으로 멈췄다가 나중에 재개해야 하기 때문에, 멈춰있는 동안에도 로컬 변수 count 값이 유지되어야 합니다. 스택에서는 이것이 불가능하기 때문에, 컴파일러가 클래스를 생성하여 로컬 변수를 클래스의 필드로 끌어올려 힙에 보관하는 것입니다.
클래스로 만들었으니, new 로 생성해서 반환하는 것만 남았습니다.
private IEnumerator TestCoroutine()
{
<TestCoroutine>d__0 <TestCoroutine>d__ = new <TestCoroutine>d__0(0);
}여기서 코루틴의 민낯이 드러났습니다.
코루틴은 기억하기 위해 클래스로 전환되었고, 클래스를 new 로 생성함으로써 힙 할당이 발생합니다.
유니티에서 힙 할당이 발생했다는 것은, 코루틴 종료 후 GC 의 수거대상이 된다는 의미와 같습니다.
힙 할당이 쌓이고 GC 가 자주 돌게 되면 프레임 저하로 이어질 수 있습니다.
Coroutine
컴파일 목차에서 상태머신 클래스 하나가 힙에 할당된다는 걸 확인했습니다.
그런데 시작 시점의 할당은 그게 끝이 아닙니다.
앞에서 봤던 StartCoroutine 의 시그니처를 다시 확인해보겠습니다.
public Coroutine StartCoroutine(IEnumerator routine)반환타입이 Coroutine 입니다. Coroutine 의 정체도 확인해보겠습니다.
public sealed class Coroutine : YieldInstruction
{
internal IntPtr m_Ptr;
Coroutine() {}
~Coroutine()
{
ReleaseCoroutine(m_Ptr);
}
[FreeFunction("Coroutine::CleanupCoroutineGC", true)]
extern static void ReleaseCoroutine(IntPtr ptr);
}보이시나요? Coroutine 은 struct 가 아니라 class 입니다.
엔진이 내부적으로 관리하는 실제 코루틴을 가리키는 래퍼 클래스로, StartCoroutine 을 호출할 때마다 새로 생성되어 반환됩니다. 소멸자 ~Coroutine 이 CleanupCoroutineGC 를 호출하는 것에서 알 수 있듯, 이 녀석은 GC 가 관리하는 힙 객체입니다.
결국 코루틴을 1번 시작하면 힙 할당이 2회 발생합니다.
- 컴파일러가 만든 상태머신 클래스
- 유니티가 반환하는
Coroutine래퍼 객체
YieldInstruction
위 목차에서는 코루틴을 1번 시작할 때 힙 할당이 2회 발생한다는 것을 알게 되었습니다.
하지만 코루틴을 사용할 때 저희가 직접 손으로 new 를 하는 것이 있습니다.
바로 new Wait 녀석들입니다. 이 녀석들을 보고 통틀어서 YieldInstruction 이라고 합니다.
public class WaitForSeconds : YieldInstruction { ... }
public class WaitForFixedUpdate : YieldInstruction { ... }
public class WaitForEndOfFrame : YieldInstruction { ... }잠깐, 자세히 살펴보니 이것들도 YieldInstruction 이라는 것을 상속받은 클래스이군요.
저희는 코루틴을 사용할 때 직접 new 키워드로 이 클래스의 인스턴스를 생성하고 있었습니다.
여기서 코루틴의 참상이 드러납니다.
코루틴 자체도 클래스로 전환되어 힙 할당이 이루어지는데, YieldInstruction도 힙 할당이 발생합니다.
끔찍한 예시를 한번 살펴보겠습니다.
void Start()
{
StartCoroutine(TestCoroutine());
}
IEnumerator TestCoroutine()
{
while (true)
{
yield return new WaitForSeconds(1f); // 매 루프마다 new
}
}위의 코드가 얼마나 끔찍한지 눈에 보이신다면, 이 글에서 얻을 수 있는 건 모두 얻으셨습니다.
TestCoroutine 을 시작할 때 2회 힙 할당이 발생했고, 이후 1초마다 WaitForSeconds 의 힙 할당이 쌓입니다.
코루틴을 사용하면 이렇게 작성하는 것이 흔히 보이는 패턴이기에 더욱 끔찍하다고 말할 수 있습니다.
위의 상황을 좋게 바꾸는 방법이 있습니다. 바로 캐싱입니다.
private WaitForSeconds _waitForSeconds = new WaitForSeconds(1f);
IEnumerator TestCoroutine()
{
while (true)
{
yield return _waitForSeconds;
}
}WaitForSeconds 객체를 1회만 생성하여 힙에 올리고 재사용하니 추가적인 힙 할당이 발생하진 않습니다.
하지만 저희는 매번 1초만 기다리고 싶을까요?
호출할 때마다 원하는 시간만큼 기다리고 싶기 때문에 실제로는 대부분 아래와 같이 사용합니다.
IEnumerator TestCoroutine(float waitTime)
{
while (true)
{
yield return new WaitForSeconds(waitTime); // heap alloc per call
}
}시간이 동적으로 바뀌는 경우엔 캐싱이 사실상 불가능합니다.
캐싱은 항상 같은 시간을 기다리는 경우에만 유효한 최적화고, 동적인 경우엔 적용이 어렵습니다.
정리하면, 힙 할당이 발생하는 시점은 두 군데입니다.
하나는 코루틴을 시작하는 순간(상태머신 + Coroutine 래퍼로 2회), 다른 하나는 새 YieldInstruction 을 new 하는 순간입니다. 반대로 코루틴이 yield 로 멈췄다가 재개되는 메커니즘 자체는 추가 할당이 없습니다. 단, yield return 0 처럼 값 타입을 반환하면 object 로 박싱되며 매번 할당이 생기므로, 1프레임 대기는 반드시 yield return null 을 써야 합니다.
진짜 문제는 할당이 반복되는 패턴입니다. 매 프레임 StartCoroutine 을 새로 호출하거나, 루프 안에서 매번 새 YieldInstruction 을 할당하는 코드가 그렇습니다. 반면 코루틴 하나를 오래 유지하며 yield return null 로 도는 것은 시작 시 한 번 할당하고 끝이라, 오히려 부담이 적습니다.
마무리
코루틴은 분명 강력하고 간편한 도구입니다.
다만 지금까지 봤듯, 매 프레임 혹은 매우 빈번하게 도는 로직에는 적합하지 않습니다.
코루틴은 Unity 초창기인 2005년 무렵부터 사용되었으니, 이 글을 쓰는 시점으로부터 약 20년 된 기능입니다.
기능 자체가 오래되었다 보니, 레거시 느낌이 나기도 하고 동시에 그만큼 MonoBehaviour 에 깊게 박혀 있어서 쉽게 못 걷어낼 것 같기도 합니다.
사람들은 이런 문제점들을 해결했을까요?
이런 코루틴을 대체할 수 있는 더욱 강력한 비동기 도구는 없을까요?
Reference
- .NET runtime - IEnumerable.cs
- .NET runtime - IEnumerator.cs
- .NET runtime - List.cs
- Gamasutra - C# Memory Management for Unity Developers
- UnityCsReference - MonoBehaviour.bindings.cs
- UnityCsReference - Coroutine.bindings.cs
- Unity Documentation - Coroutines
- Unity Documentation - Write and run coroutines
- Unity Discussions - Why do my Coroutines allocate memory when they execute?