Online Subsystem

플랫폼 독립적인 온라인 서비스 통합 - 세션 관리, 인증, 크로스플레이

학습 목표

1. Online Subsystem 개요

1.1 Online Subsystem 아키텍처

Online Subsystem은 플랫폼별 온라인 서비스를 추상화하는 언리얼의 핵심 시스템입니다.

┌─────────────────────────────────────────────────────────────────┐
│                        게임 코드                                  │
│                  (플랫폼 독립적 API 호출)                          │
├─────────────────────────────────────────────────────────────────┤
│                   Online Subsystem Interface                     │
│                                                                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │ Session  │ │ Identity │ │ Friends  │ │  Leaderb │          │
│  │Interface │ │Interface │ │Interface │ │ Interface│          │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘          │
├─────────────────────────────────────────────────────────────────┤
│                   Platform Implementations                       │
│                                                                  │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐          │
│  │  Steam   │ │   EOS    │ │   Null   │ │  Custom  │          │
│  │Subsystem │ │Subsystem │ │Subsystem │ │Subsystem │          │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘          │
└─────────────────────────────────────────────────────────────────┘

1.2 주요 인터페이스

// Online Subsystem의 주요 인터페이스들
void ExploreOnlineSubsystem()
{
    // Online Subsystem 가져오기
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub)
    {
        UE_LOG(LogOnline, Warning, TEXT("No Online Subsystem available"));
        return;
    }

    // 현재 사용 중인 서브시스템 이름
    FName SubsystemName = OnlineSub->GetSubsystemName();
    UE_LOG(LogOnline, Log, TEXT("Using Online Subsystem: %s"), *SubsystemName.ToString());

    // ===== 주요 인터페이스 =====

    // 1. Session Interface - 게임 세션 관리
    IOnlineSessionPtr SessionInterface = OnlineSub->GetSessionInterface();

    // 2. Identity Interface - 사용자 인증
    IOnlineIdentityPtr IdentityInterface = OnlineSub->GetIdentityInterface();

    // 3. Friends Interface - 친구 목록
    IOnlineFriendsPtr FriendsInterface = OnlineSub->GetFriendsInterface();

    // 4. Leaderboards Interface - 리더보드
    IOnlineLeaderboardsPtr LeaderboardsInterface = OnlineSub->GetLeaderboardsInterface();

    // 5. Achievements Interface - 업적
    IOnlineAchievementsPtr AchievementsInterface = OnlineSub->GetAchievementsInterface();

    // 6. Presence Interface - 온라인 상태
    IOnlinePresencePtr PresenceInterface = OnlineSub->GetPresenceInterface();
}

1.3 프로젝트 설정

// MyGame.Build.cs
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicDependencyModuleNames.AddRange(new string[]
        {
            "Core",
            "CoreUObject",
            "Engine",
            "InputCore",
            "OnlineSubsystem",         // Online Subsystem 핵심
            "OnlineSubsystemUtils"     // 유틸리티 함수
        });

        // EOS 사용 시
        if (Target.Platform == UnrealTargetPlatform.Win64)
        {
            PublicDependencyModuleNames.Add("OnlineSubsystemEOS");
            PublicDependencyModuleNames.Add("EOSShared");
        }

        // Steam 사용 시
        PublicDependencyModuleNames.Add("OnlineSubsystemSteam");
    }
}

2. 세션 관리 시스템

2.1 Session Manager Subsystem

// SessionManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Interfaces/OnlineSessionInterface.h"
#include "OnlineSessionSettings.h"
#include "SessionManager.generated.h"

// 세션 검색 결과 데이터
USTRUCT(BlueprintType)
struct FSessionSearchResultData
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly)
    FString SessionId;

    UPROPERTY(BlueprintReadOnly)
    FString HostName;

    UPROPERTY(BlueprintReadOnly)
    int32 CurrentPlayers;

    UPROPERTY(BlueprintReadOnly)
    int32 MaxPlayers;

    UPROPERTY(BlueprintReadOnly)
    int32 PingInMs;

    UPROPERTY(BlueprintReadOnly)
    FString MapName;

    UPROPERTY(BlueprintReadOnly)
    FString GameMode;
};

// 세션 이벤트 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionCreated, bool, bWasSuccessful);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionsFound, const TArray&, Results);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionJoined, bool, bWasSuccessful, const FString&, ErrorMessage);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSessionDestroyed);

UCLASS()
class MYGAME_API USessionManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    // ===== 세션 생성 =====

    UFUNCTION(BlueprintCallable, Category = "Session")
    void CreateSession(int32 MaxPlayers, bool bIsLAN, const FString& MapName);

    // ===== 세션 검색 =====

    UFUNCTION(BlueprintCallable, Category = "Session")
    void FindSessions(bool bIsLAN = false);

    // ===== 세션 참가 =====

    UFUNCTION(BlueprintCallable, Category = "Session")
    void JoinSession(int32 SessionIndex);

    UFUNCTION(BlueprintCallable, Category = "Session")
    void JoinSessionById(const FString& SessionId);

    // ===== 세션 종료 =====

    UFUNCTION(BlueprintCallable, Category = "Session")
    void DestroySession();

    // ===== 세션 정보 =====

    UFUNCTION(BlueprintPure, Category = "Session")
    bool IsInSession() const;

    UFUNCTION(BlueprintPure, Category = "Session")
    bool IsSessionHost() const;

    // ===== 이벤트 =====

    UPROPERTY(BlueprintAssignable, Category = "Session|Events")
    FOnSessionCreated OnSessionCreated;

    UPROPERTY(BlueprintAssignable, Category = "Session|Events")
    FOnSessionsFound OnSessionsFound;

    UPROPERTY(BlueprintAssignable, Category = "Session|Events")
    FOnSessionJoined OnSessionJoined;

    UPROPERTY(BlueprintAssignable, Category = "Session|Events")
    FOnSessionDestroyed OnSessionDestroyed;

