Addressables Assets로 모듈식 DLC와 패치 시스템 구성하기

Unity Addressables의 카탈로그와 원격 번들 구조를 이용해 DLC를 독립 배포하고 앱 재설치 없이 콘텐츠 패치를 적용하는 방법을 정리합니다. 빌드 파이프라인과 런타임 로딩, 호환성 검증까지 실무 관점에서 다룹니다.

✓ 복사 완료!
챕터패치 시스템 & Addressables 배포
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Addressables Assets로 모듈식 DLC와 패치 시스템 구성하기

목표와 전제

Addressables는 에셋의 실제 위치를 코드에서 분리하는 Unity 패키지다. 로컬 번들과 원격 번들을 같은 방식으로 다룰 수 있으며 카탈로그를 통해 키와 에셋 위치를 연결한다. 이 구조를 활용하면 실행 파일을 다시 배포하지 않고도 데이터 중심 콘텐츠를 추가하거나 교체할 수 있다.

이 글에서 말하는 DLC는 맵, 캐릭터 프리팹, 대사, 아이콘처럼 Addressables로 관리 가능한 콘텐츠 묶음이다. 네이티브 플러그인, C# 코드, 셰이더 런타임 자체를 교체하는 패치에는 적합하지 않다. 그런 변경은 일반적으로 앱 업데이트가 필요하다.

전체 구조

핵심은 기본 게임과 각 DLC가 별도의 카탈로그 및 번들 집합을 갖도록 만드는 것이다. 클라이언트는 먼저 기본 카탈로그를 사용하고 구매 또는 권한 확인이 끝난 DLC의 카탈로그만 추가로 불러온다.

flowchart LR
    A[기본 게임 앱] --> B[기본 Addressables 카탈로그]
    B --> C[기본 콘텐츠 번들]
    A --> D[권한 및 구매 확인]
    D --> E[DLC 카탈로그 URL]
    E --> F[DLC 번들]
    A --> G[원격 카탈로그 업데이트 확인]
    G --> H[패치 번들 다운로드]

기본 게임은 DLC의 에셋을 직접 참조하지 않는 편이 안전하다. 직접 참조가 남아 있으면 기본 번들에 DLC 의존성이 섞이거나 DLC가 없는 환경에서 참조가 깨질 수 있다. 공통 인터페이스와 문자열 키, 레이블, ScriptableObject 기반 메타데이터를 경계로 삼으면 결합도를 낮출 수 있다.

기본 콘텐츠와 DLC 콘텐츠가 각각 독립 카탈로그와 원격 번들로 구성된 예시

Addressables 그룹 설계

기본 콘텐츠와 DLC를 분리한다

Addressables Groups 창에서 최소한 다음 성격의 그룹을 나눈다.

  • Base_Local: 첫 실행에 반드시 필요한 UI, 부트스트랩 씬, 기본 데이터
  • Base_Remote: 앱 출시 후 교체 가능하지만 모든 사용자가 받는 콘텐츠
  • DLC_Fantasy: 판타지 팩 전용 씬, 프리팹, 오디오, 데이터
  • DLC_SciFi: SF 팩 전용 콘텐츠
  • Shared_Remote: 여러 DLC가 함께 쓰는 대용량 공통 에셋

DLC 그룹에는 dlc-fantasy처럼 DLC 식별용 레이블을 부여한다. 단, 레이블만으로 배포 단위를 분리할 수는 없다. 실제 배포 경계는 그룹의 번들 빌드 설정과 별도 카탈로그 생성 방식으로 결정된다.

번들 구성 원칙

한 프리팹이 사용하는 머티리얼, 텍스처, 애니메이션이 서로 다른 그룹에 흩어지면 다운로드와 메모리 추적이 어려워진다. 콘텐츠 단위로 함께 로드되는 에셋은 가능하면 같은 DLC 그룹에 둔다.

반대로 여러 DLC가 공통으로 사용하는 에셋은 별도 공유 그룹으로 분리한다. 다만 공유 그룹을 너무 크게 만들면 작은 DLC 하나를 위해 불필요한 데이터를 내려받게 된다. 공유 여부는 실제 의존 관계와 예상 다운로드 크기를 기준으로 판단해야 한다.

DLC끼리 순환 의존하는 구조는 피한다. 예를 들어 판타지 DLC의 캐릭터가 SF DLC의 머티리얼을 참조하면 판타지 DLC 단독 설치라는 약속이 깨진다.

