본문으로 건너뛰기
뒤로가기

[C++] 게임 클라이언트 동작 흐름

업데이트:

TL;DR — 온라인 게임의 입력 지연은 네트워크 왕복 시간뿐 아니라 서버의 처리 주기, 충돌 계산, 상태 전송과 화면 갱신이 합쳐진 결과입니다. 클라이언트는 예측과 보간으로 기다리는 시간을 화면에서 흡수하고, 서버는 충돌 후보와 사용자별 전송 대상을 줄이면서 최종 게임 상태를 일관되게 확정해야 합니다.

Table of contents

Open Table of contents

들어가며

온라인 게임에서 클라이언트와 서버는 물리적으로 떨어져 있습니다. 사용자의 입력이 서버에 도착하고, 서버가 결과를 계산하고, 그 결과가 다시 클라이언트 화면에 나타나기까지는 시간이 필요합니다.

서버의 응답을 기다린 뒤에만 캐릭터를 움직이면 입력이 늦게 반영됩니다. 반대로 클라이언트가 이동, 충돌과 점수를 직접 확정하면 다른 사용자와 서로 다른 게임 상태를 가질 수 있습니다. 결국 온라인 게임의 클라이언트는 서버가 결과를 확정한다는 원칙을 유지하면서도 사용자가 기다린다고 느끼지 않게 표현해야 하는 문제를 갖습니다.

이 글에서는 이 문제를 클라이언트와 서버의 두 방향에서 살펴봅니다. 클라이언트는 자신의 입력을 먼저 표현하고 서버 상태에 맞게 교정합니다. 다른 플레이어의 움직임은 서버에서 받은 과거 상태 사이를 복원합니다. 서버는 공간 자료구조를 사용해 충돌 가능성이 있는 후보와 사용자에게 전송할 대상을 줄입니다.

본문의 코드는 Private Server에서 실제로 사용하는 구현을 설명에 필요한 범위로 줄인 것입니다. 예외 처리와 validation처럼 흐름 설명에 직접 필요하지 않은 부분은 생략했으며, 코드 밖의 설명은 다른 온라인 게임에서도 사용할 수 있는 용어로 정리합니다.

이 글에서 다루는 내용:

사전 지식: 클라이언트-서버 구조, 게임 loop와 frame에 대한 기본 이해를 전제로 합니다.


1. 온라인 게임에서는 입력이 즉시 반영되지 않는다

싱글플레이 게임에서는 현재 실행중인 프로그램에 입력을 하고, 그 즉시 프로그램에서 게임에 입력을 반영하고 다음 화면을 그릴 수 있습니다. 따라서 입력 시점과 화면 갱신 사이에는 짧은 frame 처리 시간만 존재하게 됩니다.

그러나 온라인 게임에서는 서버가 최종 상태를 결정해야 하므로 처리 경로가 더 길어집니다.

사용자 입력
-> 입력을 서버로 전송
-> 서버의 다음 처리 시점까지 대기
-> 이동, 충돌과 게임 규칙 계산
-> 사용자에게 필요한 상태 선택
-> 계산된 상태를 클라이언트로 전송
-> 다음 화면 갱신에서 결과 표현

따라서 사용자가 느끼는 지연은 단순히 네트워크의 ping 하나로 설명되지 않습니다. 네트워크 전송, 서버 처리와 클라이언트 화면 갱신에 필요한 시간이 모두 합쳐집니다.

1.1 왕복 시간만으로 체감 지연을 설명할 수 없다

입력을 보낸 순간 서버가 바로 처리할 수 있는 것은 아닙니다. 일정한 주기로 게임 상태를 갱신하는 서버라면 입력은 다음 처리 시점까지 기다릴 수 있습니다. 현재 처리 중인 작업이 많다면 충돌 계산과 상태 생성에도 시간이 더 필요합니다.

서버가 결과를 보낸 뒤에도 클라이언트는 packet을 해석하고 다음 frame에서 화면을 갱신해야 합니다. 다른 플레이어를 부드럽게 보여주기 위해 과거 상태를 잠시 보관한다면 의도적인 표시 지연도 추가됩니다.

체감 지연을 구성 요소로 나누면 다음과 같습니다.

Lperceived=Lsend+Ltick+Lserver+Lreceive+LrenderL_{perceived} = L_{send} + L_{tick} + L_{server} + L_{receive} + L_{render}

예를 들어 네트워크 편도 전송에 각각 40ms가 걸리고, 서버가 60Hz로 상태를 처리한다고 가정하겠습니다. 입력은 서버 처리 시점까지 최대 약 16.7ms를 기다릴 수 있고, 결과가 도착한 뒤에도 다음 60FPS 화면까지 최대 약 16.7ms를 기다릴 수 있습니다.

이 예시에서는 서버 계산 시간을 제외해도 입력 후 결과를 화면에서 확인하기까지 약 80~113ms가 필요합니다.

클라이언트 -> 서버       40ms
서버 처리 시점 대기       0~16.7ms
서버 -> 클라이언트       40ms
다음 화면 갱신 대기       0~16.7ms
--------------------------------
서버 계산 제외 합계       80~113.4ms

네트워크 환경이 좋아져도 서버의 처리 주기와 화면 갱신 대기는 남습니다. 반대로 서버 계산을 빠르게 만들어도 물리적인 전송 시간은 제거할 수 없습니다.

1.2 서버의 결과를 기다리면 조작이 무거워진다

서버가 게임 로직을 판정하는 게임이라면 클라이언트는 어느 시점에 갱신된 상태를 받아야 할까요?

가장 단순한 구현은 서버가 확정한 위치를 받은 뒤에만 화면의 캐릭터를 움직이면 됩니다. 이 방식에서는 클라이언트의 화면과 서버 상태가 쉽게 일치하지만, 사용자는 입력할 때마다 전체 왕복 경로를 기다려야 합니다.

입력 발생
-> 화면 변화 없음
-> 서버 응답 도착
-> 캐릭터 이동

키를 눌렀는데 캐릭터가 잠시 멈춰 있다가 움직이면 사용자는 네트워크 지연을 조작 지연으로 느낍니다. 지연이 매번 같지 않으면 조작감도 일정하지 않습니다. 같은 입력이라도 어떤 순간에는 빠르게 반응하고 다른 순간에는 늦게 반응하기 때문입니다.

1.3 클라이언트가 결과를 확정하면 상태가 갈라진다

반대로 반응성을 높이기 위해 클라이언트가 모든 결과를 직접 결정하는 방식도 문제가 됩니다.

각 클라이언트가 가지고 있는 네트워크 환경에 따라 서버가 같은 tick에 처리한 데이터를 전송을 해도 서로 다른 시간에 packet을 받게 될 것이고, 자신이 관찰한 정보만으로 상태를 계산해 반영한다면 클라이언트마다 서로 다른 결과를 가지게 될 가능성이 높아집니다. 이 방식은 이미 서버의 판정을 최우선하는 게임 규칙과도 멀리 떨어지게 됩니다.

더 심각한 문제는 두 플레이어가 같은 자원을 동시에 획득하거나 서로 충돌하는 경합 상황입니다. 각 클라이언트가 독립적으로 결과를 확정하면 양쪽 모두 자신이 먼저 도착했다고 판단할 수 있습니다. 의도적으로 변조된 클라이언트가 허용되지 않은 이동이나 점수를 제출하는 문제도 발생합니다.

따라서 이동 결과, 충돌, 자원 획득, 점수와 생존 여부처럼 여러 사용자에게 공통된 결과는 서버가 하나의 순서와 규칙으로 확정해야 합니다.

여기서 온라인 게임의 UX 문제가 만들어집니다.

그래서 이 프로젝트에서는 다음과 같은 방식으로 클라이언트의 UX를 보완하기로 하였습니다.

  1. 클라이언트의 예측과 보간으로 반응성은 높이고
  2. 서버는 클라이언트에게 보낼 데이터 처리를 최대한 빠르게 하자

즉, 클라이언트는 기다리는 시간을 어떻게 보여줄지 결정하고 서버는 확정 상태를 만들고 전달하는 데 필요한 일을 어떻게 줄일지 결정합니다. 두 방향 모두 최종 게임 상태는 서버가 확정한다는 원칙 안에서 동작해야 합니다.


2. 빠른 반응성과 서버 권위를 함께 유지한다

앞 절에서 살펴본 문제는 반응성과 일관성 중 하나만 선택해서 해결할 수 없습니다. 클라이언트가 서버 응답만 기다리면 조작이 느려지고, 클라이언트가 결과를 직접 확정하면 사용자마다 서로 다른 상태를 가질 수 있습니다.

그래서 서버가 관리하는 확정 상태와 클라이언트가 화면에 보여주는 표현 상태를 분리했습니다.

클라이언트가 자신의 움직임을 먼저 보여주더라도 실제 이동 결과가 확정된 것은 아닙니다. 서버 상태가 도착하면 클라이언트의 예상과 비교하고, 차이가 있다면 서버 결과를 기준으로 교정해야 합니다.

2.1 서버는 공유되는 게임 결과를 확정한다

