Content Directories 의 참조 카운트는 핸들만 센다
Esc

    Content Directories 의 참조 카운트는 핸들만 센다

    –Unity메모리

    Loadable 로 프리팹을 올려 인스턴스를 만들고, 다 썼다 싶어 Release() 했는데 화면에서 오브젝트가 사라집니다. 에러도 경고도 없습니다. 반대로 인스턴스를 전부 지웠는데도 머티리얼이 메모리에 그대로 남아 있기도 합니다.

    Content Directories 는 Loadable 핸들과 씬만 세고, 마지막 쪽이 놓은 1프레임 뒤에 에셋을 내립니다. Instantiate 로 만든 인스턴스는 이 셈에 들어가지 않습니다.

    핸들 하나는 몇 번 Load 해도 하나로 센다

    같은 Loadable 인스턴스에서 Load() 를 두 번 부르고, Release() 를 한 번씩 불렀습니다. 콘텐츠 머티리얼 수는 1프레임 뒤에 셌습니다.

    단계 콘텐츠 머티리얼 Status
    같은 핸들로 Load() 두 번 10 Loaded, 두 결과가 같은 객체
    Release() 한 번 9 None
    Release() 한 번 더 9 None, 에러 없음

    Load() 를 몇 번 불렀든 Release() 한 번에 내려갑니다. 핸들 하나가 셈 하나입니다. 두 번째 Release() 는 아무 일도 하지 않습니다.

    같은 에셋을 가리키는 Loadable 을 두 개 만들면 각자 하나씩 셉니다.

    단계 콘텐츠 머티리얼 원본 / 두 번째
    두 핸들 Load() 10 Loaded / Loaded, 같은 객체
    원본만 Release() 10 None / Loaded
    두 번째도 Release() 9 None / None

    두 핸들이 돌려준 것은 같은 객체이고, 마지막 핸들을 놓을 때 내려갑니다. 그리고 내려가는 시점은 Release() 를 부른 프레임이 아니라 그다음 프레임이었습니다. Release() 직후에 세면 아직 10 입니다. 에디터와 Player 가 같았습니다.

    C++ 의 shared_ptr 처럼 손잡이 하나가 강한 참조 하나라는 점은 같지만, 복사하거나 사라질 때 저절로 늘고 주는 방식이 아닙니다. 부르는 쪽이 Load() 와 Release() 로 직접 올리고 내립니다.

    같이 쓰는 에셋은 마지막 사용자가 놓을 때 내려간다

    Cube 의 머티리얼과 InDoor 씬의 머티리얼 여섯 개는 같은 Lit.shader 를 씁니다. Cube 핸들과 씬을 둘 다 올린 뒤 내리는 순서를 바꿔 봤습니다.

    내리는 순서 첫 번째를 내린 뒤 셰이더 두 번째를 내린 뒤 셰이더
    Cube Release() → 씬 언로드 남음 3개 내려감
    씬 언로드 → Cube Release() 남음 (다섯 번 모두) 3개 내려감

    어느 순서든 마지막으로 쓰던 쪽이 놓을 때 내려갑니다. 그리고 Resources.UnloadUnusedAssets() 를 부르지 않아도 내려갑니다. 놓은 에셋만 처리하고 전체 오브젝트를 훑지 않습니다.

    거꾸로 핸들이 잡고 있는 에셋은 UnloadUnusedAssets() 를 불러도 안 내려갑니다. Cube 핸들만 잡고 C# 변수도 인스턴스도 없는 상태에서 불렀을 때 Unloading 0 unused Assets 가 찍히고 Cube 는 Loaded 로 남았습니다.

    셈을 움직이는 것 셈을 안 움직이는 것
    Loadable.Load() / Release() Instantiate / Destroy
    씬 로드 / 언로드 renderer.sharedMaterial = … 같은 대입
    디렉터리 등록 / 해제 (Root) C# 변수로 들고 있는 것

    Instantiate 한 인스턴스는 세지 않는다

    표의 오른쪽 칸이 사고가 나는 자리입니다. Cube 를 Load() 해서 Instantiate 한 뒤, 인스턴스는 그대로 두고 핸들만 Release() 했습니다.

    시점 원본 프리팹 씬의 인스턴스 인스턴스의 머티리얼
    Release() 한 프레임 정상 정상 Lit
    1프레임 뒤 파괴됨 (== null 이 참) 살아 있음 null

    인스턴스는 씬에 남아 있는데, 그 인스턴스가 쓰던 머티리얼은 셈이 0 이 되어 내려갔습니다. 경고는 없습니다. 화면에서는 어떻게 보이는지 Player 에서 큐브 가운데 픽셀을 찍었습니다.

    데이터 상태는 같고 그리는 방식만 다릅니다. URP 의 GPU Resident Drawer 가 켜져 있으면 머티리얼이 빈 렌더러를 그리기에서 빼는 것으로 보이는데, 내부 이유는 확인하지 못했습니다. 어느 쪽이든 핸들을 놓은 쪽 코드에서는 아무 신호가 없습니다.

    해제된 원본으로 다시 Instantiate 하면 에러가 나는데, 에디터와 Player 의 메시지가 다릅니다. 에디터는 MissingReferenceException 으로 “이미 파괴됐다” 고 알려 주지만, Player 는 ArgumentNullException: Value cannot be null. Parameter name: data 라서 원인을 떠올리기 어렵습니다.

    직접 확인하는 테스트 코드

    E1 은 인스턴스를 두고 핸들만 놓고, E2 는 같은 에셋에 핸들 둘을 만들어 하나씩 놓습니다. 결과는 1프레임마다 찍어 내려가는 프레임을 확인합니다.

    ContentPackRootAsset m_Root;
    GameObject m_Instance;
    Loadable<GameObject> m_Second;   // 두 번째 핸들은 필드로 들고 있어야 한다
    
    // E1: 인스턴스는 둔 채 핸들만 놓는다
    IEnumerator ReleaseWhileInUse()
    {
        m_Instance = Instantiate(m_Root.bonusPrefab.Load());
        yield return null;
    
        m_Root.bonusPrefab.Release();
        for (int i = 0; i <= 2; i++)
        {
            var r = m_Instance.GetComponent<Renderer>();
            Debug.Log($"+{i}프레임 인스턴스 살아있음:{(bool)m_Instance} 머티리얼 null:{r.sharedMaterial == null}");
            yield return null;
        }
    }
    
    // E2: 같은 에셋, 다른 핸들 둘
    IEnumerator TwoHandles()
    {
        m_Second = new Loadable<GameObject>(m_Root.bonusPrefab.LoadableObjectId);
        GameObject a = m_Root.bonusPrefab.Load();
        GameObject b = m_Second.Load();
        Debug.Log($"같은 객체? {ReferenceEquals(a, b)}");
    
        m_Root.bonusPrefab.Release();
        yield return null;
        Debug.Log($"원본만 놓은 뒤: {m_Root.bonusPrefab.Status} / {m_Second.Status}, 살아있음:{a != null}");
    
        m_Second.Release();
        yield return null;
        Debug.Log($"둘 다 놓은 뒤 살아있음:{a != null}");
    }

    E1 에서 1프레임 뒤 “머티리얼 null:True” 가 나오고, E2 에서 원본만 놓은 뒤에는 살아 있다가 둘 다 놓은 뒤 false 가 되면 같은 규칙입니다.

    주의할 점

    1. 쓰는 곳마다 자기 Loadable 을 가진다

    안 지키면 생기는 문제. Loadable 하나를 여러 시스템이 같이 쓰면, 몇 번 Load() 했든 셈은 하나입니다. 한 곳이 Release() 하면 다른 곳이 쓰던 에셋까지 내려갑니다.

    이렇게 쓴다. 프리팹을 들고 있는 풀이나 스포너는 new Loadable<T>(원본.LoadableObjectId) 로 자기 핸들을 만들어 직접 올리고 내립니다.

    2. 인스턴스를 다 지운 뒤에 Release 한다

    안 지키면 생기는 문제. 인스턴스는 셈에 안 들어가서, 인스턴스가 남아 있어도 핸들을 놓으면 머티리얼이 내려갑니다. 화면에서 사라지거나 마젠타가 되고 경고는 없습니다.

    이렇게 쓴다. 핸들의 수명을 그 프리팹으로 만든 인스턴스보다 길게 잡습니다. 인스턴스를 만든 컴포넌트의 OnDestroy 에서 인스턴스를 정리한 뒤 Release() 합니다.

    3. 놓은 직후에는 아직 안 내려갔다

    안 지키면 생기는 문제. Release() 나 씬 언로드 완료 이벤트 직후에 메모리를 재면 아직 그대로라 해제가 안 된 것처럼 보입니다.

    이렇게 쓴다. 해제를 확인하거나 이어지는 작업을 할 때는 최소 1프레임을 기다립니다. 해제 완료를 알려 주는 이벤트나 셈을 읽는 API 는 찾지 못했습니다.

    정리

    Content Directories 는 Loadable 핸들과 씬만 세어, 마지막 쪽이 놓은 1프레임 뒤에 에셋을 내립니다.

    • 핸들 하나는 몇 번 Load() 해도 셈 하나이고, 같은 에셋에 핸들이 여럿이면 각자 하나씩 셉니다. 같이 쓰는 에셋은 마지막 사용자가 놓을 때 내려갑니다.
    • UnloadUnusedAssets() 없이 놓는 순간 그 에셋만 처리하고, 핸들이 잡은 에셋은 UnloadUnusedAssets() 도 안 내립니다.
    • Instantiate 한 인스턴스는 세지 않아서, 쓰는 중에 핸들을 놓으면 머티리얼이 경고 없이 빠집니다. 인스턴스를 다 지운 뒤 놓습니다.