원격 경로와 프로필 설정

Addressables Profiles에서 빌드 결과가 저장되는 경로와 런타임 다운로드 주소를 분리한다. 개발 환경에서는 로컬 웹 서버를 사용할 수 있다. 출시 환경에서는 CDN이나 객체 스토리지 같은 호스팅을 선택하고, 실제 배포 경로와 접근 정책에 맞게 프로파일을 설정한다.

예를 들어 원격 로드 경로는 다음처럼 플랫폼과 콘텐츠 버전을 포함할 수 있다.

https://cdn.example.com/game/addressables/[BuildTarget]/[ContentVersion]/

예시 경로의 [ContentVersion]은 프로젝트에서 Addressables Profile 변수로 정의할 사용자 지정 값이다. 릴리스별 콘텐츠 버전을 경로에 포함하면 버전별 파일 세트를 나누는 데 쓸 수 있지만, 롤백과 캐시 동작은 카탈로그·해시 파일 및 CDN 정책과 함께 설계해야 한다. 카탈로그 URL까지 매번 바꾸면 클라이언트가 새 위치를 알 방법도 필요하다. 운영 방식은 크게 두 가지다.

  1. 카탈로그의 고정 URL을 유지하고 파일 내용을 갱신한다.
  2. 부트스트랩 설정 파일이나 서버 API로 현재 카탈로그 URL을 내려준다.

첫 방식은 단순하지만 CDN 캐시 무효화 정책을 세심하게 관리해야 한다. 두 번째 방식은 유연하지만 부트스트랩 API의 가용성과 보안이 추가로 필요하다.

DLC 빌드와 배포 절차

DLC를 별도 카탈로그로 제공하려면 기본 콘텐츠와 DLC 콘텐츠를 분리해 빌드·호스팅해야 한다. Unity의 일반적인 콘텐츠 업데이트는 기존 플레이어의 카탈로그를 갱신하는 기능이다. DLC별 독립 카탈로그는 그룹을 나누는 것만으로 자동 생성되지 않으므로, 별도 프로젝트나 검증된 커스텀 빌드 파이프라인으로 각 콘텐츠 세트를 빌드한다고 가정한다.

  1. 기본 게임용 Addressables 콘텐츠를 빌드하고 앱에 포함할 카탈로그를 생성한다.
  2. DLC 전용 그룹을 대상으로 별도 빌드를 수행해 DLC 카탈로그와 번들을 생성한다.
  3. DLC 번들과 카탈로그 JSON, 해시 파일을 CDN의 전용 경로에 업로드한다.
  4. 스토어 구매 또는 서버 권한을 확인한다. 콘텐츠 접근을 제한해야 한다면 카탈로그와 번들 요청도 CDN·서버의 접근 제어로 보호한다. 클라이언트 코드에서 주소를 숨기는 것만으로 서버 측 권한 검증이 되는 것은 아니다.
  5. 클라이언트에서 카탈로그를 로드한 뒤 필요한 레이블의 의존성을 내려받는다.

카탈로그와 번들은 반드시 같은 배포 세트로 관리해야 한다. 새 카탈로그가 이전 번들을 가리키거나 새 번들만 먼저 올라간 상태는 로드 실패와 재현하기 어려운 캐시 문제를 만든다. 업로드는 버전 경로에 완결된 파일 세트를 먼저 배치한 뒤 마지막에 현재 버전을 가리키는 설정을 변경하는 방식이 안전하다.

런타임에서 DLC 카탈로그 로드하기

아래 예제는 호출 전에 권한 확인을 마쳤다는 전제로 외부 카탈로그를 추가하고 DLC 레이블의 다운로드 크기를 확인한 다음 필요한 번들을 받는 흐름이다. 예제의 Task 기반 대기는 Addressables 1.21의 AsyncOperationHandle.Task를 사용하는 플랫폼용이며, WebGL에서는 이 API를 지원하지 않으므로 코루틴이나 완료 이벤트 방식으로 바꿔야 한다.

using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceLocations;

