작성일 :

에셋을 필요할 때 불러오는 방법

메모리 관리 (2) - 네이티브 메모리와 에셋에서 Unity의 네이티브 메모리 구조를 다루었습니다. 텍스처, 메쉬, 오디오 클립 같은 에셋이 네이티브 메모리에 로드되며, C# 래퍼 객체는 관리 힙에 존재하지만 실제 데이터는 네이티브 측에 상주한다는 것을 확인했습니다.

그 글에서 Resources 폴더의 한계도 함께 다루었습니다. Resources 폴더에 넣은 에셋은 빌드에 전부 포함되며, 앱 시작 시점에 에셋 경로 목록을 메모리에 올려야 합니다. 에셋이 수천 개로 늘어나면 이 목록만으로도 수 MB의 메모리를 차지하고, 앱 시작 시간도 길어집니다. Resources.UnloadAsset()으로 특정 에셋 하나를 해제할 수는 있지만, 참조 카운팅이 없어서 다른 곳에서 아직 사용 중인 에셋을 해제하면 렌더링 오류가 발생합니다. 관련 에셋을 번들 단위로 묶어 관리하는 구조도 없으므로, 에셋이 늘어날수록 해제 시점의 안전성을 보장하기 어렵습니다.

게임이 복잡해질수록 이 문제는 심각해집니다. RPG의 수백 종 장비, 오픈 월드의 지역별 환경 에셋, 라이브 서비스의 시즌 콘텐츠처럼 에셋이 계속 늘어나는 프로젝트에서는, 전체 에셋을 빌드에 포함하고 한꺼번에 올리는 방식이 메모리와 초기 다운로드 크기 양쪽에서 한계에 부딪힙니다.


에셋을 그룹 단위로 묶고, 필요할 때 로드하고, 불필요해지면 해제하는 세밀한 관리가 필요합니다. 이 역할을 담당하는 것이 AssetBundle이고, AssetBundle 위에서 의존성 관리와 참조 카운팅을 자동화하는 추상화 계층이 Addressables입니다.



AssetBundle의 기본 구조

AssetBundle은 Unity 에셋을 별도의 파일로 묶어 빌드하는 시스템입니다. 앱 본체와 분리되어 있으므로, 앱을 다시 빌드하지 않고도 에셋만 따로 갱신할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AssetBundle의 기본 구조 예시

앱 빌드 (APK / IPA)
┌────────────────────────────┐
│ 실행 코드                  │
│ 기본 씬 데이터             │
│ StreamingAssets (선택)     │
└────────────────────────────┘

AssetBundle 파일들 (별도 빌드)

번들            포함 에셋
─────────────────────────────────────────────────
스테이지 1      캐릭터 모델, 환경 텍스처, 배경 음악
스테이지 2      보스 모델, 환경 텍스처, 컷씬 데이터
공유            공통 UI, 폰트, 공통 셰이더
─────────────────────────────────────────────────
→ 로컬 저장소 또는 원격 서버에 배치


AssetBundle의 동작 방식은 빌드, 로드, 언로드의 세 단계로 나뉩니다.

빌드. Unity 에디터에서 에셋을 번들 단위로 묶어 파일을 생성합니다. 각 번들은 독립적인 바이너리 파일이며, 내부에 에셋 데이터와 메타데이터가 압축되어 저장됩니다.

로드. 런타임에서 번들 파일을 메모리에 올리고, 내부의 개별 에셋을 꺼내 사용합니다. 번들이 로컬에 있으면 디스크에서 바로 읽고, 원격 서버에 있으면 다운로드 후 로컬 캐시에 저장한 뒤 읽습니다. 로컬 번들은 StreamingAssets 폴더에 넣어 두는 것이 일반적입니다.

StreamingAssets: Resources, AssetBundle, Addressables가 에셋의 로드/언로드를 다루는 메모리 관리 시스템인 반면, StreamingAssets는 Unity가 변환하지 말아야 할 원본 파일을 빌드에 포함하기 위한 파일 저장소입니다. 이름 그대로 영상·오디오의 스트리밍 재생이 초기 주요 용도였습니다. 영상 플레이어는 Unity 에셋 참조가 아니라 파일 경로가 필요한데, Unity의 에셋 파이프라인을 거치면 원본 포맷이 변환되어 재생이 불가능하기 때문입니다.

앱 설치 파일(APK/IPA) 안에 포함되는 읽기 전용 폴더이며, Unity는 일반 에셋을 임포트할 때 플랫폼별 포맷으로 변환하지만, 이 폴더의 파일은 변환 없이 원본 그대로 빌드에 복사됩니다. Unity의 에셋 시스템에 등록되지 않는 raw 파일이므로, 씬 참조나 Inspector에서 직접 할당할 수 없고 런타임에서 Application.streamingAssetsPath를 통해 파일 경로로만 접근합니다.

