Unity Async Resource Loading으로 씬 전환 스터터링 해결하기

Unity 씬 전환에서 발생하는 프레임 드롭과 입력 멈춤은 메인 스레드의 동기 로딩이 원인인 경우가 많습니다. Addressables와 LoadSceneAsync를 이용해 리소스 로딩, 활성화, 해제를 분리하는 실전 구조와 프로파일링 기준을 정리합니다.

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

핵심 요약

씬 전환 스터터링은 텍스처·메시·셰이더·씬 데이터를 한 프레임에 동기 로딩하면서 메인 스레드가 오래 점유될 때 발생한다. Unity에서는 SceneManager.LoadSceneAsync 또는 Addressables의 LoadSceneAsync로 로딩을 분리하고 로딩 완료 뒤 allowSceneActivation 또는 activateOnLoad로 활성화 시점을 제어하면 플레이 중 프레임 드롭을 줄일 수 있다.

핵심은 비동기 API를 호출하는 것만으로 끝내지 않고 씬 활성화와 대형 UI 생성, 셰이더 워밍업, 기존 리소스 해제를 같은 프레임에 몰지 않는 것이다.

씬 전환 스터터링은 왜 발생하는가?

Unity의 씬 전환 비용은 씬 파일을 읽는 시간만 뜻하지 않는다. 게임 오브젝트 역직렬화, Awake·OnEnable 실행, 텍스처와 메시 업로드, 셰이더 변형 준비, UI 레이아웃 계산, 가비지 컬렉션까지 전환 구간에 겹칠 수 있다.

특히 SceneManager.LoadScene은 동기 로딩이다. 호출 프레임에서 로딩과 활성화 작업이 완료될 때까지 게임 루프가 진행되지 않을 수 있으므로 화면이 멈추거나 프레임 시간이 급격히 늘어난다.

작업스터터링이 생기는 대표 원인분리 방법
씬 데이터 읽기동기 LoadScene 호출LoadSceneAsync 사용
씬 활성화다수의 Awake, OnEnable, 첫 프레임 초기화활성화 시점 제어, 초기화 분산
UI 표시대형 Canvas 재빌드와 레이아웃 계산로딩 화면을 사전 유지, UI 지연 생성
셰이더 준비처음 사용하는 Shader Variant 컴파일·로드Shader Variant Collection 워밍업
메모리 정리전환 중 Resources.UnloadUnusedAssets와 GC 실행안전한 로딩 화면 구간에서 실행

Unity Profiler Timeline에서 메인 스레드의 동기 씬 로딩 스파이크를 확인하는 예시

Async Resource Loading은 어떻게 씬 전환을 부드럽게 만드는가?

AsyncOperation은 로딩 작업을 여러 프레임에 걸쳐 진행하게 한다. 로딩 진행률은 0.9까지 올라간 뒤 씬 활성화를 기다리는 방식으로 동작한다. 이 구간에서 페이드 아웃, 로딩 UI, 네트워크 동기화 같은 전환 준비를 처리할 수 있다.

flowchart LR
    A[플레이 중 씬] --> B[페이드 아웃 시작]
    B --> C[비동기 씬 로딩]
    C --> D{로딩 준비 완료?}
    D -- 아니오 --> C
    D -- 예 --> E[기존 씬 정리]
    E --> F[새 씬 활성화]
    F --> G[페이드 인]

다만 비동기 로딩은 모든 비용을 없애지 않는다. 새 씬을 활성화하는 순간에는 게임 오브젝트 생성과 라이프사이클 콜백이 집중될 수 있다. 따라서 활성화 직후의 초기화량도 관리해야 한다.

Unity LoadSceneAsync로 씬 전환을 구현하는 방법

1. 로딩 화면을 먼저 표시한다

로딩 UI는 전환할 씬 안에 두기보다 부트스트랩 씬 또는 DontDestroyOnLoad 오브젝트에 유지하는 편이 안정적이다. 새 씬이 활성화되기 전에도 즉시 표시할 수 있기 때문이다.

2. 씬을 비동기로 로드하고 활성화를 보류한다

다음 예제는 단일 씬 전환에서 사용할 수 있는 최소 구조다. allowSceneActivation = false로 두면 진행률이 0.9에 도달한 뒤 활성화만 대기한다.

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

public sealed class SceneTransition : MonoBehaviour
{
    [SerializeField] private CanvasGroup loadingCanvas;

    public IEnumerator LoadAsync(string sceneName)
    {
        yield return Fade(1f);

        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
        operation.allowSceneActivation = false;

        while (operation.progress < 0.9f)
        {
            // Unity의 0.0~0.9 진행률을 UI용 0~1 값으로 변환한다.
            float progress = Mathf.Clamp01(operation.progress / 0.9f);
            Debug.Log($"Loading: {progress:P0}");
            yield return null;
        }

        operation.allowSceneActivation = true;
        yield return operation;
        yield return Fade(0f);
    }

    private IEnumerator Fade(float targetAlpha)
    {
        loadingCanvas.alpha = targetAlpha;
        yield return null;
    }
}

3. 활성화 이후의 첫 프레임 비용을 줄인다