여러 사용자에게 영향을 주는 결과는 하나의 기준으로 판정해야 합니다. 두 플레이어가 같은 자원을 획득하거나 서로 충돌했을 때 사용자마다 다른 결과를 가질 수 없기 때문입니다.

이 프로젝트에서는 다음 상태를 서버가 확정합니다.

서버가 상태를 확정한다는 것은 클라이언트가 보낸 값을 그대로 사용하는 것과 다릅니다. 클라이언트는 이동할 방향이나 특정 행동을 사용 중인지와 같은 입력 의도를 보냅니다. 서버는 해당 입력이 현재 게임 상태에서 유효한지 확인하고, 같은 시점에 들어온 다른 사용자의 입력과 함께 게임 규칙을 적용합니다.

클라이언트가 보내는 정보
    이동 방향, 행동 상태와 같은 입력 의도

서버가 계산하는 정보
    실제 위치, 충돌, 점수와 생존 상태

클라이언트가 자신이 도착하고 싶은 위치나 획득한 점수를 결과로 제출하게 하면 서버는 그 값이 정상적인 게임 진행으로 만들어졌는지 다시 검증해야 합니다. 입력을 전달하고 서버가 결과를 계산하는 구조에서는 모든 사용자에게 같은 규칙과 처리 순서를 적용할 수 있습니다.

2.2 클라이언트는 확정 전 상태를 먼저 표현한다

서버가 결과를 확정하더라도 클라이언트 화면까지 서버의 처리 주기에 맞출 필요는 없습니다. 사용자가 자신의 캐릭터를 조작할 때는 현재 입력을 알고 있으므로, 서버 응답이 오기 전에 예상되는 움직임을 먼저 계산할 수 있습니다.

이때 먼저 계산한 위치는 서버 상태를 대신하지 않습니다. 아직 확정되지 않은 화면 표현이며, 이후 도착한 서버 상태와 비교하기 위한 예상값입니다.

다른 플레이어는 현재 어떤 입력을 하고 있는지 알 수 없습니다. 따라서 자신의 캐릭터처럼 현재 입력으로 움직임을 예측하기보다, 서버에서 받은 여러 상태를 시간순으로 보관하고 그 사이의 움직임을 복원합니다.

화면에 표현할 대상클라이언트가 알고 있는 정보표현 방법최종 기준
사용자가 조작하는 캐릭터현재 입력과 이전 서버 상태입력을 사용해 먼저 움직임을 예상이후 도착한 서버 상태
다른 플레이어서버에서 받은 과거 상태상태 사이를 시간에 따라 연결새로 도착한 서버 상태
점수, 사망과 라운드 결과서버가 확정한 결과별도 예측 없이 화면에 반영서버 상태

이 구분을 통해 사용자의 입력에는 즉시 반응하면서도, 다른 사용자와 공유되는 결과는 서버가 확정할 수 있습니다.

2.3 게임 상태와 화면 상태를 분리한다

서버 상태가 도착했을 때 클라이언트가 화면의 캐릭터를 항상 즉시 해당 위치로 옮기면 작은 오차에도 화면이 흔들릴 수 있습니다. 반대로 화면을 부드럽게 만드는 과정 때문에 서버 상태의 적용까지 늦추면 오래된 상태를 게임의 기준으로 사용하게 됩니다.

이를 피하기 위해 클라이언트 안에서도 게임 상태와 화면 상태를 구분합니다.

서버에서 받은 확정 상태
-> 클라이언트가 유지하는 현재 게임 상태 갱신
-> 화면에 사용할 위치와 움직임 계산
-> 다음 frame에서 캐릭터와 UI 갱신

클라이언트가 유지하는 게임 상태는 서버 결과를 기준으로 갱신합니다. 화면 상태는 작은 위치 차이를 여러 frame에 나누어 표현하거나, 과거 두 상태 사이의 중간 위치를 계산할 수 있습니다.

이 분리로 다음 두 요구를 함께 만족할 수 있습니다.

다만 모든 상태를 부드럽게 변경해서는 안 됩니다. 점수, 사망, 라운드 종료와 객체 제거처럼 의미가 명확한 변화는 서버 결과를 그대로 적용해야 합니다. 이동처럼 연속적인 값만 화면 보정의 대상으로 사용합니다.

2.4 클라이언트와 서버가 줄이는 지연은 서로 다르다

클라이언트 예측이나 보간은 실제 네트워크 전송 시간을 줄이지 않습니다. 서버 결과를 기다리는 동안 화면에 사용할 중간 상태를 계산해 사용자가 지연을 직접 느끼지 않도록 표현합니다.

서버는 다른 부분을 담당합니다. 한 번의 게임 상태 갱신에서 비교해야 할 충돌 후보를 줄이고, 각 사용자에게 필요한 주변 상태만 선택해 전송합니다. 서버의 계산과 전송량이 줄어들면 처리 주기를 안정적으로 유지하고 결과를 클라이언트에 전달하는 데 필요한 대기 시간을 줄이는 데 도움이 됩니다.

제거하기 어려운 시간
    네트워크 전송 시간
    -> 클라이언트가 예측과 보간으로 화면에서 흡수

줄일 수 있는 시간과 작업
    서버 계산, 상태 생성과 전송량
    -> 서버가 충돌 후보와 사용자별 전송 대상을 제한

따라서 이 구조는 클라이언트와 서버 중 한쪽에서만 완성되지 않습니다. 클라이언트는 빠르게 반응하되 결과를 확정하지 않고, 서버는 결과를 확정하되 모든 객체를 모든 사용자에게 같은 비용으로 처리하지 않습니다.

이후 절에서는 먼저 클라이언트가 사용자의 입력을 어떻게 예측하고 서버 상태로 교정하는지 살펴봅니다. 그다음 다른 플레이어의 움직임을 표현하는 방법과 서버가 충돌 및 상태 전송 대상을 줄이는 방법을 이어서 정리합니다.


3. 서버 응답 전에 사용자 입력을 먼저 보여준다

사용자가 직접 조작하는 캐릭터는 입력 직후부터 반응해야 합니다. 서버 응답을 기다린 뒤 움직이면 앞에서 살펴본 네트워크 왕복 시간과 서버 처리 시간이 모두 조작 지연으로 나타납니다.

이를 줄이기 위해 클라이언트는 현재 입력과 마지막으로 확인한 서버 상태를 사용해 다음 움직임을 먼저 계산합니다. 이 계산은 서버의 결과를 대체하는 것이 아니라, 서버 응답이 도착할 때까지 화면에 사용할 임시 상태를 만드는 과정입니다.

3.1 위치가 아니라 입력 의도를 서버에 전달한다

클라이언트가 서버에 보내는 정보는 최종 위치가 아니라 사용자가 어떤 조작을 했는지에 대한 입력입니다.

캐릭터의 이동에 대한 조작을 클라이언트가 서버로 전송하는 경우에, 입력 상태가 바뀔 때마다 다음과 같이 시퀀스를 증가시키고 서버에 그 의도를 전달하게 됩니다.

입력 101: 오른쪽 이동 시작
입력 102: 추가 행동 시작
입력 103: 오른쪽 이동 종료

시퀀스는 입력이 발생한 시간을 복원하기 위한 값이 아닙니다. 서버가 현재까지 어떤 입력을 처리했는지 클라이언트가 식별하기 위한 기준입니다. 접속이 바뀌거나 새로운 캐릭터를 조작하게 되면 이전 입력 순서를 그대로 사용하지 않습니다.

이동 패킷의 경우 입력 상태를 변경하지 않는 경우에는 매 frame마다 같은 pakcet을 별도로 보낼 필요가 없습니다. 반대로 매 frame이나 일정한 주기마다 클라이언트의 상태를 갱신시키는 packet의 경우에는 packet을 전송해야합니다.

3.2 마지막 서버 상태에서 예측을 시작한다

클라이언트 예측은 아무 위치에서 시작하지 않습니다. 서버가 마지막으로 확정한 위치와 방향을 기준으로 현재 입력을 적용합니다.

마지막 서버 위치
-> 현재 입력과 경과 시간 적용
-> 예상 위치 계산
-> 다음 화면에 반영

예측에 사용하는 이동 속도와 시간 단위가 서버와 크게 다르면 짧은 시간에도 위치 차이가 누적됩니다. 따라서 클라이언트는 서버가 알려준 처리 주기(현재 60Hz)와 이동 제한을 기준으로 예상 이동량을 계산합니다.

화면 frame의 간격은 서버의 상태 갱신 주기와 다를 수 있습니다. 클라이언트는 한 frame에서 경과한 시간을 서버의 시간 단위에 맞게 나누어 예측을 진행합니다. 이 과정의 목적은 실시간으로 변화하는 서버의 상태를 완전하게 따라잡는게 아니라 다음 서버의 상태가 도착할 때까지 자연스러운 입력 반응을 만들기 위함입니다.

