이전에 언급했던, 이번 주에 스터디 자료로 활용할 Addressables 관련 내용을 정리하였다. 추가로 AssetBundle, SBP와 현재까지의 과정에 대한 내용을 함께 구성하여 팀원들의 이해도를 높일 수 있도록 하였다. (만든 자료에서 일부 내용은 수정하여 올림)

 

이 글로 AssetBundle과 Scriptable Build Pipeline, Addressables에 대한 전반적인 내용을 이해할 수 있기를 바란다.

 

AssetBundle

 

Asset Bundle: 특히 모바일 게임에서, 게임에 접속할 때 다운로드 받는 것

 

사용해야 하는 이유 1: Google Play Store의 앱 크기 제한은 150MB (App Store는 500MB)

가끔 Google Play Store의 제한인 150MB를 초과하는 경우가 있는데, PAD(Play Asset Delivery)를 활용한 것. 최대 1GB(경우에 따라 2GB)까지 에셋 번들을 함께 다운로드 받을 수 있음. 스토어에서 다운받도록 하면 비용이 들지 않기 때문에 활용하면 좋다.

 

사용해야 하는 이유 2: 데이터 유동성 확보

앱을 통째로 빌드한다면 빌드 시간 소요, 스토어 검수 통과 필요, 플레이어가 업데이트 해야 함.

에셋 번들을 경우, 서버를 내리고 에셋 번들을 업데이트하면 플레이어가 게임 내에서 다운로드를 받을 수 있음. 처리에 걸리는 시간도 훨씬 적기 때문에 버그에 대해 훨씬 유연하게 대처할 수 있음.

 

단점: 에셋 번들 시스템을 구축하는 과정이 상당히 복잡하다.

다행인 점: 우리는 이미 에셋 번들 시스템이 구축된 상태다.

현재 사용중인 BuildPipeline의 문제점: 압축 방식에 따라 빌드 과정이 분리되어 종속성이 제대로 설정되지 않는다.

 

종속성: Material이 포함된 Prefab을 Load할 때, 필요한 Material을 먼저 Load하고 Prefab을 로드해야 한다. 그렇지 않으면 Magenta 색을 볼 수 있음. (이 경우 Prefab이 Material에 종속성이 있는 것임)

 

LZ4 방식의 빌드 과정과 LZMA의 빌드 과정이 분리되어 Dependencies가 제대로 설정되지 않음.

→ Asset의 복제가 일어남. (참조)

 

LZMA: zip, 7z 등에 사용되는 압축 방식으로, 압축률이 높지만 로딩 속도가 느림.

LZ4: LZMA에 비해 압축률은 낮지만 로딩 속도가 빠르고 효율적이기 때문에, 다운로드를 받을 때만 LZMA로 압축된 번들을 받고 다운받을 때 LZ4로 Recompress하여 디스크에 저장함.

UnCompressed: 비압축 방식으로, 빌드 속도는 빠르겠지만 용량이 굉장히 커지기 때문에 권장하지 않는 방법임. UnCompress보다 LZ4가 첫 로딩 속도가 빠른 것 같다는 내용이 있었는데, 아마 처음 실행할 때 LZ4로 압축하는 과정을 거치는 것이 아닐까 싶음.

 

Scriptable Build Pipeline

Scriptable Build Pipeline: BuildPipeline보다 유연성 있게 빌드할 수 있도록 제공하는 패키지.

ContentPipeline을 사용하면 압축 방식과 상관없이 빌드가 한꺼번에 이뤄지기 때문에 Dependencies의 손실이 없다.

기존에 사용하던 BuildPipeline과 큰 차이점이 없어서 전체 과정의 일부를 변경하는 것으로 적용되었음. (대략 주황색 정도)

 

문제점: Shader Stripping이 BuildPipeline과 다른 방식을 거치는 것으로 보인다.

 

BuildPipeline: Graphics 세팅의 FOG_EXP, FOG_EXP2, SHADOWS_SHADOWMASK를 끄면 정상적으로 Shader Stripping이 이루어진다.

 

ContentPipeline: Graphics 세팅의 FOG_EXP를 끄면 정상적으로 Shader Stripping이 이루어지지만, FOG_EXP2와 SHADOWS_SHADOWMASK는 꺼도 Shader Stripping이 이루어지지 않는다.

BUG? (추가적인 확인 필요)

Bug - Addressable and shader variants - Unity Forum → SBP를 사용하는 Addressables에서 Graphics Setting의 Shader Stripping이 안 된다는 사람이 있음.

 

Question - SBP don't strip shader variants? - Unity Forum

→ Scriptable Build Pipeline에서 Shader Stripping이 제대로 되지 않는다는 내용의 글

→ 기존과 비교해서 삭제되지 않은 것을 수동으로 삭제해주는 것으로 해결했다고 함.

우리도 약 210MB → 약 90MB로 상당 부분 감소함. (화요일)

하지만 여전히 기존의 약 70MB보다는 30%가량 큰 문제가 있음.

대부분의 용량은 Nature Shaders의 Shader에서 차이가 나는데, 이 용량만 줄인다면 Shader의 용량이 기존보다 감소함.

전체 용량으로 보았을 때는 기존의 전체 용량보다 약간이지만 작은 것을 확인하였음. (AnimationClip의 수가 30% 감소하였고, 이외에도 조금씩 변화가 있었음)

다만 바로 적용시키는 것은 꺼려지는데, Nature Shaders의 용량이 변하는 이유를 아직 확인하지 못했기 때문임.

 

1. IPreProcessShader에서 Shader Stripping이 된 이후에 Shader의 키워드들을 확인했고, 각 Shader에서 사용되는 키워드는 동일한 것을 확인할 수 있었음.

2. 각 Pass에서 사용되는 키워드도 동일한 것을 확인하였음. (BuildPipeline에서는 가끔 Pass에 1개의 키워드가 적게 들어오는 경우가 있었는데, ContentPipeline에서 이 키워드를 삭제하니 용량은 전체적으로 작아졌지만 그래픽에 문제가 생겼고, Grass의 경우 여전히 기존보다 2배 이상 컸음)

3. Log Shader Compilation을 기록해보았으나 BuildPipeline, ContentPipeline 각각의 빌드에서 컴파일 된 셰이더의 컴파일 내용은 완전히 동일했음 (특정 맵 기준)

4. 프레임에 따라 옵션이 바뀌는 기능을 끄고 해봤으나 용량은 동일했음

5. RenderDoc으로 각 경우에서 사용된 Shader를 비교해보았으나 동일한 것으로 추정됨. (Line 수로 판단)

게임 내에서 로드된 Shader에 포함된 정보들을 출력해보았으나 유의미한 결과는 얻을 수 없었음.

6. 에셋에 포함된 Shader에 대한 정보를 볼 수 있는 툴들을 다수 활용해보았으나 ShaderX로 잠겨있어서 내부 정보를 얻을 수 없었음.

7. SBP에 Build Logging에 대한 내용도 있으나 Shader로 검색한 결과 유의미한 결과를 얻을 수 없었음.

 

→ BuildPipeline을 사용할 때보다 용량이 크다는 문제점은 인식했지만, 원인을 규명하기가 어려움

 

용량 차이의 문제는 ShaderX를 사용하는 Nature Shaders만 두드러지게 나타남. 나머지는 용량이 동일하거나 차이가 있다고 해도 미미한 수준이었던 것 같음. (Nature Shaders에 Standard가 포함되어 있는 것도 약간 의문이며, 이 Standard에서도 용량 차이가 나타남)

1개의 Shader에서 8개의 Variants를 생성하는 Keyword가 있었는데, 영향은 적지만 차이점을 이해할 수 있는 단서가 될 수도 있을 것 같음. (왜 삭제가 되지 않았는지?)

Shader Stripping에 대한 Insight를 얻어서 현재 사용 중인 Shader Stripping을 더욱 최적화 할 수 있을 것 같음.

 

현재: 일부 Scene을 돌면서 수동으로 정리한, “전체 Shader에서 사용하지 않는” Keyword들을 삭제해 줌.

개선 가능: Scene들을 돌면서 나온 Log에서 각 Shader에 사용되는 Keyword, 혹은 Variants를 자동으로 추출하여 저장하고, 이를 제외한 Keyword들을 삭제해줌 (자동화, 각 Shader에 대한 최적화)

+shader variant collection?

 

Addressables

들어가기 전에…

Addressables는 빌드할 때 Scriptable Build Pipeline을 사용한다고 하는데, Addressablse로 바꾼다고 해도 현재와 동일한 문제가 발생할 가능성이 있음. (Shader Stripping 관련)

 

Addressables: Unity에서 제공하는 Asset Bundle 빌드에 사용하는 최신 Package

 

[Addressables의 장점]

1. 경로 등에 영향을 받지 않는 Address를 사용하여 유연하게 사용 가능 (기본값은 실제 Path)

Addressables 패키지를 받으면 Asset에 위와 같이 Addressables이라는 체크 박스가 생기고, 체크 박스를 체크하면 각 Asset이 고유한 Address를 가질 수 있게 됨. 기본값은 에셋의 실제 Path이며, Adress는 Asset을 수정하거나 경로를 변경해도 유지되기 때문에 실제 Path를 사용하는 것보다 유연하게 사용이 가능함

Asset을 Load할 때 Address로 호출하기 때문에 현재 방식을 그대로 사용할 수 있을 것 같음

 

2. Addressables 창에서 에디터의 Asset/AssetBundle 중 어떤 것을 로드할지 선택이 가능함

우리는 AssetBundle을 사용하지만 구현되어 있는 기능

 

3. 종속성 관리를 자동으로 해줌

AssetBundle을 사용하면 AssetBundleA를 로드할 때, 종속성이 있는 다른 번들들을 미리 로드해야 하기 때문에 각 번들의 Dependencies를 체크하고 로드하는 작업을 해줘야 하지만(우리는 구현되어 있음), Addressables는 이것을 자동으로 처리해줌

 

4. 참조 카운터를 사용하여 메모리 관리의 효율성 확보 가능

【Unity】Addressableアセットシステム入門 - 3つのメリットと基本的な使い方を紹介 - LIGHT11 (hatenadiary.com)

