비동기 리소스 로딩: Async/Await 기반으로 던전 층간 이동 시 로딩 스터터링 없애기

던전 탐험 게임에서 층간 이동 시 발생하는 프레임 드롭과 스터터링의 원인을 분석하고 C# Async/Await 및 타임 슬라이싱 기법을 활용해 끊김 없는 로딩 파이프라인을 구축하는 방법을 소개합니다.

✓ 복사 완료!
챕터비동기 & 멀티스레딩
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
비동기 리소스 로딩: Async/Await 기반으로 던전 층간 이동 시 로딩 스터터링 없애기

핵심 요약

던전 층간 이동 스터터링은 동기식 작업이나 대량의 오브젝트 인스턴스화가 메인 스레드에 집중될 때 발생할 수 있습니다. Addressables 비동기 로드와 UniTask 기반 타임 슬라이싱으로 작업을 분산할 수 있지만, 에셋 디코딩·씬 통합 등의 비용까지 사라지는 것은 아니므로 60 FPS 유지 여부는 대상 기기에서 프로파일링해야 합니다.

던전 층간 이동 시 발생하는 스터터링의 원인

로그라이크나 던전 탐험 장르의 게임을 개발할 때, 플레이어가 다음 층으로 이동하는 순간 화면 정지(Stuttering) 현상을 자주 겪게 됩니다. 포탈에 진입하여 다음 층의 맵, 몬스터 프리팹, 텍스처, 사운드 리소스를 불러오는 과정에서 메인 스레드가 한 번에 과도한 작업을 처리하기 때문입니다.

이러한 스터터링이 발생하는 핵심 원인은 다음 세 가지로 요약할 수 있습니다.

  1. 동기식 파일 I/O: 디스크에서 에셋 패키지나 프리팹 데이터를 한 번에 읽어오는 동안 메인 스레드가 블로킹됩니다.
  2. 동시 오브젝트 인스턴스화: 십수 개 이상의 프리팹을 한 프레임 안에 Instantiate하면 C# 객체 생성 및 네이티브 컴포넌트 초기화 비용으로 인해 프레임 타임이 급증합니다.
  3. 가비지 컬렉션(GC) 스파이크: 대량의 텍스처와 메시 에셋을 교체할 때 생성되는 임시 객체들이 동기적으로 해제되면서 GC 멈춤 현상을 유발합니다.

목표 프레임이 60 FPS인 게임에서 한 프레임에 허용되는 최대 시간 예산(Frame Budget)은 다음과 같습니다.

Tframe=100060≈16.67 msT_{\text{frame}} = \frac{1000}{60} \approx 16.67\text{ ms}

실제 게임 루프가 사용하는 시간은 프로젝트와 기기마다 다릅니다. 따라서 비동기 로딩 및 스폰 처리에 할당할 2~4ms 같은 값은 고정된 안전 예산이 아니라 프로파일링으로 조정할 시작 가정입니다.

Tload+Trender+Tlogic>TframeT_{\text{load}} + T_{\text{render}} + T_{\text{logic}} > T_{\text{frame}}

위 식의 조건을 만족하는 순간 프레임 드롭과 스터터링이 플레이어에게 직접적으로 전달됩니다.

던전 층간 이동 시 동기 로딩과 비동기 타임 슬라이싱 로딩의 프레임 타임 비교

비동기 로딩(Async/Await) 파이프라인 구조

문제 해결의 핵심은 리소스 로드와 객체 생성 작업을 단일 프레임에 몰아서 처리하지 않고 여러 프레임에 걸쳐 쪼개는 것(Time Slicing)입니다. C#의 async/await 문법과 할당을 줄이도록 설계된 UniTask를 활용하면 복잡한 코루틴이나 콜백 체인 없이도 메인 스레드 비동기 제어를 명확하게 구현할 수 있습니다.

던전 층간 이동 비동기 로딩 파이프라인의 전체적인 흐름은 다음과 같습니다.

flowchart TD
    A[플레이어 포탈 도착] --> B[비동기 로딩 파이프라인 시작]
    B --> C[다음 층 에셋 비동기 로드 - Addressables]
    C --> D[프레임 당 몬스터 및 오브젝트 분할 생성 - Time Slicing]
    D --> E[화면 연출 및 시야 전환]
    E --> F[이전 층 리소스 비동기 언로드]
    F --> G[이동 완료 및 플레이 재개]

비동기 로딩 파이프라인은 Addressables 로드 요청, 메인 스레드 인스턴스 생성, 핸들 해제 단계를 분리해 관리합니다. 비동기 API가 모든 읽기·디코딩·통합 비용을 백그라운드로 옮기는 것은 아니므로, 암전 연출 중에도 실제 프레임 타임을 측정해야 합니다.

C# Async/Await 및 타임 슬라이싱 구현

Unity 환경에서는 Addressables의 비동기 로드 작업을 기다린 뒤, UniTask.Yield()로 메인 스레드 인스턴스화 작업을 여러 PlayerLoop 프레임에 나눌 수 있습니다.

흔히 범하는 타임 슬라이싱 로직 오류

