Unity

[Unity] 코루틴은 프레임, yield 타이밍, 그리고 DLL 분석하기

2026. 7. 13. 18:14 @CGNY

Unity 코루틴은 프레임의 "언제" 실행될까 — Player Loop, yield 타이밍, 그리고 DLL로 직접 확인

지난 글에서 코루틴은 "Unity가 매 프레임 MoveNext()를 불러주는 함수"라고 정리했습니다.
그런데 그때 "매 프레임"이라는 말을 블랙박스로 남겨뒀습니다. 정확히 프레임의 언제 불리는 걸까요?

이 글의 목표는 세 가지입니다.

  1. 코루틴/yield 개념을 다시 짧게 정리하고,
  2. Player Loop 안에서 코루틴이 정확히 언제 재개되는지(yield 종류별 시점)를 보고,
  3. 컴파일된 DLL을 직접 열어 컴파일러가 만든 상태머신을 눈으로 확인합니다.

코루틴의 원리(IEnumerator, 상태머신, 힙)는 [지난 코루틴 글]에서 다뤘습니다. 이 글은 시점(언제)실물 확인 이 주제입니다.

이 글에서 계속 쓸 예제 코루틴은 "일정 시간 동안 앞으로 구르는" RollUpdate입니다.

public class Character : MonoBehaviour
{
    [SerializeField] private float rollTime = 0.5f;
    public bool IsRolling { get; private set; }

    // 버튼 등에서 호출: 구르기 시작
    public void Roll(float distance)
    {
        IsRolling = true;                       // 코루틴 밖에서 상태 켜기
        StartCoroutine(RollUpdate(distance));
    }

    private IEnumerator RollUpdate(float rollDistance)
    {
        float currentRollTime = 0f;
        float prevRollDistance = 0f;

        while (true)
        {
            currentRollTime += Time.deltaTime;

            float timePoint = currentRollTime / rollTime;
            float inOutSine = -(Mathf.Cos(Mathf.PI * timePoint) - 1f) / 2f;    // 부드러운 가감속(InOutSine)
            float currentRollDistance = Mathf.Lerp(0f, rollDistance, inOutSine); // 지금까지 가야 할 누적 거리
            float deltaValue = currentRollDistance - prevRollDistance;
            transform.position += transform.forward * deltaValue;
            prevRollDistance = currentRollDistance;

            if (currentRollTime >= rollTime) break;
            yield return null;                  // 여기서 멈추고 한 프레임 쉼
        }

        IsRolling = false;
    }
}

rollTime(예: 0.5초) 동안 매 프레임 조금씩 앞으로 이동하고, 다 구르면 끝나는 코루틴입니다. 이 하나를 개념 → 타이밍 → DLL까지 끝까지 따라가 보겠습니다.


1. 코루틴과 yield, 한 번 더 짧게

코루틴은 중간에서 멈췄다가 다음에 그 자리에서 이어서 실행되는 함수입니다. 반환형은 IEnumerator이고, yield return이 "멈추는 지점"입니다.

RollUpdate를 보면 while 안에서 이동을 조금 하고 yield return null로 멈춥니다. 다음에 재개되면 그 다음 줄부터 이어서 실행되고, currentRollTime 같은 지역변수 값은 그대로 유지됩니다.

yield return X의 의미는 이렇습니다.

"여기서 멈추고, X가 정한 시점에 다음 줄부터 다시 실행하라."

그리고 이 X가 무엇이냐에 따라 재개되는 시점이 달라집니다. 바로 이게 이 글의 핵심입니다.

yield 문 의미(언제 재개)
yield return null 다음 프레임
yield return new WaitForSeconds(2f) 2초 뒤
yield return new WaitForFixedUpdate() 다음 물리 갱신 뒤
yield return new WaitForEndOfFrame() 이번 프레임 렌더 끝난 뒤
yield return StartCoroutine(other) 다른 코루틴이 끝난 뒤

RollUpdateyield return null을 쓰니 "한 프레임에 한 걸음씩" 구릅니다. 그런데 이 "다음 프레임", "물리 뒤", "렌더 뒤"가 프레임의 어디냐를 알려면 먼저 한 프레임이 어떻게 도는지(Player Loop) 를 봐야 합니다.


2. 한 프레임은 어떻게 도나 — 생명주기와 Player Loop

우리가 아는 생명주기 함수들은 사실 엔진이 매 프레임 정해진 순서로 불러주는 것입니다.

@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam activityBackgroundColor #1E1B2E
skinparam activityBorderColor #BD93F9
skinparam activityFontColor #F5F5F5
skinparam activityDiamondBackgroundColor #2D2442
skinparam activityDiamondBorderColor #FFB86C
skinparam noteBackgroundColor #1E1B2E
skinparam noteBorderColor #FFB86C
skinparam noteFontColor #F5F5F5

title MonoBehaviour 생명주기 순서

start
:Awake();
note right: 객체 초기화 시 1회\n(GetComponent 캐싱 등)
:OnEnable();
:Start();
note right: 첫 Update 직전 1회
repeat
  :FixedUpdate();
  note right: 고정 간격(기본 0.02초)\n물리 담당
  :Update();
  note right: 매 프레임, 게임 로직
  :LateUpdate();
  note right: Update 뒤 (카메라 추적 등)
repeat while (오브젝트 살아있음?) is (예)
->아니오;
:OnDisable();
note right: 꺼질 때 (SetActive(false)/enabled=false),\n파괴 시엔 그 전 단계로도
:OnDestroy();
note right: 진짜 파괴될 때
stop
@enduml

여기서 꼭 짚을 두 가지가 있습니다.

① FixedUpdate는 "매 프레임"이 아닙니다.
Update는 프레임당 1번(프레임레이트에 따라 들쭉날쭉)이지만, FixedUpdate는 고정 시간 간격(기본 0.02초 = 50Hz)마다 불립니다. 프레임이 느리면 한 프레임에 여러 번, 빠르면 0번일 수도 있습니다. 그래서 Rigidbody 물리는 프레임레이트에 안 흔들리게 FixedUpdate에 넣습니다.

② OnDisable ≠ OnDestroy.
OnDisable은 꺼질 때(비활성화), OnDestroy는 파괴될 때입니다. 파괴 시엔 OnDisable이 먼저 불리고 그다음 OnDestroy가 불립니다.

이 생명주기 함수들을 순서대로 불러주는 엔진 내부의 큰 루프가 Player Loop입니다. 지난 글의 그 while 의사코드가 바로 이것입니다.


3. 그래서 코루틴은 "언제" 재개되나 (핵심)

코루틴의 MoveNext()도 이 Player Loop의 특정 지점에서 불립니다. yield 종류마다 그 지점이 다릅니다.

@startuml
skinparam backgroundColor #111217
skinparam defaultFontColor #F5F5F5
skinparam ArrowColor #FFB86C
skinparam sequenceLifeLineBorderColor #BD93F9
skinparam sequenceLifeLineBackgroundColor #1E1B2E
skinparam sequenceParticipantBorderColor #BD93F9
skinparam sequenceParticipantBackgroundColor #1E1B2E
skinparam sequenceParticipantFontColor #F5F5F5
skinparam noteBackgroundColor #1E1B2E
skinparam noteBorderColor #FFB86C
skinparam noteFontColor #F5F5F5

title 한 프레임 안에서 코루틴이 재개되는 지점 (Unity 실행 순서 기준)

participant "Player Loop" as Loop
participant "물리" as Phys
participant "MonoBehaviour" as MB
participant "코루틴" as Co
participant "렌더링" as Render

Loop -> MB : FixedUpdate()
note right of MB : 고정 간격이라\n한 프레임에 0~여러 번
Loop -> Phys : 물리 시뮬레이션
Loop -> Co : yield WaitForFixedUpdate 재개
Loop -> MB : Update()
Loop -> Co : yield null / WaitForSeconds 재개
Loop -> MB : LateUpdate()
Loop -> Render : 화면 그리기
Loop -> Co : yield WaitForEndOfFrame 재개
@enduml

정리하면 (Unity 공식 실행 순서 문서 기준):

yield 프레임 안 재개 위치
WaitForFixedUpdate 물리(FixedUpdate) 직후
null / WaitForSeconds / StartCoroutine 모든 Update() 이후, LateUpdate 이전
WaitForEndOfFrame 렌더링이 끝난 직후

RollUpdateyield return null은 "Update가 다 끝난 뒤"에 재개되고, 그게 다음 프레임으로 이어지므로 실무에서는 "한 프레임 쉰다" 로 이해하면 됩니다. MoveNext가 이 지점에서 불리는 것뿐, 지난 글에서 본 "멈췄다 재개" 원리는 그대로입니다.


4. 타이밍에서 오는 실전 포인트

이 "언제"를 알면 실무에서 흔한 패턴들이 이해됩니다.

① 스크린샷은 WaitForEndOfFrame 뒤에
화면이 다 그려진 뒤에 캡처해야 하므로 렌더 직후 시점이 필요합니다.

yield return new WaitForEndOfFrame();
var tex = ScreenCapture.CaptureScreenshotAsTexture();

WaitForSeconds는 timeScale 영향을 받습니다
Time.timeScale = 0(일시정지)이면 WaitForSeconds진행하지 않습니다. 일시정지 중에도 흘러야 하는 시간(UI 연출 등)은 WaitForSecondsRealtime을 씁니다.

③ 물리 관련은 WaitForFixedUpdate
Rigidbody를 코루틴에서 다룬다면, 물리 스텝에 맞춰 재개되는 이 yield가 자연스럽습니다.

④ 코루틴은 Update와 LateUpdate 사이
RollUpdatetransform.position을 바꾸면, 그 뒤 LateUpdate에서 도는 카메라 추적이 그 값을 반영합니다. 순서를 알면 "왜 한 프레임 늦게 반영되지?" 같은 문제를 짚을 수 있습니다.


5. RollUpdate는 DLL에서 어떻게 보이나

여기서부터가 이 글에서 제일 재미있는 부분입니다. 코루틴이 컴파일러가 만든 상태머신이라는 걸 지난 글에선 "개념"으로만 설명했는데, 이번엔 실제 DLL을 열어 눈으로 봅니다. 위에서 본 RollUpdate가 대상입니다.

5.1 소스에는 상태머신이 안 보인다

다시 보면, RollUpdate는 그냥 yield가 있는 평범한 함수처럼 보입니다.

private IEnumerator RollUpdate(float rollDistance)
{
    float currentRollTime = 0f;
    float prevRollDistance = 0f;
    // ... while (true) { ... yield return null; } ...
    IsRolling = false;
}

상태머신은 소스에 없습니다. 컴파일 후 DLL 안에 생겨납니다.

5.2 컴파일된 DLL 위치

Unity는 스크립트를 컴파일해서 이 파일에 넣습니다.

<프로젝트>/Library/ScriptAssemblies/Assembly-CSharp.dll

Library 폴더는 재생성되는 캐시라 git에는 올라가지 않습니다. Unity로 프로젝트를 한 번 열면(컴파일되면) 생깁니다.

5.3 디컴파일러 없이 빠르게 엿보기

디컴파일러를 설치하기 전에, 터미널에서 문자열만 뽑아봐도 상태머신 클래스가 실재한다는 걸 바로 볼 수 있습니다.

strings Assembly-CSharp.dll | grep 'd__'

그러면 아래 같은 이름이 나옵니다.

<RollUpdate>d__NN

RollUpdate 코루틴에 대해, 컴파일러가 <메서드이름>d__숫자라는 숨겨진 상태머신 클래스를 만들어 넣은 것입니다. (NN은 컴파일러가 붙이는 번호라 상황에 따라 다릅니다.)

5.4 디컴파일러로 내부까지 보기

구조까지 보려면 디컴파일러를 씁니다. 무료로 ILSpy, dnSpy, dotPeek(JetBrains) 중 아무거나 됩니다.

  1. 디컴파일러로 Library/ScriptAssemblies/Assembly-CSharp.dll을 엽니다.
  2. Character 클래스를 펼칩니다.
  3. [중요] ILSpy는 기본적으로 상태머신을 다시 yield 코드로 되돌려서 보여줍니다. 그래서 처음엔 소스와 똑같아 보입니다(왕복 변환이 된다는 증거이기도 합니다). 진짜 상태머신을 보려면 View → Options → Decompiler에서 "Decompile enumerators (yield return)" 체크를 끄세요.

  1. 그러면 RollUpdate 메서드가 이렇게, 상태머신 객체를 생성해 반환하는 코드로 바뀝니다.
[IteratorStateMachine(typeof(<RollUpdate>d__35))]
private IEnumerator RollUpdate(float rollDistance)
{
    return new <RollUpdate>d__35(0)
    {
        <>4__this = this,               // this 캡처
        rollDistance = rollDistance     // 파라미터를 필드로
    };
}

RollUpdate()를 불러도 몸통이 도는 게 아니라 상태머신 객체 하나가 만들어져 반환됩니다. 이제 그 생성된 클래스 <RollUpdate>d__35를 펼쳐 MoveNext()를 보면 실제 상태머신이 나옵니다. 아래는 ILSpy로 실제 디컴파일한 결과입니다.

[CompilerGenerated]
private sealed class <RollUpdate>d__35 : IEnumerator<object>, IEnumerator, IDisposable
{
    private int <>1__state;                    // 어디서 멈췄나 (재개 지점)
    private object <>2__current;               // yield한 값
    public Character <>4__this;                // 캡처된 this
    public float rollDistance;                 // 파라미터 → 필드
    private float <currentRollTime>5__2;       // 지역변수 → 필드 (스택 아님!)
    private float <prevRollDistance>5__3;      // 지역변수 → 필드

    object IEnumerator<object>.Current { [DebuggerHidden] get { return <>2__current; } }
    object IEnumerator.Current         { [DebuggerHidden] get { return <>2__current; } }

    [DebuggerHidden]
    public <RollUpdate>d__35(int <>1__state)
    {
        this.<>1__state = <>1__state;
    }

    [DebuggerHidden]
    void IDisposable.Dispose() { }             // try/finally를 쓰면 여기에 정리 코드가 들어감

    private bool MoveNext()
    {
        int num = <>1__state;
        Character character = <>4__this;
        if (num != 0)
        {
            if (num != 1) return false;        // 이미 끝났으면 더 진행 안 함
            <>1__state = -1;                   // (state 1) yield 다음 줄부터 재개
        }
        else
        {
            <>1__state = -1;                   // (state 0) 최초 진입 → 초기화
            <currentRollTime>5__2 = 0f;
            <prevRollDistance>5__3 = 0f;
        }
        <currentRollTime>5__2 += Time.deltaTime;
        float timePoint = <currentRollTime>5__2 / character.rollTime;
        float inOutSine = (0f - (Mathf.Cos(MathF.PI * timePoint) - 1f)) / 2f;
        float currentRollDistance = Mathf.Lerp(0f, rollDistance, inOutSine);
        float deltaValue = currentRollDistance - <prevRollDistance>5__3;
        Transform transform = ((Component)character).transform;
        transform.position += ((Component)character).transform.forward * deltaValue;
        <prevRollDistance>5__3 = currentRollDistance;
        if (!(<currentRollTime>5__2 >= character.rollTime))
        {
            <>2__current = null;               // ┐
            <>1__state = 1;                    // ├ 이 세 줄이 'yield return null'
            return true;                       // ┘  (값 내놓고, 재개지점 1 표시, 멈춤)
        }
        character.IsRolling = false;           // while 탈출 = 코루틴 끝
        return false;
    }

    bool IEnumerator.MoveNext() => this.MoveNext();

    [DebuggerHidden]
    void IEnumerator.Reset() { throw new NotSupportedException(); }
}

디컴파일러가 붙이는 //IL_... 경고 주석 몇 줄만 지웠고, 나머지는 실제 출력 그대로입니다.

여기서 이 글과 지난 글에서 말한 게 전부 실물로 확인됩니다.

  • 지역변수가 필드로 올라감: currentRollTime<currentRollTime>5__2. 그래서 MoveNext 호출을 넘어(=프레임을 넘어) 값이 유지됩니다.
  • 재개 지점을 기억: <>1__state(0=최초, 1=yield 뒤, -1=종료)로 "어디서 멈췄나"를 저장하고, MoveNext 앞부분의 if (num ...)가 그 지점부터 이어갑니다.
  • yield return null의 정체: <>2__current = null; <>1__state = 1; return true; 세 줄입니다. "값(null)을 내놓고, 다음 재개 지점을 1로 표시하고, 멈춘다(true)".
  • this 캡처: <>4__this로 원래 객체를 들고 있어 character.rollTime, character.IsRolling 등에 접근합니다.
  • IDisposable.Dispose(): 지금은 비어 있지만, 코루틴에 try/finally를 쓰면 여기에 정리 코드가 들어갑니다. StopCoroutine으로 중단될 때 Unity가 이 Dispose를 불러 finally가 실행되는 원리입니다.

지난 글에서 "개념 코드"로 그렸던 상태머신이, 실제 DLL 안에 이렇게 존재하는 것입니다.

5.5 직접 해보기

아무 Unity 프로젝트에서 위 RollUpdate 같은 코루틴을 하나 만들고, 아래 순서로 열어보면 그대로 확인됩니다.

  1. Unity로 프로젝트를 한 번 엽니다(컴파일 → DLL 생성).
  2. Library/ScriptAssemblies/Assembly-CSharp.dll을 ILSpy로 엽니다.
  3. ILSpy라면 먼저 View → Options → Decompiler에서 "Decompile enumerators (yield return)"를 끕니다(5.4 참고). 안 그러면 yield 코드로 되돌아가 보입니다.
  4. 코루틴이 있는 클래스 → <메서드이름>d__NNMoveNext()를 봅니다.
  5. 상태(state) 분기(if (num ...) 또는 switch)와 필드로 올라간 지역변수를 확인합니다.

6. 정리

  • yield return X = "여기서 멈추고 X가 정한 시점에 재개하라". X에 따라 재개 위치가 다릅니다.
  • 코루틴의 MoveNext는 Player Loop의 특정 지점에서 불립니다. null/WaitForSeconds는 Update 이후, WaitForFixedUpdate는 물리 이후, WaitForEndOfFrame은 렌더 이후.
  • 이 "언제"를 알면 스크린샷 캡처, timeScale, 물리 타이밍 같은 실전 문제가 풀립니다.
  • 코루틴이 컴파일러가 만든 상태머신이라는 사실은 관념이 아니라, DLL을 열면 <RollUpdate>d__NN 같은 클래스로 실제로 확인됩니다. 지역변수가 필드로 올라간 것도 거기서 보입니다.

지난 글이 "어떻게 멈췄다 재개되나(원리)"였다면, 이 글은 "언제 재개되나(시점)"와 "그 상태머신을 눈으로 확인하기"였습니다.