LYSC STUDIO

LIST
Cover

Addressable Asset System과 동적 메모리 관리: 힙 메모리 단편화 및 누수 방지 기법

·Unity Optimization

프로젝트 규모가 커짐에 따라 필수불가결해진 Addressable Asset System의 올바른 활용법과, 에셋 로드/언로드 과정에서 발생하는 메모리 단편화 및 누수를 방지하는 기술을 소개합니다.

도입 및 개요

현대 인디 게임 개발에서 고해상도 텍스처, 복잡한 3D 모델, 방대한 사운드 에셋을 효율적으로 관리하는 것은 게임의 성능과 안정성에 직결되는 매우 중요한 과제입니다. 기존의 Resources 폴더 방식은 게임 시작 시 모든 메타데이터를 메모리에 올리기 때문에 시작 시간이 길어지고 불필요한 메모리를 낭비하는 치명적인 단점이 있습니다. AssetBundle 시스템 역시 종속성 관리의 어려움과 복잡한 빌드 파이프라인으로 인해 인디 개발자들이 다루기에 진입 장벽이 높았습니다. 이러한 문제들을 해결하기 위해 등장한 Addressable Asset System(어드레서블 애셋 시스템)은 에셋을 식별자(Address)로 관리하고 동적 로딩을 단순화하며 메모리 관리를 일원화하는 강력한 툴입니다. 하지만 단순히 Addressables API를 사용하는 것만으로는 메모리 최적화가 완성되지 않습니다. 레퍼런스 카운팅(Reference Counting) 메커니즘을 정확히 이해하고 철저하게 관리하지 않으면, 오히려 심각한 메모리 누수(Memory Leak)를 유발할 수 있습니다.

Addressable 시스템의 핵심은 참조 횟수 기반의 메모리 관리입니다. Addressables.LoadAssetAsync와 같은 메서드를 통해 에셋을 로드하면 해당 에셋의 레퍼런스 카운트가 1 증가하며 메모리에 상주하게 됩니다. 동일한 에셋을 여러 번 로드하더라도 시스템은 카운트만 증가시킬 뿐 메모리에 중복 할당하지 않아 매우 효율적입니다. 문제는 에셋의 사용이 끝났을 때 발생합니다. 인스턴스화된 게임 오브젝트를 Destroy() 함수로 파괴하더라도, 로드된 원본 에셋의 레퍼런스 카운트는 자동으로 감소하지 않습니다. 반드시 Addressables.Release() 또는 Addressables.ReleaseInstance()를 명시적으로 호출하여 레퍼런스 카운트를 0으로 만들어야 가비지 컬렉터(GC) 및 내부 에셋 매니저가 메모리에서 에셋을 성공적으로 해제할 수 있습니다. 수많은 몬스터 스폰과 데스가 반복되는 RPG 게임에서 릴리즈를 누락한다면, 몇 시간의 플레이 후 메모리 부족(OOM, Out Of Memory)으로 앱이 강제 종료되는 현상을 피할 수 없습니다.

또한, 빈번한 로드와 언로드는 힙 메모리 단편화(Heap Fragmentation)를 야기할 수 있습니다. 유니티의 힙 메모리는 한 번 늘어나면 다시 줄어들지 않는 특성이 있습니다. 큰 사이즈의 에셋을 로드하기 위해 힙 메모리가 확장된 후 에셋을 언로드하면 메모리 공간은 비워지지만, 힙 자체의 크기는 유지됩니다. 만약 작은 크기의 수많은 에셋이 반복적으로 할당되고 해제되면서 메모리 공간을 잘게 쪼개놓는다면, 나중에 큰 에셋을 로드할 때 충분한 여유 공간이 있음에도 불구하고 연속된 공간을 찾지 못해 힙 사이즈를 강제로 추가 확장하게 됩니다. 이러한 단편화를 방지하기 위해서는 어드레서블 프로파일러(Addressables Profiler)와 유니티 메모리 프로파일러(Memory Profiler)를 활용하여 에셋의 생명주기를 시각적으로 모니터링해야 합니다. 언제 어떤 에셋이 로드되고 언로드되는지 정확히 트래킹하고, 가능하면 비슷한 시기에 사용되는 에셋들을 하나의 그룹(Group) 또는 번들로 묶어 동시에 로드하고 언로드하는 전략을 취해야 합니다.

구체적인 방어 사례로, 씬 전환 시의 메모리 관리를 들 수 있습니다. 대규모 오픈 월드 게임이나 방을 이동하는 던전 크롤러 게임에서는 씬(Scene) 자체를 Addressables로 로드하는 경우가 많습니다. 씬을 비동기로 로드하고 이전 씬을 언로드할 때, 종종 이전 씬에서 동적으로 로드했던 에셋들의 핸들을 잃어버려 영원히 릴리즈하지 못하는 '고아(Orphan)' 에셋 문제가 발생합니다. 이를 해결하기 위해 개발자는 딕셔너리나 리스트를 활용한 커스텀 에셋 매니저(Asset Manager) 클래스를 싱글톤으로 구축해야 합니다. 각 에셋이 로드될 때 AsyncOperationHandle을 관리 목록에 등록하고, 해당 에셋이 더 이상 필요 없는 특정 시점(예: 씬 언로드 직전)에 등록된 모든 핸들을 일괄 순회하며 Release()를 호출하는 방어적 프로그래밍 패턴을 구축해야 완벽한 메모리 무결성을 유지할 수 있습니다.

Performance & FPS Simulator
Current FPS
60.0
Implementation C# / Unity
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using System.Collections.Generic;

public class AssetManager : MonoBehaviour
{
    private Dictionary> loadedAssets = new Dictionary>();

    public void LoadAndSpawn(string address, Vector3 position)
    {
        Addressables.InstantiateAsync(address, position, Quaternion.identity).Completed += handle =>
        {
            if (handle.Status == AsyncOperationStatus.Succeeded)
            {
                loadedAssets[address] = handle;
            }
        };
    }

    public void UnloadAsset(string address)
    {
        if (loadedAssets.TryGetValue(address, out AsyncOperationHandle handle))
        {
            Addressables.ReleaseInstance(handle);
            loadedAssets.Remove(address);
        }
    }
}
Addressable 시스템의 레퍼런스 카운팅을 정확히 이해하고 커스텀 매니저를 통해 관리하는 것이 장시간 플레이에도 쾌적한 환경을 유지하는 메모리 최적화의 첫걸음입니다.