CodeGym /행동 /C# SELF /성능 최적화: ValueTask

성능 최적화: ValueTask

C# SELF
레벨 62 , 레슨 2
사용 가능

1. 소개

코드로 바로 뛰어들기 전에 간단히 정리해봅시다. .NET에서 객체를 생성하는 것(특히 Task 같은 것)은 메모리와 약간의 CPU 시간을 소모한다는 걸 기억하세요. 비동기 API가 절반 정도 경우에는 결과를 즉시 반환(예: 캐시에서 가져옴)하고, 나머지 경우에는 데이터베이스에 접근해서 실제로 비동기 작업이 필요한 상황을 생각해보세요. 파일 캐시, 네트워크 API, 오브젝트 풀 등 "비동기지만 가끔 즉시" 반환되는 시나리오에서 자주 만나게 됩니다.

항상 Task를 반환하면 즉시 결과가 있는 경우에도 불필요한 객체를 만들어야 합니다. 그런데 결과가 이미 준비돼 있으면 Task를 만들지 않고 바로 결과를 반환할 수 있다면 어떨까요? 그게 바로 ValueTask가 탄생한 이유예요.

사실: 표준 Task.CompletedTaskTask.FromResult(…)는 실제 실행 시간을 절약해 주지만, 공유 객체를 생성하므로 고부하 시나리오에서는 완벽한 해결책이 아닐 수 있습니다.

무엇이 ValueTask인가

ValueTask는 이미 준비된 결과를 나타내거나(즉시 완료된 값), 실제로 비동기인 경우에는 Task를 가리킬 수 있는 특별한 래퍼(struct)입니다. 간단히 말하면 결과 자체 또는 Task에 대한 참조를 담을 수 있는 "패키지"예요.

주요 두 가지 형태가 있습니다:

  • ValueTask — 반환 값이 없는 경우(기본적으로 Task와 유사)
  • ValueTask<TResult> — 값을 감싸는 형태(Task<TResult>의 아날로그)

TaskValueTask 비교

형식 할당 수 동기적으로 완료될 수 있는가 주로 사용되는 경우
Task
한 번 (heap) 예/아니오 거의 항상
ValueTask
영/한 번 예/아니오 최적화 용도
ValueTask<T>
영/한 번 예/아니오 최적화 용도

언제 ValueTask를 쓰는가

골든 룰이 하나 있습니다: 항상 비동기적으로만 결과를 반환하는 일반적인 async 메서드라면 Task를 쓰세요. 그것이 가장 간단하고 안전하며 이해하기 쉽습니다.

ValueTask를 고려할 만한 경우:

  • 결과가 동기적으로 얻어질 수 있을 때 (예: 캐시, 풀, 메모리) 불필요한 할당을 줄이고 싶을 때.
  • 비동기 작업이 자주 발생하지 않을 때 (자주 발생하면 구조체 복사와 코드 복잡성 때문에 이득이 사라집니다).

주의! 항상 비동기 결과만 반환한다면 Task를 사용하세요. 자주 즉시 반환되는 결과에 대해 API를 "슈퍼 최적화"하고 싶다면 ValueTask가 적절합니다.

2. 동기적 결과 vs 비동기적 결과

이제 이름으로 사용자를 찾는 함수를 살펴봅시다. 캐시에 있으면 즉시 반환하고, 없으면 "데이터베이스"에서 비동기적으로 로드한다고 가정합니다:

// 우리 User 모델을 시뮬레이션
public class User
{
    public string Name { get; set; }
}

// 아주 단순한 캐시
private readonly Dictionary<string, User> _localCache = new();

public async ValueTask<User> FindUserAsync(string name)
{
    // 로컬 캐시 확인
    if (_localCache.TryGetValue(name, out var user))
    {
        // 결과가 즉시 준비됨 — Task 할당 없음!
        return user;
    }

    // 여기서는 실제로 오래 걸리는 비동기 작업(예: DB)이라고 가정
    user = await LoadUserFromDbAsync(name);
    // 미래를 위해 캐시에 저장
    _localCache[name] = user;
    return user;
}

private async Task<User> LoadUserFromDbAsync(string name)
{
    // 지연 시뮬레이션
    await Task.Delay(500);
    return new User { Name = name };
}

중요: 캐시 히트인 경우 일반 값을 반환하므로 Task 할당이 발생하지 않습니다. 결과를 실제로 "얻어야" 할 때만 비동기 작업을 생성합니다.

ValueTask 내부 구조

ValueTask는 내부적으로 준비된 값이 있거나 Task에 대한 참조를 가질 수 있습니다:

ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));

await를 사용할 때 컴파일러가 알아서 처리해 주므로, 결과가 즉시라면 불필요한 객체가 생성되지 않습니다.

awaitValueTask에 대한 중요한 점

async 메서드와 awaitValueTask와 잘 동작합니다:

public async ValueTask PingAsync()
{
    // ...
    await Task.Delay(10);
}

하지만 ValueTask 인스턴스를 저장해두고 나중에 기다리는 경우에는 주의해야 합니다: 동일한 인스턴스에 대해 여러 번 await를 하는 것은 금지입니다. Task는 여러 번 기다릴 수 있지만 ValueTask는 그렇지 않습니다.

오류: 동일한 await를 다시 하는 경우

ValueTask<int> task = ComputeAsync();

// 이건 괜찮음
int a = await task;

// 이거는 에러! 같은 ValueTask 인스턴스에 대해 두 번째 await는 허용되지 않음:
// int b = await task; // 하면 안 됨!

3. 콘솔 앱 일부를 다시 작성해보기

미니 전자책 리더를 만든다고 가정합시다: 일부 텍스트는 이미 캐시에 있고, 나머지는 비동기적으로 불러옵니다. ValueTask로 책의 첫 줄을 최적화된 방식으로 가져오는 메서드를 구현해봅시다:

private readonly Dictionary<string, string> _bookCache = new();

public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
    if (_bookCache.TryGetValue(title, out var bookText))
    {
        var firstLine = bookText.Split('\n')[0];
        return firstLine;
    }

    // 책을 비동기적으로 로드한다고 가정
    var downloadedBook = await DownloadBookTextAsync(title);
    _bookCache[title] = downloadedBook;
    return downloadedBook.Split('\n')[0];
}

private async Task<string> DownloadBookTextAsync(string title)
{
    // 지연 시뮬레이션(예: 인터넷에서 다운로드)
    await Task.Delay(1000);
    return $"Book: {title}\nThis is the first line.\nSecond line...";
}

이렇게 하면 책이 캐시에 있을 때 ValueTask가 Task 객체 생성을 절약합니다.

4. 유용한 세부사항

언제 ValueTask를 사용하지 말아야 하는가

API가 항상 비동기적(예: 항상 네트워크 통신)이라면 Task를 사용하세요. 더 쉽고 안전합니다.

ValueTaskTask로, 그 반대로 변환하는 방법

때로는 ValueTask를 받을 수 없는 API가 있습니다. 이럴 땐 .AsTask()를 사용하세요:

ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();

Task에서 ValueTask를 만들고 싶다면 Task로부터 새 ValueTask를 생성하면 됩니다:

Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);

ValueTaskTask 비교 요약

특성
Task
ValueTask
반복 await 허용됨 허용되지 않음
빠른 응답 시 할당 발생 발생하지 않음(결과가 즉시인 경우)
호환성 인터페이스 널리 지원됨 추가 래핑 필요
적용성 모든 곳에서 최적화 목적
풀링(Pool) 공용 풀 사용 아니오, 구조체
단순성 간단함 조금 더 복잡함

실무에서 ValueTask 사용 예

public async ValueTask<string> GetMessageAsync(int id)
{
    if (_messageCache.TryGetValue(id, out var value))
        return value;

    var result = await LoadMessageFromDbAsync(id);
    _messageCache[id] = result;
    return result;
}

면접 팁: 캐싱이 있는 비동기 API의 성능을 더 끌어올리고 싶다면 ValueTask를 언급하세요.

LINQ 및 IAsyncEnumerable<T>와의 호환 예

ValueTask를 비동기 LINQ(IAsyncEnumerable<T>)와 함께 쓰는 것도 .NET에서 지원됩니다(예: ToListAsync 같은 메서드).

public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
    // 일부 숫자는 이미 계산되어 있고, 일부는 비동기 작업으로 얻어온다고 가정
    // ... (예시는 간단히 생략)
    return new List<int> { 2, 3, 5, 7, 11 };
}

5. 요약 및 구현 특이점

  1. 결과가 상당 부분 즉시 준비되는 경우(예: 캐시) 성능 최적화를 위해 ValueTask를 사용하세요.
  2. 필요없다면 ValueTask를 쓰지 마세요 — 코드 복잡성 및 오류를 초래할 수 있습니다.
  3. 같은 ValueTask 인스턴스를 여러 번 await하지 마세요. 필요하면 Task로 변환하세요.
  4. Task와의 호환성은 .AsTask()로 처리합니다.
  5. ValueTaskstruct이므로 복사 시 동작이 달라질 수 있다는 점을 기억하세요.

6. ValueTask 사용 시 흔한 실수들

실수 #1: 동일한 인스턴스에 대해 반복 await 수행. ValueTask는 구조체라서 두 번 await 할 수 없습니다. 두 번째 await는 예외나 잘못된 동작을 일으킬 수 있습니다.

실수 #2: 호환성 체크 없이 ValueTask 사용. 일부 서드파티 API는 Task를 기대하므로 ValueTask를 바로 넘기면 문제가 생길 수 있습니다.

실수 #3: 불필요한 ValueTask 사용. 결과가 항상 비동기라면 ValueTask 사용은 코드만 복잡하게 만들 뿐입니다.

실수 #4: struct로서의 ValueTask 복사. 복사된 구조체는 await 시 예기치 않은 동작을 할 수 있으니 원본을 사용하도록 주의하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION