스팀 온라인 서브시스템 기반 P2P 네트워킹 구현

스팀 온라인 서브시스템 기반 P2P 네트워킹 구현

<pname>스팀 온라인 서브시스템 기반 P2P 네트워킹 구현</pname>
<tag>회고,개발</tag>
전용 서버 없이, IP 입력도 포트포워딩도 없이 — 친구를 "초대"하는 것만으로 멀티플레이 붙이기

TL;DR

  • 전용 서버(Dedicated Server) 없이 한 플레이어가 호스트(Listen Server), 나머지가 클라이언트가 되는 P2P 구조로 멀티플레이를 구현했다.
  • 문제는 접속 방식의 UX였다. IP를 직접 입력하는 방식은 불편할 뿐 아니라, NAT 환경에서는 포트포워딩·방화벽 설정까지 요구했다.
  • 이를 Steam 온라인 서브시스템의 Session + 초대 기능으로 풀었다. 초대장을 수락하면 IP 입력이나 별도 설정 없이 호스트의 게임으로 바로 접속된다.
  • 결과적으로 서버 비용 0원, 그리고 서로 다른 네트워크 환경에 있는 유저끼리도 초대만으로 멀티플레이가 성립했다.

구현 배경

전용 서버를 두지 않고, 플레이어 한 명이 서버 역할을 겸하는 P2P(Listen Server) 구조로 멀티플레이를 만들고자 했다. 별도 서버 인프라가 없으니 비용이 들지 않고 구조도 단순하다는 장점이 있었다.
문제는 "어떻게 서로를 찾아 접속하게 할 것인가" 였다. 가장 단순한 방법은 클라이언트가 호스트의 IP를 직접 입력해 접속하는 방식이지만, 여기에는 두 가지 문제가 있었다.
첫째, 사용자 경험이 나쁘다. 게임을 하려고 상대의 IP 주소를 물어보고 타이핑해야 한다는 건 일반 유저 입장에서 진입 장벽이 크다.
둘째, 네트워크 환경 문제다. 대부분의 유저는 공유기(NAT) 뒤에 있기 때문에, 외부에서 호스트에게 직접 접속하려면 포트포워딩을 설정하거나 방화벽을 열어줘야 하는 경우가 생긴다. 이건 비개발자 유저에게 사실상 불가능에 가까운 요구다.
즉, P2P 구조 자체보다 "설정 없이 서로 연결되게 하는 접속 계층" 이 진짜 풀어야 할 과제였다.

왜 Steam 온라인 서브시스템인가

이 문제를 직접 해결하려면 NAT 트래버설(홀펀칭), 릴레이 서버, 매치메이킹 서버 같은 걸 전부 직접 구축해야 한다. 소규모 프로젝트에서 감당하기 어려운 범위다.
Steam은 이 계층을 플랫폼 차원에서 이미 제공한다.
  • SteamID 기반 접속 — IP가 아니라 유저의 Steam 계정을 식별자로 사용하므로, 유저는 IP를 알 필요가 없다.
  • NAT 우회 / 릴레이 — Steam 네트워킹이 NAT 뒤에 있는 유저 간 연결을 중계·우회해 준다. 포트포워딩·방화벽 설정 부담이 사라진다.
  • 초대 UX — Steam 오버레이의 친구 초대라는, 유저에게 이미 익숙한 흐름을 그대로 쓸 수 있다.
그래서 접속 계층을 직접 만드는 대신 Steam 온라인 서브시스템의 Session을 활용하기로 했다.

구현 흐름

전체 흐름은 다음과 같다.
  1. Steam 로그인 연동 — Online Subsystem Steam 플러그인을 활성화하고, 게임 실행 시 Steam 계정과 연동되도록 구성한다.
  1. 세션 생성 (호스트) — 방을 만드는 플레이어가 Listen Server로 세션을 생성한다.
  1. 초대 전송 — 호스트가 Steam 친구에게 게임 초대를 보낸다. (Steam 오버레이 또는 인게임 UI)
  1. 초대 수락 → Join — 초대를 받은 플레이어가 수락하면, IP 입력·설정 과정 없이 호스트의 세션에 Join한다.
  1. 접속 완료 — 호스트 맵으로 이동(ClientTravel)하며 멀티플레이 세션이 시작된다.