보시다시피 AssetBundle의 메모리를 제대로 관리하는 것은 번거로운 일입니다. 내부적으로 참조 카운터를 갖는 등의 구현이 필요합니다.

Addresssables에는 이러한 종속성을 기반으로 하는 참조 카운터 메커니즘이 포함되어 있습니다. 즉, 에셋 번들을 한 번 로드하는 경우 한 번 언로드 해야 한다는 규칙을 따른다면 종속성을 포함하여 깨끗한 메모리를 확보할 수 있습니다.

→ Bundle 간에도 참조 카운터를 가지고 있는 것으로 추측됨.

 

어드레서블 에셋 시스템으로 메모리 최적화하기 | Unity Blog

AssetBundle 내에서 사용하는 Asset이 없다면, 자동으로 번들을 해제해주는 것으로 보임.

 

즉, 번들 내 에셋의 수를 적게 유지하는 것이 효과적인데, 이러면 에셋 번들의 메타 데이터가 많아지는 문제가 발생함.

 

Unity에서는 이를 위한 해결책도 제시함.

 

함께 로드되는 Asset들을 분석해서 하나의 번들로 묶어주는 것임.

 

참조 카운터, 메모리 관련 추가 내용

[Unity3D] Addressable 특징 메모 (tistory.com)

→ InstantiateAsync가 Ref-Count를 관리하여 사용되지 않는 번들을 자동으로 해제한다고 함.

→ 기본 규칙은 번들이 너무 세부화될 수 있기 때문에 Custom Rule을 사용하는 것이 좋다고 함.

 

어드레서블 에셋 시스템 - 개념: 에셋 로드와 생성 및 해제 (tistory.com)

→ "만약 Addressable의 InstantiateAsync함수로 생성했다면 Release 시 Ref Count가 0이 될때까지 해제하지 않고 기다려준다.”

 

메모리 관리 개요 | 어드레서블 | 1.21.15 (unity3d.com)

AssetBundles have their own reference count, and the system treats them like Addressables with the assets they contain as dependencies. When you load an asset from a bundle, the bundle's reference count increases and when you release the asset, the bundle reference count decreases. When a bundle's reference count returns to zero, that means none of the assets contained in the bundle are still in use. Unity then unloads the bundle and all the assets contained in it from memory.

In this example, the asset isn't unloaded at this point. You can load an AssetBundle, or its partial contents, but you can't unload part of an AssetBundle. No asset in unloads until the AssetBundle is unloaded.

→ 에셋을 Release하더라도 바로 메모리에서 해제되는 것은 아님. 에셋 번들의 Asset을 부분적으로 Unload할 수는 없으며, 번들의 Refenrence Count가 0이 되면 번들이 해제되며 이때 Asset들의 메모리가 해제된다.

 

The exception to this rule is the engine interface [Resources.UnloadUnusedAssets]. Executing this method in the earlier example causes to unload. Because the Addressables system isn't aware of these events, the Profiler graph only reflects the Addressables ref-counts (not exactly what memory holds).

→ Resources.UnloadUnusedAssets는 예외적으로 번들에서 사용되지 않는 Asset들을 부분적으로 Unload 가능하다고 함. 단, 이는 Addressables에서 제공하는 프로파일러가 인지하지 못하기에 실제 메모리와는 다른 정보가 표기될 수 있다고 함.

 

Event Viewer | Addressables | 1.21.15 (unity3d.com)

The window shows when your application loads and unloads assets and displays the reference counts of all Addressables system operations.

→ 위에서 얘기한 프로파일러는 Event Viewer인 것으로 보이고, 빌드할 때 Send Profiler Events를 체크하여 볼 수 있다고 함. 참조 카운터와 Asset의 Load, Unload를 확인할 수 있다고 함.

 

*******************

(추가 내용)

Addressables는 사용만 제대로 한다면 Asset의 Reference Count도 관리해준다.

어떤 Prefab에서 다른 번들의 Sprite를 참조하고 있는 경우, Prefab을 Release해서 Sprite의 Reference Count가 0이 된다면 Sprite도 자동으로 해제해준다.

이 장점 하나만으로도 Addressables를 사용할 가치는 충분하다고 생각된다.

다음 글에서 이 내용에 대해서 다루겠다.

*******************

 

기타

Group, Label

Pack groups into AssetBundles | Addressables | 1.21.15 (unity3d.com)

모든 Asset은 Group에 속하고, Label을 추가하여 번들을 묶는 방식을 제어할 수 있음.

Pack Together: 그룹 내의 모든 Asset을 하나의 번들로 묶음

Pack Separately: 그룹 내의 모든 Asset을 각각의 번들로 묶음

Pack Together By Label: 그룹 내의 모든 Asset을 Label마다 1개의 번들로 묶음

 

Content Catalog

Content catalogs | Addressables | 1.21.15 (unity3d.com)

Asset의 실제 위치와 Address를 매핑하는 파일.

Hash파일을 통해 Content Catalog가 변경되면 다운로드하게 만들 수 있음.

Address로 Asset을 Load → Content Catalog에서 Address에 해당하는 Asset을 찾아오는 듯.

 

Internal Asset Naming Mode

Addressables FAQ | Addressables | 1.19.19 (unity3d.com)

Content Catalog 등에 저장되는 Asset의 ID를 결정하는 방법을 정할 수 있음.

Full Path: 파일의 전체 경로로, 개발 중에만 사용하는 것을 권장하는 듯.

Filename: 파일의 이름으로 식별하는 것. 동일한 이름을 가진 Asset을 만들 수 없음.

GUID: GUID로 식별

Dynamic: Release에 추천되는 사용법으로, 식별 가능한 가장 짧은 GUID를 만들어서 저장하기 때문에 AssetBundle과 Catalog의 크기도 감소하고, 런타임 메모리 오버헤드도 줄어든다고 함.

 

Addressables에서 CRC 사용과 로딩 속도 관련 문제

Addressable Load Performance - Unity Forum

Bug - Addressables - Extremely slow load time - Unity Forum

Are CRC checks worth the insane overhead? - Unity Forum

Addressable에서 로딩 속도가 굉장히 느리다는 얘기가 있는데, CRC를 사용하면 속도가 굉장히 느려지고, CRC를 끄면 AssetBundle을 사용할 때와 동일하다는 것으로 보임.

Unity Technologies는 CRC를 상황에 따라 나누어 사용하는 것을 권장하는 것으로 보임.

 

참고할만한 내용

Configure your project to use Addressables | Addressables | 1.21.15 (unity3d.com)

AssetBundle → Addressables

 

hammerimpact (Hammer Impact) - velog

Addressables 1.21.12 문서를 한국어로 번역한 블로그

 

Addressable Assets Systemをちゃんと導入するための技術検証まとめ – てっくぼっと! (applibot.co.jp)

Addressables의 기술적인 부분을 분석한 글

 

유니티(Unity) - Addressable(어드레서블) 사용법(8). 서버에서 다운로드하기 2편[Catalog 이해] : 네이버 블로그 (naver.com)

Addressables 카탈로그 버전 관리 관련 내용

에셋 번들을 사용하면 에셋이 중복되는 문제가 발생할 수 있다.

이는 메모리 프로파일러를 통해서 확인할 수 있다.

 

여태까지 열심히 알아본 바로는 중복이 발생하는 경우는 다음과 같다.

 

1. 에셋 번들의 종속성(Dependencies)이 제대로 설정되지 않은 경우

에셋 번들의 종속성이 설정되어 있다면 번들 밖의 에셋을 사용할 때 종속된 에셋 번들을 먼저 로드하고, 해당 에셋에 있는 에셋을 사용한다. 종속성이 잘 지정되어 있다면, Shader를 사용한다고 가정하면 종속된 에셋 번들의 Shader 1개를 공유해서 사용한다.

에셋 번들의 종속성이 설정되어 있지 않다면, 번들 밖의 에셋을 사용할 때 에셋을 복제해서 번들 내부에 포함시킨다. 이 에셋을 다른 에셋 번들이 참조할 수 없기 때문에 이런 복제를 반복하는 에셋 번들의 수만큼 에셋이 복제된다. 이 경우 똑같은 Shader가 10개 이상 메모리에 적재되는 경우도 생긴다.

 

Tales from the optimization trenches: Saving memory with Addressables | Unity Blog

 

Tales from the optimization trenches: Saving memory with Addressables | Unity Blog

Problem: If we instantiate all of our items and despawn the boss sword, we will still see the boss sword’s texture “BossSword_E ” in memory, even though it isn’t in use. The reason for this is that, while you can partially load asset bundles, it

blog.unity.com

만약 이해가 되지 않는다면 해당 내용을 참고하면 쉽게 이해할 수 있을 것이다.

Addressable에 대한 소개지만, 에셋 번들을 기반으로 하고 있기 떄문에 Assetbundle에도 대부분 유효한 내용이다.

 

1-1. 유니티를 처음 실행하면 유니티에서 Normal 관련 문제에 대해서 다이얼로그를 띄우는 경우가 있는데, 여기서 Fix Now가 아니라 Ignore를 선택한 경우.

AssetBundles missing dependency to other AssetBundle - Unity Forum

 

AssetBundles missing dependency to other AssetBundle

Hey everyone, I'm running into a very strange issue that I've been trying to solve for days now. A high level of what we do for our bundle process: We...

forum.unity.com

에셋 번들을 빌드할 때는 에디터가 Fix Now를 선택하면서 Meta 파일을 변경하고, 이로 인해 종속성이 끊어질 수 있다고 한다.

 

1-2. BuildPipeline을 사용하면서 각각의 에셋을 다른 방식으로 압축하는 경우

Unity - Scripting API: BuildPipeline.BuildAssetBundles (unity3d.com)

 

Unity - Scripting API: BuildPipeline.BuildAssetBundles

This signature of BuildAssetBundles is recommended and exposes the most functionality. The other signatures, documented below, are retained for backward compatibility and convenience. During the AssetBundle build process, classes that implement IProcessSce

docs.unity3d.com

위 링크에서

public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

가 있는데, BuildAssetBundleOptions를 받아서 빌드를 한다.