현재 조작은 왼쪽, 직진, 오른쪽의 회전 상태와 boost 여부로 속도를 계산합니다. 실제 클라이언트는 한 frame마다 다음 순서로 예상 위치를 갱신합니다.

  1. 현재 회전 입력을 왼쪽은 1, 오른쪽은 -1, 직진은 0으로 변환합니다.
  2. 회전 방향, 초당 회전 속도와 이번 frame의 경과 시간을 사용해 바라보는 각도를 변경합니다.
  3. 각도가 계속 커지거나 작아지지 않도록 -π~π 범위로 정규화합니다.
  4. 서버에서 확인한 boost 상태에 따라 일반 속도 또는 boost 속도를 선택합니다.
  5. 현재 각도의 cos, sin 값으로 진행 방향을 구하고 선택한 속도를 곱해 velocity를 만듭니다.
  6. velocity에 frame 경과 시간을 곱한 이동량을 현재 logical position에 더합니다.

이 과정을 코드로 표현하면 다음과 같습니다.

float turnSign = controlTurnState switch
{
    TurnStateV2.Left => 1.0f,
    TurnStateV2.Right => -1.0f,
    _ => 0.0f,
};

angleRadians = NormalizeHeading(
    angleRadians + turnSign * ControlAngularSpeedRadiansPerSecond * deltaSeconds);

float speed = authoritativeBoostActive ? ControlBoostSpeed : ControlBaseSpeed;
velocity = new Vector2(MathF.Cos(angleRadians), MathF.Sin(angleRadians)) * speed;
logicalPosition += velocity * deltaSeconds;ControlledEntityPrediction.cs

예를 들어 캐릭터가 오른쪽을 바라보고 있고 일반 속도가 초당 5 world unit이라면, 60FPS의 한 frame 동안 약 0.083 unit을 이동합니다. 오른쪽 회전 입력이 함께 들어오면 먼저 바라보는 각도를 시계 방향으로 약 변경한 뒤, 바뀐 방향으로 이동량을 계산합니다.

클라이언트는 이 계산을 매 frame 반복해 다음 서버 상태가 도착할 때까지 머리의 예상 위치를 전진시킵니다. 다만 authoritativeBoostActive처럼 gameplay 결과에 영향을 받는 값은 클라이언트 입력만으로 확정하지 않고 서버가 알려준 상태를 사용합니다.

3.3 예측 범위를 사용자가 조작하는 움직임으로 제한한다

클라이언트가 모든 게임 규칙을 복제하면 서버와 같은 simulation을 하나 더 유지해야 합니다. 즉, 다른 플레이어의 위치나 자원 경합 등 서버 내부 처리 순서에 따라 변경되는 값들에 대한 규칙도 모두 동일하게 알고 있어야 정확한 계산을 할 수 있게 됩니다.

그래서 클라이언트는 이동과 관련된 범위에 대해서만 예측을 하여 클라이언트 레벨에서 바로 반영하고, 최종 판정은 서버의 결과로 교정을 합니다. 다른 항목에 대해서는 서버의 판정을 우선시 하여 그 상태를 클라이언트에 반영하게 됩니다.

항목클라이언트의 사전 표현최종 판정
자신의 이동 방향과 머리 위치현재 입력으로 예측서버 상태로 교정
맵 경계와 고정 장애물알려진 형상을 사용해 이동 제한서버 충돌 결과
다른 플레이어와의 충돌사전에 확정하지 않음서버 판정
자원 획득, 성장과 점수사전에 변경하지 않음서버 판정
사망과 라운드 결과사전에 변경하지 않음서버 판정

이렇게 예측 범위를 좁히면 클라이언트가 잘못 예상했을 때 되돌려야 하는 상태도 줄어듭니다. 사용자는 입력에 대한 움직임을 즉시 볼 수 있고, 여러 사용자에게 영향을 주는 결과는 서버가 계속 관리합니다.

3.4 예측 상태는 언제든 교정될 수 있어야 한다

예측한 위치는 화면에 표시되더라도 확정된 상태가 아닙니다. 클라이언트 내부에서는 예측값과 마지막 서버 상태를 구분해 보관해야 합니다.

이 구분이 없으면 서버 상태가 도착했을 때 어느 위치를 기준으로 차이를 계산해야 하는지 알 수 없습니다. 예측 위치를 다시 서버에 결과로 제출하는 구조도 서버 권위를 약하게 만듭니다.

클라이언트 예측에는 다음 조건이 필요합니다.

이 조건을 통해 클라이언트는 즉시 반응하면서도 서버가 보낸 상태를 새로운 기준점으로 받아들일 수 있습니다.


4. 서버 결과가 도착하면 예측을 교정한다

클라이언트가 예측한 상태와 서버가 확정한 상태는 달라질 수 있습니다. 네트워크 지연 동안 다른 플레이어가 이동할 수 있고, 클라이언트가 알지 못한 충돌이나 게임 규칙이 서버에서 적용될 수 있기 때문입니다.

서버 상태가 도착했을 때 이 차이를 무시하면 오차가 계속 누적됩니다. 반대로 작은 차이마다 화면 위치를 즉시 바꾸면 캐릭터가 앞뒤로 흔들립니다. 따라서 게임의 기준을 바로잡는 과정과 화면에서 그 차이를 보여주는 과정을 분리해야 합니다.

4.1 서버가 처리한 마지막 입력을 확인한다

서버는 자신의 상태를 보낼 때 현재 결과에 반영된 마지막 입력 순서(시퀀스)를 함께 전달합니다. 클라이언트는 이 값을 통해 어떤 입력까지 서버가 처리했는지 알 수 있습니다.

클라이언트가 보낸 입력: 101, 102, 103
서버 상태의 처리 완료 입력: 102

101, 102 -> 서버 상태에 반영됨
103      -> 아직 서버 상태에 반영되지 않음

시퀀스를 확인하지 않고 가장 최근 서버 위치만 적용하면, 서버가 아직 처리하지 않은 현재 조작까지 취소한 것처럼 보일 수 있습니다. 클라이언트는 서버가 확정한 위치를 새로운 기준으로 삼고, 현재 유지 중인 입력으로 이후 예측을 계속합니다.

현재 구조는 과거의 모든 입력과 중간 simulation을 되감아 완전히 재실행하는 rollback 방식이 아닙니다. 서버 상태에 따라 예측 기준을 다시 세우고 현재 입력을 이어서 적용하는 방식입니다.

4.2 논리 상태는 서버 결과를 즉시 따른다

서버 상태가 도착하면 클라이언트가 유지하는 게임 상태는 즉시 해당 결과를 기준으로 갱신합니다. 충돌이나 자원 획득처럼 서버에서만 알 수 있는 결과를 이전 예측 상태 위에 계속 계산하지 않기 위해서입니다.

예측한 위치: (12.4, 8.1)
서버 위치:   (12.0, 8.0)

게임 상태 기준 -> 서버 위치로 변경
화면 위치 기준 -> 오차를 표현할 방법 결정

여기서 게임 상태와 화면 상태를 같은 값으로 취급하면 서버 위치를 적용하는 순간 화면이 튈 수 있습니다. 그래서 서버 결과는 게임 상태에 즉시 반영하되 화면에는 별도의 보정량을 사용합니다.

4.3 작은 오차는 화면에서 나누어 보정한다

예측 위치와 서버 위치의 차이가 작다면 여러 frame에 걸쳐 화면 위치를 서버 기준으로 이동시킬 수 있습니다. 사용자는 작은 교정을 순간 이동보다 자연스러운 속도 변화로 인식합니다.

보정 전 화면 위치
-> 오차의 일부만 반영
-> 다음 frame에서 남은 오차 반영
-> 서버 기준 위치에 수렴

보정 중에도 사용자의 새 입력은 계속 예측에 사용됩니다. 따라서 화면은 과거 서버 위치로 단순히 되돌아가는 것이 아니라, 수정된 기준점에서 현재 조작을 이어가는 모습으로 표현됩니다.

보정 속도가 너무 느리면 화면 위치가 서버 상태에서 오랫동안 벗어납니다. 너무 빠르면 작은 오차도 순간 이동처럼 보입니다. 보정 시간과 허용 오차는 네트워크 지연, 이동 속도와 화면 크기를 함께 고려해 조정해야 합니다.

클라이언트 예측 위치를 서버 위치로 교정하고 화면 오차만 점진적으로 줄이는 과정

현재 구현은 서버 위치를 논리 위치로 즉시 적용하고, 적용 직전 화면 위치와의 차이를 correctionOffset으로 보관합니다.

prender=pauthoritative+o\vec{p}_{render} = \vec{p}_{authoritative} + \vec{o}

화면 보정량 o\vec{o}는 설정한 반감기 hh를 기준으로 매 frame 감소합니다.

ot+Δt=ot0.5Δth\vec{o}_{t + \Delta t} = \vec{o}_t \cdot 0.5^{\frac{\Delta t}{h}}
Vector2 previousRenderPosition = logicalPosition + correctionOffset;
Vector2 authoritativePosition = new Vector2(
    state.HeadPositionX,
    state.HeadPositionY);
float correctionDistance = Vector2.Distance(
    previousRenderPosition,
    authoritativePosition);

logicalPosition = authoritativePosition;

if (correctionDistance <= config.ReconciliationTolerance ||
    correctionDistance > config.HardSnapDistance)
{
    correctionOffset = Vector2.Zero;
}
else
{
    correctionOffset = previousRenderPosition - logicalPosition;
}

