Content Directories 의 파일 이름은 내용 해시다
Esc

    Content Directories 의 파일 이름은 내용 해시다

    –Unity메모리

    콘텐츠 디렉터리를 빌드하고 출력 폴더를 열면 사람이 읽을 수 있는 이름이 하나도 없습니다. c4c280b6….cf 같은 파일이 수십 개 있고, 어느 것이 프리팹이고 어느 것이 셰이더인지 알 수 없습니다.

    이름이 곧 파일 내용의 해시라서 그렇습니다. 에셋 하나가 파일 하나이고, 내용이 같으면 이름도 같습니다. 덕분에 바뀌지 않은 파일은 다시 쓰지 않아도 되고, 그 대신 Player 빌드와 콘텐츠 빌드를 정해진 순서로 이어 줘야 합니다.

    에셋 하나가 해시 이름의 파일 하나가 된다

    이 프로젝트의 출력 폴더에는 네 종류가 있습니다.

    파일 개수 무엇
    <해시>.cf 18 에셋 하나의 직렬화 데이터
    <해시>.resS 5 텍스처나 메시처럼 GPU 로 보낼 큰 데이터
    <해시>.json 1 런타임 매니페스트
    BuildManifestHash.txt 1 지금 쓰는 매니페스트를 가리키는 고정 이름

    대부분의 에셋은 .cf 하나로 끝납니다. 셰이더, 머티리얼, 프리팹이 그렇습니다. 큰 원시 데이터가 있는 오브젝트만 설명 정보는 .cf, 데이터는 .resS 로 나뉩니다. 리플렉션 프로브 큐브맵은 .cf 가 2,568 B 이고 .resS 가 131,232 B 였습니다.

    이 “직렬화 파일 + .resS” 구성은 AssetBundle 안에 든 것과 같은 계열입니다. 다른 것은 번들이라는 아카이브로 묶느냐, 에셋마다 낱개 파일로 두느냐입니다.

    에셋 하나가 파일 하나라서 같은 빌드 안에서는 중복이 생길 자리가 없습니다. 이 프로젝트에서 머티리얼 일곱 개가 같은 셰이더를 쓰는데 Lit.shader 의 .cf 는 하나뿐입니다.

    파일들은 해시 사슬로 이어진다

    해시 이름이라 경로로 찾을 수 없으니, 고정 이름 파일 하나에서 출발해 차례로 따라갑니다. 등록할 때 엔진이 가장 먼저 읽는 것이 BuildManifestHash.txt 입니다.

    Unity 는 이 구조를 git 에 빗댑니다. 고정 이름 파일이 HEAD, 매니페스트가 이름을 해시로 바꾸는 트리, .cf 가 내용 해시로 이름 붙은 블롭입니다. 중요한 것은 파일끼리 해시가 아니라 안정 ID 로 참조한다는 점입니다. 셰이더 B 의 내용이 바뀌어 해시가 바뀌어도, B 를 쓰는 머티리얼 A 의 파일에는 B 의 안정 ID 만 적혀 있어서 A 의 내용과 해시는 그대로입니다. 해시가 바뀌는 것은 바뀐 파일과, 조회표를 새로 적은 매니페스트뿐입니다.

    그래서 바뀌지 않은 파일은 다시 쓰지 않는다

    내용이 같으면 이름이 같으니, 이미 있는 파일은 다시 만들 필요가 없습니다. 같은 Root 를 바꾼 것 없이 두 번 빌드했습니다.

    항목 첫 빌드 바꾼 것 없이 다시
    전체 시간 64,991 ms 8,853 ms
    데이터 빌드 실행 42.3초 0.5초 미만
    스크립트 컴파일 20.9초 7.1초
    다시 쓴 파일 / 재사용한 파일 18 / 0 0 / 18

    데이터 쪽은 거의 0 이 됐고, 남은 시간 대부분은 스크립트 컴파일입니다. 콘텐츠 빌드는 바뀐 것이 없어도 스크립트를 컴파일합니다.

    Player 빌드에는 스트리핑 정보도 넘긴다

    콘텐츠 빌드는 결과 폴더 말고도 Library/BuildHistory/<빌드ID>/ 에 기록을 남깁니다. 대부분은 사람이 보는 리포트인데, ScriptsOnlyCache.yaml 하나는 Player 빌드가 읽어야 하는 입력입니다. 콘텐츠가 쓰는 컴포넌트 타입 목록이 들어 있어서, Player 를 빌드할 때 코드 스트리핑이 그 타입을 지우지 않게 막습니다.

    매뉴얼의 배포 스크립트는 Player 빌드 직전에 두 가지를 합니다. 콘텐츠 폴더를 StreamingAssets 로 복사하고, 이 기록 폴더를 Player 빌드에 넘깁니다.

    public override void PrepareForBuild(BuildPlayerContext context)
    {
        // 콘텐츠 폴더를 임포트 없이 StreamingAssets 로 복사한다
        context.AddAdditionalPathToStreamingAssets("Builds/ContentDirectories");
    
        foreach (string directory in Directory.GetDirectories("Builds/ContentDirectories"))
        {
            // 이 출력 폴더를 만든 콘텐츠 빌드의 기록을 찾는다
            if (BuildHistory.TryGetBuildSummaryForOutputPath(directory, out BuildReportSummary summary) &&
                BuildHistory.TryGetBuildReportDirectory(summary.BuildSessionGUID, out string reportDirectory))
            {
                // ScriptsOnlyCache.yaml 이 든 폴더를 넘긴다. 못 찾으면 아무 말 없이 건너뛴다
                context.AddPreviousBuildReportDirectory(reportDirectory);
            }
        }
    }

    넘기지 않으면 어떻게 되는지, Managed Stripping Level 을 High 로 두고 이 한 가지만 다르게 Player 두 개를 만들어 콘텐츠 씬을 올렸습니다.

    빌드는 둘 다 성공합니다. 빠진 것은 런타임 경고로만 알 수 있습니다.

    직접 만든 Rotator 가 남은 것은 매뉴얼의 규칙대로입니다. Assembly-CSharp 처럼 스크립트 어셈블리에 있는 MonoBehaviour 파생 타입은 링커가 지우지 않고 남깁니다. 그런데 같은 규칙 표는 패키지 어셈블리의 MonoBehaviour 도 남긴다고 적고 있는데, SRP Core 패키지의 FreeCamera 와 ProbeVolume 은 지워졌습니다. 원인은 확인하지 못했습니다. 문서의 규칙 표만 보고 “넘기지 않아도 괜찮다” 고 판단하면 안 된다는 뜻입니다.

    이번에 빠진 두 컴포넌트는 카메라 조작과 Adaptive Probe Volume 베이크 영역 지정용이라 화면에는 차이가 없었습니다. APV 를 켜고 베이크한 뒤 다시 재도 APV 는 정상으로 초기화되고 데이터도 올라왔습니다. 하지만 지워진 것이 게임 동작을 맡은 컴포넌트였다면 그 기능이 조용히 빠졌을 것입니다.

    직접 확인하는 테스트 코드

    스트리핑을 비교하려면 Player 를 두 번 빌드하고, 콘텐츠 씬을 올린 뒤 남은 컴포넌트 타입을 찍습니다. 타입 이름을 코드에 상수 문자열로 적으면 링커가 그 문자열을 보고 타입을 남길 수 있어서, 이름은 씬의 컴포넌트에서 읽습니다.

    // 콘텐츠 씬을 올린 뒤, 카메라에 붙은 컴포넌트 중 비어 있는 것이 있는지 센다
    IEnumerator CheckStripped(ContentPackRootAsset root)
    {
        var op = SceneManager.LoadSceneAsync(root.bonusLevel, new LoadSceneParameters(LoadSceneMode.Additive));
        while (!op.isDone) yield return null;
    
        Scene scene = SceneManager.GetSceneByLoadableSceneId(root.bonusLevel);
        foreach (GameObject go in scene.GetRootGameObjects())
        {
            int missing = 0;
            var names = new List<string>();
            foreach (Component c in go.GetComponentsInChildren<Component>(true))
            {
                if (c == null) missing++;                 // 클래스가 Player 에 없으면 null 로 남는다
                else names.Add(c.GetType().Name);
            }
            Debug.Log($"{go.name}: missing={missing} [{string.Join(", ", names)}]");
        }
    }
    // 비교용 Player 빌드. 하나는 기록 폴더를 넘기고, 하나는 PrepareForBuild 에서 넘기지 않게 한다
    PlayerSettings.SetManagedStrippingLevel(NamedBuildTarget.Standalone, ManagedStrippingLevel.High);
    BuildPipeline.BuildPlayer(new BuildPlayerOptions
    {
        scenes = new[] { "Assets/Scenes/SampleScene.unity" },
        locationPathName = "Builds/Measure/Normal/Game.exe",
        target = BuildTarget.StandaloneWindows64,
        options = BuildOptions.Development,
    });

    넘기지 않은 Player 에서 카메라의 missing 이 1 이상이고 경고가 찍히면 같은 현상입니다.

    주의할 점

    1. 출력 폴더를 Assets 안에 두지 않는다

    안 지키면 생기는 문제. 해시 이름 파일이 수십, 수천 개 생기는데 Assets 안이면 에디터가 전부 임포트합니다. 빌드할 때마다 임포트가 돌아 작업이 느려집니다.

    이렇게 쓴다. 매뉴얼 예제처럼 Builds/ContentDirectories/ 같은 프로젝트 바깥 폴더에 빌드하고, Player 빌드 때 AddAdditionalPathToStreamingAssets 로 복사합니다.

    2. 콘텐츠 빌드와 Player 빌드를 같은 작업 공간에서 순서대로 한다

    안 지키면 생기는 문제. 배포 스크립트는 빌드 기록을 못 찾으면 아무 말 없이 넘기는 일을 건너뜁니다. Library 를 지웠거나, 새로 클론했거나, CI 에서 콘텐츠와 Player 를 다른 작업 공간에서 빌드하면 Player 빌드는 성공하는데 스트리핑 보호가 빠집니다.

    이렇게 쓴다. 한 작업 공간에서 콘텐츠를 먼저 빌드하고 Player 를 빌드합니다. CI 라면 TryGetBuildReportDirectory 가 실패했을 때 빌드를 멈추도록 바꿉니다.

    3. 직접 만든 스크립트가 멀쩡하다고 넘기기를 빼지 않는다

    안 지키면 생기는 문제. 직접 만든 MonoBehaviour 는 넘기지 않아도 남기 때문에 테스트에서는 문제가 안 보입니다. 패키지에서 온 컴포넌트는 콘텐츠에서만 쓰이면 지워질 수 있습니다.

    이렇게 쓴다. 코드 스트리핑을 쓰는 Player 라면 기록 폴더 넘기기를 빠뜨리지 않고, 콘텐츠 씬을 올리는 Player 테스트에서 “missing” 경고를 확인합니다.

    정리

    Content Directories 는 에셋 하나를 내용 해시 이름의 파일 하나로 빌드합니다.

    • 고정 이름 파일 → 매니페스트 → .cf → .resS 로 이어지고, 파일끼리는 안정 ID 로 불러서 바뀐 파일의 해시만 바뀝니다.
    • 바뀌지 않은 파일은 다시 쓰지 않아 재빌드가 64,991 ms 에서 8,853 ms 로 줄었습니다. 남은 대부분은 스크립트 컴파일입니다.
    • Player 빌드에 ScriptsOnlyCache.yaml 이 든 기록 폴더를 넘기지 않으면 콘텐츠에서만 쓰는 패키지 컴포넌트가 빌드 에러 없이 빠집니다.