여기에는 비트 연산자를 이용해서 여러 옵션들을 포함시킬 수 있는데, A, B, C 에셋을 LZ4로 압축하고 D, E, F 에셋을 LZMA로 압축한다면 (A, B, C)로 빌드를 1번, (D, E, F)로 빌드를 1번 거치게 된다.

때문에 빌드 과정이 2번으로 분리되고, (A, B, C) 안에서는 Dependencies가 정상적으로 설정되지만 (A, B, C)와 (D, E, F)간의 Dependencies는 기록되지 않는다.

 

Mixing Asset Bundle Compression Type - Unity Forum

 

Mixing Asset Bundle Compression Type

Does anyone know if it is possible to mix AssetBundle compression types inside a single app? We have a mix of AssetBundles some of which are deployed...

forum.unity.com

이에 대한 내용이 유니티 포럼에 나와있다.

나도 압축 방식을 2개를 사용하는 것이 문제라는 것을 인지한 이후에 검색하여 겨우겨우 찾은 내용이라 시간을 꽤나 허비했다.

 

이 글에서 나온 해결책은 Scriptable Build Pipeline을 사용하라는 것.

 

Usage Examples | Scriptable Build Pipeline | 1.21.8 (unity3d.com)

 

Usage Examples | Scriptable Build Pipeline | 1.21.8

Usage Examples Basic Example This example assumes that your are already familiar with the basic usage of BuildPipeline.BuildAssetBundles and want to switch to using Scriptable Build Pipeline with as little effort as possible. The following code example sho

docs.unity3d.com

링크 내부에 Per-Bundle Compression Example이 나와있는데, 해당 부분을 참고하면 된다.

 

using UnityEditor;
using UnityEditor.Build.Content;
using UnityEditor.Build.Pipeline;
using UnityEditor.Build.Pipeline.Interfaces;

public static class BuildAssetBundlesExample
{
    // New parameters class inheriting from BundleBuildParameters
    class CustomBuildParameters : BundleBuildParameters
    {
        public Dictionary<string, BuildCompression> PerBundleCompression { get; set; }

        public CustomBuildParameters(BuildTarget target, BuildTargetGroup group, string outputFolder) : base(target, group, outputFolder)
        {
            PerBundleCompression = new Dictionary<string, BuildCompression>();
        }

        // Override the GetCompressionForIdentifier method with new logic
        public override BuildCompression GetCompressionForIdentifier(string identifier)
        {
            BuildCompression value;
            if (PerBundleCompression.TryGetValue(identifier, out value))
                return value;
            return BundleCompression;
        }
    }

    public static bool BuildAssetBundles(string outputPath, bool useChunkBasedCompression, BuildTarget buildTarget, BuildTargetGroup buildGroup)
    {
        var buildContent = new BundleBuildContent(ContentBuildInterface.GenerateAssetBundleBuilds());
        // Construct the new parameters class
        var buildParams = new CustomBuildParameters(buildTarget, buildGroup, outputPath);
        // Populate the bundle specific compression data
        buildParams.PerBundleCompression.Add("Bundle1", BuildCompression.DefaultUncompressed);
        buildParams.PerBundleCompression.Add("Bundle2", BuildCompression.DefaultLZMA);

        if (m_Settings.compressionType == CompressionType.None)
            buildParams.BundleCompression = BuildCompression.DefaultUncompressed;
        else if (m_Settings.compressionType == CompressionType.Lzma)
            buildParams.BundleCompression = BuildCompression.DefaultLZMA;
        else if (m_Settings.compressionType == CompressionType.Lz4 || m_Settings.compressionType == CompressionType.Lz4HC)
            buildParams.BundleCompression = BuildCompression.DefaultLZ4;

        IBundleBuildResults results;
        ReturnCode exitCode = ContentPipeline.BuildAssetBundles(buildParams, buildContent, out results);
        return exitCode == ReturnCode.Success;
    }
}

내부에 요런 코드가 있는데, BundleBuildParameters를 상속받은 CustomBuildParameters Class를 정의한다.

 

안에 있는 PerBundleCompression에는 에셋 번들의 이름과 해당 번들의 압축 방식을 저장하면 된다.

 

	public override BuildCompression GetCompressionForIdentifier(string identifier)
        {
            BuildCompression value;
            if (PerBundleCompression.TryGetValue(identifier, out value))
                return value;
            return BundleCompression;
        }

또, 내부에는 GetCompressionForIdentifier가 override되어 선언되어 있는데, ContentPipeline.BuildAssetBundles는 빌드를 할 때 번들마다 GetCompressionForIdentifier()를 호출해서 압축 방식을 판단한다. (해당 함수 내부에서 Log를 찍어주면 빌드 이후에 번들마다 호출되는 것을 확인할 수 있다.) 압축 방식이 따로 지정되어있지 않으면 BundleCompression을 반환한다. BundleCompression은 Default 압축 방식을 넣어주면 된다.

 

이렇게 하면 압축 방식이 달라서 생기는 Dependencies 관련 문제는 말끔히 해결이 된다.

단, BuildPipeline과 다르기 때문에 주의해야할 점이 몇 가지 있다.

 

1-2-1. Scriptable Build Pipeline을 사용하면 BuildePipeline과는 다르게 Manifest파일이 나오지 않는다.

기존에 Manifest 파일을 사용하여 처리를 하고 있던 부분이 있다면, 그 부분들을 전부 수정해줘야 한다.

IBundleBuildResults인 results에 Manifest에 기록되던 Dependencies나, Hash, CRC 등의 값이 저장되기 때문에 해당 정보를 따로 파일에 저장해서 활용하면 된다.

 

1-2-2. Scriptable Build Pipeline을 사용하여 에셋을 빌드하면, 에셋을 로드할 때 대소문자를 엄격하게 구분한다.

아마 대소문자가 다른 에셋을 만들 것을 염두에 둔 것인지는 모르겠는데... SBP is strict about the rule: "what you pass in is exactly what you get out". 라고 하니... 뭐 그런가보다.

AssetBundle Browser로 확인해보면 BuildPipeline으로 빌드한 경우 에셋의 Path가 전부 소문자로 되어있는데, ContentPipeline으로 빌드한 경우 실제 경로와 대소문자 구분이 똑같다.

 

1-2-3. Scriptable Build Pipeline을 사용하면 빌드한 에셋 번들을 삭제해도 다시 빌드하지 않고, 캐시를 활용한다.

일반 BuildPipeline을 사용하면 빌드된 번들을 삭제하면 처음부터 다시 빌드를 하는 것 같은데, ContentPipeline의 경우 Library/BuildCache에 빌드 정보가 캐시되어 빌드를 굉장히 금방 끝낸다. 다만 여기서 문제가 바뀐 부분만 빌드를 하기 때문에 쉐이더를 컴파일하지 않아서 Shader Stripping을 수정해도 적용되지 않을 수 있다. 캐시 정보를 삭제하거나, 이건 번거로우니 IBuildParameters.UseCache = false; 를 사용해서 강제로 처음부터 다시 빌드를 하게 만들어주면 된다. 

 

Upgrade Guide | Scriptable Build Pipeline | 1.21.8 (unity3d.com)

 

Upgrade Guide | Scriptable Build Pipeline | 1.21.8

Upgrade Guide To build your AssetBundles with the SBP package, use the CompatibilityBuildPipeline.BuildAssetBundles method wherever you used the BuildPipeline.BuildAssetBundle method. Note: Not all of the features that were supported previously are support

docs.unity3d.com

이외의 자세한 내용은 위 링크를 참조하시라.

 

1-2-4. Shader Stripping에서 특정 부분이 BuildPipeline과 다르게 작동하는 것 같다. (추측)

Question - SBP don't strip shader variants? - Unity Forum

 

Question - SBP don't strip shader variants?

I'm upgrading asset bundle build method to scriptable build pipeline, but the result shows shader bundle size grown from 5MB to 22MB, and deep inside...

forum.unity.com

SBP(Scritable Build Pipeline)의 Shader Stripping에 대한 포럼의 글이 있다.

나의 경우, 기존에는 70MB 수준이던 Shader 총 용량이 210MB로 증가했다.

나도 해당 내용을 참고해서 BuildPipeline을 사용했을 때와 ContentPipeline을 사용했을 때 남은 Keywords를 비교해봤다.

FOG_EXP2와 SHADOWMASK? 2개의 키워드가 추가로 엄청나게 많은 비중을 차지하는 것을 발견했고, 해당 키워드들을 Shader Stripping 목록에 추가해서 지웠고, 90MB로 용량을 대폭 줄일 수 있었다.

처음에는 Player Settings의 Graphics내의 Shader Stripping에 있는 키워드들이라 여기서 지워주는 처리를 안 해주는 것이 아닌가, 생각을 했는데 아까 실험을 몇 가지 해보니 FOG_EXP는 제대로 지워주는 것 같아서 굉장히 헷갈린다. 월요일에 한 번 더 확인해볼 생각이다.

 

하지만 여전히 90MB로 기존에 비해 30% 가량 큰 용량을 차지하고 있는데, 일부 쉐이더의 용량이 다르다는 문제가 있다. ShaderX라는, Shader를 암호화 해주는? 기능을 사용하는 Shader인데, 분명 사용하는 키워드의 수가 이전과 동일하고 Pass 수도 동일한데 용량 차이가 2배 이상 난다. Pass 내에서 사용하는 키워드의 수가 차이가 나는 건가? 싶기도 한데... log shader compilation 기능을 사용해서 확인해보기는 했지만 경우에 따른 변수가 있어서 다시 확인을 해봐야 할 것 같다. IPreprocessShaders.OnProcessShader에서도 확인해봤지만 BuildPipeline은 이게 요상하게 많이 호출되어서 확인하기도 어렵다. RenderDoc도 사용해봤지만 별 차이를 발견하지는 못했다. 아무튼 용량에 차이가 있다는 것은 Shader Stripping의 방식에 뭔가 차이가 있다는 것인데 Shader 내부를 직접 볼 수가 없어서 정보가 굉장히 부족하다보니 하나씩 삽질을 해가면서 파악을 하고 있다. 이 문제를 해결하면 아마 여기에 추가로 적을 것이라고 생각한다. 위의 포럼에도 내용을 보탤 수 있으면 좋을 것 같다. 아마도 ContentPipeline이 추가된 이후에, Addressable이 추가되어서 ContentPipeline에 대한 지원이 부실하고, 사용하는 사람의 수가 적기 때문에 내용이 적은 것 같은데... Addressable로 업그레이드 할 수 있으면 굉장히 좋겠다는 생각이 든다.

 

