<pname>스팀 온라인 서브시스템 기반 P2P 네트워킹 구현</pname>
<tag>회고,개발</tag>
구현 배경
전용 서버를 두지 않고, 플레이어 한 명이 서버 역할을 겸하는 P2P(Listen Server) 구조로 멀티플레이를 만들고자 했다. 별도 서버 인프라가 없으니 비용이 들지 않고 구조도 단순하다는 장점이 있었다.
문제는 "어떻게 서로를 찾아 접속하게 할 것인가" 였다. 가장 단순한 방법은 클라이언트가 호스트의 IP를 직접 입력해 접속하는 방식이지만, 여기에는 두 가지 문제가 있었다.
첫째, 사용자 경험이 나쁘다. 게임을 하려고 상대의 IP 주소를 물어보고 타이핑해야 한다는 건 일반 유저 입장에서 진입 장벽이 크다.
둘째, 네트워크 환경 문제다. 대부분의 유저는 공유기(NAT) 뒤에 있기 때문에, 외부에서 호스트에게 직접 접속하려면 포트포워딩을 설정하거나 방화벽을 열어줘야 하는 경우가 생긴다. 이건 비개발자 유저에게 사실상 불가능에 가까운 요구다.
즉, P2P 구조 자체보다 "설정 없이 서로 연결되게 하는 접속 계층" 이 진짜 풀어야 할 과제였다.
왜 Steam 온라인 서브시스템인가
문제를 직접 해결하려면 NAT 트래버설(홀펀칭), 릴레이 서버, 매치메이킹 서버 같은 걸 전부 직접 구축해야 한다. 소규모 프로젝트에서 감당하기 어려운 범위다.
Steam은 이 계층을 플랫폼 차원에서 이미 제공한다.
- SteamID 기반 접속 — IP가 아니라 유저의 Steam 계정을 식별자로 사용하므로, 유저는 IP를 알 필요가 없다.
- NAT 우회 / 릴레이 — Steam 네트워킹이 NAT 뒤에 있는 유저 간 연결을 중계·우회해 준다.
- 초대 UX — Steam 오버레이의 친구 초대라는, 유저에게 이미 익숙한 흐름을 그대로 쓸 수 있다.
그래서 접속 계층을 직접 만드는 대신 Steam 온라인 서브시스템을 활용하기로 했다.
1. Steam 로그인 연동
게임 실행 시 또는 Steam 버튼을 누를 때 CheckSteamLogin 이벤트가 호출된다. 흐름은 다음과 같다.
이미 로그인된 상태라면 Steam 닉네임을 가져와 PlayerName에 세팅하고 Is Steam Login 플래그를 true로 표시한다.
로그인이 안 된 상태라면 C++로 작성한 LoginToSteam 함수를 호출해 Steam 로그인을 시도한다. 성공하면 동일하게 닉네임을 세팅하고, 실패하면 1초 후 CheckSteamLogin을 재귀 호출해 로그인이 완료될 때까지 폴링한다.

bool UZuoBlueprintFunctionLibrary::LoginToSteam() { IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get(STEAM_SUBSYSTEM); if (!Subsystem) return false; IOnlineIdentityPtr Identity = Subsystem->GetIdentityInterface(); if (!Identity.IsValid()) return false; // 이미 로그인되어 있으면 즉시 반환 if (Identity->GetLoginStatus(0) == ELoginStatus::LoggedIn) { FString Name = Identity->GetPlayerNickname(0); return true; } // Steam은 credentials 없이 자동 로그인 FOnlineAccountCredentials Credentials; Identity->OnLoginCompleteDelegates->AddLambda( [](int32 LocalUserNum, bool bWasSuccessful, const FUniqueNetId& UserId, const FString& Error) { // 로그인 결과 처리 } ); Identity->Login(0, Credentials); return true; }
LoginToSteam을 C++로 따로 분리한 이유는, Steam 서브시스템 인터페이스 접근과 델리게이트 바인딩을 Blueprint에서 직접 다루기엔 번거롭기 때문이다. C++에서 처리하고 결과만 Blueprint로 올려주는 방식으로 역할을 나눴다.

2. 세션 생성
방 만들기 버튼을 누르면 GameInstance의 Steam 로그인 여부를 먼저 확인한다.
로그인이 안 되어 있으면 토스트 메시지로 안내하고, 로그인 상태라면 ZUO_CreateSession 이벤트를 호출한다.



3. 초대 전송
리슨서버 맵에 입장한 호스트가 초대 버튼을 누르면 ZUO_InviteSession 이벤트를 호출하고, Show Invite UI 노드로 Steam 친구 목록 UI를 띄운다. 유저는 이 목록에서 초대할 친구를 선택하면 된다.



4. 초대 수신 및 수락
초대를 받은 쪽에서는 두 가지 이벤트가 처리된다.

초대가 도착하면(Event On Session Invite Received) 화면 상단에 알림을 띄워 초대가 왔음을 알린다.

초대를 수락하면(Event On Session Invite Accepted) DestroySession으로 기존 세션을 먼저 정리한 뒤 JoinSession으로 호스트 세션에 참가한다.
수락 전에 DestroySession을 먼저 호출하는 이유는, 수락하는 쪽이 이미 다른 세션에 들어가 있을 경우를 대비한 것이다. 기존 세션이 남아있는 채로 JoinSession을 호출하면 충돌이 발생하기 때문에, 없더라도 항상 먼저 정리하는 방어적 패턴으로 구성했다.
결과 및 회고
- 유저는 IP 입력·포트포워딩·방화벽 설정 없이, 초대 수락만으로 멀티플레이에 접속할 수 있게 되었다.
- 서로 다른 네트워크 환경에 있는 유저끼리도 연결이 성립했다.
- 전용 서버가 없으므로 서버 운영 비용이 발생하지 않았다.
<pagelist>
