Async Resource Loading으로 로딩 화면 스터터링 줄이기

로딩 화면이 멈추는 원인을 CPU·메인 스레드·메모리 관점에서 정리하고 비동기 리소스 로딩을 화면 전환에 안전하게 적용하는 방법을 설명합니다.

✓ 복사 완료!
챕터비동기 & 멀티스레딩
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Async Resource Loading으로 로딩 화면 스터터링 줄이기

로딩 화면은 왜 멈출까

로딩 중 진행 바와 애니메이션이 끊긴다면 장면 준비 작업과 로딩 UI 자체의 비용을 함께 측정한다. 메인 스레드가 오래 점유되는 상황은 후보 원인이지만 측정 없이 가장 흔한 원인으로 단정하지 않는다.

메인 스레드에서 수행하는 작업이 길어지면 그 프레임의 UI 갱신과 렌더 제출이 지연될 수 있다. 에셋 읽기·역직렬화·업로드, 오브젝트 생성, 셰이더 준비와 가비지 컬렉션의 실행 위치와 비용은 각각 확인한다. 모든 압축 해제나 컴파일이 메인 스레드에서 수행된다고 가정하지 않는다.

비동기 로딩의 목표는 단순히 LoadAsync를 호출하는 데 있지 않다. 무거운 작업을 시간에 나누고 준비가 끝나는 순간의 부하까지 관리해 로딩 화면이 계속 반응하도록 만드는 데 있다.

동기 로딩과 비동기 로딩의 차이

동기 로딩은 리소스 준비가 끝날 때까지 현재 호출 흐름을 붙잡는다. 장면 전환 직전에 큰 프리팹과 텍스처를 한꺼번에 읽으면 해당 프레임이 길어지고 입력, UI, 렌더링이 모두 지연된다.

비동기 로딩은 로딩 요청을 시작한 뒤 매 프레임 진행 상태를 확인할 수 있게 한다. 다만 모든 처리가 자동으로 다른 스레드에서 끝난다는 뜻은 아니다. 엔진과 플랫폼에 따라 파일 읽기나 일부 디코딩은 병렬 처리될 수 있지만 오브젝트 생성이나 활성화처럼 메인 스레드가 담당하는 단계는 남아 있을 수 있다.

flowchart LR
    A[로딩 화면 표시] --> B[비동기 리소스 요청]
    B --> C[프레임마다 진행률 갱신]
    C --> D{필수 리소스 준비 완료?}
    D -- 아니오 --> C
    D -- 예 --> E[장면 활성화 또는 게임 시작]
    E --> F[불필요한 리소스 해제]

리소스 준비와 장면 활성화를 구분하고 연출 조건을 별도로 정한다. 최소 표시 시간이나 페이드는 전환 연출 정책이며 활성화 비용이나 프레임 지연을 없애 주는 기능은 아니다.

먼저 병목을 확인한다

비동기 API로 바꾸기 전에 실제로 어느 단계가 긴지 확인해야 한다. 디스크 읽기가 병목인지 메모리 부족으로 언로드와 가비지 컬렉션이 반복되는지 장면 활성화 순간에 프리팹이 대량 생성되는지에 따라 처방이 달라진다.

Unity 2022.3의 CPU Usage Profiler 모듈과 Timeline 뷰로 로딩 구간을 살펴본다. 에셋 작업은 Asset Loading Profiler 모듈도 함께 참고한다. 특히 다음 항목을 확인한다.

  • 메인 스레드의 긴 프레임과 호출 스택
  • GC.Collect 또는 많은 할당이 발생하는 구간
  • 장면 활성화 직후의 Awake, OnEnable, Start
  • 셰이더 컴파일과 텍스처·에셋 역직렬화
  • 메모리 사용량 급증과 리소스 언로드

로딩 구간의 메인 스레드 시간을 조사하는 Profiler 설명용 화면

이미지는 설명용이며 특정 프로젝트의 실제 프로파일링 수치를 검증한 자료가 아니다. 그림의 UI는 공식 화면 안내로 사용하지 않는다. 60 FPS의 프레임 예산은 약 16.7ms다.

프로파일링 결과가 없다면 우선 가장 큰 장면을 대상으로 로딩 시작 시점, 활성화 시점, 첫 플레이 가능 시점의 시간을 로그로 남겨도 좋다. 추측보다 측정값이 우선이다.

Unity 씬 비동기 로딩의 기본 패턴

SceneManager.LoadSceneAsync는 장면을 비동기로 로드하며 allowSceneActivation으로 활성화 시점을 제어한다. 아래는 Unity 2022.3 예시다. 로더를 루트 GameObject에 하나만 두고 로딩 Canvas·Panel·Slider를 그 자식으로 배치해 Inspector 참조를 연결한다. DontDestroyOnLoad로 로더와 UI를 장면 전환 후에도 유지하며 새 장면에는 같은 로더를 다시 생성하지 않는다. 대기 중 로더를 비활성화·제거하는 취소 처리나 전환 후 게임별 준비 완료 신호는 별도 구현이 필요하다.

