메모리 관리 (2) - 네이티브 메모리와 에셋 - soo:bak
작성일 :
관리 힙 너머의 메모리
메모리 관리 (1) - 가비지 컬렉션의 원리에서 C# 관리 힙(Managed Heap)과 가비지 컬렉션(GC)의 원리를 다루었습니다. GC가 관리 힙에서 참조가 끊긴 객체를 수거하고, Boehm GC의 비압축 특성 때문에 힙이 확장될 수 있다는 것도 확인했습니다.
Unity 게임의 전체 메모리 사용량에서 관리 힙이 차지하는 비중은 일부에 불과합니다. 메모리의 대부분을 차지하는 것은 텍스처, 메쉬, 오디오 클립, 셰이더, 애니메이션 클립 같은 에셋 데이터입니다. 이 데이터는 C# 스크립트가 아니라 Unity 엔진의 C++ 코어에서 관리하며, 관리 힙이 아닌 네이티브 메모리(Native Memory)에 로드됩니다.
1
2
3
4
5
6
Unity 게임의 메모리 구성
영역 내용 비중
─────────────────────────────────────────────────────────────────────────────
네이티브 메모리 (C++ 엔진 측) 텍스처, 메쉬, 오디오, 셰이더 등 70~90%
관리 힙 (C# Managed Heap) C# 객체, 배열, 문자열, 컬렉션 등 10~30%
Part 1에서 다룬 GC 최적화는 전체 메모리의 10~30%에 해당하는 관리 힙을 대상으로 합니다. 이 글에서 다루는 네이티브 메모리 최적화는 나머지 70~90%를 대상으로 합니다. 모바일에서 메모리 부족으로 앱이 강제 종료되는 대부분의 원인은 관리 힙이 아니라 네이티브 메모리에 로드된 에셋입니다.
네이티브 메모리의 구조
앞에서 네이티브 메모리가 전체의 70~90%를 차지한다고 했습니다. 이 비중을 줄이려면, 먼저 어떤 에셋이 얼마나 큰 공간을 차지하는지 알아야 합니다.
텍스처 — 가장 큰 비중
텍스처는 모바일 게임에서 메모리를 가장 많이 소비하는 에셋입니다. 렌더링 기초 (2) - 텍스처와 압축에서 다룬 것처럼, 텍스처의 메모리 크기는 해상도 x 포맷(bpp, bit per pixel) x 밉맵 계수로 결정됩니다.
1
2
3
4
5
6
텍스처 메모리 = 가로 × 세로 × (bpp / 8) × 밉맵 계수
밉맵 계수: 밉맵 OFF → 1.0 / 밉맵 ON → 약 1.33 (1 + 1/4 + 1/16 + ... ≒ 1.33)
예: 2048 × 2048, ASTC 6x6 (3.56 bpp), 밉맵 ON
= 2048 × 2048 × (3.56 / 8) × 1.33 ≒ 2.37 MB
텍스처는 GPU가 렌더링 중에 직접 읽어야 하므로 VRAM(GPU 메모리)에 로드됩니다. PC에서는 CPU 메모리와 GPU 메모리가 물리적으로 분리되어 있지만, 모바일 기기는 CPU와 GPU가 물리적으로 같은 메모리를 공유합니다. 이 구조를 통합 메모리 아키텍처(Unified Memory Architecture)라고 합니다.
“같은 메모리를 공유한다”는 말이 “텍스처가 별도 메모리를 차지하지 않는다”는 뜻은 아닙니다. 물리적으로 같은 RAM이라 해도, 텍스처용으로 할당된 영역은 그 텍스처 전용이며 다른 용도로 사용할 수 없습니다. PC에서는 텍스처가 별도의 VRAM을 사용하므로 CPU 메모리에 영향을 주지 않지만, 모바일에서는 텍스처가 CPU와 같은 메모리 풀에서 공간을 가져갑니다. 텍스처가 차지하는 만큼 C# 객체, 게임 로직 등 나머지가 사용할 수 있는 메모리가 줄어듭니다.
1
2
3
4
5
6
7
모바일 게임의 전형적인 메모리 분포
에셋 유형 비중
───────────────────────────────
텍스처 50~70%
오디오, 셰이더, 기타 15~30%
메쉬 버퍼 5~15%
PBR 워크플로우 기준으로 캐릭터 한 명에 Diffuse(기본 색상), Normal(표면 굴곡), Mask(재질 구분) 등 텍스처가 최소 3장 필요합니다. 캐릭터가 여러 명이고, 배경과 이펙트까지 포함하면 텍스처 메모리는 빠르게 수백 MB에 도달합니다. 텍스처의 해상도와 압축 포맷을 적절히 설정하는 것이 모바일 메모리 절약에서 가장 효과가 큽니다.
메쉬 — 정점 버퍼와 인덱스 버퍼
메쉬는 정점 버퍼(Vertex Buffer)와 인덱스 버퍼(Index Buffer)의 형태로 GPU 메모리에 올라갑니다. 메쉬의 메모리 크기는 정점 수, 정점 속성 구성, 삼각형 수에 따라 달라집니다. (메쉬의 내부 구조는 렌더링 기초 (1) - 메쉬의 구조에서 확인할 수 있습니다.)
1
2
3
4
5
6
메쉬 메모리 = (정점 수 x 정점당 바이트) + (삼각형 수 x 3 x 인덱스 크기)
예: 정점 10,000개, 삼각형 18,000개, 속성 48바이트/정점, 16비트 인덱스
= (10,000 x 48) + (18,000 x 3 x 2)
= 480,000 + 108,000
= 588,000 바이트 ≒ 574 KB
이 계산 외에 메쉬 메모리를 크게 좌우하는 설정이 Read/Write Enabled 옵션입니다. 이 옵션이 켜져 있으면 GPU 버퍼에 업로드된 메쉬 데이터와 별도로, CPU가 접근할 수 있는 복사본이 같은 물리 RAM 안에 추가로 할당됩니다. 통합 메모리 구조라 해도 같은 데이터가 서로 다른 주소에 두 번 존재하므로 메모리 사용량은 2배입니다. 런타임에서 메쉬의 정점을 수정해야 하는 경우(Procedural Mesh, Cloth 시뮬레이션 등)에는 이 CPU 복사본이 필요하지만, 수정하지 않는 메쉬에까지 켜져 있으면 불필요하게 메모리가 2배로 소모됩니다.
1
2
3
4
5
6
7
Read/Write Enabled 옵션의 메모리 영향
설정 할당 합계
──────────────────────────────────────────────────
꺼짐 (기본, 권장) GPU 버퍼 574 KB 574 KB
켜짐 GPU 버퍼 574 KB +
CPU 복사본 574 KB 1,148 KB (2배)
Unity의 Mesh Import Settings에서 Read/Write Enabled가 체크되어 있는 메쉬를 확인하고, 런타임 수정이 필요 없는 메쉬는 이 옵션을 꺼 두면 CPU 복사본만큼 메모리를 절약할 수 있습니다.
오디오 — 로딩 방식에 따른 메모리 차이
오디오 클립은 디스크에 압축된 형태(Vorbis, AAC 등)로 저장됩니다. 메모리에 로드하는 방식에 따라 사용량이 크게 달라지며, Unity는 세 가지 로딩 모드를 제공합니다. 아래 표에서 등장하는 PCM(Pulse Code Modulation)은 압축을 거치지 않은 원본 파형 데이터를 가리킵니다.
1
2
3
4
5
6
7
오디오 로딩 모드 비교
로딩 모드 동작 방식 메모리 CPU
─────────────────────────────────────────────────────────────────────────────────────────
Decompress On Load 로드 시 전체 클립을 비압축 PCM으로 메모리에 올림 최대 최소
Compressed In Memory 압축 상태로 메모리에 올리고, 재생 시 실시간 디코딩 중간 약간 증가
Streaming 작은 버퍼만 유지하고 디스크에서 조금씩 읽으며 재생 최소 I/O 발생
세 모드의 메모리 사용량을 구체적인 수치로 비교하면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
1분짜리 스테레오 오디오 (44.1kHz, 16bit) 기준
비압축 PCM 크기:
= 44,100 x 2채널 x 2바이트 x 60초
= 10,584,000 바이트 ≒ 10.1 MB
Vorbis 압축 (Quality 70%):
≒ 약 1.0 ~ 1.5 MB
로딩 모드별 메모리 사용:
Decompress On Load: 약 10.1 MB (비압축 전체)
Compressed In Memory: 약 1.2 MB (압축 상태)
Streaming: 약 0.2 MB (버퍼만)
짧은 효과음(총소리, 발걸음)은 Decompress On Load가 적합합니다. 클립 길이가 짧아 비압축 크기도 작고, 재생 빈도가 높아 CPU 디코딩 부담을 줄이는 편이 유리합니다.
배경 음악(BGM)처럼 길고 동시에 하나만 재생되는 클립에는 Streaming을 사용합니다. 메모리 사용이 최소화되고, I/O 부담도 하나의 스트림이므로 크지 않습니다.
중간 길이의 음성(대사, 내레이션)은 Compressed In Memory가 균형 잡힌 선택입니다. 비압축 전체를 올리기에는 클립이 길고, 스트리밍을 쓰기에는 동시에 여러 음성이 재생될 수 있어 I/O 부담이 커지기 때문입니다.
셰이더 — variant 수가 핵심
셰이더는 GPU에서 실행되는 프로그램입니다. 셰이더 소스 코드 자체는 작지만, 빌드 시 다양한 조건(키워드 조합)에 따라 variant(변형)가 생성됩니다. 예를 들어 “안개 ON/OFF”, “그림자 ON/OFF”처럼 각 키워드의 ON/OFF 조합마다 별개의 컴파일된 바이너리가 만들어지고, 각각이 개별적으로 메모리를 차지합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
셰이더 variant 증가 예시
키워드 A: ON / OFF → 2가지
키워드 B: ON / OFF → 2가지
키워드 C: ON / OFF → 2가지
총 variant = 2 x 2 x 2 = 8개
키워드가 10개이면:
총 variant = 2^10 = 1,024개
실제로는 일부 조합만 유효하지만,
관리하지 않으면 수천~수만 개의 variant가 생성될 수 있음
셰이더 variant 하나의 크기는 플랫폼과 셰이더 복잡도에 따라 수 KB에서 수십 KB입니다. variant가 수천 개이면 셰이더 메모리만으로 수십 MB에 달할 수 있습니다. Unity의 셰이더 variant stripping 기능을 활용하여 실제로 사용하지 않는 variant를 빌드에서 제외하면, 메모리와 빌드 크기를 동시에 줄일 수 있습니다.
애니메이션 클립 — 키프레임 데이터
애니메이션 클립은 시간에 따른 프로퍼티 변화를 키프레임(Keyframe)으로 저장합니다. 뼈대(Bone)가 많고, 키프레임이 촘촘할수록 데이터 크기가 증가합니다.
1
2
3
4
5
6
7
8
애니메이션 클립 크기에 영향을 주는 요소
뼈대 수 x 키프레임 수 x 프로퍼티당 바이트 = 클립 크기
예: 60개 뼈대, 초당 30 키프레임, 3초 클립
= 60 x 90 x (위치 12B + 회전 16B)
= 60 x 90 x 28
= 151,200 바이트 ≒ 148 KB (비압축 시)
Unity는 두 가지 애니메이션 압축 모드를 제공합니다.
Keyframe Reduction은 이웃 키프레임에서 보간으로 복원 가능한 중간 키프레임을 제거합니다. 원본이 초당 30 키프레임이라도, 실제로 필요한 것은 방향이 바뀌는 지점뿐인 경우가 많습니다.
Optimal은 Keyframe Reduction 방식과 Dense 커브 압축(개별 샘플 대신 수학적 커브로 근사하여 더 적은 데이터 포인트로 표현)을 모두 시도한 뒤, 커브별로 더 작은 결과를 자동 선택합니다. 여기에 정밀도 축소(회전 쿼터니언 등에 32비트 float 전체 정밀도가 필요 없는 경우 낮은 정밀도로 저장)도 적용됩니다. Optimal을 사용하면 시각적 품질 손실을 최소화하면서 클립 크기를 50~80% 줄일 수 있습니다.
캐릭터가 많고 애니메이션이 다양한 게임에서는 애니메이션 클립의 총 메모리가 수십 MB에 이를 수 있으므로, 압축 설정을 함께 조정하는 것이 효과적입니다.
에셋 생명주기 — 로딩, 참조, 해제
앞에서 에셋 유형별로 네이티브 메모리를 얼마나 차지하는지 살펴봤습니다. 하지만 에셋의 크기만 아는 것으로는 충분하지 않습니다. 에셋이 언제 메모리에 올라가고, 어떤 조건에서 해제되는지를 이해해야 실제 메모리 사용량을 통제할 수 있습니다.
에셋의 생명주기는 로딩, 참조, 해제의 세 단계로 구성됩니다.
1
2
3
4
5
6
7
에셋 생명주기
단계 설명
──────────────────────────────────────────────────
로딩 (Load) 디스크/번들에서 메모리로 읽어옴
참조 (Use) 게임에서 사용 중, 메모리에 유지
해제 (Unload) 참조가 없어지면 메모리에서 제거 가능
에셋이 메모리에 로드되는 경로는 크게 두 가지입니다. 씬에서 직접 참조하는 방식과 코드에서 명시적으로 로드하는 방식입니다.
씬 직접 참조
MonoBehaviour의 public 필드나 [SerializeField]로 에셋을 드래그 앤 드롭해서 연결하는 방식입니다. 이렇게 참조된 에셋은 해당 씬이 로드될 때 함께 메모리에 올라갑니다.
1
2
3
4
5
6
7
8
9
10
씬 직접 참조의 생명주기
씬 로드 시:
Scene A → 캐릭터 프리팹 (텍스처 A, 메쉬 A, 머티리얼 A)
→ 배경 오브젝트 (텍스처 B)
→ 참조된 에셋이 모두 메모리에 로드됨
씬 언로드 시:
에셋의 참조 카운트 감소 → 참조 카운트가 0이 되면 해제 대상
→ 즉시 해제가 아니라 UnloadUnusedAssets 호출 시 해제
씬이 언로드되면 해당 씬에서 참조하던 에셋의 참조 카운트(Reference Count)가 감소합니다. 참조 카운트란 해당 에셋을 현재 사용하고 있는 곳의 수를 나타내는 숫자입니다. 다른 씬이나 코드에서 같은 에셋을 참조하고 있지 않다면, 참조 카운트가 0이 되어 그 에셋은 “미사용 상태”가 됩니다. Unity는 씬 전환 시 내부적으로 Resources.UnloadUnusedAssets()를 호출하여 미사용 에셋을 메모리에서 해제합니다.
씬 직접 참조 방식은 에셋의 로드/해제 시점이 씬의 수명에 결합되어 있으므로, 개발자가 별도로 로딩 코드를 작성할 필요가 없습니다. 반면, 씬이 로드되면 해당 씬에서 참조하는 모든 에셋이 한꺼번에 메모리에 올라옵니다. 씬에 참조된 에셋이 많으면 로딩 시 메모리 피크가 높아집니다.
Resources.Load
Resources 폴더에 에셋을 넣어두고, 런타임에 Resources.Load("경로")로 로드하는 방식입니다.
1
2
3
4
5
Texture2D tex = Resources.Load<Texture2D>("Textures/CharacterSkin");
tex = null;
Resources.UnloadUnusedAssets();
Resources.Load()는 호출 시점에 에셋을 디스크에서 읽어 메모리에 올립니다. 이미 로드된 에셋을 다시 요청하면 중복 로드 없이 캐시된 인스턴스를 반환합니다.
에셋을 해제하려면 해당 에셋을 가리키는 모든 C# 참조를 끊어야 합니다. 변수를 null로 설정하거나, 컬렉션에서 제거하거나, 에셋을 참조하는 GameObject를 Destroy하는 방식입니다. 참조가 모두 끊어진 상태에서 Resources.UnloadUnusedAssets()를 호출하면 해당 에셋이 메모리에서 해제됩니다.
특정 에셋 하나를 즉시 해제하고 싶다면 Resources.UnloadAsset(asset)을 사용할 수 있지만, 해당 에셋이 여전히 다른 곳에서 참조되고 있으면 텍스처가 분홍색으로 표시되는 등 렌더링 오류가 발생할 수 있습니다.
Resources 폴더에는 구조적 문제가 있습니다. 빌드 시 Resources 폴더 안에 있는 모든 에셋이 게임에서 실제로 사용하는지 여부와 무관하게 빌드에 포함됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Resources 폴더의 빌드 포함 문제
프로젝트 구조:
Assets/
Resources/
Textures/
CharacterSkin.png ← 게임에서 사용
OldCharacterSkin.png ← 사용하지 않음 (과거 리소스)
TestTexture.png ← 테스트용, 사용하지 않음
Audio/
BGM_01.ogg ← 게임에서 사용
BGM_Unused.ogg ← 사용하지 않음
빌드 결과:
위 5개 에셋 모두 빌드에 포함됨
→ 빌드 크기 증가
→ Resources 폴더의 색인(lookup table) 크기 증가
→ 앱 시작 시 색인 로딩 시간 증가
Resources 폴더의 에셋이 많아질수록 빌드 크기, 앱 시작 시간, 메모리 사용량이 함께 증가합니다. Unity 공식 문서에서도 Resources 폴더 사용을 비권장(discouraged)하고 있으며, 프로토타이핑이나 소규모 프로젝트를 제외하면 다른 방식을 권장합니다.
직접 참조 vs Resources.Load
1
2
3
4
5
6
7
8
항목 씬 직접 참조 Resources.Load
──────────────────────────────────────────────────────────────────────────────
로드 시점 씬 로드 시 자동 코드에서 명시적 호출
해제 시점 씬 언로드 시 자동(UnloadUnused) 수동(UnloadUnusedAssets 호출 필요)
빌드 포함 기준 실제 참조된 에셋만 포함 Resources 폴더의 모든 에셋 포함
빌드 크기 최소화 불필요한 에셋까지 포함되어 증가 가능
메모리 관리 씬 단위 자동 관리(비교적 단순) 개발자가 수동 관리(복잡)
적합한 경우 씬에 종속된 에셋 동적 로딩이 필요한 에셋(제한적 사용)
씬 직접 참조가 일반적으로 더 효율적입니다. Unity 엔진이 에셋의 참조 관계를 빌드 시 분석하여, 실제로 참조된 에셋만 빌드에 포함합니다. Resources.Load는 런타임에 문자열 경로로 에셋을 찾으므로, 빌드 시점에 어떤 에셋이 사용될지 엔진이 판단할 수 없습니다. 결과적으로 폴더 내 모든 에셋을 포함할 수밖에 없습니다.
에셋을 동적으로 로드해야 하는 경우에는 Resources.Load 대신 AssetBundle이나 Addressables를 사용하는 것이 바람직합니다. 이 방식들은 메모리 관리 (3) - Addressables와 에셋 전략에서 이어집니다.
모바일 메모리 예산
앞에서 에셋 유형별 메모리 크기와 생명주기를 살펴봤습니다. 하지만 에셋을 아무리 효율적으로 관리해도, 전체 사용량이 플랫폼의 한계를 넘으면 의미가 없습니다. PC에서는 수 GB의 메모리를 사용해도 문제가 없지만, 모바일에서는 OS가 앱의 메모리 사용량을 감시하고, 한계를 초과하면 앱을 강제 종료합니다.
iOS — OOM Kill
iOS에서 앱이 메모리 한계를 초과하면 OOM Kill(Out of Memory Kill)이 발생합니다. 별도의 경고나 예외 없이, OS가 앱 프로세스를 즉시 종료합니다. 사용자에게는 앱이 갑자기 꺼진 것으로 보입니다.
1
2
3
4
5
6
7
8
9
10
11
iOS 메모리 한계 (기기별 대략적 기준)
기기 물리 RAM 앱 사용 가능 (추정)
───────────────────────────────────────────────────────
iPhone SE (2세대) 3 GB 약 1.0 ~ 1.4 GB
iPhone 12 4 GB 약 1.5 ~ 2.0 GB
iPhone 14 Pro 6 GB 약 2.5 ~ 3.0 GB
iPhone 15 Pro 8 GB 약 3.5 ~ 4.0 GB
※ 앱 사용 가능 메모리는 OS 상태, 백그라운드 앱, 시스템 프로세스에 따라 변동됨
※ 이 수치에 근접하면 OOM Kill 위험이 높아짐
앱에 허용되는 메모리의 정확한 한계는 공개되어 있지 않습니다. OS 버전, 기기 모델, 현재 시스템 상태에 따라 달라집니다. 일반적으로 물리 RAM의 50~65% 정도를 앱이 사용할 수 있습니다.
허용 한계에 가까워질수록 OOM Kill 확률이 높아지므로, 안전 마진을 두고 메모리 예산을 설정해야 합니다. 저사양 기기(3GB RAM)를 지원해야 한다면, 앱 전체 메모리 사용량을 약 600~800MB 이내로 유지하는 것이 현실적인 목표입니다.
Android — 기기 파편화
Android는 iOS보다 기기 파편화가 심합니다. 고사양 플래그십 기기는 12~16GB RAM을 탑재하지만, 여전히 활성 사용자 중 상당수가 2~4GB RAM 기기를 사용합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
Android 기기 파편화 현실
고사양 (12~16 GB RAM):
메모리 여유 있음 → 크게 걱정 없음
중사양 (4~6 GB RAM):
OS + 백그라운드 앱이 2~3 GB 사용
앱에 허용되는 메모리 약 1~2 GB
저사양 (2~3 GB RAM):
OS + 백그라운드 앱이 1~2 GB 사용
앱에 허용되는 메모리 약 0.5~1 GB
← 이 기기에서 OOM 발생 빈도 높음
Android에서도 메모리 한계를 초과하면 OS의 Low Memory Killer가 메모리를 많이 사용하는 앱부터 종료합니다. iOS와 마찬가지로 사용자에게는 앱이 갑자기 꺼진 것처럼 보입니다.
따라서 모바일 메모리 예산을 설정하려면, 먼저 타깃 기기의 하한을 정해야 합니다. 저사양 기기까지 지원하려면, 총 메모리 예산을 약 400~600MB로 잡아야 할 수 있습니다.
메모리 예산 분배
전체 메모리 예산을 정했다면, 에셋 유형별로 예산을 분배합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
모바일 메모리 예산 분배 예시 (총 600MB 기준)
항목 예산 비고
──────────────────────────────────────────────────────
텍스처 250 MB 가장 큰 비중
메쉬 60 MB Read/Write 옵션 점검
오디오 50 MB 스트리밍 활용
셰이더 30 MB variant 관리 필수
애니메이션 40 MB 압축 적용
관리 힙 (C#) 70 MB GC 최적화로 절약
렌더 타깃 / 프레임버퍼 50 MB 해상도에 비례
기타 (물리, 씬 등) 50 MB
──────────────────────────────────────────────────────
합계 600 MB
※ 프로젝트 특성에 따라 크게 달라질 수 있으며, Unity Profiler의 Memory 모듈로 실측하여 조정
텍스처가 전체 예산의 40% 이상을 차지하는 것이 일반적입니다. 메모리 부족 문제가 발생했을 때, 가장 먼저 확인해야 하는 것도 텍스처입니다. 렌더링 기초 (2) - 텍스처와 압축에서 다룬 ASTC 블록 크기 조절과 해상도 조절이 가장 효과적인 메모리 절약 수단입니다.
메모리 단편화
Part 1에서 다룬 것처럼, Boehm GC는 비압축(Non-compacting) 방식입니다. 객체가 해제되어도 빈 공간을 밀어서 합치지 않으므로, 관리 힙에 빈 공간이 흩어진 채로 남습니다. 이 현상이 메모리 단편화(Memory Fragmentation)입니다. 단편화는 관리 힙에서만 발생하는 것이 아니라 네이티브 메모리에서도 발생합니다. 네이티브 메모리를 관리하는 C++ 할당기(malloc/free, Unity 내부 할당기)도 해제된 공간을 압축하지 않기 때문입니다.
관리 힙의 단편화 (복습)
Part 1에서 다룬 관리 힙 단편화의 핵심 구조는 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
관리 힙 단편화
초기 상태:
┌────┬────┬────┬────┬────┬────┐
│ A │ B │ C │ D │ E │ F │
└────┴────┴────┴────┴────┴────┘
B, D 해제 후:
┌────┬────┬────┬────┬────┬────┐
│ A │빈칸│ C │빈칸│ E │ F │
└────┴────┴────┴────┴────┴────┘
↑ ↑
흩어진 빈 공간 (단편화)
새로운 큰 배열 할당 시도:
빈 공간의 합계는 충분하지만,
연속된 빈 공간이 부족하여 할당 실패
→ 힙 확장 발생
Boehm GC는 빈 공간을 합치지 않습니다. 작은 빈 공간이 여러 곳에 흩어져 있으면, 총 여유 공간은 충분해도 큰 연속 메모리를 할당하지 못합니다. 그 결과 OS에 추가 메모리를 요청하여 힙을 확장하게 되고, 한 번 확장된 힙은 앱 종료까지 줄어들지 않습니다.
네이티브 메모리의 단편화
네이티브 메모리에서도 에셋의 로드/언로드 패턴에 따라 단편화가 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
네이티브 메모리 단편화 시나리오
1. 씬 A 로드: 텍스처 a, b, c 로드
┌──────┬──────┬──────┬─────────────────────┐
│ a │ b │ c │ 빈 공간 │
│ 4MB │ 8MB │ 2MB │ │
└──────┴──────┴──────┴─────────────────────┘
2. 씬 B로 전환: 텍스처 b 해제, 새 텍스처 d(10MB) 로드
d는 10MB이므로 b가 빠진 8MB 빈칸에 들어가지 못함
→ 뒤쪽 빈 공간에 배치
┌──────┬──────┬──────┬────────────┬─────────┐
│ a │ 빈칸 │ c │ d │빈 공간 │
│ 4MB │ 8MB │ 2MB │ 10MB │ │
└──────┴──────┴──────┴────────────┴─────────┘
3. 다시 큰 텍스처 e(12MB) 로드 시도:
빈칸 8MB + 뒤쪽 빈 공간의 합계는 충분하지만,
연속 12MB 공간이 없으면 단편화로 인한 할당 실패 가능
Unity의 네이티브 메모리 할당기(TLSF 등)는 Boehm GC보다 단편화를 줄이는 데 정교하지만, 에셋 크기 차이가 크고 로드/언로드가 빈번하면 단편화를 완전히 피할 수 없습니다.
단편화 완화 전략
단편화를 완전히 제거하기는 어렵지만, 완화하는 전략은 있습니다. 아래 전략들은 네이티브 메모리 할당기의 동작에 대한 것이므로, 로딩 방식(씬 직접 참조, Resources, AssetBundle, Addressables)과 무관하게 적용됩니다.
에셋 로딩 순서 관리. 여러 씬에 걸쳐 사용되는 공통 에셋(배경 텍스처, 캐릭터 메쉬 등)을 먼저 로드하고, 이펙트 텍스처처럼 수명이 짧은 에셋을 나중에 로드합니다. 공통 에셋이 해제되지 않고 유지되면, 수명이 짧은 에셋의 로드/해제로 생기는 빈 공간이 한쪽에 모여 큰 연속 공간을 확보하기 쉽습니다. 다만 공통 에셋 자체가 씬 전환 시 해제되면 그 자리에 큰 빈칸이 생기므로, 이 전략은 먼저 로드한 에셋이 오래 유지되는 경우에 효과적입니다.
씬 전환 전 참조 정리. 씬 전환 시 Unity가 내부적으로 Resources.UnloadUnusedAssets()를 호출합니다. 이 API는 Resources 클래스에 속해 있지만, Resources로 로드한 에셋만이 아니라 모든 미사용 에셋을 대상으로 합니다. 다만 static 변수, DontDestroyOnLoad 오브젝트, 캐시용 컬렉션 등에 에셋 참조가 남아 있으면 “사용 중”으로 간주되어 해제되지 않습니다. 씬 전환 전에 이런 참조를 null로 정리해야 해당 에셋이 미사용 상태가 되어 메모리에서 해제됩니다.
로딩 화면 활용. LoadSceneMode.Single의 기본 동작은 새 씬을 먼저 로드한 뒤 이전 씬을 해제합니다. 같은 에셋이 중복 로드되지는 않지만, 이전 씬의 고유 에셋과 새 씬의 고유 에셋이 전환 중에 동시에 메모리에 존재하여 피크가 높아집니다. 로딩 화면을 경유하여 “이전 씬 해제 → 빈 상태 → 새 씬 로드” 순서로 진행하면, 한 시점에 한 씬의 에셋만 메모리에 존재하므로 피크를 줄일 수 있고 단편화도 완화됩니다.
1
2
3
4
씬 전환 시 메모리 피크 관리
직접 전환: 씬 B 로드 → 씬 A 고유 에셋 + 씬 B 고유 에셋 동시 존재 → 피크 높음
로딩 화면 경유: 씬 A 해제 → 빈 상태 → 씬 B 로드 → 한 씬의 에셋만 존재 → 피크 낮음
이 전략들이 실제로 효과가 있는지 확인하려면, 현재 메모리가 어디에 얼마나 사용되고 있는지를 먼저 측정해야 합니다.
Unity Profiler로 메모리 확인하기
예산을 설정하고 에셋을 최적화하려면, 현재 메모리 사용량을 정확히 측정하는 과정이 먼저입니다. Unity Profiler의 Memory 모듈이 이 역할을 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Unity Profiler — Memory 모듈에서 확인할 수 있는 항목
┌──────────────────────────────────────────────────────────┐
│ Total Used Memory │
│ │
│ ├── Unity (네이티브) │
│ │ ├── Textures: XXX MB (개수: N) │
│ │ ├── Meshes: XX MB (개수: N) │
│ │ ├── Audio: XX MB (개수: N) │
│ │ ├── Shaders: XX MB (개수: N) │
│ │ ├── AnimationClips: X MB (개수: N) │
│ │ └── Materials: X MB (개수: N) │
│ │ │
│ ├── Mono / Managed (관리 힙) │
│ │ ├── Used: XX MB │
│ │ └── Reserved: XX MB │
│ │ │
│ ├── GfxDriver (그래픽스 드라이버) │
│ │ └── 렌더 타깃, 프레임 버퍼 등 │
│ │ │
│ └── Other │
│ │
└──────────────────────────────────────────────────────────┘
Profiler에서 관리 힙은 Unity 버전에 따라 “Mono” 또는 “Managed”로 표시됩니다. IL2CPP 빌드에서는 Mono 런타임을 사용하지 않지만 관리 힙 자체는 존재하며, 최신 버전에서는 “Managed”로 통일되는 추세입니다.
Profiler의 Memory 모듈에서 “Detailed” 모드를 선택하면 에셋 유형별 메모리 사용량을 확인할 수 있습니다. “Take Sample” 버튼을 눌러 특정 시점의 메모리 스냅샷을 찍으면, 개별 에셋의 이름, 크기, 참조 횟수까지 확인할 수 있습니다.
메모리 문제를 진단할 때는 네 단계로 접근합니다.
첫째, 총 메모리 사용량이 예산 이내인지 확인합니다. 앞 절에서 다룬 모바일 메모리 예산과 비교하여 초과 여부를 판단합니다.
둘째, 에셋 유형별 사용량에서 예상보다 큰 항목을 찾습니다. 텍스처가 예산의 70%를 넘는다면, 해상도나 압축 설정을 점검합니다.
셋째, 개별 에셋 목록에서 예상보다 큰 에셋이나 중복 로드된 에셋을 찾습니다. 같은 텍스처가 이름만 다르게 두 번 로드되어 있거나, 사용하지 않는 에셋이 여전히 메모리에 남아 있는 경우가 흔합니다.
넷째, 씬 전환 전후의 메모리 변화를 비교합니다. 씬을 언로드한 후에도 메모리가 줄어들지 않으면, 해제되지 않은 에셋이 있다는 뜻이며 이는 메모리 누수입니다.
에셋 메모리 최적화 체크리스트
Profiler로 메모리 사용 현황을 파악했다면, 에셋 유형별로 구체적인 최적화를 적용할 차례입니다.
텍스처
화면에서 작게 보이는 오브젝트에 2048x2048 텍스처가 할당되어 있지 않은지 확인합니다. ASTC 압축을 적용하고, 시각적 중요도에 따라 블록 크기(4x4~8x8)를 조절합니다.
3D 오브젝트의 텍스처에는 밉맵(Mipmap)을 켜고, UI 텍스처에는 밉맵을 끕니다. 밉맵은 텍스처의 축소 버전을 여러 단계로 미리 생성해 두는 기법으로, 원본 대비 약 33%의 추가 메모리를 사용합니다. UI처럼 항상 원본 해상도로 표시되는 텍스처에서는 밉맵이 메모리만 차지하므로 끄는 것이 맞습니다.
불필요한 알파 채널이 있는지도 확인합니다. 알파가 필요 없는 텍스처를 RGB 전용 포맷으로 전환하면 메모리를 절약할 수 있습니다.
메쉬
Read/Write Enabled가 불필요하게 켜진 메쉬를 찾아 끕니다. 앞에서 살펴본 것처럼 이 옵션이 켜져 있으면 GPU 버퍼와 CPU 복사본이 동시에 존재하여 메모리가 2배로 소모됩니다. 사용하지 않는 정점 속성(Tangent, UV1 등)도 제거합니다. (정점 속성별 크기와 용도는 렌더링 기초 (1) - 메쉬의 구조에서 확인할 수 있습니다.)
오디오
짧은 효과음은 Decompress On Load, 긴 BGM은 Streaming, 중간 길이 음성은 Compressed In Memory를 적용합니다.
샘플레이트는 초당 기록하는 오디오 샘플의 수이며, 샘플 수에 비례하여 메모리를 차지합니다. AudioClip Import Settings의 플랫폼별 탭에서 Sample Rate Setting을 조정할 수 있습니다. Preserve Sample Rate는 원본을 유지하고, Optimize Sample Rate는 Unity가 자동으로 선택하며, Override Sample Rate는 원하는 값을 직접 지정합니다. 예를 들어 48kHz에서 22050Hz로 낮추면 데이터 총량이 약 절반으로 줄어듭니다. 재현 가능한 최대 주파수도 낮아지지만(나이퀴스트 정리: 최대 주파수 = 샘플레이트 / 2), 모바일 게임의 효과음에서는 11kHz 이상의 고음역이 크게 중요하지 않은 경우가 많아 22050Hz로도 충분합니다.
채널 수도 메모리에 영향을 줍니다. 모노(mono)로 충분한 효과음에 스테레오(stereo)가 적용되어 있지 않은지 확인합니다. 모노는 스테레오의 절반 메모리를 사용합니다.
셰이더
셰이더 variant stripping을 설정하여 사용하지 않는 variant를 빌드에서 제외합니다. 셰이더 코드에서 multi_compile 키워드는 모든 조합의 variant를 빌드에 포함하고, shader_feature는 머티리얼에서 실제로 사용하는 조합만 포함합니다. 과도한 multi_compile 사용을 줄이고, shader_feature로 대체할 수 있는지 검토합니다.
애니메이션
애니메이션 압축을 Optimal로 설정합니다. 앞에서 설명한 것처럼 Optimal은 Keyframe Reduction과 Dense 커브 압축을 모두 시도하여 커브별로 더 작은 결과를 자동 선택합니다. 스케일(Scale) 커브가 항상 (1,1,1)인 경우 해당 커브를 제거하면 메모리를 추가로 절약할 수 있습니다.
에셋을 세밀하게 제어하려면
위 체크리스트로 개별 에셋의 크기를 줄일 수 있지만, 에셋 메모리를 체계적으로 관리하려면 한 단계 더 필요합니다. 빌드에 포함되는 에셋을 세밀하게 제어하고, 필요한 시점에만 로드하고, 사용이 끝나면 명확하게 해제할 수 있어야 합니다. Resources 폴더는 이런 제어가 어렵다는 한계가 있습니다.
이 한계를 해결하는 것이 AssetBundle과 Addressables 시스템입니다. 에셋을 번들 단위로 묶어 원격/로컬에서 동적으로 로드하고, 참조 카운팅으로 해제를 자동화합니다. 구체적인 구조와 활용 전략은 메모리 관리 (3) - Addressables와 에셋 전략에서 이어집니다.
마무리
Unity 게임의 메모리 사용량 중 대부분은 C# 관리 힙이 아니라, C++ 엔진이 관리하는 네이티브 메모리에 로드된 에셋입니다. 텍스처, 메쉬, 오디오, 셰이더, 애니메이션의 메모리 특성을 이해하고, 에셋의 생명주기를 관리하는 것이 모바일 메모리 최적화의 핵심입니다.
- 텍스처는 게임 메모리의 가장 큰 비중(50% 이상)을 차지하며, 해상도와 압축 포맷(ASTC) 설정이 메모리 절약의 핵심입니다
- 메쉬의 Read/Write Enabled 옵션이 켜져 있으면 GPU 버퍼와 CPU 복사본이 동시에 존재하여 메모리가 2배로 소모됩니다
- 오디오는 로딩 모드(Decompress On Load, Compressed In Memory, Streaming)에 따라 메모리 사용량이 10배 이상 차이납니다
- Resources 폴더는 사용 여부와 무관하게 모든 에셋이 빌드에 포함되므로, Unity 공식 문서에서 비권장합니다
- 모바일에서는 OS가 메모리 한계를 초과한 앱을 강제 종료(OOM Kill)하며, 저사양 기기 기준으로 메모리 예산을 설정해야 합니다
- 에셋 로드/언로드 반복으로 네이티브 메모리에도 단편화가 발생하며, 로딩 순서 관리와 로딩 화면 활용으로 완화할 수 있습니다
에셋 하나하나의 크기를 줄이는 것도 중요하지만, 결국 메모리를 안정적으로 유지하려면 “언제 로드하고, 언제 해제하는가”를 체계적으로 관리해야 합니다. Resources 폴더는 이런 관리가 어렵다는 한계가 있었습니다.
이 글에서는 에셋이 네이티브 메모리에서 차지하는 크기와, 씬 참조 또는 Resources.Load를 통한 생명주기를 살펴봤습니다. 하지만 두 방식 모두 에셋의 로드/해제 단위를 세밀하게 제어하기 어렵습니다. AssetBundle과 Addressables는 에셋을 번들 단위로 묶어 필요할 때만 로드하고, 참조 카운팅으로 해제를 자동화합니다.
메모리 관리 (3) - Addressables와 에셋 전략에서는 AssetBundle의 구조, Addressables의 참조 카운팅 메커니즘, 그리고 번들 분할 전략을 다룹니다.
관련 글
시리즈
- 메모리 관리 (1) - 가비지 컬렉션의 원리
- 메모리 관리 (2) - 네이티브 메모리와 에셋 (현재 글)
- 메모리 관리 (3) - Addressables와 에셋 전략
전체 시리즈
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound
- 렌더링 기초 (1) - 메쉬의 구조
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초
- GPU 아키텍처 (1) - GPU 병렬 처리와 렌더링 파이프라인
- GPU 아키텍처 (2) - 모바일 GPU와 TBDR
- Unity 렌더 파이프라인 (1) - Built-in과 URP의 구조
- Unity 렌더 파이프라인 (2) - 드로우콜과 배칭
- Unity 렌더 파이프라인 (3) - 컬링과 오클루전
- 스크립트 최적화 (1) - C# 실행과 메모리 할당
- 스크립트 최적화 (2) - Unity API와 실행 비용
- 메모리 관리 (1) - 가비지 컬렉션의 원리
- 메모리 관리 (2) - 네이티브 메모리와 에셋 (현재 글)
- 메모리 관리 (3) - Addressables와 에셋 전략
- UI 최적화 (1) - 캔버스와 리빌드 시스템
- UI 최적화 (2) - UI 최적화 전략
- 조명과 그림자 (1) - 실시간 조명과 베이크
- 조명과 그림자 (2) - 그림자와 후처리
- 셰이더 최적화 (1) - 셰이더 성능의 원리
- 셰이더 최적화 (2) - 셰이더 배리언트와 모바일 기법
- 물리 최적화 (1) - 물리 엔진의 실행 구조
- 물리 최적화 (2) - 물리 최적화 전략
- 파티클과 애니메이션 (1) - 파티클 시스템 최적화
- 파티클과 애니메이션 (2) - 애니메이션 최적화
- 프로파일링 (1) - Unity Profiler와 Frame Debugger
- 프로파일링 (2) - 모바일 프로파일링
- 모바일 전략 (1) - 발열과 배터리
- 모바일 전략 (2) - 빌드와 품질 전략