LYSC STUDIO

LIST
Cover

Addressable Asset System 기반의 대규모 오픈월드 동적 리소스 관리 최적화

·Unity Optimization

게임의 스케일이 방대해짐에 따라 필연적으로 마주하게 되는 메모리 누수와 리소스 로딩 병목 현상을 Addressable Asset System으로 우아하게 해결하고, 런타임 메모리를 극한으로 통제하는 방법을 살펴봅니다.

서론: 기존 리소스 관리 시스템의 한계와 Addressables의 화려한 등장

Unity 환경에서 게임의 시각적 요소와 기획 데이터를 담고 있는 에셋(Asset)을 관리하는 방식은 엔진의 발전과 함께 기나긴 진화의 과정을 거쳐 왔습니다. 과거 초창기 시절에는 'Resources' 폴더를 이용한 정적 로딩 방식이 표준처럼 사용되었으나, 이 폴더 내에 위치한 모든 에셋은 게임 빌드 시 거대한 하나의 해시 테이블(인덱스 트리)로 묶이게 되어 앱의 초기 구동 시간(Startup Time)을 기하급수적으로 증가시키고, 메모리를 무분별하게 점유하는 치명적인 아키텍처적 결함을 지니고 있었습니다. 이를 극복하기 위해 등장한 것이 바로 AssetBundle 기반의 동적 로딩이었습니다. 에셋 번들은 필요한 시점에 메모리에 올리고 내릴 수 있어 혁신적이었지만, 개발자가 직접 종속성(Dependencies) 트리를 파싱하고 관리해야 하며, 다운로드 및 캐싱 로직을 바닥부터 짜야 하는 극악의 유지보수 난이도를 자랑했습니다. 대규모 심리스(Seamless) 오픈월드 게임이나 방대한 라이브 서비스 업데이트를 진행하는 모바일 게임 환경에서 이러한 레거시 시스템들은 결국 추적할 수 없는 메모리 릭(Memory Leak)과 원인 모를 Out Of Memory 크래시를 유발하는 주범으로 전락했습니다. 이러한 척박한 배경 속에서 구원투수처럼 등장한 Addressable Asset System은 에셋의 물리적 위치(로컬 스토리지 또는 리모트 서버)와 로딩 구현 방식에 얽매이지 않고 오직 문자열 형태의 '주소(Address)'라는 고유 식별자만으로 언제 어디서든 에셋을 호출할 수 있게 해주는 혁신적인 하이레벨 프레임워크입니다. Addressables는 내부적으로 비동기 태스크 로딩과 엄격한 레퍼런스 카운팅(Reference Counting) 알고리즘을 지원하여, 복잡하고 지저분한 에셋 관리 로직을 추상화하고 개발자가 본연의 게임 로직과 UX 구현에만 온전히 집중할 수 있도록 돕습니다.

레퍼런스 카운팅(Reference Counting) 메커니즘의 완벽한 이해와 메모리 누수 차단

