GDK 및 Windows Store 빌드에서 Unreal Engine Xbox Save Roaming 해결 방법
핵심 요약
이 글은 Unreal Engine을 사용하는 Xbox 및 PC GDK 빌드에서 발생하는 세이브 로밍(Save Roaming) 실패 원인과 해결 방법을 다룹니다. 부팅 시 비동기 로그인 흐름 때문에 발생하는 레이스 컨디션(race condition) 문제를 분석하고, 안전하게 사용자 인덱스를 확인하는 C++ 구현 코드와 구성 파일 설정법을 제시합니다. 아울러 플랫폼 종속성을 우회하여 크로스 플랫폼 세이브를 구현할 수 있는 대안으로 horizOn 백엔드 서비스를 소개합니다.
GDK Save Roaming의 악몽
Xbox Series X 및 Windows Store GDK 빌드를 패키징하고 업적 동기화를 확인한 뒤, 부팅 시 클라우드 동기화 스피너가 작동하는 것까지 확인했음에도 플레이어의 세이브 파일이 각 플랫폼에 완전히 격리되어 있는 당혹스러운 상황을 마주하게 됩니다. 사용자 관점에서는 로그인에 성공하고 알림이 트리거되며 두 빌드 모두 게임 실행 간에 세이브 파일이 로컬에 유지되는 등 모든 것이 정상적으로 작동하는 것처럼 보입니다. 하지만 Xbox에서의 플레이 진행 상황이 PC 빌드로 로밍(roaming)되지 않으며, 그 반대도 마찬가지입니다.
세이브 데이터 불일치 문제를 해결하는 것은 라이브 게임플레이 도중 multiplayer desyncs 문제를 디버깅하는 것만큼이나 답답한 일입니다. 두 문제 모두 상태 권한(state authority)의 불일치에서 비롯됩니다. Xbox Play Anywhere (XPA)의 경우, 이 문제는 콘솔 레벨의 결함인 경우가 거의 없습니다. 대부분의 경우 Unreal Engine이 정수 기반의 추상적인 사용자 인덱스(user index)를 Microsoft의 GDK 전용 identity 시스템에 매핑하는 과정에서 발생하는 레이스 컨디션(race condition)이 원인입니다. 이 현상이 발생하는 원인과 C++ 및 Blueprints 모두에서 이를 해결하는 방법을 살펴보겠습니다.
GDK Connected Storage의 내부 구조
Save roaming이 실패하는 원인을 이해하려면, 먼저 Microsoft GDK가 내부적으로 사용자 세이브 파일을 처리하는 방식을 이해해야 합니다. 세이브 파일을 원격 서버에 업로드되는 일반 파일로 취급하는 Steam이나 커스텀 게임 Backend와 달리, Microsoft Game Development Kit (GDK)는 Connected Storage (XGameSave API)를 사용합니다. 이 아키텍처에서 세이브는 단순한 파일 경로가 아니라 구조화된 컨테이너 시스템으로 관리됩니다. 이 컨테이너는 Xbox Live 서버와 안전하게 동기화되는 개별 세이브 파일 blob을 포함합니다.
Xbox 콘솔과 Windows Store (PC) 빌드 간에 클라우드 save roaming이 제대로 작동하려면 GDK의 엄격한 매칭 매트릭스가 필요합니다. 두 빌드는 완전히 동일한 Title ID, Service Configuration ID (SCID), 그리고 Sandbox(예: RETAIL 또는 XDKS.1)를 공유해야 합니다. 또한 플레이어는 양쪽 기기 모두에서 동일한 Microsoft Account (MSA)로 로그인되어 있어야 합니다. 마지막으로 세이브 시스템에서 생성하는 컨테이너 이름이 한 글자도 빠짐없이 완전히 일치해야 합니다. 그렇지 않으면 클라우드 동기화 시스템이 이를 서로 독립된 데이터 세트로 처리하게 됩니다.
또한 Connected Storage는 타이틀당 사용자별로 최대 256MB의 스토리지를 할당하며, 개별 컨테이너 한도는 16MB입니다. 만약 세이브 시스템이 이러한 하드 제한을 초과하는 경우, API가 자동으로 실패하거나 Unreal Engine의 기본 세이브 시스템이 상위 레이어로 전달하지 못하는 에러 코드를 반환합니다. 따라서 이러한 제약 조건을 염두에 두고 세이브 시스템을 설계하여 파일 크기를 작고 구조화된 상태로 유지하는 것이 매우 중요합니다.
근본적인 원인: 비동기 로그인 레이스 컨디션
Unreal Engine 프로젝트에서 GDK save roaming이 실패하는 가장 흔한 원인은 게임이 저장 주체를 판별하는 방식에서 발생하는 미묘한 레이스 컨디션(race condition)입니다. Xbox Series X 콘솔의 경우, 운영체제(OS)가 활성화된 컨트롤러 프로필을 중심으로 빌드되어 있습니다. 게임이 부팅될 때 이미 OS 레벨에서 사용자가 선택된 상태입니다. 콘솔은 게임의 실행 컨텍스트에 XUserHandle을 자동으로 할당하고, Unreal Engine은 이 활성 프로필을 거의 즉시 UserIndex 0에 매핑합니다.
하지만 Windows PC의 경우 흐름이 완전히 다릅니다. 게임은 활성 프로필에 대한 요청 없이 즉시 부팅됩니다. Xbox App 또는 Windows Store 로그인 흐름은 백그라운드에서 비동기적으로 실행됩니다. 만약 부팅 직후(예: GameInstance::Init 호출이나 메인 메뉴의 BeginPlay 등) 게임 코드에서 LoadGameFromSlot 또는 DoesSaveGameExist를 호출하면, GDK Online Subsystem이 아직 사용자 로그인을 완료하지 않은 상태일 수 있습니다.
사용자가 로그인하기 전에 UserIndex = 0으로 Unreal Engine의 ISaveGameSystem(정확히는 GDK 구현체인 FGameSaveSystemGDK)을 호출하면, 시스템은 로컬 게스트 유저(guest user)로 폴백(fallback)합니다. 그러면 GDK는 클라우드 동기화 컨테이너를 우회하여 이 세션을 PC의 로컬 오프라인 스토리지에 매핑합니다. 몇 초 후에 비동기 로그인이 마침내 완료되더라도 게임은 이미 로컬 데이터로 실행 중입니다. 이 상태에서 플레이어가 새로운 세이브를 작성하면, 엔진이 이를 로컬 게스트 프로필에 저장하거나 새로 확인된 MSA 컨테이너에 기록하려고 시도하면서 상태 분리(state split)가 발생하고, 결과적으로 콘솔의 클라우드 프로필과 PC의 로컬 프로필이 서로 완전히 다른 파일을 갖게 됩니다.
Unreal Engine이 UserIndex를 XUserHandle에 매핑하는 방법
Unreal Engine의 세이브 시스템은 현대적인 플랫폼 identity 시스템이 나오기 훨씬 전에 설계되었습니다. 로컬 협동 플레이(co-op)에서 플레이어를 구분하기 위해 단순히 정수형 변수(UserIndex)를 사용합니다. GDK 구현에서는 엔진이 내부 GDK 서브시스템 헬퍼를 호출하여 이 정수를 XUserHandle로 변환하려고 시도합니다. 만약 플레이어 인덱스가 유효하지 않거나 초기화되지 않은 핸들로 매핑되면, 세이브 작업은 아무런 경고 없이 로컬 디렉터리로 폴백됩니다.
이를 방지하려면 두 가지 사항을 확인해야 합니다. 첫째, Online Subsystem identity 인터페이스가 플레이어 로그인 완료를 보고할 때까지는 세이브 시스템에 접근해서는 안 됩니다. 둘째, 로컬 플레이어 컨트롤러의 인덱스가 항상 0이라고 가정하는 대신, 로그인한 사용자의 FUniqueNetId와 연결된 올바른 플랫폼 전용 UserIndex를 찾아내야 합니다. 이러한 워크플로우를 보장하는 시스템을 직접 구현해 보겠습니다.
구현 가이드: 레이스 컨디션 해결하기
1단계: GDK Save Utility 클래스 작성
UBlueprintFunctionLibrary를 상속하는 커스텀 UGDKSaveHelper 클래스를 생성합니다. 이 클래스에는 올바른 사용자 인덱스를 가져오고 로그인 여부를 확인하는 로직이 포함됩니다. 헤더 파일(GDKSaveHelper.h)은 다음과 같습니다:
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "GDKSaveHelper.generated.h"
UCLASS()
class HORIZON_API UGDKSaveHelper : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "GDK Save System")
static int32 GetGDKUserIndex(const APlayerController* PlayerController);
UFUNCTION(BlueprintPure, Category = "GDK Save System")
static bool IsPlayerLoggedInGDK(const APlayerController* PlayerController);
};
그리고 로컬 플레이어의 네트워크 ID를 플랫폼별 사용자 인덱스에 안전하게 매핑하기 위해 Online Subsystem을 사용하는 구현 파일(GDKSaveHelper.cpp)입니다:
#include "GDKSaveHelper.h"
#include "GameFramework/PlayerController.h"
#include "OnlineSubsystem.h"
#include "OnlineSubsystemUtils.h"
int32 UGDKSaveHelper::GetGDKUserIndex(const APlayerController* PlayerController)
{
if (!PlayerController)
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlayerController is null."));
return -1;
}
IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
if (!OnlineSub)
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: OnlineSubsystem is null."));
return -1;
}
IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
if (!IdentityInterface.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: IdentityInterface is invalid."));
return -1;
}
ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (!LocalPlayer)
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: LocalPlayer is null."));
return -1;
}
FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
if (!UniqueId.IsValid() || !UniqueId.GetUniqueNetId()->IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: Unique ID is not valid yet."));
return -1;
}
int32 PlatformUserIndex = IdentityInterface->GetPlatformUserIndexFromUniqueNetId(*UniqueId.GetUniqueNetId());
if (PlatformUserIndex == PLATFORMUSERID_NONE)
{
UE_LOG(LogTemp, Warning, TEXT("GetGDKUserIndex: PlatformUserIndex mapped to PLATFORMUSERID_NONE."));
return -1;
}
UE_LOG(LogTemp, Log, TEXT("GetGDKUserIndex: Successfully resolved UserIndex to %d"), PlatformUserIndex);
return PlatformUserIndex;
}
bool UGDKSaveHelper::IsPlayerLoggedInGDK(const APlayerController* PlayerController)
{
if (!PlayerController)
{
return false;
}
IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
if (OnlineSub)
{
IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();
if (IdentityInterface.IsValid())
{
ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (LocalPlayer)
{
FUniqueNetIdRepl UniqueId = LocalPlayer->GetPreferredUniqueNetId();
if (UniqueId.IsValid() && UniqueId.GetUniqueNetId()->IsValid())
{
ELoginStatus::Type LoginStatus = IdentityInterface->GetLoginStatus(*UniqueId.GetUniqueNetId());
return LoginStatus == ELoginStatus::LoggedIn;
}
}
}
}
return false;
}
2단계: 세이브 및 로드 로직 수정하기
Blueprint 또는 C++ 게임 상태 관리에서 로드 호출을 래핑(wrap)해야 합니다. 시작 시 즉시 LoadGameFromSlot을 호출하는 대신, 로그인 완료를 기다리는 검사 로직을 구현하세요. Online Identity 서브시스템의 On Login Complete 이벤트에 바인딩합니다. 이벤트가 트리거되면 사용자가 완전히 로그인되었는지 확인하고, 확인된 인덱스를 쿼리한 뒤 이를 세이브 게임 노드에 직접 전달하십시오.
로그인에 실패하거나 플레이어가 취소하는 경우, 게임을 '게스트(guest)' 상태로 처리해야 합니다. 플레이어에게 진행 상황이 클라우드에 저장되지 않고 현재 기기에 로컬로 남게 된다는 경고를 표시하십시오. 이렇게 하면 플레이어가 항상 자신의 저장 상태를 인지하게 되어 기기를 전환할 때 발생할 수 있는 예상치 못한 진행 상황 손실을 방지할 수 있습니다.
3단계: 구성 파일 정렬하기
코드에서 올바른 사용자 인덱스를 찾더라도 Xbox GDK와 PC GDK 패키지의 구성 파일이 일치하지 않으면 save roaming은 실패합니다. DefaultEngine.ini를 열고 GDK online subsystem 설정이 올바른지 확인하십시오. Title ID 및 Service Configuration ID (SCID)가 Microsoft Partner Center 등록 내용과 정확히 일치해야 합니다.
[OnlineSubsystemGDK]
bEnabled=true
TitleId=1234ABCD
ServiceConfigId=12345678-1234-abcd-1234-1234567890ab
bEnableConfigService=true
[OnlineSubsystem]
DefaultPlatformService=GDK
애플리케이션을 패키징하는 MicrosoftGame.config에서 Identity 및 Title ID가 Partner Center 대시보드의 값과 정확히 일치하는지 확인합니다:
<GameConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Identity Name="YourPublisher.YourGameName"
Publisher="CN=12345678-1234-abcd-1234-1234567890ab"
Version="1.0.0.0"/>
<TitleId>1234ABCD</TitleId>
<MSAAppId>0000000012345678</MSAAppId>
<DevelopmentServer>RETAIL</DevelopmentServer>
</GameConfig>
샌드박스(예: XDKS.1)에서 테스트할 때는 테스트 계정이 해당 샌드박스에 추가되어 있어야 하며, PC의 Xbox App에 정상적으로 로그인되어 있어야 합니다. Xbox App이 Windows Store와 다른 계정으로 로그인되어 있으면 Windows GDK API가 XGameSave 프로바이더 초기화에 실패하여 로컬 디렉터리가 일치하지 않게 됩니다. 세이브 파일이 작성되는 로컬 AppData 폴더를 확인하여 이를 검증할 수 있습니다.
대안: horizOn을 통한 플랫폼 종속 없는 클라우드 세이브
Microsoft GDK Connected Storage API를 구성하고, 오프라인 게스트 폴백을 처리하며, PC와 콘솔 환경에서 동일한 샌드박스를 유지하는 작업은 설정과 디버깅에만 수주일이 걸릴 수 있습니다. 성공적으로 구현하더라도 결국 Microsoft 생태계에 종속(lock-in)됩니다. 이러한 세이브 파일은 Steam, Epic Games Store, PlayStation 또는 Nintendo Switch로 로밍되지 않습니다.
플랫폼별 래퍼(wrapper)를 작성하거나 실시간 Backend를 위한 Unreal Engine WebSockets tutorial과 같은 복잡한 실시간 설정을 사용하는 대신, horizOn과 같은 플랫폼 독립적인 Backend 서비스에 세이브 상태 아키텍처를 전적으로 오프로드(offload)할 수 있습니다. horizOn을 사용하면 세이브 상태 관리가 플랫폼 고유의 API로부터 완벽히 분리(decouple)됩니다. Steam, Epic, 또는 Xbox Live와 같은 플랫폼 ID를 사용하여 플레이어를 인증하고 단일화된 클라우드 데이터베이스에 데이터를 저장하게 됩니다.
이를 통해 Xbox 샌드박스 불일치 문제를 완전히 우회할 수 있으며, Steam, Xbox 및 PlayStation 간의 진정한 크로스 플랫폼 save roaming이 가능해집니다. 다음은 Unreal Engine C++에서 해당 서비스의 REST API를 사용해 플레이어 진행 상황을 글로벌하게 저장하는 예시입니다:
#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"
void SaveToHorizonCloud(const FString& PlayerToken, const FString& SaveDataJson)
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(TEXT("https://api.horizon.pm/v1/save-state"));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Request->SetHeader(TEXT("Authorization"), FString::Printf(TEXT("Bearer %s"), *PlayerToken));
TSharedPtr<FJsonObject> Payload = MakeShareable(new FJsonObject());
Payload->SetStringField(TEXT("slot_name"), TEXT("SaveSlot1"));
Payload->SetStringField(TEXT("data"), SaveDataJson);
FString OutputString;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
FJsonSerializer::Serialize(Payload.ToSharedRef(), Writer);
Request->SetContentAsString(OutputString);
Request->OnProcessRequestComplete().BindLambda([](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bWasSuccessful)
{
if (bWasSuccessful && Res->GetResponseCode() == 200)
{
UE_LOG(LogTemp, Log, TEXT("Save state synchronized successfully."));
}
else
{
UE_LOG(LogTemp, Error, TEXT("Failed to sync save state to cloud."));
}
});
Request->ProcessRequest();
}
견고한 클라우드 세이브 시스템을 직접 구축하려면 글로벌 데이터베이스를 설정하고, 안전한 세션 토큰을 관리하며, 오프라인 동기화 충돌을 해결해야 하므로 최소 4~6주의 엔지니어링 시간이 소요됩니다. 하지만 horizOn을 사용하면 이러한 Backend 서비스가 미리 빌드되어 제공되므로, 데이터베이스 인프라 구축에 시간을 허비하는 대신 게임 출시에 집중할 수 있습니다. 멀티 플랫폼 구성이라는 골치 아픈 문제를 단 한 번의 API 호출로 단순화해 줍니다.
크로스 플랫폼 세이브 아키텍처를 위한 Best Practices
Microsoft GDK Connected Storage 구성을 직접 해결하든, Backend 서비스를 통해 추상화하든 상관없이 다음 핵심 Best Practices를 준수해야 합니다:
- Never Save on Tick or Frame-End: Connected Storage는 디스크 마모 및 클라우드 동기화 스로틀링(throttling)을 방지하기 위해 쓰기 작업을 제한합니다. 세이브를 특정 트리거 포인트(체크포인트, 레벨 전환, 메뉴 종료 등)에서 일괄 처리(batch)하십시오.
- Implement Save Conflict Resolvers: PC의 세이브 타임스탬프가 14:00이고 Xbox의 세이브 타임스탬프가 14:05일 때 단순히 이전 데이터를 덮어쓰지 마십시오. 플레이 시간, 레벨 진행 상황 및 메타데이터를 보여주는 UI 프롬프트를 표시하여 플레이어가 직접 활성 상태를 선택할 수 있게 해야 합니다.
- Validate Save File Integrity: 저장하기 전에 세이브 데이터를 직렬화(serialize)하고 MD5 또는 SHA-256 해시를 생성하십시오. 클라우드 전송 중 파일 다운로드가 손상되지 않았는지 확인하기 위해 이 해시를 별도의 메타데이터 블록에 저장해야 합니다.
- Gracefully Handle Sign-Outs: GDK에서는 게임이 실행되는 동안 플레이어가 Xbox 대시보드를 통해 로그아웃할 수 있습니다.
OnLoginStatusChanged델리게이트를 등록하고, 활성 사용자가 변경되면 게임을 즉시 일시정지하도록 설정하십시오.
GDK 구성의 번거로움 없이 확장 가능하고 플랫폼에 구애받지 않는 클라우드 세이브 시스템을 구현할 준비가 되었다면 지금 바로 시작해 보세요. horizOn을 무료로 체험하거나 API docs를 검토하여 더 자세히 알아보십시오.