프로젝트 소개글은 여기서 확인하실 수 있습니다.
본 포스트에서는 로벤헬 프로젝트를 진행하며 제가 기술적으로 고민하고, 해결했던 흔적을 남겨두려고 합니다. 개발 과정에서 어떤 어려움이 있었고, 그 어려움을 기술적으로 어떻게 극복하려 시도했는지에 대한 분석이 담겨 있습니다.
아키텍처 설계의 배경
로벤헬은 "Server authoritative한 충돌, 이동 연산을 지원하면서도, 한 장소에 몇 천 명이 동시에 위치할 수 있는 MMO"라는, 엄청나게 거창한 목표를 두고 개발되었습니다.
사실 맨 처음에는 서버는 그냥 중개자 역할만 하고, 클라이언트가 위치를 발송하면 서버가 그걸 다른 클라이언트에게 전달하는 정도를 목표로 잡았지만, 막상 개발을 시작하니 뭔가 욕심이 나기 시작했고,
"왜 물리 연산이 FPS만큼 리얼한 MMORPG는 없을까?"
"채널 분리 없이 한 장소에서 몇 천 명의 유저를 동시에 처리할 수는 없을까?"
라는 평소 막연히 그 이유를 짐작만 하고 있었던 의문들을 직접 만들어보며 해소해보자는 생각도 들어 엄청나게 거창한 목표를 세우게 되었습니다. 또 목표가 높으면 실패하더라도 그만큼 얻어가는 건 많을 것 같아, 딱히 높게 잡아서 나쁠 건 없다고도 생각했습니다.
Server authority를 위해서는 데디케이티드 서버를 사용하기로 결정했고, 다수의 유저를 처리하는 방식은 IOCP와 비동기 IO를 통해 처리하기로 생각했습니다. 처음 저는 언리얼 자체에 Winsock과 IOCP를 내장시키는 방식을 생각했습니다.
별도의 통신 단계를 거치지 않고 곧장 서버와 클라이언트들이 통신할 수 있는 가장 직관적인 방식이었고,
동시에 서버와 클라이언트를 하나의 파일에서 작업할 수 있는 방식이었기 때문입니다.
다만 몇 가지 문제들이 고민되기 시작했습니다.
- 확장 가능한가? 즉 연산을 단일 프로세스에서 부담하는 게 과연 이상적인가?
- IOCP를 지원하지 않는 플랫폼에서의 문제는?
이러한 문제들을 해결하기 위해 저는 데디케이티드 서버와 클라이언트가 IOCP 기반의 미들웨어를 사이에 두고 소통하는 구조를 사용하게 되었습니다.
우선 데디케이티드 서버를 사용한 주된 이유는 로직이 서버 중심적으로 돌아가게 하는데 더 유리했기 때문입니다.
플레이어들의 인풋 정보를 기반으로 중앙 게임 서버에서 연산을 처리하고, 추후 오차가 발생할 경우 이를 동기화시키는 데디케이티드 방식의 서버는 단 하나의 'True game state'가 존재한다는 점이 제가 원하는 목표와 일치했습니다. 또 오차를 사건 발생 시점 이후 리플레이하는 네트워크 코드를 구현하는 것도 상당히 재미있을 것 같았습니다.
무엇보다, 언리얼 엔진 코드를 서버에서도 그대로 활용할 수 있다는 것도 큰 장점이었습니다. 서버와 클라이언트의 로직을 같은 파일 내에서 작성할 수 있다는 점은 그만큼 오차가 적어진다는 것을 의미했습니다. 여기에 더해, 데디케이티드 서버는 연산 결과가 아닌, 연산 요청을 기반으로 게임 상태를 업데이트하기 때문에, 스피드핵 같은 보안 문제에서 비교적 자유롭다는 점도 한 몫 했습니다.
그리고 사이에 중개자 역할의 미들웨어를 둔 이유는, 여러 데디케이티드 서버를 열어, 그 서버간의 동기화도 가능하도록 하는 확장 가능한 구조를 만들기 위해서 였습니다. 그렇게 Scale-out 가능하게 만든다면 데디케이티드 서버로 server authority를 유지하면서 동시에 MMORPG 수준까지 동시 접속자를 끌어올릴 수 있지 않을까? 하는 (어찌보면 막연한) 기대를 가지고 있었습니다.
언리얼 Replication을 사용하지 않은 이유
사실 언리얼에서 제공하는 Replication으로도 Server authority를 보장할 수 있지만,
1) 네트워크 부하가 큰 Replication으로는 제가 원하는 수준 만큼의 동시 접속자가 나오지 않는다는 점,
2) 제가 설계한 아키텍처와 호환되지 않아 scale-out이 어렵다는 점,
3) 직접 넷코드를 구현하며 기술적 역량을 기르고 싶었다는 점
때문에 언리얼 Replication 및 언리얼에서 제공하는 Dedicated server 기능을 일절 사용하지 않고 뼈대부터 직접 네트워크 코드를 구현했습니다.
언리얼의 여러 기능들과 Replication은 깊게 엮여 있기 때문에, 커스텀 넷코드에 맞춰 동작하도록 다시 새롭게 개발해야 하는 부분들도 여럿 있었습니다. 하지만 오히려 이런 기능들을 개발하면서 게임 네트워킹에 대한 이해는 물론, 언리얼 엔진 자체에 대해서도 더 깊이 있게 이해할 수 있었습니다.
목표 달성 실패와 원인 분석
하지만 결론부터 말하면, 몇 천 명 이라는 큰 규모의 완벽한 Server authoritative 동기화에는 실패했습니다.
물론 그래도 언리얼의 기본 Replication보다 거의 배 이상의 인원의 충돌 및 이동에 대해 Server authoritative한 동기화가 가능하고, 더 나아가 구조적으로 scale-out이 가능하게 만들었기 때문에 완전히 실패라고는 할 수 없지만, 목표했던 수준을 달성하는 것에는 안타깝게도 실패했습니다.
개발을 마무리한 지금은 당시 제가 간과했던 부분들이 무엇인지 잘 알고 있습니다.
- 처리해야 하는 데이터의 양이 생각보다 많았다는 점
- Scale-out을 하더라도 처리 가능한 트래픽이 polynomial이 아닌 logarithmic하게 증가한다는 점
- 같은 장소에 수 천 명 이상의 사람이 몰릴 경우엔 매번 Broadcast 해야 하는 Game State 데이터가 생각보다 굉장히 크다는 점
들이 주된 문제점이었습니다.
사실 대형 개발사들이 만든 MMORPG들조차도 플레이어들이 서로 다 보이는 가까운 위치에 몇 백 명만 몰려도 버벅이는 마당에, 몇 천 명이 같은 장소에 모일 수 있으면서 동시에 MMORPG 이상으로 정교한, Server authoritative한 물리연산을 한다는 게 쉽지 않다는건 어찌 보면 당연했습니다.
하지만 대체 왜 안되는데? 라는 불만 섞인 의문을 직접 해소하고 싶었고, 의문을 해소하는 데에는 성공했으니 어떤 면에서는 성공한 게 아닌가? 라는 생각(정신승리)도 듭니다. 무엇보다 기술적으로 많은 성장을 이루기도 했다는 점이 만족스럽습니다.
뭐 변명은 여기까지 하고,
제가 마주했던 예상하지 못했던 문제들 몇 가지만 더 살펴보겠습니다.
우선 물리 연산을 비롯한 게임 플레이 로직을 데디케이티드 서버의 여러 스레드로 분할시킬 계획이었으나,
언리얼의 게임플레이 로직은 싱글 스레드에서 처리되기 때문에 분할 처리가 사실상 힘들었습니다.
때문에 설사 네트워크 부하가 감당 가능했다고 한들 언리얼이 그만한 숫자의 액터의 로직 처리를 동시에 감당할 수 있었을지는 의문입니다.
또 언리얼의 물리 엔진이 Framerate dependent하다는 점이 동기화를 처리하는 데 골칫거리가 되었습니다. 언리얼은 Framerate independent한 물리 연산도 지원하긴 하지만, 아직 멀티플랫폼 지원이 되지 않고 완전하지 않다는 한계가 있습니다. (개인적으로 이 점이 유니티에서의 네트워크 동기화와의 가장 큰 차이점 중 하나라고 생각합니다)
애초에 언리얼의 카오스 엔진은 Nondeterministic한데, 여기에 Framerate dependent 하기까지 하니 서버와 클라이언트 오차가 커서, 이를 부드럽게 동기화하는 데 여러 장치를 사용해야 했습니다. 자세한 내용은 이후 다시 다뤄보겠습니다.
그럼에도, 성능 개선을 위해 한 노력들
결과적으로 목표 달성에는 실패했음에도 불구하고,
저는 할 수 있는 데까지 최대한 성능을 끌어올리고자 여러 가지 방법을 사용했습니다.
앞서 아키텍처를 설명할 때 언급했던 서버의 Scale-out 가능한 구조도 성능을 올리기 위한 방법의 일환이었지만, Scale-out을 해도 목표치에는 달성하기 어렵다는 걸 깨닫고 실제로 구현까지는 하지 않았던 것과는 다르게, 지금부터 소개할 방식들은 제가 실제로 구현하고 적용해 효과를 본 방식들입니다.
1. Application layer에서의 패킷 Fragmentation과 동적 발송 주기 결정
TCP 계층에서의 Segmentation에 더해,추가로 Application layer, 즉 게임 서버 단에서의 패킷 Fragmentation을 구현했습니다.
앞서 말했듯이 이 프로젝트의 가장 큰 걸림돌 중 하나는 제가 목표로 한 동시에 한 곳에서 만날 수 있는 플레이어 수가 너무 커서 게임 스테이트 패킷의 크기가 지나치게 커지고, 그 결과 네트워크 부하가 커진다는 점이었습니다.
이 문제를 완화하기 저는 게임 패킷을 TCP가 안정적으로 송수신할 수 있는 적절한 크기의 fragment로 쪼개 발송하고 이를 클라이언트 측에서 수신해 재결합하는 방식으로 네트워크 부하를 줄였습니다.
이때 단일 Fragment를 얼마의 주기로 발송하는지는 전체 패킷의 크기에 의해 매번 동적으로 결정되도록 구현했습니다.
이는 클라이언트가 Game state를 최대한 비슷한 Framerate로 수신하게 하기 위함으로, 만약 보내야 하는 Game state의 크기가 크다면 Fragment가 많아지기 때문에 주기를 더 짧게 가져갔고, 반대로 크기가 작다면 주기를 길게 가져갔습니다.
클라이언트는 Fragment를 받는대로 Gather해 하나의 게임 스테이트 데이터를 복원하고, 서버는 발송 주기를 조절해 클라이언트가 마치 일정한 framerate로 전체 데이터를 수신받는 것과 같은 효과를 만들었습니다.
따라서 이 방식은 정확히는 부하를 줄였다기보다는 트래픽이 '튀는' 현상을 완화했다고 보는 편이 더 맞을지도 모르겠지만, 네트워크 안정화에 도움이 되었고, TCP가 안정적으로 발송할 수 있는 단일 버퍼의 크기를 초과한 데이터를 송신할 수 있게 했으며, 무엇보다 클라이언트가 패킷 크기에 상관없이 게임 스테이트를 고정된 주기로 받을 수 있게 했다는 점에서 전체 네트워크 성능에 큰 도움이 되었습니다.
2. 패킷 최적화 및 압축
저는 게임이 주고 받는 패킷의 크기를 최대한 줄이기 위해 불필요하거나 중복되는 데이터를 제거하고, 비트 단위로 패킷을 관리하며 패킷을 최적화하는 등 여러 방식들을 사용했습니다.
조금 기억에 남는 것중 하나로는 플레이어 회전 방식을 마우스에서 8-way로 변경하는 것으로 클라이언트 패킷의 부하를 줄였던 게 있습니다. 기술적으로 고난도의 무언가여서 기억에 남는다기 보다는, 게임 서버 개발 전까지는 네트워크와 관련되어있는 것이라고는 생각치 못한 부분이었어서 기억에 남는 것 같습니다.
사실 인풋 방식의 변경이 전체 부하에 얼마나 영향을 줄까 싶을 수 있지만, 플레이어 지향 방향 정보를 표현하는 데에 필요한 패킷 크기가 훨씬 줄어들게 되는 효과를 얻을 수 있었습니다. 게다가 인풋 패킷은 굉장히 빈번히 발생하고, N명의 플레이어에 비례해 발송되는 만큼, 고작 몇 바이트만 줄이더라도 누적되어 얻는 이득은 생각보다 컸습니다.
(마우스 없이 8-way 인터페이스로도 캐릭터의 부드러운 회전 및 이동이 가능하도록 별도의 Pawn 컨트롤러를 구현하기도 했습니다)
또 전체 네트워크 트래픽의 상당부분은 서버 패킷이 차지하는 만큼, 서버 패킷 최적화에도 공을 들였습니다.
특히 데이터 압축을 통해 많은 성능 개선을 얻을 수 있었습니다. 서버 패킷 크기의 대부분은 액터의 물리 정보가 차지하는데, 물리정보를 구성하는 Quaternion과 Transform 정보를 수학적으로 압축하고 클라이언트에서 수신한 후 복구해 사용하는 방식으로 네트워크 부하를 크게 줄일 수 있었습니다.
특히 Transform의 위치 정보의 경우, 맵의 유효범위를 줄이고 부동소수점 값의 정밀도를 줄여 전체 비트 수를 줄이는 것으로 데이터 크기를 줄일 수 있었고, 전체 부하를 크게 줄일 수 있었습니다.
3. IOCP, 멀티 스레딩과 비동기 IO
물론 IOCP가 만능은 아닌 건 맞지만, 동시에 IO를 멀티 스레드에서 처리하기 쉽게 OS단에서 관리해 준다는 점에서는 너무 좋은 도구라는 것을 다시 한 번 느꼈습니다.
IO에는 비동기 TCP 소켓을 사용했으며, 미들웨어는 IOCP를 사용해 여러 호스트들과의 비동기 IO를 관리하도록 구현했습니다. 연결 대상이 하나 밖에 없는 로직 서버와 클라이언트에서는 굳이 IOCP를 사용하지 않고 언리얼의 멀티 스레딩 기능을 통해 스레드를 만들어 IO를 처리합니다.
미들웨어는 IOCP의 성능을 극대화하기 위해 Worker thread pool에서 IOCP의 IO 신호를 감지할 경우 해당 작업을 처리하는 방식으로 동작합니다. Worker thread pool은 게임 서버들과 통신하는 용도, 클라이언트들과 통신하는 용도 이렇게 두 개를 운영하는데, 이 때 각 스레드풀의 스레드 수는 CPU 환경과 예상되는 부하에 비례한 적절한 수준으로 설정해 지나친 Context switching이 발생하지 않도록 했습니다.
각 스레드들이 접근해 사용할 수 있는 메모리풀/오브젝트 풀을 구현했고, 버퍼 사용 후 반환 시 재사용할 수 있도록 작성해 메모리 할당에 소요되는 시간을 줄였습니다. 또 불필요한 복사가 발생하지 않도록 Move semantics를 신경쓰며 코드를 작성했습니다.
사용 스레드 개수가 상대적으로 많은 미들웨어에서는 스레드 문제를 해결하기 위해 아예 별도의 스핀락을 구현했고, Read lock과 Write lock을 별도로 잡을 수 있게 제작해 필요한 수준의 락을 잡게 하는 것으로 부하를 줄였습니다.
동기화를 위한 장치들
게임 서버를 개발함에 있어 가장 핵심이 되는 것 중 하나가 바로 동기화 문제입니다.
게임에서의 동기화는 채팅 서버와 다르게 단순히 데이터의 싱크를 맞춰주는 것에서 그치지 않습니다. 게임 서버에서는 데이터가 같아지는 것 이상으로, "반응성있게 느껴지게 하는 것"과 "부드럽게 보이도록 눈속임하는 것"이 중요한데, 이 문제들은 동기화 방식과 깊게 연결되어 있습니다.
시작에 앞서, 먼저 간략하게 로벤헬에서 Server-authority의 유지가 어떻게 이뤄지는지 살펴보겠습니다.
로벤헬 클라이언트는 서버에게 State가 아닌 본인의 인풋 정보를 보냅니다. (물론 특정 상황들에 한해서 State를 보내고, 서버가 그 State가 정상적인 처리인지 검증하는 방식을 섞어쓸 수도 있습니다. 실제로 FPS류 게임들에서는 히트스캔이냐 투사체냐에 따라 이러한 방식을 섞어 사용하곤 합니다.)
이렇게 보낸 인풋을 서버는 본인의 Game State에 적용해 연산하고, 그 연산 결과를 클라이언트에게 반환합니다.
클라이언트는 반환받은 결과를 적용해 화면에 표시하면 Server authoritative한 연산이 완료됩니다.
물론 이는 극히 간소화해 설명한 것일 뿐 현실은 이렇게 순탄치 않습니다. 중간중간 오버헤드들이 있어서, 단순히 서버에서 정보가 올때만 정보를 업데이트 하는 것으로는 반응성 있는 게임을 만들 수가 없기도 하고, 네트워크 부하나 물리 엔진과 같은 수많은 요소들을 고려해야 합니다. 여기서는 이러한 다양한 요소들이 어떤 식으로 문제가 되었고, 제가 어떻게 그 문제를 해결해 나갔는지 개괄적으로 다뤄보겠습니다.
1. 물리 연산 동기화 및 단일 틱에서의 다중 물리 연산 구현
FPS에 비해 반응성이 낮은 대신 동시 접속자 수가 높은 MMO 특성 상, 클라이언트가 매 틱마다, 혹은 인풋이 발생할 때마다 인풋을 발송하는 방식은 지나치게 부하가 컸습니다. 따라서 저는 클라이언트에서 InputHistory를 생성해 인풋 기록을 모아서 발송하는 방식을 사용했고, 서버에서도 이 InputHistory를 기반으로 순서대로 인풋을 재처리하는 방식을 사용해 게임 로직을 연산합니다.
하지만 이렇게 인풋을 모아서 발송하는 것에서 여러 추가적인 생각할 거리들이 생기는데, 가장 큰 문제는 게임 로직 서버가 단일 틱에서 물리 연산을 여러 차례 진행해야 한다는 점이었습니다. 만약 엔진 레벨부터 직접 만들었다면 물리 엔진과 연계해 이를 처리하기 위한 별도의 시스템을 구축할 수 있었을 텐데, 언리얼 엔진은 (특히 제가 사용중인 언리얼 5의 카오스 엔진)은 사용자 레벨에서 별도의 "물리 엔진 틱 호출"을 위한 기능을 제공하지 않기 때문에 이를 구현하기 위한 방법을 모색하느라 꽤나 골머리를 썩였습니다.
단일 틱에서의 여러 틱에 해당하는 물리 연산 처리를 위해 카오스 대신 PhysX를 사용하도록 엔진을 개조한 이후에 PhysX단에서 해당 기능을 구현하는 방법도 고려해보았지만 지나치게 overkill이라고 생각했고, 카오스 엔진을 사용하더라도 어떻게 이를 처리할 방법이 있으리라 생각해 해당 방법은 사용하지 않았습니다.
방법을 찾기 위해 엔진 소스코드를 뜯어 언리얼 Replication이 카오스 엔진과 함께 어떻게 이 부분을 처리하는 지 살펴보기도 했는데, 언리얼은 피직스 상태를 되돌린 후 다시 replay하는 방식을 사용하고 있었습니다. 해당 방식은 FPS류 게임에는 적절하지만 제가 희망하는 규모의 동시접속자를 처리하기엔 지나치게 버겁다고 판단이 되었고, 설사 부하가 감당 가능한 수준이라고 하더라도, 자체 넷코드를 사용하고 있는 로벤헬에 딱 해당 부분만 떼어 사용하는 것은 굉장히 어려우리라고 판단을 했습니다.
그렇게 이리저리 한참을 헤맨 끝에, 저는 엔진 소스코드를 참고해 충돌 연산 + Slope 미끄러짐 연산에 대해서는 단일 틱에서 다회 연산이 가능하도록 구현해내었습니다. 덕분에 점프를 제외한 이동에 대해서는, 로직 서버가 단일 틱 내에 클라이언트의 여러 틱에 해당하는 인풋들을 한 번에 처리해 거의 유사한 물리 연산 결과를 얻어낼 수 있게 되었고, 덕분에 이동 및 충돌 연산에서는 MMO 규모의 다수의 사용자에서도 Server-authoritative한 물리 연산을 하는 것에 성공했습니다.
안타깝게도 "모든" 물리연산에 대해서 단일 틱에서 다회 연산이 가능하도록 구현하는 것은 방법을 찾지 못했습니다. 중력과 같이 별도의 Force가 가해지는 물리연산의 경우 클라이언트와 서버의 연산 횟수(빈도)가 달라지기 때문에 오차가 발생하게 됩니다. 물리 엔진을 호출하지 않고 물리 연산을 아예 게임 로직 스레드 단에서 처리해 적용하는 형태로도 시도를 해보았지만, 결과가 썩 만족스럽지 않아 초안만 제작한 후 별도의 브랜치로 분리해 두었습니다. 이렇게 완벽한 해결법을 찾지 못했지만, 해당 연산들도 Server authoritative하게 처리되는 것 자체는 동일합니다. 즉 오차가 생기더라도 이는 물리 정보의 동기화 과정에서 보정됩니다.
물리 연산 동기화를 처리할 때 또 한가지 까다로웠던 점을 더 짚어보자면, 글 앞부분에서 말했던 언리얼의 Framerate dependent한 물리 엔진이 있습니다. 물리 엔진이 Framerate dependent하기 때문에, 서버가 클라와 동일한 물리연산 결과를 얻기 위해서는 단순히 물리 연산의 횟수만 맞춰주면 되는 게 아니라 각 연산이 실행되었을 당시의 Delta time까지 함께 고려해 연산해주어야 했습니다. 때문에 클라이언트는 본인의 인풋 정보에 더해 해당 인풋이 발생한 시점의 Delta time에 대한 정보를 함께 기록해 발송하고, 또 서버에서는 그 Delta time을 사용해 물리 연산을 처리하도록 구현했습니다.
2. 물리 정보 동기화
방금까지 설명한 물리 연산의 동기화가 "서버와 클라이언트가 동일한 연산을 하기 위한 동기화"였다면, 이번에는 그 "연산결과의 동기화"를 어떻게 처리했는지 설명드리겠습니다.
서버와 클라는 필연적으로 물리연산 결과에 대해 오차가 발생할 수 밖에 없는데, 이는 위에서 살펴보았듯이 서로 처리하는 연산 자체가 약간 다르기 때문이기도 하지만, 애초에 언리얼의 물리엔진이 Nondeterministic 하기 때문인 것도 있습니다. 때문에 오차를 보정하는 과정은 필수적이고, 로벤헬에서는 이를 다양한 방법으로 처리합니다.
로벤헬 클라이언트는 서버로부터 수신받은 물리 정보를
- 내 클라이언트가 조종하는 캐릭터(플레이어 폰)의 물리정보
- 다른 클라이언트들이 조종하는 캐릭터들(퍼펫)의 물리정보
이렇게 두 가지로 나누어 서로 다르게 처리합니다.
편의를 위해 내 클라이언트가 조작하는 캐릭터는 플레이어 폰,
다른 클라이언트가 조작하는 캐릭터는 퍼펫
이라는 호칭으로 사용하겠습니다.
우선 퍼펫의 물리정보의 처리 방식부터 살펴보겠습니다.
퍼펫의 인풋에 대한 실제 게임 로직의 처리는 서버에서 처리하기 때문에, 내 클라이언트에서는 단순히 그 결과만을 받아와 화면에 표시해줍니다. 이때 서버의 발송 주기보다 게임 프레임레이트가 훨씬 빠르기 때문에, 로벤헬에서는 퍼펫의 물리 정보들을 부드럽게 선형 보간하여 화면 상에 그려주는 Entity interpolation 방식을 사용합니다.
분량상 세부적인 로직을 다루진 않겠지만, Entity Interpolation은 간단히 말해 수신받은 틱의 물리 정보를 기록해두고 있다가 그 다음 틱의 정보를 수신받으면 해당 틱과 그 이전 틱 사이의 정보를 interpolate하며 부드럽게 정보를 처리하는 것을 의미합니다. 정보를 수신받자마자 바로 해당 정보를 반영하는 것이 아닌 수신받기 전의 정보와 현재 정보 사이를 interpolate하기 때문에, 물리적 움직임과 회전을 모두 부드럽게 그려낼 수 있게 됩니다.
다만 이 방식이 만능인 것은 아닙니다. Entity interpolation을 사용할 때 모든 플레이어는 다른 플레이어들의 아주 조금 전의 과거 상태를 보고 있기 때문에, 엄밀히 따졌을 때 모든 플레이어가 완벽하게 동일한 화면을 보기란 사실상 불가능합니다. 때문에 히트스캔 무기 발사와 같이 즉시 처리되어야 하는 로직같은 경우 Lag compensation과 같은 추가적인 장치를 사용하기도 하는데, 로벤헬에서는 Lag compensation을 필요로 하는 로직이 아직 없기 때문에 해당 기능을 구현하지는 않았습니다.
이렇게 퍼펫의 물리정보 동기화 방식을 살펴보았으니 이번에는 플레이어 폰의 동기화 방식을 살펴보겠습니다.
플레이어 폰의 경우는 아까와는 조금 상황이 다릅니다.
만약 플레이어 폰도 퍼펫처럼 100% 서버로부터 오는 정보에만 의존하게 된다면, 내가 누른 키의 처리가 서버까지 가서 서버에서 연산을 거치고 다시 돌아와야 반영이 되기 때문에 게임의 반응성이 상당히 저하되게 됩니다. (스태디아 같은 클라우드 게임 서비스의 지연 시간을 생각하시면 편합니다)
때문에 플레이어 폰은 서버에서의 결과가 오기 전에 먼저 물리 연산을 진행해 그 결과를 반영하는데, 이를 Client prediction이라고 부릅니다. 로벤헬에서는 Client prediction을 사용해 플레이어 폰의 위치 정보를 먼저 연산해 반영합니다.
이때 단순히 먼저 연산하는 것에 그치지 않고 그 결과를 게임 틱 단위로 기록해 트래킹하는데, (Circular buffer를 구현해 사용했습니다) 이후 서버의 연산 결과가 도착하면 그 결과를 Circular buffer의 기록과 대조하고, 만약 일정량 이상의 오차가 감지되었다면 서버측 결과를 반영하도록 물리 정보를 보정합니다. 이때 오차를 어느 정도로 허용할 것인지, 오차를 구하기 위해 어떤 알고리즘을 사용할 것인지 등 고민할 거리가 많았지만 분량 상 여기서 전부 다루기는 어려울 것 같고, 테스트를 반복하며 개선해 나갔다는 정도만 적고 넘어가겠습니다.
로벤헬은 이렇게 오차가 발견되어 서버측 연산 결과로 내 정보를 보정한 이후, 일종의 Replay 시스템과 유사한 기능을 하는 Server reconciliation이라는 장치를 추가로 사용합니다. Server reconciliation은 쉽게 말해 "오차 보정을 하고 나서 해당 시점 이후 발생했던 인풋들을 다시 처리하는 것"이라고 할 수 있습니다.
만약 서버에서 결과를 수신받았고 그 결과가 내 정보와 오차가 있었을 때 단순히 서버 결과를 덮어쓰고 끝내도록 구현한다면, 서버로부터 결과를 받기까지 입력했었던 내 인풋 정보들이 날아가게 되어 Client prediction을 하는 의미가 없어지게 됩니다.
따라서 로벤헬은 연산 결과를 Circular buffer에 저장할 때, 각 연산들에 해당하는 플레이어 인풋들도 함께 Circular buffer에 기록하고, 그 후 서버로부터 연산 결과를 수신 받았는데 만약 그 값과 내 정보 간의 오차가 기준치를 넘었다면, 서버의 연산 결과가 발생한 시점으로 내 캐릭터 상태를 되돌리고, 그 상태에서부터 내가 입력했었던 모든 인풋들을 다시 실행해 처리하는 방식(Input reapplication)으로 Server reconciliation을 처리합니다.
이렇게 하는 것으로 플레이어는 언제나 서버보다 "앞서 있는" 상태를 유지할 수 있고, 이는 게임의 조작감을 상승시키는 데 중요한 역할을 해 주었습니다.
(분량 상 이 글에서 자세히 다루지 못한 각종 물리 정보 동기화 메카닉에 관심이 있으시다면, Valve사의 "Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization - 2001" 문서를 참고하시는 걸 권장드립니다)
3. Clock 동기화
앞서 말씀드린 다양한 동기화 로직들을 구현하기 위해서는 서버와 모든 클라이언트들이 서로 동일한 시간을 사용해 정보를 처리할 수 있어야 합니다. 즉 서버, 클라들 간의 동기화된 Clock이 필요합니다.
로벤헬에서는 로직 서버의 Clock을 기준으로 해당 서버에 접속한 클라이언트들 간의 Clock을 동기화하는데, 이때 각 클라이언트 별로 네트워크 상황이나 프레임레이트가 다르고, 또 같은 클라이언트라도 매분 매초 상황이 변하기 때문에, Clock은 해당 클라이언트와 로직 서버의 Round trip time 및 프레임레이트를 반영해 ms 단위로 싱크를 보정합니다. (세부적인 로직은 다루지 않겠습니다)
사실 초반에는 시간 동기화를 위해 ms 단위가 아닌 틱 단위를 사용하고, 대신 프레임레이트에 따라 그 값을 보정하는 방식을 사용했는데, 물리 연산 동기화 및 물리 정보 동기화 과정에서 더 정밀한 수준의 시간 동기화의 필요성을 느끼게 되어 ms 단위로 싱크를 보정하는 방식으로 개선했습니다.
4. 애니메이션 동기화
로벤헬에서 애니메이션은 물리 정보와 유사하게, 플레이어 폰과 퍼펫을 구분지어 처리합니다.
우선 퍼펫 애니메이션은 FSM의 State 정보를 주고받는 것으로 동기화하며, 이때 Blendspace Value도 함께 전달하는 것으로 서버와 최대한 유사한 상태를 유지하는 방식을 사용합니다.
다만 수신한 정보를 항상 바로바로 반영하는 것은 아닙니다. 애니메이션 state를 수신 받자마자 바로 동기화할 경우 움직임이 부자연스러워지는 경우가 있기 때문입니다. 퍼펫은 앞서 말했듯이 위치 정보를 interp하는 것으로 움직이는데, 때문에 아직 폰이 아직 interpolate 중임에도 IDLE 애니메이션 state를 수신 받을 수 있습니다. 이 경우 만약 동기화를 곧장 진행하게 되면 폰이 가만히 서있는 모션을 취한 채 여기저기 움직이는 현상이 일어날 수 있습니다. 이런 유사한 문제들은 수신받은 state와 현재 퍼펫의 상태를 고려해 즉각 반영할지 여부를 결정하는 별도의 로직을 작성하는 것으로 해결할 수 있었습니다.
퍼펫과 다르게 플레이어 폰의 경우 애니메이션 정보는 Client authoritative하게 동작하는데, 이렇게 하는 데에는 두 가지 이유가 있습니다. 우선 첫째로, 오로지 시각 정보만을 나타내는 애니메이션의 경우 서버와 그 값이 다르다고 해도 게임 로직에는 전혀 지장이 없다는 점이 있습니다.(물론 만약 애니메이션에 기반한 히트판정 등이 사용될 경우 이는 변경될 소지가 있습니다)
따라서 만약 클라이언트와 서버 사이의 연산 결과가 달라 재생해야 하는 애니메이션 정보가 달라진다면 이는 애니메이션 그 자체를 동기화하는 게 아닌, Game State 동기화에 의해 처리되는 방식을 사용합니다.
둘째로, 사용자가 체감하기에 애니메이션의 보정은 물리 보정보다 더 민감하게 느껴질 수 있다는 점이 있습니다.
서버와 클라이언트가 frame perfect하게 완벽하게 동일한 화면을 보여줄 수 있다면 좋겠지만, 현실적으로 다양한 오버헤드로 인해 그건 불가능합니다. 따라서 보정 과정은 필연적인데, 물리 정보의 interpolation이 비교적 부하가 작고 눈치채기 쉽지 않은 것과는 달리, 애니메이션을 정확히 보정하는 것은 오히려 서버와의 오차를 줄일 수 있을지언정 보기에는 훨씬 부자연스러울 수 있다는 문제가 있습니다. 추가적인 부하(패킷 크기 및 연산 처리)를 감당하면서까지 해야 할 메리트가 없고, 오히려 디메리트가 더 크기 때문에 플레이어 폰의 애니메이션은 Client authoritative하게 동작합니다.
마무리하며
로벤헬은 정말 많은 것들을 배울 수 있던 프로젝트였습니다.
단순히 정해진 방식대로 딱딱 어떤 정형화된 틀을 따라 개발하는 느낌보다는, 게임이 지향하는 바에 맞게 구조나 데이터의 형식을 최적화해야 하는 과정이 굉장히 재미있었고, 그 과정에서 다양한 스펙트럼의 문제들을 마주하며 해결해나가는 과정이 매력적이었습니다.
프로젝트를 제작하면서 2학년 2학기 대학교 수업을 같이 병행했었는데, 그중 특히 네트워크 과목에서 배운 다양한 내용들을 바로바로 개발에 적용해 사용해보면서, 다시 한 번 컴퓨터 기반 지식의 중요성을 느낄 수 있었습니다.
만약 나중에 또 게임 서버 관련 프로젝트를 진행하게 된다면, 어느 정도 Client authoritative한 연산을 허용하더라도 더 많은 접속자에 대한 처리가 가능한, 즉 통상적인 MMORPG 서버와 유사한 형태의 게임 서버를 제작해보고 싶습니다.
MMORPG 서버 아키텍처에도 정말 다양한 많은 방식들이 있는데, 그 중 어떤 방식 기반으로 제작할 지는 그때 가서 조금 더 고민해봐야 할 것 같습니다. 데디케이티드 서버와는 또 다른 고민거리들을 마주할 수 있을 것 같아 정말 재미있을 것 같네요 ㅎㅎ
긴 글 읽어주셔서 감사드리고, 다음에는 지금보다도 더 발전된 모습으로 돌아오겠습니다!
'게임 제작 > Rovenhell (2023)' 카테고리의 다른 글
Rovenhell(로벤헬) 소개 (0) | 2023.10.10 |
---|