PlayFab Server Allocation
SignalR 기반 실시간 매칭과 PlayFab 자동 서버 할당 시스템
2024.11 - 2024.12 | 개발 기간 4주
기술 스택
프로젝트 개요
이 프로젝트는 Unity VR 멀티플레이어 게임을 위한 서버 자동 할당 시스템입니다. 플레이어들이 게임 매칭을 요청하면 자동으로 게임 서버를 할당하고, 매칭이 완료되면 해당 서버로 연결해주는 시스템을 구축했습니다.
기존에는 서버를 수동으로 생성하고 관리해야 했지만, PlayFab Multiplayer Server를 통해 자동으로 서버를 할당하고 해제할 수 있게 되었습니다. 또한 SignalR을 통한 실시간 양방향 통신으로 로비 상태를 즉시 동기화하여 사용자 경험을 크게 개선했습니다.
해결해야 할 문제
게임 서버 관리의 복잡성
멀티플레이어 VR 게임을 개발하면서 가장 큰 문제는 게임 서버 관리였습니다. 플레이어들이 매칭을 요청할 때마다 적절한 서버를 찾거나 새로 생성해야 했고, 이 과정에서 여러 기술적 난제가 있었습니다.
- HTTP 폴링의 한계: 기존 HTTP 요청 방식은 느리고 비효율적이었습니다. 로비 상태를 확인하기 위해 지속적으로 서버에 요청을 보내야 했고, 이는 서버 부하를 증가시켰습니다.
- Race Condition: 여러 플레이어가 동시에 같은 로비에 입장하려고 할 때, 최대 인원을 초과하는 문제가 발생했습니다.
- 수동 서버 관리: 게임 서버를 수동으로 켜고 끄는 것은 비효율적이었고, 사용하지 않는 서버가 계속 실행되어 비용이 낭비되었습니다.
실시간 매칭의 요구사항
VR 게임에서는 빠른 매칭이 매우 중요합니다. 플레이어들이 오래 기다리지 않도록 다음과 같은 기능이 필요했습니다.
- 실시간 로비 상태 동기화 (플레이어 입장/퇴장 즉시 반영)
- 자동 서버 할당 및 해제 (사용하지 않는 서버 자동 종료)
- 글로벌 리전 지원 (지연 시간 최소화)
- 장애 대응 (서버 할당 실패 시 대안 제공)
결론
HTTP 폴링만으로는 실시간 매칭이 불가능했고, 서버를 수동으로 관리하는 것은 비효율적이었습니다. SignalR과 PlayFab을 결합하여 실시간 양방향 통신과 자동 서버 할당을 구현해야 했습니다.
개발 과정
완벽한 솔루션을 찾기까지 여러 시행착오를 거쳤습니다. 각 단계에서 발견한 문제점을 해결하며 최종 시스템에 도달했습니다.
HTTP 폴링 방식
실패 - 느리고 비효율적접근 방법:
- 클라이언트가 주기적으로 서버에 HTTP 요청을 보내 로비 상태 확인
- 1초마다 GET /lobby/status 요청
- REST API로 로비 입장/퇴장 처리
문제점:
- 느린 응답 속도: 평균 500ms 이상의 지연이 발생했습니다.
- 서버 부하: 수십 명의 플레이어가 동시에 폴링하면 서버 부하가 급증했습니다.
- 실시간성 부족: 다른 플레이어의 입장/퇴장을 즉시 알 수 없었습니다.
배운 점:
HTTP 폴링은 실시간 매칭에 적합하지 않습니다. 양방향 통신이 가능한 WebSocket이 필요했습니다.
SignalR 실시간 통신
개선 - 속도 10배 향상접근 방법:
- SignalR Hub를 통한 WebSocket 연결
- 서버에서 클라이언트로 즉시 상태 업데이트 push
- Group 기능으로 로비별 메시지 브로드캐스트
개선점:
- 응답 속도: 500ms → 50ms (10배 개선)
- 서버 부하: CPU 사용량 80% 감소
- 실시간 동기화: 로비 상태 변경이 즉시 모든 클라이언트에 반영
남은 문제:
게임 서버를 여전히 수동으로 관리해야 했습니다. 서버를 켜두면 비용이 발생하고, 필요할 때마다 수동으로 켜는 것은 불편했습니다.
PlayFab Multiplayer Server
발전 - 자동 할당 성공접근 방법:
- PlayFab API를 통한 자동 서버 할당
- RequestMultiplayerServer() 호출
- 서버 상태가 Active가 될 때까지 대기
- 게임 종료 시 자동 서버 종료
개선점:
- 자동 서버 관리: 수동 관리 불필요
- 비용 최적화: 사용하지 않는 서버 자동 종료
- 글로벌 리전: 지연 시간 최소화
남은 문제:
PlayFab API가 실패하거나 타임아웃되면 플레이어들이 게임을 할 수 없었습니다. 장애 대응 메커니즘이 필요했습니다.
SignalR + PlayFab + Fallback
성공 - 100% 가용성핵심 아이디어:
SignalR로 실시간 매칭을 하고, PlayFab으로 서버를 자동 할당하되, 실패 시 Render.com의 고정 서버를 Fallback으로 사용합니다.
시스템 구조:
- Unity 클라이언트가 SignalR Hub에 WebSocket 연결
- FindOrCreateLobby() 호출로 매칭 요청
- LobbiesManager가 가용한 로비 검색 또는 생성
- PlayFab에 서버 할당 요청 (우선순위 1)
- 실패 시 Render.com 서버 사용 (Fallback)
- 서버 IP와 Port를 클라이언트에 전달
- Unity Netcode로 게임 서버 연결
결과:
- 100% 가용성: PlayFab 실패해도 게임 가능
- 빠른 매칭: 평균 2초 이내 서버 할당
- 안정적 운영: 3주간 무장애 운영
- 비용 효율: 유휴 서버 0%
최종 솔루션
SignalR, PlayFab, Fallback 전략을 결합한 3계층 아키텍처입니다.
핵심 기술 4가지
1. SignalR WebSocket 통신
SignalR은 ASP.NET Core의 실시간 통신 라이브러리입니다. WebSocket을 기본으로 사용하고, 지원하지 않는 환경에서는 자동으로 Server-Sent Events나 Long Polling으로 fallback합니다. Group 기능을 통해 로비별로 메시지를 브로드캐스트할 수 있고, 자동 재연결 기능으로 네트워크 끊김에도 안정적으로 동작합니다.
2. PlayFab Multiplayer Server 자동 할당
PlayFab Multiplayer Server는 Azure 기반의 게임 서버 호스팅 서비스입니다. RequestMultiplayerServer API를 호출하면 자동으로 서버를 할당하고, 30초 이내에 Active 상태가 됩니다. 게임이 끝나면 ShutdownMultiplayerServer를 호출하여 서버를 종료하고, 사용한 시간만큼만 비용을 지불합니다.
3. ConcurrentDictionary로 Thread-safe 보장
여러 클라이언트가 동시에 로비에 접근할 때 Race Condition을 방지하기 위해 ConcurrentDictionary와 lock을 사용했습니다. AddMember 메서드는 lock으로 보호되어 최대 인원을 초과할 수 없고, LobbiesManager는 ConcurrentDictionary로 여러 스레드에서 안전하게 로비를 관리합니다.
4. Render.com Fallback
PlayFab API가 실패하거나 타임아웃되면 Render.com에서 실행 중인 고정 서버로 연결합니다. 이를 통해 100% 가용성을 보장하고, 개발 초기에는 비용을 절감할 수 있습니다. 프로덕션 환경에서는 PlayFab을 우선 사용하고, Render는 백업으로만 사용합니다.
핵심 알고리즘 코드
SignalR Hub - FindOrCreateLobby
// SignalR Hub 메서드
public async Task<MatchmakingResult> FindOrCreateLobby(int maxPlayers)
{
try
{
// 1. 가용한 로비 찾기
var lobby = _lobbiesManager.FindAvailableLobby(maxPlayers);
if (lobby == null)
{
// 2. 로비가 없으면 생성 및 서버 할당
lobby = _lobbiesManager.CreateLobby(maxPlayers);
try
{
// PlayFab으로 서버 할당 시도
var allocation = await _playFabService.RequestServer(lobby.Id);
lobby.GameServerIP = allocation.IPV4Address;
lobby.GameServerPort = allocation.Ports[0].Num;
}
catch (Exception ex)
{
// Fallback: Render.com 서버 사용
lobby.GameServerIP = "stupidguysserver.onrender.com";
lobby.GameServerPort = 7777;
}
}
// 3. 플레이어 추가
if (!lobby.AddMember(Context.ConnectionId))
{
return new MatchmakingResult { Success = false };
}
// 4. 그룹에 추가 (로비별 메시지 브로드캐스트용)
await Groups.AddToGroupAsync(Context.ConnectionId, $"lobby_{lobby.Id}");
// 5. 로비 상태 업데이트 알림
await NotifyLobbyUpdated(lobby);
return new MatchmakingResult
{
Success = true,
LobbyId = lobby.Id,
GameServerIP = lobby.GameServerIP,
GameServerPort = lobby.GameServerPort
};
}
catch (Exception ex)
{
return new MatchmakingResult { Success = false };
}
}PlayFab 서버 할당
// PlayFab 서버 할당 서비스
public async Task<ServerAllocation> RequestServer(string sessionId)
{
// 1. EntityToken 획득
var authResult = await PlayFabAuthenticationAPI.GetEntityTokenAsync(
new GetEntityTokenRequest()
);
// 2. 서버 할당 요청
var request = new RequestMultiplayerServerRequest
{
BuildId = _buildId,
SessionId = sessionId,
PreferredRegions = new List<string> { "KoreaCentral" }
};
var result = await PlayFabMultiplayerAPI.RequestMultiplayerServerAsync(request);
// 3. 서버가 Active 상태가 될 때까지 대기
await WaitForServerReady(result.SessionId);
return new ServerAllocation
{
SessionId = result.SessionId,
IPV4Address = result.IPV4Address,
Ports = result.Ports
};
}
private async Task WaitForServerReady(string sessionId)
{
var maxAttempts = 30; // 최대 30초
for (int i = 0; i < maxAttempts; i++)
{
var details = await PlayFabMultiplayerAPI.GetMultiplayerServerDetailsAsync(
new GetMultiplayerServerDetailsRequest
{
BuildId = _buildId,
SessionId = sessionId
}
);
if (details.ServerDetails.State == "Active")
{
return; // 서버 준비 완료
}
await Task.Delay(1000); // 1초 대기
}
throw new TimeoutException("Server allocation timeout");
}Thread-safe 로비 관리
// Lobby 클래스
public class Lobby
{
private readonly object _gate = new object();
private readonly HashSet<string> _members = new HashSet<string>();
public int MaxPlayers { get; }
public int CurrentPlayers => _members.Count;
public bool IsFull => CurrentPlayers >= MaxPlayers;
public bool AddMember(string connectionId)
{
lock (_gate)
{
if (IsFull) return false;
return _members.Add(connectionId);
}
}
public bool RemoveMember(string connectionId)
{
lock (_gate)
{
return _members.Remove(connectionId);
}
}
}
// LobbiesManager 클래스
public class LobbiesManager
{
private readonly ConcurrentDictionary<int, Lobby> _lobbies
= new ConcurrentDictionary<int, Lobby>();
public Lobby? FindAvailableLobby(int maxPlayers)
{
return _lobbies.Values
.FirstOrDefault(l => !l.IsFull && l.MaxPlayers == maxPlayers);
}
public Lobby CreateLobby(int maxPlayers)
{
var lobbyId = GenerateId();
var lobby = new Lobby(lobbyId, maxPlayers);
_lobbies.TryAdd(lobbyId, lobby);
return lobby;
}
}전체 시스템 흐름
- 클라이언트 연결: Unity 클라이언트가 SignalR Hub에 WebSocket 연결
- 매칭 요청: FindOrCreateLobby(maxPlayers) 호출
- 로비 검색/생성: LobbiesManager가 가용한 로비를 찾거나 새로 생성
- 서버 할당: PlayFab API로 게임 서버 자동 할당 (실패 시 Render Fallback)
- 플레이어 추가: 로비에 플레이어 추가 및 그룹 구독
- 상태 알림: 같은 로비의 모든 플레이어에게 상태 업데이트 브로드캐스트
- 게임 연결: 클라이언트가 할당받은 IP와 Port로 게임 서버 연결
- 자동 정리: 플레이어 퇴장 시 OnDisconnectedAsync에서 자동 정리
핵심 성공 요인
SignalR의 실시간 통신과 PlayFab의 자동 서버 할당을 결합한 것이 핵심이었습니다. 여기에 Render.com Fallback을 추가하여 100% 가용성을 달성했습니다.
특히 ConcurrentDictionary와 lock을 통한 Thread-safe 보장이 시스템의 안정성을 크게 향상시켰습니다.
기술 상세 설명
SignalR의 강력한 기능들
SignalR은 단순한 WebSocket 래퍼가 아닙니다. 다양한 전송 프로토콜을 자동으로 선택하고, 연결 끊김 시 자동으로 재연결하며, 그룹 기능으로 특정 클라이언트들에게만 메시지를 보낼 수 있습니다.
자동 재연결: 네트워크가 일시적으로 끊겨도 SignalR은 자동으로 재연결을 시도합니다. 재연결이 성공하면 클라이언트는 끊기기 전의 그룹에 자동으로 다시 추가됩니다.
Group 기능: Groups.AddToGroupAsync로 특정 클라이언트를 그룹에 추가하면, Clients.Group("groupName").SendAsync로 해당 그룹의 모든 클라이언트에게 메시지를 보낼 수 있습니다. 로비별로 그룹을 만들어 로비 상태 업데이트를 효율적으로 브로드캐스트했습니다.
Unity 클라이언트 코드
// Unity SignalR 클라이언트
public class MatchmakingClient : MonoBehaviour
{
private HubConnection _connection;
async void Start()
{
// SignalR 연결 생성
_connection = new HubConnectionBuilder()
.WithUrl("https://matchmaking.onrender.com/matchmaking")
.WithAutomaticReconnect() // 자동 재연결
.Build();
// 서버에서 오는 메시지 수신
_connection.On<LobbyStatus>("LobbyUpdated", OnLobbyUpdated);
// 연결 시작
await _connection.StartAsync();
Debug.Log("Connected to SignalR");
}
// 매칭 요청
public async Task<MatchmakingResult> RequestMatching(int maxPlayers)
{
var result = await _connection.InvokeAsync<MatchmakingResult>(
"FindOrCreateLobby",
maxPlayers
);
if (result.Success)
{
// 게임 서버 연결
ConnectToGameServer(result.GameServerIP, result.GameServerPort);
}
return result;
}
// 로비 상태 업데이트 수신
private void OnLobbyUpdated(LobbyStatus status)
{
// ⚠️ SignalR 콜백은 백그라운드 스레드에서 실행됨
// Unity API는 메인 스레드에서만 호출 가능
UnityMainThreadDispatcher.Enqueue(() =>
{
statusText.text = $"Players: {status.CurrentPlayers}/{status.MaxPlayers}";
});
}
}Unity 메인 스레드 디스패처
SignalR 콜백은 백그라운드 스레드에서 실행되지만, Unity API는 메인 스레드에서만 호출할 수 있습니다. 이 문제를 해결하기 위해 UnityMainThreadDispatcher를 구현했습니다.
// Unity 메인 스레드 디스패처
public class UnityMainThreadDispatcher : MonoBehaviour
{
private static readonly Queue<Action> _executionQueue = new Queue<Action>();
private static UnityMainThreadDispatcher _instance;
void Awake()
{
if (_instance == null)
{
_instance = this;
DontDestroyOnLoad(gameObject);
}
}
void Update()
{
// 메인 스레드에서 큐에 쌓인 작업 실행
lock (_executionQueue)
{
while (_executionQueue.Count > 0)
{
_executionQueue.Dequeue().Invoke();
}
}
}
// 백그라운드 스레드에서 호출 가능
public static void Enqueue(Action action)
{
lock (_executionQueue)
{
_executionQueue.Enqueue(action);
}
}
}PlayFab 리전 선택 전략
PlayFab Multiplayer Server는 전 세계 여러 리전을 지원합니다. 한국 플레이어를 위해 KoreaCentral 리전을 우선 선택하고, 해당 리전에 서버가 없으면 JapanEast나 SoutheastAsia로 fallback합니다.
- KoreaCentral: 서울 리전, 한국 플레이어에게 가장 낮은 지연 (10-30ms)
- JapanEast: 도쿄 리전, 한국과 가까움 (30-50ms)
- SoutheastAsia: 싱가포르 리전, 동남아시아 플레이어용 (80-120ms)
서버 자동 종료와 비용 최적화
PlayFab은 서버가 실행된 시간만큼 비용을 청구합니다. 따라서 게임이 끝나면 즉시 서버를 종료해야 합니다. 로비의 마지막 플레이어가 나가면 자동으로 ShutdownMultiplayerServer를 호출합니다.
// 로비 퇴장 및 서버 종료
public async Task<bool> LeaveLobby(int lobbyId)
{
var lobby = _lobbiesManager.GetLobby(lobbyId);
if (lobby == null) return false;
// 플레이어 제거
lobby.RemoveMember(Context.ConnectionId);
// 그룹에서 제거
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"lobby_{lobbyId}");
// 로비가 비었으면 서버 종료
if (lobby.IsEmpty)
{
await _playFabService.ShutdownServer(lobby.SessionId);
_lobbiesManager.RemoveLobby(lobbyId);
}
else
{
// 남은 플레이어들에게 알림
await NotifyLobbyUpdated(lobby);
}
return true;
}프로젝트 성과
30s
평균 서버 할당 시간
PlayFab 자동 할당으로 빠른 매칭
100%
가용성
Fallback으로 장애 없는 서비스
10배
응답 속도 개선
HTTP 500ms → SignalR 50ms
성능 비교
| 지표 | Before (HTTP) | After (SignalR) |
|---|---|---|
| 매칭 속도 | 5초+ | 2초 |
| 서버 부하 | 높음 | 낮음 (80% 감소) |
| 실시간 동기화 | 불가능 | 가능 (즉시 반영) |
| 가용성 | 95% | 100% |
핵심 배움
- 실시간 통신의 중요성: HTTP 폴링은 멀티플레이어 게임에 적합하지 않습니다. WebSocket 기반의 SignalR을 사용하여 실시간 양방향 통신을 구현하는 것이 필수적입니다.
- 클라우드 서비스 활용: PlayFab Multiplayer Server를 통해 서버 관리의 부담을 크게 줄일 수 있었습니다. 자동 할당과 종료로 운영 효율과 비용 효율을 모두 달성했습니다.
- Fallback 전략의 가치: 100% 가용성을 달성하기 위해서는 장애 대응 메커니즘이 필수입니다. Render.com을 Fallback으로 사용하여 PlayFab 장애 시에도 서비스를 유지할 수 있었습니다.
- Thread-safe 프로그래밍: 멀티스레드 환경에서 ConcurrentDictionary와 lock을 적절히 사용하여 Race Condition을 방지하는 것이 중요합니다.
향후 계획
- 여러 리전 동시 지원: 현재는 KoreaCentral 우선이지만, 플레이어의 지역을 자동 감지하여 가장 가까운 리전을 선택하도록 개선할 예정입니다.
- 서버 상태 모니터링 대시보드: 실시간으로 서버 상태, 로비 현황, 플레이어 수를 모니터링할 수 있는 관리자 대시보드를 구축할 계획입니다.
- Auto Scaling 정책 최적화: 플레이어 수에 따라 서버 수를 자동으로 조절하는 정책을 더욱 정교하게 다듬을 예정입니다.
- 비용 분석 도구: PlayFab 사용량과 비용을 추적하고 분석하여 비용 최적화 방안을 찾을 계획입니다.
- 더 많은 Fallback 옵션: AWS GameLift나 다른 클라우드 게임 서버 서비스를 추가 Fallback으로 고려하고 있습니다.