반응형

[PART11.비동기와 동시성(7/12)] ValueTask — 언제 Task 대신 쓰는가

Task의 힙 할당 비용 / 동기 완료 경로 최적화 / 잘못 쓰는 경우

1. Task는 왜 매번 힙을 할당하는가

Unity 모바일 게임에서 이런 코드를 본 적이 있다고 가정하자. 매 프레임 스킬 쿨타임을 체크하는 async 메서드다. 대부분의 호출은 "쿨다운이 끝났음"을 즉시 돌려주지만, 가끔만 실제로 서버에 묻는다.

C#
// 초당 수백 번 호출되는 핫 경로
public async Task<bool> IsSkillReadyAsync(int skillId)
{
    if (_localCache.TryGetValue(skillId, out bool ready))
        return ready;  // 99% 이 경로 — "동기 완료"
    return await _server.QueryAsync(skillId);
}

문제는 Task<bool>class라는 점이다. 동기적으로 끝나는 99% 경로에서도 반환을 위해 Task<bool> 객체를 매번 새로 할당한다. 이 작은 객체들이 모여 Unity의 Boehm GC(Boehm Garbage Collector, Unity Mono 런타임이 쓰는 세대 구분 없는 가비지 컬렉터)에 누적되면 프레임 중간에 GC 스파이크가 튀면서 16ms 예산을 초과한다.

"비동기 메서드인데 왜 동기 완료라는 말이 나오는가?" — async가 항상 실제 스레드 전환을 일으킨다고 오해하기 쉽다. 캐시 hit, 이미 도착한 버퍼 읽기, 로컬 상태 조회처럼 기다림 없이 바로 답이 나오는 경로가 실무에서는 압도적으로 많다. 이 경우 Task는 순전히 "약속의 형식"을 맞추기 위해 힙을 쓴다.

ValueTask는 바로 이 "동기 완료 경로의 불필요한 할당"을 없애기 위해 태어났다.


2. ValueTask — struct로 태어난 비동기 반환 타입

2.1 비유: 배달 주문과 "가게 앞에서 바로 받기"

Task<T>배달 주문서다. 주문서는 A4 용지 한 장(힙 객체)으로 발행되고, 배달원이 도착하면 거기에 결과가 적힌다. 음식을 지금 당장 손에 쥐고 있어도 주문서는 새로 한 장 찍어야 한다.

ValueTask<T>는 "지금 손에 있으면 그대로 건네고, 없으면 주문서를 발행"하는 하이브리드다. 가게 앞에서 바로 받는 경우에는 종이를 쓰지 않는다. 종이가 필요한 경우에만 주문서를 발행한다.

2.2 구조 비교

Task vs ValueTask — 메모리 배치 차이

2.3 기본 코드 — Task 버전과 ValueTask 버전

C#
using System.Collections.Generic;
using System.Threading.Tasks;

public class TaskVsValueTask
{
    private readonly Dictionary<string, int> _cache = new();

    // Task<T> 반환 — 동기 완료여도 Task 객체 힙 할당
    public async Task<int> GetWithTaskAsync(string key)
    {
        if (_cache.TryGetValue(key, out int val))
            return val;
        return await FetchFromDbAsync(key);
    }

    // ValueTask<T> 반환 — 동기 완료 시 힙 할당 없음
    public async ValueTask<int> GetWithValueTaskAsync(string key)
    {
        if (_cache.TryGetValue(key, out int val))
            return val;
        return await FetchFromDbAsync(key);
    }

    private Task<int> FetchFromDbAsync(string key) => Task.FromResult(42);
}

두 메서드는 C# 소스에서 한 글자(반환 타입)만 다르다. 컴파일러는 두 경우에 다른 빌더를 골라 상태 머신을 만든다. 그 차이를 IL에서 직접 확인한다.

2.4 IL 분석 — 빌더 타입이 갈라지는 지점

Task 버전의 상태 머신 필드:

IL
// Task<int> 경로: AsyncTaskMethodBuilder<int>
.field public valuetype [System.Runtime]System.Runtime.CompilerServices
       .AsyncTaskMethodBuilder`1<int32> '<>t__builder'

ValueTask 버전의 상태 머신 필드:

IL
// ValueTask<int> 경로: AsyncValueTaskMethodBuilder<int>
.field public valuetype [System.Runtime]System.Runtime.CompilerServices
       .AsyncValueTaskMethodBuilder`1<int32> '<>t__builder'