Addressable 시스템이 제공하는 가장 강력하고 핵심적인 기능 중 하나는 자동화된 메모리 수명 주기(Lifecycle) 관리입니다. 이 시스템은 C++의 스마트 포인터(std::shared_ptr)와 매우 유사한 레퍼런스 카운팅 기법을 근간으로 작동합니다. 특정 에셋(예: 드래곤 모델링)을 메모리에 비동기로 로드하면 내부 관리자의 레퍼런스 카운트가 1 증가합니다. 만약 다른 스크립트에서 동일한 드래곤을 로드해달라고 추가 요청을 보내면, 시스템은 디스크 I/O를 다시 수행하지 않고 기존에 메모리에 적재된 드래곤 에셋의 카운트만 2로 증가시킨 뒤 즉시 메모리 포인터를 반환합니다. 반대로 사용이 완전히 끝난 에셋 인스턴스를 해제(Release)하면 카운트가 1씩 감소하고, 이 카운트가 마침내 0이 되는 순간 엔진은 해당 번들을 메모리에서 완전히 언로드(Unload)시킵니다. 이 메커니즘은 매우 이상적이지만, 실무 현장에서 수많은 프로그래머들이 범하는 치명적인 실수는 바로 비동기 로드 핸들(AsyncOperationHandle)을 제대로 캐싱하고 추적하지 못하여 해제(Release) 타이밍을 놓쳐버리는 것입니다. 특히 파티클 이펙트나 데미지 폰트 같은 일회성 UI 인스턴스 프리팹의 경우, 게임 씬 내에서 오브젝트가 Destroy()될 때 원본 Addressable 에셋의 레퍼런스 카운트를 짝지어서 감소시켜 주지 않으면, 이른바 '좀비 에셋'이 되어 눈에 보이지 않게 VRAM과 힙을 잠식해 나가는 끔찍한 메모리 누수가 발생합니다. 이를 원천 방지하기 위해서는 Addressables.InstantiateAsync를 사용할 때 trackHandle 파라미터를 명시적으로 true로 유지하거나, 커스텀 매니저 클래스를 싱글톤으로 구축하여 컴포넌트의 OnDestroy() 시점에 반드시 Addressables.ReleaseInstance()를 호출하는 방어적 프로그래밍(Defensive Programming) 패턴을 뼈대부터 탄탄히 구축해야 합니다.

의존성(Dependency) 최적화와 메모리 중복 적재(Duplication) 방지 전략

에셋 번들을 다룰 때 프로젝트의 운명을 가를 정도로 가장 치명적이고 골치 아픈 문제는 바로 복잡하게 얽힌 프리팹 간의 공통 리소스(텍스처, 머티리얼, 메쉬) 공유 문제입니다. 만약 용 몬스터를 담고 있는 A 번들과 기사 캐릭터를 담고 있는 B 번들이 동일한 '불꽃 이펙트 텍스처'를 각자의 인스펙터에 참조하고 있는데, 이 의존성을 개발자가 명시적으로 분리해 주지 않는다면 빌드 시스템은 A 번들과 B 번들 내부에 불꽃 텍스처 데이터를 각각 복사하여 집어넣게 됩니다. 런타임에 두 번들을 모두 로드하면 메모리 상에는 완벽히 똑같은 고용량 텍스처가 두 번 중복 적재되는 대참사가 발생합니다. 모바일 기기의 한정된 VRAM 환경에서 이러한 텍스처 중복 적재는 디바이스 발열을 초래하고 앱이 강제 종료(OOM)되는 영순위 원인입니다. Addressable 시스템은 놀랍게도 빌드 타임에 프로젝트 내의 모든 에셋들의 종속성 트리를 재귀적으로 분석하고, 둘 이상의 묶음에서 참조되는 중복 에셋들을 식별해내는 기능을 제공합니다. 에디터 툴바의 Analyze 창을 열고 'Check for Duplicate Bundle Dependencies' 룰을 실행하면, 실수로 중복 포함된 무거운 에셋 목록을 시각적으로 리포팅해주며, 원할 경우 이들을 공통 공유 번들(Shared Bundle)로 자동 추출하는 픽스(Fix) 기능을 지원합니다. 또한 런타임 프로파일러(Event Viewer) 모듈을 활성화하면 현재 게임 플레이 중에 어떤 번들이 로드되어 있고 레퍼런스 카운트가 몇인지 실시간 그래프로 모니터링할 수 있어, 메모리 중복 적재를 사전에 완벽히 차단하고 CI/CD 파이프라인에 통합할 수 있는 견고함을 선사합니다.

동기(Synchronous) 대 비동기(Asynchronous) 로딩의 양날의 검

