작성일 :

에셋에서 씬으로

Unity 에셋 시스템 (2) - Serialization과 Instantiation에서는 에셋 하나가 디스크와 메모리 사이를 오가는 과정을 다루었습니다. 에셋은 직렬화를 거쳐 파일로 저장되고 역직렬화를 거쳐 메모리로 복원되며, Instantiate는 GameObject와 컴포넌트만 복제하고 Mesh와 Texture 같은 공유 에셋은 참조만 복사합니다. 빌드 크기를 키우고 메모리 관리를 어렵게 하는 Resources 폴더의 구조적 한계도 함께 살펴보았습니다.

그런데 실행 중인 게임이 다루는 단위는 에셋 하나가 아니라 화면 하나입니다. 새 화면이 나타나려면 그 화면에 필요한 수많은 GameObject와 이들이 참조하는 에셋이 한꺼번에 준비되어야 합니다. 이 GameObject들과 참조 에셋이 모여 이루는 실행 단위가 씬(Scene)입니다.

메뉴에서 게임 플레이로, 다시 결과 화면으로 넘어가는 게임 흐름의 전환은 결국 씬 하나를 언로드하고 다른 씬을 로드하는 작업입니다. 이 교체 한 번에 이전 씬의 오브젝트 정리와 새 씬의 에셋 로드가 함께 일어나고, 메모리 사용량이 수십~수백 MB 변동할 수 있습니다. 씬을 어떻게 나누고 전환을 언제 어떤 방식으로 처리하느냐에 따라, 로딩 중 화면이 멈추기도 하고 두 씬의 에셋이 겹치는 구간에는 메모리 피크가 생기기도 합니다.

이 글에서는 씬의 구조에서 시작해 동기·비동기 씬 로딩, 여러 씬을 함께 올리는 Additive 모드, 씬 전환에도 오브젝트를 유지하는 DontDestroyOnLoad, 씬 언로딩과 메모리 해제, 대규모 월드를 위한 씬 분할 전략까지 차례로 살펴봅니다.


씬(Scene)의 구조

씬 하나는 화면을 구성하는 GameObject들의 모음입니다. 카메라와 조명, 캐릭터와 배경, UI처럼 역할이 다른 오브젝트가 한 씬 안에 함께 놓입니다. 각 GameObject에는 위치와 회전을 나타내는 Transform, 형상을 화면에 그리는 Renderer, 충돌을 판정하는 Collider, 동작을 정의하는 스크립트 같은 컴포넌트가 담기고, 이 가운데 일부는 머티리얼, 텍스처, 메쉬 같은 외부 에셋을 참조합니다.