float decay = MathF.Pow(
    0.5f,
    deltaSeconds / config.CorrectionHalfLifeSeconds);
correctionOffset *= decay;ControlledEntityPrediction.cs

허용 오차보다 작은 차이는 별도 보정 없이 서버 위치를 사용합니다.

4.4 큰 오차와 수명 변경은 즉시 맞춘다

그렇다고 모든 차이를 부드럽게 보정해서는 안 됩니다. 허용 범위를 크게 벗어난 위치에 대한 부드러운 보정은 캐릭터가 장애물을 통과하거나 이미 사망한 상태에서 계속 움직이는 것처럼 보일 수 있기 때문입니다.

그래서 다음 상황에서는 화면 상태도 즉시 서버 결과에 맞춥니다.

이 기준은 부드러움보다 상태의 의미가 우선되는 경계입니다. 작은 네트워크 오차는 화면에서 흡수하지만, 서로 다른 객체 수명이나 gameplay 결과를 이어 붙이지는 않습니다.

4.5 머리의 반응성과 몸체의 권위를 분리한다

현재 게임에서는 사용자가 조작하는 머리 위치는 먼저 예측하지만, 길게 이어지는 몸체 전체는 서버가 전달한 경로를 기준으로 표현합니다. 몸체는 충돌과 성장 결과에 영향을 받기 때문에 클라이언트가 독립적으로 확정하기 어렵습니다.

클라이언트는 예측한 머리와 최신 서버 몸체 사이를 화면에서 연결합니다. 이 방식은 조작 대상의 반응성을 유지하면서도 몸체 형상과 충돌 기준이 서버 결과에서 벗어나지 않게 합니다.

이후 서버 snapshot이 도착하면 머리의 예측 오차와 몸체 경로를 함께 갱신합니다. 따라서 클라이언트 예측은 캐릭터 전체 simulation의 복제가 아니라, 사용자가 직접 조작하는 부분의 입력 지연을 줄이는 역할에 집중합니다.


5. 다른 플레이어의 움직임은 과거 상태 사이에서 복원한다

다른 플레이어의 현재 입력은 클라이언트가 알 수 없습니다. 자신의 캐릭터처럼 키 입력을 기준으로 다음 위치를 예상할 수 없으므로, 서버에서 전달받은 상태를 시간순으로 연결해 움직임을 표현해야 합니다.

5.1 가장 최근 상태를 바로 그리면 움직임이 끊긴다

서버 상태가 도착할 때마다 다른 플레이어의 위치를 즉시 바꾸는 방식은 다음과 같이 보입니다.

서버 상태 도착: 위치 변경
다음 상태 대기: 같은 위치 유지
서버 상태 도착: 위치 변경

packet이 정확히 같은 간격으로 도착하지 않으면 멈추는 시간도 매번 달라집니다. 평균 지연이 낮더라도 도착 간격의 변화가 화면에서 떨림으로 나타날 수 있습니다.

이를 줄이기 위해 클라이언트는 최근 서버 상태를 시간순으로 보관합니다. 화면에는 가장 최신 상태보다 조금 이전의 시점을 보여주고, 해당 시점 앞뒤에 있는 두 상태 사이의 위치를 계산합니다.

5.2 보간은 일부러 과거를 보여준다

보간할 시점을 현재 추정 서버 시간보다 뒤로 두면 대부분의 경우 앞뒤 상태를 모두 확보할 수 있습니다. 현실적으로 다른 플레이어의 입력값을 예측해서 그 상태를 자신의 캐릭터를 조작하는 것과 동일한 방식으로 화면에 그리는 것은 불가능하기 때문입니다.

서버 상태 A -------- 서버 상태 B -------- 서버 상태 C
              ^
    화면에 표시할 시점

표시할 시점이 A와 B 사이라면 두 위치에서의 시간과 현재 시간의 비율을 계산해 중간 위치를 만들어 낼 수 있습니다. 그리고 head, body sample을 같은 비율을 이용해 보간하는 것입니다.

구현상 클라이언트는 다른 클라이언트들의 최근 몇 tick의 snapshot을 저장하고 있습니다. 그래서 이 tick 중에서 화면에 표시하고자 하는 목표 tick을 정하고, 목표 tick을 감싸는 주변 tick의 snapshot 정보를 기반으로 보간을 하게 됩니다. 식으로 보면 다음과 같습니다.

두 snapshot의 server tick을 T0T_0, T1T_1, 화면에 표시할 목표 tick을 TT라고 하면 보간 비율은 다음과 같습니다.

α=TT0T1T0\alpha = \frac{T - T_0}{T_1 - T_0} p(T)=(1α)p0+αp1\vec{p}(T) = (1 - \alpha)\vec{p}_0 + \alpha\vec{p}_1

실제 구현은 두 snapshot 사이의 시간 비율을 계산하고 머리 위치와 몸체 sample을 각각 선형 보간합니다.

double tickRange = latest.Snapshot.ServerTick - oldest.Snapshot.ServerTick;

// snapshot 사이의 시간 비율 계산
float amount = checked((float)(
    (targetServerTick - oldest.Snapshot.ServerTick) / tickRange));

for (int index = 0; index < bodySampleCount; ++index)
{
    // 전체 길이를 1.0이라고 했을 때,
    // 현재 index의 비율
    float normalizedDistance = bodySampleCount == 1
        ? 0.0f
        : (float)index / (bodySampleCount - 1);

    // SampleBody 에서는
    // - normalizedDistance를 실제 누적 거리로 변환
    // - 누적 거리를 포함하는 최초의 선분 시작과 끝 point를 탐색
    // - 시작과 끝 사이에서 누적 거리만큼 이동한 point를 보간하여 반환
    Vector2 oldestPoint = oldest.SampleBody(normalizedDistance, ref oldestSegmentIndex);
    Vector2 latestPoint = latest.SampleBody(normalizedDistance, ref latestSegmentIndex);

    // amount로 body 갱신
    bodyTrail[index] = Vector2.Lerp(oldestPoint, latestPoint, amount);
}


// amount로 head 갱신
Vector2 headPosition = Vector2.Lerp(
    oldest.HeadPosition,
    latest.HeadPosition,
    amount);RemoteWholeBodySnapshotHistory.cs

몸체는 snapshot마다 sample 개수와 간격이 다를 수 있습니다. 같은 배열 index끼리 바로 연결하면 이전 snapshot의 tail이 다음 snapshot의 중간 지점과 대응할 수 있습니다. 그래서 각 몸체를 머리에서 꼬리까지 0~1의 정규화된 거리로 보고, 같은 몸체 위치를 먼저 찾은 뒤 시간 비율 α\alpha로 보간합니다.

이 방법은 화면을 부드럽게 만들지만 의도적인 표시 지연을 추가합니다. 보간 간격이 짧으면 최신 상태에 가깝지만 packet 도착이 조금만 늦어도 사용할 다음 상태가 없습니다. 반대로 간격이 길면 안정적이지만 다른 플레이어가 실제보다 더 과거에 보입니다.

따라서 보간 간격은 상태 전송 주기와 실제 도착 간격의 변화를 기준으로 조정해야 합니다.

5.3 시간축을 맞춰야 상태 사이를 연결할 수 있다

클라이언트의 frame 시간과 서버의 상태 번호는 서로 다른 시간축입니다. 단순히 packet이 도착한 로컬 시각만 사용하면 네트워크 지연의 변화가 실제 움직임 시간의 변화처럼 섞일 수 있습니다.

현재 서버 시점은 짧은 요청과 응답을 주고받아 추정합니다. 클라이언트가 고유한 순서 번호를 포함한 요청을 보내면서 로컬 전송 시각을 기록하면, 서버는 같은 순서 번호와 현재 server tick을 응답합니다. 클라이언트는 응답을 받은 시각까지 기록해 왕복 시간을 구합니다.

응답에 담긴 server tick은 클라이언트에 도착하는 동안에도 계속 진행됩니다. 따라서 클라이언트는 왕복 시간의 절반을 한 방향의 전송 시간으로 보고, 이 시간을 server tick 단위로 바꾸어 응답의 tick에 더합니다. 이후에는 응답을 받은 뒤 로컬에서 흐른 시간만큼 tick을 계속 전진시킵니다.

요청을 보낸 로컬 시각 기록
-> server tick이 담긴 응답 수신
-> 왕복 시간의 절반을 tick으로 변환
-> 응답의 server tick에 전송 중 흐른 tick을 더함
-> 이후 로컬에서 흐른 시간만큼 계속 전진

실제 클라이언트에서 현재 server timeline을 계산하는 핵심 코드는 다음과 같습니다.

double oneWayTicks = Math.Ceiling(
    sample.RoundTripTimeSeconds * tickRateHz / 2.0);

double elapsedTicks =
    (nowSeconds - sample.ResponseReceivedTimeSeconds) * tickRateHz;

double estimate = sample.ServerTick + oneWayTicks + elapsedTicks;WorldTimeSyncTracker.cs