AssetBundle(LZ4/LZMA), 영상(H.264) 등 이미 자체 압축이 적용된 파일을 넣는 것이므로, “변환 없음”이 “비압축”을 뜻하지는 않습니다. 파일은 디스크에만 존재하며 명시적으로 로드할 때만 메모리를 사용하므로, 영향을 주는 것은 런타임 메모리가 아니라 앱 설치 크기입니다. 초기 플레이에 필요한 AssetBundle 파일, 스트리밍 재생용 영상, 사전 구축된 데이터베이스(SQLite 등) 등을 넣어 두는 용도로 사용합니다.

언로드. 번들이 더 이상 필요 없으면 AssetBundle.Unload()를 호출하여 메모리에서 해제합니다. Unload(true)는 번들과 번들에서 로드한 에셋을 모두 해제합니다. Unload(false)는 번들만 해제하고, 이미 로드한 에셋은 메모리에 남겨둡니다.

1
2
3
4
AssetBundle 생명 주기

빌드 (에디터):  에셋 그룹 지정 → 번들 파일 생성 → 서버 또는 로컬에 배포
런타임 (기기):  번들 파일 로드 → 개별 에셋 추출 → 에셋 사용 → 번들 언로드(메모리 해제)


이 구조 덕분에 앱 본체에는 필수 에셋만 포함하고, 나머지는 필요할 때 다운로드하여 사용할 수 있습니다. 모바일 앱 스토어는 초기 다운로드 크기를 제한하므로, AssetBundle을 활용한 온디맨드 다운로드는 이 제한을 지키는 핵심 수단입니다.



AssetBundle 의존성

AssetBundle을 사용할 때 반드시 다루어야 하는 문제가 의존성(Dependency)입니다.

에셋은 다른 에셋을 참조합니다. 3D 모델의 머티리얼은 텍스처를 참조하고, 프리팹은 메쉬와 머티리얼을 참조합니다. 이 참조 관계가 번들 경계를 넘으면 번들 간 의존성이 생깁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
번들 간 의존성
══════════════════════════════════════════════════════════

  Bundle A (캐릭터)            Bundle B (텍스처)
  ┌──────────────────┐        ┌──────────────────┐
  │                  │        │                  │
  │  캐릭터 프리팹   │        │  캐릭터 텍스처   │
  │       │          │        │       ▲          │
  │       ▼          │        │       │          │
  │  머티리얼 ───────│────────│───────┘          │
  │                  │  참조  │                  │
  └──────────────────┘        └──────────────────┘

  Bundle A만 로드한 경우:
  → 머티리얼의 텍스처 슬롯이 Bundle B의 에셋을 가리킴
  → Bundle B가 메모리에 없으면 텍스처를 찾을 수 없음
  → 화면에 마젠타색 머티리얼이 표시됨

  올바른 로드 순서:
  → Bundle B(텍스처)를 먼저 로드 → Bundle A(캐릭터)를 로드

Bundle A의 머티리얼이 Bundle B의 텍스처를 참조하는 경우, Bundle A를 로드할 때 Bundle B도 반드시 로드되어 있어야 합니다. 그렇지 않으면 머티리얼의 텍스처 슬롯이 비어 화면에 마젠타색 머티리얼이 표시됩니다.


의존성 체인은 길어질 수 있습니다. Bundle A가 Bundle B에 의존하고, Bundle B가 Bundle C에 의존하면, A를 로드할 때 B와 C도 함께 로드해야 합니다. 에셋 수가 늘어나고 번들 구조가 복잡해질수록 이 의존성 그래프도 복잡해집니다.

AssetBundle API를 직접 사용하면, 개발자가 이 의존성을 수동으로 관리해야 합니다. 매니페스트(manifest) 파일 — 번들 빌드 시 Unity가 자동 생성하는 메타데이터로, 각 번들의 에셋 목록과 번들 간 의존 관계가 기록되어 있습니다 — 을 읽어 로드 순서를 결정하며, 언로드 시에도 다른 번들이 아직 참조 중인지 확인하는 과정이 모두 개발자 몫입니다.

프로젝트 규모가 커지면 이 수동 관리가 오류의 원인이 됩니다.


이 의존성 관리를 자동으로 처리하기 위해 Unity가 제공하는 시스템이 Addressables입니다.


Addressables 시스템

Addressables는 AssetBundle 위에 구축된 추상화 계층입니다. AssetBundle의 기능(에셋을 번들로 묶고, 로드하고, 언로드하는 것)을 그대로 사용하되, 번들의 내부 구조와 의존성 관리를 시스템이 대신 처리합니다.

주소(Address) 기반 접근

Addressables의 핵심 아이디어는 에셋에 주소(address) 문자열을 부여하는 것입니다. 주소만 지정하면 시스템이 해당 에셋을 찾아 로드합니다. 에셋이 어떤 번들에 속하는지, 번들이 로컬에 있는지 서버에 있는지는 시스템이 자동으로 판단합니다.

1
2
3
4
5
6
7
8
9
10
11
AssetBundle 직접 사용 vs Addressables

                 AssetBundle 직접 사용          Addressables