protected:
    // 델리게이트 핸들
    FDelegateHandle CreateSessionDelegateHandle;
    FDelegateHandle FindSessionsDelegateHandle;
    FDelegateHandle JoinSessionDelegateHandle;
    FDelegateHandle DestroySessionDelegateHandle;

    // 검색 결과 캐시
    TSharedPtr SessionSearch;

    // 콜백 함수
    void OnCreateSessionComplete(FName SessionName, bool bWasSuccessful);
    void OnFindSessionsComplete(bool bWasSuccessful);
    void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
    void OnDestroySessionComplete(FName SessionName, bool bWasSuccessful);

    // 헬퍼 함수
    IOnlineSessionPtr GetSessionInterface() const;
    FUniqueNetIdPtr GetLocalPlayerNetId() const;
};

2.2 Session Manager 구현

// SessionManager.cpp
#include "SessionManager.h"
#include "OnlineSubsystem.h"
#include "OnlineSessionSettings.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/LocalPlayer.h"
#include "GameFramework/PlayerController.h"

void USessionManager::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    UE_LOG(LogOnline, Log, TEXT("SessionManager initialized"));
}

void USessionManager::Deinitialize()
{
    // 세션이 있으면 정리
    if (IsInSession())
    {
        DestroySession();
    }

    Super::Deinitialize();
}

IOnlineSessionPtr USessionManager::GetSessionInterface() const
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        return OnlineSub->GetSessionInterface();
    }
    return nullptr;
}

FUniqueNetIdPtr USessionManager::GetLocalPlayerNetId() const
{
    UWorld* World = GetWorld();
    if (!World) return nullptr;

    const ULocalPlayer* LocalPlayer = World->GetFirstLocalPlayerFromController();
    if (LocalPlayer)
    {
        return LocalPlayer->GetPreferredUniqueNetId().GetUniqueNetId();
    }
    return nullptr;
}

// ===== 세션 생성 =====

void USessionManager::CreateSession(int32 MaxPlayers, bool bIsLAN, const FString& MapName)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (!Sessions.IsValid())
    {
        UE_LOG(LogOnline, Error, TEXT("Session interface not available"));
        OnSessionCreated.Broadcast(false);
        return;
    }

    FUniqueNetIdPtr LocalPlayerId = GetLocalPlayerNetId();
    if (!LocalPlayerId.IsValid())
    {
        UE_LOG(LogOnline, Error, TEXT("Local player not found"));
        OnSessionCreated.Broadcast(false);
        return;
    }

    // 기존 세션 정리
    auto ExistingSession = Sessions->GetNamedSession(NAME_GameSession);
    if (ExistingSession)
    {
        Sessions->DestroySession(NAME_GameSession);
    }

    // 델리게이트 바인딩
    CreateSessionDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle(
        FOnCreateSessionCompleteDelegate::CreateUObject(
            this, &USessionManager::OnCreateSessionComplete));

    // 세션 설정
    FOnlineSessionSettings SessionSettings;
    SessionSettings.bIsLANMatch = bIsLAN;
    SessionSettings.bUsesPresence = true;
    SessionSettings.NumPublicConnections = MaxPlayers;
    SessionSettings.NumPrivateConnections = 0;
    SessionSettings.bAllowInvites = true;
    SessionSettings.bAllowJoinInProgress = true;
    SessionSettings.bAllowJoinViaPresence = true;
    SessionSettings.bShouldAdvertise = true;
    SessionSettings.bUseLobbiesIfAvailable = true;

    // 커스텀 설정 추가
    SessionSettings.Set(
        SETTING_MAPNAME,
        MapName,
        EOnlineDataAdvertisementType::ViaOnlineService
    );

    SessionSettings.Set(
        FName("GameMode"),
        FString("CoOp"),
        EOnlineDataAdvertisementType::ViaOnlineService
    );

    SessionSettings.Set(
        FName("BuildVersion"),
        FString("1.0.0"),
        EOnlineDataAdvertisementType::ViaOnlineService
    );

    // 세션 생성
    Sessions->CreateSession(*LocalPlayerId, NAME_GameSession, SessionSettings);

    UE_LOG(LogOnline, Log, TEXT("Creating session - MaxPlayers: %d, LAN: %s"),
           MaxPlayers, bIsLAN ? TEXT("Yes") : TEXT("No"));
}

void USessionManager::OnCreateSessionComplete(FName SessionName, bool bWasSuccessful)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        Sessions->ClearOnCreateSessionCompleteDelegate_Handle(CreateSessionDelegateHandle);
    }

    if (bWasSuccessful)
    {
        UE_LOG(LogOnline, Log, TEXT("Session created successfully: %s"), *SessionName.ToString());

        // Listen 서버로 맵 열기
        UWorld* World = GetWorld();
        if (World)
        {
            // 세션 설정에서 맵 이름 가져오기
            FString MapName = TEXT("MainLevel");
            if (Sessions.IsValid())
            {
                FOnlineSessionSettings* Settings = Sessions->GetSessionSettings(NAME_GameSession);
                if (Settings)
                {
                    Settings->Get(SETTING_MAPNAME, MapName);
                }
            }

            // listen 옵션으로 맵 로드
            UGameplayStatics::OpenLevel(World, FName(*MapName), true, TEXT("listen"));
        }
    }
    else
    {
        UE_LOG(LogOnline, Error, TEXT("Failed to create session"));
    }

    OnSessionCreated.Broadcast(bWasSuccessful);
}

// ===== 세션 검색 =====