Addressables는 본질적으로 디스크 접근과 네트워크 통신을 수반하기 때문에 내부 구조 전체가 철저하게 비동기 API(IEnumerator, Task)를 기반으로 비동기-퍼스트(Async-First) 원칙 아래 설계되었습니다. 플레이어가 드넓은 지형을 뛰어다니는 대규모 오픈월드 게임에서는 캐릭터가 이동하는 동안 백그라운드 스레드에서 다음 구역의 지형 메쉬와 콜라이더 데이터를 미리 조용히 로드(Background Streaming)해야 하므로 이러한 비동기 처리는 절대적인 필수 조건입니다. 하지만 실무 개발 과정에서는 기획적 요구사항이나 기존 레거시 코드의 구조적 한계로 인해 부득이하게 즉시 데이터를 가져와야 하는 '동기적인 로딩'이 절실히 필요한 순간이 발생합니다. 이에 대응하기 위해 Unity 2021 버전 이후의 Addressables 패키지는 LoadAssetAsync().WaitForCompletion() 메서드를 새롭게 도입하여, 개발자가 원할 경우 메인 스레드를 블로킹(Blocking)하고 에셋이 로드될 때까지 강제로 대기하는 기능을 제공하게 되었습니다. 이 기능은 코드 작성을 극도로 편리하게 만들어주지만, 남용할 경우 끔찍한 재앙을 불러옵니다. 특히 로컬 낸드 플래시 스토리지에서 데이터를 읽어오는 수십~수백 밀리초의 I/O 대기 시간 동안 유니티의 렌더링 루프 전체가 완전히 멈춰버리므로 게임 화면이 얼어붙는 심각한 프레임 히치(Hitch) 현상을 유발합니다. 따라서 WaitForCompletion()은 게임의 극초기화 단계에서 설정 파일을 읽어오거나 씬 전환 중 로딩 스크린이 안전하게 화면을 덮고 있는 상태에서만 극히 제한적으로 사용해야 합니다. 인게임 전투나 이동 등 실시간 상호작용 도중의 동적 에셋 생성은 무조건 Task나 코루틴 기반의 비동기 호출 패턴을 강제해야 하며, 실제 에셋이 준비되기 전까지 물리 연산을 지연시키거나 플레이스홀더(Placeholder) 로딩 아이콘을 띄워두는 세심한 UX 설계가 반드시 프로그래밍과 병행되어야만 합니다.

원격 카탈로그(Remote Catalog)를 통한 무점검 라이브 서비스 패치

현재의 모바일 게임 시장 생태계에서는 앱스토어나 구글 플레이의 까다롭고 느린 검수 과정을 매번 거치지 않고, 게임 내 리소스 패치만으로 신규 캐릭터나 이벤트 콘텐츠를 업데이트하는 OTA(Over-The-Air) 인게임 패치 시스템이 프로젝트의 생명줄과도 같습니다. Addressables는 원격 카탈로그(Remote Catalog) 시스템을 통해 이러한 라이브 옵스(LiveOps) 환경을 가장 완벽하게 지원하는 툴입니다. 프로젝트 빌드 시 생성되는 catalog.json 및 해시 파일은 현재 클라이언트가 들고 있는 에셋 버전을 기록합니다. 게임이 구동될 때 클라이언트는 설정된 AWS S3나 CDN 서버에 있는 최신 해시 파일을 가볍게 조회하여 차이점(Delta)을 비교하고, 오직 새롭게 추가되거나 변경된 번들 파일만 선택적으로 다운로드합니다. 하지만 최적화의 관점에서 카탈로그 업데이트 과정은 매우 민감하게 다루어져야 합니다. 앱을 실행하고 매 씬마다 카탈로그를 확인하도록 방치하면 불필요한 HTTPS 네트워크 핸드셰이크 오버헤드가 누적되어 쾌적함을 해칩니다. 따라서 타이틀 화면 진입 시 1회만 체크하거나 자체 게임 서버의 경량화된 버전 API를 먼저 호출하여 업데이트 플래그가 켜져 있을 때만 Addressables 업데이트 루틴을 가동하는 이중 검증 로직이 권장됩니다. 또한 대규모 1GB 단위의 업데이트를 진행할 때 수천 개의 작은 100KB짜리 에셋 번들을 개별로 다운로드하는 것은 엄청난 네트워크 커넥션 생성 비용과 타임아웃 오류를 야기하므로 속도가 기하급수적으로 저하됩니다. 따라서 배포 파이프라인 설계 시 에셋들을 논리적 묶음(예: 챕터 단위, 대규모 이벤트 팩)으로 통폐합(Grouping)하고, LZ4 압축 포맷을 적극 사용하여 다운로드 시의 대역폭 한계와 런타임 메모리 압축 해제 속도 사이의 황금 밸런스를 치열하게 찾아내야 합니다.

