관리 힙은 30MB 인데 게임은 2GB 를 먹는다
Esc

    관리 힙은 30MB 인데 게임은 2GB 를 먹는다

    –Unity최적화GC메모리

    메모리 프로파일러를 열면 숫자가 안 맞습니다.

    관리 힙은 30MB 라고 나오는데 게임이 실제로 쓰는 메모리는 2GB 입니다. GC 를 아무리 손봐도 이 2GB 는 꿈쩍도 안 합니다.

    당연합니다. GC 는 그쪽을 안 봅니다.

    유니티의 메모리는 관리 메모리와 네이티브 메모리로 나뉩니다. 덩치는 네이티브 쪽이 훨씬 큽니다.

    텍스처 하나가 어디에 있나

    Texture2D 하나를 로드했다고 해 봅시다. 2048×2048 짜리라 대충 16MB 입니다.

    관리 힙에 있는 Texture2D 객체는 네이티브 쪽을 가리키는 손잡이입니다. 몇십 바이트짜리입니다. 16MB 짜리 픽셀 데이터는 엔진이 관리하는 네이티브 메모리에 있습니다. C++ 쪽입니다.

    그러니 참조를 놓고 GC 를 돌려도 16MB 는 안 사라집니다. GC 는 그 존재를 모릅니다.

    같은 이야기가 Mesh, AudioClip, RenderTexture, AnimationClip 에 전부 해당합니다. 게임에서 무거운 것은 거의 다 이쪽입니다.

    그럼 무엇으로 치우나

    C# 쪽에서 GC 가 모르는 자원을 Dispose 로 치웠던 것과 같은 이야기입니다. 이름만 다릅니다.

    Destroy 로 치웁니다.

    Destroy(텍스처);           // 네이티브 쪽까지 없앱니다
    텍스처 = null;             // 관리 껍데기도 놓습니다

    Destroy 를 부르면 네이티브 메모리가 그 자리에서 해제됩니다. null 을 넣는 것만으로는 껍데기만 놓는 것이라 아무 일도 안 일어납니다.

    여기서 유니티 특유의 함정이 하나 나옵니다.

    Destroy(오브젝트);
    if (오브젝트 != null) { ... }   // false 입니다. 변수는 null 이 아닌데도

    Destroy 를 한 뒤에도 C# 변수 자체는 null 이 아닙니다. 껍데기 객체는 아직 힙에 있으니까요. 그런데 유니티가 == 를 재정의해 뒀습니다. 네이티브 쪽이 없어졌으면 null 이라고 답하도록 만든 것입니다.

    앞에서 연산자 오버로딩을 봤다면 이게 무엇인지 바로 보입니다. UnityEngine.Object 의 operator == 입니다. 그래서 유니티 객체에는 ?. 나 ?? 를 쓰면 안 됩니다. 그건 재정의된 == 를 안 거치고 진짜 참조만 보기 때문입니다.

    오브젝트?.뭔가();      // 파괴된 객체인데 그냥 호출됩니다
    if (오브젝트 != null) 오브젝트.뭔가();   // 이쪽을 씁니다

    안 쓰는 에셋 치우기

    씬을 넘길 때는 하나씩 Destroy 하는 대신 한꺼번에 정리합니다.

    Resources.UnloadUnusedAssets();

    “아무도 안 가리키는 에셋” 을 찾아 네이티브 메모리를 해제합니다. 개념은 GC 와 같습니다. 다른 점은 이게 네이티브 쪽을 본다는 것입니다.

    여기에 순서 함정이 있습니다. UnloadUnusedAssets 는 관리 객체가 아직 그 에셋을 가리키고 있으면 못 치웁니다. 그래서 이 순서가 됩니다.

    참조들 = null;                      // 관리 쪽 참조를 먼저 놓고
    GC.Collect();                       // 관리 힙을 정리해서 손이 안 닿게 만든 다음
    Resources.UnloadUnusedAssets();     // 네이티브를 치웁니다

    C# 쪽에서 GC.Collect() 를 직접 부르지 말라고 했는데 여기는 예외입니다. 성능 때문이 아니라 순서를 맞추려고 부르는 것이고, 씬 전환처럼 어차피 멈춰 있는 자리에서만 합니다.

    Addressables 를 쓰면 이 일을 참조 세기로 대신합니다. LoadAssetAsync 한 것을 Release 하면 셈이 0 이 될 때 알아서 내려갑니다. 직접 세는 대신 짝을 맞추는 것이라 실수가 줄어듭니다. 대신 짝을 안 맞추면 그대로 샙니다.

    직접 잡는 메모리

    한 종류가 더 있습니다. Job System 이나 Burst 를 쓰면 만나게 됩니다.

    var 결과 = new NativeArray<float>(1000, Allocator.TempJob);
    // ...
    결과.Dispose();

    NativeArray 는 관리 힙이 아니라 네이티브 메모리를 직접 잡습니다. GC 가 모르니 반드시 Dispose 를 해야 합니다. 안 하면 에디터가 누수를 경고합니다.

    Allocator 가 수명을 정합니다. Temp 는 한 프레임, TempJob 은 몇 프레임, Persistent 는 내가 치울 때까지입니다. 수명에 맞는 것을 고르면 실수가 줄어듭니다.

    이건 C# 의 IDisposable 그대로입니다. using 으로 감싸면 예외가 나도 정리됩니다.

    재는 법

    Profiler 의 숫자를 볼 때 무엇이 무엇인지 갈라 봐야 합니다.

    • GC Alloc 프레임당 관리 힙 할당. 여기가 0 이면 GC 튐은 이 프레임의 문제가 아닙니다
    • Mono/Managed 사용량 관리 힙. 대개 게임 전체에서 작은 비중입니다
    • Native 사용량 텍스처, 메시, 오디오. 대개 여기가 대부분입니다

    그래서 문제를 먼저 갈라야 합니다.

    • 프레임이 튄다 → GC Alloc 을 봅니다. 할당 문제입니다. 앞의 세 편 이야기입니다
    • 메모리가 부족해 죽는다 → 네이티브를 봅니다. 에셋 문제입니다. 이 글 이야기입니다

    둘은 원인도 대응도 다릅니다. 섞으면 엉뚱한 곳을 고치게 됩니다. 할당을 아무리 줄여도 텍스처 때문에 죽는 것은 안 고쳐지고, 에셋을 아무리 내려도 프레임 튐은 그대로입니다.

    Memory Profiler 패키지를 쓰면 스냅샷을 두 번 찍어 비교할 수 있습니다. 씬을 열고 닫은 뒤 스냅샷이 제자리로 안 돌아오면 그 씬이 무언가를 붙잡고 있는 것입니다.

    정리

    • 유니티 메모리는 관리 메모리와 네이티브 메모리로 나뉘고, 덩치는 네이티브 쪽입니다
    • 텍스처나 메시의 관리 객체는 껍데기입니다. 진짜 데이터는 네이티브에 있고 GC 는 그것을 모릅니다
    • 그래서 null 을 넣어도 안 줄어듭니다. Destroy 로 치웁니다
    • 유니티가 == 를 재정의해서 파괴된 객체를 null 로 답합니다. 그래서 ?. 와 ?? 를 쓰면 안 됩니다
    • 씬 전환에서는 참조를 놓고 → GC.Collect() → Resources.UnloadUnusedAssets() 순서입니다
    • NativeArray 는 직접 Dispose 합니다. Allocator 로 수명을 고릅니다
    • 프레임이 튀는 문제와 메모리가 부족한 문제는 다릅니다. GC Alloc 과 Native 사용량을 갈라 보세요

    GC 쪽은 앞으로 CoreCLR 이 들어오면서 나아집니다. 데스크톱 한정이지만요. 네이티브 쪽은 그것과 무관하게 그대로입니다. 에셋을 언제 올리고 언제 내리는가는 런타임이 바뀌어도 계속 우리가 정해야 하는 일입니다.