본문 바로가기

앙박기술서/장편

앙박의 기술서 [ Photon Fusion2 ]

LOG

2025.11.06 게시글 초안 등록


 

멀티 게임 INFEST

 

 올해 6월에 연습 삼아 'INFEST'라는 4인 협동 좀비 슈팅 게임을 만들었었습니다. 그 프로젝트에서 멀티 플레이 환경을 구축하기 위해 Photon Fusion2라는 SDK를 사용했었는데, 이번 포스팅에서 그에 관한 내용을 정리하려고 합니다.  

 

Photon 홈페이지

 

Photon products let you build and launch multiplayer games worldwide across all platforms. You can focus on developing your game while we scale to meet the ...

- Photon Engine 홈페이지에서 발췌 -


 Photon은 브랜드 이름이고, Fusion은 해당 브랜드에서 판매하는 제품들 중 하나입니다. 인용구에서 볼 수 있듯 Photon은 간단한 네트워크 구축 API와 크로스 플랫폼 지원을 강점으로 내새웁니다. 자체 서버를 구축할 자본과 지식이 없는 인디 게임들이 애용하는 제품이며 실제로 '프로젝트 윈터', '파스모포비아', 'VR Chat' 등의 인지도 있는 게임들이 사용 중입니다. 

 

 

Photon SDK 다운로드 페이지

 

 Photon은 많은 네트워킹 솔루션을 제공하고 있습니다. 그냥 개인적인 생각이지만 대부분의 제품이 Unity 엔진과 호환되는 것으로 보아 애초부터 인디 게임 개발자들을 타겟으로 잡은 것 같기도 합니다. 개발자들은 제품군들의 용도와 사용법을 비교해 가장 적합한 것을 선택해 사용하면 됩니다.

 

Photon Fusion 2 Game Sample
Photon Fusion 2 Game Sample

 

 Fusion2를 채택했던 이유로는 바로 위와 같은 멀티 게임 예제가 필요했기 때문입니다. 주어진 개발 기간이 두 달도 되지 않았기 때문에 제품군을 엄선하는데 시간읆 쓰는 것은 다소 사치라고 느껴졌고, 개발 시간 단축을 위해 참고가능한 예제를 찾는 것을 우선시했습니다. 게임 'INFEST'는 4인 협동 좀비 FPS 장르였고 이에 적합한 FPS 예제가 Fusion2에 있었습니다.

 

Fusion 2 네트워크 쿼드런트

 

 위 사진은 게임 장르에 따라 Fusion 2가 추천하는 네트워크 토폴로지들입니다. 게임 INFEST는 레프트 4 데드 2와 유사한 Coop FPS 장르로 분류될 수 있으며 그에 따라 Client-Host 네트워크를 사용하는 것이 적합한 것으로 보입니다.

 

 

 제품을 구매하면 위와 같이 사용 중인 제품과 정보가 나옵니다. 중요한 것은 CCU와 트래픽입니다. 동시 접속이 가능한 인원 수를 CCU라 하고, 트래픽은 월마다 사용 가능한 제한된 네트워크 데이터 전송량입니다. 실제로 두 제한을 넘긴 적이 없어서 그럴 시 어떤 일이 발생할지 알 순 없지만, 예측하자면 대기열이 발생하거나 서버가 터지는 등의 현상들이 발생할 듯 싶습니다. 

 

첫 멀티플레이 테스트

 

 Fusion2에서 제공하는 질 좋은 예제와 풍부한 튜토리얼에도 불구하고 실제 개발 과정은 고달팠습니다. 다양한 총기, 좀비들, 퀘스트, 점수판 등 새로운 요소들을 추가하는 과정에서 많은 고비가 있었습니다. 원하는 기능을 정상적으로 구현하는 데엔 네트워크와 Fusion2에 대한 깊은 이해가 필요했습니다.  

 

Fusion 2 튜토리얼

 

 개발 과정에서 있었던 수많은 이슈들의 해결책은 대부분 Fusion2 기술문서에 담겨있습니다. 그 예시로, "좀비가 소환될 때 팀원이 튕기는 현상"은 Hitbox 문서와 네트워크 최적화 문서에서 원인과 해결법을 찾았고 "Host에 따라 좀비 이동 속도가 달라지는 현상"은  튜토리얼 문서의 네트워크 동기화 기법에서 원인과 해결법을 찾았습니다. 후자의 경우는 좀 중요한 거라 보충 설명을 하자면, 원래 좀비들의 움직임은 Unity NavMesh 패키지로 제어되어 frame 보정이 들어갔었습니다. 헌데 fusion2의 경우에는 네트워크 tick을 기준으로 물리를 연산하고 동기화하기 때문에 tick 보정을 사용하지 않으면 문제가 생깁니다. 때문에 tick 보정이 가능한 커스텀 함수가 필요했고, 다행이 NavMesh에서 Move()라는 유용한 함수를 제공했기에 문제를 해결할 수 있었습니다.

 

 참고로 Fusion2는 유니티의 기본 생명 주기 위에 네트워크 생명 주기를 덮어 씁니다. 예시로 Host-Client 구조라면 Host를 제외한 모든 Client들의 유니티 생명주기는 멈추게 됩니다. 때문에 콜라이더 충돌과 같은 유니티 생명 주기 콜백 함수를 활용하는데 어느정도 제약이 걸리게 됩니다. 이는 Host를 중심으로 이벤트를 처리하고 RPC를 적극적으로 사용함으로써 대처할 수 있습니다.

 

디버깅

 

 Fusion2에선 유용한 디버깅 툴도 제공합니다. 패킷 송수신량, 패킷 크기, 대역폭 등 중요한 데이터들을 런타임 중에 실시간으로 확인할 수 있습니다. 사용이 편리하므로 이 툴을 활용해 최적화 작업을 진행하면 됩니다.

 

 참고로 최적화 작업을 진행할 때, 최적의 패킷 사이즈는 MTU(Maximum Transmission Unit)보다 작은 1280byte입니다. 초당 패킷 전송량도 적정량을 유지하는 게 권장됩니다. 그렇지 않을시 패킷 손실이 일어나 플레이어마다 보이는 좀비 수가 달라지고 투명 좀비가 생기는 등의 치명적인 버그가 발생할 수 있습니다. 

 

 링크: Fusion2 - 지연 보상

 링크: Fusion2 - 최적화

 

 

 유니티 인스펙터에서 네트워크 세팅을 직접 설정할 수 있습니다. Tick Rate는 네트워크 동기화 주기를 의미하는 개념이며 높을 수록 동기화가 부드러워집니다. 다만, Tick Rate가 높을 수록 네트워크 부하가 심해지므로 개발 중인 게임의 네트워크 사양에 맞춰 최적의 설정을 찾아야합니다. 

 

 

 INetworkRunnerCallbacks는 Fusion2 코딩의 핵심입니다. INetworkRunnerCallbacks의 함수들은 NetworkRunner라는 컴포넌트가 리플렉션을 통해 적절한 상황에서 자동으로 호출하며, 함수를 호출한 주체인 Runner는 파라미터로도 전달됩니다.

 

네트워크 러너는 일종의 컨트롤러라 볼 수 있다.

 

 Runner 컴포넌트는 세션 참여(e.g. Runner.StartGame()), 씬 로드(e.g. Runner.LoadScene()) 등의 네트워크 함수를 제공하며 Local 플레이어의 네트워크 정보(e.g. Runner.LocalPlayer)나 권한같은 유용한 데이터도 제공합니다. 이를 이용해 네트워크의 전체적인 흐름을 제어할 수 있습니다.

 

 OnPlayerJoined 콜백의 두 번째 파라미터인 PlayerRef는 세션에 접속한 플레이어의 정보를 담고 있습니다. 이를 이용해 새로운 플레이어가 접속할 때 알림 팝업을 띄우는 등의 후처리가 가능합니다. 그 외 Reason 계열의 파라미터들은 해당 콜백이 실행된 이유를 담고 있어 디버깅에 유용하게 사용됩니다. 

 

 OnInput 콜백은 네트워크 동기화 흐름 중에서 클라이언트들의 입력을 안정적으로 처리해줍니다.

 

 (링크: Fusion2 - 네트워크 러너)

 

네트워크 입력을 다루는데 필수적인 구조체

 

 Fusion2에선  INetworedInput를 상속한 구조체로 입력을 처리합니다. 멤버로는 Built-In 타입과 최상의 계층의 Struct 타입만 들어갈 수 있습니다. 주의할 점으로 bool값을 사용할 땐 NetworkBool을 대신 써야 한다는게 있습니다. C# 언어가 플랫폼 간 bool 사이즈가 일관되지 않아서, 직렬화시 플랫폼간에 데이터가 맞춰지지 않을 수 있기 때문입니다. `(링크: Fusion2 - 플레이어 입력)

 

public void OnInput(NetworkRunner runner, NetworkInput input)
{
    var data = _playerInputActionHandler.GetNetworkInput();

    if (data != null)
    {
        input.Set(data.Value);
    }
}

 

 위 코드는 프로젝트에서 사용한 INetworkRunnerCallbacks의 입력 처리 코드입니다. 유니티의 InputSystem으로 입력을 받아 네트워크 네트워크 구조체에 담은 후 폴링합니다.

 

using Fusion;
using UnityEngine;

public struct NetworkInputData : INetworkInput
{
    public Vector3 direction;           
    public Vector2 lookDelta;           
    public NetworkButtons buttons;      
    public Vector2 scrollValue;           

    public const byte BUTTON_FIRE = 0;      
    public const byte BUTTON_ZOOM = 1;      
    public const byte BUTTON_SWAP = 2;      

    public const byte BUTTON_JUMP = 3;      // space
    public const byte BUTTON_RELOAD = 4;    // R
    public const byte BUTTON_INTERACT = 5;  // F
    public const byte BUTTON_USEGRENAD = 6;   // G
    public const byte BUTTON_USEHEAL = 7;   // E
    public const byte BUTTON_USESHIELD = 8;   // V
    public const byte BUTTON_RUN = 9;       // lShift
    public const byte BUTTON_SIT = 10;       // lCtrl
    public const byte BUTTON_SCOREBOARD = 11;    // Tab
    public const byte BUTTON_MENU = 12;    // ESC
    public const byte BUTTON_CHANGECAMERA = 13;


    public const byte BUTTON_FIREPRESSED = 14;    
    public const byte BUTTON_ZOOMPRESSED = 15;    
    public const byte BUTTON_GRENADPRESSED = 16;    

    public bool isJumping;
    public bool isReloading;
    public bool isFiring;
    public bool isZooming;
    public bool isInteracting;
    public bool isUsingGrenad;
    public bool isUsingHeal;
    public bool isUsingShield;
    public bool isRunning;
    public bool isSitting;
    public bool isScoreBoardPopup;
    public bool isMenuPopup;
    public bool isChangingCamera;


    public bool isShotgunOnFiring;
    public bool isOnZoom;
    public bool isFellGrenad;

    public override string ToString()
    {
        string result =
            $"Direction: {direction}\n" +
            $"LookDelta: {lookDelta}\n" +
            $"ScrollValue: {scrollValue}\n" +
            $"Buttons: {buttons}\n" +
            $"IsJumping: {isJumping}\n" +
            $"IsReloading: {isReloading}\n" +
            $"IsFiring: {isFiring}\n" +
            $"IsZooming: {isZooming}\n" +
            $"IsInteracting: {isInteracting}\n" +
            $"IsUsingItem: {isUsingGrenad}\n" + 
            $"IsUsingItem: {isUsingHeal}\n" +
            $"IsUsingItem: {isUsingShield}\n" +
            $"IsRunning: {isRunning}\n" +
            $"IsSitting: {isSitting}\n" +
            $"IsScoreBoardPopup: {isScoreBoardPopup}\n" +
            $"IsMenuPopup: {isMenuPopup}\n" +
            $"IsChangingCamera: {isChangingCamera}\n" +
            $"IsShotgunOnFiring: {isShotgunOnFiring}\n" +
            $"IsOnZoom: {isOnZoom}";
        return result;
    }

}



 프로젝트에서 사용했던 네트워크 인풋입니다. InputSystem으로 입력을 받을시 네트워크 인풋 스트럭트를 만들어 입력을 풀링합니다. 또한 ToString 함수를 오버라이드해 디버그 하기 쉬운 형태로 입력값을 OnGUI에서 출력해줬습니다.

 

 

 Client마다 각자 Local에서 입력 풀링을 진행합니다. 입력 풀링이 제대로 처리되었으면 NetworkBehaviour 스크립트의 GetInput()함수를 이용해 가져올 수 있습니다.

 

매칭

public async void CreateNewSession(bool IsPublic, string code = "")
{
    Global.Instance.UIManager.Show<UILoadingPopup>();

    await Task.Delay(1000);
    foreach (var runner in FindObjectsOfType<NetworkRunner>())
    {
        await runner.Shutdown();
    }

    do {
        if (Runner != null)
            await Runner.Shutdown();

        // 새로운 Runner 생성
        var runnerGO = new GameObject("Runner (Shared)");
        Runner = runnerGO.AddComponent<NetworkRunner>();
        runnerGO.AddComponent<PlayGameListener>();


        var customProps = new Dictionary<string, SessionProperty>();

        customProps["map"] = (int)SelectedGameMap;
        customProps["type"] = (int)SelectedGameType;

        if (code == "")
            code = GenerateSessionCode();

        await Runner.StartGame(new StartGameArgs()
        {
            GameMode = GameMode.Shared,
            SessionName = code,
            IsVisible = IsPublic,
            SessionProperties = customProps,
            SceneManager = gameObject.AddGetComponent<NetworkSceneManagerDefault>()
        });
    } while (Runner.SessionInfo.PlayerCount > 1);

    if (Runner.IsSharedModeMasterClient)
    {
        Runner.Spawn(RoomPrefab);
    }
}

    

 프로젝트에서 사용했던 세션 생성 코드입니다. Runner를 생성하고 StartGame() 함수를 호출해 세션을 생성합니다. 세션 이름은 알파벳을 무작위로 조합해 생성합니다. IsVisible에 파라미터로 받은 IsPublic을 할당해 세션의 공개 여부를 정합니다. 맵 유형과 게임 모드 정보를 담은 customProps를 세션 정보로 넣어줍니다.         

 

Fusion2에서 쉽게 구현 가능한 이동/점프/사격

 

 Runer를 이용해 네트워크 흐름을 설계했다면, 그 후 NetworkBehaviour, NetworkTRSP 두 가지를 주축으로 원하는 로직을 구현하면 됩니다. 세부적으로는 Fusion2 생명주기, RPC, 네트워크 변수/컬렉션을 자주 사용합니다. 유니티 넷코드와 유사한 구조와 네이밍을 가지고 있으니 만약 넷코드에 능숙하다면 Fusion2 또한 금방 이해하고 사용할 수 있습니다.

네트워크 속성

[Networked, OnChangedRender(nameof(OnIsJumpAttack))]
public NetworkBool IsJumpAttack { get; set; } = false;

private void OnIsJumpAttack() => animator.SetBool("IsJumpAttack", IsJumpAttack);

 

 [Networked] 프로퍼티를 사용하면 몇몇 변수들을 자동으로 네트워크에 동기화시킬 수 있습니다. 변수 값이 변경될 때 호출되는 OnChangeRender로 콜백을 넘겨줄 수도 있습니다. 킬/데스/어시스트/골드/닉네임 같이 점수판에 실시간으로 동기화 되야 하는 값에 붙이기 용이합니다. 또한, 콜백을 넘겨줄 수 있다는 점을 살려 애니메이션 동기화(위 코드 예시 참고)에도 사용할 수 있습니다. NetworkBool을 사용할 때 차지하게 될 패킷 사이즈는 4byte보다 작습니다. (링크: Fusion 2 - 네트워크 속성)

네트워크 오브젝트 풀링

 

 Fusion2는 네트워크 객체를 스폰하는 별도의 로직을 가지고 있습니다. 추가적으로 오브젝트 풀링 모듈을 장착할 수 있습니다.

(링크: Fusion2 - Fusion 객체 풀링)

 

오브젝트 풀링 적용 전 후

 

 오브젝트 풀링을 사용했을 때 전/후 성능을 비교했었습니다. 자료엔 없지만 메모리 사용량도 비교했었는데, 뭔가 놓쳤는지 유의미한 결과가 나오진 않았습니다. 다른 요구사항들의 우선순위에 밀리다보니 아직까지도 오브젝트 풀링의 유용성을 입증하진 못했습니다.

 

세션 브라우저

 

 Fusion2는 세션 브라우저 기능 또한 지원합니다. 다만, Fusion2에서 공식적으로 세션 브라우저를 권장하지 않으니 주의해야 합니다. 

악랄한 끼임 현상

 

 캐릭터가 특정 개체에 스치기만 해도 오브젝트에 빨려들어가 끼어버리곤 합니다. 네트워크단에서 물리 연산을 할 때, MeshCollider를 가진 객체와 충돌하기만 하면 연산이 잘못되는 듯 싶습니다. 아무래도 MeshCollider의 복잡한 충돌 처리 떄문인 듯 싶어 BoxCollider로 바꿔주니 해결되었습니다.

 

콜라이더 충돌 이벤트를 사용할 때 주의해야 한다

 

 콜라이더 충돌 이벤트는 호스트에서만 작동합니다. 사용하고 싶다면 서버 측에서 처리해야 합니다. 서버 쪽에서 충돌 이벤트를 처리하고, 적절한 RPC를 클라이언트에게 쏴주면 됩니다. 실제로 이 방식으로 클라이언트의 상점 입장 시스템을 구축했습니다.;

심심찮게 공중을 날아다닌다.

 

 네트워크에선 아무리 사소한 것이라도 놓치면 안 됩니다. 만약 조금이라도 설렁설렁 넘어간다면 곧장 희안한 버그들을 발생시킵니다. 특히 입력 풀링과 그에 따른 움직임과 동기화는 유의해야 합니다.

NavMesh 사용 가능합니다.

 

 우선 서버 측에서 NavMesh를 통해 객체를 움직입니다. 그 후 NetworkTransform으로 객체의 위치를 동기화시키면 됩니다. 한 가지 유의할 점은 NavMesh가 Unity 패키지라 Fusion2와 충돌한다는 점입니다. 네트워크 객체를 스폰할 때 자꾸 엉뚱한 위치에 스폰되는 경우가 있습니다. 이건 Unity(NavMesh)와 Fusion2(NetworkTransform 혹은 Runner)이 서로 객체의 Transform을 제어하려 들어서 생기는 일입니다. NetworkTransform을 비활성화한 채로 객체를 스폰한 후, 곧바로 NetworkTransform을 활성화 시켜주면 잘 작동합니다. 위에서 말했던 Host에 따라 몬스터 이동 속도가 달라지는 이유도 NavMesh 때문입니다. 다시 말하자면 NavMesh의 SetDestination() 함수는 Frame기반이기 때문에 Fusion2에 호환되지 않습니다. Move() 함수에 Tick 보정을 넣으면 해결 됩니다.

 

 

아래는 Fusion2를 사용한 프로젝트 소개 영상입니다.

 

'앙박기술서 > 장편' 카테고리의 다른 글

앙박의 기술서 [ C++ 철학 엿보기 ]  (0) 2025.11.15