← 문제로

12. ValueTask vs Task (할당·풀링·"한 번만 소비" 규칙)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — ValueTask vs Task (할당·풀링·"한 번만 소비" 규칙)

난이도: 상

요약

ValueTask<T>는 결과 T(동기 완료) 또는 비동기 백킹(Task<T>/IValueTaskSource<T>)을 감싸는 구조체다. 캐시 적중처럼 동기적으로 끝나는 경우 Task<T> 객체 할당을 피해 GC 압박을 줄인다 — 초당 수십만 번 불리는 게임 핫패스에서 의미가 크다. 대신 "정확히 한 번만, 완료 후에 소비" 라는 엄격한 규칙이 있어 (A) 이중 await, (B) 완료 전 .Result 접근 같은 오용이 미묘한 버그를 만든다.

핵심 개념

  • Task<T>: 참조 타입(힙 객체). 한 번 만들면 여러 번 await, 저장, 공유, WhenAll/WhenAny 조합이 모두 안전. 대신 매 호출 할당.
  • ValueTask<T>: readonly struct. 동기 완료면 내부에 T만 들고 할당 0. 비동기면 IValueTaskSource(풀링 가능)나 Task를 가리킨다. 풀링 백킹은 첫 소비 후 재활용될 수 있어, 두 번째 소비 시 다른 작업의 상태를 읽거나 예외가 난다.

단계별 내부 동작 / 위험

  • (1) 동기 완료의 이득: _hot에 있으면 new ValueTask<PlayerData>(p) — 힙 할당 없이 값만 감싼다. Task<T>였다면 (캐시된 Task를 재사용하지 않는 한) 매번 Task를 할당. 캐시 적중률이 높은 조회(플레이어/세션/설정)에서 할당과 GC를 크게 줄인다.
  • (2) (A) 이중 await — 잘못됨: ValueTask한 번만 await해야 한다. await vt를 두 번 하면, 백킹이 풀링된 IValueTaskSource일 때 첫 await 후 소스가 풀로 반환·재사용되어 두 번째 await가 엉뚱한 결과/예외/교착을 부른다. 동기 완료나 Task 백킹이면 "우연히" 동작할 수도 있어 더 위험하다(테스트는 통과, 운영에서 간헐 실패). 규칙상 미정의 동작으로 취급해야 한다.
  • (3) (B) 완료 전 .Result: ValueTask.Result(와 GetAwaiter().GetResult())는 완료된 뒤에만 유효하다. 아직 완료 전이면 동작이 정의되지 않는다(블로킹 보장도 없음). 게다가 이미 await로 한 번 소비한 뒤 .Result로 또 접근하는 것도 "두 번 소비"라 금지. 동기 대기가 꼭 필요하면 vt.AsTask().Result처럼 Task로 만들어야 하지만, 그 자체가 블로킹 안티패턴.
  • (4) 여러 번/늦게 쓰려면: 한 번만 await해서 결과를 변수에 담거나, ValueTask를 즉시 .AsTask()로 한 번 변환해 일반 Task처럼 다룬다(이러면 할당이 생기지만 안전). .Preserve()로 다회 await 가능한 형태로 만들 수도 있다.
// (A) 수정: 한 번만 await, 결과를 재사용
var data = await cache.GetAsync(id);
var first = data; var second = data;

// 여러 곳에서 써야 하면 Task 로 승격
Task<PlayerData> t = cache.GetAsync(id).AsTask();
await Task.WhenAll(t, SomethingElseAsync());
var d = await t;   // Task 는 다회 await 안전
  • (5) 반환 타입 선택:
    • ValueTask 권장: 자주 동기 완료되고(캐시/버퍼drain) 호출 빈도가 매우 높아 할당을 줄여야 하는 핫패스. 단 소비자가 규칙을 지킨다는 전제.
    • Task 권장: 결과를 저장·공유·다회 await 하거나 WhenAll/WhenAny로 조합할 일이 많을 때, 또는 API 단순성이 중요할 때. Task는 함정이 적다.
    • 측정 없이 ValueTask로 "최적화"하지 말 것 — 대부분 비동기로 완료된다면 ValueTask는 이득이 거의 없고 복잡성만 는다.

흔한 오해·함정

  • "ValueTask는 그냥 빠른 Task" → 아니다. 소비 규칙(한 번, 완료 후)이 다르고, 잘못 쓰면 간헐 버그. Task처럼 캐싱/공유하면 안 된다.
  • "동기 완료니 await 안 하고 .Result 써도 된다" → IsCompletedSuccessfully 확인 후에만. 일반적으로는 그냥 await가 안전.
  • 풀링 백킹([AsyncMethodBuilder]/PoolingAsyncValueTaskMethodBuilder, ManualResetValueTaskSourceCore)일수록 이중 소비가 치명적.

면접 포인트

  • ValueTask가 무엇을 감싸며 동기 완료 시 어떻게 할당을 피하는가, 게임 핫패스에서의 이점.
  • "정확히 한 번, 완료 후 소비" 규칙과 이중 await/조기 .Result가 위험한 이유(풀링 재활용).
  • Task vs ValueTask 선택 기준, 다회 사용이 필요할 때 .AsTask()/.Preserve().