---
title: "UniTask 는 깨운 쪽 스레드에서 이어진다"
description: "UniTask 에는 원래 스레드로 되돌려 주는 장치가 없어, await 다음 줄은 완료를 알린 쪽 스레드에서 돕니다. 메인으로 돌아갈 자리는 예외 경로까지 코드에 직접 적고, 코루틴을 await 하면 UniTask 가 깨우는 쪽이 됩니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-thread/
---
```csharp
async UniTask<Mesh> BuildMeshAsync(CancellationToken ct)
{
    await UniTask.SwitchToThreadPool();      // 여기서부터 워커 스레드
    var verts = HeavyCalculation();
    await UniTask.SwitchToMainThread(ct);    // 여기서부터 다시 메인 스레드

    var mesh = new Mesh();
    mesh.SetVertices(verts);
    return mesh;
}
```

`Task` 는 await 가 끝나면 유니티의 동기화 컨텍스트가 메인 스레드로 돌려줍니다. UniTask 로 옮기면서 세 번째 줄을 빼먹으면 `new Mesh()` 가 워커 스레드에서 불립니다.

**UniTask 에는 되돌려 주는 장치가 없어서, await 다음 줄은 완료를 알린 쪽이 부르는 스레드에서 돕니다.** 메인으로 돌아갈 자리는 코드에 직접 적고, 예외로 빠져나가는 길에도 적어야 합니다.

## 깨운 쪽이 스레드를 정한다

await 는 아직 안 끝났으면 이어서 할 일(continuation)을 awaiter 에게 넘기고 빠져나옵니다. awaiter 가 그것을 **어느 스레드에서 부르느냐**가 곧 await 다음 줄의 스레드입니다. 이미 끝났으면(`IsCompleted` 가 `true`) 넘기지 않고 그 자리에서 이어 갑니다.

스레드를 옮기는 awaitable 은 이 두 지점만 바꿉니다. 모두 구조체라 할당이 없습니다.

| Awaitable | `IsCompleted` | continuation 을 넘기는 곳 |
| --- | --- | --- |
| `SwitchToMainThread` | **지금 메인이면 `true`** | 지정한 PlayerLoop 타이밍의 큐 |
| `SwitchToThreadPool` | 항상 `false` | `ThreadPool.UnsafeQueueUserWorkItem` |
| `SwitchToSynchronizationContext` | 항상 `false` | 지정한 컨텍스트의 `Post` |

`SwitchToMainThread` 는 이미 메인이면 await 자체가 사라져, 습관처럼 넣어도 손해가 없습니다. 대신 그때는 **타이밍 인자도 무시됩니다.** `SwitchToThreadPool` 은 이미 워커여도 한 번 더 큐에 넣고, 취소 토큰을 받지 않습니다.

## 예외로 빠져나가는 길에도 돌아올 자리를 적는다

맨 위 코드를 따라갑니다. 메인 스레드를 1, 워커를 6 으로 잡았습니다.

```csharp
// SwitchToThreadPoolAwaitable.Awaiter
public bool IsCompleted => false;                      // 항상 false. 반드시 넘어간다

public void UnsafeOnCompleted(Action continuation)
    => ThreadPool.UnsafeQueueUserWorkItem(switchToCallback, continuation);   // 이어질 코드를 스레드 풀 큐에 넣는다

static void Callback(object state) => ((Action)state)();   // 워커가 이어질 코드를 부른다

// SwitchToMainThreadAwaitable.Awaiter
public bool IsCompleted => PlayerLoopHelper.MainThreadId == Thread.CurrentThread.ManagedThreadId;   // 이미 메인이면 넘어가지 않는다

public void UnsafeOnCompleted(Action continuation)
    => PlayerLoopHelper.AddContinuation(playerLoopTiming, continuation);     // 이어질 코드를 그 타이밍의 큐에 넣는다

public void GetResult() { cancellationToken.ThrowIfCancellationRequested(); }

// AsyncUniTaskMethodBuilder<T>
public void SetException(Exception exception)
{
    if (runnerPromise == null)
        ex = exception;
    else
        runnerPromise.SetException(exception);         // 기다리던 호출자를 그 자리에서 깨운다
}
```

정상 경로는 이렇게 갑니다.

