이전에 Task와 UniTask의 동작 방식을 정리하면서, 코루틴도 함께 정리해두면 좋겠다는 생각이 들었다.
그래서 이번 글에서는 코루틴이 실제로 어떤 구조로 동작하는지, 그 원리를 정리해보려고 한다.
많은 개발자가 처음 하는 오해는 이것이다.
코루틴 = 비동기 -> 스레드?
하지만 Unity의 코루틴은 스레드를 전혀 생성하지 않는다.
대신 메인 스레드에서 실행되는 프레임 기반 상태 머신이다.
즉, 코루틴은 멀티스레드가 아니라
Update처럼 매 프레임 실행되지만, 함수 전체를 한 번에 실행하는 것이 아닌 yield return을 기준으로 중단하고 다시 재개하는 구조이다.
그래서 코루틴은 Unity API를 안전하게 호출할 수 있고, 동시에 스레드에 비해 매우 가볍다.
코루틴 함수는 아래와 같이 생겼다.
IEnumerator FadeOut() { yield return new WaitForSeconds(1f); // ... }
여기서 자연스럽게 궁금증이 생긴다.
IEnumerator가 무엇이고, 왜 IEnumerator를 반환할까?
그 이유는 Unity의 코루틴이 C#의 반복자(Iterator) 패턴을 기반으로 만들어졌기 때문이다.
<IEnumerator란?>
IEnumerator는 다음 요소로 이동할 수 있는 반복자를 의미하는 인터페이스이다.
public interface IEnumerator { bool MoveNext(); object Current { get; } void Reset(); }
여기서 중요한 것은 MoveNext()이다.
즉, 코루틴은 스레드처럼 자동으로 실행되는 게 아니라, Unity가 매 프레임 MoveNext()를 호출해줘야만 조금씩 앞으로 진행되는 구조라는 뜻이다.
C# 컴파일러는 yield return이 포함된 메서드를 자동으로 상태 머신 형태의 클래스로 변환한다.
변환 과정은 다음과 같다.
- 코루틴 함수 전체가 클래스로 재구성됨
- yield return이 등장할 때마다 하나의 state가 생성됨
- MoveNext()가 호출될 때마다 state가 변경되며 코드가 이어서 실행됨
즉, 코루틴은 중단되는 것이 아니라
매 프레임 MoveNext()가 실행되며, 이전 상태의 다음 줄부터 이어서 실행되는 구조이다.
코루틴은 다음 흐름으로 Unity에서 실행된다.
<1. StartCoroutine 호출>
<2. PlayerLoop(Update 루프)에서 MoveNext 호출>
매 프레임 Unity 엔진은 다음을 반복한다.
코루틴 리스트 순회 -> 각 코루틴의 MoveNext() 실행
MoveNext()의 결과와 Current값에 따라 Unity는 스케줄링을 결정한다.
<3. yield return 결과로 재개 시점 결정>
| yield 구문 | 의미 |
|---|---|
| yield return null | 다음 프레임까지 대기 |
| yield return new WaitForSeconds(t) | t초 대기 |
| yield return new WaitUntil(cond) | cond()가 true 될 때까지 대기 |
| yield return StartCoroutine(sub) | sub 코루틴 완료 후 재개 |
| yield break | 코루틴 종료 |
Unity는 Current에 반환된 객체를 보고
다음 MoveNext를 언제 실행할지를 판단한다.
<1. 스레드 없이 "비동기처럼 보이게" 만들기 위해>
실제로는 메인 스레드지만, yield return을 통해 비동기 흐름을 자연스럽게 표현할 수 있다.
<2. 대부분의 Unity API가 메인 스레드 전용이기 때문>
Transform, GameObject, Renderer 등 거의 모든 API는 멀티스레드에서 접근 불가하다. 따라서 스레드를 생성하는 방식은 적합하지 않다.
<3. 게임 연출·타이밍 제어에 최적화되어 있기 때문>
코루틴은 다음과 같은 작업에 매우 자연스럽다.
ex)
즉, 프레임 기반 흐름 제어라는 게임 구조에 딱 맞는다.
<4. 매우 가벼운 구조>
코루틴은 상태 머신 객체 하나만 유지하면 되며, 스레드를 생성하는 비용에 비해 매우 싸다.
물론 코루틴이 만능은 아니다.
<1. CPU 작업에 취약함>
코루틴은 스레드가 아니므로
for(int i = 0; i< 99999999; i++) { }
이런 연산을 코루틴에서 실행하면
메인 스레드가 멈추고 게임이 프리즈된다.
<2. 복잡한 비동기 로직 표현이 어렵다>
중첩된 코루틴, 여러 대기 조건, 분기 처리 등이 많아지면 흐름 관리가 어려워지고 디버깅도 힘들다.
그래서 async/await 기반의 UniTask를 함께 쓰는 경우가 많다.
코루틴의 동작 방식은 한 문장으로 정리하면 다음과 같다.
코루틴은 스레드가 아니라, Unity PlayerLoop가 매 프레임 MoveNext()를 호출하는
프레임 기반 상태머신이다.
추가로 기억하면 좋은은 점
*IEnumerator기반이기 때문에 yield return을 사용할 수 있다.
코루틴은 Unity 개발자에게 가장 친숙한 도구지만, 정작 내부 구조를 알고 쓰는 경우는 많지 않다.
막상 그 구조를 들여다보면 예상보다 훨씬 단순하면서도, 놀라울 만큼 효율적으로 설계되어 있다는 것을 알 수 있다.
프레임 기반 상태 머신이라는 구조를 이해하고 나면,
코루틴이 왜 게임 연출에 딱 맞는지,
왜 복잡한 비동기에는 적합하지 않은지 자연스럽게 느껴질 것이다.
결국 중요한 건 도구를 어떻게 쓰느냐이다.
코루틴을 제대로 알면, 게임의 흐름을 더 부드럽고 정확하게 다루는 데 많은 도움이 될 것이다.