본문으로 건너뛰기
HY Devlog
뒤로 가기

Unload(false)는 인스턴스를 내리지 않는다

2025 유니티 컨퍼런스에서 나온 이야기를 찾아보던 중 스크랩한 글이다. Unite Seoul 2025의 최적화 세션을 보면서 적은 영상 필기다.

이 클리핑은 지금까지 다룬 것들과 형식이 다르다. 공식 문서도 블로그 글도 아니고, 발표를 들으면서 받아 적은 메모다. 그래서 읽는 방법도 달라야 한다. 문장 하나하나가 완성된 설명이 아니라 “이걸 확인해봐라”는 목록이다. 셰이더 배리언트부터 오버드로우까지 스무 항목쯤이 들어 있다.

그래서 그렇게 읽었다. 항목을 하나씩 문서와 대조했다. 대부분 맞는다. 받아 적은 메모치고 정확도가 높고, 심지어 Unity 공식 블로그 링크를 세 개 걸어둔다.

걸린 건 다섯이다. 그중 하나는 방향이 반대로 적혀 있어서 그대로 따라가면 반대 결과가 나온다.

AssetBundle.Unload(false); 로 에셋 번들을 내리면 관련된 인스턴스가 같이 내려가기 때문에 Custom AssetBundle Provider 를 만들어서 제어하는 것도 가능하다.

false를 넘기면 인스턴스가 같이 내려간다고 적혀 있다. 문서는 그 반대를 적는다.

목차

목차

Unload(false)는 인스턴스를 내리지 않는다

AssetBundle.Unload(bool unloadAllLoadedObjects)는 인자 하나로 동작이 갈린다. 문서가 두 경우를 따로 적는다.

false — tracking data structures and any memory buffers holding content of the AssetBundle are freed, but any instances of objects loaded from the bundle remain intact.

true — all objects that were loaded from the bundle are destroyed. If any GameObjects in a Scene reference the destroyed assets, these references become missing.

false는 인스턴스를 남기고, true가 파괴한다. 필기는 false 쪽에 파괴하는 동작을 붙여 적었다. 두 경우를 맞바꿔 쓴 것으로 보인다.

이 인자를 거꾸로 알고 있으면 증상이 두 방향으로 나온다.

의도쓴 인자실제 결과
메모리를 비우려고Unload(false)압축 버퍼만 빠지고 로드한 오브젝트는 그대로 남는다
참조를 유지하려고Unload(true)씬이 참조하던 에셋이 Missing으로 깨진다

앞쪽은 “번들을 내렸는데 메모리가 안 줄어든다”가 되고, 뒤쪽은 “머티리얼이 분홍색이 됐다”가 된다. 둘 다 원인을 엉뚱한 데서 찾게 되는 증상이라 비용이 크다.

두 경우에 공통인 것도 문서가 적어둔다.

no more objects can be loaded from from the bundle unless it is reloaded.

(원문의 from from은 문서 쪽 오타다.) 어느 쪽이든 그 번들에서 새로 로드하는 건 끝난다. false가 “가볍게 내리는 것”이 아니라, 로드 경로를 닫고 압축 데이터만 회수하는 동작이다.

필기가 이 항목에 걸어둔 Unity 블로그 링크는 Addressables로 메모리를 줄이는 글이고, 거기서 다루는 PersistentManager.Remapper와 SerializedFile도 필기에 그대로 적혀 있다. 그 두 영역이 번들 개수에 따라 커진다는 설명과 **“최소한으로 필요한 번들들을 로드해야 한다”**는 결론은 맞다. Addressables를 다룬 글에서 본 로드 단위 이야기와 같은 자리다.

0이 비활성화를 뜻하지 않는 자리 둘

필기에서 숫자 0을 “꺼짐”으로 읽은 데가 두 곳 있다. 둘 다 문서는 다른 뜻으로 적는다.

셰이더 청크 개수

Dynamic Shader Loading 항목이다.

기본값은 청크갯수가 0으로 설정(비활성화)되어 있다.

청크 갯수를 늘리고 청크 사이즈는 작은 값부터 시작해서 테스트 해보길 권장한다.

문서의 설명은 이렇다.

Use Default chunk count to limit how many decompressed chunks Unity keeps in memory. The default is 0, which means there’s no limit.