─────────────────────────────────────────────────────────────
로드 절차         매니페스트 읽기                주소 하나로 호출
                  → 의존 번들 먼저 로드          LoadAssetAsync<T>("address")
                  → 대상 번들 로드
                  → 에셋 이름으로 추출
의존성 관리        개발자가 직접 추적             시스템이 자동 해결
언로드            의존성 역추적 필요             Release() 호출만으로 처리
로컬/원격 판별     개발자가 경로 지정             시스템이 자동 판별


실제 로드 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 텍스처 로드
AsyncOperationHandle<Texture2D> handle =
    Addressables.LoadAssetAsync<Texture2D>("character_diffuse");

handle.Completed += (op) =>
{
    Texture2D texture = op.Result;
    // 텍스처 사용
};

// 사용이 끝나면 해제
Addressables.Release(handle);


"character_diffuse"라는 주소만 지정하면, 이 텍스처가 어떤 번들에 들어 있는지, 그 번들이 다른 번들에 의존하는지, 번들이 로컬에 있는지 서버에 있는지를 Addressables가 자동으로 판단하여 처리합니다.

프리팹을 로드하여 씬에 인스턴스화하는 코드도 같은 패턴입니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 프리팹 인스턴스화
AsyncOperationHandle<GameObject> handle =
    Addressables.InstantiateAsync("enemy_prefab");

handle.Completed += (op) =>
{
    GameObject enemy = op.Result;
    // 인스턴스 사용
};

// 사용이 끝나면 해제 (게임오브젝트 파괴 + 참조 카운트 감소)
Addressables.ReleaseInstance(handle.Result);


InstantiateAsync는 내부적으로 프리팹이 속한 번들을 로드하고, 의존하는 번들도 함께 로드한 뒤, 프리팹을 인스턴스화합니다. 이 모든 과정이 하나의 호출로 완료됩니다.


의존성 자동 해결

Addressables가 에셋을 로드할 때, 카탈로그(Catalog)라는 데이터 구조를 참조합니다. 카탈로그에는 각 에셋의 주소, 소속 번들, 번들 간 의존성 정보가 기록되어 있습니다. 이 카탈로그는 빌드 시점에 Addressables가 자동으로 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Addressables 카탈로그 구조 (개념)

카탈로그 항목 1:
  주소: "character_diffuse"
  ├─ 소속 번들: bundle_textures_01
  └─ 위치: 원격 (CDN)

카탈로그 항목 2:
  주소: "enemy_prefab"
  ├─ 소속 번들: bundle_prefabs_01
  ├─ 의존 번들: bundle_textures_01, bundle_materials_01
  └─ 위치: 로컬 (StreamingAssets)

"enemy_prefab" 로드 요청 시:
  1. 카탈로그에서 의존 번들 확인
  2. bundle_textures_01, bundle_materials_01 먼저 로드
  3. bundle_prefabs_01 로드
  4. 프리팹 추출 및 반환
  → 개발자는 이 과정을 의식하지 않음

개발자가 "enemy_prefab"을 로드하면, Addressables는 카탈로그를 조회하여 이 프리팹이 bundle_prefabs_01에 속하고, 이 번들이 bundle_textures_01bundle_materials_01에 의존한다는 것을 파악합니다. 의존 번들을 먼저 로드하고, 이후 프리팹이 속한 번들을 로드한 뒤 프리팹을 반환합니다.

이 자동 해결 덕분에, 번들 구조를 재편성하거나 에셋의 번들 소속을 변경해도 기존 로드 코드를 수정할 필요가 없습니다. 에셋에 부여한 주소 문자열(예: "character_diffuse")만 바꾸지 않으면, 해당 에셋이 어떤 번들로 이동하든 카탈로그 재빌드만으로 반영됩니다.



참조 카운팅 (Reference Counting)

Addressables는 로드한 에셋에 대해 참조 카운팅(Reference Counting)을 적용합니다. 같은 에셋을 여러 곳에서 로드하면 참조 카운트가 증가하고, Release()를 호출하면 감소합니다.

실제 메모리 해제는 번들 단위로 이루어집니다. 번들에 속한 모든 에셋의 참조 카운트가 0이 되면, Addressables가 해당 번들을 자동으로 언로드합니다. 개발자가 하는 일은 Release() 호출까지이며, 번들 언로드 시점의 판단과 실행은 시스템이 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
참조 카운팅 동작 (hero_tex와 hero_mat가 같은 번들에 포함된 경우)

시점   동작                           hero_tex   hero_mat   번들 상태
──────────────────────────────────────────────────────────────────────
T1     LoadAssetAsync("hero_tex")        1         -        번들 로드됨
T2     LoadAssetAsync("hero_tex")        2         -        유지 (재로드 없음)
T3     LoadAssetAsync("hero_mat")        2         1        유지
T4     Release(hero_tex) 2회 호출        0         1        유지 (hero_mat 사용 중)
T5     Release(hero_mat)                 0         0        번들 언로드됨