예를 들어 요청을 보낸 뒤 0.25초 만에 server tick 1000이 담긴 응답을 받았고 서버가 초당 60 tick을 처리한다고 가정하겠습니다. 한 방향의 전송 시간은 왕복 시간의 절반인 0.125초로 보고, 이를 8 tick으로 올림해 응답의 tick에 더합니다. 따라서 응답을 받은 시점의 server timeline은 1008로 추정합니다. 그로부터 로컬에서 0.25초가 더 흐르면 15 tick을 더해 현재 시점을 1023으로 추정합니다.

한 번의 왕복 시간에는 순간적인 queue 대기나 packet 지연이 섞일 수 있습니다. 현재 구현은 최근 8개 표본을 보관하고 그중 왕복 시간이 가장 짧은 표본을 사용합니다. 왕복 시간이 같다면 더 최근에 받은 표본을 선택합니다. 모든 값을 평균내기보다 불규칙한 대기 시간이 가장 적게 포함된 표본을 기준으로 삼는 방식입니다. 이 표본은 주기적으로 다시 수집하며, 다시 접속하면 이전 연결의 표본을 폐기합니다.

이 작업은 클라이언트와 서버의 실제 시간을 완전히 동기화시키는 목적이 아니라, 요청과 응답의 전송 시간을 비슷하다 가정하고 서버의 현재 tick을 추정하여 찾기 위해 진행합니다. 이를 기반으로 클라이언트는 서버가 어떤 timeline을 가지고 있는지 스스로 계산해서 작업을 할 수 있게 됩니다.

클라이언트는 이렇게 추정한 현재 서버 시점에서 보간 간격을 뺀 시간을 화면의 목표 시점으로 사용합니다.

추정한 현재 서버 시점
- 보간을 위한 지연 간격
= 화면에 표현할 목표 시점 (이를 기반으로 다른 클라이언트 보간 처리)

5.4 추정은 짧고 제한적으로 사용한다

표현할 목표 시점보다 새로운 상태가 늦게 도착하면 두 상태 사이를 보간할 수 없습니다. 이때 가장 최근 위치에서 마지막으로 전달받은 속도를 짧게 적용해 다음 위치를 예상하는 방법이 extrapolation입니다.

마지막 서버 위치
-> 마지막 서버 속도 × 외삽할 시간
-> 새로운 상태를 기다리는 동안 표시할 위치

클라이언트는 먼저 화면의 목표 tick이 최신 snapshot보다 얼마나 앞섰는지 계산합니다. 그 차이가 허용 범위 안이면 tick을 초로 바꾸고 마지막 속도를 적용합니다. 방향은 새 입력을 알 수 없으므로 마지막으로 전달받은 각도를 그대로 유지합니다.

double ticksAfterLatest = targetServerTick - latest.ServerTick;
double boundedTicks = Math.Min(ticksAfterLatest, maxExtrapolationTicks);
float elapsedSeconds = checked((float)(boundedTicks / tickRateHz));
Vector2 position = latest.Position + latest.Velocity * elapsedSeconds;RemoteSnapshotHistory.cs

외삽을 오래 지속하면 다른 플레이어의 방향 전환이나 충돌 결과를 모르는 상태에서 오차가 계속 커집니다. 따라서 허용 범위는 상태 전송 주기의 두 배로 제한합니다. 최소 한 tick은 보장하지만, 그보다 상태 전송 주기의 두 배가 크다면 그 값을 사용합니다.

ulong doubledInterval = (ulong)snapshotIntervalTicks * 2UL;
maxExtrapolationTicks = checked((uint)Math.Max(1UL, doubledInterval));RemoteReplicaStore.cs

현재 서버는 초당 60 tick을 처리하고 2 tick마다 상태를 전송합니다. 따라서 클라이언트가 외삽하는 최대 범위는 4 tick, 시간으로는 약 67ms입니다. 예를 들어 마지막 위치가 (10, 0)이고 오른쪽으로 초당 10 unit의 속도를 전달받았다면, 최대 외삽 위치는 약 (10.67, 0)입니다.

화면의 목표 시점이 이 범위를 넘어가도 위치를 더 전진시키지 않습니다. 최대 범위까지 계산한 위치에서 멈추고 표시 속도를 0으로 바꾼 뒤 새로운 상태를 기다립니다. 늦게 도착한 packet 하나 때문에 캐릭터가 오랫동안 잘못된 방향으로 이동하는 것을 막기 위한 제한입니다.

bool frozen = ticksAfterLatest > maxExtrapolationTicks;

sample = new RemoteSnapshotSample(
    targetServerTick,
    position,
    frozen ? Vector2.Zero : latest.Velocity,
    latest.AngleRadians,
    frozen ? RemoteSnapshotSampleMode.Frozen : RemoteSnapshotSampleMode.Extrapolated);RemoteSnapshotHistory.cs

긴 몸체를 가진 다른 플레이어에는 더 보수적인 정책을 적용합니다. 머리만 마지막 속도로 움직이고 몸체를 임의로 늘리면 서버가 보내지 않은 형상과 충돌 결과를 화면에 만들 수 있기 때문입니다. 두 개의 전신 snapshot이 있으면 머리와 몸체를 함께 보간하지만, 사용할 다음 snapshot이 없으면 전신을 외삽하지 않고 마지막으로 확정된 형상을 유지합니다.

이 선택은 packet 지연이 외삽 한도를 넘었을 때 대상이 잠깐 멈춰 보이는 단점이 있습니다. 대신 다음 snapshot이 도착했을 때 잘못 추정한 위치나 긴 몸체가 크게 되돌아가는 현상을 막아줍니다. 즉, 외삽은 짧은 packet 도착 간격만 메우고 그 이후에는 서버가 확정하지 않은 상태를 더 만들지 않습니다.


6. 서버는 충돌 가능성이 있는 후보만 계산한다

클라이언트가 예측과 보간으로 지연을 표현하더라도 서버의 상태 계산이 처리 주기 안에 끝나지 않으면 새로운 결과 자체가 늦게 만들어집니다. 서버에서는 매 tick 반복되는 계산에서 공간적으로 관련 없는 대상을 먼저 제외해야 합니다.

충돌 계산이 대표적인 예입니다. 플레이어와 몸체가 늘어날 때 모든 형상을 서로 비교하면 실제로 멀리 떨어진 객체에도 정밀한 충돌 검사를 수행하게 됩니다.

6.1 전수 비교는 객체 수가 늘수록 빠르게 커진다

충돌 객체가 NN개일 때 모든 쌍을 비교하면 가능한 비교 횟수는 다음과 같습니다.

C=N(N1)2C = \frac{N(N-1)}{2}

객체가 두 배로 늘면 가능한 쌍은 단순히 두 배가 아니라 약 네 배로 늘어납니다. 실제 게임에서는 대부분의 객체가 서로 멀리 떨어져 있으므로 이 쌍을 모두 정밀하게 비교할 필요가 없습니다.

그래서 충돌 계산을 두 단계로 나눕니다.

  1. 단순한 경계 영역으로 충돌 가능성이 있는 후보를 찾습니다.
  2. 후보에 대해서만 실제 원과 캡슐 형상의 충돌을 계산합니다.

첫 번째 단계를 broad phase, 두 번째 단계를 narrow phase라고 부릅니다.

6.2 단순한 사각 영역으로 후보를 좁힌다

각 충돌 형상은 축에 정렬된 사각 영역으로 감쌀 수 있습니다. 원이나 캡슐의 실제 충돌을 계산하는 것보다 두 사각 영역이 겹치는지 확인하는 편이 단순합니다.

중심이 (cx,cy)(c_x, c_y)이고 반지름이 rr인 원의 AABB는 다음 범위입니다.

AABBcircle=[cxr,cx+r]×[cyr,cy+r]AABB_{circle} = [c_x-r, c_x+r] \times [c_y-r, c_y+r]

캡슐은 시작점과 끝점의 X, Y 최솟값과 최댓값을 구한 뒤 반지름만큼 범위를 확장합니다. 이 사각 영역은 실제 형상보다 넓을 수 있지만, 실제로 충돌하는 형상을 후보에서 누락하지 않는 보수적인 범위가 됩니다.

실제 형상
-> 형상을 감싸는 AABB 계산
-> 공간 트리에 등록
-> 움직이는 머리의 AABB와 겹치는 후보 조회

서버는 Box2D의 Dynamic AABB Tree를 사용해 충돌 형상의 공간 위치를 관리합니다. 플레이어가 움직이면 해당 proxy의 영역을 갱신하고, 머리가 지나간 영역과 겹칠 가능성이 있는 proxy만 조회합니다.

Dynamic AABB Tree가 반환한 객체는 실제 충돌 결과가 아니라 후보입니다. AABB가 겹쳐도 원과 캡슐의 실제 형상은 떨어져 있을 수 있습니다.

6.3 후보에 대해서만 정확한 충돌을 계산한다

broad phase에서 얻은 후보에는 정확한 형상 검사를 적용합니다.

정확한 충돌 계산이 끝나면 물리 라이브러리는 어떤 형상이 접촉했는지에 대한 결과만 반환합니다. 해당 접촉이 사망인지, 자원 획득인지와 같은 의미는 서버의 게임 규칙이 결정합니다.

공간 트리
-> 충돌 후보
-> 정확한 형상 검사
-> 접촉 결과
-> 서버 게임 규칙
-> 사망, 획득과 상태 변경

