📌 시작은 Null OSS였던 프로젝트
🔎Null OSS란?
Unreal Engine에서 기본적으로 제공하는 더미 OnlineSubsystem으로, 로컬 환경(Package / PIE)에서 멀티플레이 테스트를 가능하게 해준다.
세션 생성 및 참여에 대한 테스트가 가능하지만 동일한 Lan 안에서 한정적인 테스트가 가능하다.
즉, 외부 접속으로 타인과 다른 지역 Lan을 통해 접속해 같이 멀티플레이를 즐길 순 없는 상태였다.
그래서 1차적으로 전체적인 게임 기능 구현을 완료한 뒤, 동일한 로컬에서 2개의 프로세스를 띄운 후 정상적으로 작동하는지 테스트하였고, 그 다음 Steam OSS로 전환을 시도하였다.
Steam OSS 전환으로 기대한 점은 인터넷을 통해 같은 세션에 접속해 실질적인 멀티플레이 환경을 만드는 것이었고,
포트포워딩 없이 Steam Relay를 활용한 P2P로, Steam ID를 기반으로 세션을 생성하고 검색해 참가하는 구조를 만드는 것이었다.
📌 Steam OSS + Advanced Sessions Plugin 적용 과정
✅ DefaultEngine.ini 설정 변경
프로젝트 경로의 Config 폴더에 들어가면 DefaultEngine.ini 파일을 확인할 수 있고, 아래와 같이 수정하였다.
[/Script/Engine.GameEngine]
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")
[OnlineSubsystem]
DefaultPlatformService=Steam
[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480
bUseLobbiesIfAvailable=true
bAllowP2PPacketRelay=true
bUseSteamNetworking=false
[/Script/OnlineSubsystemSteam.SteamNetDriver]
NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection"
- NetDriverDefinitions: GameNetDriver 라는 이름의 네트워크 드라이브를 등록
- DriverClassName: Steam 전용 드라이버인 SteamNetDriver를 사용하도록 설정
- DriverClassNameFallback: Steam 실패 시, Fallback으로 기본 IPNetDriver 사용하도록 설정
- DefaultPlatformService: 전체 OSS 플랫폼으로 Steam을 사용하도록 지정(기본은 NULL OSS)
- OnlineSubsystemSteam:
- bEnabled: SteamOSS 활성화
- SteamDevAppID: 테스트용 AppId인 480 지정
- bUseLobbiesIfAvailable: Steam 로비 시스템 활성화 - 세션 생성 시, Steam Lobby 자동 생성
- bAllowP2PPacketRelay: Steam Relay 사용 - NAT Traversal / 포트포워딩 없이 연결 가능
- bUseSteamNetworking: Steam의 최신 네트워크 API를 사용(UDP 기반의 높은 성능)
- NetConnectionClassName: SteamNetDriver가 사용할 네트워크 연결 클래스 지정 / Steam ID 기반 연결 지원
🔎bUseSteamNetWorking 설정
Steam의 최신 네트워크 API를 사용 여부에 대한 설정인데, 필자는 false로 이 기능을 사용하지 않았다.
이유는 Unreal Engine의 SteamNetDriver와 완전히 호환되지 않는 문제가 있었다.
bUseSteamNetworking을 true로 사용했을 때, Steam이 새롭게 제공하는 Steam Socket을 사용하여 향상된 네트워크 품질 을 만들어 낼 수 있지만 Advanced Sessions 플러그인과 Listen Server 기반에는 적합하지 않았다고 느꼈다.
Steam Sockets이 Unreal Engine과의 통합이 완벽하지 않아 Steam OSS에서 다양한 문제가 발생할 수 있는 상태인 것으로 보인다.
필자는 Listen Server 환경에서 Advanced Sessions 플러그인과 Steam OSS를 사용 했는데, 세션의 Ping 값을 계산할 수 있는 정확한 IP 주소를 얻지 못하거나 세션은 검색되었으나 실제로 접근할 수 없는 경우가 발생했다.
따라서 false로 설정한 후, Steam OSS가 기존의 안정적인 P2P / Steam Relay 시스템을 사용하도록 하였다.
추후 Dedicated Server 빌드로 멀티플레이 게임 제작을 할때는 위와 같이 true로 설정 후에 Steam Networking Sockets과 Steam Datagram Relay(SDR)를 통해, 안정적인 멀티플레이 환경을 구축해볼 것이다.
✅ Advanced Sessions Plugin
https://github.com/mordentral/AdvancedSessionsPlugin
해당 주소에서 Advanced Sessions Plugin을 다운 받고, 프로젝트 경로에 Plugins 폴더를 만들어 넣어준다.
안정성을 위해 이쯤에서 .vs, sln, Intermediate, Binaries, DeriveDataCache, Saved을 지우고 Generate 진행한다.
에디터로 돌아와, Edit → Plugins 에서 Adavanced Sessions 플러그인이 잘 체크되어있는지 확인한다.
✅ Steam API 및 AppID 포함
두 개의 파일을 프로젝트 경로의 Binaries → Win64 폴더에 넣어주어야 한다.
- steam_appid.txt: 텍스트 파일을 만들어서 480 기입 후 저장, 위와 같이 이름을 변경 및 저장
- 480은 개발자 테스트용 AppID이고, 게임을 스팀에 출시하면 Valve가 자동으로 실제 AppID를 할당
- 앞서 .ini 에서 SteamDevAppId = 480 설정은 Unreal OSS 설정
- steam_appid_txt은 Steam API 런타임 라이브러리를 위한 것이므로 둘 다 필요
- steam_api64.dll: Steamworks SDK의 클라이언트 런타임 라이브러리
- Steam 클라이언트와의 통신을 담당
- 로그인 상태 확인, Steam ID 조회, 친구 목록, 세션 로비 등록 등 모든 Steam 기능을 이 DLL로 처리
- https://partner.steamgames.com/home 해당 사이트에서 로그인 후, Steamworks SDK 다운로드 가능
- SDK 다운로드 후 C:\폴더 경로\sdk\redistributable_bin\win64 에서 dll 확인 가능
- 이 dll은 스팀 연동 개발 중에만 필요하고 steam에 업로드 시에는 자동 처리됨
📌 세션 생성 및 접속 구현
✅ 세션 흐름
메인 메뉴 UI에서 Versus Mode(대전 모드) 혹은 Coop Mode(협동 모드) 를 선택 했을 때,
현재 접속 가능한 세션을 찾아 보고 유효한 세션이 없다면, 세션을 생성하고 로비 레벨로 이동한다. 세션을 생성한 이 유저는 호스트 플레이어가 되며 Listen Server가 된다.
만약 현재 접속 가능한 세션을 찾았다면 해당 세션에 참가한 뒤, 호스트와 같은 로비 레벨로 이동하도록 구현하였다.
필자는 앞서 설치했던 Advanced Sessions Plugin을 사용해서 세션을 구현하였고,
이 플러그인은 Unreal Engine에서 멀티플레이 세션 기능을 확장하고 더 세밀한 제어를 가능하게 해주는 오픈소스 Blueprint 노드 기반의 플러그인이다.
Steam OSS나 기타 플랫폼에서 세션 생성, 참가, 초대, 사용자 정보 접근 등의 기능을 더 강력하고 쉽게 처리할 수 있도록 하는 일종의 헬퍼 유틸리티 플러그인이라고 볼 수 있다.
내부적으로는 결국 Unreal Engine의 OnlineSubsystem API를 사용한다.
해당 플러그인 전용 함수들은 UOnlineBlueprintCallProxyBase라는 비동기 블루프린트 전용 유틸 클래스를 상속받은 클래스에 정의되어 있기 때문에, C++ 코드에서 직접 호출해서 쓰는 데는 적합하지 않다.(Activate 함수 등으로 블루프린트 실행 트리거와 맞물려 있기 때문에 수동으로 관리 어려움)
따라서 해당 플러그인을 사용할 것이라면, 블루프린트로 구현하는 것을 권장한다.
✅ Find or Create Session
- FindOrCreateSession 이름의 커스텀 Event 노드 생성
- Make Literal Session Property String & Make Literal Session Search Property:
- Advanced Sessions 플러그인의 핵심 기능 중 하나로, 세션을 보다 정확하고 세밀하게 구분 및 검색하도록 하는 헬퍼 함수
- 세션을 생성할 때 해당 Key - Value를 지정하고, 세션을 찾을 때 이 Key - Value를 필터링해서 이 속성을 가진 세션을 반환
- string 뿐만 아니라 int나 bool 등 다른 타입을 지원하는 노드 존재
- FindSessionsAdvanced: 최대 결과 수, LAN 사용 여부, 검색 필터 등 옵션에 따라 검색된 세션이 Results 배열에 저장
- 만약 Result가 없다면(== 0) 바로 Create Session 노드를 실행해 세션을 생성하고 로비로 이동
- Session이 존재한다면 Branch에서 False 핀을 타고 Result 배열을 순회하며, 각 요소는 Safe Join Session 노드에 전달 되어 내부적으로 처리
🚫Join Session 노드
기존에는 Advanced Sessions 플러그인의 Join Session 노드를 사용해서 세션에 참가하도록 구현하였었다.
하지만 이전에 사용되었던 죽은 세션에 접속하는 등 유효하지 않은 세션에 접속을 시도하여, 실제 존재하는 세션에 접근하지 못하고 세션 참가 실패로 이어지는 현상이 반복되었다.
블루프린트 상에서 검증하는 로직을 구현해서 시도 해보기도 했지만 결과적으로 유효한 세션만 도출해내는 것에 실패하였다.
심지어 세션 검증이 제대로 되지 않은 상태에서 플러그인의 JoinSession 노드가 실행되었고, 이 노드는 내부적으로 자동으로 ClientTravel 하게 되어있어, 유효하지 않은 세션으로 Join을 시도하며 정상적인 멀티플레이가 불가능했다.
따라서 Advanced Sessions에서 기존 제공되는 JoinSession 노드를 사용하지 않기로 결정하였다.
유효한 세션을 제대로 직접 검증하고 검증된 세션이 없다면 Create Session을 시도하도록 하고, 만약 유효한 세션을 발견했다면 해당 세션으로 Join Session 한 뒤에 ClientTravel 하는 함수를 C++로 구현해서 BP 상에서 호출하도록 하였다.
✔️ Safe Join Session
//AdvancedGameInstance.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnJoinSessionFailed);
public:
/** 세션 유효성 검증 */
UFUNCTION(BlueprintCallable)
void SafeJoinSession(const FBlueprintSessionResult& SearchResult);
UPROPERTY(BlueprintAssignable)
FOnJoinSessionFailed OnJoinSessionFailed;
private:
void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
FDelegateHandle JoinSessionDelegateHandle;
void UCSAdvancedGameInstance::SafeJoinSession(const FBlueprintSessionResult& SearchResult)
{
const FOnlineSessionSearchResult& NativeResult = SearchResult.OnlineResult;
const int32 BuildID = NativeResult.Session.SessionSettings.BuildUniqueId;
const int32 Ping = NativeResult.PingInMs;
const bool bValidInfo = NativeResult.Session.SessionInfo.IsValid();
const bool bDefinitelyDead = (BuildID == 0 && Ping == 9999);
if (bDefinitelyDead || !bValidInfo)
{
OnJoinSessionFailed.Broadcast();
return;
}
IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get();
if (!Subsystem) return;
IOnlineSessionPtr SessionInterface = Subsystem->GetSessionInterface();
if (!SessionInterface.IsValid()) return;
APlayerController* PlayerController = GetFirstLocalPlayerController();
if (!PlayerController) return;
JoinSessionDelegateHandle = SessionInterface->AddOnJoinSessionCompleteDelegate_Handle(
FOnJoinSessionCompleteDelegate::CreateUObject(this, &UCSAdvancedGameInstance::OnJoinSessionComplete)
);
SessionInterface->JoinSession(0, NAME_GameSession, NativeResult);
}
- 1차적으로 SafeJoinSession 함수에서 BuildUniqueID와 SessionInfo가 유효한지 체크
- Ping의 경우, 유효한 세션을 찾았음에도 Steam Relay 초기 연결 또는 IP 확인 실패로 인해 Unreal이 Ping 측정에 실패해 9999로 측정되는 경우가 있다. 따라서 Ping 9999라고 해서 무조건 유효하지 않은 세션으로 판단하지 않아야 함
[2025.04.16-19.36.20:796][859]LogScript: Script Msg: Found a session. Ping is 9999
- BuildUniqueID가 0이고 Ping도 9999라면 죽은 세션 등 유효하지 않은 세션이므로, BroadCast 호출
- BP에서 Broad Cast 받아서 자동으로 Create Session 노드 호출하도록 설계
- 유효한 세션임이 검증되었다면, JoinSession이 성공적으로 끝났을 때 호출될 함수인 OnJoinSessionComplete 바인딩
void UCSAdvancedGameInstance::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result)
{
IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get();
if (!Subsystem) return;
IOnlineSessionPtr SessionInterface = Subsystem->GetSessionInterface();
if (!SessionInterface.IsValid()) return;
SessionInterface->ClearOnJoinSessionCompleteDelegate_Handle(JoinSessionDelegateHandle);
FString ConnectString;
if (!SessionInterface->GetResolvedConnectString(SessionName, ConnectString))
{
OnJoinSessionFailed.Broadcast();
return;
}
if (ConnectString.IsEmpty() || ConnectString.Contains(TEXT(":0")))
{
OnJoinSessionFailed.Broadcast();
return;
}
APlayerController* PlayerController = GetFirstLocalPlayerController();
if (PlayerController)
{
PlayerController->ClientTravel(ConnectString, ETravelType::TRAVEL_Absolute);
}
}
- AdvancedGameInstance::OnJoinSessionComplete / JoinSession 성공 시, 델리게이트로 자동 호출
- 온라인 서브 시스템과 세션 인터페이스 획득
- Delegate Handle 제거(중복 호출 방지)
- GetResolvedConnectString 함수로 세션에 연결 가능한 주소(SteamP2P 주소)를 ConnectString으로 저장
- 실패하거나 유효하지 않으면 BroadCast 한 뒤 return하여 세션 실패 처리
- 포트가 :0으로 잘못된 세션이라면 이 또한 BroadCast 한 뒤 return하여 세션 실패 처리
- 2차 검증을 통해 정상적인 세션임이 확정되었다면 ClientTravel 하여 해당 호스트 서버의 월드로 이동
🔔 결과
이렇게 SafeJoinSession 함수에서 1차로 BuildUniqueID와 SessionInfo를 통해 세션이 유효한지 검증을 하고,
OnJoinSessionComplete 함수에서 SteamP2P 주소를 확인하여 2차 검증을 통해 유효한 세션임을 명확하게 검증하도록 하고 이때만 Client Travel을 수행하도록 하였다.
그 결과, 호스트 및 게스트 로그에 BuildUniqueID가 동일하게 "37670630"으로, SteamP2P 주소가 동일하게 "765...:7777"로 포트 번호까지 정확하게 일치하는 것을 확인할 수 있었다.
정확한 검증 없이 세션에 접속하는 과정으로 이어진다면, 원활한 멀티플레이 접속이 되지 않았다.
특히 Ping이 9999로 로그가 찍힌다면 무조건 유효하지 않은 세션이라고 판단을 했어서 계속 Join 시도 자체가 불가능 했던 적도 있었다.
자세하게 검증하되 Ping에 있어서는 제한을 푸는 방향이 좋을 듯 하다.
📍 추가) 플레이어 세션 등록
플레이어를 세션에 등록하는 것은 멀티플레이에서 매우 중요한 안정성 확보 수단이다.
특히 Steam OSS 환경에서 Server는 내부적으로 FNameOnlineSession::ResisteredPlayers 배열을 유지하는데, 이 배열에 플레이어가 등록되지 않으면 서버 입장에서 "세션에 존재하지 않은 유령 유저"로 취급된며, 일부 온라인 기능(세션 유지, StartSession, KickPlayer, Invite 처리 등) 에서 제외될 수 있다.
특히 Steam Overlay의 세션 리스트나 초대 기능은 ResisteredPlayers 기반으로 표시되며 등록되지 않으면 친구 초대나 UI에서 유저가 보이지 않을 수 있다.
필자의 경우에도 게스트 플레이어는 JoinSession 과정에서 내부적으로 Register 등록이 이루어졌지만, 호스트 플레이어는 세션을 생성하고 바로 ServerTravel 하는 과정에서 Register에 등록이 되지 않아 로그에서 경고를 확인했었다.
LogOnlineSession: Warning: PlayerController is not registered with the session
void ACSLobbyGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
RegisterPlayerToSession(NewPlayer);
}
void ACSLobbyGameMode::RegisterPlayerToSession(APlayerController* NewPlayer)
{
IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get();
if (Subsystem)
{
IOnlineSessionPtr SessionInterface = Subsystem->GetSessionInterface();
if (SessionInterface.IsValid())
{
APlayerState* PlayerState = NewPlayer->PlayerState;
if (PlayerState)
{
FUniqueNetIdRepl ReplId = PlayerState->GetUniqueId();
if (ReplId.IsValid())
{
const TSharedPtr<const FUniqueNetId> NetId = ReplId.GetUniqueNetId();
if (NetId.IsValid())
{
const FName SessionName = NAME_GameSession;
const bool bWasSuccessful = SessionInterface->RegisterPlayer(SessionName, *NetId, false);
}
}
}
}
}
}
세션 생성 및 참여 이후 Lobby Level로 Travel 되어 PostLogin이 호출되는 시점에, 안전하게 다시 명시적으로 Register에 등록 시켜주었다.
- PlayerState->GetUniqueID( ):
- FUniqueNetIdRepl 타입을 반환하며, 언리얼이 제공하는 복제 가능한 래퍼 타입
- 내부적으로 TSharedPtr<const FUniqueNetId>를 감싸고 있음
- Replication 지원을 위해 설계된 타입
- GetUniqueNetId( ):
- FUniqueNetIdRepl 안에 있는 실제 FUniqueNetId의 포인터를 반환
- 반환 타입은 TSharedPtr<const FUniqueNetId>이며, 이게 실제 OSS나 세션 함수에서 요구하는 타입
- virtual bool ResisterPlayer(FName SessionName, const FUniqueNetId& PlayerId, bool bWasFromInvite):
- Listen 혹은 Dedicated Server에서 클라이언트를 명시적으로 세션에 등록
- 내부적으로 해당 플레이어를 서버가 관리하는 세션의 RegisteredPlayers 배열에 추가한다.
- 이 배열 목록은 세선 유지/연결 검증/세션 정보 동기화/초대 기능 등에 영향을 준다.