T2: 같은 에셋을 다시 로드해도 참조 카운트만 증가, 재로드 없음
T4: hero_tex가 모두 해제되어도 같은 번들의 hero_mat가 사용 중이므로 번들 유지
T5: 번들 내 모든 에셋의 참조 카운트가 0 → 번들이 메모리에서 해제됨

이 원리는 의존 번들에도 동일하게 적용됩니다. 어떤 번들이 의존하는 번들은, 해당 번들을 필요로 하는 에셋이 모두 해제되어야 언로드됩니다.


1
2
3
4
5
6
7
8
9
10
11
의존 번들의 연쇄 언로드

Bundle A (프리팹) ──의존──▶ Bundle B (텍스처)

현재 상태:
  에셋 X (Bundle A): 참조 카운트 2   → Bundle A 유지 → Bundle B도 유지
  에셋 Y (Bundle A): 참조 카운트 0

에셋 X를 Release하여 참조 카운트가 0이 되면:
  → Bundle A의 모든 에셋 참조 카운트 0 → Bundle A 언로드
  → Bundle B를 참조하는 다른 번들이 없으면 → Bundle B도 언로드


참조 카운팅은 메모리 관리를 자동화하지만, Release 호출을 빠뜨리면 메모리 누수가 발생합니다. 에셋을 로드한 횟수와 해제한 횟수가 일치해야 합니다. LoadAssetAsync를 3번 호출했으면 Release도 3번 호출해야 참조 카운트가 0이 되어 실제 언로드가 이루어집니다.

InstantiateAsync로 생성한 인스턴스는 Addressables.ReleaseInstance()로 해제합니다. Destroy()만 호출하면 게임오브젝트는 파괴되지만 Addressables의 참조 카운트는 감소하지 않으므로, 원본 에셋과 번들이 메모리에 계속 남게 됩니다.

1
2
3
4
5
6
InstantiateAsync로 생성한 오브젝트의 해제 비교

해제 방식                          오브젝트    참조 카운트          결과
──────────────────────────────────────────────────────────────────────────
Destroy(obj)                       파괴됨     유지(감소 안 됨)    번들 잔류 → 누수
Addressables.ReleaseInstance(obj)  파괴됨     감소               카운트 0이면 번들 언로드


씬 전환 시에도 같은 문제가 발생합니다. LoadSceneMode.Single로 새 씬을 로드하면 Unity가 이전 씬의 게임오브젝트를 파괴하지만, Addressables의 참조 카운트는 감소하지 않습니다. 게임오브젝트가 파괴되는 것과 Release()가 호출되는 것은 별개이기 때문입니다. 씬 전환 후 Unity가 내부적으로 Resources.UnloadUnusedAssets()를 호출하더라도, Addressables가 참조 중이라고 판단하는 에셋은 해제 대상에서 제외됩니다.

따라서 씬 전환 전에 모든 Addressables 핸들을 명시적으로 Release()해야 합니다.


프로젝트 규모가 커지면 로드/해제 쌍을 추적하기 어려워집니다.

Addressables의 Event Viewer(에디터에서 Window > Asset Management > Addressables > Event Viewer)를 사용하면, 현재 로드된 에셋과 참조 카운트를 실시간으로 확인할 수 있습니다. 참조 카운트가 감소하지 않는 에셋이 있으면 Release 호출이 누락된 것입니다.



로딩 전략

Addressables는 의존성 해결과 번들 언로드 시점을 자동으로 처리하지만, 에셋을 언제 로드하고 Release()할지는 개발자가 결정해야 합니다.

씬 진입 전에 미리 로드할 것인지, 필요한 시점에 로드할 것인지에 따라 메모리 사용 패턴과 사용자 경험이 달라집니다.

대표적인 전략은 프리로드와 지연 로드입니다.



프리로드 (Preload)

프리로드는 씬에 진입하기 전, 로딩 화면에서 필요한 에셋을 미리 로드하는 방식입니다. 플레이어가 로딩 화면을 보는 동안 캐릭터 모델, 환경 텍스처, 사운드 등을 메모리에 올려둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
프리로드 흐름

타이틀 화면 ──▶ 로딩 화면 (진행률 표시) ──▶ 게임플레이 씬 (지연 없이 즉시 사용)
                  캐릭터 로드
                  환경 로드
                  사운드 로드

메모리:
──────────────────────────────────────────────────
타이틀 화면:   [기본 에셋]
로딩 화면:     [기본 에셋][캐릭터][환경][사운드]  ← 증가
게임플레이:    [기본 에셋][캐릭터][환경][사운드]  ← 유지


프리로드의 장점은 게임플레이 중 로딩 지연이 없다는 점입니다. 에셋이 이미 메모리에 있으므로, 적을 스폰하거나 이펙트를 재생할 때 즉시 사용할 수 있습니다. 프레임 단위의 반응이 중요한 액션 게임에서 에셋 로딩으로 인한 끊김은 치명적이므로, 핵심 에셋은 프리로드하는 것이 일반적입니다.

