프로그래밍/General

멀티 플레이 게임서버 구현 2편: 클라이언트측 예측과 서버측 재조정

Lou Park 2023. 9. 17. 11:55

들어가며

이 글은  https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html 글을 공부하면서 옮긴 것으로, 번역과 의역이 섞여있습니다.

이전 글에서는 권위 있는 서버와 입력만 서버로 보내고 서버가 업데이트된 게임 상태를 보낼 때까지 렌더링하는 멍청한 클라이언트를 가진 클라이언트-서버 모델을 알아보았습니다.

 

그러나 이런 시스템을 그냥 구현하게 되면 사용자 입력과 화면 변경 사이에 지연이 발생하게 됩니다. 예를 들어, 플레이어가 오른쪽 화살표 키를 누르면 캐릭터가 움직이기 시작하기 전에 0.5초 정도의 시간이 걸립니다. 이는 클라이언트 입력이 먼저 서버로 이동하고, 서버가 입력을 처리하고 새로운 상태를 계산하며, 업데이트된 상태가 다시 클라이언트에게 도착해야하기 때문입니다.

 

 

 

인터넷과 같은 네트워크 환경에서 지연이 수십ms ~ 수백ms가 될 수 있는 상황에서 게임은 최선의 경우에는 반응이 없는 것처럼 느껴질 수 있고, 최악의 경우에는 완전히 플레이할 수 없는 상태가 될 수 있습니다. 이 글에서는 이 문제를 최소화하거나 없애는 방법을 알아보겠습니다.

 

클라이언트측 예측 (Client-side prediction)

부정행위를 하는 플레이어가 일부 있기는 하지만, 대부분의 경우 게임서버는 유효한 요청을 처리하고 있습니다. 이는 서버로 수신된 대부분의 입력이 유효하며 예상대로 게임 상태를 업데이트할 것을 의미합니다. 즉, 만약 여러분의 캐릭터가 (10, 10)에 있고 오른쪽 화살표 키가 눌리면, (11, 10)에 도착하게 될 겁니다.

 

우리는 이를 이점으로 활용 할 수 있습니다. 게임 월드가 충분히 결정론적인 경우(예측이 가능할 경우) 에는 입력을 서버로 보내고 클라이언트에서 이를 즉시 처리할 수 있습니다. 즉, 클라이언트에서 입력이 처리된 후 서버가 입력을 처리한 후에 게임 상태가 어떻게 변할 것인지 예측합니다. 이로써 입력을 받고 상태를 렌더링하는 사이의 지연이 제거됩니다. 게다가 대부분의 경우 이 예측은 정확하게 맞아떨어지므로 서버가 업데이트된 게임 상태를 보내기 전에 보이는 시각적 불일치가 사라질겁니다.

 

예를 들어 100ms의 지연이 있다고 가정하고 캐릭터가 한 칸 이동하는 애니메이션에 100ms가 걸린다고 해 봅시다. 그냥 구현하면 전체 작업에 200ms가 걸릴 것입니다.

 

 

우리는 서버로 보내는 입력이 성공적으로 실행될 것이라고 가정할 수 있습니다. 이 가정 하에, 클라이언트는 입력이 처리된 후의 게임 월드 상태를 예측하고, 대부분의 경우 이 예측이 맞을 것입니다.

 

입력을 보내고 서버로 부터 새로운 게임 상태를 받는 것을 기다리는 대신, 입력을 보내고 해당 입력이 성공했다고 가정하고 그 결과를 렌더링해버릴 수 있습니다. 동시에 서버가 "진짜(True)" 게임 상태를 보내기를 기다립니다. 이 "진짜" 게임 상태는 대부분의 경우 로컬로 계산된 상태와 일치할 것입니다.

 

이제 플레이어의 조작과 화면 상의 결과 사이에 어떠한 지연도 없습니다. 그런데도 서버는 여전히 권위를 가지고 있습니다. (만약 해킹된 클라이언트가 유효하지 않은 입력을 보내면 화면에 원하는 대로 렌더링할 수 있지만, 이는 다른 플레이어가 보는 서버의 상태에는 영향을 미치지 않습니다).

 

동기화 문제 (Synchronization issues)

잘 동작하는듯 보이지만, 위 예시에서 약간 수정된 시나리오를 고려해보겠습니다. 서버로의 지연이 250ms이고, 한 칸에서 다른 한 칸으로 이동하는 데 재생되는 애니메이션이 100ms가 걸린다고 가정해 봅시다. 또한 플레이어가 오른쪽 키를 연속으로 2번 눌러 오른쪽으로 2칸 이동하려고 한다고 가정해 봅시다.

 

지금까지의 기술을 사용하면 다음과 같이 작동할 것입니다.

 

우리는 t=250 ms에서 흥미로운 문제를 맞닥뜨리게 됩니다. 새로운 게임 상태가 도착할 때, 클라이언트에서 예측한 상태는 x=12이지만, 서버에서 온 새로운 게임 상태는 x=11이라고 말합니다. 서버가 권한을 가지고 있기 때문에 클라이언트는 캐릭터를 x=11로 다시 이동시켜야 합니다. 그러나 그 후 t=350에서 새로운 서버 상태가 도착하며, 이 상태에서는 x=12라고 합니다. 그래서 캐릭터는 이번에는 앞쪽으로 다시 점프합니다.

 