1. 메인(스레드 1)에서 `SwitchToThreadPool` 을 `await` 합니다. 이어질 코드가 스레드 풀 큐에 들어가고 메서드는 빠져나갑니다.
2. 워커(스레드 6)가 이어질 코드를 부릅니다. 깨운 쪽이 워커이니 다음 줄도 워커에서 돕니다. 메인은 그동안 자기 프레임을 계속 돕니다.
3. 워커에서 `SwitchToMainThread` 를 `await` 합니다. 지금 스레드가 메인이 아니라 이어질 코드가 Update 큐에 들어가고 워커는 빠져나갑니다.
4. 다음 `Update` 에서 PlayerLoop 가 큐를 비우며 이어질 코드를 부릅니다. 깨운 쪽이 메인이니 여기부터 유니티 API 를 불러도 됩니다.

워커 구간에서 예외가 나면 3 번에 닿지 못합니다. 상태 머신이 예외를 잡아 `SetException` 을 부르고, 러너가 기다리던 호출자를 그 자리에서 깨웁니다. 깨우는 스레드가 워커이니 호출자의 `catch` 도 워커에서 돕니다.

완료 원천은 완료 신호를 받는 그 자리에서 continuation 을 부릅니다. 그래서 워커에서 난 예외는 호출자를 워커에서 깨웁니다.

개발 빌드 플레이어에서 재 보니 메인이 1 번일 때 호출자의 catch 가 Mono 9 번, IL2CPP 10 번 스레드에서 돌았습니다. 에디터에서는 1 번이었습니다. 워커가 너무 빨리 던져 호출자가 await 할 때 이미 끝나 있었기 때문입니다. **catch 의 스레드는 워커가 언제 던지느냐에 달려 있습니다.**

어느 쪽이든 되도록 예외 경로에도 복귀를 적습니다. 가장 짧은 방법이 `ReturnToMainThread` 입니다.

```csharp
await using (UniTask.ReturnToMainThread())
{
    await UniTask.SwitchToThreadPool();
    HeavyWork();
    if (failed) return;       // 어디로 빠져나가든
}                             // 블록 끝에서 메인으로 돌아온다
```

블록 안은 워커, 블록 뒤와 블록 안에서 `return` 한 호출자는 메인(1)에서 이어졌습니다. 일반 `using` 은 `CS1674` 로 컴파일되지 않습니다.

워커에서 `SwitchToMainThread` 로 돌아오는 데는 60 fps 플레이어에서 평균 16.3~16.6 ms, 곧 **정확히 한 프레임**이 걸렸습니다. 다음에 오는 그 타이밍의 큐를 기다리기 때문입니다.

## 유니티 비동기 작업은 이미 메인에서 깨운다

`Resources.LoadAsync`, `SceneManager.LoadSceneAsync` 같은 작업은 유니티 엔진이 메인 스레드에서 끝을 알립니다. 돌아올 자리를 적을 필요가 없습니다.

```csharp
await op;                          // completed 이벤트만. 취소 불가, WhenAll 에 못 넣음
await op.WithCancellation(ct);     // 이벤트 + PlayerLoop 폴링. 취소 가능
await op.ToUniTask(progress, timing, ct);
```

고르는 기준은 스레드가 아니라 취소와 조합입니다. `Resources.LoadAsync` 1회당 할당은 세 방식 모두 UniTask 가 더한 몫이 0 이었습니다.

## 코루틴을 await 하면 UniTask 가 깨우는 쪽이 된다

`await SomeCoroutine()` 은 `StartCoroutine` 을 부르지 않습니다. UniTask 가 PlayerLoop 에서 직접 `MoveNext()` 를 돌리고, `yield return` 값을 스스로 해석합니다. 그래서 **언제 깨우는지도 UniTask 의 해석을 따릅니다.**

```csharp
while (enumerator.MoveNext())
{
    var current = enumerator.Current;       // 코루틴이 yield return 한 값

    if (current == null)
    {
        yield return null;                  // 한 프레임 쉰다
    }
    else if (current is CustomYieldInstruction cyi)
    {
        while (cyi.keepWaiting)
            yield return null;
    }
    else if (current is WaitForSeconds wfs)
    {
        // 초를 읽는 공개 API 가 없어 private 필드 m_Seconds 를 리플렉션으로 꺼낸다
        // 매 프레임 deltaTime 을 더하다가 그 초를 넘으면 빠져나온다
    }
    else if (current is AsyncOperation ao)
    {
        // isDone 이 될 때까지 yield return null
    }
    else if (current is IEnumerator e)
    {
        // 같은 해석을 재귀로
    }
    else
    {
        // 모르는 타입. 경고를 남기고 한 프레임만 쉰다
        Debug.LogWarning("... is not supported ...");
        yield return null;
    }
}
```

`WaitForSeconds(0.2f)` 는 아는 타입이라 원래와 같은 때 깨어납니다. `WaitForEndOfFrame` 은 어느 분기에도 걸리지 않아 마지막 `else` 로 갑니다. 예외는 없고 경고가 한 번 남으며, 프레임 끝이 아니라 다음 프레임 `Update` 에서 깨어납니다. 타이밍이 조용히 틀어집니다.

모르는 타입은 경고만 남기고 다음 프레임 `Update` 로 넘깁니다. `ToUniTask(this)` 를 쓰면 진짜 코루틴 엔진이 돌리므로 모든 `YieldInstruction` 이 원래대로 동작합니다.

얻는 것도 있습니다. UniTask 가 `MoveNext()` 를 `try` 로 감싸 부르므로, 코루틴 안의 예외가 **await 한 쪽의 `try/catch` 로 올라옵니다.** 반대 방향 `uniTask.ToCoroutine()` 은 `StartCoroutine` 에 넘길 `IEnumerator` 를 만듭니다.

## 주의할 점

### 1. 워커 구간은 예외가 나도 메인으로 돌아오게 짠다

**안 지키면 생기는 문제.** 워커 구간에서 예외가 나면 호출자의 catch 가 워커에서 돌 수 있습니다. 거기서 오류 UI 를 띄우면 유니티 API 를 워커에서 부르게 됩니다.

**이렇게 쓴다.** 워커 구간을 `await using (UniTask.ReturnToMainThread())` 로 감싸거나 `finally { await UniTask.SwitchToMainThread(); }` 를 둡니다.

### 2. 타이밍을 미루려면 SwitchToMainThread 가 아니라 Yield 를 쓴다

**안 지키면 생기는 문제.** 메인에서 `SwitchToMainThread(PlayerLoopTiming.LastPostLateUpdate)` 를 부르면 `IsCompleted` 가 `true` 라 그 자리에서 진행합니다. 프레임 끝까지 미룬다는 의도가 조용히 무시됩니다.

**이렇게 쓴다.** 스레드 복귀는 `SwitchToMainThread`, 타이밍 이동은 `await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate)` 입니다. `Yield` 는 메인에서도 항상 큐에 넣습니다.

### 3. WaitForEndOfFrame 이 든 코루틴은 ToUniTask(this) 로 돌린다

**안 지키면 생기는 문제.** `await OldCoroutine()` 은 `WaitForEndOfFrame`, `WaitForFixedUpdate`, 중첩 `Coroutine` 을 해석하지 못해 다음 프레임 `Update` 로 넘깁니다. 스크린샷 캡처나 물리 동기화 코드가 예외 없이 틀린 타이밍에 돕니다.

**이렇게 쓴다.** 그런 코루틴은 `await OldCoroutine().ToUniTask(this)` 로 돌립니다. 콘솔에 `is not supported on await IEnumerator` 경고가 없는지 확인합니다.

## 정리

UniTask 는 **await 다음 줄을 깨운 쪽 스레드에서 이어 갑니다.** 메인으로 돌아갈 자리는 코드에 직접 적습니다.

- `SwitchToMainThread` 는 이미 메인이면 사라지고 타이밍 인자도 무시됩니다. 워커에서는 한 프레임 뒤에 돌아옵니다.
- 워커에서 난 예외는 호출자를 워커에서 깨울 수 있어, `await using (UniTask.ReturnToMainThread())` 로 복귀를 보장합니다.
- 코루틴을 await 하면 UniTask 가 `yield` 값을 해석해, 모르는 타입은 다음 프레임 `Update` 로 바뀝니다.