using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class SceneLoader : MonoBehaviour
{
    [SerializeField] private GameObject loadingPanel;
    [SerializeField] private Slider progressBar;
    [SerializeField] private float minimumLoadingTime = 0.5f;
    private bool isLoading;

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);
    }

    public void LoadGameScene(string sceneName)
    {
        if (isLoading || string.IsNullOrWhiteSpace(sceneName)) return;
        if (loadingPanel == null || progressBar == null
            || float.IsNaN(minimumLoadingTime) || float.IsInfinity(minimumLoadingTime)
            || minimumLoadingTime < 0f)
        {
            Debug.LogError("로딩 UI와 최소 표시 시간을 확인하세요.");
            return;
        }
        isLoading = true;
        StartCoroutine(LoadSceneRoutine(sceneName));
    }

    private IEnumerator LoadSceneRoutine(string sceneName)
    {
        loadingPanel.SetActive(true);
        progressBar.value = 0f;

        yield return null;

        float startedAt = Time.unscaledTime;
        AsyncOperation operation = null;
        try
        {
            operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single);
        }
        catch (System.Exception error)
        {
            Debug.LogException(error);
        }
        if (operation == null)
        {
            loadingPanel.SetActive(false);
            isLoading = false;
            yield break;
        }
        operation.allowSceneActivation = false;

        while (operation.progress < 0.9f)
        {
            progressBar.value = Mathf.Clamp01(operation.progress / 0.9f) * 0.95f;
            yield return null;
        }

        progressBar.value = 0.95f;

        while (Time.unscaledTime - startedAt < minimumLoadingTime)
        {
            yield return null;
        }

        operation.allowSceneActivation = true;

        while (!operation.isDone)
        {
            yield return null;
        }
        progressBar.value = 1f;
        loadingPanel.SetActive(false);
        isLoading = false;
    }
}

allowSceneActivation=false이면 progress가 0.9에서 멈추고 isDone은 false로 유지된다. 이때 다른 AsyncOperation의 진행도 대기할 수 있으므로 활성화 전에 뒤에 큐잉한 언로드 완료를 기다리는 식으로 구성하지 않는다. 예제는 준비 구간을 표시상 0–95%에 배정하고 isDone 후 100%로 만든다. 이 95%는 UI 정책이며 시간·바이트·실제 작업량의 비율이 아니다. 최소 0.5초는 예시 연출 값이며 로드 요청을 시작한 뒤의 경과 시간으로 검사한다.

또한 loadingPanel.SetActive(true) 직후 yield return null을 한 번 넣는 이유도 중요하다. 로딩을 바로 시작하면 UI가 그려지기 전에 무거운 작업이 시작될 수 있다. 한 프레임을 양보하면 로딩 화면이 먼저 렌더링될 기회를 얻는다.

Addressables로 리소스를 나눠 준비하기

장면 하나에 모든 것을 넣으면 장면 활성화 순간의 비용이 커질 수 있다. Addressables를 사용하면 공통 UI, 캐릭터 모델, 환경 프리팹처럼 수명과 우선순위가 다른 리소스를 별도로 요청할 수 있다.

아래는 Addressables 1.21의 핸들 사용 규칙에 맞춰 프리팹을 로드하는 예시다. 생성한 캐릭터는 스포너의 자식으로 두고 스포너와 함께 제거한다고 가정한다. 캐릭터를 분리해 더 오래 유지하거나 다른 인스턴스를 만들려면 에셋 참조의 소유권과 해제 시점도 함께 이전해야 한다.

using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class CharacterSpawner : MonoBehaviour
{
    [SerializeField] private string characterAddress;
    [SerializeField] private Transform spawnPoint;

    private AsyncOperationHandle<GameObject> handle;

    private IEnumerator Start()
    {
        handle = Addressables.LoadAssetAsync<GameObject>(characterAddress);

        while (!handle.IsDone)
        {
            Debug.Log($"Character load: {handle.PercentComplete:P0}");
            yield return null;
        }

        if (handle.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogError("캐릭터 리소스를 불러오지 못했습니다.");
            ReleaseHandle();
            yield break;
        }

        Instantiate(handle.Result, spawnPoint.position, spawnPoint.rotation, transform);
    }

    private void OnDestroy()
    {
        ReleaseHandle();
    }

    private void ReleaseHandle()
    {
        if (handle.IsValid())
        {
            Addressables.Release(handle);
            handle = default;
        }
    }
}

여기서도 로딩 완료와 인스턴스화는 별개의 비용이다. 특히 복잡한 프리팹을 여러 개 Instantiate하면 그 프레임은 다시 길어질 수 있다. 적 생성, 장비 부착, UI 구성처럼 많은 오브젝트를 만드는 작업은 코루틴이나 작업 큐로 나눠 한 프레임당 처리량을 제한하는 편이 낫다.

스터터링을 줄이는 실전 원칙

1. 활성화 직후의 초기화를 줄인다

Awake나 Start에서 많은 작업을 동기로 수행하면 활성화 프레임이 길어질 수 있다. 첫 화면에 필요한 항목과 뒤로 미룰 항목을 구분하고 분산 초기화의 효과를 측정한다.

예를 들어 월드맵의 모든 NPC를 즉시 생성하기보다 플레이어 주변의 NPC만 만들고 나머지는 거리 또는 카메라 진입에 맞춰 스트리밍한다.

2. 로딩 진행률을 정확한 시간으로 해석하지 않는다

Addressables 1.21의 PercentComplete는 하위 작업을 동일 가중치로 계산하며 다운로드 바이트 비율과 다르다. 다운로드 상태는 GetDownloadStatus()를 따로 참고한다. 씬의 progress를 포함해 이 값들은 남은 시간을 직접 뜻하지 않는다.

진행 바에 연출용 보간을 적용한다면 무엇의 완료를 표시하는지 정하고 해당 조건 이전에는 100%를 표시하지 않는 정책을 사용할 수 있다. 준비 단계와 전체 전환 완료를 따로 표시할 수도 있다. 이 표기가 이용자 신뢰에 미치는 효과는 검증하지 않았다.

3. 메모리 예산을 지킨다

메모리 부족 상황의 원인은 에셋 재로드, 운영체제의 페이징, 할당 실패 등으로 나눠 확인한다. 모든 플랫폼이 에셋을 자동으로 내렸다가 다시 읽는 것은 아니다. 대상 플랫폼에서 텍스처 해상도, 압축 형식, 오디오 로드 방식과 동시 로드 개수를 측정한다.

Addressables 메모리 관리 안내에 따라 로드와 해제를 대응시킨다. 핸들 해제는 참조 수를 줄이고 핸들을 무효화하며 메모리를 즉시 모두 반환한다는 뜻은 아니다. 수동 Instantiate로 만든 객체가 살아 있는 동안에는 사용하는 에셋의 참조를 유지한다.

4. 셰이더 준비 비용을 별도로 본다

첫 전투나 첫 이펙트에서만 끊긴다면 에셋 읽기·인스턴스화와 함께 셰이더 로딩·사전 준비 비용도 후보로 조사한다. 필요한 변형을 사전 준비하는 안을 검토하고 준비 구간과 첫 사용 프레임을 실제 기기에서 측정한다.

셰이더 변형을 무작정 모두 포함하면 빌드 크기와 로딩 비용이 커질 수 있다. 실제로 사용하는 조합을 수집해 필요한 범위를 좁히는 것이 중요하다.

로딩 화면 자체도 가볍게 유지한다

로딩 화면은 애니메이션과 팁 문구를 보여 주기 좋은 장소지만 그 화면이 무거우면 문제를 악화시킨다. 고해상도 영상, 복잡한 파티클, 매 프레임 발생하는 문자열 할당, 불필요한 레이아웃 재계산은 피한다.

문자열 생성과 UI 갱신 비용을 측정하고 표시 변화 폭이나 갱신 간격을 제한하는 안을 고려한다. 다음 예시는 텍스트 포맷팅이 아니라 Slider 값의 갱신을 1% 단위로 제한한다. 앞의 SceneLoader와는 별개로 같은 컴포넌트의 progressBar 필드를 사용한다고 가정한다.

private float lastShownProgress = -1f;

private void UpdateProgress(float progress)
{
    if (float.IsNaN(progress) || float.IsInfinity(progress)) return;
    progress = Mathf.Clamp01(progress);
    float rounded = Mathf.Floor(progress * 100f) / 100f;

    if (Mathf.Approximately(rounded, lastShownProgress))
    {
        return;
    }

    lastShownProgress = rounded;
    progressBar.value = rounded;
}

점검 목록

배포 전에는 개발 PC뿐 아니라 목표 기기에서 다음을 확인한다.

  • 차가운 실행 직후와 재실행 후의 로딩 시간이 다른가
  • 메모리가 적은 기기에서 로딩 중 강한 프레임 드롭이 발생하는가
  • 장면 활성화 직후 첫 입력이 자연스럽게 반응하는가
  • 첫 전투, 첫 이펙트, 첫 UI 진입에서 추가 스터터링이 있는가
  • 로딩 실패나 취소 상황에서 진행 UI와 리소스 참조가 정상적으로 정리되는가

마무리

비동기 로딩을 적용할 때는 리소스 준비와 장면 활성화를 구분하고 생성 비용과 메모리 사용량을 함께 측정한다. 로딩 UI를 먼저 표시하는 것과 실제 프레임 지연을 줄이는 것은 별도의 검증 항목이다.

프로파일러로 긴 프레임을 찾고 필수 리소스 로드와 초기화 작업을 분리하는 순서로 조사한다. 변경 전후의 로딩·활성화·첫 플레이 가능 시점과 프레임 시간을 비교해 개선 여부를 판단한다. 비동기 API 사용만으로 스터터링이 제거되거나 이 순서가 모든 프로젝트에서 가장 효과적이라고 보장하지 않는다.