빌더(Builder)란? async 메서드가 컴파일될 때 컴파일러는 메서드 본문을 상태 머신(struct)로 변환하고, 그 구조체 안에 _builder 필드를 둔다. 빌더는 "누가 실제로 Task/ValueTask 객체를 만들고 결과를 세팅할지"를 결정하는 공장이다. AsyncTaskMethodBuilder항상 Task 인스턴스를 힙에 할당하지만, AsyncValueTaskMethodBuilder동기 완료 시 구조체만 반환하고 힙에 손대지 않는다.

핵심은 메서드 완료 시점 IL이다. Task 버전은 SetResult 호출 시 내부적으로 힙의 Task 객체를 갱신하는 반면, ValueTask 버전은 스택에 자리 잡은 구조체에 값을 그대로 세팅한다.

IL
// Task 버전 완료
IL_00b6: call instance void
         AsyncTaskMethodBuilder`1<int32>::SetResult(!0)

// ValueTask 버전 완료
IL_00b6: call instance void
         AsyncValueTaskMethodBuilder`1<int32>::SetResult(!0)

IL 자체의 형태는 비슷해 보이지만, 호출하는 메서드가 사는 타입이 다르다. 전자는 힙에 Task를 올리고, 후자는 구조체를 그대로 호출자에게 돌려준다. 동기 완료 경로에서는 이 차이가 "할당 있음/없음"으로 직결된다.

2.5 정의

항목 Task / Task<T> ValueTask / ValueTask<T>
타입 class (참조 타입) struct (값 타입)
도입 .NET Framework 4.0 C# 7.0 / .NET Core 2.0 (비제네릭은 Core 2.1)
메모리 할당 항상 힙 동기 완료 시 할당 없음, 비동기 완료 시 내부 Task/Source를 참조
재사용 여러 번 await 가능 단 한 번만 await
기본 선택 범용 비동기 반환 성능 최적화 도구

3. 내부 동작 — 상태 머신 빌더와 IValueTaskSource

3.1 ValueTask가 담는 세 가지 상태

ValueTask<T> 내부 _obj 필드의 3가지 상태

ValueTask<T>의 내부 필드 _obj상태 분기를 담는다.

  • _obj == null → 이미 결과가 _result에 박혀 있음. 힙 할당 없음.
  • _obj is Task<T> → 일반적인 Task 래핑. 힙 할당 있음 (하지만 Task와 동일).
  • _obj is IValueTaskSource<T> → 외부가 만든 풀링된 소스 객체 참조. 풀에서 빌려 쓰므로 반복 호출해도 할당 누적 없음.

이 분기 구조가 ValueTask의 본질이다. "힙 할당이 없다"는 말은 엄밀히 말해 ①·③ 경로에서 그렇다는 뜻이다. ② 경로는 Task와 같다.

3.2 동기 완료 경로를 직접 만들기

직접 ValueTask를 반환할 때는 async 키워드 없이 생성자를 쓸 수도 있다.

C#
public ValueTask<int> GetCachedAsync(string key)
{
    if (_cache.TryGetValue(key, out int val))
        return new ValueTask<int>(val);           // ① _obj=null, _result=val

    return new ValueTask<int>(FetchAsync(key));   // ② _obj=Task 래핑
}

private Task<int> FetchAsync(string key) => Task.FromResult(42);
new ValueTask<int>(val) 생성자 ValueTask<T>을 직접 받는 생성자와 Task를 받는 생성자를 둘 다 제공한다. 값 생성자는 _objnull로 두고 _result에만 값을 채우므로, 이 구조체를 반환하는 것만으로도 힙 할당이 0이다. Task 생성자는 내부 _obj에 Task를 담는다.

3.3 IL에서 본 동기 완료 생성자

IL
// public ValueTask<int> GetValueTaskAsync() => new ValueTask<int>(42);
IL_0002: newobj instance void
         valuetype [System.Runtime]System.Threading.Tasks
         .ValueTask`1<int32>::.ctor(!0)

newobj가 보이지만 놀라지 말자. newobj는 참조 타입이면 힙 할당을, 값 타입이면 스택(또는 레지스터)에 구조체를 생성하는 명령이다. ValueTask<int>는 struct이므로 이 newobj는 힙에 손대지 않는다. 호출자는 구조체를 값으로 그대로 받는다.

3.4 IValueTaskSource — 비동기 경로까지 할당을 제거

그렇다면 실제로 비동기 대기가 발생하는 경우(②)에도 할당을 없앨 수는 없을까? IValueTaskSource<T>가 그 답이다.

Socket.ReceiveAsync, System.IO.Pipelines, Channel<T>.Reader.ReadAsync 같은 저수준 API는 IValueTaskSource<T>를 구현한 풀링된 객체를 사용한다. 호출마다 풀에서 객체를 Rent, await 완료 후 Return. 이 방식으로 비동기 경로에서도 할당을 거의 0으로 만든다.

C#
// 개념적 스케치 — 실제 .NET 내부 동작
public ValueTask<int> ReceiveAsync()
{
    var source = _pool.Rent();               // 풀에서 객체 빌리기
    source.StartOperation();
    return new ValueTask<int>(source, source.Token);
    // _obj = IValueTaskSource<int>
    // await 완료 후 GetResult() 내부에서 풀로 반환
}

이 메커니즘이 없으면 ValueTask는 "동기 완료만 최적화하는 반쪽짜리 도구"다. IValueTaskSource 덕분에 네트워크 루프, 파이프라인 처리 같은 지속적인 비동기 핫 경로에서도 zero-allocation이 가능해진다.


4. 실전 적용 — 캐시·버퍼·에셋 로딩

4.1 판단 기준: ValueTask를 쓸지 결정하는 플로우

ValueTask 도입 결정 플로우

이 네 질문에 전부 YES로 답할 수 있어야 ValueTask가 실제 이득을 낸다. 하나라도 확신이 없으면 Task가 정답이다.

4.2 Before/After — Unity 어드레서블 에셋 캐시

❌ Before: Task로 매번 할당

C#
using UnityEngine;
using UnityEngine.AddressableAssets;
using System.Collections.Generic;
using System.Threading.Tasks;

public class AssetCache
{
    private readonly Dictionary<string, GameObject> _loaded = new();

    // 초당 수백 회 — 총알, 파티클, 이펙트 프리팹 요청
    public async Task<GameObject> GetPrefabAsync(string key)
    {
        if (_loaded.TryGetValue(key, out var go))
            return go;  // 99% 이 경로

        var handle = Addressables.LoadAssetAsync<GameObject>(key);
        var result = await handle.Task;
        _loaded[key] = result;
        return result;
    }
}

호출당 Task 객체 하나가 힙에 올라간다. 총알 스폰 루프에서 초당 300회 호출하면 초당 300개의 Task 인스턴스가 누적된다.

✅ After: ValueTask로 동기 경로 할당 제거

C#
using UnityEngine;
using UnityEngine.AddressableAssets;
using System.Collections.Generic;
using System.Threading.Tasks;

public class AssetCache
{
    private readonly Dictionary<string, GameObject> _loaded = new();

    public ValueTask<GameObject> GetPrefabAsync(string key)
    {
        if (_loaded.TryGetValue(key, out var go))
            return new ValueTask<GameObject>(go);  // 힙 할당 0

        return new ValueTask<GameObject>(LoadSlowPath(key));  // 느린 경로만 Task
    }

    private async Task<GameObject> LoadSlowPath(string key)
    {
        var handle = Addressables.LoadAssetAsync<GameObject>(key);
        var result = await handle.Task;
        _loaded[key] = result;
        return result;
    }
}

중요한 건 동기 경로를 async가 아닌 일반 메서드로 분리한 점이다. async ValueTask로 선언하면 컴파일러가 여전히 상태 머신을 생성하므로, 동기 경로에서 상태 머신 박싱 비용이 조금 남는다(.NET 6 이전). 핫 경로라면 이처럼 "동기 경로는 생성자로 즉시 반환, 느린 경로만 async Task를 래핑"하는 패턴이 가장 깔끔하다.

4.3 IL로 확인 — 동기 경로 할당 0

new ValueTask<GameObject>(go)가 컴파일된 IL은 newobj로 끝나지만 대상이 struct이므로 힙을 쓰지 않는다(3.3 참고).

IL
// return new ValueTask<GameObject>(go);
IL_0012: newobj instance void
         valuetype [System.Runtime]System.Threading.Tasks
         .ValueTask`1<class [UnityEngine]UnityEngine.GameObject>::.ctor(!0)