LoadSceneAsync가 끝난 직후 무거운 초기화를 한 곳에 모으면 스터터링이 다시 생긴다. NPC 수십 개 생성, Addressables 프리팹 인스턴스화, 대형 목록 UI 바인딩은 코루틴으로 나누거나 플레이어 입력이 가능한 시점 이후로 미룬다.

예를 들어 적 생성은 한 프레임에 전부 처리하지 말고 일정 개수씩 나눈다.

private IEnumerator SpawnEnemies(GameObject prefab, int count)
{
    const int batchSize = 5;

    for (int i = 0; i < count; i++)
    {
        Instantiate(prefab);

        if ((i + 1) % batchSize == 0)
            yield return null;
    }
}

Addressables는 언제 사용하는 것이 좋은가?

LoadSceneAsync는 Build Settings에 포함된 씬을 전환하는 간단한 경우에 충분하다. 반면 씬과 함께 내려받을 콘텐츠, 메모리 수명, 의존성 번들을 관리해야 한다면 Unity Addressables가 더 적합하다.

상황권장 방식
소규모 게임, 빌드에 포함된 고정 씬SceneManager.LoadSceneAsync
DLC, 원격 카탈로그, 다운로드 콘텐츠Addressables
씬별 에셋의 명시적 로드·해제 필요Addressables
애드티브 씬을 여러 개 유지Addressables 또는 LoadSceneMode.Additive

Addressables에서는 activateOnLoad: false로 활성화를 지연할 수 있다. 로드 핸들은 해제 시 필요하므로 보관해야 한다.

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

public sealed class AddressableSceneLoader : MonoBehaviour
{
    private AsyncOperationHandle<SceneInstance> sceneHandle;

    public IEnumerator LoadScene(string key)
    {
        sceneHandle = Addressables.LoadSceneAsync(key, activateOnLoad: false);
        yield return sceneHandle;

        AsyncOperationHandle<SceneInstance> activation = sceneHandle.Result.ActivateAsync();
        yield return activation;
    }

    public IEnumerator UnloadScene()
    {
        if (!sceneHandle.IsValid())
            yield break;

        yield return Addressables.UnloadSceneAsync(sceneHandle);
    }
}

Addressables를 사용하면서 Resources.Load와 수동 번들 로딩을 같은 자산에 섞으면 참조 수명 추적이 어려워진다. 하나의 콘텐츠 경로에서는 로딩 책임을 한 방식으로 통일하는 편이 좋다.

씬 전환 최적화에서 확인할 프로파일링 지표

추측으로 최적화하지 말고 Unity Profiler의 CPU Usage와 Timeline에서 전환 구간을 측정한다. 아래 항목이 길게 보이면 대응 위치를 찾기 쉽다.

  • Loading.ReadObject, Loading.AwakeFromLoad: 에셋 역직렬화와 로드 후 초기화 비용
  • GC.Collect: 대량 할당 뒤 가비지 컬렉션 발생 여부
  • Canvas.SendWillRenderCanvases: UI 리빌드 비용
  • BehaviourUpdate, PlayerLoop: 활성화 직후 스크립트 초기화 집중 여부
  • Rendering 구간의 셰이더 관련 스파이크: 첫 렌더 시 셰이더 변형 준비 여부

프레임 예산도 같이 본다. 목표가 60 FPS라면 한 프레임 예산은 다음과 같다.

1000 ms60 FPS≈16.67 ms\frac{1000\text{ ms}}{60\text{ FPS}} \approx 16.67\text{ ms}

전환 중 일시적으로 로딩 화면을 보여도 활성화 프레임이 수백 ms까지 늘어나면 사용자는 멈춤으로 인식한다. 최적화 목표는 평균 FPS보다 이 최악 프레임 시간을 낮추는 데 있다.

자주 묻는 질문 (FAQ)

LoadSceneAsync를 쓰면 스터터링이 완전히 사라지나요?

아니다. 파일 읽기와 일부 로드는 분산되지만 씬 활성화, Awake, UI 생성, 셰이더 준비 비용은 여전히 스파이크를 만들 수 있다. 활성화 직후 초기화를 나누고 Profiler로 병목을 확인해야 한다.

operation.progress가 0.9에서 멈춘 것은 버그인가요?

아니다. allowSceneActivation이 false이면 Unity는 로딩 준비가 된 뒤 진행률을 0.9에 유지하고 활성화를 기다린다. true로 변경하면 다음 단계로 진행된다.

Resources.UnloadUnusedAssets는 언제 호출해야 하나요?

필요할 때만 로딩 화면이 이미 표시된 구간에서 호출한다. 매 전환마다 습관적으로 실행하면 정리 자체가 프레임 스파이크를 만들 수 있다.

정리

씬 전환 스터터링의 첫 해결책은 동기 LoadScene을 비동기 로딩으로 바꾸는 것이다. 그 다음에는 씬 활성화 시점, 첫 프레임 초기화, UI 리빌드, 셰이더 준비, 메모리 정리를 분리해 최악 프레임 시간을 측정하고 줄인다.

작은 프로젝트는 SceneManager.LoadSceneAsync로 시작해도 충분하다. 콘텐츠 다운로드와 에셋 수명 관리가 필요해질 때만 Addressables로 확장하면 된다.