스크립팅 API 쪽은 더 직접적이다.

The default value is 0, which means Unity loads and decompresses all the chunks into memory.

0은 비활성화가 아니라 상한 없음이다. 그리고 비활성화된 것이 “기능”도 아니다. 청크로 나누는 동작은 돌아가고 있고, 메모리에 몇 개까지 들고 있을지의 상한만 안 걸린 상태다.

그래서 “청크 개수를 늘려라”는 조언도 방향이 뒤집힌다. 0에서 올리는 건 상한을 새로 거는 것이고, 숫자를 키우면 상한이 느슨해진다. 메모리를 줄이려면 작은 값부터 넣어봐야 한다 — 필기가 청크 사이즈에 대해 적은 그 조언이 개수 쪽에도 해당한다.

0이 “끄기”처럼 보이는데 실제로는 제한 없음인 구조는 전에도 봤다. Docker 자원 제한을 다룬 글에서 --memory-swap=0이 스왑을 끄지 않고 지정하지 않은 것으로 취급되던 자리와 같은 모양이다. 상한 값에서 0은 보통 “없음”이고, “없음”은 “0”이 아니라 “무제한”이다.

레이어별 그림자 컬링 거리

컬링 항목에 API 두 개가 이름만 적혀 있다.

  • Camera.layerCullDistances,
  • Light.layerShadowCullDistances,

둘 다 실재하는 API이고 용도도 맞다. 다만 Light.layerShadowCullDistances에는 문서가 조건을 세 개 달아둔다.

조건문서
광원 종류“Directional lights only.”
배열 길이float 배열 정확히 32개를 넣어야 한다
값 0의 뜻해당 레이어의 현재 동작을 유지한다 (끄는 게 아니다)

여기서도 0이 “끔”이 아니다. 전체를 끄려면 null을 넣는다 — 문서가 그걸 32개의 0을 넣은 것과 같다고 적는다. 즉 **0은 “이 레이어는 건드리지 않음”**이다.

둘을 같이 쓸 때의 규칙도 적혀 있다. 같은 레이어에 둘 다 값이 있으면 둘 중 더 작은 거리가 그림자 컬링 거리가 된다. 카메라 쪽만 줄여놓고 그림자가 안 줄어든다 싶으면 이쪽을 보면 된다.

Update When Offscreen은 기본이 꺼져 있다

스키닝 항목이다.

Skinned Mesh Renderer > UpdateWhenOffscreen 이 기본으로 켜져있는데, 비활성화 하면 안보일때 메쉬를 업데이트 하지 않는다.

매뉴얼의 인스펙터 레퍼런스는 반대로 적는다.

Update When Offscreen — Enable this option to calculate the bounding volume at every frame, even when the mesh is not visible by any Camera.

The property is disabled by default, for performance reasons.

기본이 꺼짐이고, 그 이유가 성능이다. 이미 최적화된 기본값이라 끄러 갈 것이 없다.

그러면 이 칸을 켜야 하는 경우는 언제인가. 문서의 Bounds 설명이 답한다. Unity는 메시 임포트 시점에 바운드를 미리 계산해두고, 이 옵션을 켜면 매 프레임 다시 계산해서 그 값을 덮어쓴다. 본이 크게 움직여서 미리 계산된 바운드를 벗어나 보여야 할 메시가 사라지는 증상이 있을 때 켜는 칸이다.

즉 성능 항목이 아니라 정확도 항목이고, 기본값이 이미 성능 쪽에 서 있다. 필기의 결론(“비활성화하면 업데이트하지 않는다”)은 동작 설명으로는 맞지만, 할 일이 없는 항목을 할 일로 적었다.

본 수 제한은 문서가 임포트 쪽을 권한다

같은 항목의 앞 문장이다.

Quality Settings > Skin weight or SkinnedMeshRenderer > Quality 에서 최대 영향을 주는 본 갯수를 제한 해서 성능을 올릴 수 있다.

경로와 효과가 맞다. 선택지도 문서와 맞는다.

설정뜻
AutoQuality Settings > Skin Weights의 전역 제한을 쓴다
1 Bone / 2 Bones / 4 Bones정점당 본 수의 런타임 상한