단점은 메모리 점유 시간이 길다는 점입니다. 스테이지 전체에서 사용할 에셋을 한꺼번에 올리면, 실제로는 스테이지 후반에만 등장하는 보스 에셋도 시작부터 메모리를 차지합니다. 모바일처럼 메모리가 제한된 환경에서는 이 점유가 부담이 됩니다.

프리로드를 효과적으로 사용하려면, 에셋을 구역(zone) 또는 단계(phase)별로 분류하여 해당 구간에 필요한 에셋만 로드합니다. 한 구역에서 다음 구역으로 이동할 때, 이전 구역의 에셋을 해제하고 새 구역의 에셋을 로드하는 방식입니다.



지연 로드 (Lazy Load)

지연 로드는 에셋이 실제로 필요한 시점에 로드하는 방식입니다. 적이 스폰될 때 적의 프리팹을 로드하고, 새로운 지역에 진입할 때 해당 지역의 텍스처를 로드합니다.

1
2
3
4
5
6
7
8
9
10
지연 로드 흐름

게임플레이 중 ──▶ 적 스폰 요청 ──▶ 비동기 로드 + 생성 ──▶ 사용 ──▶ Release ──▶ 언로드
                   LoadAssetAsync("orc_prefab")                   Release(handle)

메모리:
──────────────────────────────────────────────────
게임플레이:   [기본 에셋]
적 스폰:      [기본 에셋][orc + 의존 에셋]  ← 증가
적 처치:      [기본 에셋]                  ← 감소


지연 로드의 장점은 메모리 효율입니다. 사용하지 않는 에셋은 메모리에 올라가지 않으므로, 전체 메모리 피크가 낮아집니다. 콘텐츠가 방대하여 모든 에셋을 한 번에 올릴 수 없는 오픈 월드 게임에서는 사실상 필수적인 전략입니다.

단점은 로딩 지연입니다. 에셋을 디스크에서 읽고 압축을 해제하는 데 시간이 걸립니다. 모바일 기기의 저장 장치에서 수 MB 크기의 번들을 읽는 데 수십에서 수백 밀리초가 소요될 수 있으며, 이 시간 동안 해당 에셋을 사용할 수 없습니다.


Addressables의 LoadAssetAsync는 비동기 연산입니다. 에셋 로드가 진행되는 동안에도 게임 루프는 계속 실행됩니다. 메인 스레드가 블록되지 않으므로, 로드 시간이 길어도 화면이 멈추지는 않습니다.

하지만 에셋이 준비될 때까지 해당 에셋을 사용하는 기능은 작동할 수 없습니다. 로드 완료 전에는 대체 오브젝트를 표시하고, 완료 후 교체하는 등 에셋 준비 상태에 따른 처리가 필요합니다.



프리로드와 지연 로드의 조합

실제 프로젝트에서는 두 전략을 혼합합니다. 핵심 에셋은 프리로드하고, 부가적인 에셋은 지연 로드하는 것이 일반적인 패턴입니다.

1
2
3
4
5
6
7
8
9
혼합 전략 예시

전략          에셋 예시                              이유
──────────────────────────────────────────────────────────────────────
프리로드      플레이어 캐릭터 모델, 애니메이션          없으면 게임 진행 불가
(로딩 화면)   기본 환경 텍스처, 공통 UI, 기본 사운드    즉시 사용 가능해야 함

지연 로드     NPC 모델 (해당 지역 진입 시)            필요할 때만 메모리 사용
(필요 시점)   보스 에셋, 이벤트 컷씬, 수집 아이템       약간의 로딩 지연 허용

이 분류는 프로젝트의 특성에 따라 달라집니다. 핵심 원칙은, 로딩 지연이 플레이어 경험을 해치는 에셋은 프리로드하고, 약간의 지연이 허용되는 에셋은 지연 로드하는 것입니다.



에셋 중복과 Analyze 도구

앞에서 프리로드와 지연 로드를 조합하는 로딩 전략을 살펴봤습니다. 로딩 전략이 효과를 발휘하려면, 번들 구조 자체가 효율적이어야 합니다. Addressables에서 번들 구조를 잘못 설계하면 에셋 중복(Asset Duplication)이 발생합니다. 같은 에셋이 여러 번들에 복사되어 포함되는 현상입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
에셋 중복 발생 조건
══════════════════════════════════════════════════════════

  공유 텍스처 (어떤 번들에도 명시적으로 포함되지 않음)
         ▲               ▲
         │               │
  Bundle A의 머티리얼   Bundle B의 머티리얼
  (A에 명시적 포함)     (B에 명시적 포함)

  빌드 결과:
  ┌─────────────┐     ┌─────────────┐
  │ Bundle A    │     │ Bundle B    │
  │             │     │             │
  │ 머티리얼 A   │     │ 머티리얼 B   │
  │ 텍스처 사본  │     │ 텍스처 사본   │  ← 동일 텍스처가 복사됨
  │             │     │             │
  └─────────────┘     └─────────────┘

  텍스처 크기가 2MB라면, 총 4MB 사용 (2MB 낭비)