void USessionManager::FindSessions(bool bIsLAN)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (!Sessions.IsValid())
    {
        UE_LOG(LogOnline, Error, TEXT("Session interface not available"));
        OnSessionsFound.Broadcast(TArray());
        return;
    }

    FUniqueNetIdPtr LocalPlayerId = GetLocalPlayerNetId();
    if (!LocalPlayerId.IsValid())
    {
        UE_LOG(LogOnline, Error, TEXT("Local player not found"));
        OnSessionsFound.Broadcast(TArray());
        return;
    }

    // 델리게이트 바인딩
    FindSessionsDelegateHandle = Sessions->AddOnFindSessionsCompleteDelegate_Handle(
        FOnFindSessionsCompleteDelegate::CreateUObject(
            this, &USessionManager::OnFindSessionsComplete));

    // 검색 설정
    SessionSearch = MakeShareable(new FOnlineSessionSearch());
    SessionSearch->MaxSearchResults = 20;
    SessionSearch->bIsLanQuery = bIsLAN;
    SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);

    // 빌드 버전 필터 (선택사항)
    // SessionSearch->QuerySettings.Set(
    //     FName("BuildVersion"),
    //     FString("1.0.0"),
    //     EOnlineComparisonOp::Equals);

    // 검색 시작
    Sessions->FindSessions(*LocalPlayerId, SessionSearch.ToSharedRef());

    UE_LOG(LogOnline, Log, TEXT("Searching for sessions - LAN: %s"),
           bIsLAN ? TEXT("Yes") : TEXT("No"));
}

void USessionManager::OnFindSessionsComplete(bool bWasSuccessful)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        Sessions->ClearOnFindSessionsCompleteDelegate_Handle(FindSessionsDelegateHandle);
    }

    TArray Results;

    if (bWasSuccessful && SessionSearch.IsValid())
    {
        UE_LOG(LogOnline, Log, TEXT("Found %d sessions"),
               SessionSearch->SearchResults.Num());

        for (int32 i = 0; i < SessionSearch->SearchResults.Num(); i++)
        {
            const FOnlineSessionSearchResult& SearchResult = SessionSearch->SearchResults[i];
            const FOnlineSession& Session = SearchResult.Session;

            FSessionSearchResultData ResultData;
            ResultData.SessionId = Session.GetSessionIdStr();
            ResultData.CurrentPlayers = Session.SessionSettings.NumPublicConnections -
                                        Session.NumOpenPublicConnections;
            ResultData.MaxPlayers = Session.SessionSettings.NumPublicConnections;
            ResultData.PingInMs = SearchResult.PingInMs;

            // 호스트 이름
            ResultData.HostName = Session.OwningUserName;

            // 커스텀 설정 가져오기
            Session.SessionSettings.Get(SETTING_MAPNAME, ResultData.MapName);
            Session.SessionSettings.Get(FName("GameMode"), ResultData.GameMode);

            Results.Add(ResultData);

            UE_LOG(LogOnline, Log, TEXT("  [%d] %s - %s (%d/%d) Ping: %dms"),
                   i, *ResultData.HostName, *ResultData.MapName,
                   ResultData.CurrentPlayers, ResultData.MaxPlayers,
                   ResultData.PingInMs);
        }
    }
    else
    {
        UE_LOG(LogOnline, Warning, TEXT("Session search failed or returned no results"));
    }

    OnSessionsFound.Broadcast(Results);
}

// ===== 세션 참가 =====

void USessionManager::JoinSession(int32 SessionIndex)
{
    if (!SessionSearch.IsValid() ||
        SessionIndex < 0 ||
        SessionIndex >= SessionSearch->SearchResults.Num())
    {
        UE_LOG(LogOnline, Error, TEXT("Invalid session index: %d"), SessionIndex);
        OnSessionJoined.Broadcast(false, TEXT("Invalid session index"));
        return;
    }

    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (!Sessions.IsValid())
    {
        OnSessionJoined.Broadcast(false, TEXT("Session interface not available"));
        return;
    }

    FUniqueNetIdPtr LocalPlayerId = GetLocalPlayerNetId();
    if (!LocalPlayerId.IsValid())
    {
        OnSessionJoined.Broadcast(false, TEXT("Local player not found"));
        return;
    }

    // 델리게이트 바인딩
    JoinSessionDelegateHandle = Sessions->AddOnJoinSessionCompleteDelegate_Handle(
        FOnJoinSessionCompleteDelegate::CreateUObject(
            this, &USessionManager::OnJoinSessionComplete));

    // 세션 참가
    Sessions->JoinSession(
        *LocalPlayerId,
        NAME_GameSession,
        SessionSearch->SearchResults[SessionIndex]
    );

    UE_LOG(LogOnline, Log, TEXT("Joining session at index: %d"), SessionIndex);
}

void USessionManager::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        Sessions->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionDelegateHandle);
    }

    FString ErrorMessage;
    bool bSuccess = (Result == EOnJoinSessionCompleteResult::Success);

    if (bSuccess)
    {
        UE_LOG(LogOnline, Log, TEXT("Successfully joined session: %s"), *SessionName.ToString());

        // 연결 문자열 가져오기
        FString ConnectString;
        if (Sessions->GetResolvedConnectString(SessionName, ConnectString))
        {
            UE_LOG(LogOnline, Log, TEXT("Connecting to: %s"), *ConnectString);

            // 서버에 연결
            APlayerController* PC = GetWorld()->GetFirstPlayerController();
            if (PC)
            {
                PC->ClientTravel(ConnectString, TRAVEL_Absolute);
            }
        }
        else
        {
            bSuccess = false;
            ErrorMessage = TEXT("Failed to get connect string");
            UE_LOG(LogOnline, Error, TEXT("%s"), *ErrorMessage);
        }
    }
    else
    {
        switch (Result)
        {
            case EOnJoinSessionCompleteResult::SessionIsFull:
                ErrorMessage = TEXT("Session is full");
                break;
            case EOnJoinSessionCompleteResult::SessionDoesNotExist:
                ErrorMessage = TEXT("Session does not exist");
                break;
            case EOnJoinSessionCompleteResult::CouldNotRetrieveAddress:
                ErrorMessage = TEXT("Could not retrieve address");
                break;
            case EOnJoinSessionCompleteResult::AlreadyInSession:
                ErrorMessage = TEXT("Already in session");
                break;
            default:
                ErrorMessage = TEXT("Unknown error");
                break;
        }

        UE_LOG(LogOnline, Error, TEXT("Failed to join session: %s"), *ErrorMessage);
    }

    OnSessionJoined.Broadcast(bSuccess, ErrorMessage);
}

// ===== 세션 종료 =====

void USessionManager::DestroySession()
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (!Sessions.IsValid())
    {
        return;
    }

    auto ExistingSession = Sessions->GetNamedSession(NAME_GameSession);
    if (!ExistingSession)
    {
        return;
    }

    DestroySessionDelegateHandle = Sessions->AddOnDestroySessionCompleteDelegate_Handle(
        FOnDestroySessionCompleteDelegate::CreateUObject(
            this, &USessionManager::OnDestroySessionComplete));

    Sessions->DestroySession(NAME_GameSession);

    UE_LOG(LogOnline, Log, TEXT("Destroying session"));
}

void USessionManager::OnDestroySessionComplete(FName SessionName, bool bWasSuccessful)
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        Sessions->ClearOnDestroySessionCompleteDelegate_Handle(DestroySessionDelegateHandle);
    }

    UE_LOG(LogOnline, Log, TEXT("Session destroyed: %s"),
           bWasSuccessful ? TEXT("Success") : TEXT("Failed"));

    OnSessionDestroyed.Broadcast();
}

// ===== 세션 정보 =====

bool USessionManager::IsInSession() const
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        return Sessions->GetNamedSession(NAME_GameSession) != nullptr;
    }
    return false;
}

bool USessionManager::IsSessionHost() const
{
    IOnlineSessionPtr Sessions = GetSessionInterface();
    if (Sessions.IsValid())
    {
        FNamedOnlineSession* Session = Sessions->GetNamedSession(NAME_GameSession);
        if (Session)
        {
            return Session->bHosting;
        }
    }
    return false;
}

3. Epic Online Services (EOS) 통합

3.1 EOS 설정

; DefaultEngine.ini - EOS 기본 설정

[OnlineSubsystem]
DefaultPlatformService=EOS

[OnlineSubsystemEOS]
bEnabled=true

[/Script/EOSShared.EOSDeveloperSettings]
; Epic Developer Portal에서 가져온 값들
ProductName=MyOpenWorldRPG
ProductVersion=1.0.0
ProductId=YOUR_PRODUCT_ID
SandboxId=YOUR_SANDBOX_ID
DeploymentId=YOUR_DEPLOYMENT_ID
ClientId=YOUR_CLIENT_ID
ClientSecret=YOUR_CLIENT_SECRET

; 캐시 설정
CacheDir=Saved/EOS

[/Script/Engine.Engine]
!NetDriverDefinitions=ClearArray
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/SocketSubsystemEOS.NetDriverEOSBase",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")

3.2 EOS 인증 매니저

// EOSAuthManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Interfaces/OnlineIdentityInterface.h"
#include "EOSAuthManager.generated.h"

// 로그인 유형
UENUM(BlueprintType)
enum class EEOSLoginType : uint8
{
    // Epic Games 계정 (오버레이 표시)
    AccountPortal,

    // 개발 인증 도구 (개발용)
    Developer,

    // 디바이스 ID (익명 - 모바일)
    DeviceId,

    // 외부 인증 (Steam, PSN 등)
    ExternalAuth
};

// 로그인 결과 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnEOSLoginComplete, bool, bWasSuccessful, const FString&, ErrorMessage);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnEOSLogoutComplete);

UCLASS()
class MYGAME_API UEOSAuthManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;

    // ===== 로그인 =====

    // Epic 계정 포털 로그인
    UFUNCTION(BlueprintCallable, Category = "EOS|Auth")
    void LoginWithAccountPortal();

    // 개발자 인증 (테스트용)
    UFUNCTION(BlueprintCallable, Category = "EOS|Auth")
    void LoginWithDeveloperTool(const FString& Host, const FString& Credentials);

    // 디바이스 ID 로그인 (익명)
    UFUNCTION(BlueprintCallable, Category = "EOS|Auth")
    void LoginWithDeviceId();

    // ===== 로그아웃 =====

    UFUNCTION(BlueprintCallable, Category = "EOS|Auth")
    void Logout();

    // ===== 상태 확인 =====

    UFUNCTION(BlueprintPure, Category = "EOS|Auth")
    bool IsLoggedIn() const;

    UFUNCTION(BlueprintPure, Category = "EOS|Auth")
    FString GetDisplayName() const;

    UFUNCTION(BlueprintPure, Category = "EOS|Auth")
    FString GetProductUserId() const;

    // ===== 이벤트 =====

    UPROPERTY(BlueprintAssignable, Category = "EOS|Events")
    FOnEOSLoginComplete OnLoginComplete;

    UPROPERTY(BlueprintAssignable, Category = "EOS|Events")
    FOnEOSLogoutComplete OnLogoutComplete;

protected:
    void LoginInternal(const FOnlineAccountCredentials& Credentials);
    void OnLoginCompleteInternal(int32 LocalUserNum, bool bWasSuccessful,
                                  const FUniqueNetId& UserId, const FString& Error);
    void OnLogoutCompleteInternal(int32 LocalUserNum, bool bWasSuccessful);

    IOnlineIdentityPtr GetIdentityInterface() const;
};

// EOSAuthManager.cpp
#include "EOSAuthManager.h"
#include "OnlineSubsystem.h"

void UEOSAuthManager::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    // 로그인 델리게이트 바인딩
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (Identity.IsValid())
    {
        Identity->AddOnLoginCompleteDelegate_Handle(
            0,
            FOnLoginCompleteDelegate::CreateUObject(
                this, &UEOSAuthManager::OnLoginCompleteInternal));

        Identity->AddOnLogoutCompleteDelegate_Handle(
            0,
            FOnLogoutCompleteDelegate::CreateUObject(
                this, &UEOSAuthManager::OnLogoutCompleteInternal));
    }
}

IOnlineIdentityPtr UEOSAuthManager::GetIdentityInterface() const
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        return OnlineSub->GetIdentityInterface();
    }
    return nullptr;
}

void UEOSAuthManager::LoginWithAccountPortal()
{
    FOnlineAccountCredentials Credentials;
    Credentials.Type = TEXT("accountportal");

    LoginInternal(Credentials);
}

void UEOSAuthManager::LoginWithDeveloperTool(const FString& Host, const FString& CredentialsString)
{
    // 개발 인증 도구 사용 (테스트용)
    // Host: localhost:6547 (기본값)
    // Credentials: DevAuthTool에서 생성한 인증 이름

    FOnlineAccountCredentials Credentials;
    Credentials.Type = TEXT("developer");
    Credentials.Id = Host.IsEmpty() ? TEXT("localhost:6547") : Host;
    Credentials.Token = CredentialsString;

    LoginInternal(Credentials);
}

void UEOSAuthManager::LoginWithDeviceId()
{
    // 익명 로그인 (모바일/기기 ID 기반)
    FOnlineAccountCredentials Credentials;
    Credentials.Type = TEXT("deviceid");

    LoginInternal(Credentials);
}

void UEOSAuthManager::LoginInternal(const FOnlineAccountCredentials& Credentials)
{
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (!Identity.IsValid())
    {
        UE_LOG(LogOnline, Error, TEXT("Identity interface not available"));
        OnLoginComplete.Broadcast(false, TEXT("Identity interface not available"));
        return;
    }

    // 이미 로그인 중이면 스킵
    if (Identity->GetLoginStatus(0) == ELoginStatus::LoggedIn)
    {
        UE_LOG(LogOnline, Log, TEXT("Already logged in"));
        OnLoginComplete.Broadcast(true, TEXT(""));
        return;
    }

    UE_LOG(LogOnline, Log, TEXT("Attempting login with type: %s"), *Credentials.Type);

    Identity->Login(0, Credentials);
}

void UEOSAuthManager::OnLoginCompleteInternal(int32 LocalUserNum, bool bWasSuccessful,
                                               const FUniqueNetId& UserId, const FString& Error)
{
    if (bWasSuccessful)
    {
        UE_LOG(LogOnline, Log, TEXT("Login successful - User: %s"), *GetDisplayName());
    }
    else
    {
        UE_LOG(LogOnline, Error, TEXT("Login failed: %s"), *Error);
    }

    OnLoginComplete.Broadcast(bWasSuccessful, Error);
}

void UEOSAuthManager::Logout()
{
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (Identity.IsValid())
    {
        Identity->Logout(0);
    }
}

void UEOSAuthManager::OnLogoutCompleteInternal(int32 LocalUserNum, bool bWasSuccessful)
{
    UE_LOG(LogOnline, Log, TEXT("Logout complete: %s"),
           bWasSuccessful ? TEXT("Success") : TEXT("Failed"));

    OnLogoutComplete.Broadcast();
}

bool UEOSAuthManager::IsLoggedIn() const
{
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (Identity.IsValid())
    {
        return Identity->GetLoginStatus(0) == ELoginStatus::LoggedIn;
    }
    return false;
}

FString UEOSAuthManager::GetDisplayName() const
{
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (Identity.IsValid())
    {
        TSharedPtr UserId = Identity->GetUniquePlayerId(0);
        if (UserId.IsValid())
        {
            return Identity->GetPlayerNickname(0);
        }
    }
    return TEXT("");
}

FString UEOSAuthManager::GetProductUserId() const
{
    IOnlineIdentityPtr Identity = GetIdentityInterface();
    if (Identity.IsValid())
    {
        TSharedPtr UserId = Identity->GetUniquePlayerId(0);
        if (UserId.IsValid())
        {
            return UserId->ToString();
        }
    }
    return TEXT("");
}

4. 크로스플랫폼 지원

4.1 EOS Plus - Steam 통합

; DefaultEngine.ini - EOS + Steam 크로스플레이 설정

[OnlineSubsystemEOSPlus]
bEnabled=true

[OnlineSubsystem]
; EOS Plus를 기본으로 사용
DefaultPlatformService=EOSPlus
; Steam을 네이티브 플랫폼으로
NativePlatformService=Steam

[OnlineSubsystemEOS]
bUseEAS=true
bUseEOSConnect=true

; Steam 계정을 EOS Connect에 연동
bMirrorStatsToEOS=true
bMirrorAchievementsToEOS=true
bMirrorPresenceToEOS=true

