C# Async/Await을 활용한 비동기 리소스 로딩 모범 사례

C# Async/Await을 활용한 비동기 리소스 로딩 모범 사례

게임 플레이를 멈추지 않으면서 리소스를 안전하게 불러오기 위한 C# async/await 설계 원칙을 정리한다. Unity의 비동기 작업을 Task로 다루는 방법과 취소, 오류, 중복 요청 관리까지 살펴본다.

비동기 로딩의 목표는 기다림을 숨기는 일이 아니다

비동기 리소스 로딩은 파일 읽기나 네트워크 요청이 끝나는 동안 메인 스레드를 점유하지 않도록 하는 기법이다. 다만 await를 붙였다고 해서 자동으로 모든 문제가 해결되지는 않는다. 로딩 대상의 중복 요청, 씬 전환 중 취소, 실패한 에셋의 처리, Unity API의 메인 스레드 제약까지 함께 설계해야 한다.

좋은 로딩 코드는 호출한 쪽이 다음 네 가지를 분명히 알 수 있게 만든다.

  • 언제 완료되는가
  • 실패하면 어떤 예외 또는 결과를 받는가
  • 더 이상 필요 없을 때 어떻게 취소하는가
  • 같은 리소스를 여러 번 요청해도 불필요한 로딩이 발생하지 않는가
flowchart TD
    A[로드 요청] --> B{캐시에 있나?}
    B -- 예 --> C[캐시된 리소스 반환]
    B -- 아니오 --> D{진행 중인 요청이 있나?}
    D -- 예 --> E[같은 Task를 함께 대기]
    D -- 아니오 --> F[비동기 로딩 시작]
    F --> G{성공했나?}
    G -- 예 --> H[캐시에 저장]
    H --> C
    G -- 아니오 --> I[오류 기록 및 호출자에게 전달]

async void 대신 Task를 반환한다

이벤트 핸들러처럼 반환형을 바꿀 수 없는 경우를 제외하면 async void는 피하는 편이 좋다. 호출자는 작업 완료를 기다릴 수 없고 예외를 자연스럽게 전파받기도 어렵다.

// 피해야 할 형태
public async void LoadPlayerPrefabAsync()
{
    await Task.Delay(100);
}

// 호출자가 완료와 예외를 다룰 수 있는 형태
public async Task<GameObject> LoadPlayerPrefabAsync()
{
    await Task.Delay(100);
    return new GameObject("Player");
}

Task<T>를 반환하면 상위 코드에서 로딩 순서를 명확히 표현할 수 있다. 예를 들어 플레이어 프리팹을 만든 뒤에만 장비 데이터를 적용하도록 만들 수 있다.

var playerPrefab = await loader.LoadAsync<GameObject>("Player");
var player = Instantiate(playerPrefab);
await equipmentService.ApplyAsync(player);

Unity의 비동기 작업을 Task로 감싼다

Unity의 ResourceRequest, AsyncOperation 같은 타입은 Task가 아니므로 C#의 await로 바로 기다릴 수 없다. 프로젝트에서 사용하는 Unity 버전과 라이브러리에 따라 제공되는 await 지원이 다르므로 공통 계층에서는 명시적인 변환 함수를 두면 호출부를 단순하게 유지할 수 있다.

아래 예시는 AsyncOperation 완료를 Task로 변환한다. 완료 이벤트를 사용하므로 매 프레임 직접 폴링할 필요가 없다.

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public static class AsyncOperationExtensions
{
    public static Task AsTask(this AsyncOperation operation, CancellationToken token = default)
    {
        if (operation == null)
        {
            throw new ArgumentNullException(nameof(operation));
        }

        if (operation.isDone)
        {
            return Task.CompletedTask;
        }

        var completionSource = new TaskCompletionSource<object>(
            TaskCreationOptions.RunContinuationsAsynchronously);

        operation.completed += _ => completionSource.TrySetResult(null);

        if (token.CanBeCanceled)
        {
            token.Register(() => completionSource.TrySetCanceled(token));
        }

        return completionSource.Task;
    }
}