중복이 발생하는 조건은 명확합니다. Addressables에서 에셋을 그룹에 넣으면 해당 그룹의 번들에 포함됩니다. 다른 번들의 에셋이 이 에셋을 참조하면, 빌드 시스템은 해당 에셋이 이미 특정 번들에 존재한다는 것을 알고 있으므로 복사하지 않고 번들 간 의존성만 생성합니다.

문제는 여러 번들에서 참조하는 에셋이 어떤 그룹에도 할당되지 않은 경우입니다. 빌드 시스템은 이 에셋이 어디에 있는지 모르므로, 참조하는 번들 각각에 복사본을 포함시킵니다. 위 다이어그램에서 공유 텍스처가 어떤 번들에도 명시적으로 포함되지 않았기 때문에, Bundle A와 B 양쪽에 복사본이 들어간 것이 이 경우에 해당합니다.

이 문제의 해결책은 공유되는 에셋을 별도의 번들에 명시적으로 할당하는 것입니다. 위 예시에서 공유 텍스처를 Bundle_Shared에 넣으면, Bundle A와 B는 Bundle_Shared에 의존하되 텍스처를 복사하지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
중복 해결: 공유 번들 분리
══════════════════════════════════════════════════════════

  ┌─────────────┐     ┌─────────────┐
  │ Bundle A    │     │ Bundle B    │
  │             │     │             │
  │ 머티리얼 A   │     │ 머티리얼 B   │
  │             │     │             │
  └──────┬──────┘     └──────┬──────┘
         │                   │
         └────────┬──────────┘
                  │
                  ▼
         ┌─────────────────┐
         │ Bundle_Shared   │
         │                 │
         │ 공유 텍스처      │  ← 한 번만 존재
         │                 │
         └─────────────────┘

  텍스처 크기 2MB × 1 = 2MB (중복 없음)


에셋 수가 적을 때는 수동으로 확인할 수 있지만, 에셋이 수백, 수천 개가 되면 중복을 육안으로 찾기 어렵습니다. Addressables에 내장된 Analyze 도구(에디터에서 Window > Asset Management > Addressables > Analyze)가 이 검출을 자동으로 수행합니다.

Analyze 도구의 “Check Duplicate Bundle Dependencies” 규칙을 실행하면, 여러 번들에 중복 포함된 에셋 목록이 표시됩니다. 각 에셋이 어떤 번들들에 의해 참조되는지, 중복으로 인해 낭비되는 크기가 얼마인지 확인할 수 있습니다.

“Fix Selected Rules” 버튼을 누르면, 중복 에셋을 자동으로 공유 번들로 분리하는 것도 가능합니다.

정기적으로 Analyze를 실행하여 중복을 검출하고, 공유 에셋을 적절한 번들로 분리하면 번들 크기와 런타임 메모리를 효율적으로 관리할 수 있습니다.



빌드 사이즈 최적화

앞에서 런타임 메모리의 효율적 관리를 다루었습니다. 메모리와 함께 모바일에서 신경 써야 할 또 다른 축이 초기 다운로드 크기입니다.

모바일 앱은 이 크기에 대한 플랫폼 제한이 있습니다. Google Play의 AAB(Android App Bundle) 기본 설치 크기 제한은 200MB이고, iOS에서는 200MB를 넘으면 셀룰러 네트워크에서 다운로드 경고가 표시됩니다.

이 제한을 지키면서 풍부한 콘텐츠를 제공하려면, 에셋의 배포 전략이 필요합니다.



온디맨드 다운로드

초기 설치에는 게임을 시작하는 데 필수적인 에셋만 포함합니다. 타이틀 화면, 튜토리얼, 첫 번째 스테이지의 에셋이 이에 해당합니다. 나머지 에셋은 게임 진행에 따라 다운로드합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
온디맨드 다운로드 구조
══════════════════════════════════════════════════════════

초기 설치 (APK / IPA)
┌──────────────────────────────────────────┐
│  실행 코드                                │
│  타이틀/튜토리얼 에셋                       │  ≤ 200MB
│  Addressables 카탈로그                    │
└──────────────────────────────────────────┘

원격 서버 (CDN — 전 세계에 분산된 콘텐츠 배포 서버)
┌──────────────────────────────────────────┐
│  스테이지 1 번들  (50MB)                   │
│  스테이지 2 번들  (80MB)                   │
│  스테이지 3 번들  (60MB)                   │
│  이벤트 콘텐츠 번들  (30MB)                │
│  ...                                     │
└──────────────────────────────────────────┘

게임 진행 흐름:
  튜토리얼 완료 → 스테이지 1 번들 다운로드 → 플레이
                → 스테이지 2 번들 다운로드 → 플레이
                → ...


Addressables는 번들의 위치를 로컬과 원격으로 구분하여 설정할 수 있습니다. Addressables 그룹 설정에서 “Build & Load Path”를 원격(Remote)으로 지정하면, 해당 그룹의 번들은 빌드 후 서버에 업로드하고, 런타임에서는 서버에서 다운로드하여 로컬 캐시에 저장합니다. 한 번 다운로드한 번들은 캐시에 보관되므로, 같은 번들을 다시 요청할 때 네트워크 통신 없이 캐시에서 로드합니다.


