---
title: "UniTask 는 PlayerLoop 에서 await 를 이어 준다"
description: "UniTask 는 첫 씬 전에 PlayerLoop 단계마다 맨 앞과 맨 뒤, 열여섯 자리에 큐와 러너를 꽂습니다. await 뒤 코드는 그 자리에서 메인 스레드로 이어지고, 언제 깨어나는지는 고른 타이밍이 정합니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-playerloop/
---
```csharp
async void Awake()
{
    await UniTask.Yield();      // 누가, 언제 이어 주나?
    await UniTask.Delay(500);
}
```

코루틴은 `MonoBehaviour` 가 돌리고, `Task` 는 스레드 풀이나 동기화 컨텍스트가 이어 줍니다. UniTask 에는 숨은 게임오브젝트도 전용 스레드도 없는데, `await` 뒤는 누가 언제 이어 줄까요.

UniTask 는 **첫 씬 전에 PlayerLoop 단계마다 맨 앞과 맨 뒤, 열여섯 자리에 큐와 러너를 꽂고, `await` 뒤 코드는 그 자리에서 메인 스레드로 이어집니다.**

## 첫 씬 전에 열여섯 자리를 잡는다

```csharp
// Runtime/PlayerLoopHelper.cs (줄임)
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]   // 2020.1 이상
static void Init()
{
    var playerLoop = PlayerLoop.GetCurrentPlayerLoop();
    Initialize(ref playerLoop);    // 단계마다 앞뒤로 끼워 넣는다
}
```

`AfterAssembliesLoaded` 는 첫 씬을 불러오기 전입니다. 그래서 어떤 `Awake` 에서 `await` 해도 받아 줄 자리가 이미 있습니다.

자리는 최상위 단계 여덟 개의 맨 앞과 맨 뒤(`Last` 가 붙은 타이밍), 모두 열여섯입니다. 자리마다 둘을 꽂습니다. 한 번 깨우면 되는 일을 담는 **큐**(`ContinuationQueue`)와 매 프레임 물어야 하는 일을 담는 **러너**(`PlayerLoopRunner`)입니다. 같은 자리에서는 큐, 러너 순으로 돌고, 앞 자리는 둘 다 `MonoBehaviour.Update` 보다 먼저입니다.

Mono 개발 빌드 플레이어의 PlayerLoop 를 덤프했습니다. 숫자는 그 단계 하위 시스템 목록에서의 위치(0 부터)입니다.

| 프레임 안 순서 | 단계 | 하위 수 | 앞 (Yield · Runner) | 뒤 (LastYield · Last) |
| --- | --- | --- | --- | --- |
| 0 | `TimeUpdate` | 7 | 0 · 1 | 5 · 6 |
| 1 | `Initialization` | 15 | 1 · 2 | 12 · 13 |
| 2 | `EarlyUpdate` | 43 | 0 · 1 | 40 · 41 |
| 3 | `FixedUpdate` | 23 | 0 · 1 | 20 · 21 |
| 4 | `PreUpdate` | 18 | 0 · 1 | 16 · 17 |
| 5 | `Update` | 13 | 0 · 1 | 10 · 11 |
| 6 | `PreLateUpdate` | 22 | 0 · 1 | 19 · 20 |
| 7 | `PostLateUpdate` | 56 | 0 · 1 | 54 · 55 |

- `PlayerLoopTiming` 번호는 실행 순서가 아닙니다. 번호가 14, 15 로 가장 큰 `TimeUpdate` 가 가장 먼저 돕니다.
- "맨 앞" 은 끼워 넣는 순간의 이야기입니다. 이 프로젝트에서는 나중에 끼어든 다른 패키지가 `Initialization` 의 0 번을 차지했습니다.

`Update()` 안에서 기본 타이밍으로 `await` 하면, 이번 프레임의 `Update` 자리는 이미 지나갔으니 다음 프레임에 이어집니다.

## Yield 는 큐가 프레임당 한 번 부른다

`await UniTask.Yield()` 는 이어질 델리게이트를 그 타이밍의 큐에 넣을 뿐입니다. 큐는 배열을 두 개 둡니다. 비우는 동안 들어온 것은 다른 배열로 받아 두었다가 다음 프레임에 부릅니다. `while (true) await UniTask.Yield();` 루프 P, Q 가 한 프레임을 지나는 모습입니다.

넣을 때는 큐가 비우는 중인지를 보고 배열을 고릅니다.

```csharp
// Enqueue(cont)
if (dequing)
    waitingList[waitingListCount++] = cont;   // 비우는 중이면 대기 배열로
else
    actionList[actionListCount++] = cont;
```

비울 때는 `actionList` 만 돌고, 다 돌면 두 배열을 맞바꿉니다.

```csharp
// RunCore(): 프레임마다 이 타이밍에서 한 번
if (actionListCount == 0)
    return;

dequing = true;

for (int i = 0; i < actionListCount; i++)
{
    var action = actionList[i];
    actionList[i] = null;

    try
    {
        action();                             // 깨어난 루프가 다시 Yield 를 부르면 waitingList 로 간다
    }
    catch (Exception ex)
    {
        Debug.LogException(ex);
    }
}

dequing = false;

(actionList, waitingList) = (waitingList, actionList);   // 복사 없이 두 배열을 맞바꾼다
actionListCount = waitingListCount;
waitingListCount = 0;
```

`actionList` 에 P, Q 가 들어 있는 프레임은 이렇게 돕니다.

1. `dequing` 을 켭니다. 이제부터 들어오는 것은 `waitingList` 로 갑니다.
2. P 를 부릅니다. P 는 한 바퀴 돌고 다시 `Yield()` 를 불러 `waitingList` 에 들어갑니다. 같은 프레임에 또 불리지 않습니다.
3. Q 도 같습니다. `actionList` 는 비고 `waitingList` 는 `[P, Q]` 가 됩니다.
4. `dequing` 을 끄고 두 배열을 맞바꿉니다. `actionList` 가 다시 `[P, Q]` 가 되어 다음 프레임에 한 번씩 불립니다.

배열이 하나였다면 P 가 넣은 자기 자신을 같은 `for` 가 또 만나 한 프레임 안에서 끝없이 돕니다.

그래서 `Yield` 루프는 정확히 프레임당 한 번 돕니다. 하나가 예외를 던져도 로그만 남기고 다음으로 넘어갑니다. 항목 하나를 깨우는 값은 개발 빌드 플레이어에서 Mono 약 0.036 µs, IL2CPP 약 0.076 µs 였습니다.

## 러너는 매 프레임 묻고, 순서는 지키지 않는다

`Delay`, `WaitUntil` 처럼 "아직인가" 를 물어야 하는 대기 객체는 러너에 들어갑니다. 러너는 프레임마다 항목마다 `MoveNext()` 를 한 번 부르고, 끝난(`false`) 자리는 맨 뒤 항목을 당겨 메웁니다. 배열 복사가 없는 대신 순서가 섞입니다.

```csharp
// PlayerLoopRunner.RunCore (줄인 코드)
var j = tail - 1;                                  // j 는 맨 뒤 항목을 가리킨다

for (int i = 0; i < loopItems.Length; i++)         // i 는 앞에서부터 간다
{
    if (loopItems[i] != null)
    {
        if (loopItems[i].MoveNext())
            continue;                              // 아직 안 끝났다. 그대로 두고 다음 칸으로

        loopItems[i] = null;                       // 끝났다. i 번 칸이 구멍이 된다
    }

    // i 번 칸이 구멍이다. 맨 뒤에서 살아 있는 항목을 찾아 메운다
    while (i < j)
    {
        var fromTail = loopItems[j];

        if (fromTail != null && fromTail.MoveNext())   // 뒤 항목의 이번 프레임 MoveNext 를 여기서 부른다
        {
            loopItems[i] = fromTail;               // 아직 안 끝났으면 구멍으로 옮긴다
            loopItems[j--] = null;
            goto NEXT;
        }

        loopItems[j--] = null;                     // 뒤 항목도 끝났으면 버리고 그 앞을 본다
    }

    tail = i;                                      // i 와 j 가 만났다. 살아 있는 것은 i 앞까지다
    break;

    NEXT: ;
}
```

`[A, B, C, D, E]` 다섯이 들어 있고 이번 프레임에 B, D 가 끝난다고 하면 이렇게 돕니다.

1. A 는 아직이라 그대로 둡니다.
2. B 가 끝나 1 번 칸이 구멍이 됩니다. 맨 뒤 E 의 `MoveNext()` 를 부르고, E 가 아직이라 1 번 칸으로 옮깁니다. 배열은 `[A, E, C, D, _]` 가 됩니다.
3. C 는 아직이라 그대로 둡니다.
4. D 가 끝나 3 번 칸이 구멍이 됩니다. 뒤에 남은 항목이 없어 `tail` 을 3 으로 줄이고 끝냅니다. 배열은 `[A, E, C]` 가 됩니다.

다섯 모두 `MoveNext()` 가 한 번씩 불렸고, 배열을 당겨 복사하지 않았습니다. 대신 E 가 C 앞으로 왔습니다.

다음 프레임에 A, C, E 가 함께 끝나게 했더니 이어지는 코드는 A, C, E 순서였습니다. 뒤에서부터 메우며 한 번 더 뒤집힌 우연이라, 끝나는 프레임이 조금만 달라져도 바뀝니다. **같은 자리에서 깨어나는 작업들의 순서는 정해져 있지 않습니다.**

## 에디터에서는 플레이마다 러너를 새로 만든다

도메인 리로드를 끄면 정적 필드가 플레이 사이에 남습니다. UniTask 는 이때 플레이에 들어갈 때마다 러너와 큐를 새로 만들어 다시 꽂습니다. 꽂기 전에 같은 타입을 걷어 내므로 두 벌이 되지 않습니다.

<x-compare>
<script type="application/json">
{
  "label": "도메인 리로드를 끄고 플레이를 두 번 했을 때",
  "tabs": [
    { "label": "1회차", "text": "2초짜리 Delay 하나와 끝없이 Yield 하는 루프 하나를 걸어 둔 채, Delay 가 끝나기 전에 플레이를 멈춘다. DelayPromise 풀에는 50 개가 들어 있다.", "stats": [["걸어 둔 Delay", "대기 중 1"], ["DelayPromise 풀", "50"]] },
    { "label": "2회차", "text": "다시 플레이한다. 러너 배열이 새것으로 바뀌어 있고, 1회차의 Delay 와 Yield 루프는 3초를 기다려도 이어지지 않는다. 반면 풀은 정적 필드라서 그대로 남아 있다.", "stats": [["러너 배열", "새로 만들어짐"], ["1회차 Delay 가 이어짐", "0 건"], ["1회차 Yield 루프", "멈춤"], ["DelayPromise 풀", "50 (남음)"]], "tone": "good" }
  ]
}
</script>
</x-compare>

끊기는 것은 러너에 걸린 대기 작업까지입니다. 풀 같은 다른 정적 상태와 사용자 코드의 정적 필드는 다음 플레이로 넘어갑니다.

플레이하지 않을 때는 에디터 갱신(`EditorApplication.update`)이 열여섯 자리의 큐와 러너를 한꺼번에 돌립니다. 타이밍 구분이 사라지고, `Delay` 는 `DelayType.Realtime` 으로 바뀝니다.

## 주의할 점

### 1. 같은 프레임에 끝나는 작업의 순서에 기대지 않는다

**안 지키면 생기는 문제.** 러너가 끝난 자리를 뒤의 항목으로 메워서, A, B 순서로 건 `Delay` 가 같은 프레임에 끝나면 B 가 먼저 이어질 수 있습니다. 순서에 기대는 초기화 코드가 가끔 틀립니다.

**이렇게 쓴다.** 순서가 필요하면 한 `async` 메서드 안에서 차례로 `await` 합니다.

### 2. 직접 만든 IPlayerLoopItem 의 MoveNext 는 던지지 않게 한다

**안 지키면 생기는 문제.** 러너는 `MoveNext()` 에서 난 예외를 로그로만 남기고 그 항목을 뺍니다. 완료가 불리지 않았으니 그것을 기다리던 `await` 는 영영 깨어나지 않습니다.

**이렇게 쓴다.** `MoveNext()` 본문을 `try`/`catch` 로 감싸고, 잡은 예외는 `core.TrySetException(ex)` 로 넘긴 뒤 `false` 를 돌려줍니다.

### 3. 삽입 지점을 줄였으면 빠진 타이밍을 쓰지 않는다

**안 지키면 생기는 문제.** 자리를 줄이려면 직접 다시 초기화합니다.

```csharp
var loop = PlayerLoop.GetCurrentPlayerLoop();
PlayerLoopHelper.Initialize(ref loop, InjectPlayerLoopTimings.Minimum);   // Update | FixedUpdate | LastPostLateUpdate
```

빠진 타이밍을 누가 쓰면(`UniTask.Yield(PlayerLoopTiming.PreLateUpdate)` 등) `Target playerLoopTiming is not injected` 예외로 멈춥니다. 아끼는 양은 대기 작업이 없는 프레임에서 개발 빌드 플레이어 Mono 7.0 µs, IL2CPP 3.7 µs 뿐입니다.

**이렇게 쓴다.** 기본값 `All` 로 둡니다. 줄일 때는 프로젝트 전체에서 쓰는 타이밍을 찾아본 뒤 그것이 든 조합을 고릅니다.

## 정리

UniTask 의 `await` 뒤 코드는 **PlayerLoop 열여섯 자리에 꽂아 둔 큐와 러너가 메인 스레드에서 이어 주고**, 언제 깨어나는지는 고른 타이밍이 정합니다.

- 자리 잡기는 첫 씬보다 먼저라서 `Awake` 에서도 바로 `await` 할 수 있습니다.
- 큐는 `Yield` 를 프레임당 한 번 부르고, 러너는 매 프레임 `MoveNext()` 를 묻되 순서는 지키지 않습니다.
- 도메인 리로드를 끄면 플레이마다 러너를 새로 만들고, 에디트 모드에서는 에디터 갱신이 전부를 한꺼번에 돌립니다.