public sealed class DlcContentService
{
    public async Task<bool> InstallFantasyDlcAsync(string catalogUrl)
    {
        AsyncOperationHandle<IResourceLocator> catalogHandle =
            Addressables.LoadContentCatalogAsync(catalogUrl, false);

        try
        {
            IResourceLocator locator = await catalogHandle.Task;
            if (catalogHandle.Status != AsyncOperationStatus.Succeeded || locator == null)
            {
                Debug.LogError($"DLC 카탈로그를 불러오지 못했습니다: {catalogUrl}");
                return false;
            }

            const string label = "dlc-fantasy";
            AsyncOperationHandle<long> sizeHandle =
                Addressables.GetDownloadSizeAsync(label);

            long downloadSize;
            try
            {
                downloadSize = await sizeHandle.Task;
                if (sizeHandle.Status != AsyncOperationStatus.Succeeded)
                {
                    Debug.LogError("DLC 다운로드 크기를 확인하지 못했습니다.");
                    return false;
                }
            }
            finally
            {
                if (sizeHandle.IsValid())
                    Addressables.Release(sizeHandle);
            }

            if (downloadSize > 0)
            {
                AsyncOperationHandle downloadHandle =
                    Addressables.DownloadDependenciesAsync(label);

                try
                {
                    await downloadHandle.Task;
                    if (downloadHandle.Status != AsyncOperationStatus.Succeeded)
                    {
                        Debug.LogError("DLC 번들 다운로드에 실패했습니다.");
                        return false;
                    }
                }
                finally
                {
                    if (downloadHandle.IsValid())
                        Addressables.Release(downloadHandle);
                }
            }

            return true;
        }
        catch (Exception exception)
        {
            Debug.LogError($"DLC 설치 중 오류가 발생했습니다: {exception.Message}");
            return false;
        }
        finally
        {
            if (catalogHandle.IsValid())
                Addressables.Release(catalogHandle);
        }
    }
}

LoadContentCatalogAsync가 성공했다고 해서 모든 DLC 파일이 내려받아졌다는 뜻은 아니다. 이 호출은 카탈로그를 메모리에 추가한다. 실제 번들은 해당 에셋을 로드할 때 받거나 위 예제처럼 DownloadDependenciesAsync로 미리 내려받는다.

다운로드 진행률이 필요하다면 DownloadDependenciesAsync 핸들에서 GetDownloadStatus()를 주기적으로 읽어 UI에 표시한다. 네트워크 오류, 저장 공간 부족, 사용자 취소도 별도 상태로 처리해야 한다. 코드 예제는 취소와 재시도 UX를 생략한 최소 흐름이다.

콘텐츠 패치 적용하기

모든 사용자가 쓰는 원격 콘텐츠는 기본 카탈로그의 업데이트 기능으로 관리할 수 있다. 앱 시작 직후가 아니라 타이틀 화면이나 콘텐츠 진입 전처럼 사용자가 기다릴 수 있는 시점에 확인하는 편이 좋다. UpdateCatalogs가 완료될 때까지 다른 Addressables 요청이 차단되므로, 기존 카탈로그로 에셋을 사용 중인지와 업데이트 시점을 함께 고려한다.

다음 예제에서 true는 업데이트가 없거나 카탈로그 확인·적용이 성공했다는 뜻이고 false는 실패를 뜻한다. 예제의 Task 대기는 WebGL에서는 사용할 수 없다.

using System;
using System.Collections.Generic;
using UnityEngine;
using System.Threading.Tasks;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceLocations;

public static class ContentUpdateService
{
    public static async Task<bool> CheckAndApplyAsync()
    {
        try
        {
            AsyncOperationHandle<List<string>> checkHandle =
                Addressables.CheckForCatalogUpdates(false);

            List<string> catalogs;
            try
            {
                catalogs = await checkHandle.Task;
                if (checkHandle.Status != AsyncOperationStatus.Succeeded)
                    return false;
            }
            finally
            {
                if (checkHandle.IsValid())
                    Addressables.Release(checkHandle);
            }

            if (catalogs == null || catalogs.Count == 0)
                return true;

            AsyncOperationHandle<List<IResourceLocator>> updateHandle =
                Addressables.UpdateCatalogs(catalogs, false);
            try
            {
                await updateHandle.Task;
                return updateHandle.Status == AsyncOperationStatus.Succeeded;
            }
            finally
            {
                if (updateHandle.IsValid())
                    Addressables.Release(updateHandle);
            }
        }
        catch (Exception exception)
        {
            Debug.LogError($"콘텐츠 카탈로그 업데이트 중 오류가 발생했습니다: {exception.Message}");
            return false;
        }
    }
}