각 플랫폼이 제공하는 온디맨드 리소스 기능도 활용할 수 있습니다. Android의 Play Asset Delivery(PAD)는 install-time, fast-follow, on-demand 세 가지 전달 모드를 제공합니다. install-time은 설치와 함께 다운로드되고, fast-follow는 설치 직후 백그라운드에서 다운로드되며, on-demand는 앱이 요청할 때 다운로드됩니다. iOS의 On-Demand Resources(ODR)도 유사한 구조로, 태그를 부여한 리소스를 앱이 요청할 때 App Store에서 다운로드합니다.



에셋 압축 방식

AssetBundle은 빌드 시 압축됩니다. 압축 방식에 따라 번들의 파일 크기, 로드 속도, 메모리 사용 패턴이 달라지므로, 사용 목적에 맞는 방식을 선택해야 합니다.

1
2
3
4
5
6
7
8
9
AssetBundle 압축 방식 비교

항목        LZ4                              LZMA
──────────────────────────────────────────────────────────────────
압축률      중간 (원본의 약 60~70%)           높음 (원본의 약 40~50%)
로드 방식   청크(Chunk) 단위, 부분 접근 가능  전체 압축 해제 후 접근
로드 속도   빠름 (필요한 부분만 해제)         느림 (전체를 먼저 해제)
메모리      낮음 (청크만 해제)               높음 (전체 해제 버퍼 필요)
용도        런타임 로딩용, 모바일 권장        다운로드/배포용, 크기 절약


LZ4는 블록(chunk) 단위 압축 방식입니다. 데이터를 독립적인 블록으로 나누어 각각 압축하므로, 번들 내부의 에셋을 개별적으로 접근할 때 해당 에셋이 속한 블록만 압축을 해제합니다. 번들 전체를 한꺼번에 해제하지 않으므로 로드 속도가 빠르고, 메모리 피크도 낮습니다. 모바일에서 런타임 로딩에 사용하는 번들은 LZ4 압축이 권장됩니다.

LZMA는 스트림 기반 압축 방식으로, 압축률이 높습니다. 같은 에셋을 LZ4로 압축했을 때보다 파일 크기가 작아지므로, 다운로드 크기를 줄이는 데 유리합니다. 대신 LZMA는 데이터를 하나의 연속된 스트림으로 압축하기 때문에, 에셋 하나에 접근하려면 번들 전체를 메모리에 압축 해제해야 합니다. 해제 시간이 길고, 해제된 데이터를 담을 임시 버퍼가 필요하여 메모리 피크가 높아집니다.

1
2
3
4
5
6
7
8
9
LZ4 vs LZMA 메모리 피크 비교

LZ4 로드 과정:
  [블록 로드] → [블록 압축 해제] → [최종 에셋]
                 ▲ 메모리 피크: 블록 + 에셋만

LZMA 로드 과정:
  [번들 로드] → [전체 압축 해제 (번들 + 해제 버퍼 동시 존재)] → [최종 에셋]
                 ▲ 메모리 피크: 번들 + 전체 해제 버퍼


서버용 번들을 LZMA로 빌드하면(Addressables 그룹 설정의 Bundle Compression 옵션) 다운로드 크기가 최소화됩니다. 클라이언트가 이 번들을 처음 다운로드하면, Unity의 Caching 시스템이 자동으로 LZMA를 해제한 뒤 LZ4로 재압축하여 로컬 캐시에 저장합니다. 이후 같은 번들을 요청하면 서버에 다시 접근하지 않고 캐시의 LZ4 번들에서 직접 로드합니다. 재압축은 첫 다운로드 시에만 발생하는 일회성 비용이며, Caching.compressionEnabled 속성(기본값 true)이 이 재압축 여부를 제어합니다.



비압축(Uncompressed)

압축을 적용하지 않는 선택지도 있습니다. 파일 크기가 가장 크지만, 압축 해제 과정이 없으므로 로드 속도가 가장 빠릅니다. 오디오나 비디오처럼 이미 자체 압축(Vorbis, H.264 등)이 적용된 데이터에는 번들 압축이 추가적인 크기 절약을 주지 않으면서 로드 비용만 늘리므로, 비압축이 합리적일 수 있습니다.

모바일에서의 일반적인 지침은, 대부분의 번들에 LZ4를 사용하는 것입니다. LZ4는 압축률과 로드 속도 사이에서 모바일에 적합한 균형점을 제공합니다.



번들 그룹 전략

압축 방식은 번들의 로드 속도와 메모리 사용을 결정하지만, 그 효과를 최대한 활용하려면 어떤 에셋을 어떤 번들에 묶을지가 먼저 결정되어야 합니다. Addressables에서 이 구성은 그룹(Group) 설정으로 결정됩니다. 하나의 그룹이 하나의 번들(또는 설정에 따라 여러 번들)로 빌드되며, 그룹 구성에 따라 다운로드 크기, 로딩 시간, 메모리 사용량이 달라집니다.