여기서 취소는 Unity 엔진의 내부 로딩을 반드시 중단한다는 뜻이 아니다. 이 예제에서는 호출자가 결과를 더 이상 기다리지 않도록 만드는 취소다. 실제 요청을 중단하거나 핸들을 해제할 수 있는지는 사용하는 API의 계약을 따로 확인해야 한다.

취소 토큰은 화면과 수명을 함께한다

로딩 화면이나 UI 패널이 닫힌 뒤에도 작업이 계속되면 이미 파괴된 객체를 갱신하려다 오류가 날 수 있다. 화면이 사라질 때 취소 토큰을 취소하고 완료 뒤에도 대상이 유효한지 확인하는 습관이 필요하다.

using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public sealed class PreviewPanel : MonoBehaviour
{
    private CancellationTokenSource _lifetimeCts;

    private void Awake()
    {
        _lifetimeCts = new CancellationTokenSource();
    }

    public async Task ShowPreviewAsync(ResourceLoader loader, string key)
    {
        try
        {
            var prefab = await loader.LoadAsync<GameObject>(key, _lifetimeCts.Token);

            if (this == null || prefab == null)
            {
                return;
            }

            Instantiate(prefab, transform);
        }
        catch (OperationCanceledException)
        {
            // 패널이 닫혀 취소된 경우는 정상 흐름으로 처리한다.
        }
    }

    private void OnDestroy()
    {
        _lifetimeCts.Cancel();
        _lifetimeCts.Dispose();
    }
}

CancellationTokenSource는 생성한 쪽이 수명을 책임져야 한다. 여러 로딩 요청에 토큰을 공유할 수는 있지만 서로 독립적인 요청까지 한 토큰에 묶으면 하나를 취소했을 때 모두 취소될 수 있다.

중복 요청은 진행 중인 Task를 공유한다

같은 아이콘이나 프리팹을 여러 UI가 동시에 요청하는 상황은 흔하다. 캐시에는 아직 결과가 없지만 이미 로딩이 시작된 경우, 새 요청마다 로드를 다시 시작하면 I/O와 메모리를 낭비한다. 키별로 진행 중인 Task를 보관하면 한 번의 로딩 결과를 여러 호출자가 함께 기다릴 수 있다.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public sealed class ResourceLoader
{
    private readonly Dictionary<string, UnityEngine.Object> _cache = new();
    private readonly Dictionary<string, Task<UnityEngine.Object>> _inFlight = new();

    public async Task<T> LoadAsync<T>(string path, CancellationToken token = default)
        where T : UnityEngine.Object
    {
        if (_cache.TryGetValue(path, out var cached))
        {
            return cached as T;
        }

        if (!_inFlight.TryGetValue(path, out var task))
        {
            task = LoadCoreAsync<T>(path);
            _inFlight[path] = task;
        }

        try
        {
            var asset = await task.WaitAsync(token);
            return asset as T;
        }
        finally
        {
            if (task.IsCompleted)
            {
                _inFlight.Remove(path);
            }
        }
    }

    private async Task<UnityEngine.Object> LoadCoreAsync<T>(string path)
        where T : UnityEngine.Object
    {
        var request = Resources.LoadAsync<T>(path);
        await request.AsTask();

        if (request.asset == null)
        {
            throw new InvalidOperationException($"리소스를 찾을 수 없습니다: {path}");
        }

        _cache[path] = request.asset;
        return request.asset;
    }
}

Task.WaitAsync는 최신 .NET API다. 프로젝트의 API 호환성 수준에서 사용할 수 없다면 취소 가능한 대기 함수를 별도로 구현하거나 해당 환경에서 지원하는 비동기 라이브러리를 사용해야 한다. 핵심은 호출자 취소와 실제 공유 로딩 작업의 수명을 분리하는 것이다. 한 호출자의 취소가 다른 호출자가 기다리는 공용 로딩까지 망가뜨려서는 안 된다.

