---
title: "UniTask 의 대기는 무엇을 세느냐만 다르다"
description: "UniTask 의 대기는 모두 매 프레임 한 번 아직인가를 묻고 무엇을 세는지만 다릅니다. 그래서 Yield 는 같은 프레임에 끝날 수 있고, Delay 는 일시정지에 멈추거나 조금 일찍 끝납니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-delay/
---
```csharp
spriteRenderer.enabled = false;
await UniTask.Yield();          // yield return null 대신? 아니면 NextFrame()?
spriteRenderer.enabled = true;
```

`yield return null` 을 옮기면 `Yield()` 와 `NextFrame()` 이 후보로 나옵니다. 이름은 비슷한데 하나는 같은 프레임에 끝날 수 있습니다. `Delay` 도 일시정지 메뉴에서 멈추고, 1초를 기다렸는데 조금 일찍 끝납니다.

UniTask 의 대기는 **모두 매 프레임 한 번 "아직인가" 를 묻고, 무엇을 세는지만 다릅니다.** API 는 이름이 아니라 세야 하는 것으로 고릅니다.

## 대기 객체의 MoveNext 는 뼈대가 같고 조건 한 줄만 다르다

`Yield()` 를 뺀 대기는 모두 풀에서 꺼낸 대기 객체가 러너에 올라가고, 러너가 프레임마다 `MoveNext()` 를 한 번 부릅니다. `true` 를 돌려주면 다음 프레임에 다시 불리고, `false` 를 돌려주면 러너에서 빠집니다. 여섯 대기 객체의 `MoveNext()` 는 뼈대가 같습니다.

```csharp
public bool MoveNext()
{
    // 1. 취소됐으면 취소로 끝낸다 (모두 같다)
    if (cancellationToken.IsCancellationRequested)
    {
        core.TrySetCanceled(cancellationToken);
        return false;
    }

    // 2. 아직이면 true 를 돌려준다 (이 조건만 서로 다르다)
    if (/* 아직인가 */)
        return true;

    // 3. 완료하고 러너에서 빠진다 (모두 같다)
    core.TrySetResult(...);
    return false;
}
```

다른 것은 2번 조건뿐입니다. 소스에서 그 줄만 옮기면 이렇습니다.

```csharp
// NextFrame(): 프레임 번호가 그대로면 아직이다
if (frameCount == Time.frameCount)
    return true;

// DelayFrame(n): 지나간 프레임 수가 n 에 못 미치면 아직이다
if (++currentFrameCount < delayFrameCount)
    return true;

// Delay(t): 더한 deltaTime 이 t 에 못 미치면 아직이다
elapsed += Time.deltaTime;
if (elapsed < delayTimeSpan)
    return true;

// Delay(t, DelayType.UnscaledDeltaTime): 더하는 값만 다르다
elapsed += Time.unscaledDeltaTime;
if (elapsed < delayTimeSpan)
    return true;

// Delay(t, DelayType.Realtime): 스톱워치가 t 에 못 미치면 아직이다
if (stopwatch.ElapsedTicks < delayTimeSpanTicks)
    return true;

// WaitUntil(f): f() 가 거짓이면 아직이다
if (!predicate())
    return true;
```

`DelayFrame` 과 `Delay` 는 소스에서 조건이 뒤집혀 있어(`>=` 이면 완료) 같은 모양으로 맞춰 옮겼습니다. 각 대기가 세는 것을 정리하면 이렇습니다.

| API | 프레임마다 보는 것 |
| --- | --- |
| `Yield()` | 없음. 그 자리가 오면 한 번 불림 |
| `NextFrame()` | 프레임 번호가 바뀌었나 |
| `DelayFrame(n)` | 지나간 프레임 수 |
| `Delay(t)` | `deltaTime` 누적 |
| `Delay(t, UnscaledDeltaTime)` | `unscaledDeltaTime` 누적 |
| `Delay(t, Realtime)` | 스톱워치 경과 시간 |
| `WaitUntil(f)` | `f()` 의 값 |

`Yield()` 만 대기 객체 없이 큐에 델리게이트를 넣습니다.

아래 절들은 이 조건 한 줄에서 따라 나오는 동작입니다.

## Yield 는 세지 않아서 같은 프레임에 끝날 수 있다

`Yield()` 는 "다음에 그 자리가 오면" 깨어날 뿐입니다. 그 자리가 이번 프레임에 아직 남아 있으면 이번 프레임에 깨어납니다. `NextFrame()` 은 만든 순간의 `frameCount` 를 적어 두고 **번호가 바뀐 뒤에만** 끝납니다.

한 프레임은 Update 단계, PostLateUpdate 단계 순서로 돌고, UniTask 의 큐와 러너는 각 단계의 앞머리에서 돕니다. `MonoBehaviour.Update()` 는 Update 단계의 큐가 돈 **뒤에** 불립니다. 프레임 10 의 `Update()` 에서 세 가지를 `await` 하면 이렇게 깨어납니다.

| `await` 한 것 | 호출한 곳 | 깨어난 곳 |
| --- | --- | --- |
| `Yield()` | 프레임 10, Update | **프레임 11**, Update |
| `Yield(PostLateUpdate)` | 프레임 10, Update | **프레임 10**, PostLateUpdate |
| `NextFrame(PostLateUpdate)` | 프레임 10, Update | **프레임 11**, PostLateUpdate |

- `Yield()` 는 Update 큐에 들어갑니다. 프레임 10 의 Update 큐는 이미 돌고 지나갔으니 프레임 11 에 불립니다.
- `Yield(PostLateUpdate)` 는 PostLateUpdate 큐에 들어갑니다. 그 자리가 프레임 10 에 아직 오지 않았으니 같은 프레임에 불립니다.
- `NextFrame(PostLateUpdate)` 는 프레임 번호 10 을 적어 둡니다. 프레임 10 의 PostLateUpdate 에서도 한 번 불리지만 번호가 같아서 넘어가고, 프레임 11 에 끝납니다.

`Update()` 에서 기본 인자로 부르면 둘 다 다음 프레임입니다. 코루틴에서 옮긴 코드가 대부분 문제없이 도는 이유입니다. 갈리는 것은 기다릴 자리가 이번 프레임에 남아 있을 때입니다. `FixedUpdate()` 안에서 기다려 봤습니다.

| `FixedUpdate()` 안에서 | 재개 |
| --- | --- |
| `await UniTask.Yield()` | 5회 모두 **같은 프레임**의 Update 타이밍 |
| `await UniTask.NextFrame()` | 5회 모두 다음 프레임 |

대신 `Yield()` 는 대기 객체를 쓰지 않아 쌉니다. 매 프레임 `await` 하는 루프 하나에 Mono 0.047 µs, IL2CPP 0.085 µs 로 `NextFrame()` 보다 약 4배, 2배 쌌습니다. 토큰을 받을 자리는 없어서, `Yield(ct)` 는 대기 객체를 꺼내 러너로 갑니다.

프레임이 바뀌어야 하면 `NextFrame()`, 그 자리까지 비켜 주면 되면 `Yield()` 입니다.

## Delay 는 시간을 더해서, 일시정지에 멈추고 조금 일찍 끝난다

### 세는 방법

`Delay` 는 러너가 부를 때마다 이번 프레임의 `deltaTime` 을 `elapsed` 에 더하고, 목표 시간을 넘으면 끝납니다. 만든 프레임은 세지 않습니다.

```csharp
public bool MoveNext()
{
    if (cancellationToken.IsCancellationRequested) { ... return false; }

    // 아직 아무것도 안 셌을 때만 프레임 번호를 본다
    if (elapsed == 0.0f)
    {
        if (initialFrame == Time.frameCount)
            return true;                   // 만든 프레임은 세지 않고 넘어간다
    }

    elapsed += Time.deltaTime;             // 이번 프레임의 시간을 더한다

    if (elapsed >= delayTimeSpan)          // 목표 시간을 넘었나
    {
        core.TrySetResult(null);           // 완료
        return false;                      // 러너에서 빠진다
    }

    return true;                           // 다음 프레임에 다시 불린다
}
```

`Delay(100)` 을 프레임 10 에 만들었고 한 프레임이 0.04초라면 이렇게 셉니다.

| 프레임 | 하는 일 | `elapsed` |
| --- | --- | --- |
| 10 | 만든 프레임이라 세지 않음 | 0 |
| 11 | 0.04 를 더함 | 0.04 |
| 12 | 0.04 를 더함 | 0.08 |
| 13 | 0.04 를 더함. 0.1 을 넘어 완료 | 0.12 |

이 세는 방법에서 두 가지 동작이 따라 나옵니다.

### 일시정지에 멈춘다

`Time.timeScale` 이 0 이면 `Time.deltaTime` 도 0 입니다. 매 프레임 0 을 더하니 `elapsed` 가 움직이지 않고, `timeScale` 이 돌아올 때까지 끝나지 않습니다.

일시정지에도 흘러야 하면 `DelayType` 을 넘깁니다.

- `DelayType.UnscaledDeltaTime` 은 더하는 값만 `Time.unscaledDeltaTime` 으로 바뀝니다. `timeScale` 과 상관없이 흐릅니다.
- `DelayType.Realtime` 은 더하지 않고 스톱워치를 읽습니다. 이것도 `timeScale` 과 상관없이 흐릅니다.

`Delay(ms, ignoreTimeScale: true)` 는 `UnscaledDeltaTime` 입니다. `Realtime` 이 아닙니다.

### 조금 일찍 끝난다

처음 더하는 `deltaTime` 은 만든 프레임이 **시작한 시각부터** 잰 간격입니다. 그래서 프레임이 시작하고 `Delay` 를 만들기까지 흐른 시간이 `elapsed` 에 함께 들어가고, 그만큼 일찍 끝납니다.

프레임 앞부분에서 5 ms 를 쓴 뒤에 `Delay` 를 만들어 쟀습니다. `deltaTime` 의 합이 벽시계보다 5.0~5.1 ms 앞섰고, 작업이 없을 때는 0.1 ms 안쪽이었습니다. 쌓이는 오차가 아니라 시작할 때 한 번 생기는 차이입니다. `float` 로 더해서 생기는 오차는 0.000 ms 였습니다.

### Realtime 은 최대 한 프레임 늦게 끝난다

`Realtime` 은 스톱워치로 재서 위의 차이가 없습니다. 대신 확인이 프레임마다 한 번이라, 목표 시간이 지난 뒤 처음 오는 프레임에 끝납니다.

1초 `Delay` 가 끝나는 시각을 벽시계로 쟀습니다(창 모드 60 fps).

| | Mono | IL2CPP |
| --- | --- | --- |
| `DeltaTime` | 2.23 ms 일찍 | 0.83 ms 일찍 |
| `Realtime` | 14.65 ms 늦게 | 15.87 ms 늦게 |

### DelayType 고르기

| `DelayType` | 일시정지 중 | 끝나는 시각 |
| --- | --- | --- |
| `DeltaTime` (기본) | 멈춘다 | 조금 일찍 |
| `UnscaledDeltaTime` | 흐른다 | 조금 일찍 |
| `Realtime` | 흐른다 | 최대 한 프레임 늦게 |

에디터에서 플레이 모드가 아닐 때는 어떤 `DelayType` 을 넘겨도 `Realtime` 으로 돕니다.

## DelayFrame 은 만든 프레임을 건너뛰고, WaitUntil 은 건너뛰지 않는다

### DelayFrame

`DelayFrame(n)` 은 `Delay` 처럼 만든 프레임을 세지 않고, 그다음 프레임부터 한 프레임에 하나씩 셉니다. 프레임 10 에 `DelayFrame(3)` 을 만들면 이렇습니다.

| 프레임 | 센 수 |
| --- | --- |
| 10 | 만든 프레임이라 세지 않음 |
| 11 | 1 |
| 12 | 2 |
| 13 | 3. 완료 |

`DelayFrame(0)` 은 러너가 처음 부를 때 바로 끝납니다. `Yield()` 와 같은 때 깨어납니다.

### WaitUntil

`WaitUntil(f)` 는 러너가 부를 때마다 `f()` 를 부르고, 참이면 끝납니다. 만든 프레임을 건너뛰지 않습니다.

- 조건이 이미 참이면 러너가 처음 부를 때 끝납니다. 그 러너의 자리가 이번 프레임에 남아 있으면 같은 프레임에 끝납니다.
- `f()` 가 예외를 던지면 그 예외로 끝납니다.

## 주의할 점

### 1. 프레임을 세야 하는 곳에 Yield 를 쓰지 않는다

**안 지키면 생기는 문제.** `Yield(PlayerLoopTiming.PostLateUpdate)` 를 `Update()` 에서, 또는 `Yield()` 를 `FixedUpdate()` 에서 기다리면 같은 프레임에 깨어납니다. "한 프레임 뒤에 다시 읽는다" 고 짠 코드가 아직 갱신되지 않은 값을 읽습니다.

**이렇게 쓴다.** 프레임이 바뀌어야 하는 곳은 `NextFrame()` 이나 `DelayFrame(n)` 을 씁니다.

### 2. 일시정지 중에도 흘러야 하는 대기에는 DelayType 을 넘긴다

**안 지키면 생기는 문제.** 기본 `DeltaTime` 은 `timeScale = 0` 에서 끝나지 않습니다. 일시정지 메뉴의 페이드나 팝업 타이머가 멈춥니다.

**이렇게 쓴다.** 일시정지 UI 는 `DelayType.UnscaledDeltaTime`, 네트워크 타임아웃처럼 벽시계가 기준인 것은 `DelayType.Realtime` 을 넘깁니다.

### 3. Delay 를 미리 만들어 두지 않는다

**안 지키면 생기는 문제.** 대기 객체는 만드는 순간 러너에 올라가 세기 시작합니다.

```csharp
var delay = UniTask.Delay(1000);   // 이 순간 세기 시작한다
await LoadAsync();                 // 0.6초가 걸렸다면
await delay;                       // 남은 0.4초만 기다린다
```

**이렇게 쓴다.** 기다릴 자리에서 `await UniTask.Delay(...)` 로 만들고 바로 기다립니다.

## 정리

UniTask 의 대기는 **매 프레임 한 번 묻고 무엇을 세는지만 다르므로**, 세야 하는 것으로 고릅니다.

- `Yield()` 는 아무것도 세지 않아 같은 프레임에 끝날 수 있고, `NextFrame()` 은 `frameCount` 가 바뀐 뒤에만 끝납니다.
- `Delay` 의 기본 `DeltaTime` 은 일시정지에 멈추고 조금 일찍 끝나며, `Realtime` 은 최대 한 프레임 늦게 끝납니다.
- `DelayFrame` 은 만든 프레임을 건너뛰고 세며, `WaitUntil` 은 건너뛰지 않고 조건식을 부릅니다.