타임 슬라이싱을 구현할 때는 Stopwatch.Restart()를 매 반복마다 호출하지 말고 현재 프레임에서 누적된 작업 시간을 측정해야 합니다. 한 번의 인스턴스화가 예산보다 오래 걸리면 초과를 막을 수 없으므로 이 방식은 하드 프레임 제한이 아니라 작업 분산을 위한 최선 노력 기준입니다.

올바른 방식은 현재 프레임의 누적 소모 시간을 측정하고 설정된 예산을 초과했을 때 await UniTask.Yield()로 프레임을 넘긴 뒤 비로소 스톱워치를 리셋하는 것입니다.

실전 타임 슬라이싱 로더 코드

다음은 에셋 비동기 로드, 프레임 누적 시간 측정 그리고 Addressables 핸들 생명주기 관리를 포함한 예제 코드입니다.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class DungeonFloorLoader : MonoBehaviour
{
    [Header("Frame Budget Settings")]
    [Tooltip("한 프레임당 인스턴스화에 허용할 최대 시간(ms)")]
    [SerializeField] private float maxFrameBudgetMs = 3.0f;

    // 현재 층에서 로드한 에셋 핸들 목록 (언로드 시 해제 목적)
    private readonly List<AsyncOperationHandle<GameObject>> loadedHandles = new();
    private readonly List<GameObject> spawnedInstances = new();

    /// <summary>
    /// 다음 층의 방 및 오브젝트를 타임 슬라이싱 방식으로 비동기 생성합니다.
    /// </summary>
    public async UniTask LoadFloorAsync(List<AssetReferenceGameObject> roomAssetRefs, CancellationToken cancellationToken)
    {
        // 1. 에셋 비동기 로드 요청
        try
        {
            var loadTasks = new List<UniTask<GameObject>>();
            foreach (var assetRef in roomAssetRefs)
            {
                var handle = Addressables.LoadAssetAsync<GameObject>(assetRef);
                loadedHandles.Add(handle);
                loadTasks.Add(handle.ToUniTask(cancellationToken: cancellationToken));
            }

            // 모든 프리팹 로드 작업이 끝날 때까지 대기
            GameObject[] prefabs = await UniTask.WhenAll(loadTasks);
            await UniTask.SwitchToMainThread(cancellationToken);

            // 2. 타임 슬라이싱 기반 인스턴스화
            var stopwatch = Stopwatch.StartNew();

            for (int i = 0; i < prefabs.Length; i++)
            {
                cancellationToken.ThrowIfCancellationRequested();

                GameObject prefab = prefabs[i];
                if (prefab != null)
                {
                    GameObject instance = Instantiate(prefab);
                    spawnedInstances.Add(instance);
                }

                // 현재 프레임 누적 소모 시간이 예산을 초과하면 다음 프레임으로 작업을 넘김
                if (stopwatch.Elapsed.TotalMilliseconds >= maxFrameBudgetMs)
                {
                    await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
                    stopwatch.Restart(); // 새 프레임이 시작되었으므로 타이머 리셋
                }
            }
        }
        catch
        {
            foreach (var instance in spawnedInstances)
            {
                if (instance != null)
                    Destroy(instance);
            }
            spawnedInstances.Clear();

            foreach (var handle in loadedHandles)
            {
                if (handle.IsValid())
                    Addressables.Release(handle);
            }
            loadedHandles.Clear();
            throw;
        }
    }
}

위 코드의 핵심은 다음과 같습니다:

  • UniTask.WhenAll 동시 로드 요청: 프리팹 로드 작업을 각각 시작한 뒤 함께 기다립니다. 실제 I/O 병렬성은 Addressables와 플랫폼 구현에 따라 달라집니다.
  • 누적 시간 기반 타임 슬라이싱: stopwatch.Elapsed.TotalMilliseconds로 누적 시간을 검사해 UniTask.Yield()를 호출합니다. 한 객체 생성이 예산을 넘길 수 있으므로 프로파일링으로 값을 조정합니다.
  • 핸들 캐싱: loadedHandles 리스트에 핸들을 저장해 추후 에셋 언로드 시 메모리 누수가 발생하지 않도록 대비합니다.

던전 층 맵 구역 프리로딩과 비동기 언로드를 관리하는 구역 상태 구조도

프리로딩 및 비동기 리소스 언로드

플레이어가 포탈에 다가가거나 특정 구역에 진입하는 시점에 다음 층의 핵심 리소스 로드를 미리 요청하면, 전환 시점에 로드 작업이 겹치는 양을 줄일 수 있습니다. 실제 스터터링 감소 여부는 에셋 디코딩과 씬 통합 비용을 포함해 측정해야 합니다.

또한 이전 층의 리소스를 정리할 때도 주의해야 합니다. 단순히 오브젝트를 파괴하는 것만으로는 Addressables 번들 메모리가 해제되지 않으며 한 프레임에 수백 개의 오브젝트를 Destroy하면 네이티브 메모리 해제 비용으로 또 다른 스파이크가 일어납니다.

타임 슬라이싱 언로드 및 메모리 반환