Dynamic AABB Tree가 충돌 후보를 좁히고 정확한 형상 검사로 전달하는 과정

실제 서버 코드는 각 머리의 AABB로 Dynamic Tree를 조회하고, 반환된 후보에 대해서만 정확한 접촉 검사를 실행합니다.

candidateUserData_.clear();
CandidateQueryContext queryContext{&candidateUserData_};
const b2AABB headAabb = ComputeAabb(head);

// broad phase: Dynamic Tree 조회
b2DynamicTree_Query(
    &tree_,
    headAabb,
    PlayerCollisionCategoryBits,
    CollectCandidate,
    &queryContext);

// narrow phase: 후보군에 대해서 실제 충돌 확인
for (const std::uint64_t candidateUserData : candidateUserData_)
{
    const std::uintptr_t candidateAddress =
        static_cast<std::uintptr_t>(candidateUserData);
    const TreeProxyEntry* const candidateEntry =
        reinterpret_cast<const TreeProxyEntry*>(candidateAddress);
    const WorldCollisionProxy& candidate = candidateEntry->proxy;

    if (candidate.ownerKey == head.ownerKey ||
        !HasExactContact(head, candidate))
    {
        continue;
    }

    contacts.push_back(NormalizeContact(head, candidate));
}WorldBox2dCollisionAdapter.cpp

b2DynamicTree_Query의 결과는 겹칠 가능성이 있는 후보 목록입니다. HasExactContact가 원-원 또는 원-캡슐 접촉을 다시 확인하고, 자기 몸체와의 접촉은 후보에서 제외합니다.


7. 서버는 모든 상태를 모든 플레이어에게 보내지 않는다

서버가 모든 게임 로직을 계산했더라도 모든 클라이언트에게 모든 객체의 상세 정보를 보낼 필요는 없습니다. 화면에서 관찰할 수 없는 먼 플레이어도 있기 때문입니다.

관심 영역(Area of Interest)은 각 사용자가 현재 자세히 관찰해야 하는 객체의 범위를 정합니다. 서버는 사용자 주변의 객체만 상세 복제 대상으로 선택하고, 이 목록의 변화를 클라이언트에 전달합니다.

7.1 공간을 격자로 나누어 주변 후보를 찾는다

현재 관심 영역 탐색은 World 공간을 일정한 크기의 cell로 나눈 Uniform Grid를 사용합니다. 객체는 자신의 위치와 크기에 해당하는 cell에 등록됩니다.

사용자의 관찰 범위를 계산할 때는 전체 객체를 순회하지 않고 범위와 겹치는 cell의 객체만 후보로 가져옵니다. 그다음 실제 거리와 형상을 확인해 상세 상태가 필요한 객체를 결정합니다.

전체 World 객체
-> 관찰 범위와 겹치는 grid cell 조회
-> 주변 후보 수집
-> 실제 관찰 범위 검사
-> 사용자별 visible set 생성

Uniform Grid는 구조가 단순하고 현재처럼 하나의 Channel 안에서 일정한 크기의 공간을 조회하는 데 적합합니다. 반면 객체가 특정 cell에 과도하게 밀집하면 후보 수가 많아질 수 있습니다.

World 좌표 (x,y)(x, y)가 들어갈 cell은 cell 크기 SS를 사용해 다음과 같이 구합니다.

cellx=xS,celly=yScell_x = \left\lfloor \frac{x}{S} \right\rfloor, \qquad cell_y = \left\lfloor \frac{y}{S} \right\rfloor

관찰자 중심에서 retain radius까지 포함하는 최소·최대 cell을 조회한 뒤, 해당 cell에서 가져온 후보에 정확한 원형 범위 검사를 적용합니다.

관찰자와 대상 중심의 차이를 (dx,dy)(d_x, d_y), 관심 영역 반지름을 RR, 대상 객체의 반지름을 rr이라고 하면 최종 포함 조건은 다음과 같습니다.

dx2+dy2(R+r)2d_x^2 + d_y^2 \leq (R + r)^2

대상의 중심만 확인하지 않고 반지름 rr을 더하기 때문에, 대상 형상의 일부가 관심 영역과 겹치는 경우도 포함합니다.

const double differenceX =
    static_cast<double>(observer.centerX) - target.centerX;
const double differenceY =
    static_cast<double>(observer.centerY) - target.centerY;
const double maximumDistance =
    static_cast<double>(interestRadius) + target.circleRadius;

return differenceX * differenceX + differenceY * differenceY <=
       maximumDistance * maximumDistance;WorldSpatialIndex.cpp

7.2 진입과 유지 범위를 다르게 사용한다

관심 영역 경계에 플레이어가 서 있으면 작은 위치 변화만으로 화면에 나타났다 사라지는 현상이 반복될 수 있습니다. 이를 줄이기 위해 새 객체가 들어오는 범위와 이미 보이는 객체를 유지하는 범위를 다르게 사용합니다.

이처럼 진입과 이탈 기준 사이에 여유를 두면 경계 근처의 작은 움직임이나 위치 오차가 잦은 생성과 제거로 이어지는 것을 줄일 수 있습니다.

Uniform Grid 후보 조회 뒤 enter와 retain 범위로 visible set 변화를 판정하는 과정

서버는 enter 범위와 retain 범위의 결과를 이전 visible set과 비교해 세 가지 목록을 만듭니다.

std::set_difference(
    enteredRadiusVisible.begin(),
    enteredRadiusVisible.end(),
    previousVisible.begin(),
    previousVisible.end(),
    std::back_inserter(diff.entered));

std::set_intersection(
    retainedRadiusVisible.begin(),
    retainedRadiusVisible.end(),
    previousVisible.begin(),
    previousVisible.end(),
    std::back_inserter(diff.stayed));

std::set_difference(
    previousVisible.begin(),
    previousVisible.end(),
    retainedRadiusVisible.begin(),
    retainedRadiusVisible.end(),
    std::back_inserter(diff.left));WorldAoiPlanner.cpp

집합 연산의 입력은 entity key 순서로 정렬되어 있습니다. 따라서 같은 위치와 이전 visible set이라면 같은 diff 순서를 만들 수 있고, 이후 replication record의 순서도 안정적으로 유지할 수 있습니다.

7.3 이전 목록과 비교해 수명 변화를 만든다

서버는 이번 tick에 계산한 목록만 전송하지 않고, 사용자별 이전 목록과 비교합니다.

변화서버가 전달할 의미클라이언트 동작
새로 보임객체의 종류와 초기 상태화면 객체 생성
계속 보임최신 움직임과 몸체 상태기존 객체 갱신
더 이상 보이지 않음상세 복제 수명 종료화면 객체 제거

새로 보이는 객체에는 이후 상태를 해석할 수 있는 초기 정보가 필요하기 때문입니다. 반대로 범위를 벗어난 객체는 마지막 위치에 계속 남아 있지 않도록 제거 정보를 전달해야 합니다.

관심 영역은 단순한 거리 필터가 아니라 클라이언트가 유지하는 객체 수명의 시작과 끝을 결정하는 서버 계약입니다.

7.4 관심 영역은 simulation보다 replication을 줄인다

관심 영역 밖에 있다고 해서 해당 객체가 게임에서 사라지는 것은 아닙니다. 서버는 관심 영역과 무관하게 전체 World의 authoritative 상태를 계속 계산합니다. 관심 영역은 각 사용자에게 어떤 상세 상태를 만들고 전송할지를 줄이는 역할입니다.

따라서 줄어드는 비용은 다음과 같습니다.

충돌용 Dynamic AABB Tree와 관심 영역용 Uniform Grid는 모두 공간적으로 관련 없는 대상을 제외하지만 목적이 다릅니다. 전자는 서버 simulation의 충돌 후보를 줄이고, 후자는 사용자별 replication 대상을 줄입니다. 두 자료구조의 수명과 갱신 경계도 서로 분리되어 있습니다.


8. 상태의 중요도에 따라 전송과 적용 방식을 나눈다

관심 영역에서 전송 대상을 줄여도 하나의 사용자가 받아야 하는 상태는 여러 종류입니다. 모든 상태를 같은 방식으로 처리하면 중요한 변화가 누락되거나 오래된 위치를 불필요하게 모두 적용할 수 있습니다.

그래서 객체의 수명을 바꾸는 event와 반복해서 갱신되는 snapshot을 구분합니다.

8.1 수명 변화(객체의 라이프사이클)는 순서대로 적용한다

객체가 새로 보이거나 더 이상 보이지 않는 변화는 클라이언트 replica의 수명을 바꿉니다.

이러한 정보는 중간 값을 생략하고 최신 값만 적용하기 어렵습니다. 초기 정보 없이 이동 상태를 먼저 받으면 어떤 화면 객체를 만들어야 하는지 알 수 없고, remove를 건너뛰면 범위를 벗어난 객체가 화면에 남습니다.

8.2 반복 상태는 최신 값으로 대체할 수 있다

위치, 방향과 몸체처럼 주기적으로 발행되는 상태는 더 최신 snapshot이 도착하면 오래된 값의 의미가 줄어듭니다.

클라이언트가 일시적으로 처리 속도를 따라가지 못할 때 오래된 위치를 모두 화면에 적용하면 현재 상태에 도달하는 시간이 더 늦어집니다. 완성된 최신 snapshot이 있다면 이전 snapshot을 화면 갱신 대상으로 계속 유지하지 않아도 됩니다.

다만 replaceable하다는 의미는 수명 event도 함께 건너뛸 수 있다는 뜻이 아닙니다. spawn과 remove를 순서대로 적용한 뒤 해당 수명에 맞는 최신 상태를 사용해야 합니다.

8.3 큰 snapshot은 완성된 묶음으로 적용한다

여러 플레이어의 긴 몸체 상태를 모두 합치면 애플리케이션 packet 하나의 payload 한도를 넘을 수 있습니다. 현재 상태 packet의 최대 payload는 8,186 bytes이므로, 서버는 하나의 snapshot publication을 여러 chunk로 나누고 chunk 하나를 packet 하나에 담아 전송합니다.

즉, packet 안에 여러 chunk가 들어가는 구조가 아니라 여러 packet이 하나의 snapshot을 구성하는 구조입니다. 각 packet에는 한 chunk의 상태 record와 함께 같은 snapshot을 구성하는지 확인할 수 있는 식별자, 서버 tick, 전체 chunk 수와 현재 chunk의 index가 포함됩니다.

Snapshot 81
    Packet A [Chunk 0 / 3]
    Packet B [Chunk 1 / 3]
    Packet C [Chunk 2 / 3]
-> 세 packet의 chunk가 모두 유효할 때 한 번에 commit

여기서 packet은 TCP가 물리적으로 나누는 segment가 아니라 프로젝트 protocol의 application packet을 뜻합니다. 하나의 application packet이 여러 TCP 수신으로 나뉘거나 여러 packet이 한 번에 수신되더라도, network runtime의 framing 단계가 packet 경계를 먼저 복원한 뒤 클라이언트에 전달합니다.

클라이언트가 첫 번째 chunk부터 화면에 반영하면 같은 frame에 일부 플레이어는 새 상태이고 나머지는 이전 상태인 장면이 만들어집니다. 특히 하나의 몸체를 구성하는 머리와 trail이 서로 다른 tick에서 왔다면 연결이 끊어질 수 있습니다.

그래서 수신한 chunk를 곧바로 replica에 적용하지 않고 snapshot 식별자별 임시 공간에 보관합니다. 도착 순서가 바뀌어도 chunkIndex가 가리키는 위치에 넣기 때문에 조립할 수 있습니다. 모든 위치가 채워진 뒤에만 index 순서대로 하나의 상태 묶음을 만듭니다.

pending.Chunks[chunkIndex] = chunk;
++pending.ReceivedCount;

if (pending.ReceivedCount != pending.Chunks.Length)
{
    return ChunkGroupAcceptResult.Buffered;
}

for (int index = 0; index < pending.Chunks.Length; ++index)
{
    orderedChunks.Add(pending.Chunks[index]!);
}ChunkGroupAssembler.cs

단순히 chunk 개수만 채우는 것으로는 충분하지 않습니다. 같은 묶음에 포함된 모든 chunk의 snapshot 식별자, server tick과 전체 chunk 수가 같아야 합니다. 같은 index의 내용이 완전히 같은 중복 packet은 이미 받은 것으로 취급하지만, 같은 index에 다른 내용이 들어오면 어느 쪽이 올바른지 판단할 수 없으므로 전체 묶음을 유효하지 않은 상태로 처리합니다.

조립이 끝난 뒤에도 모든 chunk에 걸쳐 같은 객체 식별자와 generation이 두 번 포함되었는지 확인합니다. 중복 객체가 있다면 한 snapshot 안에 서로 다른 두 상태가 존재하는 셈이므로 commit하지 않습니다.

ClientWorldEntityKey key =
    new ClientWorldEntityKey(record.EntityId, record.Generation);

if (!entityKeys.Add(key))
{
    return false;
}RemoteEntityStateGroupAssembler.cs

더 최신 snapshot이 도착하면 완성되지 않은 이전 snapshot을 계속 기다리지 않습니다. 이전 임시 묶음을 버리고 최신 식별자를 위한 조립을 시작하며, 이미 commit한 식별자보다 오래된 chunk는 stale 상태로 폐기합니다. 주기적으로 대체할 수 있는 상태가 유실되었을 때 과거 화면을 완성하느라 최신 상태의 적용까지 늦추지 않기 위한 선택입니다.

완성된 묶음을 replica에 적용할 때도 두 단계를 사용합니다. 먼저 묶음 안의 모든 객체가 현재 존재하는지, 플레이어의 전신 상태인지, 해당 객체가 마지막으로 보관한 상태보다 오래된 server tick은 아닌지 전체를 검사합니다.

여기서 비교 기준은 5.3절에서 계산한 추정한 현재 서버 시점이 아닙니다. 전신 snapshot을 이미 받은 객체라면 그 객체의 최신 snapshot에 기록된 server tick과 비교합니다. 아직 전신 snapshot이 없다면 객체를 생성한 spawn packet의 server tick을 최초 기준으로 사용합니다.

uint latestServerTick = LatestSnapshot.HasValue
    ? LatestSnapshot.Value.ServerTick
    : initialServerTick; // replica를 생성한 spawn의 server tick

return serverTick >= latestServerTick;RemoteWholeBodySnapshotHistory.cs

예를 들어 어떤 replica가 tick 120에 생성되고 전신 상태를 tick 124, 128 순서로 받았다면 다음 묶음은 128 이상이어야 합니다. tick 127은 과거 상태이므로 거부하지만, tick 128은 같은 시점의 snapshot을 교체할 수 있도록 허용합니다. 묶음의 server tick 하나를 포함된 모든 replica의 기준과 비교하며, 하나라도 조건을 만족하지 않으면 아무 상태도 변경하지 않습니다. 검사를 모두 통과한 뒤에야 각 객체의 snapshot history에 같은 publication을 반영합니다.

chunk 수신
-> 같은 snapshot의 임시 공간에 보관
-> 모든 chunk와 metadata 완성
-> 중복 객체와 전체 replica 조건 검증
-> 하나의 상태 묶음으로 commit
-> 다음 화면 frame에서 공개

이 경계 덕분에 클라이언트는 완성된 이전 snapshot 또는 완성된 다음 snapshot 중 하나만 관찰합니다. 조립 중인 절반짜리 상태는 보간 history와 화면 양쪽에 노출되지 않습니다.

8.4 네트워크 처리 예산과 화면 반영 경계를 분리한다

network runtime은 수신 완료를 queue에 넣지만, background thread에서 화면 객체를 직접 만들거나 제거하지 않습니다. Godot main thread가 frame 시작 시 queue에서 event를 꺼내 protocol을 해석하고 클라이언트의 논리 상태를 변경합니다.

즉, background thread가 packet의 gameplay 처리를 끝낸 뒤 결과 묶음을 main thread에 넘기는 구조는 아닙니다. thread 사이를 건너가는 것은 처리 결과가 아니라 수신 event입니다. 수신 byte에서 application packet의 경계를 복원하고 event를 queue에 게시하는 데까지는 network runtime이 담당하고, packet decode부터 snapshot 조립, replica 변경과 scene 반영까지는 Godot main thread가 같은 frame 호출 안에서 순서대로 수행합니다.

Network Runtime thread
    socket 수신 -> packet framing -> event queue에 게시

Godot main thread
    event를 최대 64개 꺼냄
    -> packet decode
    -> client model 변경
    -> 최종 model에서 표시 상태 계산
    -> Godot scene 갱신

따라서 별도의 64개 처리 결과 객체를 main thread로 전달하지 않습니다. main thread가 직접 event를 처리해 자신이 소유한 client model을 변경하고, drain loop가 끝나면 같은 model을 읽어 화면을 그립니다.

queue가 한 번에 많이 쌓였을 때 모두 처리하면 입력, 예측과 화면 갱신이 같은 frame에서 밀릴 수 있습니다. 현재 클라이언트는 한 frame에 최대 64개의 network event만 처리하고, 남은 event는 다음 frame에서 이어서 처리합니다.

int drainedEventCount = 0;
while (drainedEventCount < maxEventsPerFrame &&
       transport.TryPopEvent(out RemoteGameplayTransportEvent? transportEvent))
{
    ++drainedEventCount;
    ProcessTransportEvent(transportEvent, nowSeconds);
}RemoteGameplaySession.cs

main thread는 queue가 빌 때까지, 또는 최대 64개의 event를 꺼낼 때까지 packet을 순서대로 처리해 client model에 적용합니다. 64개가 모이기를 기다리는 것은 아니므로 queue에 event가 10개만 있다면 10개만 처리합니다.

처리가 끝나면 packet마다 화면을 따로 갱신하지 않고, 해당 frame의 최종 client model을 Godot scene에 한 번 반영합니다. 64개를 넘은 나머지 event는 다음 frame에서 이어서 처리합니다.


9. 지연된 패킷과 재접속으로 이전 상태가 되살아나지 않게 한다