내용을 쓰다보니 Shader Stripping에 대한 내용도 적으면 좋겠다는 생각이 들기는 하는데, 현재 사용하는 Shader Stripping에 대한 기능을 업그레이드 할 계획을 세우고 있어서 해당 시스템을 구축하고 나서 내용을 적을 예정이다.

 

Bug - (Case 1321433) Addressables/SBP don't detect IPreprocessShaders changes - Unity Forum

 

Bug - (Case 1321433) Addressables/SBP don't detect IPreprocessShaders changes

Adding and changing an IPreprocessShaders implementation doesn't cause Addressables/SBP to rebuild shaders. You have to purge the build cache for...

forum.unity.com

+Scriptable Build Pipeline을 사용하면 IPreprocessShaders가 제대로 호출되지 않는데, SBP는 기존의 BuildPipeline과는 다르게 Library/BuildCache 폴더에 캐시를 해두고 빌드를 하기 때문에 빌드한 에셋 번들을 날려도 캐시된 데이터를 바탕으로 에셋 번들을 빌드하기 때문인 것으로 생각된다. 때문에 Shader가 바뀌지 않는 이상 Shader를 다시 컴파일하지 않고, 이에 따라 IPreProcessShaders가 제대로 호출되지 않는 것이다. 이를 해결하기 위해서는 BuildCache 폴더를 삭제한 후에 다시 빌드할 수도 있지만, 이 폴더는 파일 수가 굉장히 많고 용량이 크기 때문에 IBuildParameters.UseCache = false; 를 해주면 조금 더 간편하게 IPreProcessShaders를 호출할 수 있다.

 

 

2. 에셋 번들을 잘못 Unload 한 경우

 

AssetBundleを完全に理解する - Qiita

 

AssetBundleを完全に理解する - Qiita

はじめに前回の記事では、Unityにおけるリソース読み込みについての基本的な知識を総ざらいし、ResourcesやAssetBundleの特徴や違いについて取り上げ、AssetBundleの必要性…

qiita.com

위 링크에 위와 같은 내용이 있다.

assetBundle.Unload(false)를 하면 로드된 리소스를 유지한 채로 meta 정보를 날리는데, 이 meta 정보에는 리소스가 메모리 상 어디에 로드되었는지가 기록되기 때문에 이걸 날려버리고 다시 로드를 하면 기존의 리소스를 참조하지 못하고 새로운 리소스를 로드하게 된다.(라고 들었다) 즉, 메모리 상에 에셋이 중복되어 생성되는 것이다.

assetBundle.Unload(true)를 하면 로드된 리소스까지 같이 날려버리지만, 사용하고 있는 경우 마젠타 색으로 표시될 것이다.

즉, 어느 경우든 "해제하는 에셋 번들에서 에셋을 사용하고 있지 않다" 는 것이 확실해야 문제가 발생하지 않는다.

이것을 확인할 수 있는 방법은 Reference Count를 기록하여 0이 되었는지 확인하는 것인데, BuildPipeline 등은 이 기능을 코드로 직접 구현해줘야 한다. 참고로 Addressable은 이걸 알아서 해주면서 참조가 0이되면 알아서 해제까지 해주기 때문에 Addressable을 사용하는 것이 굉장히 좋겠다.

 

다음 주 회사에서 하는 스터디 내용으로 Addressable에 대한 내용을 준비해 갈 예정인데, 여태 알아본 바로는 우리가 사용하는 기본 AssetBundle의 굉장한 상위 호환으로 느껴진다. 준비를 잘 해가서 Addressable로 업그레이드하면 굉장히 좋겠다는 점을 어필하고 싶다. 아마 Addressable에 대해 공부한 내용을 블로그에도 적을 것 같다.

유니티에 대해 공부를 하다보면 에셋 번들에 대한 내용을 드문드문 접할 수 있다.

나 또한 에셋 번들에 관한 얘기를 지나가면서 몇 번 듣기는 했는데... 에셋 번들이 뭔지도 모르고 있었다.

지금으로부터 약 1달 전, 메모리 관리를 하며 에셋의 복제를 확인했고, 그 원인이 에셋 번들에 있음을 인지한 이후로 많은 공부를 하였다.

지금은 기존의 BuildPipeline을 Scriptable Build Pipeline으로 업그레이드를 했고, Shader Stripping에 문제가 있어서 해당 부분에 대한 원인을 찾아보고 있는 중이다.

아무튼, 이제는 에셋 번들에 대해 조금 알게된 것 같아 에셋 번들이란 무엇인지를 정리해보고자 한다.

 

모바일 게임을 설치하고 실행하면 이런 화면을 보게 될 것이다.

여기서 다운로드 받는 것이 에셋 번들이다.

 

원스토어에서 블루 아카이브의 용량은 143MB이지만, 실제 핸드폰에서 차지하는 게임 용량은 7.57GB로 굉장한 괴리감이 있다. 

스토어에서 받은 용량을 제외한 나머지 용량은 전부 에셋 번들을 다운로드 받은 것이라고 보면 된다.

 

그러면, 에셋 번들을 사용하는 이유는 무엇일까?

 

 

첫 번째로는 스토어에서 용량을 작게 유지하기 위해서이다.

 

구글 플레이 스토어의 경우, 앱의 크기를 150MB로 제한을 해놨기 때문에 울며 겨자먹기로 이 용량보다 작게 유지해야 한다.

 

앱스토어 버전은 용량 제한이 500MB라 그런지 464.4MB이고, 전체적으로 다른 앱들도 용량이 크다. (스토어를 통해 받으면 돈이 안 나가니까 비용 절감에 도움이 된다)

 

그리고 앱의 용량이 작을수록 다운로드 수가 늘어난다고 한다.

 

아마 나같아도 요렇게 되어있으면 별로 안 받고 싶어질 것 같다.

 

가끔 위 사진처럼 플레이 스토어에도 150MB를 넘는 경우가 있는데, PAD(Play Asset Delivery)라는 기능을 이용한 것이다. 앱은 따로 받고, 에셋을 따로 올려서 앱을 다운로드 받을 때 최대 1GB까지 같이 받게 만들 수 있다. 후술하겠지만 여기에 올릴 에셋은 LZ4로 압축하는 것이 좋다.

 

 

에셋 번들을 사용하는 두 번째 이유는, 게임 내 리소스들의 유동성을 확보할 수 있다는 점이다.

 

앱을 빌드하면 스토어에 바로 올릴 수 있는 것이 아니다. 스토어에 앱을 올릴 때는 스토어의 검수 과정을 거치는데, 거절을 당할 수도 있고, 거절당한 이유를 알 수 없는 경우도 있어서 예상한 것보다 시간이 많이 걸릴 수 있다. 매 번 유저에게 스토어에서 앱을 다운로드 받으라고 하는 것도 바람직하지 못하다. 에셋 번들을 사용하지 않는 경우 게임 내 이미지 1개를 바꾸는 데에도 이런 작업이 필요하다는 점에서 굉장히 비효율적이라고 할 수 있겠다. 데이터가 잘못 들어가서 버그가 났는데, 서버를 내리고, 앱을 다시 빌드해서 검수를 통과하여 며칠이 지난 뒤에 서버가 열린다? 생각만 해도 끔찍한 일이다.

 

반대로 에셋 번들을 사용한다면, 앱을 빌드하고 심사할 필요 없이 에셋 번들만 따로 빌드해서 업로드를 하고, 유저가 접속할 때 변경사항을 체크하여 바뀐 부분만 다운로드를 받으면 바로 게임에 적용할 수가 있다. 종종 게임에 접속할 때 점검도 없었는데 갑자기 작은 용량의 데이터를 받는 경우가 있을텐데, 그게 바로 이런 경우라고 할 수 있겠다. 이 경우, 버그가 터져도 에셋 번들을 빌드할 몇 시간만 서버를 내렸다가 다시 오픈하면 문제를 해결할 수 있다.

 

버그 뿐만 아니라 게임 내에서 테이블을 통해 데이터를 읽어오고 적용하도록 만들면, 신규 아이템이나 캐릭터 등을 추가할 때도 단순히 데이터 패치를 통해서 적용할 수 있다.

 

 

위 두 가지 이유로 인해서 모바일 게임을 만든다면 에셋 번들을 쓰는 것은 선택이 아니라 필수라고 할 수 있을 것이다.

 

 

하지만 에셋 번들의 단점이 있는데, 에셋 번들을 사용하는 시스템을 구축하는 것이 굉장히 복잡하다는 것이다.

 

에셋 번들을 사용하려면 위와 같은 시스템을 구축해야 한다.

 

먼저 에셋 번들의 빌드는 BuildPipeline이나 Scriptable Pipeline의 ContentPipeline 등을 사용하여 에셋 번들을 빌드할 수도 있고, 유니티에서 많은 부분을 개선하여 낸 Addressable을 사용할 수도 있다.

 

*BuildPipeline이나 ContentPipeline을 사용하면 굉장히 번거로운 처리들을 직접 해줘야 하기 때문에 신규로 에셋 번들을 사용할 준비를 하고 있다면 이런 부분들의 일부를 알아서 처리해주는 Addressable을 사용하는 것을 권장한다.

 

 

그 다음은 에셋 번들을 다운로드 받을 수 있도록 업로드를 해야한다. 보통은 aws를 사용할텐데, 1GB에 대략 30원 정도의 비용이 나간다. 용량이 작다면 위에 언급한 PAD나 ODR(On Demand Resources)을 활용할 수도 있겠다.

 

 