/// <summary>
/// 이전 층의 인스턴스와 로드된 에셋 핸들을 정리하는 예시입니다.
/// </summary>
public async UniTask UnloadFloorAsync()
{
    var stopwatch = Stopwatch.StartNew();

    // 1. 인스턴스 타임 슬라이싱 파괴
    for (int i = 0; i < spawnedInstances.Count; i++)
    {
        if (spawnedInstances[i] != null)
        {
            Destroy(spawnedInstances[i]);
        }

        if (stopwatch.Elapsed.TotalMilliseconds >= maxFrameBudgetMs)
        {
            await UniTask.Yield(PlayerLoopTiming.Update);
            stopwatch.Restart();
        }
    }
    spawnedInstances.Clear();

    // 2. Addressables 에셋 핸들 반환 (참조 카운트 감소)
    foreach (var handle in loadedHandles)
    {
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
    loadedHandles.Clear();

    // 3. 미참조 에셋 가비지 컬렉션 (전환 연출 암전 중 비동기 실행)
    await Resources.UnloadUnusedAssets().ToUniTask();
}

오브젝트 1개 파괴마다 무조건 1프레임을 쉬어가면 정리 시간이 길어질 수 있습니다. 언로드 단계에서도 프레임 예산을 두고 묶음 처리할 수 있지만, Destroy와 네이티브 정리 비용은 기기·객체 구성에 따라 측정해야 합니다. 이 메서드는 취소되지 않는 정리 경로로 호출해, 이미 요청한 리소스 해제가 중단되지 않게 합니다.

실제 적용 시 고려사항

점검 항목주의사항 및 해결 방안
CancellationToken과 정리 경로오브젝트가 파괴될 때 소유한 로딩 작업을 취소하려면 this.GetCancellationTokenOnDestroy()를 전달할 수 있습니다. 토큰은 모든 Addressables 작업 자체를 자동 취소하지 않으므로, 실패·취소 시 핸들과 이미 생성한 인스턴스를 별도로 해제해야 합니다.
Addressables 참조 관리Destroy는 씬 상의 인스턴스만 지울 뿐 에셋 번들 메모리를 반환하지 않습니다. 반드시 로드한 AsyncOperationHandle을 Addressables.Release해야 번들이 언로드됩니다.
오브젝트 풀링(Object Pooling) 연계반복 재사용할 오브젝트는 풀에 반환한 뒤 재사용할 수 있습니다. 인스턴스화와 초기화 비용이 얼마나 줄어드는지는 객체 구성과 풀 처리 비용을 프로파일링해 확인합니다.
로딩 UI 진행률 정규화로딩 바 UI 갱신 시 에셋 로드 단계(0%~50%)와 인스턴스 생성 타임 슬라이싱 단계(50%~100%)로 가중치를 나누어 보간하면 유저에게 훨씬 자연스러운 시각 피드백을 줍니다.

자주 묻는 질문 (FAQ)

Task.Yield() 대신 UniTask.Yield()를 써야 하는 이유는 무엇인가요?

Task.Yield()가 항상 스레드 풀로 이동하는 것은 아닙니다. 현재 SynchronizationContext가 있으면 그 컨텍스트로 돌아오며, Unity의 메인 스레드 복귀 시점은 사용 중인 컨텍스트와 await 흐름에 따라 달라질 수 있습니다. UniTask.Yield()는 Unity PlayerLoop 시점을 지정할 수 있고 UniTask는 구조체 기반으로 할당을 줄이도록 설계됐지만, 실제 할당량은 빌드 설정과 호출 경로를 프로파일러로 확인해야 합니다.

타임 슬라이싱 프레임 예산(maxFrameBudgetMs)은 얼마가 가장 적절한가요?

60 FPS의 전체 프레임 예산은 약 16.67ms입니다. 인스턴스화에 배정할 값은 나머지 로직·렌더링의 실제 측정치와 대상 기기의 여유분을 기준으로 정하며, 2.5~4.0ms는 시험 시작값의 한 예일 뿐 보편적인 최적값은 아닙니다.

Resources.UnloadUnusedAssets()와 Addressables.Release()는 함께 써야 하나요?

역할이 다릅니다. Addressables.Release()는 해당 작업과 연결 리소스를 해제합니다. Resources.UnloadUnusedAssets()는 Unity가 사용하지 않는 것으로 판단한 에셋을 언로드하는 별도 작업이며, 매 층 전환마다 반드시 호출해야 하는 것은 아닙니다. 메모리 압박과 호출 비용을 프로파일링해 필요할 때 사용하고, 이 작업 중에도 참조 중인 에셋은 유지해야 합니다.

정리

던전 층간 이동 스터터링은 프로젝트에 따라 동기 작업, 에셋 통합, 단일 프레임 인스턴스화 등이 원인일 수 있습니다. Addressables 비동기 로드와 UniTask 타임 슬라이싱은 작업을 분산하는 수단이며, 실제 프레임률과 메모리 사용량은 대상 플랫폼에서 검증해야 합니다. 핸들 해제와 필요에 따른 풀링을 함께 관리하면 로딩 경로의 리소스 생명주기를 명확히 할 수 있습니다.