빠진 건 문서가 그 바로 아래에 붙여둔 권고다.

for performance reasons, it’s better to set the number of bones that affect a vertex on import, rather than using a runtime cap.

런타임 상한보다 임포트 설정이 먼저라는 것이다. 런타임 캡은 이미 들어온 데이터를 매 프레임 깎는 쪽이고, 임포트 설정은 데이터 자체를 줄인다. 그리고 한 가지 더 — 정점당 네 개를 넘는 본 영향이 필요하면 컴포넌트에서는 지정할 수 없고 Auto로 두어야 한다.

배리언트는 2의 거듭제곱이 아니라 집합의 곱이다

셰이더 배리언트 항목의 첫 줄이다.

쉐이더에 키워드가 추가될 때 마다 2의 지수형태로 증가한다.

키워드가 켜진 쉐이더, 안켜진 쉐이더를 각각 경우의 수로 빌드를 해야되니 배리언트가 증가하는 것

두 번째 문장이 첫 문장의 조건을 말해준다. 켜짐/꺼짐 두 가지인 키워드라면 2의 거듭제곱이 맞다. 문서가 적는 일반 규칙은 그보다 넓다.

The number of shader variants that Unity compiles for a shader program is the product of the keyword sets; that is to say, Unity compiles one variant for every combination that includes one element from each set.

집합의 곱이다. 한 집합에 상호배타적인 키워드를 셋 넣으면 그 집합은 ×3(또는 암묵적 “없음”까지 ×4)이 되고, 2의 거듭제곱에서 벗어난다. 실무에서 에셋스토어 셰이더를 열었을 때 숫자가 예상보다 큰 이유가 거기 있다 — 필기가 “에셋스토어에서 구매한 쉐이더의 경우 주의할 필요가 있다”고 적은 그 자리다.

차수를 보려면 집합으로 세야 한다.

키워드 구성배리언트 수
토글 10개2¹⁰ = 1,024
3개짜리 집합 8개3⁸ = 6,561
토글 4개 + 4개짜리 집합 2개2⁴ × 4² = 256

multi_compile이 주 원인이라는 필기의 지적도 맞다. 커스텀 셰이더를 다룬 글에서 shader_feature와 갈리는 한 문장을 봤다 — shader_feature는 빌드의 머티리얼이 실제로 쓰는 조합만 컴파일하고 나머지를 제거하지만, multi_compile은 쓰든 말든 컴파일한다. **런타임에 코드로 바꿀 옵션이 아니면 shader_feature**가 기본값이어야 한다.

FixedUpdate 횟수를 묶는 건 TimeStep이 아니다

필기의 진단은 정확하다.

기본적으로 FixedTimeUpdate는 이전 프레임에서 렉이 발생했을때 현재 프레임에서 보상하기 위해 여러번 발생하게 된다.

처방이 둘인데, 둘 다 에둘러 간다.

따라서 TimeStep을 Update와 동일하게 맞춰주거나, (기본값은 FixedTimeStep이 0.02라서 50프레임 기준이다) 스크립트로 수동호출하는 방식을 고려할 수 있다.

0.02초가 50Hz라는 건 맞다. 그런데 고정 타임스텝을 가변인 Update에 “동일하게 맞춘다”는 것은 성립하지 않는다. Update는 프레임마다 간격이 달라지고 FixedUpdate는 고정이다. 값을 키우면 물리 스텝이 드물어지는 효과가 있을 뿐이다.

횟수를 직접 묶는 설정이 따로 있다. Time.maximumDeltaTime이다.

The maximum value of Time.deltaTime in any given frame. This is a time in seconds that limits the increase of Time.time between two frames.

Bounds the maximum number of times Unity executes MonoBehaviour.FixedUpdate in a frame to Time.maximumDeltaTime / Time.fixedDeltaTime.

한 프레임의 FixedUpdate 호출 수 상한이 이 둘의 비로 정해진다. 프로젝트 세팅에서는 Project Settings > Time > Maximum Allowed Timestep이고, 문서는 이 칸을 “프레임 레이트가 낮을 때 최악의 경우를 제한하는 간격”이라고 설명한다.

제약도 하나 있다.

maximumDeltaTime cannot be set lower than Time.fixedDeltaTime.

