UniTask 스트림은 당길 때만 값을 준다
Esc

    UniTask 스트림은 당길 때만 값을 준다

    2026년 10월 4일 수정–Unity라이브러리동시성
    var ct = this.GetCancellationTokenOnDestroy();
    await foreach (var _ in button.OnClickAsAsyncEnumerable().WithCancellation(ct))
    {
        await SaveAsync(ct);   // 3초. 그동안 누른 클릭은 받지 않는다
    }

    onClick.AddListener 로 짜면 저장 중 연타를 막으려고 isSaving 같은 플래그를 세워야 합니다. 이벤트는 받는 쪽 사정을 모르고 밀어 넣기 때문입니다.

    UniTask 스트림은 소비자가 당길 때만 값을 줍니다. 당기지 않는 동안 온 값은 쌓이지 않고 버려지므로 플래그가 필요 없습니다.

    소비자가 당길 때만 값이 간다

    await foreach 는 while (await e.MoveNextAsync()) 로 바뀝니다. 본문이 도는 동안에는 MoveNextAsync() 가 불리지 않습니다. 이것이 “당길 때만” 의 실체입니다.

    EveryUpdate 열거자로 봅니다. PlayerLoop 가 매 프레임 MoveNext() 로 값을 밀고, 소비자가 MoveNextAsync() 로 당깁니다. 안의 완료 원천은 한 세대에 한 번만 완료되고, 새 세대는 소비자가 당길 때만 열립니다.

    // PlayerLoop 가 매 프레임 부른다
    public bool MoveNext()
    {
        if (disposed)
        {
            completionSource.TrySetResult(false);
            return false;                          // PlayerLoop 에서 빠진다
        }
    
        completionSource.TrySetResult(true);       // 이번 세대가 이미 완료됐으면 false 를 돌려주고 값은 버려진다
        return true;                               // 스트림은 끝나지 않으므로 루프에 남는다
    }
    
    // await foreach 가 값을 당길 때 부른다
    public UniTask<bool> MoveNextAsync()
    {
        completionSource.Reset();                  // 세대를 올리고 완료 상태를 푼다. 받을 준비가 됐다는 표시다
        return new UniTask<bool>(this, completionSource.Version);
    }

    소비자가 프레임 10 의 값을 받고 두 프레임짜리 작업을 하면 이렇게 됩니다.

    프레임 일어나는 일 그 프레임의 값
    10 소비자가 당겨 세대 1 을 엶. MoveNext() 가 세대 1 을 완료 받음
    11 세대 1 은 이미 완료. 소비자는 작업 중이라 당기지 않음 버려짐
    12 같음. 프레임 뒤쪽에서 작업이 끝나 소비자가 다시 당겨 세대 2 를 엶 버려짐
    13 MoveNext() 가 세대 2 를 완료 받음

    소비자가 본문에서 break 하면 await foreach 의 finally 가 DisposeAsync 를 불러 disposed 가 켜집니다. 다음 프레임의 MoveNext() 가 그것을 보고 false 를 돌려주고, 그 순간 PlayerLoop 에서 빠집니다.

    바쁜 동안의 값은 이미 완료된 세대에 들어가려다 실패하고 버려집니다. 버튼도 같습니다. 열거자는 세대 번호만 올려 재사용하므로 값마다 만드는 것이 없고, 매 프레임 할당은 0 B 였습니다.

    속도가 이득은 아닙니다. 구독자 하나가 매 프레임 더하는 시간입니다.

    방식 Mono IL2CPP
    MonoBehaviour.Update() 0.069 0.049
    EveryUpdate().ForEachAsync(...) 0.081 0.084

    얻는 것은 상태를 지역 변수로 들고 가고 바쁜 동안의 값을 저절로 버리는 코드 모양입니다.

    이렇게 쓴다

    처리 중에 온 값을 버려도 되는지로 고릅니다.

    // 버려도 된다: 저장 중에 누른 클릭은 무시한다. 플래그가 필요 없다
    await foreach (var _ in button.OnClickAsAsyncEnumerable().WithCancellation(ct))
    {
        await SaveAsync(ct);
    }
    
    // 하나도 버리면 안 된다: Queue() 를 끼운다
    await foreach (var message in messages.Queue().WithCancellation(ct))
    {
        await HandleAsync(message, ct);   // 처리 중에 온 메시지는 쌓였다가 차례로 온다
    }

    Queue() 는 원본을 따로 끝까지 당겨 채널에 쌓아 두고, 소비자는 채널에서 꺼냅니다. 버퍼에 상한이 없어서, 생산이 계속 처리보다 빠르면 쌓이기만 합니다.

    스트림은 await foreach 로 읽습니다. break 나 예외로 빠져나가도 DisposeAsync 가 불려 열거자가 PlayerLoop 에서 빠집니다. 열거자를 손으로 들고 MoveNextAsync() 를 부르다가 DisposeAsync 를 빠뜨리면, EveryUpdate 같은 열거자는 PlayerLoop 에 남아 매 프레임 불립니다.

    트리거는 유니티 메시지를 받을 컴포넌트를 붙인다

    Update, OnCollisionEnter 같은 메시지를 기다리려면 그 메시지를 받는 컴포넌트가 있어야 합니다. GetAsyncUpdateTrigger() 는 AsyncUpdateTrigger 를 찾고, 없으면 AddComponent 합니다. 오브젝트당 하나이고, 첫 호출에 64 B 가 들었습니다(Mono·IL2CPP Development 플레이어).

    var trigger = this.GetAsyncUpdateTrigger();
    await trigger.UpdateAsync();                        // 다음 Update 한 번
    await foreach (var _ in trigger) { /* 매 Update */ } // 스트림

    MonoBehaviour 에서 부른 GetCancellationTokenOnDestroy() 는 유니티 내장 destroyCancellationToken 을 돌려주고 컴포넌트를 붙이지 않습니다. 오브젝트가 파괴되면 await foreach 는 정상 종료하고, UpdateAsync() 는 OperationCanceledException 으로 끝납니다.

    이렇게 쓴다

    trigger.UpdateAsync() 는 부를 때마다 핸들러를 새로 만들어 목록에 넣고 뺍니다. 한 번 기다릴 때마다 120 B 였습니다. 루프에서 되풀이해 기다릴 때는 핸들러를 한 번 받아 두고 다시 씁니다. 이때는 0 B 였습니다.

    // 매번 120 B
    while (!ct.IsCancellationRequested)
    {
        await trigger.UpdateAsync();
        Move();
    }
    
    // 0 B. 핸들러를 루프 밖에서 한 번 받는다
    var handler = trigger.GetUpdateAsyncHandler(ct);
    
    while (!ct.IsCancellationRequested)
    {
        await handler.UpdateAsync();
        Move();
    }

    토큰을 넘겨 두면 취소될 때 핸들러가 트리거의 목록에서 빠집니다.

    트리거는 도는 중에 목록이 바뀌어도 한 번씩 건넨다

    한 트리거를 여러 곳에서 기다릴 수 있어, 메시지가 오면 구독자 목록을 돌며 OnNext 를 부릅니다. 목록은 구독자가 Prev/Next 를 자기 몸에 든 연결 리스트이고, head.Prev 가 꼬리입니다.

    문제는 OnNext 안에서 목록이 바뀐다는 것입니다. UpdateAsync() 를 기다리던 코드는 그 자리에서 이어 달리며 자기 핸들러를 빼고, 다시 기다리며 새로 넣습니다. A, B, C 가 있고 B 가 UpdateAsync() 를 기다리던 경우입니다.

    var h = head;
    
    while (h != null)
    {
        iteratingNode = h;                  // 지금 부르는 노드를 적어 둔다
        h.OnNext(value);                    // 기다리던 코드가 여기서 이어 달린다. 목록이 바뀔 수 있다
    
        // 부르는 동안 h 가 빠졌으면 Remove 가 iteratingNode 에 다음 노드를 적어 둔다
        h = h == iteratingNode ? h.Next : iteratingNode;
    }
    
    iteratingNode = null;
    
    // 도는 동안 새로 등록된 핸들러는 대기 목록에 있다. 순회가 끝난 뒤 꼬리에 붙인다
    if (iteratingHead != null)
    {
        Add(iteratingHead);
        iteratingHead = null;
    }

    B 의 OnNext 안에서 일어나는 일은 둘입니다.

    1. B 가 빠집니다. Remove 는 빠지는 노드가 iteratingNode 이면 그 자리에 다음 노드 C 를 적어 둡니다. 그래서 h 는 끊어진 B.Next 가 아니라 C 로 갑니다. 목록은 A → C 가 됩니다.
    2. B 가 곧바로 다시 기다립니다. while 루프가 await trigger.UpdateAsync() 를 다시 불러 새 핸들러 B2 를 등록합니다. 도는 중이라 본 목록이 아니라 대기 목록에 들어갑니다. 순회가 끝나면 A → C → B2 가 되고, B2 는 다음 Update 부터 받습니다.

    빠지는 쪽은 다음 노드를 iteratingNode 에 적어 두고, 새로 들어오는 쪽은 대기 목록에 있다가 순회가 끝나면 붙습니다. 그래서 받을 쪽은 모두 한 번씩 받고, 같은 발행을 두 번 받는 쪽은 없습니다.

    대기 목록에 둘 이상 쌓이면 이 약속이 깨집니다. B 와 C 가 대기 목록에 있으면 꼬리 C 는 B 의 Prev 에 적혀 있습니다. Add(iteratingHead) 는 B 를 노드 하나로 보고 head.Prev = B 로 적습니다. 다음 Add(D) 가 꼬리로 적힌 B 뒤에 D 를 붙이며 B.Next = D 로 덮어써서 C 가 떨어집니다.

    실측도 같았습니다. A 의 발행 안에 B, C 를 걸고 같은 프레임에 D 를 걸자 5 프레임 동안 B 5번, D 5번, C 0번 받았습니다. C 는 예외도 경고도 없이 영영 깨어나지 않습니다.

    이렇게 쓴다

    조건은 “트리거가 구독자를 부르는 도중에 같은 트리거에 새 구독이 둘 이상 생기는 것” 입니다. 그 조건을 만들지 않습니다.

    • 여러 곳이 같은 트리거를 기다려야 하면, 트리거 콜백 밖에서 핸들러를 미리 받아 둡니다. 위의 GetUpdateAsyncHandler 모양입니다.
    • 콜백 안에서 이어서 기다려야 하면 await UniTask.Yield() 로 한 프레임 넘긴 뒤에 구독합니다.
    await trigger.UpdateAsync();   // 여기서 깨어난 자리는 트리거가 구독자를 부르는 도중이다
    
    await UniTask.Yield();         // 순회가 끝난 뒤로 넘긴다
    
    RunB(trigger).Forget();        // 이제 둘을 새로 걸어도 대기 목록에 쌓이지 않는다
    RunC(trigger).Forget();

    정리

    UniTask 스트림은 소비자가 당길 때만 값을 줍니다.

    • 완료 원천이 세대마다 한 번만 완료되어, 바쁜 동안의 값은 버려집니다. 전부 받으려면 Queue() 입니다.
    • 트리거는 메시지를 받을 컴포넌트를 붙이고, 도는 중에 목록이 바뀌어도 구독자마다 한 번씩 건넵니다.
    • 한 발행 안에 둘 이상 구독하면 꼬리 표시가 틀어져 하나가 떨어집니다.

    그래서 버리면 안 되는 값에는 Queue() 를 끼우고, 되풀이해 기다리는 루프는 핸들러를 받아 두고, 트리거 콜백 안에서는 새 구독을 한 프레임 미룹니다.