그 다음은 유저가 모바일 기기에서 에셋 번들을 다운로드하는 부분을 구축해야 한다. 에셋 번들마다 버전을 관리하고, Hash를 이용해서 변경된 에셋 번들만을 받을 수 있도록 해야한다. 유니티로 한다면 UnityWebRequest, DownloadHandlerAssetBundle을 사용하게 될 것이다.

DownloadHandler 생성 - Unity 매뉴얼 (unity3d.com)

 

DownloadHandler 생성 - Unity 매뉴얼

다음과 같은 몇 가지 DownloadHandler 타입이 있습니다.

docs.unity3d.com

 

마지막으로는 에셋 번들을 로드하는 과정을 거쳐야 한다. 에셋 번들의 Dependencies를 읽어와서 에셋을 로드하기 전에 필요한 다른 에셋들을 먼저 로드해야 리소스가 깨지지않고 정상적으로 보이게 된다. 이런 코드를 직접 만든다면 순환 참조를 하지 않도록 막아주는 것이 필요하다.

 

정말 마지막으로는 로드한 에셋을 관리하는 것인데, 더 이상 사용하지 않는 에셋들을 해제해준다거나 하는 부분이다. 다만 에셋의 복제나 리소스가 깨진다든지 등의 문제가 생길 가능성이 많다. Addressable은 사용하지 않는 에셋 번들을 자동으로 메모리에서 해제해준다고 하니 신경을 적게 써도 된다. Dependencies로 알아서 체크해준다는 것 같고. 이외에도 위에 언급한 내용 중 일부분을 알아서 처리해주기도 하니 새로 구축한다면 반드시! 꼭! Addressable을 사용하도록 하자!

 

 

에셋 번들을 빌드할 때는 압축을 하게 되는데 3가지 방식이 있다.

 

1. 압축을 아예 하지 않는 방법

압축을 하지 않아서 빌드가 빠르겠지만, 용량이 굉장히 커지기 때문에 권장하지 않는 방식이다.

 

Bug - Addressables - Extremely slow load time - Unity Forum

It seems that LZ4 makes that first load faster than Uncompressed

추가로, 위 글에서 Uncompressed보다 LZ4가 첫 로딩 속도가 빠른 것 같다는 내용이 있는데, Unity에서 저장하는 방식을 LZ4로 해놔서 아마 실행할 때 Uncompressed를 LZ4로 하여 디스크에 저장하는 과정이 있는 것이 아닐까 싶다. 결국 Uncompressed는 꼭 필요한 것이 아니라면 권장하는 방식이 아니라고 생각된다.

 

2. LZMA

용량을 가장 작게 줄일 수 있는 압축 방식으로, zip이나 7z 등도 해당 방식을 사용한다. 단, 압축률이 높은 만큼 데이터를 불러오는 속도가 느리다. 설상가상으로 에셋 중 1개만 필요하더라도 에셋 번들 전체를 압축 해제해야 하기 때문에, 이 방식으로는 사용하기 어렵다. 하지만, 유니티에서 앱을 실행할 때, 혹은 다운로드를 받는 도중에 바로 다음에 쓸 LZ4로 Recompress를 해서 디스크에 저장하기 때문에 이런 걱정은 할 필요가 없다. 단, 주의해야할 점은, aws 등을 사용해서 다운로드를 받게 한다면 다운로드와 Recompress가 동시에 일어나기 때문에 유저가 다운로드를 받으면서 자연스럽게 재압축을 할 수 있지만, PAD 등의 기능을 사용하면 LZMA를 받아서 앱을 처음 실행할 때 통째로 LZ4로 Recompress를 하기 때문에 앱을 처음에 실행할 때 굉장히 오래 걸리게 된다. 떄문에 이런 기능을 사용할 것이라면 LZ4로 압축하는 것이 좋다. 단, 후술하겠지만 BuildPipeline을 사용해서 LZ4와 LZMA로 따로 빌드를 한다면 Dependencies를 제대로 찾을 수 없기 때문에 에셋 중복이 일어날 수 있다는 점을 주의해야 한다.

 

3. LZ4

LZ4는 위에서 말한 것 처럼 압축률은 LZMA보다 낮지만, 데이터를 로딩하는 속도가 빠르고 청크 단위로 로딩이 가능하기 때문에, 필요한 에셋만 압축을 해제하여 사용할 수 있다. LZMA로 압축해도 유니티가 LZ4로 재압축을 해주기 때문에 대부분의 경우 LZMA로 압축하는 것이 유저의 다운로드 용량을 줄여주면서 비용도 절감할 수 있는 방법이다.

 

참고로, 파일에 따라 다르겠지만, 일반적으로 LZ4대신 LZMA를 사용하면 약 30%정도 용량을 더 줄일 수 있다는 것 같다. (기억에 의존한 내용)

 

에셋 번들 압축 - Unity 매뉴얼 (unity3d.com)

 

에셋 번들 압축 - Unity 매뉴얼

기본적으로 Unity는 LZMA 압축을 사용하여 에셋 번들을 생성하고, LZ4 압축을 사용하여 캐싱합니다. 이 섹션에서는 이 두 가지 압축 포맷에 대해 설명합니다.

docs.unity3d.com

자세한 내용은 위 링크를 참조하시라.

 

 

 

또, 에셋 번들을 사용하면 에셋이 중복되는 문제가 발생할 수 있다.

이는 메모리 프로파일러를 통해서 확인할 수 있다.

 

글이 길어져서 에셋의 중복에 대한 이야기는 끊고 다음 글에서 마저 다루도록 하겠다.

함수를 호출하는 오버헤드에 따른 성능 차이를 알고 싶어서 Vector3의 Normalize 방법 별 성능 테스트를 해보았다.

 

이전에도 Vector3.Normalize()와 Vector3.normalized에 대한 차이는 해봤는데 그것과 더불어 Vector3.Normalize() 내부에 있는 함수를 꺼내와서 직접 사용하면 얼마나 차이가 있을지 비교해봤다.

 

using System;
using UnityEngine;

public partial class Tester : MonoBehaviour
{
    private void OverheadTest()
    {
        Vector3 vector3 = new Vector3(2, 1, 1);
        Vector3 vector3Temp = new Vector3();

        DoTest("Vector3.normalized Test", repeatTime => {
            for (int n = 0; n < repeatTime; ++n)
            {
                vector3Temp = vector3.normalized;
            }
        });

        DoTest("Vector3.Normalize() Test", repeatTime => {
            for (int n = 0; n < repeatTime; ++n)
            {
                vector3Temp = Vector3.Normalize(vector3);
            }
        });

        DoTest("Vector3.Normalize() disassemble Test", repeatTime => {
            for (int n = 0; n < repeatTime; ++n)
            {
                float num = (float)Math.Sqrt(vector3.x * vector3.x + vector3.y * vector3.y + vector3.z * vector3.z);
                if (num > 1E-05f)
                {
                    vector3Temp = vector3 / num;
                }
                else
                {
                    vector3Temp = Vector3.zero;
                }
            }
        });
    }
}

테스트 코드는 위와 같다.

 

Vector3.normalized는 내부적으로 Vector3.Normalized()를 호출하기 때문에 함수 호출이 1번 더 발생된다.

 

Vector3.Normalize()는 위와 같이 작동하는데, 이 부분을 빼와서 돌렸다.

Math.Sqrt는 "Math.Sqrt() translates to a single floating point machine code instruction." 이라고 하여 이대로 사용하는 게 빠를 것 같아서 이대로 두었다.

 

10억번씩 5회 돌려봤고, Vector3.normalized를 사용하는 경우 143612ms, Vector3.Normalized()를 사용하는 경우 120142ms, Vector3.Normalized() 내부를 꺼내와서 사용한 경우 78946ms가 소요되었다.

 

Vector3.Normalized()를 사용하면 Vector3.normalized를 사용하는 것보다 16.3%정도 빠르다.

Vector3.Normalized() 내부를 꺼내와서 사용하면 Vector3.normalized를 사용하는 것보다 45%나 빠르다.

 

차이가 생각보다 큰데, 테스트삼아 스크립트에 특별한 기능을 하지 않는 함수 참조를 0회~10회 하도록 만들어서 비교해봤지만, 20억 번씩을 돌려봐도 별 차이가 없어서 약간 의아했다. Vector3의 경우 외부에서 참조해오는 것이라 차이가 많이 생기는 것일 수도 있을 것 같다.

 

뭐, 아무튼... Vector3.Normalized()를 사용하는 것이 약간은 더 좋을 것 같다. 내부에서 꺼내와서 사용하는 것은 성능상으로는 좋을지 몰라도 일반적인 상황에서 딱히 좋은 방법은 아닐 것이라고 생각한다.

 

 

객체지향이란 무엇일까 최근에 생각하고 있는데, 성능을 일부 포기하고 가독성과 재사용성을 높여 생산성을 끌어올리는 방법이라고 생각하게 되었다. 조금 더 나아가자면 가독성을 일부 포기하면서까지 재사용성을 극단적으로 높이면서 생산성을 향상시키는 방법이라고 생각한다.

 

때문에 절차지향 프로그래밍에 비해서 속도가 얼마나 느릴까... 하는 생각으로 비교를 해봤는데, Vector3를 호출하는 경우는 생각보다 차이가 심하고, 같은 스크립트 내에서 참조하는 경우에는 전혀 차이가 없어서 조금 알쏭달쏭한 것 같다.

 

당연히 오버헤드에 대한 영향은 무조건 있겠지만, 언어 내부적으로 최적화를 통해서 비슷한 위치에서는 오버헤드에 따른 성능 저하가 거의 없고, Vector3와 같이 외부에서 호출하는 경우에는 그 영향이 크게 나타나는 것이 아닐까 싶다.

 

MethodImplOptions.AggressiveInlining에 대해서도 찾아봤는데 컴파일러가 해당 함수의 코드 전체를 호출한 코드 위치로 복사하여 대체하기 때문에 오버헤드를 줄여주는 키워드라고 한다. 아마 1번 실행할 때는 크게 차이가 없을 것 같고, 반복문 등에서 의미가 크게 나타날 것 같다.

 

.NET Framework: 2016. C# - JIT 컴파일러의 인라인 메서드 처리 유무 (sysnet.pe.kr)

 

.NET Framework: 2016. C# - JIT 컴파일러의 인라인 메서드 처리 유무

.NET Framework: 2016. C# - JIT 컴파일러의 인라인 메서드 처리 유무 [링크 복사], [링크+제목 복사] 조회: 3151 글쓴 사람 정성태 (techsharer at outlook.com) 홈페이지 첨부 파일 부모글 보이기/감추기 (연관된

www.sysnet.pe.kr

 

그리고 찾아보니 이런 글도 있었는데, JIT 컴파일러에서는 자체적으로 인라인을 하는 것에 따른 비용과 효과를 판단해서 인라인을 해주기도 하고, 안 해주기도 한다는 것 같다. 음... 내가 모르는, 성능 향상을 위한 노력들이 많구나 하는 것을 다시금 느끼게 되었다.

 

근데 그렇다면 인라인하는 처음 1번을 제외하고는 함수를 인라인한 테스트들과 같은 실행시간을 가져야할 것 같은데... 음... 결과를 보면 그렇지만도 않은 모양이다. 아마 개선을 해서 이 정도의 시간이 소요되는 것이 아닐까... 라고 생각해본다.

(Unity 2021.3.10f1 기준)

 

+2024.03.30.

List는 가비지를 생성하지 않는 것이 맞고, Dictionary는 처음에만 조금 가비지를 생성하는 것? 같다. 자료 구조에 따라 가비지가 생성될 수도 있으니 애매하면 프로파일러를 사용해서 확인해보면 좋을 것 같다.

JacksonDunstan.com | Do Foreach Loops Still Create Garbage?

 

JacksonDunstan.com | Do Foreach Loops Still Create Garbage?

Over a year ago I wrote an article title Do Foreach Loops Create Garbage using Unity 5.2 and tested foreach with a variety of collections: List, Dictionary, arrays, etc. Since then Unity has a new C# compiler and version 5.6 has been released. Is it safe t

www.jacksondunstan.com

 

[결론]

1. foreach는 더 이상 가비지를 생성하지 않는 것이 맞다. (+2024.03.30. List와 Dictionary에서)

2. 배열이나 List와 같이 index로 접근 가능한 경우, for문을 사용하는 게 속도도 50~60% 가량 빠르고 데이터를 제어하기도 편리하니 for문을 사용하는 것이 좋다.

3. Dictionary의 key로 된 List를 만들어서 for문을 돌리는 것보다도 foreach로 Dictionary를 돌리는 것이 2배 가량 빠르고 가비지가 안 나오니 Dictionary를 탐색할 때는 걱정없이 foreach를 사용하자.

 

for와 foreach의 성능 차이에 대한 이야기가 종종 화두로 떠오르곤 한다.

 

옛날에는 foreach가 가비지를 많이 생성해서 foreach 사용을 피하곤 했다고 하고, 예전 코드를 보다가 가비지 생성을 피하기 위해서인지 List에 Dictionary의 key를 저장하는 식으로 사용하는 것도 보았다.

 

그래서 이제는 foreach에서 가비지가 정말 안 나오는지와 for와 foreach의 성능 비교 확인해봤다.

 

기껏 테스트 환경을 만들어놨는데 로그를 무조건 띄우게 되어있어서 GC.Alloc()이 집계되는지라 Start()와 Update()에 따로 작성해주었다.

아무튼, int로 된 Dictionary를 만들어서 Update()에서 foreach로 순회했지만 GC Alloc은 하나도 발생하지 않았다. 이제는 가비지가 발생하지 않는 것이 맞다.

 

[Unity] Foreach의 GC의 원인 및 수정 (tistory.com)

 

[Unity] Foreach의 GC의 원인 및 수정

Unity 5.5 미만 버전에서 foreach를 사용할 경우 가비지가 발생하는 이슈가 있었다. 원인 - Mono C# Unity 버전에서 foreach loop 종료 시 값을 강제로 박싱 - 값 형식의 열거자를 생성하였는데, 해당 열거자

everyday-devup.tistory.com

+기존의 foreach에서 가비지가 발생했던 원인은 값 타입을 interface(참조 형식)로 박싱했기 때문이고, 현재는 포인터를 직접 넘겨주는 식으로 수정되어 가비지가 발생하지 않는다고 한다.

 

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public partial class Tester : MonoBehaviour
{
    List<int> globalIntList = new List<int>();
    List<string> globalStringList = new List<string>();

    private void ForAndForeachTest()
    {
        List<int> preparedIntList = new List<int>();

        int temp = 0;

        for (int i = 0; i < _repeatTime; ++i)
        {
            preparedIntList.Add(i);
        }

        DoTest("List Traversal using for", repeatTime => {
            for (int i = 0; i < repeatTime; ++i)
            {
                temp = preparedIntList[i];
            }
        });

        DoTest("List Traversal using foreach", repeatTime => {
            foreach (var number in preparedIntList)
            {
                temp = number;
            }
        });

        Dictionary<int, int> intDictionary = new Dictionary<int, int>();

        for (int i = 0; i < _repeatTime; ++i)
        {
            intDictionary.Add(i, i);
            globalIntList.Add(i);
        }

        DoTest("int Dictionary Traversal using foreach", repeatTime => {
            foreach (var s in intDictionary)
            {
                temp = s.Value;
            }
        });

        DoTest("int Dictionary Traversal using Global List + for", repeatTime => {
            for (int i = 0; i < repeatTime; ++i)
            {
                temp = intDictionary[globalIntList[i]];
            }
        });

        DoTest("int Dictionary Traversal using new Key List + for", repeatTime => {
            List<int> newKeyList = intDictionary.Keys.ToList();

            for (int i = 0; i < repeatTime; ++i)
            {
                temp = intDictionary[newKeyList[i]];
            }
        });

        DoTest("int Dictionary Traversal using prepared List + for", repeatTime => {
            for (int i = 0; i < repeatTime; ++i)
            {
                temp = intDictionary[preparedIntList[i]];
            }
        });

        Dictionary<string, string> stringDictionary = new Dictionary<string, string>();
        List<string> stringList = new List<string>();

        string stringTemp = "";

        for (int i = 0; i < _repeatTime; ++i)
        {
            globalStringList.Add(i.ToString());
            stringDictionary.Add(i.ToString(), i.ToString());
            stringList.Add(i.ToString());
        }

        DoTest("string Dictionary Traversal using Global List + for", repeatTime => {
            for (int i = 0; i < repeatTime; ++i)
            {
                stringTemp = stringDictionary[globalStringList[i]];
            }
        });

        DoTest("string Dictionary Traversal using new Key List + for", repeatTime => {
            List<string> newKeyList = stringDictionary.Keys.ToList();

            for (int i = 0; i < repeatTime; ++i)
            {
                stringTemp = stringDictionary[newKeyList[i]];
            }
        });

        DoTest("string Dictionary Traversal using prepared List + for", repeatTime => {
            for (int i = 0; i < repeatTime; ++i)
            {
                stringTemp = stringDictionary[stringList[i]];
            }
        });
    }
}

 

 

결과를 보면 List를 사용할 때는 for문이 foreach보다 대략 50~60% 정도 빠르다.

Dictionary를 사용할 때는 for문으로 돌려면 List를 따로 만들어야 하는데, 미리 key로 이루어진 List를 만들어서 순회해도 foreach로 도는 것보다 2배 가량 느리다.

 

나머지는 key가 포함된 List를 전역 변수로 하는가, 지역 변수로 하는가, 람다식 내부에 만들어주는가에 따라서 속도 차이가 있는 것 같아서 차이를 보려고 했는데, 실행할 때마다, 또 껐다 킬 때마다 속도가 들쭉날쭉하다. 처음에는 Keys.ToList()가 제일 빨라서 가까운 곳에 선언되서 접근 속도가 빠른가보다 생각했는데, 별 관계 없는 것 같다.

 

근데 어차피 foreach가 빨라서 굳이 List로 만들어서 사용할 필요가 없다.

 

결론적으로 List나 배열 등 index로 접근 가능한 경우에는 for문을 사용하는 게 속도도 빠르고, 데이터를 제어하기도 편리하니 for문을 쓰고, Dictionary를 사용할 때는 걱정 없이 foreach를 사용하면 될 것 같다.

[결론]

요즘은 컴파일러들이 알아서 바꿔주기 떄문에 i++나 ++i나 별 차이 없다.

하지만 컴파일러가 바꿔준다는 것은 비효율적이라는 말이기도 하니 ++i로 작성하는 습관을 들이는 것이 좋겠다.

 

개발을 하다보면 성능에 대해서 고민이 들 때가 있다.

 

for문을 돌 때 ++i을 쓰는데, i++에 비해 속도가 얼마나 빠를까?

for문을 쓰는 것이 좋은가? 아니면 foreach문을 쓰는 것이 좋은가?

Vector3.normalized는 Vector3.Normalize()를 호출하기 때문에 Vector3.Normalize()를 호출하면 오버헤드가 적은데, 얼마나 빠를까?

Object.Instantiate()를 하고 SetParent, position, rotation 설정을 해주는 것과 Object.Instantiate()에서 한 번에 해주는 것에 얼마나 차이가 있을까?

 

등등... 다양한 것들에 대해서 문득 궁금해지는 순간들이 온다.

물론 이런 것들은 게임 개발에 있어서 성능 중 극히 일부분을 차지하는 것이긴 하지만, 알아두면 좋을 것 같다고 생각해서 궁금했던 것들을 한 번 테스트해보자는 생각을 했다.

 

이전에도 몇 번 테스트를 했던 경험이 있는데, 테스트를 조금 편하게 할 수 있으면 좋을 것 같아서 테스트 환경을 구축했다.

 

using System;
using System.Diagnostics;
using UnityEngine;
using Debug = UnityEngine.Debug;

public partial class Tester : MonoBehaviour
{
    [SerializeField] private string _testName = "";
    [SerializeField] private int _repeatTime = 0;
    [SerializeField] private byte _entireTestRepeatTime = 1;

    private string _currentTestName = "";
    private Stopwatch _stopwatch = new();

    public void StartTest()
    {
        for (int i = 0; i < _entireTestRepeatTime; ++i)
        {
            Invoke(_testName, 0);
        }
    }