fixedDeltaTime보다 작게는 못 내려간다. 그래서 이 비의 최솟값이 1이고, “한 프레임에 FixedUpdate 한 번”이 내릴 수 있는 바닥이다.

세 값의 관계를 정리하면 이렇다.

설정인스펙터바꾸면
Time.fixedDeltaTimeFixed Timestep물리 스텝의 간격이 바뀐다
Time.maximumDeltaTimeMaximum Allowed Timestep한 프레임의 호출 수 상한이 바뀐다
Physics.simulationModePhysics > Simulation Mode누가 호출하는지가 바뀐다

필기가 점검 항목으로 적은 FixedTimeStep과 Simulation Mode는 각각 첫 줄과 셋째 줄이고, 가운데 줄이 빠져 있다. 히칭 뒤에 물리가 몰아서 도는 증상을 직접 막는 건 가운데 줄이다.

레거시 Animation은 문서가 쓰지 말라고 한다

애니메이터 항목에 이런 조언이 있다.

애니메이터자체로도 부하가 있기 때문에 작은 클립 (애니메이션 커브가 400 이하)만 재생할 꺼라면 애니메이터 보다는 레거시Animation 컴포넌트를 사용하는게 성능적으로 이득이다.

성능 수치는 발표자의 측정이라 내가 검증할 수 없다. 커브 400개라는 기준도 문서에 없는 숫자다. 다만 그 컴포넌트에 대해 문서가 하는 말은 확인할 수 있다.

This is the Legacy Animation component, which was used on GameObjects for animation purposes prior to the introduction of the Mecanim Animation system.

This component is retained in Unity for backwards compatibility. For new projects, use the Animator component.

문서는 새 프로젝트에서 쓰지 말라고 한다. 발표의 조언과 문서의 안내가 정면으로 갈린다.

둘 다 틀리지 않았을 수 있다. 성능만 보면 발표가 맞고, 유지보수와 지원을 보면 문서가 맞다. 그래서 이 항목은 **“어느 쪽이 맞나”가 아니라 “무엇을 포기하는지 알고 고르라”**가 결론이다. 필기만 읽으면 뒤쪽을 모른 채 고르게 된다.

같은 항목의 다른 문장들은 문서와 충돌하지 않는다. 애니메이터가 잡 시스템으로 도는 것, 특정 이벤트가 메인 스레드로 강제하는 것, Playable로 그룹을 묶어 Animator.Update를 직접 돌리는 것 — 전부 실재하는 경로다. CrossFade를 다룬 글에서 본 전이 처리도 그 애니메이터 쪽 비용 안에 있다.

대조하고 넘어간 것들

걸리지 않은 항목이 훨씬 많다. 확인한 것들을 문서 문장과 함께 남겨둔다.

필기의 주장확인
레이캐스트 기본 distance가 무한대맞음. maxDistance 기본값이 Mathf.Infinity
레이어마스크로 대상을 줄여라맞음. 단 기본값은 전체가 아니라 DefaultRaycastLayers다
Object.InstantiateAsync로 비동기 처리 가능맞음. “the last stage involving integration and awake calls is executed on the main thread”
GarbageCollector.GCMode로 GC 비활성화 가능맞음. “Continuous allocations after disabling the garbage collector will result in a continuous increase in memory usage”
Object.name 같은 복사본 반환 멤버 주의맞음
UI는 동적·정적 요소를 분리맞음
투명 영역이 제거된 메시로 오버드로우 최적화맞음

두 줄은 한 겹 더 붙여둘 값이 있다.

레이어마스크의 기본값은 “전부”가 아니다. DefaultRaycastLayers이고, 여기엔 빌트인 Ignore Raycast 레이어가 빠져 있다. 줄이라는 조언은 맞지만, 출발점이 이미 전체가 아니다. 구체적인 사용법은 OverlapSphere를 다룬 글에 있다.

Object.name 항목은 이 블로그에서 한 번 파본 자리다. 오브젝트 풀링을 다룬 글에서 GameObject.name이 [FreeFunction] 외부 호출이라 접근할 때마다 네이티브 문자열을 관리 측 문자열로 새로 만든다는 것까지 확인했는데, 그게 공식 문서에는 할당으로 적혀 있지 않다. 필기가 “복사본 반환 멤버”라고 묶어둔 그 성질이다.

