언리얼엔진은 복잡한 구조의 네트워크 구조를 가지고 있습니다. 이 글에서는 전체적인 흐름을 이해하는 입장에서 작성되었으므로 PushModel
이나 Iris
와 같은 향상된 네트워크 시스템을 다루고 있진 않습니다.
그러나 언리얼에 대한 일반적인 지식이 있다는 가정하에 진행하는 부분이 있기에 소켓프로그래밍 기초나 언리얼 네트워크 기초에 대해서 공부하셨다면 이해하기 편합니다.
이 글은 (언리얼)네트워크에 대해서 조금이라도 지식이 있는 분들이 이해할 수 있도록 작성되었습니다. 네트워크에 관한 지식이 풍부하지 않다면 이해하는데 어려움이 있을 수 있습니다.
2부는 1부를 이어서 진행하기에 1부를 보고 오시는 것을 권장드립니다.
언리얼에서 생성된 World는 데디서버 기준으로 서버와 각 클라이언트마다 존재합니다. 그리고 월드데이터의 송수신은 NetDriver를 통해 이루어집니다.
NetDriver는 서버와 클라이언트의 네트워크를 관리하는 핵심 드라이버 역할을 합니다.
일반적으로 NetDriver(IpNetDriver)는 서버와 클라이언트에게 하나씩 존재합니다.
그외에도 다양한 NetDriver가 존재할 수 있습니다만 여기서는 Replicate와 RPC에 대해 중점적으로 다루기에 IpNetDriver에 대해서만 다루고자 합니다. IpNetDriver는 IP(인터넷 프로토콜) 기반 네트워크 통신을 구현하는데 사용됩니다.
이 글에선 편의상 그리고 이해하기 쉽도록 IpNetDriver를 NetDriver라고 부르겠습니다.
NetDriver는 데이터 공유를 위한 NetConnection을 가지게 되고 NetConnection을 이용해서 데이터를 공유합니다.
서버의 NetDriver는 연결된 모든 클라이언트를 ClientConnections에 저장하여 관리하고,
클라이언트는 ServerConnection에 서버의 NetConnection을 저장하여 서버와 데이터를 주고받습니다.
아래 그림을 통해 NetDriver와 NetConnection의 대략적인 관계를 알 수 있습니다.
이때 서버와 클라가 서로의 NetConnection을 알기 위해선 1장에서 보았던 UDP HandShaking과정이 필요합니다.
데이터(패킷)의 경우, 서버와 클라 모두 NetDriver에서 패킷을 수신하고 해당 패킷을 NetConnection에 전달합니다.
그리고 각각의 개별 NetConnection는 공유할 데이터를 DataChannel로 라우팅합니다.
이때 DataChannel은 UActorChannel, UControlChannel, UVoiceChannel 등 알맞는 채널을 통해서 라우팅합니다.
DataChannel 중 서버에서 클라이언트로 복제(Replicate)되는 모든 액터는 고유 ActorChannel이 존재합니다.
NetDriver, NetConnection, Channel의 관계는 아래 그림과 같습니다.
게임 세션의 생성, 파괴 및 관리와 같은 제어 메시지를 전송하는 데 사용됩니다. 예를 들어, 클라이언트가 서버에 연결하거나 연결을 종료할 때 사용되는 메시지가 이 채널을 통해 전송됩니다.
1부에서 HandShaking을 수행하는 채널이 ControlChannel입니다.
음성 통신을 위해 사용됩니다. 멀티플레이어 게임에서 플레이어 간의 실시간 음성 채팅 기능을 구현할 때 이 채널을 사용할 수 있습니다.
게임 내의 Actor를 동기화할 때 사용됩입니다. 예를 들어, 캐릭터의 이동, 데미지, 액터의 생성과 파괴 등의 데이터가 이 채널을 통해 전송됩니다.
Bunch는 언리얼 네트워트 데이터의 기본 단위로 게임의 현재 상태를 담은 데이터의 조각입니다. 여기에는 개체, 이벤트, 속성에 대한 정보뿐 아니라 네트워크 통신을 제어하는 메타데이터도 포함됩니다.
Packet은 Bunch보다 큰 네트워크 데이터입니다. 그리고 실제로 네트워크를 통해 데이터를 전송하는 데 사용됩니다. 패킷에는 일반적으로 여러 개의 Bunch로 이루어져 있습니다.
Bunch는 게임 데이터를 패키징하는 역할을 담당하고, Packet은 이러한 Bunch를 네트워크를 통해 전송하는 역할을 담당합니다.
Packet은 송신 과정에서 직렬화하여 전달된 후, 수신 과정에서 역직렬화를 통해 게임에 적용됩니다.
그리고 Bunch는 현재 Bunch가 처음인지, 끝인지, 쪼개져 있는지를 1Byte자료형으로 관리(Bool)하고 있습니다. 이 값은 패킷을 수신받을 때 헤더에서 읽어 들이게 됩니다.
선언부는 아래처럼 Bunch에 존재합니다.
해당 값은 데이터를 수신할 때, 비트연산을 통해 Buffer에서 읽어들입니다.
위 코드의 빨간 박스를 해석하면 아래와 같습니다.
버퍼의 특정 위치에 있는 값과 &연산 처리 후 간단하게 bool값으로 변환해주는 로직(!!)으로 하드코딩스럽게 짜여져 있습니다.
이제 어느정도의 개념은 잡혔으니 코드로 어떻게 동작하는지 분석해봅시다.
서버의 NetDriver는 UWorld::Listen에서 생성됩니다.
그리고 CreateNetDriver_Local 함수에서 NetDriver를 생성하게 됩니다.
클라의 NetDriver는 UEngine::Browse에서 생성됩니다.
이하 생성 로직은 서버의 UWorld::Listen과 동일합니다.
서버의 NetConnection은 NetDriver의 TickDispatch를 통해서 클라이언트로부터 패킷을 전송받는 과정에서 생성됩니다.
이렇게 생성된 UIpConnection 객체는 UNetDriver::AddClientConnection 함수에서 서버의 ClientConnections에 추가됩니다.
클라의 NetConnection은 NetDriver가 생성된 후 UPendingNetGame::InitConnect에서 생성됩니다.
ActorChannel의 경우엔 생성되는 방식이 기존의 방식과는 살짝 다릅니다. 일반적으로 Actor가 스폰될 때, RPC호출 등을 통해 생성됩니다.
그럼 Actor가 스폰되는 상황에 대해 조금 알아봅시다.
(Actor가 어떻게 스폰되는지는 이후에 다뤄보고자 하니 여기서는 간단하게만 짚고 넘어갑시다.)
액터 스폰을 위해 SpawnActor 함수를 서버에서 호출하면 서버는 Replicate가 가능한 Actor를 ReplicateActor 리스트
에 저장해두고 해당 액터의 정보를 연결된 ClientConnections를 통해 모든 클라이언트에게 패킷으로 전달하게 됩니다.
이때 호출되는 함수가 UNetDriver의 ServerReplicateActors_ProcessPrioritizedActorsRange입니다. 아래는 위 함수에서 Channel 생성로직 부분만 가져온 것입니다.
위의 Channel의 개념을 설명하면서 언급했듯이 동기화를 위한 데이터는 Channel을 통해서 전달되기 때문에 해당 정보를 Replicate해주려면 ActorChannel이 필요합니다.
그러므로 이 함수를 통해 모든 클라이언트에게 Actor정보를 전달하기 이전에 해당 액터의 Channel을 생성해줍니다.
그리고 해당 ActorChannel을 통해 Replicate를 진행합니다.
서버는 위 로직의 CreateChannelByName에서 시작해서 아래의 흐름에 따라 생성됩니다.
NetConnection의
CreateChannelByName
→GetOrCreateChannelByName
→InternalCreateChannelByName
순으로 진행됩니다.
CreateChannelByName의 채널 생성 로직 바로 아래부분을 보면 해당 채널들을 NetConnection이 Array에 추가하여 관리하는 것을 볼 수 있습니다.
만약 통신 중 해당 채널이 이미 존재한다면 생성된 채널을 경유해서 패킷을 전송하게됩니다.
그럼 클라이언트의 경우엔 어떻게 Channel을 생성할까요?
먼저 서버에서 Actor를 생성한 후 Replicate했으니 해당 패킷을 Recv하는 과정이 필요합니다.
그리고 패킷을 수신받는 과정에서 Channel을 생성해야합니다.
패킷이 수신되는 과정은 아래처럼 1부의 클라이언트 Role 설정과정과 비슷합니다.
NetConnection의
TickDispatch
→ReceivedPacket
→DispatchPacket
→CreateChannelByName
순으로 진행됩니다.
그리고 수신받은 Bunch의 데이터를 통해서 Channel을 생성합니다. 이후 로직은 서버와 동일합니다.
언리얼은 Reliable UDP를 사용하는 것으로 알려져 있습니다. 그럼 Reliable UDP는 무엇이고 어떻게 동작하는 것일까요?
UDP는 1부에서 봤듯이 UDP Socket에서 Bind와 RecvFrom을 통해 간단하게 데이터를 수신할 수 있습니다.
주요 프로세스는 아래와 같으며 NetDriver의 TickDispatch에서 시작합니다.
그리고 반복자를 통해 패킷을 수신합니다.
반복자의 생성자에서 AdvanceCurrentPacket 호출하고 RecvFrom으로 패킷을 수신하게 되는 것이죠.
Reliable UDP는 기존의 UDP에서 손실된 패킷을 무시하지 않고 재전송해주는 방식을 말합니다. 이 과정에서 패킷의 순서, 도착, 무결성을 보장합니다.
언리얼의 RUDP는 위 3가지를 보장하기 위해 패킷에 시퀀스 번호를 붙입니다. 정상적인 순서대로 패킷이 들어왔다는 가정하에 현재 수신된 패킷의 시퀀스 번호는 이전 패킷의 시퀀스 번호의 +1이 되는 것이죠.
시퀀스 번호는 IsInternalAck를 통해 100% Reliable한 패킷인지 체크 후, 수신된 Bunch의 시퀀스 번호를 InReliable[Bunch의 채널 인덱스]
의 다음 번호(+1)로 설정합니다.
이 로직도 NetDriver의 TickDispatch에서 구현됩니다.
InReliable에 대한 설명은 자세히 나와 있지 않지만 아마도 Reliable한 Channel들의 인덱스값을 통해 해당 Channel에서 가장 최근에 처리된 SequenceNumber이지 않을까라고 생각합니다.
그리고 Reader를 통해 읽어들인 바이트데이터를 Bunch의 Buffer 공간에 직렬화합니다.
Buffer에 AddUninitialized를 통해 추가적인 공간을 할당하고 있는데, 개인적으로 이 할당이 왜 이루어지는지는 모르겠습니다... 아시는 분이 있다면 알려주세요.
이렇게 ChSequence가 설정되면 ReceivedRawBunch 함수를 호출하여 Bunch에 있는 데이터를 처리합니다.
서버에서 패킷을 전송할 때마다 시퀀서 번호를 1씩 증가 시키고 클라이언트에서는 시퀀스 번호를 포함한 패킨을 수신받게 되는데, 만약 도중에 시퀀스 번호가 가운데 빠져있다면 해당 시퀀스 번호를 서버에게 다시 요청하게 됩니다.
이때 패킷 손실을 탐지하기 위해 가장 최근에 전달받은 시퀀스 번호와 현재 시퀀스 번호를 비교하게됩니다.
정상적으로 작동한다면 시퀀스 번호의 차이가 1이어야 합니다. 차이가 1이상이면 패킷이 도중에 손실된 것이고 1보다 작으면 정상적으로 수신되지 않는 상태를 뜻합니다. 아래 코드를 봅시다.
시퀀스 번호의 차이가 1이 아니면 현재 Bunch를 InRec
에 임시 저장합니다. 그리고 추가적으로 Bunch가 들어오면 싱글 링크드리스트처럼 번호순으로 삽입하여 정렬합니다.
그리고 정상적인 패킷이 들어왔을 때, else에서 ReceivedNextBunch를 통해 Bunch 데이터를 처리합니다.
이때 InRec
가 nullptr이 아니라면 작은 ChSequence순으로 지연된 Bunch를 추가적으로 처리합니다.
언리얼에서는 일부 누락된 패킷을 미리 저장해두는 방식으로 순서를 보장하면서 해당 패킷이 누락되는 문제를 해결합니다.
해당 로직을 테스트하기 위해선
https://dev.epicgames.com/documentation/en-us/unreal-engine/using-network-emulation-in-unreal-engine?application_version=5.3
위 링크를 통해 강제적으로 패킷 손실을 발생시켜 줘야합니다
패킷의 수신 과정에서 UNetConnection::ReceivedPacket 함수를 자세히 보면 시퀀스 데이터를 업데이트 하는 과정에서 패킷의 수신에 성공하면 ReceivedAsk, 실패하면 ReceivedNak을 호출하는 것을 볼 수 있습니다.
NetPacketNotify의 Update 함수에선 ProcessReceivedAsks에서 수신의 성공, 실패 여부를 결정합니다.
그리고 이 값을 AskCount가 1이면 성공하고 ReceivedAsk를 호출, 2이상이면 실패가 되어 ReceivedNak이 호출됩니다.
ReceivedNak에서 ReceivedAsk하지 못한 Bunch를 재전송합니다.
2부에서는 좀 더 넓은 의미로 네트워크를 다뤄봤습니다. 사실 이 글을 작성하면서 이렇게까지 써야할까? 라는 생각이 정말 많이 들었고 포스팅을 하는 과정도 쉽지 않았습니다. 모든 내용들이 생소하기도 했고 공부하면 공부할수록 새로운 내용들이 쏟아졌습니다.
그리고 글을 작성하다보니 3부까지 만들어야겠다는 생각이 들었습니다. 2부에서 모든 내용을 다룰려고 했으나 너무너무너무 내용이 많았습니다. 그래서 Replicate와 RPC, RPC는 왜 즉각적인 호출이 보장될까?에 대해서는 3부에서 다뤄보도록 하겠습니다.
아마 앞으로의 포스팅 주기는 살짝 길어질 듯 합니다. 기존에 진행하던 프로젝트에 우선순위를 높게 두고 블로그는 살짝 우선순위를 낮출 예정입니다.
이 글이 언리얼 네트워크를 공부하는데 있어서 많은 도움이 되었으면 합니다. 잘못된 부분에 대한 피드백은 언제나 환영입니다.
긴 글 읽어주셔서 감사합니다. 내잔이였습니다.
https://lawnight.github.io/server/serialization/
https://www.docswell.com/s/strvert/K7V3JJ-unrealengine-network-architecture?ref=rss#p42
https://www.cnblogs.com/lawliet12/p/17332897.html
https://www.jianshu.com/p/b4f1a5412cc9
와.. 너무 유익한 그리고 떠먹여주는 포스팅이었어요 ㅠㅠㅠ 언리얼 네트워킹에 대해 더 알아갑니다! 감사합니다!!
앗 그럼 질문이 있는데 제가 알기론 일반 RPC는 UDP 방식이라 누락될수도있고, 순서가 보장되지도 않는걸로 알고있는데
그렇다면 만약 서버에서 호출한 한 함수에서 A Reliable RPC, B RPC, C Reliable RPC, D RPC 순서대로 호출한 경우 받는측에서는 B와 D는 BD, DB 이런식으로 바꿔서 올 수 있으나 A 다음 C가 호출되는 순서는 무조건 보장되는걸까요??
그럼 ABCD, BACD, DBAC 이런식으로 받게되는 경우도 생기는걸까요??