객체 ID는 서버가 실행되는 동안 다시 사용될 수 있습니다. 제거된 객체와 새로 생성된 객체가 같은 숫자 ID를 사용한다면, ID만으로는 늦게 처리된 상태가 어느 수명에 속하는지 구분하기 어렵습니다.

접속도 비슷합니다. 같은 클라이언트가 다시 연결되더라도 이전 접속에서 받은 상태를 새 게임의 기준으로 사용할 수 없습니다.

9.1 객체 ID와 세대 번호를 함께 사용한다

서버는 객체 ID와 함께 해당 ID가 몇 번째로 사용되는지를 나타내는 generation을 전달합니다.

Entity 42 / Generation 3 제거
Entity 42 / Generation 4 생성
Entity 42 / Generation 3 상태 도착
-> 현재 객체와 다른 수명이므로 폐기

클라이언트는 ID와 generation을 하나의 key로 사용해 replica를 찾습니다. 같은 ID라도 generation이 다르면 이전 객체의 snapshot history, 화면 효과와 조작 상태를 새 객체에 적용하지 않습니다.

generation은 네트워크 지연 시간을 계산하는 값이 아니라 객체 수명을 구분하는 값입니다. 이를 통해 ID 재사용과 늦게 소비된 application event가 결합해 유령 객체를 만드는 것을 막습니다.

9.2 제거와 재생성을 하나의 이동으로 취급하지 않는다

캐릭터가 사망한 뒤 같은 플레이어를 위한 새 캐릭터가 생성될 수 있습니다. 이때 이전 위치에서 새 위치까지 보간하면 화면에는 맵을 가로지르는 빠른 이동처럼 보입니다.

새 generation은 이전 위치의 연속이 아닙니다. 클라이언트는 이전 예측과 snapshot history를 제거하고 새로운 spawn 상태에서 화면 객체를 시작합니다. 사용자가 조작하는 대상도 서버가 새 identity를 확정한 뒤 다시 연결합니다.

9.3 disconnect 요청과 상태 정리 시점을 구분한다

클라이언트가 disconnect를 요청했다고 해서 network event가 즉시 모두 사라지는 것은 아닙니다. 이미 전달 중이던 event나 마지막 서버 결과가 main thread queue에 남아 있을 수 있습니다.

따라서 접속 종료를 요청한 시점과 실제 종료 event를 소비한 시점을 구분합니다. 실제 종료를 확인하면 해당 접속에 속한 gameplay 상태를 정리합니다.

정리 대상에는 다음 정보가 포함됩니다.

새 접속은 새로운 generation과 초기 snapshot에서 시작합니다. 이전 접속의 상태를 이어서 사용하면 새 서버 상태가 도착하기 전에 오래된 캐릭터와 점수가 화면에 나타날 수 있습니다.

9.4 화면 flow와 접속 수명은 같지 않을 수 있다

라운드 결과 화면은 network connection이 종료된 뒤에도 사용자에게 보여줄 필요가 있습니다. 반대로 접속이 활성 상태여도 아직 조작할 캐릭터가 생성되지 않았다면 gameplay 화면으로 바로 전환할 수 없습니다.

따라서 접속 상태 하나를 화면 상태 하나와 동일하게 취급하지 않습니다. 게임 진입, 캐릭터 생성 대기, 플레이, 결과와 오류 화면은 서버에서 받은 lifecycle event와 로컬 UI 선택을 함께 사용해 전환합니다.

이 분리는 reconnect 시 network 상태를 깨끗하게 정리하면서도 사용자에게 필요한 결과 화면을 유지할 수 있게 합니다.


10. 입력에서 화면까지 하나의 흐름으로 연결한다

앞에서 살펴본 각 메커니즘은 서로 독립적인 기능이 아닙니다. 한 번의 사용자 입력이 서버 결과와 다른 사용자의 화면에 도달하는 전체 경로 안에서 연결됩니다.

두 플레이어가 같은 공간에서 움직이는 상황을 예로 들 수 있습니다. 플레이어 A는 자신이 조작하는 캐릭터를 보고 있고, 플레이어 B는 A를 주변 객체로 관찰하고 있습니다.

10.1 입력 직후에는 로컬 화면만 먼저 움직인다

  1. 플레이어 A가 이동 방향을 입력합니다.
  2. A의 클라이언트는 현재 입력으로 머리 위치를 먼저 예측합니다.
  3. A의 화면은 서버 응답 전에 움직이기 시작합니다.
  4. 동시에 입력과 순서 번호가 서버로 전달됩니다.

이 시점의 화면 위치는 A의 클라이언트에만 있는 임시 표현입니다. B의 화면이나 서버 상태에는 아직 반영되지 않았습니다.

10.2 서버가 이동과 충돌 결과를 확정한다

  1. 서버는 다음 tick에서 A의 입력을 읽습니다.
  2. 입력이 현재 session과 캐릭터에 유효한지 확인합니다.
  3. 이동 경로를 계산하고 Dynamic AABB Tree에서 충돌 후보를 조회합니다.
  4. 후보에 대해서만 정확한 형상 충돌을 계산합니다.
  5. 충돌, 자원 획득과 생존 여부를 포함한 최종 상태를 commit합니다.

충돌 후보 탐색과 실제 판정이 끝날 때까지 A의 예측 위치는 서버 결과에 영향을 주지 않습니다. 서버는 자신이 보관한 이전 상태와 입력을 사용해 결과를 계산합니다.

10.3 관심 영역에 따라 사용자별 전송 상태를 만든다

  1. 서버는 commit된 위치를 공간 인덱스에 반영합니다.
  2. A와 B의 관심 영역을 각각 조회합니다.
  3. A에게는 자신이 조작하는 캐릭터의 authoritative 상태를 보냅니다.
  4. B의 관심 영역에 A가 포함되면 A의 최신 상태를 B에게 보냅니다.
  5. A가 B의 관심 영역에 처음 들어왔다면 초기 상태를 함께 보냅니다.

서버는 같은 World 결과를 기준으로 하지만 사용자마다 필요한 상태 묶음은 다르게 만듭니다. 멀리 떨어져 A를 관찰할 수 없는 플레이어에게는 A의 긴 몸체 상태를 보낼 필요가 없습니다.

10.4 각 클라이언트가 목적에 맞게 화면을 갱신한다

  1. A의 클라이언트는 예측 위치와 서버 위치를 비교합니다.
  2. 작은 차이는 화면에서 부드럽게 보정하고 큰 차이는 즉시 맞춥니다.
  3. B의 클라이언트는 A의 상태를 snapshot history에 추가합니다.
  4. B의 화면은 과거 두 서버 상태 사이에서 A의 움직임을 보간합니다.
%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart TB
  Input["Player A 입력"] --> Prediction["A · 로컬 prediction"]
  Input -->|"입력 전송"| Server["서버 · 이동과 충돌 판정"]
  Server --> Commit["Canonical commit<br/>상태 확정"]
  Commit --> AOI["Replication<br/>본인 state · AOI 대상"]
  AOI --> Reconcile["A · reconciliation"]
  AOI --> Interpolate["B · interpolation"]
  Prediction -->|"서버 상태와 비교·보정"| Reconcile

A와 B의 화면 갱신 방식은 다르지만 두 화면의 기준은 같은 서버 상태입니다. A는 자신의 입력을 알고 있기 때문에 먼저 예측할 수 있고, B는 서버가 전달한 상태 사이를 연결합니다.

이 흐름에서 클라이언트 예측은 네트워크 지연을 없애지 않고, 충돌 후보 탐색과 관심 영역도 클라이언트 화면을 직접 부드럽게 만들지 않습니다. 각 메커니즘은 전체 지연 경로에서 서로 다른 부분을 담당합니다.


정리하며

온라인 게임의 지연은 network ping 하나가 아니라 입력 전송, 서버 tick, gameplay 계산, 상태 복제와 화면 갱신이 합쳐진 결과입니다. 물리적인 전송 시간을 완전히 제거할 수 없기 때문에 클라이언트와 서버가 서로 다른 부분을 담당해야 합니다.

클라이언트는 사용자의 현재 입력을 알고 있는 캐릭터를 먼저 예측하고, 서버 상태가 도착하면 논리 상태를 교정합니다. 다른 플레이어는 입력을 알 수 없으므로 서버 snapshot 사이를 보간합니다. 이 과정에서 화면은 부드럽게 표현할 수 있지만 이동, 충돌, 점수와 수명은 서버가 확정합니다.

서버는 Dynamic AABB Tree로 충돌 가능성이 있는 후보를 먼저 줄이고, 정확한 형상 검사는 후보에 대해서만 수행합니다. 사용자별 상태 전송에는 별도의 Uniform Grid와 관심 영역을 사용해 주변 객체만 상세 복제 대상으로 선택합니다.

핵심 내용을 정리하면 다음과 같습니다.

결국 목표는 클라이언트가 서버를 대신하게 만드는 것이 아닙니다. 서버가 최종 상태를 일관되게 확정하는 동안, 클라이언트는 기다리는 시간을 사용자가 받아들일 수 있는 움직임으로 표현하고 서버는 결과를 만드는 데 필요한 작업을 줄이는 것입니다.


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

다음 글
[C++] Service Host: 패킷과 서비스 로직을 연결하는 공통 파이프라인