[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480  ; 테스트용 AppID (실제 배포 시 변경)

4.2 크로스플레이 세션 매니저

// CrossPlaySessionManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "CrossPlaySessionManager.generated.h"

// 플랫폼 유형
UENUM(BlueprintType)
enum class EGamePlatform : uint8
{
    Unknown,
    Steam,
    Epic,
    PlayStation,
    Xbox,
    Switch
};

UCLASS()
class MYGAME_API UCrossPlaySessionManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    // 현재 플랫폼 가져오기
    UFUNCTION(BlueprintPure, Category = "CrossPlay")
    EGamePlatform GetCurrentPlatform() const;

    // 크로스플레이 가능 여부
    UFUNCTION(BlueprintPure, Category = "CrossPlay")
    bool IsCrossPlayEnabled() const;

    // 크로스플레이 세션 생성
    UFUNCTION(BlueprintCallable, Category = "CrossPlay")
    void CreateCrossPlaySession(int32 MaxPlayers, const FString& MapName);

    // 크로스플레이 세션 검색
    UFUNCTION(BlueprintCallable, Category = "CrossPlay")
    void FindCrossPlaySessions();

    // 플랫폼별 친구 목록 통합
    UFUNCTION(BlueprintCallable, Category = "CrossPlay")
    void GetCombinedFriendsList();

protected:
    // EOS와 네이티브 플랫폼 친구 목록 병합
    void MergeFriendsLists(
        const TArray>& EOSFriends,
        const TArray>& NativeFriends
    );
};

// CrossPlaySessionManager.cpp
#include "CrossPlaySessionManager.h"
#include "OnlineSubsystem.h"

EGamePlatform UCrossPlaySessionManager::GetCurrentPlatform() const
{
    // 네이티브 플랫폼 서브시스템 확인
    IOnlineSubsystem* NativeSub = IOnlineSubsystem::GetByPlatform();
    if (NativeSub)
    {
        FName SubName = NativeSub->GetSubsystemName();

        if (SubName == TEXT("Steam"))
        {
            return EGamePlatform::Steam;
        }
        else if (SubName == TEXT("EOS"))
        {
            return EGamePlatform::Epic;
        }
        // 추가 플랫폼...
    }

    return EGamePlatform::Unknown;
}

bool UCrossPlaySessionManager::IsCrossPlayEnabled() const
{
    // EOS Plus 사용 여부 확인
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        return OnlineSub->GetSubsystemName() == TEXT("EOSPlus");
    }
    return false;
}

void UCrossPlaySessionManager::CreateCrossPlaySession(int32 MaxPlayers, const FString& MapName)
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub)
    {
        UE_LOG(LogOnline, Error, TEXT("Online Subsystem not available"));
        return;
    }

    IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
    if (!Sessions.IsValid())
    {
        return;
    }

    // 크로스플레이 세션 설정
    FOnlineSessionSettings SessionSettings;
    SessionSettings.bUsesPresence = true;
    SessionSettings.NumPublicConnections = MaxPlayers;
    SessionSettings.bAllowJoinInProgress = true;
    SessionSettings.bAllowJoinViaPresence = true;
    SessionSettings.bShouldAdvertise = true;

    // EOS 로비 사용 (크로스플랫폼 필수)
    SessionSettings.bUseLobbiesIfAvailable = true;

    // 크로스플레이 플래그
    SessionSettings.Set(
        FName("CROSSPLAY_ENABLED"),
        true,
        EOnlineDataAdvertisementType::ViaOnlineService
    );

    SessionSettings.Set(
        SETTING_MAPNAME,
        MapName,
        EOnlineDataAdvertisementType::ViaOnlineService
    );

    const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
    if (LocalPlayer)
    {
        Sessions->CreateSession(
            *LocalPlayer->GetPreferredUniqueNetId(),
            NAME_GameSession,
            SessionSettings
        );
    }
}

void UCrossPlaySessionManager::FindCrossPlaySessions()
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub) return;

    IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
    if (!Sessions.IsValid()) return;

    // 크로스플레이 세션만 검색
    TSharedRef SearchRef = MakeShared();
    SearchRef->MaxSearchResults = 20;
    SearchRef->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
    SearchRef->QuerySettings.Set(
        FName("CROSSPLAY_ENABLED"),
        true,
        EOnlineComparisonOp::Equals
    );

    const ULocalPlayer* LocalPlayer = GetWorld()->GetFirstLocalPlayerFromController();
    if (LocalPlayer)
    {
        Sessions->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SearchRef);
    }
}

void UCrossPlaySessionManager::GetCombinedFriendsList()
{
    // EOS 친구 목록 가져오기
    IOnlineSubsystem* EOSSub = IOnlineSubsystem::Get(TEXT("EOS"));
    IOnlineSubsystem* NativeSub = IOnlineSubsystem::GetByPlatform();

    TArray> EOSFriends;
    TArray> NativeFriends;

    // EOS 친구
    if (EOSSub)
    {
        IOnlineFriendsPtr EOSFriendsInterface = EOSSub->GetFriendsInterface();
        if (EOSFriendsInterface.IsValid())
        {
            EOSFriendsInterface->ReadFriendsList(
                0,
                TEXT(""),
                FOnReadFriendsListComplete::CreateLambda(
                    [this, &EOSFriends, EOSFriendsInterface](int32 LocalUserNum, bool bWasSuccessful, const FString& ListName, const FString& ErrorStr)
                    {
                        if (bWasSuccessful)
                        {
                            EOSFriendsInterface->GetFriendsList(LocalUserNum, ListName, EOSFriends);
                        }
                    }
                )
            );
        }
    }

    // 네이티브 플랫폼 친구 (Steam 등)
    if (NativeSub && NativeSub != EOSSub)
    {
        IOnlineFriendsPtr NativeFriendsInterface = NativeSub->GetFriendsInterface();
        if (NativeFriendsInterface.IsValid())
        {
            NativeFriendsInterface->ReadFriendsList(
                0,
                TEXT(""),
                FOnReadFriendsListComplete::CreateLambda(
                    [this, &NativeFriends, NativeFriendsInterface](int32 LocalUserNum, bool bWasSuccessful, const FString& ListName, const FString& ErrorStr)
                    {
                        if (bWasSuccessful)
                        {
                            NativeFriendsInterface->GetFriendsList(LocalUserNum, ListName, NativeFriends);
                        }
                    }
                )
            );
        }
    }

    // 목록 병합
    MergeFriendsLists(EOSFriends, NativeFriends);
}

