작성일 :

에셋이 모여 이루는 가장 큰 단위

Part 2에서 에셋이 직렬화되어 디스크에 저장되고, 역직렬화되어 메모리에 올라가는 과정을 다루었습니다. Instantiate가 오브젝트를 복제할 때 공유 에셋은 참조만 복사한다는 점, Resources 폴더의 한계도 확인했습니다.


이 글에서는 에셋들이 모여 구성하는 가장 큰 단위인 씬(Scene)의 관리 방법을 살펴봅니다. 로딩과 언로딩을 통해 게임의 흐름(메뉴 → 게임 플레이 → 결과 화면)을 제어하는 기본 메커니즘이며, 전환 시점마다 대규모 메모리 할당과 해제가 발생합니다. 한 번의 전환에 수십~수백 MB의 에셋이 해제되고 다시 로드되므로, 씬 관리 전략은 곧 메모리 관리의 핵심 축이 됩니다.


씬(Scene)의 구조

씬은 GameObject들의 집합입니다. 카메라, 조명, 캐릭터, 배경, UI 등 게임 화면을 구성하는 모든 오브젝트가 하나의 씬에 포함됩니다.

씬 (.unity 파일) Main Camera (GameObject) Transform Camera AudioListener Directional Light (GameObject) Transform Light Player (GameObject) Transform MeshRenderer Material Texture Rigidbody PlayerController (스크립트) Environment (GameObject) Ground Building_01 Building_02 Canvas (UI) HealthBar ScoreText 각 GameObject 안에 컴포넌트들이 포함됨 MeshRenderer → Material → Texture 는 에셋 참조 관계

Part 2에서 다루었듯이, 씬 파일(.unity)은 YAML 형식으로 저장됩니다. 씬에 포함된 모든 GameObject와 컴포넌트가 직렬화되어 기록되고, 각 오브젝트는 fileID로 식별됩니다. 에디터에서 씬을 저장하면 이 YAML 데이터가 디스크에 기록되고, 씬을 로드하면 역직렬화를 통해 메모리에 오브젝트를 복원합니다.

Build Settings에 씬 등록

빌드에 포함할 씬은 Build Settings(File → Build Settings)의 Scenes In Build 목록에 등록해야 합니다. 등록된 씬은 인덱스 번호를 부여받으며, 인덱스 0번 씬이 앱 실행 시 가장 먼저 로드되는 씬입니다.


Build Settings의 씬 목록 Scenes In Build: 0 Scenes/Loading.unity ← 앱 시작 시 로드 1 Scenes/MainMenu.unity 2 Scenes/GamePlay.unity 3 Scenes/Result.unity → 인덱스 0번이 첫 실행 씬 → 목록에 없는 씬은 빌드에 포함되지 않음 → 씬 이름 또는 인덱스로 런타임 로딩 가능


Unity의 Addressables 시스템을 사용하면 Build Settings에 등록하지 않은 씬도 런타임에 로드할 수 있지만, SceneManager를 통한 기본적인 씬 전환에는 Build Settings 등록이 필요합니다.


SceneManager.LoadScene — 동기 로딩

SceneManager.LoadScene은 씬을 동기적(Synchronous)으로 로드합니다. 호출 자체는 즉시 반환되어 같은 프레임 내 나머지 코드가 계속 실행되지만, 실제 씬 로딩은 다음 프레임에서 수행됩니다. 로딩이 완료될 때까지 메인 스레드가 블로킹되는데, Unity는 게임 로직, 렌더링 명령 생성, 입력 처리를 모두 메인 스레드에서 순차 실행하므로 블로킹 동안 화면 갱신과 입력 처리가 멈춥니다.

동기 로딩의 실행 흐름 프레임 N 호출 시점 SceneManager.LoadScene("GamePlay") 호출 호출 이후 같은 프레임 내 나머지 코드는 계속 실행됨 다음 프레임 (메인 스레드 블로킹 — 로딩 완료까지) 1. 현재 씬의 모든 오브젝트 파괴 (OnDisable → OnDestroy) 2. 새 씬 파일 읽기 (디스크 I/O) 3. 참조 에셋 로딩 (텍스처, 메쉬, 오디오 등) 4. 모든 오브젝트 역직렬화 (메모리 배치) 5. Awake() 호출 6. OnEnable() 호출 화면 갱신 없음 (게임 멈춤) 로딩 완료 후 첫 프레임 Start() 호출 (첫 Update() 직전) 새 씬의 첫 Update() 실행 실제 씬 전환은 다음 프레임에서 수행 · 로딩 완료까지 메인 스레드 블로킹 · 화면 갱신 없음

