[영상필기] [Unite Seoul 2025] Addressables을 사용한 최신 모범 사례
https://www.youtube.com/watch?v=UgOdwds7udc
발표 PPT : https://on.unitysquare.co.kr/4kFgTgg
새로 알게된 사실 요약
- 하드웨어가 완전히 동일한 사양이 아니라면 에셋번들은 빌드하는 PC마다 해시가 달라질 수 있다.
- 모바일 게임에서 흔히 사용되는 필수번들만 다운받기, 전체번들 다운받기는 Label을 통해서 구현하면된다.
에셋 번들의 결정성
- 에셋 번들에서 비결정성을 줄이는 것이 중요하다. 그렇다면 에셋번들의 결정성은 무엇인가?
- 같은 에셋이 에셋번들에 포함되면, 언제나 같은 에셋 번들 바이너리를 내놓게 된다.
- 왜 이게 중요한가? 캐시된 에셋번들이 최신이 아니라서 다운로드 할 시점을 파악해야한다.
- 빌드과정이 비 결정적이라면 에셋번들이 변하지도 않았는데 유저는 다시 다운을 받아야한다.
따라서 동일한 에셋이라면 어떤 머신에서 빌드하던지 같은 바이너리 결과가 나오는게 에셋번들의 결정성이다.
하지만 비 결정적인 경우가 발생한다. 예외사항을 살펴보자.
1. 유니티 엔진의 버그 (예를들어 애니메이션 컨트롤러가 포함된 번들을 빌드할 때 다른 아키텍쳐를 가진 머신 에서 빌드하면 서로 다른 결과의 바이너리가 도출된다)
2. 하드웨어 아키텍쳐가 다르면 부동소수점 연산이 차이가 있어서 이 또한 비결정성으로 이어진다.
3. 텍스쳐 압축도 하드웨어에 따라 달라질 수 있어서 결정성을 보장받지 못한다.
4. 맥과 윈도우는 줄바꿈을 다르게 처리하기 때문에 이 또한 문제다.
5. 에셋 포스트 프로세스 등으로 임포트 과정에서 개입하는 경우도 비결정성이 생길 수 있다.
그렇다면 이러한 비결정성을 어떻게 줄일 수 있을까?
게임의 릴리스 빌드를 만들 때는 특정 머신을 지정해서 사용하자.
빌드 머신이 여러대라면 하드웨어 사양이 최대한 비슷해야한다.
에셋 번들 결정성 디버깅하기
Build LayoutReport 를 통해 빌드 상세과정을 확인할 수 있으니 이걸로 디버깅 하기 바란다.
에셋번들에는 Hash 값이 있는데, 이 값이 바뀌었다면 에셋 내용이 바뀌었음을 알 수 있다.
만약 Hash가 바뀌었다면 에셋 디펜던시에서 어떤 에셋의 Hash가 바뀌어서 변경을 유발시켰는지 디버깅 해보자.
BuildLayoutReport의 API를 사용해서 이를 분석하는데 활용할 수 있다.
private void CompareBuildLayouts(string buildLayoutPathA, string buildLayoutPathB)
{
var buildLayoutA = BuildLayout.Open(buildLayoutPathA, readFullFile: true);
var buildLayoutB = BuildLayout.Open(buildLayoutPathB, readFullFile: true);
foreach (var bundleA in BuildLayoutHelpers.EnumerateBundles(buildLayoutA))
{
foreach (var bundleB in BuildLayoutHelpers.EnumerateBundles(buildLayoutB))
{
if (bundleA.InternalName != bundleB.InternalName)
continue;
if (bundleA.Hash != bundleB.Hash)
Debug.LogWarning($"Asset Bundle Hash changed: {bundleA.Name}");
}
}
}
어드레서블 Hash의 계산방법
- 압축되지 않은 값을 기준으로 해시를 생성한다. 그러면 압축방법이 달라도 해시가 같아지기 때문이다.
- 해시 계산에서 헤더는 제외된다. 유니티의 다른 버전에서 빌드할때 값이 달라지지 않게 하기 위해서다.
- 하지만 헤더가 계산에서 제외되기 때문에 어떤 경우에는 헤더가 바뀌면 아카이브 컨텐츠가 바뀌지만 해시에서는 반영되지 않을 수 있다.
- 에셋번들 해시는 에셋번들의 카탈로그파일에 저장된다.
- 런타임중에 카탈로그에서 에셋번들 해시를 읽어들어서 이미 캐시된 번들을 사용할지 다운로드할지 결정한다.
Bundle Naming Mode
에셋번들 그룹 설정에서 Bundle Naming Mode에 대해서 질문이 많이 들어온다고 한다.
Append Hash , No Hash 가 권장사항이다. 나머지 두개는 디버깅이 어렵다.
AppendHash 의 장점은 디버깅이 쉽다는 것이다.
번들이 바뀐 것을 명시적으로 알 수 있고, 어떤 번들이 로드되어있는지 파악하기 쉽다.
같은 버전의 번들(or 다른플랫폼)이 같은 경로에 저장될 수 있는 장점도 있다.
AppendHash의 단점으로는
- 파일을 덮어쓰지 않기 때문에 점점 쓰레기 번들이 늘어난다.
- 작은 변경이 있는 경우 콘솔에서 파일 수준 패치를 방지되버린다. 작은 변경사항을 패치를 통해서 적용할 수 있는데, 파일이름 자체가 바뀌어버리다보니 전체를 다시 받아야한다.
Remote Catalog Namig
- 카탈로그는 에셋번들의 가용성, 종속성, 위치를 알려준다.
- 빌드에 포함되는 로컬 카탈로그, 서버에 올라가는 리모트 카탈로그로 구분된다.
- 리모트 카탈로그는 런타임시에 서버에서 다운받아서 사용한다.
- 플레이어 빌드에는 리모트 카탈로그의 URL이 포함되어 이를 다운로드하고, 이 카탈로그를 보고 번들을 받아서 캐싱한다.
Player Version Override
- 플레이어 버전 오버라이드 속성을 수정하면 카탈로그의 이름을 제어할 수 있다.
저기에 값을 입력하면 아래와 같이 catalog_XXXX.json 형식으로 파일이 생성된다.
기본값으로는 번들버전으로 지정되어있다.
1) Timestamp를 사용하는 경우
디버깅이 편하다.
2) BundleVersion (디폴트설정) : 플레이어 버전을 사용하는 경우
- 다른 플레이어 버전에서 같은 번들을 사용하지 못하는 문제가 있다.
3) Constant String : 고정 문자열 방식
- 호환되지 않을때 수동으로 문자열을 수정한다.
- 수동이기 때문에 실수하기 쉽다.
리모트 카탈로그 로드하기
리모트 카탈로그를 사용하도록 설정한 경우 자동으로 다운받는게 기본설정이다.
하지만 LoadContentCatalogAsync 를 사용하여 수동으로 받아올 수 있다.
컨텐츠 전용 프로젝트 분리하기
규모가 큰 프로젝트의 경우 프로젝트를 여러개로 분리해서 카탈로그를 만드는 워크플로우를 사용하기도 한다.
- 당연히 이런 프로세스를 만드는 것은 어렵고 도전적이다.
- 스크립트를 공유해야하는 경우 패키지 형태로 디펜던시를 관리해야한다.
라벨을 사용하여 선택적으로 로드하기
플레이 중 추가 컨텐츠를 다운받을지 선택지가 주어지는 경우가 있다.
먼저 Prologue 라벨을 다운받고서 프롤로그를 플레이 하는 동안에 Essential 라벨을 백그라운드에서 다운받는 식으로 진행한다.
또한 라벨은 동일한 용도의 에셋의 다른 버전을 로드하는데도 사용된다.
저사양 폰에서는 저화질 에셋은 다운로드 하겠다 하면 라벨을 구분지어서 분기 로딩할 수 있다.
미지원 CPU에서는 특정 쉐이더를 로드안하게 할 수도 있다.
Load Path brancing
어드레서블의 로드를 분리하기 위한 방법은 2가지가 있다.
1. 어드레서블 프로파일을 사용한다.
- 프로파일로 개발용, QA용 인지에 따라 나눌 수 있다.
- 단점은 엔드포인트가 바뀌었을 때, CND 서버가 바뀌었을 때 카탈로그를 재생성하려면 빌드를 다시 해야한다.
- 여러 조합이 생기면 관리할 프로필 갯수가 급격하게 늘어날 수 있다.
2. 어드레서블 프로파일에 Remote Path 를 분기시켜서 로드 주소를 바꾼다.
- 1의 단점을 해결하기 위해 RemotePath에 동적인 값을 넣을 수 있다.
- { } 를 넣어서 동적변수로 프로퍼티를 지정하면 런타임 중에 동적으로 해석된다.
- 여기에 넣을 수 있는 건 런타임 클래스의 정의된 정적 속성이여야한다. (네임스페이스 포함해서 적어야한다.)
- 아쉽게도 게임이 시작될 때 어드레서블 초기화 단계에서 한 번만 초기화되고, 런타임 중에 바꿀수는 없다.
3. InternalIdTransformFunc 을 이용한다.
- 이는 델리게이션 방법으로 유저가 커스텀 메서드를 명시하도록 한다.
- 어드레서블이 불러오는 URL을 개별적으로 수정할 수 있고, 초기화 단계가 끝났어도 동적으로 수정할 수도있다.
- 주의할 점은 델리게이션 메서드를 로딩전에 할당해야한다. 그렇지 않았다면 디폴트 URL이 사용된다.
void UsingInternalIdTransformFuncSample()
{
Addressables.InternalIdTransformFunc = MyCustomTransform;
opHandle = Addressables.InstantiateAsync(asset);
opHandle.Completed += OnInstantiateComplete;
}
// Implement a method to transform the internal ids of locations
static string MyCustomTransform(IResourceLocation location)
{
if (location.ResourceType == typeof(IAssetBundleResource)
&& !location.InternalId.StartsWith("http"))
{
Debug.Log($"Replace local identifier with remote URL : {location.InternalId}");
return "file:///" + location.InternalId;
}
return location.InternalId;
}
4. WebRequestOverride 속성을 이용한다.
- 3번에서 인증이 필요한 경우에는 어떻게 할까? 직접 웹 요청을 조작하면된다.
- 요청 자체에 헤더 필드를 추가할 수도있다.
private void Start()
{
Addressables.WebRequestOverride = AddPrivateToken;
}
// Demonstrate adding an Authorization header to access a Cloud Content Delivery private bucket
private void AddPrivateToken(UnityWebRequest request)
{
var encodedToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{bucketAccessToken}"));
request.SetRequestHeader("Authorization", $"Bearer {encodedToken}");
}
Remapper 와 어드레서블
- Remapper는 디바이스에 저장된 물리적 시리얼라이즈 된 아이디를 IO로 읽어들인 후에 런타임 환경에서 실제 오브젝트로의 참조로 연동해주는 컴포넌트이다.
- 해시맵을 사용한다.
- 문제는 해시맵이 꽉찬 상태로 추가하려고 하면 용량이 두배(기본 16MB > 32MB)로 증가한다.
메모리 급증은 로드된 번들의 안에 포함된 오브젝트 수와 관련있다. 사용되지 않아도 엔트리에 기여하기 때문이다.
종속성도 조심해야한다. 작은 번들하나를 로드했는데, 그 번들과 디펜더시가 걸려있는 번들이 크기가 큰 경우도 있다.
이를 어떻게 해결할 것인가?
Concurrent Content Grouping
- 에셋 번들을 구성할 때 사용되지 않는 에셋이 포함되는 것을 최소화 하는 것을 말한다.
- 즉, 같은 시점에 사용되는 에셋만 같은 번들로 묶는다.
그런데 언제나 이게 가능한 것이 아니기 때문에 트레이드 오프가 필요하다.
메모리 프로파일러를 사용하여 어떤 영향을 미치는지 확인하기 바란다.
AssetReference, AssetLabelReference
- 고정된 string으로 에셋을 로드하는 경우 휴먼에러에 취약하다.
- 에셋레퍼런스를 인스펙터에 할당하고 이를 사용하면 된다. 다만 타입을 제한하지 않기 때문에 주의 필요하다.
- 에셋라벨레퍼런스도 동일하게 사용가능하다.
- AssetReferenceGameObject 등 미리 특수타입으로 제한한 클래스로 만들어진 경우에는 그걸 사용하고, 없는 경우에는 직접 만들 수 있다. 예를들어 AudioClip은 미리 생성된 클래스가 없기 때문에 아래와 같이 만들 수 있다.
[System.Serializable]
public class AssetReferenceAudioClip : AssetReferenceT<AudioClip>
{
public AssetReferenceAudioClip(string guid) : base(guid) {}
}