void UCrossPlaySessionManager::MergeFriendsLists(
    const TArray>& EOSFriends,
    const TArray>& NativeFriends)
{
    // 중복 제거 및 병합 로직
    TSet SeenIds;
    TArray> MergedFriends;

    // EOS 친구 추가
    for (const auto& Friend : EOSFriends)
    {
        FString FriendId = Friend->GetUserId()->ToString();
        if (!SeenIds.Contains(FriendId))
        {
            SeenIds.Add(FriendId);
            MergedFriends.Add(Friend);
        }
    }

    // 네이티브 플랫폼 친구 추가 (EOS에 없는 경우)
    for (const auto& Friend : NativeFriends)
    {
        FString FriendId = Friend->GetUserId()->ToString();
        if (!SeenIds.Contains(FriendId))
        {
            SeenIds.Add(FriendId);
            MergedFriends.Add(Friend);
        }
    }

    UE_LOG(LogOnline, Log, TEXT("Combined friends list: %d friends"), MergedFriends.Num());
}

5. 친구 초대 시스템

5.1 초대 매니저

// InviteManager.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Interfaces/OnlineFriendsInterface.h"
#include "InviteManager.generated.h"

// 친구 정보 구조체
USTRUCT(BlueprintType)
struct FFriendInfo
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly)
    FString UserId;

    UPROPERTY(BlueprintReadOnly)
    FString DisplayName;

    UPROPERTY(BlueprintReadOnly)
    bool bIsOnline;

    UPROPERTY(BlueprintReadOnly)
    bool bIsPlayingThisGame;

    UPROPERTY(BlueprintReadOnly)
    bool bCanInvite;
};

// 초대 관련 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnFriendsListReady, const TArray&, Friends);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInviteSent, bool, bWasSuccessful, const FString&, FriendName);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnInviteReceived, const FString&, FromUser, const FString&, SessionId);

UCLASS()
class MYGAME_API UInviteManager : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;

    // 친구 목록 요청
    UFUNCTION(BlueprintCallable, Category = "Invite")
    void RequestFriendsList();

    // 친구 초대
    UFUNCTION(BlueprintCallable, Category = "Invite")
    void InviteFriend(const FString& FriendUserId);

    // 초대 수락
    UFUNCTION(BlueprintCallable, Category = "Invite")
    void AcceptInvite(const FString& SessionId);

    // 초대 거절
    UFUNCTION(BlueprintCallable, Category = "Invite")
    void DeclineInvite(const FString& SessionId);

    // 이벤트
    UPROPERTY(BlueprintAssignable)
    FOnFriendsListReady OnFriendsListReady;

    UPROPERTY(BlueprintAssignable)
    FOnInviteSent OnInviteSent;

    UPROPERTY(BlueprintAssignable)
    FOnInviteReceived OnInviteReceived;

protected:
    void OnReadFriendsListComplete(int32 LocalUserNum, bool bWasSuccessful,
                                    const FString& ListName, const FString& ErrorStr);
    void OnSessionInviteReceived(const FUniqueNetId& UserId, const FUniqueNetId& FromId,
                                  const FString& AppId, const FOnlineSessionSearchResult& InviteResult);
};

// InviteManager.cpp
#include "InviteManager.h"
#include "OnlineSubsystem.h"

void UInviteManager::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);

    // 초대 수신 델리게이트 등록
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
        if (Sessions.IsValid())
        {
            Sessions->AddOnSessionInviteReceivedDelegate_Handle(
                FOnSessionInviteReceivedDelegate::CreateUObject(
                    this, &UInviteManager::OnSessionInviteReceived));
        }
    }
}

void UInviteManager::RequestFriendsList()
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub) return;

    IOnlineFriendsPtr Friends = OnlineSub->GetFriendsInterface();
    if (!Friends.IsValid()) return;

    Friends->ReadFriendsList(
        0,
        EFriendsLists::ToString(EFriendsLists::Default),
        FOnReadFriendsListComplete::CreateUObject(
            this, &UInviteManager::OnReadFriendsListComplete)
    );
}

void UInviteManager::OnReadFriendsListComplete(int32 LocalUserNum, bool bWasSuccessful,
                                                const FString& ListName, const FString& ErrorStr)
{
    TArray FriendInfoList;

    if (bWasSuccessful)
    {
        IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
        if (OnlineSub)
        {
            IOnlineFriendsPtr FriendsInterface = OnlineSub->GetFriendsInterface();
            if (FriendsInterface.IsValid())
            {
                TArray> FriendsList;
                FriendsInterface->GetFriendsList(LocalUserNum, ListName, FriendsList);

                for (const auto& Friend : FriendsList)
                {
                    FFriendInfo Info;
                    Info.UserId = Friend->GetUserId()->ToString();
                    Info.DisplayName = Friend->GetDisplayName();

                    // 온라인 상태
                    FOnlineUserPresence Presence;
                    Friend->GetUserAttribute(TEXT("Presence"), Presence.Status.StatusStr);
                    Info.bIsOnline = Friend->GetPresence().bIsOnline;
                    Info.bIsPlayingThisGame = Friend->GetPresence().bIsPlayingThisGame;

                    // 초대 가능 여부 (온라인이고 같은 게임 플레이 중이 아닐 때)
                    Info.bCanInvite = Info.bIsOnline && !Info.bIsPlayingThisGame;

                    FriendInfoList.Add(Info);
                }
            }
        }
    }

    // 온라인 친구 먼저 정렬
    FriendInfoList.Sort([](const FFriendInfo& A, const FFriendInfo& B)
    {
        if (A.bIsOnline != B.bIsOnline)
        {
            return A.bIsOnline > B.bIsOnline;
        }
        return A.DisplayName < B.DisplayName;
    });

    OnFriendsListReady.Broadcast(FriendInfoList);
}