씬 (.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 는 에셋 참조 관계

그림에서 보듯 씬 파일에는 GameObject와 컴포넌트의 구성만 들어 있는 것이 아니라, 각 컴포넌트가 어떤 외부 에셋을 참조하는지도 함께 기록됩니다. Player의 MeshRenderer가 Material을 참조하고 그 Material이 다시 Texture를 참조하는 연결 관계까지 씬 파일 안에 담깁니다.

씬을 저장하면 이 구성 전체가 직렬화되어 .unity 파일에 기록되고, 각 오브젝트는 fileID라는 고유 번호로 식별됩니다. 씬을 로드할 때는 반대로 Unity가 이 데이터를 역직렬화해 메모리 위의 오브젝트로 복원합니다.

에셋이 YAML로 직렬화되어 디스크에 기록되는 과정은 Unity 에셋 시스템 (2) - Serialization과 Instantiation에서 자세히 다룹니다.

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번이 첫 실행 씬 → 목록에 없는 씬은 빌드에 포함되지 않음 → 씬 이름 또는 인덱스로 런타임 로딩 가능


이 목록을 기준으로 씬을 불러오는 것이 SceneManager입니다. 목록에 등록된 씬은 런타임에서 이름이나 인덱스로 지정해 불러올 수 있고, 목록에 없는 씬은 일반 빌드에 포함되지 않아 이 방식으로는 찾을 수 없습니다.

등록하지 않은 씬까지 런타임에 로드해야 한다면 Unity의 Addressables 시스템이 필요하지만, 이는 별도의 패키징과 로딩 규칙을 따르는 다른 주제입니다. 이 글에서는 등록된 씬을 SceneManager로 불러오는 기본 흐름을 다룹니다. 그 첫 방식이 다음 절에서 살펴볼 동기 로딩입니다.


SceneManager.LoadScene: 동기 로딩

씬을 불러오는 가장 단순한 API가 SceneManager.LoadScene입니다. 이 API는 씬을 동기적(Synchronous)으로 로드하지만, 동기라고 해서 호출한 줄이 그 자리에서 멈추지는 않습니다. 호출은 같은 프레임 안에서 곧바로 반환되어 뒤따르는 코드도 계속 실행되고, 실제 씬 교체는 다음 프레임에 이루어집니다.

실제 비용은 다음 프레임에서 나타납니다. Unity가 새 씬의 오브젝트와 에셋을 로드하고 활성화하는 동안 메인 스레드는 이 작업을 우선 처리해야 합니다. Unity의 게임 로직 실행, 입력 처리, 렌더링 명령 생성도 같은 메인 스레드에서 이루어지므로, 동기 로딩이 진행되는 동안에는 다음 화면을 그리거나 입력에 반응할 시간이 생기지 않습니다.

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

블로킹 시간은 새 씬에서 생성해야 하는 오브젝트 수와, 그 오브젝트들이 참조하는 에셋의 양에 따라 길어집니다. 텍스처, 메쉬, 오디오처럼 로드해야 할 데이터가 많은 게임 씬에서는 메인 스레드가 몇 초 동안 로딩 작업을 처리할 수 있습니다. 이 동안 화면 갱신과 입력 처리가 멈추므로, 사용자는 앱이 응답하지 않는 상태로 인식할 수 있습니다. 모바일 환경에서는 이런 정지가 길어지면 OS가 앱을 응답 없음 상태로 판단해 종료할 가능성도 있습니다.

그래서 동기 로딩은 짧은 정지가 허용되는 상황에 한정해서 사용하는 편이 좋습니다. 예를 들어 앱 시작 시 첫 씬을 불러오면서 스플래시 화면이 표시되는 구간이나, 포함된 오브젝트와 에셋이 적어 로딩이 거의 즉시 끝나는 작은 씬이 이에 해당합니다. 플레이 중에 큰 게임 씬으로 전환해야 한다면, 다음 절에서 다룰 비동기 로딩을 사용하는 편이 적절합니다.

기존 씬의 처리

동기 로딩에서 새 씬을 불러올 때 기본 모드는 LoadSceneMode.Single입니다. 이 모드에서는 새 씬이 현재 씬을 대체하므로, Unity는 기존 씬의 GameObject들을 먼저 정리합니다. 각 오브젝트는 비활성화 과정에서 OnDisable을 받고, 이어서 파괴 과정에서 OnDestroy를 받습니다. 구독 해제나 임시 상태 정리처럼 오브젝트 수명에 묶인 작업은 이 시점에 처리해야 합니다.

다만 GameObject가 파괴되었다고 해서 그 오브젝트가 참조하던 텍스처, 메쉬, 오디오 같은 에셋까지 즉시 메모리에서 내려가는 것은 아닙니다. 오브젝트의 수명과 에셋 메모리의 수명은 별도로 관리됩니다. 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.0부터 1.0까지지만, 씬 로딩에서는 이 값을 그대로 로딩 바의 0%부터 100%까지로 해석하면 실제 흐름과 어긋날 수 있습니다.

씬 데이터 읽기, 역직렬화, 메모리 배치처럼 전환 전에 준비해야 하는 작업은 주로 progress 0.0부터 0.9까지의 구간에 반영됩니다. progress0.9f에 도달했다는 것은 새 씬을 활성화하기 직전까지의 준비가 끝났다는 뜻에 가깝습니다.

남은 0.9에서 1.0까지의 구간은 씬 활성화 단계입니다. 이때 준비된 오브젝트가 실제 씬으로 전환되고, 활성화 과정에서 필요한 초기화가 이어집니다. 따라서 로딩 화면의 진행 바를 표시할 때는 보통 operation.progress / 0.9f로 0.0부터 0.9까지의 구간을 0%부터 100%까지로 환산하고, 활성화 시점은 다음 절의 allowSceneActivation으로 따로 다룹니다.

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

allowSceneActivation으로 활성화 시점 제어

progress0.9f에 도달했다는 것은 전환에 필요한 준비가 끝나고, 이제 씬을 활성화할 수 있는 상태에 가까워졌다는 뜻입니다. 이 지점에서 곧바로 새 씬으로 넘어가도 되지만, 로딩 화면의 페이드 아웃을 끝내거나 “터치하여 시작” 입력을 기다려야 하는 경우도 있습니다.

이때 사용하는 스위치가 AsyncOperation.allowSceneActivation입니다. 로딩을 시작한 뒤 이 값을 false로 두면, 작업은 progress 0.9f에서 대기하고 새 씬은 아직 활성화되지 않습니다. 전환 연출이나 사용자 입력까지 마쳤다면 값을 true로 바꿉니다. 그때 남아 있던 활성화 단계가 진행되고, 새 씬의 오브젝트가 실제 실행 상태로 들어오며 생명주기 콜백이 이어집니다.


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


활성화를 보류한 상태에서는 완료를 판단하는 기준도 달라집니다. allowSceneActivationfalse이면 작업은 progress 0.9f에서 대기하고, AsyncOperation.isDone은 아직 true가 되지 않습니다. 따라서 코루틴에서 yield return operation으로 완료를 기다리면, 활성화를 허용하기 전까지 코루틴도 그 지점에서 멈춰 있게 됩니다.

이 구간에서는 isDone을 준비 완료 신호로 쓰지 않습니다. 대신 progress >= 0.9f를 확인해 새 씬을 활성화할 준비가 되었는지 판단합니다. 이 시점에 로딩 화면의 시작 버튼을 표시하거나 페이드 아웃 같은 전환 연출을 마무리하고, 실제로 넘어가야 할 때 allowSceneActivationtrue로 바꾸면 됩니다. isDone은 씬 활성화까지 끝난 뒤에 확인할 최종 완료 신호로 보는 편이 맞습니다.

한 가지 더 주의할 점은 동기 로딩과 섞어 쓰는 경우입니다. SceneManager.LoadScene처럼 동기 씬 로딩을 호출하면, 대기 중이던 비동기 작업이 함께 진행되며 의도하지 않은 시점에 활성화될 수 있습니다. allowSceneActivation으로 전환 시점을 잡아 두었다면, 같은 흐름 안에서 별도의 동기 씬 로딩을 끼워 넣지 않는 편이 안전합니다.

비동기 로딩에도 남는 프레임 드롭

비동기 로딩을 사용해도 모든 비용이 백그라운드로 사라지는 것은 아닙니다. 디스크 읽기나 데이터 준비는 여러 프레임에 나뉘어 진행되지만, 준비된 에셋을 Unity 객체로 통합하고 씬에 반영하는 일부 작업은 메인 스레드에서 처리됩니다. 이 통합 시간이 한 프레임 안에서 길어지면, 비동기 로딩 중에도 순간적인 프레임 드롭이 보일 수 있습니다.

Unity는 이런 통합 작업이 한 프레임을 지나치게 오래 붙잡지 않도록 시간 예산을 둡니다. 이 예산은 Application.backgroundLoadingPriority로 조절합니다. 기본값인 ThreadPriority.Normal은 프레임당 비교적 큰 시간을 로딩 통합에 허용하므로 로딩은 빨리 끝나지만, 60 FPS 기준 한 프레임 예산인 약 16.7ms 중 상당 부분을 차지할 수 있습니다. 값을 ThreadPriority.Low로 낮추면 프레임마다 로딩에 쓰는 시간이 줄어 화면 끊김은 완화될 수 있지만, 그만큼 전체 로딩 시간은 길어집니다.

또 다른 스파이크 지점은 씬 활성화 프레임입니다. allowSceneActivationtrue로 바꾼 뒤 새 씬이 실제로 활성화되면, 그 씬의 오브젝트들이 생명주기 콜백을 실행합니다. 이때 Awake, OnEnable, Start에서 대량 생성, 동기 로드, 복잡한 초기화를 한꺼번에 수행하면 로딩 작업과 별개로 프레임 드롭이 생깁니다.

이 비용은 backgroundLoadingPriority로 줄일 수 없습니다. 활성화 이후에 실행되는 사용자 코드의 비용이기 때문입니다. 무거운 초기화는 코루틴이나 async 흐름으로 여러 프레임에 나누고, 가능하면 로딩 화면 중에 미리 준비하거나 실제로 필요해지는 시점까지 늦추는 식으로 따로 관리해야 합니다.


Additive 씬 로딩

지금까지의 씬 로딩은 새 씬이 기존 씬을 대체하는 흐름이었습니다. LoadSceneMode.Single로 씬을 로드하면 이전 씬은 언로드되고, 그 씬에 있던 GameObject들은 파괴됩니다. 전환이 끝난 뒤 실행 중인 씬은 새로 로드한 씬 하나만 남습니다.

LoadSceneMode.Additive는 이 동작을 바꿉니다. 기존 씬을 내리지 않고 새 씬을 실행 중인 씬 목록에 추가합니다. 결과적으로 여러 씬이 동시에 로드되고, 각 씬에 들어 있는 오브젝트들이 같은 월드 안에서 함께 업데이트되고 렌더링됩니다.

이 방식은 화면 전체를 새 씬으로 갈아끼우는 전환이 아니라, 현재 구성 위에 씬 조각을 더하는 방식에 가깝습니다. 공통 시스템을 담은 씬은 유지하고, UI, 던전 층, 실내 공간, 보스룸 같은 콘텐츠 씬만 필요할 때 추가하거나 제거할 수 있습니다. 대신 Single 모드처럼 이전 씬이 자동으로 정리되지 않으므로, 더 이상 필요 없는 Additive 씬은 SceneManager.UnloadSceneAsync로 직접 내려야 합니다.

Single은 대체, Additive는 추가 Single: 기존 씬 대체 상태 1 씬 A LoadScene("B", Single) 상태 2 씬 B 씬 A 언로드됨 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로 여러 씬을 동시에 로드하면, 새로 생성되는 오브젝트를 어느 씬에 넣을지 정해야 합니다. 씬이 하나뿐일 때는 드러나지 않던 문제지만, UI 씬과 게임 플레이 씬이 함께 올라와 있는 상태라면 Instantiatenew GameObject로 만든 오브젝트의 소속 씬이 의미를 갖습니다.

이 기본 소속을 정하는 기준이 Active Scene입니다. 이름 때문에 현재 실행 중인 씬 하나만 가리키는 것처럼 보일 수 있지만, Additive로 로드된 씬들은 모두 함께 실행됩니다. 여기서 Active Scene은 새 GameObject가 들어갈 기본 대상 씬이고, 동시에 전역 환경 설정을 가져오는 기준 씬입니다. 어느 씬을 Active Scene으로 둘지는 SceneManager.SetActiveScene(scene)으로 바꿉니다.


1
2
3
4
5
6
7
8
9
10
// 로드된 씬: UI Scene, GamePlay Scene
// Active Scene: GamePlay Scene

var bullet = Instantiate(bulletPrefab);
// bullet은 GamePlay Scene에 생성됨

SceneManager.SetActiveScene(uiScene);

var tooltip = Instantiate(tooltipPrefab);
// tooltip은 UI Scene에 생성됨


코드에서 보듯 같은 Instantiate 호출이라도 Active Scene이 어디냐에 따라 생성 결과가 다른 씬에 들어갑니다. 다만 SetActiveScene은 이후 생성될 오브젝트의 기본 소속을 바꾸는 호출입니다. 이미 만들어진 오브젝트의 소속 씬을 옮기지는 않습니다. 기존 오브젝트를 다른 씬으로 옮겨야 한다면 SceneManager.MoveGameObjectToScene을 따로 사용합니다.

Active Scene을 맞춰 두어야 하는 첫 번째 이유는 언로드 범위입니다. 특정 씬을 언로드하면 그 씬에 속한 오브젝트가 함께 정리됩니다. 총알, 이펙트, 임시 UI처럼 콘텐츠 씬과 함께 사라져야 하는 오브젝트가 다른 씬에 생성되면 언로드 뒤에도 남을 수 있고, 반대로 계속 유지되어야 할 오브젝트가 콘텐츠 씬에 들어가면 씬을 내릴 때 함께 사라질 수 있습니다.

두 번째 이유는 전역 환경 설정입니다. 라이트맵처럼 씬별로 저장되는 데이터는 각 씬에 묶여 있지만, 환경 조명, 스카이박스, 포그처럼 화면 전체에 적용되는 설정은 Active Scene의 값을 기준으로 삼습니다. 콘텐츠 씬을 교체했는데 Active Scene을 새 씬으로 옮기지 않으면, 오브젝트는 새 씬의 것이지만 환경 설정은 이전 씬의 값으로 남아 어색한 화면이 나올 수 있습니다.


DontDestroyOnLoad

LoadSceneMode.Single로 씬을 전환하면 이전 씬에 있던 오브젝트는 파괴됩니다. 하지만 게임 실행 동안 계속 유지되어야 하는 오브젝트도 있습니다. 점수와 진행도를 관리하는 GameManager, BGM을 이어서 재생하는 AudioManager, 서버 연결을 유지하는 NetworkManager처럼 씬이 바뀌어도 사라지면 안 되는 시스템 오브젝트입니다.

이런 오브젝트를 씬 전환의 파괴 대상에서 제외하는 호출이 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 씬으로 옮겨집니다. 그래서 이후 LoadSceneMode.Single로 다른 씬을 로드해도 기존 씬의 언로드 대상에 포함되지 않습니다. 런타임에 gameObject.scene.name을 확인하면 "DontDestroyOnLoad"라는 이름을 볼 수 있고, Hierarchy 창에서도 이 별도 씬을 확인할 수 있습니다.

적용 대상은 루트 GameObject입니다. 루트에 DontDestroyOnLoad를 한 번 적용하면 그 아래 자식들도 함께 유지됩니다. 반대로 자식 오브젝트를 직접 넘기면 호출은 적용되지 않고, Unity는 루트 GameObject나 루트의 컴포넌트에만 동작한다는 경고를 출력합니다. 따라서 유지해야 하는 묶음이 있다면, 그 묶음의 루트에만 호출을 둡니다.

필요하다면 DontDestroyOnLoad 씬에 들어간 오브젝트를 다시 일반 씬으로 옮길 수도 있습니다. 이때는 SceneManager.MoveGameObjectToScene(gameObject, targetScene)을 사용합니다. 이 메서드 역시 루트 GameObject를 대상으로 하므로 자식을 넘기면 예외가 발생합니다. 전역으로 유지하던 오브젝트를 특정 씬의 수명에 다시 묶고 싶다면, 대상 씬으로 옮긴 뒤 그 씬이 언로드될 때 함께 정리되도록 만들 수 있습니다.

일반적인 사용 대상

DontDestroyOnLoad는 게임 실행 동안 계속 유지되어야 하는 시스템 성격의 오브젝트에 적합합니다. 반대로 특정 씬에서만 잠시 사용하는 오브젝트에는 적용하지 않는 편이 좋습니다.

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

그림에서 EventSystem에 “중복 방지 필요”라고 적은 이유는 수명보다 개수가 더 중요한 오브젝트이기 때문입니다. EventSystem을 DontDestroyOnLoad로 유지했는데 새 씬에도 EventSystem이 배치되어 있으면, 씬 전환 뒤에는 EventSystem이 둘 이상 남습니다. UI 입력은 하나의 EventSystem을 기준으로 처리되어야 하므로, 이런 오브젝트는 전역으로 유지할지 씬마다 둘지를 먼저 정하고 중복 인스턴스가 생기지 않게 관리해야 합니다.

부적합한 대상도 같은 기준으로 판단합니다. 특정 화면에서만 필요한 UI 패널, 특정 전투에서만 쓰는 임시 데이터, 한 번 재생되고 사라질 이펙트처럼 수명이 짧거나 특정 씬에 묶인 오브젝트는 DontDestroyOnLoad에 올리지 않는 편이 맞습니다. 이런 오브젝트가 씬 전환 뒤에도 살아남으면 더 이상 쓰이지 않는 상주 객체가 되고, 참조하고 있던 에셋까지 함께 메모리에 남길 수 있습니다.

싱글턴 중복 인스턴스 문제

EventSystem에서 본 중복 문제는 싱글턴 매니저에서도 자주 생깁니다. DontDestroyOnLoad로 유지하는 매니저는 보통 게임 전체에 하나만 있어야 하지만, 그 매니저가 씬 안에 배치되어 있다면 씬을 다시 로드할 때 새 인스턴스가 다시 만들어집니다.

예를 들어 씬 A에 GameManager가 있고, 첫 실행 때 이 오브젝트를 DontDestroyOnLoad로 올렸다고 가정해 보겠습니다. 이후 다른 씬을 거쳐 다시 씬 A를 로드하면, 씬 A에 배치된 GameManager가 새로 생성됩니다. 기존 GameManager는 DontDestroyOnLoad 씬에 남아 있으므로, 결과적으로 같은 역할의 매니저가 두 개가 됩니다.

이 문제는 씬 전환 코드가 아니라 매니저 자신이 방어하는 편이 안전합니다. Awake에서 이미 등록된 인스턴스가 있는지 먼저 확인하고, 기존 인스턴스가 있다면 새로 만들어진 자신을 즉시 파괴합니다. 기존 인스턴스가 없을 때만 자신을 전역 인스턴스로 등록하고 DontDestroyOnLoad에 올리는 식입니다. 이렇게 해야 어떤 씬에서 시작하든 매니저가 하나만 유지됩니다.

남용 시 메모리 상주 위험

중복만큼 자주 문제가 되는 것이 메모리입니다. DontDestroyOnLoad 자체가 곧바로 메모리 누수를 만드는 것은 아닙니다. 문제는 이 오브젝트가 오래 살아남는 만큼, 이 오브젝트가 잡고 있는 참조도 오래 살아남는다는 점입니다.

전역 매니저가 큰 텍스처, 오디오 클립, 프리팹을 필드로 직접 참조하고 있으면 Unity 입장에서는 그 에셋이 여전히 사용 중인 상태입니다. 씬을 언로드하거나 Resources.UnloadUnusedAssets()가 실행되어도, 살아 있는 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 오브젝트가 에셋을 직접 참조하면 씬 전환과 무관하게 메모리 점유

그림의 예처럼 AudioManager가 메뉴, 게임, 보스, 엔딩 BGM을 모두 직접 참조하면 현재 한 곡만 재생하더라도 네 개의 오디오 클립이 모두 AudioManager를 통해 도달 가능한 상태로 남습니다. 씬이 바뀌어도 AudioManager는 유지되므로, 그 참조를 끊지 않는 한 클립들도 계속 메모리에 머무를 수 있습니다.

따라서 DontDestroyOnLoad 오브젝트는 가능한 한 가볍게 유지하는 편이 좋습니다. 전역 매니저는 재생 상태, 설정, 로딩 흐름처럼 오래 유지되어야 하는 데이터와 기능만 갖고, 실제 콘텐츠 에셋은 필요해지는 시점에 따로 로드하는 구조가 낫습니다.

Addressables 같은 동적 로딩을 사용하면 메뉴 BGM은 메뉴에서만 로드하고, 보스 BGM은 보스전에 들어갈 때 로드한 뒤 사용이 끝났을 때 해제할 수 있습니다. 인스펙터 필드로 직접 참조해야 한다면, 게임 전체에서 계속 필요한 에셋인지 먼저 확인해야 합니다. DontDestroyOnLoad 오브젝트를 전역 콘텐츠 보관함처럼 쓰기 시작하면 씬 전환으로 정리될 수 있는 리소스까지 함께 붙잡게 됩니다.


씬 언로딩과 메모리 해제

씬을 언로드하면 먼저 그 씬에 속한 GameObject와 컴포넌트가 정리됩니다. 하지만 이것만으로 그 오브젝트들이 참조하던 텍스처, 메쉬, 오디오 클립까지 곧바로 메모리에서 내려가는 것은 아닙니다.

Unity에서 씬 언로딩은 오브젝트 파괴에셋 해제가 나뉘어 진행됩니다. 화면에서 오브젝트가 사라졌다는 것은 더 이상 그 오브젝트가 실행되지 않는다는 뜻이지, 관련 에셋의 참조가 모두 끊어졌다는 뜻은 아닙니다. 같은 에셋을 다른 씬이나 DontDestroyOnLoad 오브젝트가 여전히 참조하고 있다면, 그 에셋은 계속 메모리에 남아야 합니다.

오브젝트 파괴 vs 에셋 해제

씬 언로드의 첫 번째 결과는 오브젝트 파괴입니다. 언로드되는 씬에 속한 GameObject와 컴포넌트가 제거되고, 활성 상태였던 오브젝트에는 OnDisableOnDestroy가 호출됩니다. 그 오브젝트에서 실행 중이던 코루틴(Coroutine)도 함께 멈춥니다. 코루틴은 MonoBehaviour에 묶여 실행되므로, 대상 MonoBehaviour가 사라지면 더 이상 이어서 실행될 주체가 없습니다.

하지만 오브젝트가 외부 시스템에 남긴 연결까지 모두 사라지는 것은 아닙니다. 이벤트 발행자가 DontDestroyOnLoad 오브젝트나 정적 이벤트처럼 계속 살아 있다면, 파괴된 오브젝트의 메서드가 델리게이트에 남을 수 있습니다. 이 상태에서 콜백이 호출되면 이미 파괴된 Unity 오브젝트에 접근하게 되므로, 직접 등록한 이벤트 구독은 OnDisable이나 OnDestroy에서 해제해 두는 편이 안전합니다.

여기까지는 오브젝트의 생명주기 정리입니다. 텍스처, 메쉬, 오디오 클립 같은 에셋 메모리는 별도 기준으로 처리됩니다. 방금 파괴된 오브젝트가 참조를 놓았더라도, 같은 에셋을 다른 씬이나 전역 오브젝트가 여전히 참조하고 있다면 그 에셋은 계속 필요하기 때문입니다.

씬 언로드와 에셋 해제 단계 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 오브젝트가 참조 중이면 해제되지 않음 오브젝트 파괴와 에셋 메모리 해제는 별도 과정

그림에서 보듯 1단계에서 Player, Enemy, Environment가 파괴되어도 Texture_A, Mesh_B, Audio_C가 곧바로 내려가는 것은 아닙니다. 파괴된 오브젝트의 참조는 사라졌지만, 같은 에셋을 다른 씬이나 DontDestroyOnLoad 오브젝트가 잡고 있을 수 있기 때문입니다.

그래서 에셋 해제에는 참조 검사 단계가 필요합니다. Resources.UnloadUnusedAssets()는 메모리에 올라온 에셋 중 더 이상 도달 가능한 참조가 없는 것만 찾아서 내립니다. 반대로 살아 있는 오브젝트가 하나라도 참조하고 있는 에셋은 씬 언로드 뒤에도 유지됩니다.

Resources.UnloadUnusedAssets()

Resources.UnloadUnusedAssets()는 현재 메모리에 올라와 있지만 더 이상 사용되지 않는 에셋을 찾아 내리는 함수입니다. Unity는 씬 계층, DontDestroyOnLoad 씬, 컴포넌트 필드, 정적 필드처럼 살아 있는 객체에서 도달할 수 있는 참조를 따라가며 에셋이 아직 필요한지 판단합니다.

이 검사에서 어떤 살아 있는 객체도 더 이상 참조하지 않는 에셋만 해제 대상이 됩니다. 반대로 한 곳에서라도 참조가 남아 있다면, 그 에셋은 씬 언로드 이후에도 유지됩니다. 그래서 이 함수는 “메모리를 전부 비우는 호출”이 아니라 “참조가 끊긴 에셋만 정리하는 호출”로 이해하는 편이 맞습니다.

UnloadUnusedAssets()의 기준 에셋 참조 상태 결과 Texture_A 참조 없음 해제 Texture_B Player가 참조 유지 Mesh_C 참조 없음 해제 Audio_D AudioManager가 참조 (DDOL) 유지 AsyncOperation 반환 · 참조 검사 비용 존재 로딩 화면이나 전환 구간에서 호출하는 편이 안전

호출 결과는 AsyncOperation으로 돌아오므로 비동기 흐름에 넣을 수 있습니다. 다만 비동기라고 해서 비용이 없는 것은 아닙니다. 참조 관계를 검사하고 해제 대상을 정리하는 작업 자체는 프로젝트에 로드된 에셋과 객체가 많을수록 무거워질 수 있습니다.

따라서 이 함수는 게임 플레이 중에 자주 호출할 함수가 아닙니다. 씬 전환 중 로딩 화면을 띄운 상태, 큰 콘텐츠 묶음을 언로드한 직후, 또는 메모리를 정리해도 화면 멈춤을 감출 수 있는 구간에서 호출하는 편이 좋습니다. 확인해야 할 대상이 많은 프로젝트일수록 한 번 실행하는 비용이 커지므로, 매 프레임 호출하는 식의 사용은 피해야 합니다.

GC.Collect와의 관계

Resources.UnloadUnusedAssets()GC.Collect()는 둘 다 메모리 정리와 관련이 있지만, 정리하는 대상이 다릅니다. 가비지 컬렉션(Garbage Collection)은 C#의 관리 힙(Managed Heap)을 검사해, 코드 어디에서도 더 이상 도달할 수 없는 객체를 수거합니다. GC.Collect()는 이 수거 작업을 즉시 요청하는 함수입니다.

반면 UnloadUnusedAssets()는 Unity 에셋 쪽을 정리합니다. 텍스처의 픽셀 데이터, 메쉬의 정점 데이터, 오디오 데이터처럼 실제 에셋 데이터는 네이티브 메모리에 있고, C#에서는 Texture2D, Mesh, AudioClip 같은 관리 래퍼를 통해 그 데이터를 가리킵니다. GC.Collect()만 호출한다고 해서 이런 네이티브 에셋 메모리가 내려가는 것은 아닙니다.

두 정리 작업이 서로 영향을 주는 지점은 이 관리 래퍼입니다. 살아 있는 C# 객체가 Texture2DMesh 래퍼를 참조하고 있으면, Unity는 그 에셋을 아직 사용할 수 있는 상태로 봅니다. 따라서 에셋을 내리려면 네이티브 데이터만 보는 것이 아니라, 살아 있는 관리 객체에서 해당 에셋으로 이어지는 참조가 남아 있는지도 함께 중요해집니다.

씬을 언로드하면 GameObject와 컴포넌트의 네이티브 오브젝트는 파괴됩니다. Unity의 특수한 null 비교에서는 이 객체들이 == null처럼 보일 수 있습니다. 하지만 C# 래퍼 객체 자체는 관리 힙에 남아 있을 수 있고, 다른 관리 객체나 정적 필드가 그 래퍼를 계속 붙잡고 있다면 가비지 컬렉터도 수거하지 않습니다.

그 래퍼가 다시 Texture2D, Mesh, AudioClip 같은 에셋 래퍼를 참조하고 있다면, 에셋도 도달 가능한 상태로 남습니다. 이 경우 UnloadUnusedAssets()를 호출해도 해당 에셋은 사용 중인 것으로 판단되어 유지됩니다. 결국 관리 힙의 참조 정리가 제대로 되어 있지 않으면, 에셋 해제 결과에도 영향을 줄 수 있습니다.

그렇다고 씬 전환마다 GC.Collect()를 따로 호출하는 습관을 들일 필요는 없습니다. GC.Collect()도 프레임을 멈출 수 있는 무거운 작업이고, UnloadUnusedAssets() 역시 별도 비용이 큽니다. 보통은 참조를 명확히 끊고, 큰 씬 전환이나 로딩 화면처럼 멈춤을 감출 수 있는 구간에서 UnloadUnusedAssets()를 호출한 뒤, 실제로 관리 힙 문제가 남는지는 프로파일러로 확인하는 편이 좋습니다.

씬 전환 시 전체 흐름

앞에서 본 내용을 실제 씬 전환에 적용하면 핵심은 순서입니다. 메모리 여유가 충분한 전환이라면 새 씬을 먼저 로드한 뒤 이전 씬을 내리는 방식도 사용할 수 있습니다. 하지만 이전 씬과 새 씬의 에셋 규모가 크다면, 두 씬의 에셋이 동시에 올라오는 순간에 메모리 피크가 크게 튈 수 있습니다.

이 피크를 낮추려면 로딩 화면을 중간에 두고 전환을 단계로 나누는 편이 낫습니다. 먼저 로딩 화면만 남길 수 있는 상태를 만든 뒤, 이전 씬을 언로드하고, 더 이상 참조되지 않는 에셋을 정리한 다음 새 씬을 로드합니다. 이렇게 하면 새 씬의 에셋을 올리기 전에 이전 씬에서만 쓰던 에셋을 내릴 기회를 만들 수 있습니다.

메모리 피크 관리는 메모리 관리 (2) - 네이티브 메모리와 에셋에서 자세히 다룹니다.

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

그림에서 중요한 지점은 3단계입니다. UnloadSceneAsync로 Additive 씬을 내리면 그 씬의 오브젝트는 제거되지만, 에셋 메모리까지 함께 내려간다고 볼 수는 없습니다. 이전 씬에서만 쓰던 에셋을 실제로 정리하려면 참조가 끊긴 상태에서 Resources.UnloadUnusedAssets()를 호출해야 합니다.

Single 모드와 Additive 모드는 여기서 차이가 납니다. LoadSceneMode.Single로 씬을 로드하면 Unity가 Resources.UnloadUnusedAssets()를 자동으로 호출합니다. 반면 Additive 씬을 UnloadSceneAsync로 내리는 경우에는 에셋 메모리를 비우려면 별도의 정리 단계가 필요합니다. Additive 기반 전환에서 메모리 피크를 관리하려면 이 차이를 전제로 순서를 잡아야 합니다.

각 단계 사이에서는 yield return이나 await로 앞 작업의 완료를 기다려야 합니다. UnloadSceneAsync, Resources.UnloadUnusedAssets(), LoadSceneAsync는 모두 완료 시점이 뒤로 밀릴 수 있는 작업입니다. 이전 씬 언로드와 에셋 정리가 끝나기 전에 새 씬 로드를 시작하면, 두 씬의 에셋이 겹쳐 올라와 피크를 낮추려던 의도가 사라집니다.

메모리 피크가 기기의 허용 범위를 넘으면 모바일에서는 OOM(Out Of Memory)으로 앱이 종료될 수 있습니다. 기기 전체 RAM이 4GB나 8GB라고 해도 앱 하나가 그 전부를 쓸 수 있는 것은 아닙니다. 큰 씬을 전환할 때는 평균 메모리보다 전환 순간의 최고치를 먼저 확인해야 합니다.


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

앞서 살펴본 흐름은 메뉴에서 게임으로, 게임에서 결과 화면으로 넘어가는 것처럼 전환 지점이 분명한 구조에 잘 맞습니다. 플레이어가 잠시 로딩 화면을 보고, 이전 씬을 정리한 뒤, 다음 씬으로 넘어가는 방식입니다.

하지만 오픈 월드나 넓은 필드에서는 문제가 달라집니다. 플레이어는 끊김 없이 이동해야 하고, 월드 전체를 한 번에 로드하기에는 지형, 오브젝트, 텍스처, 조명 데이터가 너무 큽니다. 하나의 씬에 모두 담으면 초기 로딩 시간도 길어지고, 런타임 메모리도 기기의 한계를 넘기 쉽습니다.

이때 사용하는 방식이 씬 분할(Scene Splitting)입니다. 월드를 여러 조각의 씬으로 나누고, 플레이어 주변처럼 지금 필요한 조각만 Additive로 로드합니다. 멀어진 조각은 언로드해 메모리에서 내려 보내고, 새로 가까워진 조각은 다시 로드합니다. 즉 씬을 화면 전환 단위가 아니라 월드를 스트리밍하는 단위로 사용하는 방식입니다.


그리드 기반 월드 분할

가장 단순한 분할 방식은 월드를 격자(Grid)로 나누는 것입니다. 월드 좌표를 일정한 크기의 셀로 나누고, 각 셀을 하나의 씬으로 저장합니다. 예를 들어 Cell_11에는 그 구역의 지형, 배치 오브젝트, 조명, 지역 전용 이펙트가 들어갑니다.

런타임에서는 플레이어가 속한 셀을 기준으로 로드할 셀 집합을 계산합니다. 현재 셀만 로드하면 경계에 다가갔을 때 다음 구역이 보이지 않으므로, 보통 현재 셀과 주변 셀까지 함께 올립니다. 아래 예시는 플레이어가 Cell_11에 있을 때 주변 1칸까지 포함한 3×3 범위를 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 로드: 현재 셀 + 주변 1칸 (3×3 = 9개 셀) 언로드 후보: 로드 범위 밖으로 멀어진 셀 점선 = 로드 범위 경계

로드 범위는 게임의 카메라 거리와 이동 속도에 맞춰 정합니다. 멀리까지 보이는 3D 월드라면 주변 1칸만으로는 부족할 수 있고, 이동 속도가 빠른 게임이라면 플레이어가 셀 경계에 닿기 전에 다음 셀이 준비되어 있어야 합니다. 반대로 시야가 좁거나 이동 속도가 느린 게임에서는 더 작은 범위로도 충분할 수 있습니다.

셀 크기도 비용을 크게 바꿉니다. 셀이 너무 크면 한 번 로드할 때 필요 없는 오브젝트와 에셋까지 함께 올라와 메모리 이점이 줄어듭니다. 셀이 너무 작으면 로드와 언로드가 너무 자주 발생하고, 씬 수가 많아져 관리 비용이 늘어납니다. 따라서 셀 크기는 월드의 밀도, 시야 거리, 이동 속도, 플랫폼 메모리를 함께 보고 정해야 합니다.

스트리밍: 미리 로드하고 늦게 내리기

그리드로 월드를 나누었다면, 다음 문제는 플레이어 이동에 맞춰 로드 범위를 자연스럽게 옮기는 일입니다. 셀 경계에 도착한 뒤에야 다음 셀을 로드하기 시작하면 이미 늦습니다. 로딩이 끝날 때까지 빈 지형이 보이거나, 오브젝트가 뒤늦게 나타나는 팝인이 발생할 수 있습니다.

스트리밍(Streaming)은 이 문제를 피하기 위해 필요한 셀을 미리 준비하는 방식입니다. 플레이어가 오른쪽 셀로 이동하고 있다면, 현재 3×3 범위는 유지한 채 오른쪽 열을 먼저 비동기로 로드합니다. 새 셀이 준비되고 플레이어가 실제로 다음 셀에 들어간 뒤에야, 반대편으로 멀어진 셀을 언로드합니다. 핵심은 새 범위는 필요해지기 전에 올리고, 기존 범위는 안전해진 뒤에 내리는 것입니다.

셀 스트리밍 흐름 시간 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 진입 새 열 준비 후 왼쪽 열 언로드 새로 로드 언로드 로딩 중 로드 유지 다음 셀을 먼저 준비해 경계 진입 시 끊김을 줄임 전환 중에는 기존 범위 + 미리 로드한 셀이 함께 유지됨

위 타임라인에서 t0는 플레이어가 Cell_11에 있고 3×3 범위가 이미 로드된 상태입니다. t1에서는 플레이어가 Cell_12 방향으로 이동하고 있으므로 오른쪽 열인 03, 13, 23을 미리 로드합니다. 이때 기존 왼쪽 열인 00, 10, 20은 아직 내리지 않습니다. 새 열이 준비되기 전에 기존 셀을 내리면, 플레이어가 방향을 조금 바꾸거나 로딩이 늦어졌을 때 빈 구간이 생길 수 있기 때문입니다.

t2처럼 새 열 로드가 끝나고 플레이어가 Cell_12에 들어가면 로드 범위를 한 칸 오른쪽으로 옮깁니다. 그때 왼쪽 열은 언로드 후보가 됩니다. 실제 구현에서는 경계에 닿자마자 바로 내리기보다, 플레이어가 충분히 멀어졌을 때 내리는 식으로 약간의 여유를 두는 편이 안정적입니다. 경계 근처에서 앞뒤로 움직일 때 로드와 언로드가 반복되는 것을 막기 위해서입니다.

미리 로드할 시점은 플레이어의 이동 속도와 셀 로딩 시간을 기준으로 정합니다. 로딩에 1초가 걸리고 플레이어가 1초 안에 셀 경계에 도달할 수 있다면, 경계 직전이 아니라 그보다 앞에서 로드를 시작해야 합니다. 로딩이 늦으면 팝인이 생기고, 너무 일찍 시작하면 전환 구간에서 동시에 유지하는 셀이 많아져 메모리 피크가 커집니다.

따라서 메모리 상한을 계산할 때는 안정 상태의 3×3 범위만 보면 부족합니다. 위 예시의 안정 상태는 9개 셀이지만, t1처럼 새 열을 미리 로드하는 동안에는 기존 9개 셀에 3개 셀이 더해져 일시적으로 12개 셀이 메모리에 올라올 수 있습니다. 스트리밍 구조에서는 로드 범위뿐 아니라 미리 로드하는 버퍼까지 포함해 메모리 예산을 잡아야 합니다.

씬 간 공유 에셋 관리

셀을 씬 단위로 나누었다고 해서 에셋 중복 문제가 자동으로 해결되는 것은 아닙니다. 이웃한 셀은 같은 나무 프리팹, 바위 메쉬, 지형 텍스처, 머티리얼을 공유하는 경우가 많습니다. 이 공유 에셋을 어디에 두고 어떤 단위로 패키징하느냐에 따라 빌드 크기와 런타임 메모리가 달라집니다.

프로젝트 안의 일반 씬들이 같은 에셋 파일을 직접 참조한다면, Unity는 같은 GUID를 가리키는 에셋을 같은 대상으로 볼 수 있습니다. 문제는 셀을 AssetBundle이나 Addressables 그룹으로 나누어 배포할 때 더 자주 드러납니다. 공유 에셋을 별도 의존성으로 분리하지 않으면, 각 셀 번들 안에 같은 데이터가 반복해서 포함될 수 있습니다.

공유 에셋의 패키징 방식 문제: 셀 번들마다 공유 에셋을 함께 포함한 경우 Cell_11 번들 Tree_A (4MB) Cell_12 번들 Tree_A (4MB) Cell_21 번들 Tree_A (4MB) 동일 데이터가 3개 번들에 포함 = 12MB 공유 에셋을 한 곳에서 소유 해결 1 빌드 포함 씬 직접 참조 같은 GUID 기준 해결 2 AssetBundle 공유 번들 분리 셀 번들은 의존성만 가짐 해결 3 Addressables 별도 그룹 분리 참조 카운트로 수명 관리 공유 데이터는 한 번만 로드

그림의 핵심은 Tree_A를 어느 셀이 소유하느냐입니다. Tree_A를 각 셀 번들에 함께 넣으면 Cell_11, Cell_12, Cell_21이 모두 같은 데이터를 자기 번들 안에 갖게 됩니다. 겉으로는 같은 나무 프리팹을 참조하는 것처럼 보여도, 패키징 결과는 세 사본이 될 수 있습니다.

해결은 공유 에셋의 소유 위치를 셀 밖으로 빼는 것입니다. Tree_A를 별도의 공유 번들이나 Addressables 그룹에 두고, 셀 번들은 그 에셋을 직접 포함하는 대신 의존성으로 참조합니다. 그러면 여러 셀이 동시에 로드되어도 실제 Tree_A 데이터는 공유 위치에서 한 번만 로드됩니다.

수명 관리도 함께 맞춰야 합니다. 공유 번들은 하나의 셀이 언로드되었다고 바로 내리면 안 됩니다. 다른 셀이 아직 Tree_A를 사용하고 있을 수 있기 때문입니다. AssetBundle을 직접 관리한다면 의존 번들의 로드와 언로드 순서를 직접 보장해야 하고, Addressables를 사용한다면 핸들과 참조 카운트를 기준으로 Release 시점을 맞춰야 합니다.

정리하면, 셀 씬은 지역 전용 데이터만 갖고, 여러 셀이 함께 쓰는 에셋은 별도 공유 단위로 분리하는 편이 좋습니다. 이렇게 해야 셀 스트리밍을 하면서도 같은 프리팹이나 텍스처가 셀마다 중복 로드되는 일을 줄일 수 있습니다.

Addressables를 활용한 구체적인 방법은 메모리 관리 (3) - Addressables와 에셋 전략에서 자세히 다룹니다.

공통 씬과 콘텐츠 씬 분리

규모가 큰 프로젝트에서는 모든 전역 오브젝트를 DontDestroyOnLoad에 올리기보다, 처음부터 별도의 씬으로 관리하는 구조를 많이 사용합니다. 게임 실행 동안 유지될 시스템은 공통 씬(Persistent Scene)에 두고, 스테이지나 지역에 따라 바뀌는 요소는 콘텐츠 씬(Content Scene)으로 분리하는 방식입니다.

공통 씬은 게임의 기준이 되는 씬입니다. 플레이어, 카메라 시스템, UI, 입력, 게임 매니저처럼 지역이 바뀌어도 계속 유지되어야 하는 오브젝트가 여기에 들어갑니다. 콘텐츠 씬은 교체 가능한 부분입니다. 지형, 적 배치, 지역 오브젝트, 로컬 조명처럼 스테이지마다 달라지는 데이터가 여기에 들어갑니다.

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

이 구조에서는 스테이지를 바꿀 때 콘텐츠 씬만 언로드하고 다음 콘텐츠 씬을 Additive로 로드합니다. 공통 씬은 계속 남아 있으므로 플레이어, 카메라, UI, 매니저를 다시 만들 필요가 없습니다. 전환 중에도 입력 상태나 UI 상태를 유지하기 쉽고, 공통 시스템의 초기화 비용도 반복해서 발생하지 않습니다.

DontDestroyOnLoad와 비교하면 수명 관리도 더 눈에 보입니다. DontDestroyOnLoad 씬은 Unity 내부에서 관리되므로 개발자가 씬 단위로 언로드할 수 없습니다. 그 안의 오브젝트를 정리하려면 개별적으로 Destroy()해야 합니다. 반면 공통 씬은 일반 씬이므로 로드 여부를 직접 제어할 수 있고, 필요하면 공통 씬 자체를 언로드해 그 안의 오브젝트를 한 번에 정리할 수 있습니다.

대신 경계는 분명히 해야 합니다. 특정 지역에서만 필요한 적, 지역 연출, 로컬 조명, 임시 오브젝트가 공통 씬에 들어가면 다시 전역 상주 객체가 됩니다. 공통 씬에는 여러 콘텐츠 씬을 지나도 계속 살아야 하는 것만 두고, 지역에 묶인 데이터는 콘텐츠 씬에 남기는 편이 좋습니다.


마무리

이번 글에서는 씬이 무엇으로 이루어지는지에서 출발해, 씬을 로드하고 유지하고 해제하는 과정을 메모리 사용과 함께 살펴보았습니다. 핵심은 다음과 같습니다.

  • 동기 씬 로딩은 로딩이 끝날 때까지 메인 스레드를 붙잡아 화면을 멈추게 합니다. 반면 비동기 씬 로딩은 파일 읽기와 역직렬화, 통합 작업을 여러 프레임과 스레드에 분산해 그 멈춤을 줄입니다.
  • AsyncOperationprogress는 0.9까지가 실제 로딩 구간이고 그 뒤가 활성화 구간이므로, 로딩 바로 표시할 때는 0.9를 100%로 환산합니다. allowSceneActivation을 false로 두면 활성화를 원하는 시점까지 미룰 수 있습니다.
  • Additive 모드는 기존 씬을 둔 채 새 씬을 더해 여러 씬을 함께 올려 둡니다. UI와 게임 플레이, 환경을 역할별 씬으로 나누면 화면 전체를 한꺼번에 교체하지 않고 필요한 부분만 로드하거나 언로드할 수 있습니다.
  • DontDestroyOnLoad는 씬이 바뀌어도 오브젝트를 살려 두지만, 그 오브젝트가 참조하는 에셋까지 함께 메모리에 남습니다. 따라서 전역으로 유지할 대상은 꼭 필요한 시스템으로 좁혀야 합니다.
  • 씬 언로드는 오브젝트를 파괴할 뿐, 그것이 참조하던 에셋을 곧바로 해제하지는 않습니다. Additive 씬을 내린 뒤 남은 에셋까지 해제하려면 Resources.UnloadUnusedAssets()를 직접 호출해야 합니다.
  • 대규모 월드는 월드를 격자 셀 단위 씬으로 나누고, 게임 내내 유지할 시스템을 담는 공통 씬과 지역별 콘텐츠 씬을 분리해 다룹니다. 플레이어 주변의 셀만 로드하도록 개수를 제한하면 메모리 상한을 예측하기 쉬워집니다.

결국 씬 관리는 메모리를 언제 확보하고 언제 해제할지 정하는 문제입니다. 어떤 에셋을 어느 시점에 로드하고 언로드할지, 전환 중 두 씬의 에셋이 겹치는 피크를 어떻게 낮출지가 메모리가 빠듯한 모바일에서 안정성을 좌우합니다.


이 글은 씬 단위의 로드와 해제를 다뤘습니다. 에셋 하나하나의 메모리 생명주기를 관리하는 AssetBundle과 Addressables의 로드·해제 패턴은 메모리 관리 (3) - Addressables와 에셋 전략에서 이어집니다. 씬 안의 오브젝트가 어떤 구조로 연결되는지는 Unity 엔진 핵심 (1) - GameObject와 Component에서 확인할 수 있습니다.



관련 글

전체 시리즈

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

Categories: ,