IL_0017: ret

비교를 위해 async Task<GameObject> 경로를 IL로 보면 AsyncTaskMethodBuilder<GameObject>::Create 호출 + Task 객체 생성이 MoveNext 시작부에 항상 삽입된다. ValueTask의 직접 생성자 경로는 이 오버헤드를 완전히 우회한다.

4.4 Unity 현실: UniTask와의 관계

Unity 신입 개발자가 먼저 알아야 할 사실 — ValueTask조차도 Unity에서는 완벽하지 않다.

  • Unity의 TaskUnitySynchronizationContext를 경유해 메인 스레드로 스케줄링된다. 불필요한 context 캡처 비용이 누적된다.
  • ValueTask도 비동기 경로에 진입하면 내부적으로 Task를 쓰므로 이 비용에서 자유롭지 않다.
  • IL2CPP(Unity의 AOT 컴파일러)에서는 상태 머신 박싱 최적화가 JIT만큼 공격적이지 않다.

이 문제를 정면으로 푼 것이 UniTask다. UniTask는 ValueTaskIValueTaskSource의 아이디어를 Unity 환경에 맞게 재구현한 struct 타입으로, Unity의 PlayerLoop와 통합된 UniTask.Yield(), UniTask.NextFrame(), UniTask.Delay() 등을 제공하며 zero allocation을 목표로 한다.

C#
// UniTask 예시 — Unity 핫 경로에서 실제로 쓰이는 형태
public async UniTask<GameObject> GetPrefabAsync(string key)
{
    if (_loaded.TryGetValue(key, out var go))
        return go;  // 완전 zero allocation (UniTask 풀링)

    var handle = Addressables.LoadAssetAsync<GameObject>(key);
    var result = await handle.Task;
    _loaded[key] = result;
    return result;
}

"순수 .NET에서 ValueTask가 하려는 일"을 "Unity 런타임에 딱 맞춰" 구현한 것이 UniTask라고 이해하면 된다. 따라서 Unity 모바일에서 GC 프리 비동기를 원한다면 ValueTask를 직접 쓰는 대신 UniTask를 선택하는 것이 실무 정답에 가깝다. ValueTask의 원리를 이해하는 것은 UniTask가 왜 그렇게 설계되었는지를 이해하기 위해서다.


5. 함정과 주의사항

5.1 ❌ 함정 1: 두 번 await

ValueTask소비되면(consumed) 내부 상태가 풀로 반환될 수 있다. 두 번째 await는 이미 반환된 객체를 건드리게 되어 InvalidOperationException 또는 다른 호출의 결과를 받는 재앙이 된다.

❌ 잘못된 코드

C#
public async Task BadAsync()
{
    ValueTask<int> vt = GetValueTaskAsync();
    int a = await vt;
    int b = await vt;  // 🔥 InvalidOperationException 가능
}

private ValueTask<int> GetValueTaskAsync() => new ValueTask<int>(42);

IL에서 보면 컴파일러는 두 번째 await에 대해 또 하나의 GetAwaiter().GetResult() 호출을 생성한다. 런타임에서 이 두 번째 GetResult가 token 검증에 걸리면 예외가 날아간다.

IL
// BadAsync — ValueTaskAwaiter::GetResult()가 두 번 호출됨
IL_007a: call instance !0
         ValueTaskAwaiter`1<int32>::GetResult()  // ← 첫 번째
...
IL_00d3: call instance !0
         ValueTaskAwaiter`1<int32>::GetResult()  // ← 두 번째 (위험)

✅ 올바른 코드

여러 번 소비해야 한다면 AsTask()로 변환한다.

C#
public async Task GoodAsync()
{
    ValueTask<int> vt = GetValueTaskAsync();
    Task<int> task = vt.AsTask();  // Task로 승격 (필요 시 한 번만 할당)
    int a = await task;
    int b = await task;  // Task는 여러 번 await 안전
}

IL에서는 ValueTask::AsTask()가 한 번 호출된 후 이후는 일반 TaskAwaiter::GetResult()만 반복된다.