동기 로딩의 가장 큰 문제는 프레임 정지입니다. 씬의 에셋 총량이 클수록, 참조하는 에셋이 많을수록 정지 시간이 길어집니다. 에셋이 많은 게임 씬을 동기 로딩하면 2~5초 이상 화면이 멈출 수 있습니다. 사용자에게는 앱이 멈춘 것처럼 보이며, 모바일 OS가 응답 없음으로 판단하여 앱을 강제 종료하는 경우도 발생합니다.

동기 로딩이 적합한 경우는 제한적입니다. 앱 시작 시 첫 번째 씬을 로드하는 경우(어차피 스플래시 화면이 표시됨), 또는 에셋이 거의 없는 작은 씬을 로드하는 경우에만 사용합니다.

기존 씬의 처리

기본 동작(LoadSceneMode.Single)에서는 새 씬을 로드하기 전에 현재 씬의 모든 오브젝트를 파괴합니다. 각 오브젝트의 OnDisable, OnDestroy 순서로 콜백이 호출됩니다. 새 씬 로드가 완료되면 Unity가 자동으로 Resources.UnloadUnusedAssets()를 호출하여, 이전 씬의 오브젝트만 참조하던 에셋을 메모리에서 해제합니다. 다만 이 자동 해제는 새 씬의 에셋 로딩이 끝난 뒤에 수행되므로, 전환 중에는 이전 씬과 새 씬의 에셋이 동시에 메모리에 올라가는 피크 구간이 생길 수 있습니다.


SceneManager.LoadSceneAsync — 비동기 로딩

SceneManager.LoadSceneAsync는 씬을 비동기적(Asynchronous)으로 로드하여 동기 로딩의 프레임 정지 문제를 해결합니다. 파일 읽기(I/O)와 역직렬화의 상당 부분은 백그라운드 스레드에서 수행되고, 로드된 오브젝트를 씬에 통합(Integration)하는 작업은 메인 스레드에서 여러 프레임에 걸쳐 분산 처리됩니다. 따라서 로딩이 진행되는 동안에도 게임이 계속 실행됩니다.

비동기 로딩의 실행 흐름 시간 N N+1 N+2 N+3 N+K N+K+1 백그라운드 스레드 파일 I/O + 역직렬화 디스크 읽기, 에셋 데이터 역직렬화 메인 스레드 게임 실행 + 오브젝트 통합 처리 화면 갱신 유지, 매 프레임 분산 처리 씬 활성화 Awake() OnEnable() Start() progress: 1.0 progress 0.0 0.1 0.3 0.5 0.9 1.0 에셋 로딩 구간 (0.0 ~ 0.9) 활성화 (0.9 ~ 1.0) 백그라운드 스레드: I/O와 역직렬화 담당 메인 스레드: 게임 실행 유지 + 오브젝트 통합 담당 로딩 중에도 화면 갱신과 입력 처리가 계속됨

AsyncOperation과 progress

LoadSceneAsync는 로딩 상태를 추적할 수 있는 AsyncOperation 객체를 반환합니다. progress 프로퍼티의 값은 0에서 1까지 변화하지만, 실제 로딩 작업은 0~0.9 구간에서 수행됩니다. 0.9에서 1.0으로의 전환은 씬 활성화(오브젝트 초기화) 단계에 해당합니다.

progress 값의 의미 에셋 로딩 구간 디스크 읽기 · 역직렬화 · 메모리 배치 활성화 Awake · Start 0.0 0.9 1.0 로딩 바 표시 비율 = operation.progress / 0.9f 0.9 이전까지를 100%로 환산 progress가 0.9에 도달하면 에셋 로딩 완료, 이후 씬 활성화 단계 진입

allowSceneActivation으로 활성화 시점 제어

AsyncOperation.allowSceneActivationfalse로 설정하면, progress가 0.9에 도달해도 씬이 활성화되지 않고 대기합니다. 데이터 로딩이 끝난 뒤 원하는 시점(사용자 입력, 애니메이션 종료 등)에 allowSceneActivation = true로 설정하면 씬이 활성화됩니다.


allowSceneActivation 활용 1. 로딩 시작 AsyncOperation op = SceneManager.LoadSceneAsync("GamePlay"); op.allowSceneActivation = false; 2. 로딩 진행 (progress → 0.9) 씬 데이터는 메모리에 준비 완료 활성화되지 않음 — 대기 상태 3. 원하는 시점에 활성화 op.allowSceneActivation = true; → Awake, OnEnable, Start 호출 → 씬 전환 완료 활용 예: • 로딩 화면의 "터치하여 시작" 연출 • 최소 로딩 시간 보장 (너무 빠르면 로딩 화면이 깜빡임) • 다른 비동기 작업(네트워크 등)과 동기화


allowSceneActivationfalse인 동안에는 AsyncOperation.isDonetrue가 되지 않습니다. 로딩 완료를 isDone으로 검사하는 코드(예: 코루틴의 yield return operation)는 allowSceneActivationtrue로 전환될 때까지 완료되지 않습니다. 로딩 완료 여부를 판단하려면 progress >= 0.9f 조건을 사용해야 합니다.

동기 로딩과 비동기 로딩을 혼용할 때 주의할 점이 있습니다. SceneManager.LoadScene은 호출 시점에 진행 중인 모든 AsyncOperation을 강제 완료시킵니다. allowSceneActivationfalse로 설정하여 활성화를 보류한 비동기 로딩이 있더라도 즉시 완료 처리되므로, 의도하지 않은 씬 활성화가 발생할 수 있습니다.


비동기 로딩이 프레임 드롭을 완전히 제거하지는 않습니다. Unity는 매 프레임 메인 스레드에서 오브젝트 통합에 쓸 수 있는 시간을 제한하지만, 단일 에셋의 통합 작업이 이 시간 예산을 초과하면 해당 프레임에서 스파이크가 발생합니다. 씬 활성화 시점에 호출되는 Awake/Start 콜백에서 무거운 초기화(대량의 오브젝트 생성, 복잡한 데이터 구조 구축 등)를 수행하는 경우에도 스파이크의 원인이 됩니다.

Application.backgroundLoadingPriority는 매 프레임 오브젝트 통합에 허용되는 메인 스레드 시간의 상한을 결정합니다. 기본값(ThreadPriority.Normal)은 프레임당 최대 10ms입니다. 60 FPS 기준으로 한 프레임의 전체 시간은 약 16.7ms이므로, 통합에 10ms를 쓰면 게임 로직과 렌더링에 남는 시간은 약 6.7ms뿐입니다. ThreadPriority.Low로 낮추면 프레임당 통합 시간이 2ms로 줄어들어 프레임 드롭이 완화되지만, 프레임당 처리량이 줄어든 만큼 총 로딩 시간은 길어집니다.


Additive 씬 로딩

지금까지 다룬 씬 로딩은 모두 기존 씬을 파괴하고 새 씬으로 교체하는 LoadSceneMode.Single 방식이었습니다. 이와 달리, Additive 모드는 기존 씬을 유지한 채 추가 씬을 함께 로드합니다.

Single 모드 vs Additive 모드 LoadSceneMode.Single 상태 1 씬 A LoadScene("B", Single) 상태 2 씬 B 씬 A 파괴됨 LoadSceneMode.Additive 상태 1 씬 A Additive 상태 2 씬 A + 씬 B Additive 상태 3 씬 A + 씬 B + 씬 C 세 씬 동시 활성화

Additive 씬의 활용

Additive 씬 로딩을 활용하면 UI, 게임 플레이, 환경을 각각 별도 씬으로 분리하고, 필요한 부분만 독립적으로 로드하거나 교체할 수 있습니다.

Additive 씬 활용 예시 예시 1: UI와 게임 분리 Base Scene (항상 로드) 게임 매니저 이벤트 시스템 고정 UI Scene (Additive) Canvas HUD, 메뉴, 인벤토리 Additive GamePlay Scene (Additive) 카메라 조명 플레이어, 적, 환경 Additive 세 씬이 동시에 활성화 예시 2: 던전/스테이지 분리 Persistent Scene 플레이어 UI 항상 유지 Dungeon_Floor_1 현재 로드 현재 층의 지형, 적, 오브젝트 교체 Dungeon_Floor_2 다음 층 진입 시 다음 층의 지형, 적, 오브젝트 Persistent Scene은 유지, 콘텐츠 씬만 교체

SetActiveScene

Additive로 여러 씬이 동시에 로드되어 있으면, 런타임에 생성하는 오브젝트(Instantiate, new GameObject 등)의 소속 씬을 결정해야 합니다.

nity는 Active Scene으로 지정된 씬에 새 오브젝트를 소속시키며, SceneManager.SetActiveScene(scene)으로 Active Scene을 전환할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
// 로드된 씬: [UI Scene] + [GamePlay Scene (Active)]

Instantiate(bulletPrefab);
// → bulletPrefab의 인스턴스는 GamePlay Scene에 소속

SceneManager.SetActiveScene(uiScene);

Instantiate(tooltipPrefab);
// → tooltipPrefab의 인스턴스는 UI Scene에 소속

// Active Scene 설정은 오브젝트의 소속 씬을 결정
// 씬 언로드 시 해당 씬에 소속된 오브젝트만 파괴됨


Active Scene을 올바르게 설정해야 하는 이유는 두 가지입니다.

첫째, 오브젝트의 소속 씬이 언로딩 동작에 영향을 줍니다. 특정 씬을 언로드하면 그 씬에 소속된 오브젝트만 파괴됩니다. 오브젝트가 잘못된 씬에 소속되어 있으면, 의도하지 않은 시점에 파괴되거나 파괴되지 않는 문제가 발생합니다.

둘째, Active Scene이 전역 라이팅 설정을 결정합니다. 라이트맵은 각 씬에 독립적으로 베이크되어 Additive로 로드된 씬도 자기 자신의 라이트맵을 사용하지만, 환경 조명·스카이박스·포그 같은 전역 설정은 Active Scene의 것이 적용됩니다. 콘텐츠 씬을 교체할 때 SetActiveScene을 올바른 씬으로 전환하지 않으면, 이전 씬의 환경 라이팅이 그대로 남아 시각적 이상이 발생할 수 있습니다.


DontDestroyOnLoad

씬을 전환(LoadSceneMode.Single)하면 현재 씬의 모든 오브젝트가 파괴됩니다. 그런데 게임 매니저, 오디오 매니저, 네트워크 매니저처럼 게임 전체 수명 동안 유지되어야 하는 오브젝트가 있습니다. DontDestroyOnLoad(gameObject)를 호출하면, 해당 오브젝트는 씬 전환 시에도 파괴되지 않고 유지됩니다.

DontDestroyOnLoad의 동작 씬 전환 전 Main Menu Scene MenuUI 일반 오브젝트 GameManager DDOL* 적용 씬 전환 (LoadScene Single) 씬 전환 후 MenuUI 파괴됨 DontDestroyOnLoad (별도 씬) GameManager 파괴되지 않고 유지 GamePlay Scene Player Environment MenuUI는 씬 전환 시 파괴됨 GameManager는 DontDestroyOnLoad 씬으로 이동하여 유지 * DDOL = DontDestroyOnLoad

DontDestroyOnLoad를 호출하면 해당 오브젝트는 기존 씬에서 분리되어, Unity가 내부적으로 관리하는 별도의 DontDestroyOnLoad 씬으로 이동합니다. 이 씬은 Hierarchy 창에서 별도로 표시되며, 런타임에서 gameObject.scene.name을 확인하면 "DontDestroyOnLoad"라는 이름이 반환됩니다.

DontDestroyOnLoad는 루트 GameObject에만 동작합니다. 자식 오브젝트에 DontDestroyOnLoad(childObject)를 호출하면 Unity는 “DontDestroyOnLoad only works for root GameObjects or components on root GameObjects”라는 경고를 출력하며, 해당 호출은 무시됩니다. 루트 GameObject에 DontDestroyOnLoad를 적용하면, 그 오브젝트의 모든 자식도 함께 보존됩니다.

반대로, DontDestroyOnLoad 씬에 있는 오브젝트를 다시 특정 씬으로 되돌리고 싶다면 SceneManager.MoveGameObjectToScene(gameObject, targetScene)을 사용합니다.

이 메서드 역시 루트 GameObject에만 동작하며, 자식 오브젝트에 호출하면 InvalidOperationException이 발생합니다. 더 이상 영구 보존할 필요가 없는 오브젝트를 일반 씬으로 옮겨, 해당 씬이 언로드될 때 함께 파괴되도록 할 때 활용합니다.

일반적인 사용 대상

DontDestroyOnLoad는 게임 전체 수명 동안 유지되어야 하는 시스템 오브젝트에 사용합니다.

DontDestroyOnLoad 대표 사용처 적합한 사용처 GameManager 게임 상태 관리 (점수, 진행도) AudioManager BGM 끊김 없이 재생 NetworkManager 서버 연결 유지 InputManager 입력 설정 유지 EventSystem UI 이벤트 처리 (주의: 중복 방지 필요) 부적합한 사용처 특정 씬에서만 필요한 오브젝트 임시 데이터를 가진 오브젝트 UI 요소 (특정 화면의 UI)

EventSystem을 DontDestroyOnLoad로 설정하는 경우, 새 씬에도 EventSystem이 있으면 두 개가 동시에 존재하게 됩니다. Unity는 하나의 활성 EventSystem만 허용하므로, 중복을 감지하여 자기 자신을 파괴하는 로직을 추가해야 합니다.

