Async Resource Loading으로 로딩 화면 스터터링 없애기

Async Resource Loading으로 로딩 화면 스터터링 없애기

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

로딩 화면은 왜 멈출까

로딩 화면을 띄웠는데 진행 바가 멈추거나 애니메이션이 끊기는 현상은 흔하다. 보통 로딩 화면 자체가 느린 것이 아니라 다음 장면을 준비하는 작업이 메인 스레드를 오래 점유해서 생긴다.

텍스처 압축 해제, 메시 생성, 셰이더 변형 컴파일, 프리팹 인스턴스화, 가비지 컬렉션처럼 CPU 시간을 많이 쓰는 작업이 한 프레임에 몰리면 렌더링과 UI 갱신도 기다려야 한다. 그 결과 로딩은 진행 중이지만 플레이어에게는 화면이 멈춘 것처럼 보인다.

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

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

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

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

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

핵심은 준비활성화를 분리하는 것이다. 준비가 끝났다고 즉시 장면을 보여 주지 말고 최소 표시 시간이나 페이드아웃 같은 연출 조건까지 만족한 뒤 전환하면 더 안정적인 화면 전환을 만들 수 있다.

먼저 병목을 확인한다

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

Unity에서는 Profiler의 CPU Usage와 Timeline을 열어 로딩 구간을 살펴본다. 특히 다음 항목을 확인한다.

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

Unity Profiler에서 로딩 구간의 메인 스레드 프레임 시간을 확인하는 화면

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

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

SceneManager.LoadSceneAsync는 장면을 백그라운드에서 준비하고 allowSceneActivation으로 실제 활성화 시점을 제어할 수 있다. 아래 예제는 로딩 화면을 먼저 표시하고 장면 준비가 끝난 뒤 짧은 최소 표시 시간을 보장한다.

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;

    public void LoadGameScene(string sceneName)
    {
        StartCoroutine(LoadSceneRoutine(sceneName));
    }

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

        yield return null;

        float startedAt = Time.unscaledTime;
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
        operation.allowSceneActivation = false;

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

        progressBar.value = 1f;

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

        operation.allowSceneActivation = true;

        while (!operation.isDone)
        {
            yield return null;
        }
    }
}

Unity 씬 로딩의 progress는 활성화 전까지 보통 0.9 부근에서 멈춘다. 오류가 아니라 엔진이 장면 활성화를 기다리는 상태다. 따라서 진행 바에 그대로 넣기보다 예제처럼 0.9를 100%로 환산해야 한다.

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

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

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

아래 코드는 주소를 이용해 프리팹을 비동기로 불러온 뒤 인스턴스화하는 간단한 예다.

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("캐릭터 리소스를 불러오지 못했습니다.");
            yield break;
        }

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

    private void OnDestroy()
    {
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
}

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

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

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

AwakeStart에서 모든 데이터를 탐색하거나 풀을 대량 생성하면 비동기 로딩의 이점이 사라진다. 첫 화면에 필요한 항목만 먼저 초기화하고 멀리 있는 지역의 오브젝트나 선택 사항은 플레이 시작 후 분산해서 준비한다.

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

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

PercentComplete나 씬 로딩 진행률은 작업의 내부 단계와 가중치에 따라 움직인다. 플레이어에게 정확한 남은 시간을 약속하는 용도보다 현재 준비 상태를 전달하는 용도로 쓰는 편이 안전하다.

진행 바가 너무 오래 정지해 보인다면 실제 진행률에 연출용 보간을 적용할 수 있다. 단, 실제 완료 전 100%를 표시하지 않아야 한다. 완료된 것처럼 보인 뒤 다시 기다리게 하면 신뢰를 잃기 쉽다.

3. 메모리 예산을 지킨다

메모리가 부족하면 로딩 중 리소스를 내리고 다시 읽는 일이 생긴다. 이는 디스크 접근과 CPU 작업을 늘리고 프레임 시간도 불안정하게 만든다. 대상 플랫폼에서 텍스처 해상도, 압축 형식, 오디오 로드 방식, 동시 로드 개수를 함께 점검해야 한다.

Addressables의 참조를 해제할 때도 주의가 필요하다. 아직 사용하는 에셋을 너무 일찍 Release하면 재로딩이 발생하거나 예기치 않은 문제가 생길 수 있다. 리소스의 소유자와 해제 시점을 명확히 정한다.

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

첫 전투나 첫 이펙트에서만 끊긴다면 리소스 파일이 아니라 셰이더 변형 준비가 원인일 수 있다. 자주 사용하는 셰이더와 변형을 로딩 구간이나 타이틀 화면에서 미리 준비하고 실제 기기에서 첫 사용 프레임을 측정한다.

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

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

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

특히 UI 텍스트를 매 프레임 새 문자열로 갱신하는 방식은 할당을 만들 수 있다. 진행률 표시가 꼭 필요하다면 일정한 변화 폭이나 일정 시간 간격으로만 갱신하는 방식을 고려한다.

private float lastShownProgress = -1f;

private void UpdateProgress(float progress)
{
    float rounded = Mathf.Floor(progress * 100f) / 100f;

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

    lastShownProgress = rounded;
    progressBar.value = rounded;
}

점검 목록

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

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

마무리

Async Resource Loading은 로딩 시간을 마법처럼 없애지 않는다. 대신 긴 작업을 관찰하고 분산할 수 있게 해 준다. 로딩 화면을 먼저 표시하고 리소스 준비와 장면 활성화를 분리하며 활성화 직후의 생성 비용과 메모리 사용량까지 함께 관리해야 체감 가능한 개선으로 이어진다.

가장 효과적인 순서는 단순하다. 프로파일러로 긴 프레임을 찾고 꼭 필요한 리소스부터 비동기로 준비한 뒤 남은 초기화 작업을 여러 프레임으로 나눈다. 이 과정을 반복하면 로딩 화면의 멈춤뿐 아니라 실제 플레이 중 발생하는 첫 진입 스터터링도 함께 줄일 수 있다.

#Unity#Async Loading#Addressables#Loading Screen#Optimization

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs