UniTask 의 대기는 무엇을 세느냐만 다르다
Esc

    UniTask 의 대기는 무엇을 세느냐만 다르다

    2026년 10월 4일 수정–Unity라이브러리동시성
    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() 는 뼈대가 같습니다.

    public bool MoveNext()
    {
        // 1. 취소됐으면 취소로 끝낸다 (모두 같다)
        if (cancellationToken.IsCancellationRequested)
        {
            core.TrySetCanceled(cancellationToken);
            return false;
        }
    
        // 2. 아직이면 true 를 돌려준다 (이 조건만 서로 다르다)
        if (/* 아직인가 */)
            return true;
    
        // 3. 완료하고 러너에서 빠진다 (모두 같다)
        core.TrySetResult(...);
        return false;
    }

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

    // 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 에 더하고, 목표 시간을 넘으면 끝납니다. 만든 프레임은 세지 않습니다.

    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 를 미리 만들어 두지 않는다

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

    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 은 건너뛰지 않고 조건식을 부릅니다.