에셋 그룹화(Grouping) 및 라벨링(Labeling) 아키텍처 수립

Addressable 윈도우에 등록된 에셋들을 어떠한 기준으로 그룹(Group)에 배치하고 어떻게 라벨(Label)을 달아줄 것인가 하는 정책은, 최종 런타임 메모리 효율과 번들 파편화 정도를 결정짓는 핵심 설계 영역입니다. 초보 개발자들이 가장 흔히 저지르는 뼈아픈 실수는 관리가 귀찮다는 이유로 씬 내의 모든 몬스터, UI, 사운드를 'Default Local Group'이라는 하나의 거대한 집단으로 뭉뚱그려 버리는 것입니다. 이 경우 게임 플레이 중 단 하나의 자그마한 효과음 에셋만 호출하더라도, Addressables 시스템은 그 효과음이 포함된 거대한 수백 메가바이트의 번들 전체를 힙 메모리에 로드해야 하므로 끔찍한 메모리 낭비가 초래됩니다. 효율적인 번들링의 원칙은 그룹을 반드시 '함께 묶여서 로드되고 함께 언로드되는(Co-loaded and Co-unloaded)' 생명주기(Lifecycle) 단위로 세분화하는 것입니다. 예를 들어 튜토리얼 스테이지 1에만 등장하는 고유 몬스터 메쉬들은 Stage1_Monsters 그룹으로, 인게임 상점과 인벤토리에서 늘 띄워두어야 하는 공통 아이콘들은 Common_UI 그룹으로 나누어 격리시키는 식입니다. 여기에 다채로운 라벨(Label) 메타데이터 기능을 조합하면 무궁무진한 유연성을 확보할 수 있습니다. 예를 들어 텍스처 에셋들에 'HD_Textures', 'SD_Textures' 등의 해상도 라벨을 부여해두면, 런타임에 게임이 실행되는 디바이스의 RAM 스펙을 쿼리하여 사양이 낮을 경우 자동으로 SD 라벨이 붙은 번들만을 다운로드하고 로드하도록 우아하게 분기 처리를 구현할 수 있습니다. 이러한 체계적인 그룹화 및 라벨링 명명 규칙(Naming Convention) 정책 수립은 절대 개발자 혼자서 독단적으로 결정할 수 없으며, 프로젝트 초기 프로토타입 단계에서 테크니컬 아트(TA) 파트너와 리드 프로그래머가 머리를 맞대고 긴밀하게 협업하여 결정해야 할 가장 중대한 기술 최적화 과제입니다.

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

// Addressables 기반의 메모리 누수 방지형 통합 에셋 매니저 클래스
public class AddressableMemoryManager : MonoBehaviour
{
    // 로드된 에셋들의 상태 핸들을 고유 주소(Key) 기반으로 딕셔너리에 추적 보관
    // 이를 통해 동일한 에셋에 대한 중복 로딩 요청을 캐싱하고 안전한 메모리 해제를 보장함
    private Dictionary> loadedAssets = new Dictionary>();