핵심은 클라이언트가 IP를 전혀 알 필요가 없다는 점이다. 접속에 필요한 정보(세션 → 호스트)는 Steam이 SteamID를 통해 내부적으로 해석해 준다.
아래 코드는 실제 구현을 개념 수준으로 정리한 예시 흐름이다. (실제 코드로 교체해서 사용)
세션 생성 (호스트)
FOnlineSessionSettings Settings; Settings.bIsLANMatch = false; // Steam(온라인) 세션 Settings.NumPublicConnections = MaxPlayers; Settings.bShouldAdvertise = true; // 초대/검색 대상으로 노출 Settings.bAllowJoinInProgress = true; Settings.bUsesPresence = true; // 친구 초대(프레즌스) 사용 Settings.bAllowInvites = true; SessionInterface->CreateSession(0, NAME_GameSession, Settings); // 성공 콜백에서 ServerTravel 로 게임 맵 이동
초대 수락 처리 (클라이언트)
// Steam 오버레이 등에서 초대 수락 시 호출되는 델리게이트 void HandleSessionUserInviteAccepted(bool bWasSuccessful, int32 ControllerId, TSharedPtr<const FUniqueNetId> UserId, const FOnlineSessionSearchResult& InviteResult) { if (bWasSuccessful && InviteResult.IsValid()) { SessionInterface->JoinSession(0, NAME_GameSession, InviteResult); } } // JoinSession 성공 콜백에서 접속 주소를 받아 ClientTravel FString ConnectString; SessionInterface->GetResolvedConnectString(NAME_GameSession, ConnectString); PlayerController->ClientTravel(ConnectString, ETravelType::TRAVEL_Absolute);
유저 관점에서 보면 이 모든 과정이 "초대 → 수락" 두 번의 클릭으로 끝난다.

트레이드오프: 왜 굳이 P2P(Listen Server)였나

이 부분이 이 구현에서 가장 중요한 판단이었다. P2P는 공짜가 아니라 명확한 장단점을 감수하고 택하는 구조이기 때문이다.
장점
  • 전용 서버 인프라가 필요 없어 서버 운영 비용이 들지 않는다.
  • 매치메이킹·접속 계층을 Steam에 위임하므로 구현·운영이 단순하다.
단점 (감수한 것들)
  • 호스트 의존성 — 서버가 곧 특정 플레이어이므로, 호스트의 네트워크 품질과 PC 성능이 전체 세션 경험을 좌우한다.
  • 호스트 이탈 시 세션 종료 — 별도의 호스트 마이그레이션을 구현하지 않으면, 호스트가 나가는 순간 방이 해체된다.
  • 신뢰 경계 문제 — 호스트가 권위를 가지므로 치팅·조작 관점에서 전용 서버보다 취약하다.
  • 확장성 한계 — 대규모 동시접속에는 적합하지 않다.
이 프로젝트는 친구 초대 기반의 소규모 세션이 핵심이었기 때문에, 위 단점(특히 확장성·치팅)은 치명적이지 않다고 판단했다. 반대로 서버 비용 절감과 "설정 없는 접속"이라는 접근성의 이점은 이 프로젝트의 목표와 정확히 맞았다. 그래서 Dedicated Server 대신 Steam 기반 Listen Server를 선택했다.
정리하면, 이건 "P2P가 더 좋아서"가 아니라 프로젝트의 규모와 목표에 맞춰 트레이드오프를 저울질한 결과의 선택이었다.

결과

  • 유저는 IP 입력·포트포워딩·방화벽 설정 없이, 초대 수락만으로 멀티플레이에 접속할 수 있게 되었다.
  • 서로 다른 네트워크 환경(각자 다른 공유기 뒤)에 있는 유저끼리도 연결이 성립했다.
  • 전용 서버가 없으므로 서버 운영 비용이 발생하지 않았다.

회고 / 배운 점

  • P2P의 진짜 난이도는 "P2P 로직" 자체보다 NAT 환경에서 서로를 찾고 연결하는 접속 계층에 있었다. 이걸 직접 만들지, 플랫폼(Steam)에 위임할지가 실질적인 설계 결정이었다.
  • 네트워크 구조 선택은 정답이 있는 게 아니라 프로젝트 규모·목표에 따른 트레이드오프라는 걸 체감했다. Listen Server / Dedicated Server의 경계와 각각의 비용을 이해하는 게 중요했다.
  • (심화 방향) 규모가 커지거나 호스트 이탈 대응이 필요해지면, 호스트 마이그레이션이나 Dedicated Server 전환이 다음 과제가 될 것이다.
<pagelist>