IL
IL_0019: call instance valuetype ValueTask`1<int32>
         DoubleAwait::GetValueTaskAsync()
IL_0022: call instance class Task`1<!0>
         ValueTask`1<int32>::AsTask()
AsTask()의 비용 동기 완료된 ValueTask에 AsTask()를 부르면 결국 Task 객체 하나를 힙에 새로 만든다. 즉 ValueTask 최적화를 포기하는 호출이다. 여러 번 await가 필요하다면 "처음부터 Task를 반환"하는 것이 맞다. AsTask()는 "특수한 경우의 탈출구"지 정상 경로가 아니다.

5.2 ❌ 함정 2: .Result / .GetAwaiter().GetResult() 동기 블로킹

❌ 잘못된 코드

C#
public int BadSync()
{
    ValueTask<int> vt = GetValueTaskAsync();
    return vt.Result;  // 🔥 아직 완료 안됐거나 이미 소비됐을 수 있음
}

Task에서도 위험한 패턴이지만 ValueTask에서는 두 배로 위험하다:

  1. Task와 동일한 동기 블로킹 → UI 스레드 데드락
  2. ValueTask는 단 1회 소비 규칙 → token 불일치로 예외

✅ 올바른 코드

동기 컨텍스트에서 결과가 필요하면 호출 체인을 아예 async로 바꾸거나, 애초에 동기 메서드를 설계한다.

C#
public async Task<int> GoodAsync()
{
    ValueTask<int> vt = GetValueTaskAsync();
    return await vt;  // 자연스러운 비동기 대기
}

5.3 ❌ 함정 3: 컬렉션에 ValueTask 저장

❌ 잘못된 코드

C#
private readonly Dictionary<int, ValueTask<Data>> _inflight = new();

public ValueTask<Data> GetAsync(int id)
{
    if (_inflight.TryGetValue(id, out var vt))
        return vt;  // 🔥 이미 완료·재활용된 ValueTask를 재사용하려는 시도

    vt = StartLoadAsync(id);
    _inflight[id] = vt;
    return vt;
}

ValueTask는 풀링된 IValueTaskSource를 내부에 가질 수 있고, 한 번 소비되면 그 소스 객체는 다른 호출의 결과를 담기 위해 풀에 반납된다. 딕셔너리에 저장된 ValueTask를 나중에 꺼내 쓰면 엉뚱한 데이터를 받는다.

✅ 올바른 코드

캐싱이 필요하면 Task로 저장한다.

C#
private readonly Dictionary<int, Task<Data>> _inflight = new();

public Task<Data> GetAsync(int id)
{
    if (_inflight.TryGetValue(id, out var task))
        return task;

    task = StartLoadAsync(id);
    _inflight[id] = task;
    return task;
}

private async Task<Data> StartLoadAsync(int id) { /* ... */ return new Data(); }

5.4 ❌ 함정 4: Task.WhenAll / WhenAny에 직접 넘기기

Task.WhenAllTask[]를 받는다. ValueTask 배열을 넘기려면 각각 AsTask()로 변환해야 하는데, 이때마다 힙 할당이 발생한다. 여러 ValueTask를 합성할 일이 있으면 Task로 바꾸는 것이 맞다.

C#
// ❌ 비싸다 — AsTask()가 N번 호출되며 힙 할당 N개
var tasks = items.Select(x => GetAsync(x).AsTask()).ToArray();
await Task.WhenAll(tasks);

// ✅ 애초에 Task를 반환하는 메서드를 설계

5.5 Unity 실전 함정: async ValueTask와 IL2CPP 박싱

.NET 6 이전에는 async ValueTask 메서드 내부의 상태 머신이 비동기 경로에서 힙에 박싱됐다. .NET 6부터 [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]로 풀링을 강제할 수 있지만, Unity의 IL2CPP는 이 최적화를 100% 따라오지 않는다. Unity에서 zero allocation이 진짜 목표라면 UniTask가 정답이다.


6. C# 버전별 변화

6.1 C# 7.0 (.NET Core 2.0, 2017) — ValueTask<T> 도입

ValueTask<T> 구조체가 처음 등장했다. 내부는 단순히 T 값 하나 + Task<T> 참조 하나를 담는 구조체였다. 동기 완료만 최적화할 수 있었고, 비동기 경로는 Task와 동일.

C#
// C# 7.0 사용 예
public ValueTask<int> GetAsync() => new ValueTask<int>(42);

6.2 .NET Core 2.1 (2018) — 비제네릭 ValueTask + IValueTaskSource

  • 결과 없는 ValueTask (비제네릭) 추가 → async ValueTask 메서드 작성 가능.
  • IValueTaskSource<T> 인터페이스 도입 → 라이브러리 개발자가 풀링된 상태 객체로 비동기 경로까지 zero allocation 구현 가능.
  • Socket.ReceiveAsync 등 주요 API가 Task → ValueTask로 마이그레이션.

Before (C# 7.0): 비동기 경로는 여전히 Task 할당

C#
public ValueTask<int> ReadAsync()
{
    if (HasBufferedData)
        return new ValueTask<int>(_buffer);
    return new ValueTask<int>(SlowPathTaskAsync());  // Task 할당 발생
}

After (.NET Core 2.1+): IValueTaskSource로 풀링

C#
// 개념 스케치 — AwaitableSocketAsyncEventArgs 같은 실제 구현
public ValueTask<int> ReadAsync()
{
    var source = _sourcePool.Rent();
    source.Start();
    return new ValueTask<int>(source, source.Version);
    // 비동기 경로도 할당 0
}

6.3 .NET 6 (2021) — PoolingAsyncValueTaskMethodBuilder

  • [AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]async ValueTask 메서드의 상태 머신까지 풀링 가능.
  • 사용자 코드가 IValueTaskSource를 직접 구현하지 않아도 zero allocation 비동기 경로를 얻을 수 있음.
  • 기본 빌더는 여전히 AsyncValueTaskMethodBuilder(풀링 없음) — 옵트인 방식.
C#
using System.Runtime.CompilerServices;

// 상태 머신 박싱까지 제거
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> ReadPooledAsync() { /* ... */ return 42; }

이 속성을 붙이면 IL의 <>t__builder 필드 타입이 PoolingAsyncValueTaskMethodBuilder로 바뀐다. 실행 시점에 상태 머신 객체가 내부 풀에서 Rent/Return 된다.

6.4 Unity에서의 버전 매핑

Unity 버전 런타임 ValueTask 상태
Unity 2020.x Mono (.NET Standard 2.0/2.1) 기본 ValueTask 사용 가능, PoolingBuilder 미지원
Unity 2021.x+ Mono / IL2CPP ValueTask + Core 2.1 기능 사용 가능
Unity 2022 LTS+ .NET Standard 2.1 대부분의 .NET 6 기능 가능하나 PoolingBuilder는 완전 지원 보장 안 됨

결론적으로 Unity 실전에서는 ValueTask 자체는 쓸 수 있지만, 가장 공격적인 할당 제거 최적화는 UniTask가 훨씬 앞서 있다.


7. 정리

핵심만 추린 체크리스트다.

  • [ ] Task가 기본이다. 고민되면 Task를 쓴다. ValueTask는 성능 프로파일링 후의 선택이다.
  • [ ] ValueTask는 struct다. 동기 완료 경로에서 힙 할당이 0이 된다.
  • [ ] _obj의 3가지 상태: null(동기 완료) / Task 래핑 / IValueTaskSource. 이 중 ①·③만 할당 없음 이득이 있다.
  • [ ] 단 한 번만 await 한다. 두 번 이상 필요하면 AsTask()로 변환하거나 처음부터 Task로 설계한다.
  • [ ] .Result / GetResult() 동기 블로킹 금지. 데드락 + token 위반 두 배 위험.
  • [ ] 컬렉션·딕셔너리에 저장 금지. 풀링된 소스가 재활용되어 엉뚱한 결과를 돌려줄 수 있다.
  • [ ] Task.WhenAll 등 합성 작업이 필요하면 처음부터 Task를 설계한다.
  • [ ] Unity 모바일에서 zero allocation 비동기가 목표라면 UniTask를 먼저 고려한다. ValueTask의 원리를 이해하는 것은 UniTask를 정확히 쓰기 위한 전제다.
  • [ ] IValueTaskSource 구현은 라이브러리 저자 영역이다. 일반 게임 코드에서 직접 구현할 일은 거의 없다.

"ValueTask를 쓸까?"라는 질문은 결국 "내 메서드가 초당 수천 회 호출되고, 그 호출의 대부분이 동기적으로 끝나며, 지금 GC가 실제 성능 문제인가?"로 바뀐다. 세 질문 모두에 YES가 나와야 ValueTask가 정답이다. 그렇지 않다면 Task가 더 안전하고 더 단순하다.

반응형

+ Recent posts