    // async/await 키워드를 사용하여 콜백 지옥(Callback Hell)을 방지하고 깔끔한 비동기 로직 구현
    public async Task LoadAndInstantiateAsync(string addressableKey, Transform parent = null)
    {
        // 방어적 프로그래밍: 이미 로드 중이거나 완전히 로드 완료된 핸들이 캐시 딕셔너리에 존재하는가?
        if (loadedAssets.TryGetValue(addressableKey, out var existingHandle))
        {
            // 로드가 완료된 상태라면, 기존에 로드된 원본 에셋(Result)을 바탕으로 새 인스턴스만 복제하여 반환
            if (existingHandle.Status == AsyncOperationStatus.Succeeded)
            {
                return Instantiate(existingHandle.Result, parent);
            }
            // 아직 로드 진행 중(Progress)이라면 Task 완료를 기다림 (중복 디스크 I/O 방지)
            await existingHandle.Task;
            return Instantiate(existingHandle.Result, parent);
        }

        // 캐시에 없다면 새로운 비동기 로드 오퍼레이션 요청
        AsyncOperationHandle handle = Addressables.LoadAssetAsync(addressableKey);
        // 핸들을 딕셔너리에 즉시 등록하여 추적 시작
        loadedAssets[addressableKey] = handle;

        // 비동기 작업이 완료될 때까지 메인 스레드를 블로킹하지 않고 대기
        await handle.Task;

        // 로드 성공 여부 검증
        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            // 로드된 원본 에셋을 기반으로 하이라키(Hierarchy)에 인스턴스화
            return Instantiate(handle.Result, parent);
        }
        else
        {
            // 오타나 서버 다운 등으로 로드에 실패했을 경우의 강력한 예외 처리
            Debug.LogError($"[Addressables] 에셋 로드 치명적 실패! Key: {addressableKey}");
            loadedAssets.Remove(addressableKey);
            return null;
        }
    }

    // 더 이상 필요 없는 특정 에셋의 메모리 점유를 해제하는 안전한 메서드
    public void UnloadAssetFromMemory(string addressableKey)
    {
        // 추적 중인 핸들이 유효한지 검사
        if (loadedAssets.TryGetValue(addressableKey, out var handle))
        {
            // 엔진 내부의 레퍼런스 카운트를 1 감소시킴.
            // 카운트가 0이 되면 Addressables 시스템이 자체적으로 메모리(VRAM 포함)에서 완전히 언로드 수행
            Addressables.Release(handle);
            loadedAssets.Remove(addressableKey);
            Debug.Log($"[Addressables] 레퍼런스 반환 및 메모리 해제 완료: {addressableKey}");
        }
    }

    // 씬이 전환되거나 게임이 종료될 때 싱글톤/매니저가 파괴되는 시점의 최후 방어선
    private void OnDestroy()
    {
        // 메모리 릭(Leak)을 방지하기 위해 딕셔너리에 남아있는 모든 핸들을 순회하며 강제 릴리즈 처리
        foreach (var handle in loadedAssets.Values)
        {
            if(handle.IsValid())
            {
                Addressables.Release(handle);
            }
        }
        loadedAssets.Clear();
        Debug.Log("[Addressables] 매니저 파괴: 모든 로드된 에셋 메모리 완전 정리 완료.");
    }
}
결론적으로 Addressable Asset System은 단순한 파일 다운로더나 리소스 로더가 아니라, 프로젝트의 전체 메모리 수명 주기를 완벽하게 통제하고 지휘하는 척추와도 같은 아키텍처입니다. 비동기 프로그래밍 모델(Task, Await)과 레퍼런스 카운팅이라는 낯선 개념에 적응해 나가는 과정이 개발팀 전체에 다소 고통스러운 허들로 다가올 수 있으나, 한 번 이 프레임워크 위에 견고한 매니저 클래스와 규칙을 구축해 놓으면 상용화 단계에서 가장 큰 스트레스 원인인 런타임 OOM(Out of Memory) 크래시의 늪에서 영구적으로 해방될 수 있습니다. 결국 대규모 글로벌 런칭 프로젝트의 기술적 승패는, 기기가 가진 한정된 RAM 용량 안에서 얼마나 똑똑하게 불필요한 번들을 내리고 필요한 데이터를 적시에 스와핑(Swapping)하느냐의 싸움에 달려 있습니다. 유니티 에디터의 프로파일러 윈도우를 언제나 보조 모니터에 켜두고, 실시간으로 출렁이는 메모리 점유율 그래프와 레퍼런스 카운트의 섬세한 흐름을 눈으로 직접 추적하는 집요한 습관을 들이십시오. 며칠을 플레이해도 끝내 우상향하지 않고 안정적인 수평선을 유지하는 평온한 메모리 그래프를 마주하는 순간, Addressables 시스템이 지닌 진정한 가치와 여러분의 최적화 설계 능력을 깊이 실감하게 될 것입니다.