진행 중인 요청 공유와 캐시 반환의 관계를 설명하는 리소스 로더 구조

오류를 삼키지 말고 복구 전략을 정한다

비동기 메서드 내부에서 예외를 기록만 하고 null을 반환하면 호출자는 실패 원인을 잃기 쉽다. 로더는 실패를 예외나 명시적인 결과 타입으로 전달하고 화면 계층은 사용자에게 보여 줄 대체 동작을 결정하는 편이 역할이 분명하다.

try
{
    var portrait = await loader.LoadAsync<Sprite>("Portraits/Hero", token);
    portraitImage.sprite = portrait;
}
catch (OperationCanceledException)
{
    // 화면 전환에 따른 취소는 별도 알림 없이 끝낸다.
}
catch (Exception exception)
{
    Debug.LogException(exception);
    portraitImage.sprite = fallbackPortrait;
}

복구 전략은 리소스의 중요도에 따라 달라진다. 장식용 초상화는 기본 이미지로 대체할 수 있지만 전투 씬의 필수 데이터가 없다면 재시도나 로딩 실패 화면이 더 적절할 수 있다.

메인 스레드 제약을 구분한다

비동기 작업이 있다고 해서 Unity API를 임의의 작업 스레드에서 호출해도 되는 것은 아니다. GameObject 생성, Instantiate, 컴포넌트 접근처럼 Unity 엔진 객체를 다루는 코드는 일반적으로 메인 스레드에서 실행해야 한다.

반대로 JSON 파싱, 압축 해제, 순수 C# 데이터 변환처럼 Unity 객체에 닿지 않는 무거운 계산은 별도 작업으로 분리할 여지가 있다. 다만 Task.Run은 성능을 보장하는 만능 해법이 아니다. 작은 작업을 자주 넘기면 스레드 전환 비용이 더 커질 수 있으므로 프로파일링 결과를 보고 적용한다.

var json = await fileService.ReadTextAsync(path, token);
var saveData = await Task.Run(() => ParseSaveData(json), token);

// Unity 객체 반영은 메인 스레드 복귀가 보장되는 지점에서 수행한다.
ApplySaveDataToScene(saveData);

Addressables를 쓴다면 해제까지 짝지어 관리한다

Addressables의 비동기 핸들은 로딩 완료만큼 해제 시점도 중요하다. 로드한 에셋을 계속 유지할지 화면을 떠날 때 반환할지를 소유권 기준으로 정해야 한다. 예를 들어 씬 전용 UI 에셋은 화면 수명에 맞춰 해제하고 게임 전체에서 재사용하는 공통 에셋은 별도 캐시 정책으로 관리할 수 있다.

핸들을 얻은 곳과 해제하는 곳이 멀어질수록 누수와 이중 해제가 발생하기 쉬우므로 로더나 화면 컨트롤러처럼 책임이 분명한 객체 한 곳에서 관리하는 편이 안전하다.

점검 목록

  • 공개 비동기 API는 가능하면 Task 또는 Task<T>를 반환하는가
  • 취소가 호출자의 대기 취소인지 실제 요청 중단인지 구분했는가
  • 동일 키의 동시 요청이 하나의 진행 중인 작업을 공유하는가
  • 실패한 로딩의 예외와 대체 동작이 정해져 있는가
  • Unity 객체 접근이 메인 스레드 규칙을 지키는가
  • 캐시된 리소스와 Addressables 핸들의 해제 책임이 명확한가

비동기 로딩의 품질은 await 한 줄보다 리소스 수명과 실패 경로를 얼마나 명확히 모델링했는지에서 결정된다. 작은 로더부터 결과, 취소, 중복 요청의 규칙을 고정해 두면 씬과 콘텐츠가 늘어난 뒤에도 로딩 코드를 예측 가능하게 유지할 수 있다.

#CSharp#Unity#AsyncAwait#ResourceLoading#Addressables

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs