Photon Fusion 2 기반 멀티룸 서버 구조
: 여러 방을 안정적으로 운영하려면, 방을 논리적으로만 나누면 부족하다.
Photon Fusion 2로 2D 대전 게임을 만들면서 멀티룸 구조가 필요해졌습니다.
플레이어는 로비에 접속하고, 방을 만들고, 원하는 방에 들어가 게임을 진행해야 했습니다.
그래서 처음에는 하나의 서버 프로세스 안에서 로비와 게임 방을 모두 관리하려고 했습니다.
[서버 프로세스]
├─ 로비 Runner
├─ 게임방 Runner A
└─ 게임방 Runner B
구조만 보면 단순하고 자연스러워 보였습니다.
로비는 방 목록을 관리하고, 각 게임방 Runner는 자기 방의 게임만 처리하면 된다고 생각했습니다.
하지만 실제로 두 번째 방을 만드는 순간 문제가 생겼습니다.
첫 번째 방의 캐릭터가 사라졌습니다.
서버가 종료된 것도 아니고, 클라이언트가 튕긴 것도 아니었습니다.
그냥 첫 번째 방의 네트워크 오브젝트들이 조용히 사라졌습니다.
이 문제를 해결하려면 먼저 하나를 확인해야 했습니다.
'두 번째 방을 만든 게 왜 첫 번째 방에게 영향을 주었을까'
문제는 Fusion의 씬 관리 방식에서 시작됐습니다.
Photon Fusion 2의 기본 씬 관리자인 NetworkSceneManagerDefault에는 Scene Takeover라는 동작이 있습니다.
새 Runner가 씬을 로드할 때, 이미 같은 씬이 로드되어 있으면 기존 씬을 인계받을 수 있습니다.
우리 프로젝트에서는 모든 게임 방이 같은 SampleScene을 사용하고 있었습니다.
그래서 두 번째 방의 Runner가 SampleScene을 로드하는 순간, 첫 번째 방이 사용하던 씬과 충돌했습니다.
그 결과 첫 번째 방의 Runner는 자기 씬에 대한 통제권을 잃었고, 그 씬 위에 있던 플레이어와 RoomManager, CardDeck 같은 오브젝트들도 함께 사라졌습니다.
즉, 문제는 단순히 오브젝트 하나가 사라진 것이 아니었습니다.
방은 여러 개였지만, 실제로는 같은 씬과 같은 실행 환경을 공유하고 있었다.
그렇기에 씬을 서로 인계받지 못하게 막아야 했습니다.
가장 먼저 Scene Takeover를 껐습니다.
var sceneManager = roomObj.AddComponent<NetworkSceneManagerDefault>();
sceneManager.IsSceneTakeOverEnabled = false;
이렇게 하니 두 번째 방을 만들어도 첫 번째 방의 씬이 바로 사라지지는 않았습니다.
겉으로 보기에는 문제가 해결된 것처럼 보였습니다.
하지만 여기서 끝이 아니었습니다.
씬 인계는 막았지만, 두 방은 여전히 하나의 Unity 프로세스 안에서 실행되고 있었습니다.
씬은 나뉘어 있어도 메모리 공간은 같았습니다.
Runner는 여러 개여도 Unity 엔진 인스턴스는 하나였습니다.
그러다 보니 다음 문제가 드러났습니다.
이번에는 전역 검색이 문제였습니다.
Unity의 FindObjectOfType<T>()는 특정 씬 안에서만 컴포넌트를 찾지 않습니다.
현재 로드된 모든 씬을 대상으로 검색합니다.
즉, 게임방 A의 코드가 게임방 B의 RoomManager를 찾을 수 있었습니다.
반대로 게임방 B의 총알이 게임방 A의 PlayerHealth를 찾을 수도 있었습니다.
Scene Takeover를 꺼도, 이런 전역 검색이 남아 있으면 방끼리 서로 섞일 가능성이 있었습니다.
그래서 SceneSearch 유틸리티를 만들었습니다.
var roomManager = SceneSearch.FindInScene<RoomManager>(gameObject.scene);
이제 오브젝트가 속한 씬 안에서만 컴포넌트를 찾도록 바꿨습니다.
이 변경으로 코드 레벨의 참조 문제는 많이 줄었습니다.
하지만 여전히 찝찝한 부분이 남아 있었습니다.
전역 검색은 우리가 작성한 코드에서 제어할 수 있습니다.
하지만 Fusion 내부 상태나 Runner 간섭까지 우리가 완전히 제어할 수는 없습니다.
결국 문제는 다시 같은 지점으로 돌아왔습니다.
한 프로세스 안에서 여러 방을 완전히 격리할 수 있는가?
이 질문 때문에 Fusion의 PeerMode도 확인해봤습니다.
PeerMode.Multiple이라는 이름만 보면 여러 Runner를 한 프로세스 안에서 안정적으로 돌릴 수 있을 것 같았습니다.
하지만 실제로는 기대한 방향과 달랐습니다.
GameMode.Server로 로비 서버를 시작하는 순간 서버가 바로 종료됐고, 여러 Server Runner를 운영하기 위한 해답으로 쓰기에는 적합하지 않았습니다.
확인 결과 Multiple Peer 모드는 여러 클라이언트를 한 프로세스에서 테스트하는 용도에 가까웠습니다.
그래서 다시 Single 모드로 되돌렸습니다.
여기까지 오면서 결론은 점점 분명해졌습니다.
Scene Takeover를 꺼도 부족했습니다.
전역 검색을 제한해도 부족했습니다.
Multiple Peer도 이 문제의 해답은 아니었습니다.
문제의 본질은 특정 옵션 하나가 아니라 격리의 부족이었습니다.
최종적으로 선택한 구조는 Process-per-Room입니다.
말 그대로 게임 방마다 별도의 서버 프로세스를 실행하는 방식입니다.
[로비 서버]
├─ [게임 서버 프로세스 A] - Room_A
├─ [게임 서버 프로세스 B] - Room_B
└─ [게임 서버 프로세스 C] - Room_C
이 구조에서는 각 게임 서버가 자기만의 실행 환경을 가집니다.
NetworkRunner이렇게 되면 방끼리 영향을 줄 수 있는 범위가 크게 줄어듭니다.
게임방 A에서 문제가 생겨도 게임방 B의 씬이나 오브젝트를 건드릴 수 없습니다.
FindObjectOfType<T>()를 사용하더라도 프로세스 안에는 해당 게임방의 씬만 있기 때문에 다른 방의 오브젝트를 찾을 수 없습니다.
Scene Takeover도 마찬가지입니다.
인계받을 다른 게임방 씬이 같은 프로세스 안에 없기 때문에, 방끼리 씬을 빼앗는 문제가 발생하지 않습니다.
물론 이 구조에도 비용은 있습니다.
로비 서버가 게임 서버 프로세스를 생성하고 종료해야 합니다.
방 목록과 세션 상태도 별도로 추적해야 합니다.
게임 서버가 비정상 종료됐을 때 정리하는 로직도 필요합니다.
하지만 이 복잡성은 예측 가능합니다.
반대로 한 프로세스 안에서 여러 방이 서로 간섭하는 문제는 재현하기도 어렵고, 디버깅하기도 어렵습니다.
그래서 이 프로젝트에서는 프로세스 관리를 감수하고, 방 단위 격리를 확실하게 가져가는 쪽을 선택했습니다.
구조를 바꿨다고 해서 클라이언트 흐름까지 크게 바뀌지는 않습니다.
플레이어 입장에서는 여전히 로비에 접속하고, 방을 만들고, 해당 방에 들어가면 됩니다.
먼저 플레이어가 게임을 실행하면 로비 세션에 접속합니다.
로비 서버는 LobbyServerManager가 담당합니다.
플레이어가 방 만들기를 누르면, 클라이언트는 로비 서버에 방 생성 요청을 보냅니다.
로비 서버는 이 요청을 받고 새로운 게임 서버 프로세스를 실행합니다.
--mode game --session Room_abc123
새로 실행된 게임 서버는 자기만의 NetworkRunner를 만들고 Photon Cloud에 새로운 세션을 등록합니다.
세션 등록이 끝나면 로비 서버는 클라이언트에게 해당 세션 이름을 전달합니다.
이제 클라이언트는 로비에서 나와 전달받은 세션 이름으로 게임 방에 접속합니다.
로비 Runner와 게임방 Runner는 서로 다른 프로세스에서 실행되지만, 클라이언트 입장에서는 자연스럽게 방에 입장하는 흐름입니다.
게임 방 안에서는 GameServerManager가 전체 라이프사이클을 관리합니다.
모든 플레이어가 방을 나가면, 게임 서버는 일정 시간 대기한 뒤 스스로 종료됩니다.
이렇게 하면 방이 만들어질 때는 게임 서버가 실행되고, 방이 비면 게임 서버가 정리됩니다.
Process-per-Room 구조를 선택하면 자연스럽게 다음 질문이 생깁니다.
방마다 서버 프로세스를 띄우면, EC2에서 포트를 계속 열어야 할까?
Photon Cloud 릴레이 모드에서는 그렇지 않습니다.
EC2 게임 서버가 클라이언트의 직접 접속을 기다리는 구조가 아니기 때문입니다.
게임 서버가 먼저 Photon Cloud에 Outbound 연결을 겁니다.
클라이언트도 Photon Cloud에 Outbound 연결을 겁니다.
Photon Cloud는 양쪽 연결을 받아서 데이터를 릴레이합니다.
[EC2 게임 서버] ── Outbound ──> [Photon Cloud] <── Outbound ── [클라이언트]
이 구조에서는 게임 서버가 직접 Listen 포트를 열 필요가 없습니다.
Outbound 연결을 만들 때 포트는 운영체제가 자동으로 할당합니다.
이 포트를 Ephemeral Port라고 합니다.
그래서 StartGame()을 호출할 때 Address를 직접 지정하지 않아도 됩니다.
await runner.StartGame(new StartGameArgs
{
GameMode = GameMode.Server,
SessionName = sessionName,
PlayerCount = maxPlayers,
Scene = sceneInfo,
SessionProperties = sessionProperties
});
AWS 보안그룹도 같은 흐름으로 이해할 수 있습니다.
보안그룹은 Stateful 방화벽입니다.
EC2가 먼저 만든 Outbound 연결의 응답 트래픽은 별도의 Inbound 규칙 없이도 허용됩니다.
따라서 Photon Cloud 릴레이 모드에서는 게임 서버 프로세스가 여러 개 떠도, 각 방마다 Inbound 포트를 열어줄 필요가 거의 없습니다.
반대로 Photon Cloud를 거치지 않고 클라이언트가 EC2 게임 서버에 직접 접속하게 만들 수도 있습니다.
이걸 Direct Connection이라고 합니다.
이 경우에는 상황이 달라집니다.
클라이언트가 직접 EC2에 접속해야 하므로, 게임 서버가 특정 포트를 열고 기다려야 합니다.
Address = NetAddress.Any(27016)
방마다 별도 프로세스를 띄운다면 포트도 방마다 나눠야 합니다.
예를 들어 첫 번째 방은 27016, 두 번째 방은 27017처럼 관리해야 합니다.
그리고 EC2 보안그룹에서도 해당 포트 범위에 대한 Inbound 규칙을 열어야 합니다.
정리하면 다음과 같습니다.
이 프로젝트에서는 우선 안정적인 멀티룸 운영이 중요했기 때문에 Photon Cloud 릴레이 모드를 기준으로 구조를 잡았습니다.
최종 서버 코드는 역할에 따라 세 가지로 나뉩니다.
ServerManager는 서버 프로그램의 진입점입니다.
커맨드라인 인자를 보고 로비 서버로 실행할지, 게임 서버로 실행할지 결정합니다.
LobbyServerManager는 로비 세션만 담당합니다.
플레이어의 방 생성 요청을 받고, 게임 서버 프로세스를 실행하고, 생성된 세션 정보를 클라이언트에게 전달합니다.
GameServerManager는 게임 방 하나만 담당합니다.
해당 방의 Ready, 카운트다운, 게임 시작, 게임 종료, 재경기, 플레이어 이탈 처리를 관리합니다.
이렇게 나누면 각 클래스의 책임도 자연스럽게 분리됩니다.
로비 서버는 방을 만들고 안내하는 역할만 합니다.
게임 서버는 자기 방의 게임 진행만 책임집니다.
서버 진입점은 어떤 모드로 실행할지만 결정합니다.
결국 구조는 단순해집니다.
로비는 여러 방을 관리하고, 게임 서버는 자기 방 하나만 관리한다.
처음에는 두 번째 방을 만들었을 때 첫 번째 방의 캐릭터가 사라지는 단순한 버그처럼 보였습니다.
하지만 원인을 따라가 보니 문제는 더 구조적이었습니다.
Scene Takeover는 첫 번째 원인이었습니다.
전역 검색은 두 번째 문제였습니다.
Multiple Peer는 기대한 해결책이 아니었습니다.
이 과정을 거치면서 알게 된 것은 하나였습니다.
여러 방을 안정적으로 운영하려면, 방을 논리적으로만 나누면 부족하다.
최종 결론은 다음과 같습니다.
로비는 하나로 유지하고, 게임 방은 프로세스 단위로 분리한다.
이 구조는 프로세스 관리라는 새로운 복잡성을 가져옵니다.
하지만 그 대신 방끼리 서로 영향을 주는 문제를 원천적으로 줄일 수 있습니다.
디버깅하기 어려운 씬 간섭 문제를 계속 끌고 가는 것보다, 운영 가능한 복잡성을 선택하는 쪽이 더 나은 설계라고 판단했습니다.