플레이어는 오른쪽 화살표 키를 2번 눌렀습니다. 캐릭터는 오른쪽으로 2칸 이동하고, 거기에 50ms 동안 머무른 후 왼쪽으로 1칸 점프하고, 거기에 100ms 동안 머무른 후 오른쪽으로 1칸 점프했습니다. 위치 조정이 일어나면서 버벅이듯 보일 것이고, 이는 용인될 수 없는 일입니다.

 

서버측 재조정 (Server reconciliation)

이 문제를 해결하는 핵심은 클라이언트가 게임 월드를 “현재” 시간으로 본다는 것이지만, 렉 때문에 서버에서 받는 업데이트는 사실 “과거” 게임 상태입니다. 서버가 업데이트된 게임 상태를 보낸 시점에는 클라이언트가 보낸 모든 입력을 처리하지 않았습니다.

 

하지만 이 문제를 해결하는 것은 그리 어렵지 않습니다. 먼저, 클라이언트는 각 요청에 시퀀스(Sequence)를 추가합니다. 예를 들어, 첫 번째 키를 누르는 것은 요청 #1이고, 두 번째 키를 누르는 것은 요청 #2입니다. 그런 다음 서버가 응답할 때, 서버가 처리한 마지막 입력의 시퀀스를 포함합니다.

 

 

이제 t=250에서 서버는 "내가 요청 #1까지 본 것을 기반으로 당신의 위치는 x = 11이다"라고 합니다. 서버가 권한을 가지고 있기 때문에 캐릭터 위치를 x=11로 설정합니다. 이제 클라이언트가 서버에 보낸 요청을 클로닝하고 있다고 가정해 봅시다.

 

새로운 게임 상태에 의해, 클라이언트는 서버가 이미 요청 #1을 처리했다는 것을 알기 때문에 해당 복사본을 버릴 수 있습니다. 그러나 서버가 아직 요청 #2의 처리 결과를 보내지 않았다는 것도 알고있죠. 그래서 클라이언트 측 예측을 다시 적용하면 클라이언트는 서버에 의해 보내진 마지막 권한이 있는 상태를 기반으로 게임의 "현재" 상태를 계산할 수 있습니다. 그리고 서버가 아직 처리하지 않은 입력을 포함합니다.

 

그래서 t=250에서 클라이언트는 "x=11, 마지막 처리된 요청 = #1"을 받습니다. 클라이언트는 요청 #1까지 보낸 입력 복사본을 버립니다. 그러나 서버가 인증하지 않은 #2까지 아직 처리하지 않았다는 것을 알고 있으므로 #2의 복사본을 보존합니다. 그런 다음 내부 게임 상태를 서버가 보낸 내용, x = 11,으로 업데이트하고 서버가 아직 처리하지 않은 모든 입력을 적용합니다. 이 경우에는 "오른쪽으로 이동"이라는 입력 #2입니다. 최종 결과는 x=12로, 맞아떨어집니다.

 

계속해서, t=350에서 새로운 게임 상태가 서버에서 도착합니다. 이번에는 "x=12, 마지막 처리된 요청 = #2"라고 합니다. 이 시점에서 클라이언트는 #2까지의 모든 입력을 버리고 x=12로 상태를 업데이트합니다. 처리되지 않은 입력이 더 이상 없으므로 처리가 종료됩니다.

 

사소한 것들…

위에서 논의했던 예시는 이동에 관한 것이지만, 거의 모든 다른 상황에도 동일한 원칙을 적용할 수 있습니다. 예를 들어, 턴제 전투 게임에서 플레이어가 다른 캐릭터를 공격할 때 HP와 딜량을 나타내는 숫자를 표시할 수 있지만 실제로 서버가 그렇다고 말하지 않는 한 캐릭터의 체력을 실제로 업데이트해서는 안됩니다.

 

게임 상태의 복잡성 때문에 항상 캐릭터를 죽이지 않는 것이 좋을 수 있습니다. 클라이언트의 게임 상태에서 캐릭터의 체력이 음수로 떨어진 경우에도 서버가 그렇다고 말하지 않는 한 캐릭터를 죽이는 것을 피하려고 할 것입니다(상대 캐릭터가 치명적인 공격을 받기 직전에 체력회복 포션을 먹었으나, 서버가 아직 알려주지 않았다면 어떻게 될까요?)

 

이로 인해 흥미로운 점이 생깁니다. 월드가 완전히 결정론적이며 클라이언트가 전혀 속이지 않더라도 클라이언트가 예측한 상태와 서버가 보낸 상태가 일치하지 않을 수 있습니다. 이러한 시나리오는 하나의 플레이어로서는 위에서 설명한대로 불가능하지만, 여러 플레이어가 동시에 서버에 연결된 경우에는 쉽게 발생할 수 있습니다. 이것은 다음 글의 주제로 다뤄보도록 하겠습니다.

 

요약

권위 있는 서버를 사용할 때, 플레이어에게 입력을 실제로 처리하는 서버를 기다리는 동안 반응성을 제공하는 효과를 주어야 합니다. 이를 위해 클라이언트는 입력의 결과를 시뮬레이션합니다. 업데이트된 서버 상태가 도착하면 예측된 클라이언트 상태가 업데이트된 상태와 클라이언트가 보낸 입력을 기반으로 다시 계산됩니다.

 

 

 

시리즈 모두 보기

1편: 클라이언트 - 서버 아키텍쳐 
[*] 2편: 클라이언트측 예측과 서버측 재조정 
3편: 엔티티 인터폴레이션
4편: 지연 보상