반대로, 특정 화면에서만 쓰이는 UI 패널이나 임시 이펙트처럼 수명이 한정된 오브젝트에 DontDestroyOnLoad를 적용하면, 씬이 바뀐 뒤에도 메모리에 남아 낭비로 이어집니다.

싱글턴 중복 인스턴스 문제

DontDestroyOnLoad 오브젝트는 대부분 싱글턴 패턴으로 구현됩니다. 그런데 씬 A에서 GameManager를 DontDestroyOnLoad로 등록한 뒤, 게임 도중 씬 A로 다시 돌아오면 씬 A가 새로 로드되면서 GameManager가 한 번 더 생성됩니다. 기존 인스턴스는 DontDestroyOnLoad 씬에 이미 존재하므로, 두 개의 GameManager가 동시에 존재하게 됩니다. 이 중복을 방지하려면 Awake에서 기존 인스턴스 여부를 확인하고, 이미 존재하면 새로 생성된 자신을 즉시 파괴하는 로직이 필요합니다.


남용 시 메모리 누수 위험

DontDestroyOnLoad 오브젝트에서 대량의 텍스처나 오디오 클립을 직접 참조하고 있으면, 그 에셋들은 씬 전환과 무관하게 게임 전체 수명 동안 메모리에 상주합니다.

AudioManager (DontDestroyOnLoad) BGM_MainMenu .ogg (3MB) 메인 메뉴에서만 필요 BGM_GamePlay .ogg (5MB) 게임 중에만 필요 BGM_Boss .ogg (4MB) 보스전에서만 필요 BGM_Ending .ogg (3MB) 엔딩에서만 필요 3 + 5 + 4 + 3 = 15MB 항상 메모리에 상주 해결: BGM을 직접 참조하지 않고 필요 시 Addressables로 동적 로드/해제 DDOL 오브젝트가 에셋을 직접 참조하면 씬 전환과 무관하게 메모리 점유

DontDestroyOnLoad 오브젝트는 가능한 한 가볍게 유지해야 합니다. 에셋 데이터를 직접 참조하지 않고 Addressables 등으로 동적 로드/해제하며, 씬별로 필요한 리소스는 해당 씬에서만 로드하는 것이 원칙입니다. 적용 대상의 수 자체도 최소한으로 제한해야 합니다.


씬 언로딩과 메모리 해제

씬을 전환하거나 Additive 씬을 제거할 때, 메모리가 즉시 해제되지는 않습니다. 오브젝트 파괴와 에셋 메모리 해제는 별도의 과정이기 때문입니다.

오브젝트 파괴 vs 에셋 해제

씬을 언로드하면 그 씬에 소속된 GameObject와 컴포넌트가 파괴됩니다. 파괴되는 오브젝트에서 실행 중이던 코루틴(Coroutine)은 자동으로 중단됩니다. 코루틴은 MonoBehaviour가 소유하고 실행하는 함수이므로, 해당 오브젝트와 생명주기를 공유하기 때문입니다.

반면, 해당 오브젝트가 다른 시스템에 등록한 이벤트 핸들러(예: 버튼의 onClick, C# 이벤트 구독 등)는 자동으로 해제되지 않습니다. 이벤트 시스템은 구독자의 생존 여부를 추적하지 않기 때문입니다. 파괴된 오브젝트의 메서드를 콜백으로 여전히 참조하게 되면 MissingReferenceException이 발생하므로, OnDestroy에서 이벤트 구독을 해제하는 것이 안전합니다.

오브젝트가 파괴되어도, 그 오브젝트가 참조하던 에셋(텍스처, 메쉬, 오디오 클립 등)의 메모리는 즉시 해제되지 않습니다.

씬 A 언로드 시 단계 1 — 오브젝트 파괴 Player (파괴) Enemy (파괴) Environment (파괴) OnDisable, OnDestroy 호출 Texture_A Mesh_B Audio_C 메모리에 잔류 단계 2 — 에셋 해제 (별도 호출 필요) Resources.UnloadUnusedAssets() 참조 없는 에셋 해제: Texture_A, Mesh_B, Audio_C Texture_A — 해제됨 Mesh_B, Audio_C — 해제됨 ※ 다른 씬이나 DDOL 오브젝트가 참조 중이면 해제되지 않음 오브젝트 파괴와 에셋 메모리 해제는 별도 과정

오브젝트가 파괴되면 그 오브젝트의 에셋 참조는 사라지지만, 다른 오브젝트나 DDOL 씬에서 같은 에셋을 여전히 참조하고 있을 수 있습니다. Unity는 Resources.UnloadUnusedAssets()를 통해 모든 참조를 확인한 뒤에야 에셋 메모리를 해제합니다.

Resources.UnloadUnusedAssets()

이 함수는 현재 메모리에 로드된 에셋들의 참조 상태를 추적하여, 어디에서도 참조되지 않는 에셋을 메모리에서 해제합니다.

UnloadUnusedAssets() 동작 에셋 참조 상태 결과 Texture_A 참조 없음 해제 Texture_B Player가 참조 유지 Mesh_C 참조 없음 해제 Audio_D AudioManager가 참조 (DDOL) 유지 비동기 실행 (AsyncOperation 반환) · 참조 추적에 수백 ms 소요 가능 프레임 스파이크 유발 가능 → 로딩 화면 중 호출 권장

UnloadUnusedAssets는 비용이 높은 연산입니다. 프로젝트에 에셋이 많을수록 추적 시간이 길어지며, 매 프레임 호출하면 성능 저하를 유발합니다.

GC.Collect와의 관계

가비지 컬렉션(Garbage Collection)은 C#의 관리 힙(Managed Heap)에서 더 이상 도달할 수 없는(unreachable) 객체를 수거하는 과정이며, GC.Collect()는 이를 즉시 실행하는 함수입니다.

UnloadUnusedAssets의 참조 추적이 정확하려면 관리 힙의 도달 불가능한 객체가 먼저 수거되어야 하므로, UnloadUnusedAssets는 내부적으로 GC.Collect()를 호출합니다. 개발자가 별도로 GC.Collect()를 호출할 필요는 없습니다.

Unity에서 텍스처나 메쉬 같은 에셋의 실제 데이터는 네이티브(C++) 메모리에 저장되고, C# 코드에서는 Texture2D, Mesh 같은 래퍼 객체를 통해 이를 참조합니다.

씬이 언로드되면 GameObject와 컴포넌트는 파괴되어 == nulltrue를 반환하지만, 가비지 컬렉터가 수거하기 전까지 관리 힙에는 남아 있습니다.

이 객체가 참조하던 Texture2D 래퍼도 도달 가능한 상태로 유지되어, Unity는 해당 네이티브 에셋을 아직 사용 중으로 판단합니다.

GC.Collect()를 실행해야 이런 객체들이 수거되고, 네이티브 에셋에 대한 참조가 완전히 제거되어 비로소 해제 대상이 됩니다.

씬 전환 시 전체 흐름

씬 전환의 최적 패턴은 로딩 화면을 경유하는 것입니다. 메모리 관리 (2) - 네이티브 메모리와 에셋에서 다룬 메모리 피크 관리와 직접 연결됩니다.

씬 전환 시 권장 흐름 1. 로딩 씬 Additive 로드 로딩 화면 표시 2. 이전 씬 언로드 오브젝트 파괴 3. 미사용 에셋 해제 UnloadUnusedAssets 4. 새 씬 비동기 로드 progress 로딩 바 5. 로딩 씬 언로드 각 단계에서 yield return으로 완료를 대기 → 다음 단계가 올바른 상태에서 시작 메모리 변화 메모리 시간 / 단계 피크 ① 로딩 씬 추가 로드 ② 이전 씬 언로드 ③ 이전 에셋 해제 메모리 여유 확보 ④ 새 에셋 로드 ⑤ 로딩 씬 언로드 핵심: 이전 에셋 해제 후 새 에셋 로드 → 두 씬 에셋 동시 상주 구간(피크) 최소화 Additive 씬 언로드 시 UnloadUnusedAssets 자동 호출 없음 → 명시 호출 필수

3단계의 Resources.UnloadUnusedAssets() 명시 호출은 필수입니다. LoadScene(Single) 방식에서는 Unity가 새 씬 로드 후 UnloadUnusedAssets를 자동으로 호출하지만, UnloadSceneAsync(Additive 씬 언로드)에서는 자동 호출이 없습니다. 따라서 Additive 기반 씬 전환에서는 개발자가 직접 호출해야 이전 씬의 에셋이 해제됩니다.

각 단계 사이에 yield return(코루틴) 또는 await(async/await)로 완료를 대기하는 것도 필수입니다. UnloadSceneAsyncUnloadUnusedAssets는 모두 비동기로 실행되므로, 완료를 기다리지 않고 다음 단계를 시작하면 이전 에셋이 해제되기 전에 새 에셋이 로드되어 메모리 피크가 줄어들지 않습니다.

메모리 피크가 높아지면 모바일에서 OOM(Out Of Memory)이 발생할 수 있습니다. OOM은 앱의 메모리 사용량이 OS 허용 한도를 초과했을 때 OS가 앱을 강제 종료하는 현상입니다. 모바일 기기의 RAM이 4~8GB이더라도 OS가 앱 하나에 허용하는 메모리는 대체로 1~2GB 수준이므로, 두 씬의 에셋이 겹치는 순간 이 한도를 넘기기 쉽습니다.


대규모 월드를 위한 씬 분할 전략

지금까지 다룬 씬 전환 패턴은 메뉴 → 게임 → 결과처럼 단계가 명확히 나뉘는 구조에 적합합니다. 오픈 월드나 대규모 맵을 가진 게임에서는 전체 월드를 하나의 씬에 담을 수 없습니다. 에셋 총량이 기기의 메모리 한계를 초과하기 때문입니다.

이 문제를 해결하는 방법이 씬 분할(Scene Splitting)입니다.


그리드 기반 월드 분할

월드를 격자(Grid)로 나누어 각 셀을 별도의 씬으로 구성합니다. 플레이어 위치에 따라 현재 셀과 인접 셀만 Additive로 로드하고, 로드 범위를 벗어난 셀은 언로드하면 전체 월드 중 플레이어 주변의 에셋만 메모리에 유지됩니다.

그리드 기반 씬 분할 Cell_00 .unity Cell_01 .unity Cell_02 .unity Cell_10 .unity Cell_11 플레이어 위치 Cell_12 .unity Cell_20 .unity Cell_21 .unity Cell_22 .unity 플레이어가 Cell_11에 위치할 때: 3×3 범위 전체 로드 로드 : Cell_00 ~ Cell_22 (3×3 = 9개 셀) 언로드 : 3×3 범위 밖의 셀 점선 = 로드 범위 경계

스트리밍: 미리 로드하기

플레이어가 셀 사이를 이동하면 로드 범위도 함께 이동합니다. 스트리밍(Streaming)은 플레이어가 셀 경계에 가까워질 때 이동 방향의 인접 셀을 미리 비동기 로드하고, 반대쪽 셀을 언로드하여 끊김 없이 월드를 탐색할 수 있게 하는 방식입니다.

씬 스트리밍 동작 시간 t0 t1 t2 00 01 02 10 ★11 12 20 21 22 3×3 모두 로드 플레이어 Cell_11 중앙 00 01 02 03 10 ★11 12 13 20 21 22 23 Cell_12 방향 이동 감지 03/13/23 비동기 로드 시작 (점선) 00 01 02 03 10 11 ★12 13 20 21 22 23 플레이어 Cell_12 진입 03/13/23 로드 완료, 00/10/20 언로드 새로 로드 언로드 로딩 중 로드 유지 플레이어 시점에서 끊김 없이 월드가 이어짐 항상 일정 범위의 셀만 메모리에 유지

플레이어가 현재 셀을 이동하는 동안, 이동 방향의 다음 셀이 백그라운드에서 비동기 로드됩니다. 셀 경계에 도착할 때 로딩이 이미 완료되어 있으면 끊김 없이 새 영역이 나타납니다. 비동기 로딩은 셀의 에셋 양에 따라 수백 ms에서 수 초까지 걸리므로, 플레이어의 이동 속도와 로딩 시간을 고려하여 경계 도달 전에 충분한 여유를 두고 로딩을 시작해야 합니다. 로딩이 완료되기 전에 플레이어가 새 셀에 진입하면, 빈 공간이 보이거나 오브젝트가 갑자기 나타나는 팝인(pop-in)이 발생합니다.

메모리 측면에서는 로드된 셀의 총 수가 항상 일정하게 유지됩니다. 위 예시에서 로드 범위가 3×3이면 항상 9개 셀의 에셋만 메모리에 올라가므로, 월드 전체 크기와 무관하게 메모리 사용량의 상한을 예측할 수 있습니다.

씬 간 공유 에셋 관리

인접한 셀들은 같은 나무 프리팹이나 지형 텍스처를 공유하는 경우가 많습니다. 각 셀을 AssetBundle로 패키징할 때, 공유 에셋을 별도 번들로 분리하지 않으면 동일한 에셋이 셀마다 복사되어 메모리에 중복으로 올라갑니다.

공유 에셋 문제와 해결 문제: 공유 에셋을 별도 번들로 분리하지 않은 경우 Cell_11 번들 Tree_A (4MB) Cell_12 번들 Tree_A (4MB) Cell_21 번들 Tree_A (4MB) 같은 에셋 3벌 복사 = 12MB 해결 방안 해결 1 씬 직접 참조 (번들 미사용) 같은 GUID → 1회 로드 해결 2 AssetBundle 공유 번들 분리 의존성 기록만, 복사 없음 해결 3 Addressables 별도 그룹 분리 참조 카운팅 → 1회 로드 메모리 사용: 4MB (1회 로드)

Unity는 같은 에셋 파일(같은 GUID)을 참조하면 메모리에 한 번만 로드합니다.

AssetBundle은 번들 단위로 독립된 파일을 생성합니다. 위 예시에서 Tree_A가 Cell_11 번들에도 Cell_12 번들에도 필요한데 어떤 번들에도 배정되어 있지 않으면, Unity는 런타임에 Tree_A를 찾을 곳이 없으므로 각 번들 안에 데이터를 복사합니다. 복사된 데이터는 번들마다 별개이므로, 두 번들을 동시에 로드하면 같은 에셋이 메모리에 두 벌 존재합니다.

Tree_A를 별도의 공유 번들에 배정하면, 각 셀 번들에는 “공유 번들에서 로드”라는 의존성 기록만 남아 에셋이 한 번만 로드됩니다. Addressables의 그룹 구성과 의존성 분석 기능을 활용하면 이 문제를 체계적으로 관리할 수 있습니다. 구체적인 방법은 메모리 관리 (3) - Addressables와 에셋 전략에서 다룹니다.

공통 씬과 콘텐츠 씬 분리

대규모 프로젝트에서는 씬을 공통 씬(Persistent Scene)콘텐츠 씬(Content Scene)으로 분리하는 구조가 일반적입니다.

공통 씬 + 콘텐츠 씬 구조 Persistent Scene (항상 로드) 플레이어 캐릭터 카메라 시스템 게임 매니저 UI 조명 (글로벌) Additive 로드 / 언로드 Content Scene (교체) 스테이지별 지형 스테이지별 적 배치 스테이지별 오브젝트 스테이지별 조명 (로컬) 점선 = 교체 가능한 씬 스테이지 전환 흐름 1. Content Scene (Stage 1) 언로드 2. UnloadUnusedAssets() 3. Content Scene (Stage 2) 비동기 로드 → 플레이어, UI는 유지됨

공통 씬에는 씬 전환과 무관하게 유지되어야 하는 오브젝트를, 콘텐츠 씬에는 스테이지별로 달라지는 요소만 배치합니다. 스테이지 전환 시 콘텐츠 씬만 교체하므로 공통 요소의 재로딩이 발생하지 않습니다.

DontDestroyOnLoad 씬은 Unity가 내부적으로 관리하므로 개발자가 씬 자체를 언로드할 수 없고, 오브젝트를 해제하려면 하나씩 Destroy()를 호출해야 합니다. 공통 씬 방식에서는 씬의 로드/언로드 시점을 개발자가 직접 제어하므로, 공통 씬을 언로드하면 그 안의 모든 오브젝트가 함께 파괴됩니다.


마무리

  • 동기 로딩은 메인 스레드를 블로킹하여 프레임 정지를 유발하며, 비동기 로딩은 여러 프레임에 걸쳐 작업을 분산합니다. allowSceneActivation으로 활성화 시점을 제어할 수 있습니다.
  • Additive 씬 로딩으로 UI, 게임플레이, 환경을 독립된 씬으로 분리하여 개별적으로 로드/언로드할 수 있습니다.
  • DontDestroyOnLoad는 씬 전환 시 오브젝트를 유지하지만, 참조 에셋이 해제되지 않으므로 최소한으로 사용해야 합니다.
  • 오브젝트 파괴 후에도 에셋 메모리는 즉시 해제되지 않으며, Resources.UnloadUnusedAssets()로 명시적 해제가 필요합니다.
  • 대규모 월드에서는 그리드 씬 분할과 공통 씬/콘텐츠 씬 분리로 메모리 사용량의 상한을 예측 가능하게 유지합니다.

씬 관리의 본질은 메모리의 할당과 해제 시점을 제어하는 것입니다. 어떤 에셋을 언제 로드하고 언제 해제할지, 씬 전환 시 메모리 피크를 어떻게 최소화할지가 모바일 환경에서의 안정성을 결정합니다.


에셋의 메모리 생명주기를 직접 제어하는 AssetBundle과 Addressables의 로드/해제 패턴은 메모리 관리 (3) - Addressables와 에셋 전략에서, Unity 엔진의 기본 구조와 실행 흐름은 Unity 엔진 핵심 (1) - GameObject와 Component에서 각각 확인할 수 있습니다.



관련 글

시리즈

전체 시리즈

Tags: Scene, Unity, 모바일, 씬관리, 에셋

Categories: ,