
클라이언트 예측과 랙 보정으로 이동 패킷을 부드럽게 만드는 법
입력 기반 클라이언트 예측과 서버 권위, 스냅샷 보간, 과거 시점 판정을 함께 설계해 지연 환경에서도 자연스러운 이동을 구현한다.
왜 이동이 끊겨 보일까
멀티플레이어 게임에서 클라이언트가 이동 요청을 서버에 보내고 서버의 응답을 받은 뒤에야 캐릭터를 움직이면 왕복 지연 시간만큼 조작이 늦어진다. 왕복 지연이 80ms라면 버튼을 누른 뒤 최소 80ms가 지나야 화면의 캐릭터가 반응한다. 패킷 손실이나 지터까지 더해지면 이동도 계단처럼 끊긴다.
이 문제를 해결할 때 중요한 원칙은 두 가지다. 최종 상태는 서버가 결정하지만 로컬 플레이어에게 보이는 즉각적인 반응은 클라이언트가 먼저 제공해야 한다. 이를 위해 클라이언트 예측(Client Prediction), 서버 조정(Server Reconciliation), 스냅샷 보간(Interpolation), 랙 보정(Lag Compensation)을 역할에 맞게 조합한다.

전체 흐름
클라이언트는 위치 자체보다 방향, 점프, 입력 시퀀스 번호 같은 입력 명령을 서버로 보낸다. 같은 물리 규칙으로 로컬에서 즉시 시뮬레이션한 결과는 임시 화면 상태다. 서버는 입력을 검증하고 권위 있는 상태를 스냅샷으로 돌려준다.
flowchart LR
A[로컬 입력 생성] --> B[입력 번호 부여 후 서버 전송]
A --> C[클라이언트에서 즉시 예측 시뮬레이션]
B --> D[서버 권위 시뮬레이션]
D --> E[권위 상태와 마지막 처리 입력 번호 전송]
E --> F[클라이언트 조정]
F --> G[확정 상태부터 미처리 입력 재실행]
G --> C
다른 플레이어는 일반적으로 예측하지 않는다. 서버가 보낸 여러 스냅샷을 잠시 버퍼에 쌓고 약간 과거의 두 상태 사이를 보간해 보여 준다. 이 작은 표시 지연은 네트워크 도착 간격의 흔들림을 흡수하는 대가다.
입력을 보내고 로컬에서 먼저 실행하기
입력 패킷에는 고유하게 증가하는 시퀀스 번호를 넣는다. 서버와 클라이언트가 같은 틱 간격과 이동 규칙을 사용할수록 조정량이 작아진다. 다만 부동소수점 연산, 충돌 판정 순서, 서로 다른 프레임 속도 때문에 완전히 동일한 결과를 기대해서는 안 된다.
public struct MoveInput
{
public uint Sequence;
public Vector2 Move;
public bool Jump;
}
void FixedUpdate()
{
MoveInput input = new MoveInput
{
Sequence = nextSequence++,
Move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")),
Jump = Input.GetButton("Jump")
};
pendingInputs.Enqueue(input);
SimulateMove(ref predictedState, input, Time.fixedDeltaTime);
SendToServer(input);
}
SimulateMove는 클라이언트와 서버가 공유하는 순수한 함수에 가깝게 만드는 편이 좋다. 입력을 읽거나 네트워크를 전송하는 코드, 애니메이션 재생, 이펙트 생성까지 이 함수에 넣으면 재실행 과정에서 부작용이 반복된다.
void SimulateMove(ref PlayerState state, MoveInput input, float dt)
{
Vector3 direction = new Vector3(input.Move.x, 0f, input.Move.y);
direction = Vector3.ClampMagnitude(direction, 1f);
state.Velocity.x = direction.x * moveSpeed;
state.Velocity.z = direction.z * moveSpeed;
if (input.Jump && state.IsGrounded)
state.Velocity.y = jumpSpeed;
state.Velocity.y += gravity * dt;
state.Position += state.Velocity * dt;
}
실제 프로젝트에서는 충돌 처리, 경사면, 이동 발판, 넉백처럼 결정성을 깨기 쉬운 요소가 많다. 따라서 클라이언트 예측은 서버와의 오차를 없애는 장치가 아니라 오차를 빠르게 숨기고 수정하기 위한 장치로 이해하는 편이 정확하다.
서버 응답을 받았을 때 조정하기
서버 스냅샷에는 권위 있는 상태와 서버가 마지막으로 처리한 입력 번호를 포함한다. 클라이언트는 그 번호 이하의 입력을 큐에서 제거하고 서버 상태를 기준으로 남은 입력을 다시 실행한다. 이것이 서버 조정이다.
void OnServerSnapshot(ServerSnapshot snapshot)
{
while (pendingInputs.Count > 0 && pendingInputs.Peek().Sequence <= snapshot.LastProcessedSequence)
pendingInputs.Dequeue();
predictedState = snapshot.State;
foreach (MoveInput input in pendingInputs)
SimulateMove(ref predictedState, input, fixedDeltaTime);
}
위치만 서버 값으로 덮어쓰면 캐릭터가 자주 뒤로 튀는 현상이 생긴다. 반대로 오차를 무조건 무시하면 치트나 충돌 차이로 인한 상태 불일치가 누적된다. 보통은 오차 크기에 따라 처리 방식을 나눈다.
- 매우 작은 오차는 다음 렌더링 프레임들에 걸쳐 시각 위치만 부드럽게 보정한다.
- 일정 기준을 넘는 오차는 논리 상태를 즉시 서버 상태로 되돌린 뒤 미처리 입력을 재실행한다.
- 텔레포트, 강한 넉백, 사망처럼 불연속적인 사건은 부드럽게 처리하지 않고 즉시 확정한다.
시각 보정은 렌더링 전용 오프셋으로 분리하면 물리 상태를 오염시키지 않는다. 예측된 논리 위치는 정확히 조정하고 화면에 표시할 모델만 짧은 시간 동안 목표 위치로 보간한다.
원격 플레이어는 스냅샷을 보간한다
원격 플레이어의 최신 스냅샷을 곧바로 그리면 패킷 도착 시점마다 위치가 갱신되어 끊겨 보인다. 대신 예를 들어 100ms 정도 과거 시점을 렌더링 대상으로 잡고 그 시점을 감싸는 두 스냅샷 사이를 보간한다.
Vector3 InterpolatePosition(Snapshot a, Snapshot b, double renderTime)
{
double span = b.ServerTime - a.ServerTime;
float t = span <= 0.0 ? 0f : (float)((renderTime - a.ServerTime) / span);
return Vector3.Lerp(a.Position, b.Position, Mathf.Clamp01(t));
}
보간 지연은 고정값으로 시작하되 실제 지터가 큰 환경에서는 최근 패킷 간격을 측정해 조절할 수 있다. 너무 짧으면 다음 스냅샷이 도착하기 전에 렌더링 시간이 따라잡혀 멈춤이 생기고 너무 길면 다른 플레이어가 눈에 띄게 늦게 보인다.
스냅샷이 잠시 끊겼을 때 무한히 마지막 속도로 외삽하면 벽을 통과하거나 크게 어긋날 수 있다. 외삽은 짧은 상한 시간으로 제한하고 그 뒤에는 마지막 상태를 유지하거나 애니메이션만 완만하게 감속하는 방식이 안전하다.
랙 보정은 과거의 월드를 판정한다
클라이언트 예측은 주로 내 이동의 조작감을 위한 기술이다. 반면 랙 보정은 사격이나 근접 공격처럼 서버 판정이 필요한 상호작용에서 중요하다.
플레이어 A가 화면에서 적을 조준해 발사했더라도 발사 입력이 서버에 도착할 때까지 적은 이미 이동해 있다. 서버가 현재 위치만 기준으로 판정하면 A는 분명 맞혔다고 보았는데 빗나간 결과를 받는다. 서버는 입력이 발생했을 것으로 추정되는 과거 시점으로 다른 플레이어의 히트박스를 되감아 판정할 수 있다.
bool ValidateShot(ShotCommand shot, ClientConnection client)
{
double shotTime = EstimateServerTime(client, shot.ClientTick);
double rewindTime = ClampToHistory(shotTime);
using (RewindWorldTo(rewindTime))
{
Ray ray = BuildValidatedAimRay(shot);
return Physics.Raycast(ray, out RaycastHit hit, maxRange, hitMask);
}
}
여기서 shot.ClientTick을 그대로 신뢰하면 안 된다. 서버는 연결별 지연 추정치, 허용 가능한 과거 기록 범위, 무기 발사 간격, 최대 사거리와 시야 방향을 함께 검증해야 한다. 되감기는 판정용 충돌체에만 적용하고 게임 세계의 실제 시뮬레이션 상태를 과거로 되돌려 다른 시스템을 실행해서는 안 된다.
구현할 때 자주 놓치는 점
틱과 렌더링 시간을 분리한다
네트워크 시뮬레이션은 일정한 틱으로 수행하고 화면 렌더링은 별도의 프레임 속도로 수행한다. 입력에는 틱 또는 시퀀스 번호를 기록하고 스냅샷에는 서버 시간을 기록해야 재실행과 보간의 기준이 명확해진다.
중복과 순서 뒤바뀜을 전제로 한다
신뢰성 없는 전송에서는 패킷이 유실되거나 순서가 바뀔 수 있다. 오래된 스냅샷으로 최신 상태를 덮어쓰지 않도록 서버 틱이나 스냅샷 번호를 비교한다. 입력은 서버가 이미 처리한 시퀀스 번호를 기록해 중복 적용을 막는다.
게임 규칙과 연출을 분리한다
입력 재실행 중에 발소리, 먼지 이펙트, 카메라 흔들림을 매번 생성하면 조정이 일어날 때 연출이 중복된다. 확정된 이벤트에만 연출을 재생하거나 이벤트 ID를 두어 이미 처리한 연출을 걸러낸다.
측정값으로 기준을 정한다
보정 임계값, 보간 지연, 외삽 상한은 감으로 고정하지 않는다. 개발 환경에서 지연과 지터, 손실을 인위적으로 넣고 다음 값을 기록한다.
- 예측 상태와 서버 상태의 평균 및 최대 거리 오차
- 초당 조정 횟수와 큰 순간 이동 횟수
- 스냅샷 버퍼가 비는 시간
- 사격 명중 판정이 클라이언트 시점과 달라지는 비율
정리
부드러운 네트워크 이동은 패킷을 더 자주 보내는 것만으로 해결되지 않는다. 로컬 플레이어는 입력을 즉시 예측하고 서버 응답이 오면 마지막 확정 입력 이후의 명령을 재실행해 조정한다. 원격 플레이어는 스냅샷 보간으로 지터를 숨기며 사격 같은 상호작용은 서버가 제한된 과거 기록을 사용해 판정한다.
먼저 단순한 평면 이동과 고정 틱에서 입력 시퀀스, 서버 스냅샷, 재실행을 구현해 보자. 그 뒤 충돌과 점프, 이동 발판을 하나씩 추가하면서 오차와 조정 횟수를 관찰하면 복잡한 시스템도 훨씬 안전하게 확장할 수 있다.


