콘솔 게임 부분 설치 구현: 미설치 콘텐츠 진입 차단과 다운로드 상태 UI 동기화

부분 설치를 지원하는 콘솔 게임에서 DLC·챕터·언어팩 같은 미설치 콘텐츠의 진입을 차단하고 다운로드·설치 상태를 Unity UI와 동기화하는 상태 머신과 C# 구현 패턴을 설명합니다.

✓ 복사 완료!
챕터넷코드 & 상태 동기화 (Client Prediction/Lag)
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
콘솔 게임 부분 설치 구현: 미설치 콘텐츠 진입 차단과 다운로드 상태 UI 동기화

핵심 요약

부분 설치 콘솔 게임에서는 콘텐츠 진입 버튼을 단순히 비활성화하는 것만으로 부족합니다. 플랫폼 설치 API가 반환하는 상태를 콘텐츠 ID별 상태 머신으로 정규화하고 실제 진입 직전에 다시 검사하는 이중 게이트가 필요합니다.

다운로드 UI는 버튼이 직접 상태를 관리하지 않고 ContentInstallService가 발행하는 스냅샷을 구독해야 합니다. 다운로드 중단, 백그라운드 전환, 설치 완료 이벤트, 오류와 재시도까지 하나의 상태 흐름으로 처리하면 메뉴와 실제 설치 상태가 어긋나는 문제를 줄일 수 있습니다.

부분 설치 콘솔 게임에서 가장 먼저 정의해야 할 것

부분 설치는 게임을 실행하는 데 필요한 기본 패키지와 선택 콘텐츠를 분리하는 방식입니다. 예를 들어 다음 콘텐츠는 선택 설치 대상으로 분리할 수 있습니다.

콘텐츠 유형예시진입 전 필요한 조건
캠페인 챕터Chapter 2, 엔딩 챕터해당 번들의 설치 완료
DLC확장팩 퀘스트, 신규 캐릭터DLC 권한과 설치 완료
언어팩일본어 음성, 고해상도 텍스트언어팩 설치 완료
멀티플레이 리소스맵, 캐릭터 스킨매치에 필요한 리소스 설치 완료
고해상도 에셋4K 텍스처, 고품질 음원품질별 에셋 번들 설치 완료

핵심은 콘텐츠가 존재하는가와 콘텐츠를 지금 사용할 수 있는가를 구분하는 것입니다.

  • 콘텐츠 정의: 클라이언트의 콘텐츠 매니페스트에 등록되어 있는가
  • 권한 상태: 사용자가 DLC를 구매했거나 이용 권한을 보유했는가
  • 설치 상태: 필요한 패키지 또는 청크가 설치되어 있는가
  • 다운로드 상태: 현재 다운로드 또는 설치 작업이 진행 중인가
  • 실행 조건: 네트워크, 버전, 선행 콘텐츠 등 추가 조건을 만족하는가

이 다섯 가지를 하나의 bool 값으로 합치면 오류 원인을 UI에 표시하기 어렵고 다운로드 중인 콘텐츠를 다시 다운로드하는 문제도 발생합니다.

왜 미설치 콘텐츠에 진입하는 문제가 발생하는가?

가장 흔한 원인은 메뉴 UI가 기억하고 있는 상태와 플랫폼이 실제로 관리하는 설치 상태가 서로 다른 시점의 값이기 때문입니다.

예를 들어 사용자가 타이틀 메뉴에서 챕터 다운로드를 시작한 뒤 콘솔 홈으로 이동했다고 가정해 보겠습니다. 플랫폼 다운로드가 백그라운드에서 계속 진행되더라도 게임의 메뉴 오브젝트는 이전 상태인 NotInstalled를 유지할 수 있습니다. 반대로 UI가 다운로드 완료 메시지를 표시했지만 실제 패키지 마운트가 끝나지 않았다면 게임플레이 씬에서 리소스를 찾지 못할 수 있습니다.

또 다른 문제는 진입 검사가 버튼 클릭 이벤트에만 있는 경우입니다. 다음 경로는 버튼을 거치지 않을 수 있습니다.

  • 저장 데이터에서 이전 위치로 바로 이동
  • 퀘스트 보상으로 잠금이 해제된 콘텐츠 실행
  • 알림 팝업의 확인 버튼
  • 딥 링크 또는 플랫폼 활동 재개
  • 로딩 완료 후 자동으로 다음 챕터 이동

따라서 UI 제어와 별개로 콘텐츠 로더 또는 씬 진입 서비스에도 동일한 접근 검사가 있어야 합니다.

콘텐츠 진입 게이트는 어떻게 설계할까?

진입 게이트는 다음 순서로 동작하는 것이 안전합니다.

  1. 콘텐츠 ID로 로컬 매니페스트를 조회합니다.
  2. 플랫폼 설치 상태를 조회해 내부 상태로 변환합니다.
  3. 권한과 선행 조건을 검사합니다.
  4. 설치가 완료되지 않았다면 다운로드 또는 설치 UI를 엽니다.
  5. 다운로드 완료 후 필요한 패키지를 다시 검증합니다.
  6. 모든 조건을 통과한 경우에만 씬 로딩이나 콘텐츠 실행을 시작합니다.

여기서 중요한 점은 다운로드 100%와 실행 가능이 항상 같지 않다는 사실입니다. 플랫폼에 따라 다운로드 완료 후 설치, 압축 해제, 패키지 마운트, 파일 검증 단계가 추가될 수 있습니다. 내부 게임 코드에서는 플랫폼별 상태를 그대로 노출하지 말고 공통 상태로 변환하는 편이 좋습니다.

권장 상태 모델

flowchart TD
    A[콘텐츠 선택] --> B[권한 검사]
    B -->|권한 없음| C[구매 또는 권한 안내]
    B -->|권한 있음| D[플랫폼 설치 상태 조회]
    D -->|미설치| E[다운로드 시작]
    D -->|다운로드 중| F[진행률 UI 표시]
    D -->|설치 중| G[설치 완료 대기]
    D -->|설치 완료| H[콘텐츠 검증]
    E --> F
    F --> G
    G --> H
    H -->|검증 성공| I[콘텐츠 진입]
    H -->|검증 실패| J[오류 및 재시도]

다음과 같이 상태를 정의하면 UI가 특정 플랫폼 SDK의 열거형에 직접 의존하지 않습니다.

내부 상태의미기본 UI
Unknown아직 조회하지 않았거나 플랫폼 응답을 기다리는 상태로딩 표시
NotEntitled콘텐츠 권한이 없는 상태구매 또는 권한 안내
NotInstalled설치되지 않은 상태다운로드 버튼
Queued다운로드 대기열에 등록된 상태대기 중
Downloading다운로드 진행 중인 상태진행률과 일시정지
Installing다운로드 후 설치 또는 마운트 중인 상태설치 중
Installed실행에 필요한 설치가 완료된 상태플레이 버튼
Failed다운로드, 설치 또는 검증에 실패한 상태재시도 버튼
Unavailable지역, 버전, 서버 정책 등으로 사용할 수 없는 상태사용 불가 안내

Unity C#에서 설치 상태를 플랫폼 중립적으로 감싸기

플랫폼 SDK의 실제 API는 콘솔과 배포 환경에 따라 다릅니다. 그러므로 게임플레이 코드는 Sony, Microsoft, Nintendo 또는 퍼블리셔의 설치 API를 직접 호출하지 않고 어댑터 인터페이스를 사용해야 합니다.

먼저 콘텐츠 단위와 상태 스냅샷을 정의합니다.

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public enum InstallState
{
    Unknown,
    NotEntitled,
    NotInstalled,
    Queued,
    Downloading,
    Installing,
    Installed,
    Failed,
    Unavailable
}

public readonly struct InstallSnapshot
{
    public readonly string ContentId;
    public readonly InstallState State;
    public readonly float Progress;
    public readonly long BytesDownloaded;
    public readonly long BytesTotal;
    public readonly string ErrorCode;

    public InstallSnapshot(
        string contentId,
        InstallState state,
        float progress = 0f,
        long bytesDownloaded = 0,
        long bytesTotal = 0,
        string errorCode = null)
    {
        ContentId = contentId;
        State = state;
        Progress = Mathf.Clamp01(progress);
        BytesDownloaded = bytesDownloaded;
        BytesTotal = bytesTotal;
        ErrorCode = errorCode;
    }

    public bool IsUsable => State == InstallState.Installed;
}