그룹을 구성하는 기본 원칙은 함께 사용되는 에셋을 함께 묶는 것입니다. 스테이지 1에서만 사용되는 에셋은 하나의 그룹으로, 모든 스테이지에서 공통으로 사용되는 에셋은 별도의 공유 그룹으로 분리합니다. 이렇게 하면 스테이지 1에 진입할 때 해당 번들만 로드하고, 스테이지를 떠날 때 해제할 수 있습니다. 모바일에서 번들 하나의 크기는 5~30MB가 로드 시간과 관리 편의성 사이의 균형점입니다.

반대로 모든 에셋을 하나의 번들에 넣으면, 에셋 하나를 쓰기 위해 전체 번들을 로드해야 하고 부분 업데이트도 불가능합니다.

에셋마다 개별 번들을 만들면, 번들 수가 수백 개로 늘어나 카탈로그가 비대해지고 HTTP 요청 수도 증가하여 네트워크 오버헤드가 발생합니다.


콘텐츠 업데이트 빈도도 그룹 분리의 기준입니다. 자주 변경되는 에셋(이벤트 콘텐츠, 시즌 스킨)과 거의 변경되지 않는 에셋(기본 UI, 공통 셰이더)을 같은 그룹에 넣으면, 이벤트 콘텐츠만 바뀌어도 전체 번들을 다시 다운로드해야 합니다. 분리해 두면 변경된 번들만 업데이트할 수 있습니다.


Addressables의 카탈로그에는 에셋 주소와 번들 위치 정보가 담겨 있습니다. 서버에 새로운 번들을 배포하면 카탈로그도 함께 갱신해야 합니다.

Addressables는 앱 시작 시점에 서버의 카탈로그 해시를 확인하여, 로컬 카탈로그의 해시와 다르면 내용이 변경된 것으로 판단하고 새 카탈로그를 다운로드합니다. 해시는 파일 내용을 고정 길이 문자열로 요약한 값으로, 파일이 변경되었는지 빠르게 확인할 수 있습니다. 이 구조를 활용하면 앱 스토어에 앱을 다시 제출하지 않고도 에셋을 업데이트할 수 있습니다.



메모리에서 서브시스템으로

메모리 관리 시리즈에서 가비지 컬렉션의 원리, 네이티브 메모리와 에셋의 구조, Addressables를 통한 에셋 로드/언로드 전략까지 다루었습니다. 스크립트 최적화 시리즈에서 다룬 C# 메모리 할당 패턴과 합치면, 메모리와 스크립트 수준의 최적화 기초를 다진 상태입니다.

이 기초 위에서, Unity의 개별 서브시스템이 가진 고유한 최적화 패턴을 살펴볼 차례입니다. UI, 라이팅, 셰이더, 물리, 파티클, 애니메이션 등 각 서브시스템은 고유한 성능 특성과 병목 지점이 있습니다. UIOptimization 시리즈에서 Unity UI(Canvas, 레이아웃, 배칭)의 구조와 모바일에서의 최적화 패턴을 이어서 다룹니다.



마무리

에셋을 세밀하게 관리하는 것은 모바일 메모리 최적화의 핵심입니다. AssetBundle이 에셋을 별도 파일로 분리하는 기반을 제공하고, Addressables가 그 위에서 의존성 해결과 참조 카운팅을 자동으로 처리합니다.

  • AssetBundle은 에셋을 별도 파일로 빌드하여 필요할 때 로드하고 불필요 시 언로드하는 구조를 제공합니다
  • 번들 간 의존성은 에셋의 참조 관계에 의해 발생하며, 수동 관리가 복잡해지는 것이 AssetBundle 직접 사용의 한계입니다
  • Addressables는 주소 기반 접근, 의존성 자동 해결, 참조 카운팅을 제공하여 번들 관리를 추상화합니다
  • 프리로드는 게임플레이 중 지연을 제거하고, 지연 로드는 메모리 효율을 높입니다
  • Release 호출을 빠뜨리면 참조 카운트가 감소하지 않아 메모리 누수가 발생합니다
  • Analyze 도구로 에셋 중복을 검출하고, 공유 에셋은 별도 그룹으로 분리합니다
  • 모바일 번들은 LZ4 압축이 권장되며, 초기 설치에는 핵심 에셋만 포함하고 나머지는 온디맨드 다운로드합니다

번들 구조 설계, 로딩 전략, 압축 방식, 중복 검출은 서로 연결된 문제입니다. 그룹을 잘 나눠야 로딩 전략이 효과를 발휘하고, 중복이 없어야 압축과 다운로드 최적화가 실질적인 크기 절감으로 이어집니다.


UIOptimization 시리즈에서 Canvas 시스템의 구조와 모바일 UI 최적화를 이어서 다룹니다.



관련 글

시리즈

전체 시리즈

Tags: Addressables, Unity, 모바일, 에셋번들, 최적화

Categories: ,