    private void DoTest(string testName, Action<int> test, int repeatTime = 0)
    {
        int tempRepeatTime = _repeatTime;
        if (repeatTime > 0)
        {
            _repeatTime = repeatTime;
        }
        else if (repeatTime > _repeatTime)
        {
            Debug.LogError("repeatTime can't bigger than original repeatTime");
            return;
        }
        _currentTestName = testName;

        
        _stopwatch.Restart();

        test.Invoke(_repeatTime);

        _stopwatch.Stop();
        LogStopwatchElapsedMilliseconds();
    }

    private void LogStopwatchElapsedMilliseconds()
    {
        Debug.Log($"Test {_testName}/{_currentTestName} done, RepeatTime : {_repeatTime}, Takes {_stopwatch.ElapsedMilliseconds}ms");
    }
}

먼저 Tester.cs 파일에 Tester 클래스를 선언해주었다. 편한 사용을 위해서 인스펙터에서 테스트할 함수의 이름과 반복 횟수, 전체 테스트의 반복 횟수를 설정할 수 있도록 했다. Invoke로 함수 이름을 String으로 받아서 실행하는 것은 추적하기 힘들어지니 별로 좋은 방식은 아니지만, 여기서는 편리함을 중요시하여 사용하였다.

 

또, 실행 시간을 기록하거나 출력하는 것을 간편하게 처리해주도록 테스트를 실행할 함수를 Action으로 받아서 한꺼번에 처리해주도록 해주어서 테스트 코드를 간편하게 작성할 수 있도록 하였다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public partial class Tester : MonoBehaviour
{
    private void NppAndppNTest()
    {
        DoTest("n++ Test", repeatTime => {
            for (int n = 0; n < repeatTime; n++) ;
        });

        DoTest("++n Test", repeatTime => {
            for (int n = 0; n < repeatTime; ++n) ;
        });
    }
}

테스트할 함수가 많아지면 스크립트가 많이 길어질 것 같아서 Tester를 partial 클래스로 나누었다. 위의 경우 NppAndppNTest.cs로 스크립트를 나누어 작성하였다. 그리고 Tester에서 사용할 함수라는 것을 쉽게 분별할 수 있도록 TestList 폴더에 모아두었다.

 

이로써 위와 같이 테스트할 함수만 형식에 맞게 작성해주면 테스트를 간편하게 수행할 수 있게 되었다.

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(Tester))]
public class StartTestButtonEditor : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        Tester tester = (Tester)target;
        if (GUILayout.Button("Start Test"))
        {
            tester.StartTest();
        }
    }
}

또, 인스펙터에서 버튼을 눌러서 바로 테스트할 수 있도록 Editor도 추가해주었다.

 

결과적으로 이런 GameObject가 만들어졌다.

 

여기서 Start Test 버튼을 누르면...

 

이런 식으로 결과가 나온다.

3번 테스트를 수행해서 2개씩 3번 나눠서 봐야하는데, ++n이 약간 빠른가? 싶지만 마지막 결과는 n++이 빠르다.

왜 이런 결과가 나왔는지 의문이 들어서 찾아보니 요즘에는 컴파일을 하면서 n++과 같은 것들을 ++n으로 바꿔도 되는 경우(for문에서 사용하는 것처럼 단순히 증가만 시키는 경우) ++n으로 바꿔서 컴파일해준다고 한다.

 

그래서 결론적으로 n++을 쓰나 ++n으로 쓰나 상관없다는 것이다.

쩝... 뭔가 허무한 결과긴 하지만 컴파일러가 바꿔준다는 것은 비효율적이라는 말이기도 하니 n++보다는 ++n이라고 작성하는 습관을 들이는 것이 좋을 것 같다.

오늘은 유니티에서 Package Manager를 통해서 제공하는 Memory Profiler에 대해서 소개할 예정이다. 유니티 2021에서 제공하는 0.7.1 버전을 기준으로 소개를 할 것인데, 현재는 1.0도 나와있다. UI나 세부적인 부분은 바뀌었겠지만 아마 핵심적인 부분들은 크게 바뀌지 않았을 것이라고 생각된다.

 

 

1.0에 대한 내용은 Unity Korea 채널에서 오지현님의 Memory Profiler 1.0 소개 영상을 보면 도움이 될 것 같다.

 

https://youtu.be/rdspAfOFRJI

 

우선, 기본적으로 제공하는 프로파일러에서도 메모리에 대한 대략적인 정보와 어느정도의 디테일한 정보를 볼 수 있었지만, 지금은 Detail한 정보를 보려면 메모리 프로파일러를 통해서 보라고 한다. 메모리 프로파일러는 Heap Memory에 올라와있는 GameObject, Transform은 물론이고 Texture2D, Shader 등 Native Memory에 있는 리소스들까지 전부 볼 수 있다. 프로파일러처럼 실시간으로 보지는 못하지만, 원하는 시점에 스냅샷을 찍어서 해당 시점의 메모리 상황을 저장해놓고, 다른 스냅샷과 비교하면서 어떤 차이가 있는지 살펴볼 수 있는 훌륭한 도구이다.

 

메모리 프로파일러를 열면 다음과 같은 창을 볼 수 있다. 처음에는 아무것도 없는 상태이다. 기본적인 정보를 설명하자면 이렇다.

 

1. Editor라고 되어있는 부분을 누르면 Editor, 혹은 모바일 기기 등을 선택하고 스냅샷을 찍을 수 있다. 모바일 기기를 연결하기 위해서는 안드로이드는 ADB 관련 세팅이, ios는 XCode 관련 세팅이 필요할 것이다. Editor에서는 유니티 Editor에서 사용되는 메모리들도 같이 잡히기 때문에 실제 타겟 기기에서 사용하는 메모리와는 상당히 차이가 크게 나타나기 때문에, 의미있는 메모리 분석을 하기 위해서는 타겟 기기에서 스냅샷을 찍는 것이 좋다.

 

2. 스냅샷을 찍는 버튼이다. 오른쪽 버튼을 누르면 스냅샷을 찍을 때 기록할 메모리들과 일부 설정들을 조절할 수 있다. 스냅샷을 찍을 때 경고창이 나타나는데, 모든 메모리들이 기록되기 때문에 중요한 정보들이 유출되지 않도록 조심하라는 내용이다.

 

3. Single Snapshot은 1개의 스냅샷에 대한 자세한 정보를 볼 수 있고, Compare Snapshots를 누르면 2개의 스냅샷을 선택해서 각 상황에서의 메모리 변화를 비교할 수 있다.

 

4. 기록한 스냅샷이 저장된 모습이다. 기록할 당시의 스크린샷과 이름, 기록한 시간 등이 저장된다. 스크린샷의 오른쪽 아래에는 어느 플랫폼에서 찍었는지 나타내준다. 위 사진의 경우 윈도우, 유니티 에디터에서 찍었기 때문에 두 아이콘이 나타났는데, 안드로이드 기기에서 찍으면 안드로이드 모양 아이콘이 나타나는 식이다. 이름을 누르면 이름 변경이 가능하고, 그 외의 부분을 누르면 스냅샷을 살펴볼 수 있다. 우클릭으로 추가 메뉴를 열 수도 있다. 정보를 세세하게 기록하기 때문에 용량이 상당히 큰데, 대략 200~300MB정도 된다.

 

스냅샷을 누르면 기록할 당시의 메모리의 세부적인 부분을 볼 수 있다. 테스트용 안드로이드 기기를 회사에 놓고 와서 에디터에서 기록한 스냅샷을 기준으로 설명하겠지만, 정확한 분석을 위해서는 타겟 기기에서 스냅샷을 기록할 필요가 있다는 점을 다시 한 번 강조하겠다.

 

Managed Heap은 가비지 컬렉터가 관리하는 메모리 공간으로, 우리가 객체를 생성하는 등의 활동을 할 때 이 영역을 차지한다. 96.3MB를 할당받은 상태이기 때문에 사용량에 관계없이 96.3MB의 메모리 공간을 차지한다. 현재 44MB밖에 사용하고 있지 않은데, 이 상태가 지속되면 자동으로 할당받은 메모리 공간이 줄어든다.

 

Vitrual Machine (Mono)는 에디터에서 실행하거나, Mono로 빌드한 경우에 사용되는 메모리이다. 유니티 엔진은 C++로 되어있기 때문에 프로그래머가 작성한 C# 코드를 그때그때 C++ 코드로 변환하면서 사용되는 메모리이다. IL2CPP 방식으로 빌드하면 해당 메모리 사용량이 0이 되니 크게 신경쓸 필요는 없다.

 

이외에는 Native Memory나 프로파일러, 유니티에서 사용하는 메모리 등을 보여주는데, 에디터라 전체적인 사용량이 큰 모습이다.

 

현재 화면에서는 대략적인 사용량만 볼 수 있는데, 실제로 자세히 분석을 위해서는 상단에 있는 메뉴들을 클릭해주어야 한다.

 

https://docs.unity3d.com/Packages/com.unity.memoryprofiler@0.7/manual/memory-breakdowns.html

 

Memory Breakdowns | Memory Profiler | 0.7.1-preview.1

Memory Breakdowns The Memory Breakdowns tab displays a broader view of how the memory in your snapshot is distributed. Use this information to identify areas where you can eliminate duplicate memory entries or to find which objects use the most memory. The

docs.unity3d.com

 

Memory Breakdowns는 2022.1 이상의 버전에서 지원하는 기능이다. 여기서는 자동으로 Type별로 묶고, 크기를 내림차순으로 정렬하여 보여주는 것 같다. 유니티에서 분석하여 Potential Duplicates view라는 것도 보여주어 메모리를 조금 더 간편하게 분석할 수 있도록 지원하는 것 같다. 나는 아직 2021 버전에서밖에 사용해보지 않았기 때문에 더 자세한 내용은 위 문서를 참조하길 바란다.

 