GC 비활성화는 모드가 둘이 아니라 셋이다. 필기는 Disabled만 언급하는데 Manual이 따로 있다 — 자동 호출만 끄고 수동 수집은 남긴다. 문서의 권고도 구체적이다: 수명이 긴 할당에만 쓰고, 새 콘텐츠를 로드하기 전에 되돌려 수동으로 회수하라는 것이다. 필기의 “추천하긴 어려움”보다 조건부로 쓸 자리가 적혀 있는 쪽이다.

IL2CPP 스트리핑 항목도 한 줄 보탤 데가 있다. 필기는 “많은 개발사들이 이 옵션을 올렸다가 실제 사용하는 코드가 삭제되버려서 사용하지 않는 경향이 있다”고 하는데, IL2CPP에서는 끌 수가 없다.

레벨문서 설명
Disabled“Unity doesn’t remove any code.” Mono 전용이고 Mono의 기본값
MinimalUnityEngine과 .NET 클래스 라이브러리만 훑는다. IL2CPP의 기본값
Low위에 더해 사용자 어셈블리도 훑되, 씬에서 참조되지 않는 경우에만
Medium전 어셈블리를 부분적으로 훑고, 규칙을 적용해 더 많은 패턴을 제거한다
High전 어셈블리를 광범위하게 훑는다. “prioritizes size reduction more than code stability”

IL2CPP의 바닥이 Minimal이다. 그래서 “안 쓰는 경향”의 실제 상태는 끈 게 아니라 기본값에 머문 것이고, 필기가 권하는 Medium은 두 칸 위다. link.xml로 보존 범위를 지정하는 방법은 Newtonsoft Json 설치를 다룬 글에서 다뤘다.

UGUI 항목의 Canvas.cullTransparentMesh는 이름이 조금 다르다. 실제로는 CanvasRenderer.cullTransparentMesh이고 — 필기도 “캔버스 렌더러에는”이라고 본문에 적어뒀다 — 설명은 이렇다.

Indicates whether geometry emitted by this renderer can be ignored when the vertex color alpha is close to zero for every vertex of the mesh.

버전별 기본값 변경은 문서에 적혀 있지 않다. 마이그레이션한 프로젝트라면 확인해보라는 필기의 조언은 유효하지만, 근거를 문서에서 찾을 수는 없었다.

어디에 왜 쓰나

프로젝트의 실제 값을 찍어본다

이 필기의 쓸모는 항목마다 내 프로젝트의 현재 값을 확인하게 만드는 데 있다. 그중 숫자로 나오는 것들은 코드로 한 번에 찍을 수 있다.

using UnityEngine;

public class OptimizationSettingsReport : MonoBehaviour
{
    private const int FIXED_UPDATE_WARN = 4;

    [Header("Report")]
    [SerializeField, Tooltip("씬 시작 시 설정값을 로그로 남긴다")]
    private bool _reportOnStart = true;

    private void Start()
    {
        if (!_reportOnStart)
        {
            return;
        }

        ReportTime();
        ReportSkinning();
    }

    private static void ReportTime()
    {
        float fixedStep = Time.fixedDeltaTime;
        float maxStep = Time.maximumDeltaTime;

        // 문서가 적은 상한 식: maximumDeltaTime / fixedDeltaTime
        int maxCalls = Mathf.FloorToInt(maxStep / fixedStep);

        Debug.Log($"fixedDeltaTime {fixedStep:F4}s ({1f / fixedStep:F0}Hz)");
        Debug.Log($"maximumDeltaTime {maxStep:F4}s");
        Debug.Log($"frame당 FixedUpdate 상한 {maxCalls}회");

        if (maxCalls >= FIXED_UPDATE_WARN)
        {
            Debug.LogWarning($"히칭 뒤 물리가 한 프레임에 최대 {maxCalls}번 몰린다");
        }
    }