카탈로그를 갱신해도 이미 메모리에 로드한 에셋 인스턴스가 자동으로 새 버전으로 교체되지는 않는다. 관련 번들이 메모리에 로드된 상태에서 카탈로그를 갱신하면 번들 ID 충돌이 생길 수 있으므로, 에셋 로드 전에 업데이트하거나 필요하면 Unique Bundle Ids 설정과 메모리 사용량을 검토한다. 씬, 프리팹, ScriptableObject를 사용 중인 상태에서 패치를 적용했다면 다음 콘텐츠 진입 시점에 새 키를 다시 로드하도록 설계하는 편이 예측 가능하다.

코드와 데이터의 호환성 경계

Addressables 패치는 데이터 교체에는 강하지만 코드 호환성 문제를 해결하지는 않는다. 새 DLC 데이터가 기존 클라이언트에 없는 컴포넌트나 직렬화 필드를 요구하면 역직렬화 또는 런타임 동작이 실패할 수 있다.

따라서 DLC 메타데이터에는 최소 클라이언트 버전과 콘텐츠 버전을 둔다. 서버가 카탈로그 URL을 전달할 때 이 정보를 함께 주고 지원하지 않는 클라이언트에는 앱 업데이트를 유도한다.

using System;

[Serializable]
public sealed class DlcManifest
{
    public string id;
    public string catalogUrl;
    public string requiredAppVersion;
    public string contentVersion;
}

에셋 키도 공개 API처럼 다루는 것이 좋다. 이미 배포한 키를 무심코 변경하면 이전 저장 데이터, 원격 설정, 이벤트 테이블이 참조하는 콘텐츠를 찾지 못할 수 있다. 키 변경이 필요하면 이전 키를 별칭으로 유지하거나 마이그레이션 테이블을 마련한다.

캐시와 제거 전략

다운로드한 원격 번들은 기본적으로 Unity의 AssetBundle 캐시에 남는다. DLC 구매 취소나 저장 공간 확보 기능을 제공할 때는 앱이 현재 사용 중인 핸들을 해제한 뒤 Addressables.CleanBundleCache 또는 키 기준 ClearDependencyCacheAsync를 검토한다. 하지만 공용 번들은 다른 DLC가 참조할 수 있으므로 단순히 특정 DLC 파일만 삭제한다고 가정하면 안 된다.

가장 단순한 전략은 DLC를 명확히 독립된 번들 집합으로 만들고 제거 기능은 다음 실행 시 적용하는 것이다. 실행 중인 에셋을 제거하면 씬 전환이나 재사용 과정에서 예상하지 못한 재다운로드가 발생할 수 있다.

배포 전 점검 목록

  • 빈 캐시 상태에서 기본 게임만 설치해 정상 실행되는지 확인한다.
  • 권한이 없는 계정에 게임 기능상 DLC 접근을 허용하지 않는지 확인하고, 콘텐츠 자체를 보호해야 한다면 카탈로그·번들 요청도 서버 또는 CDN에서 거부되는지 확인한다.
  • DLC 카탈로그만 로드한 뒤 필요한 번들이 실제로 내려받히는지 확인한다.
  • 네트워크 중단, CDN 404, 디스크 공간 부족에서 오류 메시지와 재시도 흐름을 확인한다.
  • 이전 클라이언트가 새 콘텐츠를 받았을 때 호환성 검사가 동작하는지 확인한다.
  • 카탈로그와 번들을 이전 버전으로 되돌렸을 때 정상적으로 롤백되는지 확인한다.
  • 개발 빌드뿐 아니라 IL2CPP와 대상 플랫폼의 실제 빌드에서도 검증한다.

마무리

Addressables 기반 DLC 시스템의 핵심은 다운로드 API 자체보다 콘텐츠 경계를 명확히 하는 데 있다. 기본 게임, 공용 콘텐츠, 각 DLC의 의존성을 분리하고 카탈로그와 번들을 하나의 버전 세트로 배포하면 패치와 확장 콘텐츠를 훨씬 안정적으로 운영할 수 있다. 출시 전에는 반드시 빈 캐시, 이전 클라이언트, 실패한 다운로드처럼 정상 경로 밖의 상황을 포함해 검증해야 한다.