Content Directories 는 번들을 에셋 단위로 쪼갠다
Esc

    Content Directories 는 번들을 에셋 단위로 쪼갠다

    –Unity메모리

    콘텐츠를 AssetBundle 로 나누면 번들을 어떻게 묶을지 고민이 끝나지 않습니다. 텍스처 하나를 쓰려고 번들 하나와 그 번들이 기대는 번들까지 열고, 공용 셰이더는 번들마다 복사되고, 그걸 막으려고 중복 방지용 번들을 따로 만듭니다.

    Unity 6.6 의 Content Directories 는 번들이라는 묶음을 없애고, 에셋 하나를 파일 하나로 두어 따로 찾고 따로 올리고 따로 내립니다. 번들 하나가 떠맡던 일을 Root, 디렉터리, Loadable 셋으로 나눈 것입니다.

    번들 하나가 세 가지 일을 떠맡고 있었다

    Unity 는 Content Directories 를 소개하는 글에서 AssetBundle 의 문제를 이렇게 짚었습니다. 번들 하나가 배포, 저장, 로드의 나눌 수 없는 단위라는 것입니다. 의존성도 번들 단위로 따라가서, 함께 받고 함께 올리고 함께 내려야 하는 큰 덩어리가 됩니다. 그래서 번들을 어떻게 나누느냐가 로딩 성능과 다운로드 크기를 좌우했고, 이건 AssetBundle 을 직접 쓰든 Addressables 를 쓰든 같았습니다.

    매뉴얼도 같은 이야기를 합니다. 에셋 하나를 로드해도 그 번들과 의존 번들 전체를 재귀로 열어야 하고, 그 에셋이 쓰지 않는 것까지 같이 올라옵니다. 공용 에셋은 번들마다 복사될 수 있고, 그걸 막으려고 중복을 피하는 것 말고는 하는 일이 없는 번들이 생깁니다.

    같은 Cube 프리팹 하나를 꺼낼 때 무엇이 열리는지 두 방식으로 보면 차이가 보입니다.

    포장만 다르고 파일 안의 직렬화 포맷은 같습니다. AssetBundle 안에도 직렬화 파일과 .resS 가 들어 있고, Content Directories 는 그것을 번들로 묶지 않고 에셋마다 낱개 파일로 둡니다.

    셋으로 나눴다: Root, 디렉터리, Loadable

    번들 하나가 하던 “무엇이 들어 있나를 정리하는 일, 파일로 나가는 일, 올리고 내리는 일” 을 셋이 나눠 맡습니다.

    나눈 것 맡는 일 따로 할 수 있는 것
    Root 목차. 콘텐츠로 들어가는 입구 타입으로 Root 를 골라 꺼내기
    디렉터리 빌드, 배포, 등록 따로 빌드하고 Player 에 넣을지 정하기
    Loadable<T> 메모리 에셋 하나를 따로 Load 하고 Release 하기

    Root 는 직접 만든 ScriptableObject 입니다. Player 빌드가 Scene List 에서 출발해 닿는 것을 담듯, 콘텐츠 빌드는 Root 에서 출발해 닿는 것을 전부 담습니다. 런타임에는 ContentLoadManager.GetRootAssets<T>() 로 꺼내는 입구가 됩니다. 파일 이름이 해시라 경로로는 찾을 수 없기 때문입니다.

    public class ContentPackRootAsset : ScriptableObject
    {
        public ContentPackMetadata metadata;     // 일반 참조: Root 와 같이 올라온다
        public Loadable<GameObject> bonusPrefab; // Load() 를 불러야 올라온다
        public LoadableSceneId bonusLevel;       // SceneManager 로 올린다
    }

    디렉터리 는 빌드 한 번의 결과 폴더입니다. 이 폴더를 RegisterContentDirectory 로 등록해야 엔진이 안에 무엇이 있는지 압니다. 같은 빌드 안에서는 같은 에셋이 한 번만 들어갑니다.

    Loadable<T> 는 에셋 하나의 수명 손잡이입니다. 빌드에는 들어가지만 Load() 를 부를 때까지 메모리에 안 올라오고, Release() 하면 그 에셋만 내려갑니다. 문자열 경로 대신 타입이 붙은 참조라서 이름을 바꾸거나 옮겨도 끊기지 않습니다.

    이 프로젝트의 Root 하나로 빌드하면 Root 에서 닿는 전체는 2.85 MB 인데, 등록할 때 바로 올라오는 것은 Root 와 일반 참조인 메타데이터, 스크립트 정보까지 6.93 KB 입니다. 나머지는 Loadable 과 LoadableSceneId 를 넘어갈 때만 올라옵니다.

    기존 Addressables 프로젝트에서 숫자로 확인됐다

    Unity 는 같은 글에서 Addressables 를 쓰던 Slime Rancher 2 를 코드를 바꾸지 않고 Content Directories 로 돌린 결과를 냈습니다. Unity 6.6 베타, MacBook Pro M5 Max 기준입니다.

    항목 AssetBundle Content Directories
    증분 빌드 32분 16초 3분 4초
    클린 빌드 58분 6초 37분 13초
    Player 크기 4 GB 2.88 GB
    부팅에서 게임플레이까지 45초 30초

    다만 매뉴얼은 “클린 빌드는 AssetBundle 보다 느릴 수 있다” 고 적고 있어서, 클린 빌드 숫자는 프로젝트 구성에 따라 달라질 수 있습니다.

    대가도 있습니다. 6.6 은 Player 와 함께 넣는 로컬 콘텐츠만 다룹니다. 원격으로 받아 오는 배포는 아직 없고, Unity 는 이것을 Unity 7 세대에 에셋 단위 다운로드로 넓히겠다고 예고했습니다. 또 Loadable 의 Load 와 Release 짝을 맞추는 일은 쓰는 쪽 몫입니다.

    직접 확인하는 테스트 코드

    Root Asset 하나를 빌드하고 등록해서 꺼내 보는 최소 코드입니다. 위 저장소의 BuildContentPackRootAsset.cs 와 ContentDirectoryManager.cs 를 줄인 것입니다. 에디터에서는 빌드 출력 폴더를, Player 에서는 StreamingAssets 를 등록합니다.

    // 에디터 스크립트: Root 에서 닿는 것을 전부 콘텐츠 디렉터리로 빌드한다
    [MenuItem("Build/Content Directories/Content Pack")]
    public static void Build()
    {
        var parameters = new BuildContentDirectoryParameters
        {
            outputPath = "Builds/ContentDirectories/ContentPack",
            rootAssetPaths = new[] { "Assets/ContentPacks/ContentPackRootAsset.asset" },
            options = BuildContentOptions.None,
        };
        BuildPipeline.BuildContentDirectory(parameters);
    }
    // 런타임: 등록하고, Root 를 꺼내고, Cube 하나만 올렸다 내린다
    public class ContentBootstrap : MonoBehaviour
    {
        ContentDirectoryHandle m_Handle;
        ContentPackRootAsset m_Root;
    
        void Start()
        {
    #if UNITY_EDITOR
            string path = "Builds/ContentDirectories/ContentPack";
    #else
            string path = Application.streamingAssetsPath + "/ContentPack";
    #endif
            m_Handle = ContentLoadManager.RegisterContentDirectory(path);
    
            m_Root = ContentLoadManager.GetRootAssets<ContentPackRootAsset>()[0];
            Debug.Log($"메타데이터: {m_Root.metadata.description}");   // 등록만으로 올라와 있다
            Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}");      // None. 아직 안 올라왔다
    
            GameObject cube = m_Root.bonusPrefab.Load();
            Debug.Log($"Cube 상태: {m_Root.bonusPrefab.Status}");      // Loaded
        }
    
        void OnDestroy()
        {
            m_Root.bonusPrefab.Release();
            ContentLoadManager.UnregisterContentDirectory(m_Handle);
        }
    }

    등록 직후 메타데이터는 읽히고 Cube 는 None 으로 나오면, Root 와 일반 참조만 올라오고 Loadable 은 기다린다는 것이 확인됩니다.

    주의할 점

    1. 메모리를 나누려면 Root 가 아니라 Loadable 로 끊는다

    안 지키면 생기는 문제. Root 를 여러 개로 나누면 정리는 되지만 메모리는 그대로입니다. 디렉터리를 등록하면 그 안의 Root 전부와 그 Root 들이 일반 참조로 쥔 것이 한꺼번에 올라옵니다.

    이렇게 쓴다. 나중에 올려도 되는 것은 Root 의 필드를 Loadable<T> 나 LoadableSceneId 로 바꿉니다. Root 는 목록이 너무 길어져 찾기 어려울 때 나눕니다.

    2. 코드는 콘텐츠에 들어가지 않는다

    안 지키면 생기는 문제. 콘텐츠에 새 컴포넌트나 새 Root 클래스를 넣고 콘텐츠만 다시 빌드하면, Player 에 그 클래스가 없어서 객체를 만들지 못합니다. 콘텐츠 파일에는 클래스 이름표만 들어가고 코드 본체는 늘 Player 의 DLL 에 있습니다.

    이렇게 쓴다. 새 스크립트가 생긴 변경은 Player 도 다시 빌드합니다. 콘텐츠만 갈아 끼우는 변경은 기존 클래스와 필드 안에서만 합니다.

    정리

    Content Directories 는 번들이 한꺼번에 하던 일을 Root, 디렉터리, Loadable 로 나눠 에셋 하나를 단위로 다룹니다.

    • 에셋 하나가 파일 하나라 의존 관계가 파일 → 파일이고, 쓰는 것만 열리고 놓은 것만 내려갑니다.
    • Root 는 입구, 디렉터리는 빌드와 배포 단위, Loadable 은 메모리 단위입니다. 등록하면 Root 와 일반 참조만 올라옵니다.
    • 대신 6.6 은 로컬 콘텐츠 전용이고, Load 와 Release 의 짝은 쓰는 쪽이 맞춥니다.