void UInviteManager::InviteFriend(const FString& FriendUserId)
{
    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (!OnlineSub) return;

    IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
    if (!Sessions.IsValid()) return;

    // 현재 세션이 있는지 확인
    FNamedOnlineSession* CurrentSession = Sessions->GetNamedSession(NAME_GameSession);
    if (!CurrentSession)
    {
        UE_LOG(LogOnline, Warning, TEXT("No active session to invite to"));
        OnInviteSent.Broadcast(false, TEXT(""));
        return;
    }

    // UniqueNetId 생성
    IOnlineIdentityPtr Identity = OnlineSub->GetIdentityInterface();
    if (!Identity.IsValid()) return;

    TSharedPtr FriendNetId = Identity->CreateUniquePlayerId(FriendUserId);
    if (!FriendNetId.IsValid())
    {
        OnInviteSent.Broadcast(false, TEXT("Invalid friend ID"));
        return;
    }

    // 초대 전송
    bool bSuccess = Sessions->SendSessionInviteToFriend(0, NAME_GameSession, *FriendNetId);

    // 친구 이름 가져오기 (로그용)
    FString FriendName = FriendUserId;
    IOnlineFriendsPtr Friends = OnlineSub->GetFriendsInterface();
    if (Friends.IsValid())
    {
        TSharedPtr Friend = Friends->GetFriend(0, *FriendNetId,
                                            EFriendsLists::ToString(EFriendsLists::Default));
        if (Friend.IsValid())
        {
            FriendName = Friend->GetDisplayName();
        }
    }

    UE_LOG(LogOnline, Log, TEXT("Invite sent to %s: %s"),
           *FriendName, bSuccess ? TEXT("Success") : TEXT("Failed"));

    OnInviteSent.Broadcast(bSuccess, FriendName);
}

void UInviteManager::OnSessionInviteReceived(const FUniqueNetId& UserId, const FUniqueNetId& FromId,
                                              const FString& AppId, const FOnlineSessionSearchResult& InviteResult)
{
    // 초대자 이름 가져오기
    FString FromUserName = FromId.ToString();

    IOnlineSubsystem* OnlineSub = IOnlineSubsystem::Get();
    if (OnlineSub)
    {
        IOnlineFriendsPtr Friends = OnlineSub->GetFriendsInterface();
        if (Friends.IsValid())
        {
            TSharedPtr Friend = Friends->GetFriend(0, FromId,
                                                EFriendsLists::ToString(EFriendsLists::Default));
            if (Friend.IsValid())
            {
                FromUserName = Friend->GetDisplayName();
            }
        }
    }

    FString SessionId = InviteResult.Session.GetSessionIdStr();

    UE_LOG(LogOnline, Log, TEXT("Received invite from %s to session %s"),
           *FromUserName, *SessionId);

    OnInviteReceived.Broadcast(FromUserName, SessionId);
}

void UInviteManager::AcceptInvite(const FString& SessionId)
{
    // SessionManager를 통해 세션 참가
    USessionManager* SessionManager = GetGameInstance()->GetSubsystem();
    if (SessionManager)
    {
        SessionManager->JoinSessionById(SessionId);
    }
}

void UInviteManager::DeclineInvite(const FString& SessionId)
{
    UE_LOG(LogOnline, Log, TEXT("Declined invite to session: %s"), *SessionId);
    // 필요시 거절 알림 전송
}

6. 실습 과제

과제 1: 세션 브라우저 UI

세션 검색 결과를 표시하고 참가할 수 있는 UI 위젯을 구현하세요.

  • 세션 목록 스크롤 뷰
  • 호스트 이름, 플레이어 수, 핑 표시
  • 새로고침 버튼
  • 참가 버튼

과제 2: 친구 초대 시스템

온라인 친구 목록을 표시하고 초대를 보낼 수 있는 시스템을 구현하세요.

  • 친구 목록 UI (온라인/오프라인 구분)
  • 초대 버튼
  • 초대 수신 팝업
  • 수락/거절 버튼

과제 3: 자동 재연결

연결이 끊어졌을 때 자동으로 재연결을 시도하는 시스템을 구현하세요.

  • 연결 끊김 감지
  • 재연결 시도 (최대 3회)
  • 재연결 진행 UI
  • 실패 시 메인 메뉴 이동

핵심 요약

PRACTICE

도전 과제

배운 내용을 직접 실습해보세요

실습 1: Online Subsystem NULL 설정

DefaultEngine.ini에서 OnlineSubsystem=NULL을 설정하고, IOnlineSubsystem::Get()으로 서브시스템에 접근하세요. 세션 생성(CreateSession)과 검색(FindSessions)의 기본 플로우를 구현하세요.

실습 2: 세션 기반 매치메이킹

FOnlineSessionSettings를 설정하여 RPG Co-op 세션을 생성하세요. bUsesPresence, NumPublicConnections, 커스텀 세션 키(MapName, Difficulty)를 설정하고, 다른 플레이어가 검색/참가하는 플로우를 구현하세요.

심화 과제: Steam Online Subsystem 통합

OnlineSubsystemSteam 플러그인을 연동하고, Steam의 로비/매치메이킹 API를 통해 실제 온라인 세션을 구현하세요. Steam App ID 설정, 인증 플로우, 프렌드 초대 기능을 통합하세요.