Tree Map을 누르면 현재 전체 메모리 사용량을 Tree Map 형식으로 볼 수 있다. 현재 왼쪽 위 사각형인 Texture2D를 눌러서 Texture2D에 대한 자세한 정보가 표시된 모습이다. 현재 어떤 요소가 메모리를 많이 잡아먹고 있는지, 그 안에는 어떤 것들의 사용량이 큰지 눈으로 쉽게 파악할 수 있다.

 

그 다음인 Objects And Allocations는 내가 가장 자주 사용하는 탭으로, 전체 메모리를 한 눈에 살펴볼 수 있다.

 

위에 Data Type, Type, Name, Size 등의 부분을 누르면 해당 내용을 기준으로 Group을 짓거나, 원하는 내용만 표시할 수도 있으며, 크기순으로 정렬할 수도 있다.

 

예를 들어 Type으로 Group짓고, Size를 내림차순으로 정렬하면 이런 식으로 메모리를 많이 차지하고 있는 항목들을 순서대로 볼 수 있다.

 

원하는 내용을 펼쳐보면 내부에서도 내림차순으로 정렬되어 있는데, 기능 구현에 중점을 둔 포트폴리오라 메모리 최적화는 신경을 쓰지 않아서 오디오 클립 하나가 20.6MB를 차지하고, Texture 사이즈도 제멋대로라 압축이 제대로 되지 않은 등의 모습을 확인할 수 있었다. 만약 메모리가 부족하다면 이곳에서 큰 용량을 차지하는 메모리를 줄이는 등의 목표를 세울 수 있다.

 

Compare Snapshots로 2개의 스냅샷을 비교해보면 이런 식으로 나타난다. 게임을 켜고 아무것도 하지 않았음에도 불구하고 전체적인 메모리 사용량이 크게 늘어난 모습이다. 에디터이기 때문에 그렇다. 스냅샷을 찍을 때마다 메모리가 아주 큰 폭으로 늘어나기 때문에 시간이 지날수록 렉이 걸리는 것을 경험할 수 있을 것이다. 모바일 기기에서 테스트를 하는 경우, 특수한 경우가 아니라면 한참이 지난 후에 찍더라도 전체 용량이 1MB도 채 변하지 않는다. 그래서 정확한 분석을 위해서는 타겟 기기에서 스냅샷을 찍으라고 누차 이야기를 하고 있는 것이다.

 

Compare Snapshots에서는 Tree Map은 제공하지 않고, Objects and Allocations를 이용해야 한다.

 

다시 Objects and Allocations로 들어와보면 Diff라는 탭이 하나 더 생긴 것을 확인할 수 있다.

또, 상단의 메모리 사용량을 보면 Total Sizes: 뒤에서 두 스냅샷에서의 메모리 사용량의 차이를 볼 수 있다.

 

Diff를 기준으로 Group지으면 이렇게 Same, Deleted in B, New in B를 나눠서 확인할 수 있다. Deleted in B와 New in B를 중점적으로 살펴보면서 삭제되서는 안 될 것들이 삭제되었다든지, 생성되서는 안 될 것들이 생성되었다든지를 확인하면 된다. 위의 경우, 가만히 있었는데도 105MB가 삭제되고 284.5MB가 생성되었다는 것은 무언가 큰 문제가 있다는 뜻이다. 물론 에디터에서 찍은 스냅샷이라 그럴 것이다.

 

 

아까와 같이 Type별로 추가로 Group짓고, Size를 내림차순으로 정렬해보면 더 쉽게 파악할 수 있다. 위 사진에서 볼 수 있듯이 RenderTexture같은 경우는 똑같은 것들이 삭제되었다가 새로 생긴 것을 볼 수 있는데, 모종의 이유로 파괴되었다가 다시 생성되어 교체된 것이다. 음... 이 경우는 이유는 잘 모르겠다. 아마 화면이 완전히 가려져서 그런 것이 아닐까 생각해본다.

 

실질적으로 어떻게 유용하게 사용할 수 있는지 예시를 들어보자면, 나의 경우에는 비교 작업을 통해서 몬스터를 잡을 때 몬스터의 Material, SkinnedMeshRenderer 등이 매 번 삭제되고 생성되고 있는 것을 확인할 수 있었다. 무언가 이상하다고 느껴 확인해보니 몬스터에 대한 오브젝트 풀링이 제대로 작동하지 않고 있었던 것을 발견하고 수정하였다. 이를 통해서 사냥 중 가비지 컬렉터가 작동하는 주기를 50% ~ 100% 혹은 그 이상 늘릴 수 있었다. (평소에는 2분 주기로 GC가 호출되었는데, 3~4분 정도로 바뀌었고, 몬스터가 위치하는 필드가 작은 등의 일부 경우에는 30분 가량 호출이 되지 않을 때도 있었다)

또, AssetBundle 사용 관련 문제로 인해 Shader가 복제된 것을 확인하였는데, 에셋 번들 빌드 중 Dependencies가 제대로 등록되지 않아 해당 원인이 무엇인지 파악 중에 있다.

추가로, Shader 용량이 예상보다 큰 것을 확인하여 사용하지 않는 Varient를 없애 전체 Shader 용량을 1/4 가량으로 줄이는 작업에 조금이나마 기여를 한 바가 있다.

 

그리고 풀링한 오브젝트들 중에 사용한 지 오래된 리소스들을 정리하는 작업도 하였는데, 이 과정에서 불필요하게 메모리를 차지하고 있는 리소스(AudioClip 등)를 발견하여 정리해주는 작업을 추가로 하기도 하였다.

 

이외에도 따로 한참 방치해두며 스냅샷을 찍어 메모리 누수가 없는 것을 확인하기도 하였다.

 

여태까지 약 2~3주간 사용법을 익히고 위에 언급한 것처럼 내가 생각해 낸 방식대로 사용을 하였는데, 더 유용한 사용법들이 많이 있을 것 같다. 위에 언급한 내용들만으로도 충분히 사용할 가치가 있을 것이라고도 생각한다. 메모리 관리에 어려움이 있다면, 메모리 프로파일러를 사용해보는 것을 적극 권장하는 바이다.

 

마지막으로, ADB의 dumpsys meminfo를 활용하여 기기 내의 전체 용량과 어플리케이션이 사용하는 비중을 확인하는 것도 도움이 될 수 있을 것 같다.

 

C#에서는 가비지 컬렉터가 더이상 참조되지 않는, 즉 사용되지 않는 이른바 쓰레기 메모리, 즉 가비지들을 수거해간다.

 

가비지 컬렉터가 메모리를 자동으로 관리해주긴 하지만, 가비지 컬렉터는 참조가 하나라도 있으면 사용된다고 간주하기 때문에 더이상 사용하지 않는 것들의 참조를 해제함으로써 가비지 컬렉터가 수집해갈 수 있도록 해줄 필요가 있다.

즉, 가비지로 만들어주는 작업이 필요한 셈이다.

 

 

 

스크립트 상에서 사용하고 있는 변수에 null을 넣어서 참조를 없애주는 방법이 있지만, 다른 곳에서 참조하고 있을 수도 있기 때문에 제대로 사라질 것이라는 보장은 없다. 특히 오브젝트의 경우 null을 넣어도 제대로 해제가 되지 않으니 Destroy()를 사용하라고 하는데, 확인해보니 GameObject는 Transform과 상호 참조를 하기 때문에 쓰는 곳도 없는데 메모리에 남아있는 문제가 있었다.

 

 

그림으로 그려보면 대충 이런 느낌일 것 같다.

 

가비지 컬렉터가 쓰지 않는 메모리들을 수거해주긴 하지만, 우리가 참조를 끊음으로써 가비지 컬렉터가 수거해갈 수 있도록 만들어줘야 한다.

 

만약 이런식으로 사용하지 않는데 참조 해제를 제대로 해주지 않는 일이 반복되면 메모리 상에 쓰레기가 계속 쌓이고, 결국 메모리가 부족해지는 때가 올 것이다. 부족해지면 메모리를 늘리다가 한계에 다다르면 어플리케이션이 종료되는 마지막을 맞이하게 된다.

 

이렇게 되지 않기 위해서 우리는 가비지 컬렉터를 믿고 맡길 것이 아니라 메모리의 사용이 끝나면 제대로 해제해줘야 한다.

 

 

추가로, Garbage Collector는 Managed Heap만을 관리해준다. 여기는 프로그래머가 사용하는 각종 Class들이 주로 저장된다.

 

Texture, Mesh, Material 등 Resource들은 Native Memory에서 따로 관리를 해준다. Native Memory는 유니티 내부에서 C++로 관리를 하는 것으로 알고 있는데, GC가 관리하는 영역이 아니기 때문에 Load한 Texture, Mesh 등의 참조를 전부 끊고 GC를 돌려도 수집해가지 않는다. Native Memory에서 Resource들을 해제해주려면 Resources.UnloadUnusedAssets()를 호출해줘야 수거해간다. (씬이 생성될 때 가져온 리소스들은 삭제되지 않는다든가 하는 내용을 봤던 것 같은데, 어디서 봤는지 잘 모르겠다)

 

 

아무튼, Native Memory를 관리해주는 가비지 컬렉터가 따로 있는 셈이다. 그런데, Native Memory는 대체로 용량이 큰데다가 가비지 컬렉터와 같은 점진적 수집 기능이 없어서 그런지 Resources.UnloadUnusedAssets()을 호출하면 게임이 잠깐 뚝하고 끊긴다. 로딩을 할 때라든지, 메모리가 부족할 때(onLowMemory가 호출되었을 때 등) 호출하는 것이 바람직하다.

 

===========================================================================

 

뭔가 그림이 있으면 재밌을 것 같아서 그려봤는데, 나름대로 괜찮게 그려진 것 같다.

최근 회사에서 메모리 관리에 대해서 맡게 되어 메모리 프로파일러를 보는데, 처음에 이런 내용을 모르고 보니까 머리에 내용이 들어오지 않았다. 여러 방면으로 삽질하고 공부하다가 최근 이해가 되어서 나름대로 정리해본 내용이다. 다음엔 아마 메모리 프로파일러 보는 법에 대해서 적지 않을까 싶다. 익숙해지는데 시간이 좀 걸리고, 많이 복잡하지만 메모리 관리에 상당한 도움을 주는 좋은 도구이다.

+ Recent posts