public interface IPlatformContentProvider
{
    Task<InstallSnapshot> QueryAsync(
        string contentId,
        CancellationToken cancellationToken);

    Task StartDownloadAsync(
        string contentId,
        CancellationToken cancellationToken);

    Task PauseDownloadAsync(
        string contentId,
        CancellationToken cancellationToken);

    event Action<InstallSnapshot> StatusChanged;
}

IPlatformContentProvider의 구현체에서 플랫폼 SDK의 원시 상태를 InstallState로 변환합니다. 이때 Downloading, Installing, Installed를 구분해야 합니다. 다운로드 바이트가 100%가 되었을 때 곧바로 Installed를 발행하면 안 되고 플랫폼의 설치 완료 또는 마운트 완료 콜백을 기준으로 해야 합니다.

다운로드 상태와 UI를 어떻게 동기화할까?

UI가 다운로드 매니저의 내부 필드를 매 프레임 읽는 방식은 간단해 보이지만 다음 문제가 있습니다.

  • 여러 메뉴가 서로 다른 캐시 값을 표시할 수 있음
  • 화면이 비활성화된 동안 발생한 완료 이벤트를 놓칠 수 있음
  • 네트워크 오류와 사용자의 일시정지를 구분하기 어려움
  • 동일 콘텐츠의 중복 요청을 막기 어려움

대신 서비스가 콘텐츠별 최신 스냅샷을 보관하고 상태가 바뀔 때 이벤트를 발행하도록 구성합니다. UI가 열릴 때는 현재 스냅샷을 즉시 받고 이후 변경 이벤트를 구독합니다.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public sealed class ContentInstallService : IDisposable
{
    private readonly IPlatformContentProvider provider;
    private readonly Dictionary<string, InstallSnapshot> snapshots = new Dictionary<string, InstallSnapshot>();
    private readonly Dictionary<string, Task> activeRequests = new Dictionary<string, Task>();
    private readonly object syncRoot = new object();

    public event Action<InstallSnapshot> SnapshotChanged;

    public ContentInstallService(IPlatformContentProvider provider)
    {
        this.provider = provider;
        this.provider.StatusChanged += OnPlatformStatusChanged;
    }

    public bool TryGetSnapshot(string contentId, out InstallSnapshot snapshot)
    {
        lock (syncRoot)
        {
            return snapshots.TryGetValue(contentId, out snapshot);
        }
    }

    public async Task<InstallSnapshot> RefreshAsync(
        string contentId,
        CancellationToken cancellationToken)
    {
        InstallSnapshot snapshot = await provider.QueryAsync(
            contentId,
            cancellationToken);

        Publish(snapshot);
        return snapshot;
    }

    public Task StartDownloadAsync(
        string contentId,
        CancellationToken cancellationToken)
    {
        lock (syncRoot)
        {
            if (activeRequests.TryGetValue(contentId, out Task existing))
            {
                return existing;
            }

            Task request = StartDownloadInternalAsync(
                contentId,
                cancellationToken);
            activeRequests[contentId] = request;
            return request;
        }
    }

    private async Task StartDownloadInternalAsync(
        string contentId,
        CancellationToken cancellationToken)
    {
        await Task.Yield();
        try
        {
            await provider.StartDownloadAsync(
                contentId,
                cancellationToken);
            await RefreshAsync(contentId, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            // 취소는 설치 상태를 뜻하지 않으므로 마지막 스냅샷을 덮어쓰지 않는다.
            throw;
        }
        catch (Exception exception)
        {
            Publish(new InstallSnapshot(
                contentId,
                InstallState.Failed,
                errorCode: exception.GetType().Name));
        }
        finally
        {
            lock (syncRoot)
            {
                activeRequests.Remove(contentId);
            }
        }
    }

    private void OnPlatformStatusChanged(InstallSnapshot snapshot)
    {
        Publish(snapshot);
    }

    private void Publish(InstallSnapshot snapshot)
    {
        lock (syncRoot)
        {
            snapshots[snapshot.ContentId] = snapshot;
        }

        SnapshotChanged?.Invoke(snapshot);
    }

    public void Dispose()
    {
        provider.StatusChanged -= OnPlatformStatusChanged;
        SnapshotChanged = null;
    }
}

이 구현의 핵심은 세 가지입니다.

  • 콘텐츠 ID별 최신 상태를 캐시합니다.
  • 동일 콘텐츠에 대한 중복 다운로드 요청을 합칩니다.
  • 플랫폼 이벤트와 명시적인 RefreshAsync 결과를 모두 같은 Publish 경로로 보냅니다.

실제 프로젝트에서는 Publish가 백그라운드 스레드에서 호출될 가능성을 확인해야 합니다. Unity UI 컴포넌트는 메인 스레드에서만 갱신해야 하므로 플랫폼 콜백을 메인 스레드 큐에 넣은 뒤 이벤트를 발행하는 디스패처를 추가하는 것이 안전합니다.

콘텐츠 선택 UI는 어떤 상태를 보여줘야 할까?

콘텐츠 카드 또는 챕터 버튼은 설치 상태를 행동 중심의 문구로 변환해야 합니다. 내부 상태명을 그대로 출력하면 사용자가 현재 할 수 있는 행동을 이해하기 어렵습니다.

using TMPro;
using UnityEngine;
using UnityEngine.UI;

public sealed class ContentInstallButton : MonoBehaviour
{
    [SerializeField] private string contentId;
    [SerializeField] private Button actionButton;
    [SerializeField] private TMP_Text actionLabel;
    [SerializeField] private TMP_Text progressLabel;
    [SerializeField] private Slider progressBar;
    [SerializeField] private GameObject progressRoot;

    private ContentInstallService installService;

    public void Initialize(ContentInstallService service)
    {
        installService = service;
        installService.SnapshotChanged += OnSnapshotChanged;

        if (installService.TryGetSnapshot(contentId, out InstallSnapshot snapshot))
        {
            Apply(snapshot);
        }
    }

    private void OnSnapshotChanged(InstallSnapshot snapshot)
    {
        if (snapshot.ContentId == contentId)
        {
            Apply(snapshot);
        }
    }

    private void Apply(InstallSnapshot snapshot)
    {
        bool showProgress = snapshot.State == InstallState.Queued
            || snapshot.State == InstallState.Downloading
            || snapshot.State == InstallState.Installing;

        progressRoot.SetActive(showProgress);
        progressBar.value = snapshot.Progress;

        switch (snapshot.State)
        {
            case InstallState.NotEntitled:
                actionLabel.text = "구매 필요";
                actionButton.interactable = true;
                break;

            case InstallState.NotInstalled:
                actionLabel.text = "다운로드";
                actionButton.interactable = true;
                break;

            case InstallState.Queued:
                actionLabel.text = "대기 중";
                actionButton.interactable = false;
                break;

            case InstallState.Downloading:
                actionLabel.text = "다운로드 중지";
                actionButton.interactable = true;
                progressLabel.text = $"{snapshot.Progress:P0}";
                break;

            case InstallState.Installing:
                actionLabel.text = "설치 중";
                actionButton.interactable = false;
                progressLabel.text = "설치 중...";
                break;

            case InstallState.Installed:
                actionLabel.text = "플레이";
                actionButton.interactable = true;
                progressLabel.text = "설치 완료";
                break;

            case InstallState.Failed:
                actionLabel.text = "다시 시도";
                actionButton.interactable = true;
                progressLabel.text = "설치 실패";
                break;

            default:
                actionLabel.text = "확인 중...";
                actionButton.interactable = false;
                break;
        }
    }

    private void OnDestroy()
    {
        if (installService != null)
        {
            installService.SnapshotChanged -= OnSnapshotChanged;
        }
    }
}

Button.interactable는 사용자 경험을 위한 표시일 뿐 보안 경계가 아닙니다. 실제 진입 함수에서 반드시 최신 상태를 다시 조회해야 합니다.

실제 콘텐츠 진입 직전에 무엇을 다시 검사해야 할까?

콘텐츠 진입 서비스는 UI 상태를 신뢰하지 않고 다음 조건을 다시 검사해야 합니다.

  • 콘텐츠 권한이 있는가
  • 설치 상태가 Installed인가
  • 필요한 종속 콘텐츠도 설치되었는가
  • 현재 게임 버전과 콘텐츠 버전이 호환되는가
  • 패키지 검증 또는 마운트가 완료되었는가
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;

public sealed class ContentEntryService
{
    private readonly ContentInstallService installService;
    private readonly IContentManifest manifest;
    private readonly IEntitlementService entitlementService;

    public ContentEntryService(
        ContentInstallService installService,
        IContentManifest manifest,
        IEntitlementService entitlementService)
    {
        this.installService = installService;
        this.manifest = manifest;
        this.entitlementService = entitlementService;
    }

    public async Task<bool> TryEnterAsync(
        string contentId,
        CancellationToken cancellationToken)
    {
        ContentDefinition definition = manifest.Get(contentId);
        if (definition == null)
        {
            return false;
        }

        if (!await entitlementService.HasEntitlementAsync(
                contentId,
                cancellationToken))
        {
            return false;
        }

        InstallSnapshot snapshot = await installService.RefreshAsync(
            contentId,
            cancellationToken);

        if (snapshot.State != InstallState.Installed)
        {
            return false;
        }

        foreach (string dependencyId in definition.Dependencies)
        {
            InstallSnapshot dependency = await installService.RefreshAsync(
                dependencyId,
                cancellationToken);

            if (!dependency.IsUsable)
            {
                return false;
            }
        }

        AsyncOperation loadOperation = SceneManager.LoadSceneAsync(
            definition.SceneName,
            LoadSceneMode.Additive);
        while (!loadOperation.isDone)
        {
            await Task.Yield();
        }
        return true;
    }
}

권한 검사도 IContentManifest 또는 별도의 IEntitlementService를 통해 분리하는 것이 좋습니다. 설치되었다고 해서 항상 사용할 권한이 있는 것은 아니며 반대로 권한이 있어도 아직 설치되지 않았을 수 있습니다.

다운로드 진행률은 왜 튀거나 멈춰 보이는가?

플랫폼 SDK가 전달하는 진행률 이벤트는 일정한 주기로 오지 않을 수 있습니다. 다운로드 속도나 네트워크 상태에 따라 동일한 값이 반복되거나 짧은 시간 동안 진행률이 증가하지 않을 수 있습니다.

진행률 계산은 가능하면 바이트 기준으로 처리합니다.

progress=bytesDownloadedbytesTotalprogress = \frac{bytesDownloaded}{bytesTotal}

단, bytesTotal이 0이거나 플랫폼이 전체 크기를 제공하지 않는 경우를 고려해야 합니다.

private static float CalculateProgress(
    long bytesDownloaded,
    long bytesTotal)
{
    if (bytesTotal <= 0)
    {
        return 0f;
    }

    return Mathf.Clamp01(
        (float)bytesDownloaded / bytesTotal);
}

UI에는 다음 규칙을 적용하면 불필요한 깜빡임을 줄일 수 있습니다.

  • 진행률이 감소하지 않도록 단조 증가시킵니다. 단, 새 다운로드 작업이 시작되면 초기화합니다.
  • 표시용 갱신 주기를 초당 5~10회 정도로 제한합니다.
  • Downloading에서 Installing으로 바뀌는 순간에는 진행률보다 상태 문구를 우선합니다.
  • 전체 크기를 알 수 없으면 확정된 퍼센트 대신 다운로드 중...을 표시합니다.
  • 완료 이벤트를 받은 뒤 한 번 더 설치 상태를 조회합니다.

표시용 스무딩은 실제 상태를 바꾸면 안 됩니다. 예를 들어 UI 애니메이션이 99%에서 100%로 올라가는 동안 실제 상태가 Failed로 바뀌면 즉시 오류 상태를 표시해야 합니다.

백그라운드 전환과 재개는 어떻게 처리할까?

콘솔 게임에서는 사용자가 게임을 일시 중단하거나 다른 앱으로 이동할 수 있습니다. 게임이 다시 활성화될 때 메모리에 남아 있는 값을 그대로 표시하면 오래된 상태가 됩니다.

Unity에서는 OnApplicationFocus, OnApplicationPause 또는 플랫폼별 라이프사이클 콜백에서 콘텐츠 상태를 다시 조회합니다.

using System.Threading;
using UnityEngine;

public sealed class ContentStatusLifecycle : MonoBehaviour
{
    private ContentInstallService installService;
    private CancellationTokenSource refreshCts;

    public void Initialize(ContentInstallService service)
    {
        installService = service;
    }

    private async void OnApplicationFocus(bool hasFocus)
    {
        if (!hasFocus)
        {
            refreshCts?.Cancel();
            refreshCts = null;
            return;
        }
        if (installService == null)
        {
            return;
        }

        var invocationCts = new CancellationTokenSource();
        CancellationTokenSource previousCts = refreshCts;
        refreshCts = invocationCts;
        previousCts?.Cancel();

        // 실제 프로젝트에서는 콘텐츠 매니페스트의 선택 콘텐츠 목록을 순회합니다.
        try
        {
            foreach (string contentId in ContentCatalog.OptionalContentIds)
            {
                await installService.RefreshAsync(
                    contentId,
                    invocationCts.Token);
            }
        }
        catch (OperationCanceledException) when (invocationCts.IsCancellationRequested)
        {
            // Focus changed or this component is being destroyed.
        }
        catch (Exception exception)
        {
            Debug.LogException(exception);
        }
        finally
        {
            if (ReferenceEquals(refreshCts, invocationCts))
                refreshCts = null;

            invocationCts.Dispose();
        }
    }

    private void OnDestroy()
    {
        refreshCts?.Cancel();
        refreshCts = null;
    }
}

콘텐츠 수가 많다면 포커스를 얻을 때 모든 콘텐츠를 동시에 조회하지 말고 다음 정책을 적용할 수 있습니다.

  • 현재 화면에 보이는 콘텐츠 우선 조회
  • 다운로드 중인 콘텐츠 우선 조회
  • 마지막 조회 시각이 오래된 콘텐츠만 조회
  • 플랫폼 이벤트가 누락된 콘텐츠만 재검증

이때 캐시의 유효 시간은 설치 완료 판정의 근거가 되어서는 안 됩니다. 캐시는 메뉴 표시를 빠르게 하는 용도이고 최종 진입 판정은 신선한 플랫폼 상태를 사용해야 합니다.

오류와 재시도 UX는 어떻게 구성할까?

모든 실패를 다운로드 실패로 표시하면 사용자가 무엇을 해야 하는지 알 수 없습니다. 가능하다면 오류를 다음처럼 분류합니다.

오류 유형사용자에게 보여줄 안내재시도 정책
네트워크 단절인터넷 연결을 확인하세요자동 재시도 후 수동 재시도
저장 공간 부족저장 공간을 확보하세요자동 재시도하지 않음
권한 없음콘텐츠 구매 또는 계정 권한을 확인하세요구매 화면으로 이동
서버 유지보수잠시 후 다시 시도하세요지수 백오프 적용
콘텐츠 손상콘텐츠를 다시 설치하세요검증 후 재다운로드
플랫폼 오류플랫폼 상태를 확인하세요오류 코드 기록 후 수동 재시도

재시도 횟수는 무제한으로 두지 않는 편이 좋습니다. 자동 재시도를 적용한다면 초기 지연 시간 t0와 최대 지연 시간 tmax를 두고 다음과 같은 지수 백오프를 사용할 수 있습니다.

waitTime=min(tmax,t0×2n)waitTime = min(t_{max}, t_0 \times 2^n)

여기서 n은 연속 실패 횟수입니다. 저장 공간 부족이나 권한 없음처럼 사용자의 행동이 필요한 오류에는 자동 재시도를 적용하지 않아야 합니다.

구현 단계 체크리스트

1단계: 콘텐츠 매니페스트를 고정한다

각 선택 콘텐츠에 최소한 다음 정보를 포함합니다.

using System;

[Serializable]
public sealed class ContentDefinition
{
    public string ContentId;
    public string SceneName;
    public string[] Dependencies;
    public long ExpectedSizeBytes;
    public string Version;
}

콘텐츠 ID는 UI 이름이나 씬 이름과 분리합니다. 이름이 현지화되거나 씬 구조가 바뀌어도 플랫폼의 설치 패키지와 연결되는 식별자는 유지되어야 합니다.

2단계: 플랫폼 어댑터를 구현한다

플랫폼별 SDK 응답을 InstallSnapshot으로 변환합니다. 다음 항목은 어댑터의 책임으로 둡니다.

  • 설치 상태 조회
  • 다운로드 시작과 일시정지
  • 플랫폼 콜백 수신
  • 바이트 단위 진행률 변환
  • 플랫폼 오류 코드를 공통 오류 분류로 변환
  • 게임 재개 시 상태 재조회

3단계: UI와 게임플레이 진입을 서비스에 연결한다

UI는 스냅샷을 구독하고 문구와 버튼 상태를 그립니다. 게임플레이 코드는 TryEnterAsync 같은 진입 서비스를 호출합니다. 어느 쪽도 플랫폼 SDK를 직접 호출하지 않게 하면 테스트와 플랫폼 교체가 쉬워집니다.

4단계: 재개와 이벤트 누락을 검증한다

다음 시나리오를 반드시 테스트합니다.

  • 다운로드 중 콘솔을 일시 중단한 뒤 재개
  • 다운로드 중 인터넷 연결 해제
  • 다운로드 중 게임 종료 후 재실행
  • 완료 이벤트 직전에 메뉴 닫기
  • 동일 콘텐츠에 다운로드 버튼을 빠르게 여러 번 입력
  • 저장 데이터가 이미 설치된 콘텐츠를 가리키는 경우
  • 콘텐츠는 설치됐지만 종속 에셋이 없는 경우
  • 저장 공간 부족으로 설치가 중단된 경우
  • 플랫폼 계정을 변경한 뒤 콘텐츠 메뉴 진입

테스트에서 확인할 로그 항목

설치 문제는 재현 시점과 플랫폼 상태를 함께 기록해야 원인을 찾을 수 있습니다. 최소한 다음 필드를 구조화된 로그로 남깁니다.

로그 필드목적
contentId어떤 콘텐츠에서 문제가 발생했는지 확인
previousState상태 전이 직전 상태 확인
nextState잘못된 전이 또는 누락된 이벤트 확인
bytesDownloaded진행률 계산 검증
bytesTotal전체 용량과 비교
platformErrorCodeSDK 원인 분석
appVersion클라이언트 호환성 확인
contentVersion매니페스트 불일치 확인
timestamp백그라운드 전환과 이벤트 순서 분석

특히 Installed 상태를 기록할 때는 다운로드 완료 시각과 설치·마운트 완료 시각을 구분하면 유용합니다.

자주 묻는 질문 (FAQ)

Q1. UI에서 버튼을 비활성화했는데도 진입 검사가 필요한가요?

필요합니다. 버튼 외에도 저장 데이터 복원, 알림, 자동 이동, 딥 링크 등 여러 경로에서 콘텐츠 진입이 발생할 수 있습니다. 최종 진입 서비스에서 Installed와 종속 콘텐츠 상태를 다시 확인해야 합니다.

Q2. 다운로드 진행률이 100%면 바로 플레이해도 되나요?

항상 그렇지는 않습니다. 다운로드 이후 설치, 압축 해제, 패키지 마운트 또는 파일 검증 단계가 있을 수 있습니다. 플랫폼의 설치 완료 콜백이나 최종 상태 조회가 확인된 뒤 Installed로 전환해야 합니다.

Q3. 플랫폼별 설치 API를 게임 코드에서 직접 호출해도 되나요?

가능하면 피하는 것이 좋습니다. IPlatformContentProvider 같은 어댑터로 플랫폼 API를 감싸고 게임 코드에는 공통 상태와 콘텐츠 ID만 노출하면 플랫폼별 차이와 테스트 복잡도를 줄일 수 있습니다.

정리

부분 설치 가능한 콘솔 게임의 핵심은 다운로드 기능 자체가 아니라 설치 상태를 신뢰할 수 있는 단일 흐름으로 관리하는 데 있습니다.

  • 콘텐츠 ID별 상태 머신을 정의합니다.
  • 플랫폼 SDK 상태를 공통 InstallState로 변환합니다.
  • UI는 서비스가 발행하는 스냅샷을 구독합니다.
  • 동일 콘텐츠의 중복 다운로드 요청을 합칩니다.
  • 백그라운드 복귀 시 상태를 다시 조회합니다.
  • 실제 콘텐츠 진입 직전에 설치 상태와 종속성을 재검사합니다.
  • 다운로드 완료와 설치 완료를 구분합니다.

이 구조를 적용하면 미설치 콘텐츠 진입으로 인한 런타임 오류를 줄이고 사용자가 현재 다운로드 상태와 다음 행동을 명확하게 이해할 수 있습니다.