    private static void ReportSkinning()
    {
        // 전역 본 수 상한. 컴포넌트의 Quality가 Auto면 이 값을 쓴다.
        Debug.Log($"QualitySettings.skinWeights {QualitySettings.skinWeights}");

        SkinnedMeshRenderer[] renderers =
            FindObjectsByType<SkinnedMeshRenderer>(FindObjectsSortMode.None);

        int offscreenUpdating = 0;

        foreach (SkinnedMeshRenderer renderer in renderers)
        {
            // 기본값은 false다. true면 누가 켠 것이다.
            if (renderer.updateWhenOffscreen)
            {
                offscreenUpdating++;
                Debug.Log($"updateWhenOffscreen on {renderer.name}", renderer);
            }
        }

        Debug.Log($"SkinnedMeshRenderer {renderers.Length}개 중 {offscreenUpdating}개가 화면 밖에서도 갱신된다");
    }
}

updateWhenOffscreen을 세는 방향이 중요하다. 기본값이 꺼짐이므로 켜져 있는 쪽이 예외고, 예외에는 이유가 있어야 한다. 필기대로 “기본이 켜짐”이라고 알고 있으면 전부 끄러 다니게 되는데, 실제로는 켠 사람을 찾는 작업이다.

FindObjectsByType은 Unity 6의 이름이다. 구버전의 FindObjectsOfType은 사용이 권장되지 않는다.

번들을 내리는 코드를 한 군데로 모은다

Unload의 인자는 거꾸로 알면 증상이 엉뚱한 데서 나온다. 호출을 흩어놓지 않고 의도가 이름에 드러나는 함수로 감싸두면, 적어도 어느 쪽을 의도했는지 코드에 남는다.

using System.Collections.Generic;
using UnityEngine;

public class BundleRegistry : MonoBehaviour
{
    [Header("Logging")]
    [SerializeField, Tooltip("내릴 때 로그를 남긴다")]
    private bool _verbose = true;

    private readonly Dictionary<string, AssetBundle> _bundles =
        new Dictionary<string, AssetBundle>();

    public void Register(string key, AssetBundle bundle)
    {
        if (bundle == null || string.IsNullOrEmpty(key))
        {
            return;
        }

        _bundles[key] = bundle;
    }

    /// 압축 데이터만 회수한다. 이미 로드한 오브젝트는 그대로 살아 있다.
    /// 이 번들에서 새로 로드하는 것은 이 호출로 끝난다.
    public void ReleaseCompressedData(string key)
    {
        if (!_bundles.TryGetValue(key, out AssetBundle bundle) || bundle == null)
        {
            return;
        }

        if (_verbose)
        {
            Debug.Log($"[bundle] {key} — 압축 데이터만 해제, 인스턴스 유지");
        }

        bundle.Unload(false);
        _bundles.Remove(key);
    }

    /// 이 번들에서 로드한 오브젝트까지 파괴한다.
    /// 씬이 그 에셋을 참조하고 있으면 참조가 끊긴다.
    public void DestroyLoadedObjects(string key)
    {
        if (!_bundles.TryGetValue(key, out AssetBundle bundle) || bundle == null)
        {
            return;
        }

        if (_verbose)
        {
            Debug.LogWarning($"[bundle] {key} — 로드한 오브젝트까지 파괴한다");
        }

        bundle.Unload(true);
        _bundles.Remove(key);
    }
}

함수 이름이 인자를 대신한다. Unload(false)가 코드에 그대로 박혀 있으면 읽는 사람이 매번 어느 쪽인지 떠올려야 하지만, ReleaseCompressedData와 DestroyLoadedObjects는 이름이 결과를 말한다. 인자 하나로 동작이 반대가 되는 API는 이렇게 감싸두는 편이 안전하다.

쓰지 말아야 할 자리

정리


참고

이 글의 출발점이 된 자료는 맨텀 — [영상 필기][Unite Seoul 2025] Unity 프로젝트 개발 시 반드시 체크해야 할 최적화 관련 기능 공유 (2025-05-13)이다. 필기에 적힌 항목을 하나씩 현행 Unity 문서와 대조했고, 수치가 발표자의 측정인 항목은 검증 대상에서 제외했다. UGUI 쪽 오버드로우와 정점 비용은 Outline 컴포넌트를 다룬 글에, 레이아웃 시스템 부하는 레이아웃 그룹을 다룬 글에 있다.


이 글 공유하기:

이전 글
아웃라인은 셰이더가 아니라 정점 복제다
다음 글
백틱은 세 개가 아니라 세 개 이상이다