
데드레커닝으로 몬스터와 다른 유저의 위치를 부드럽게 동기화하기
데드레커닝은 수신한 위치·속도 정보를 바탕으로 다음 위치를 예측해 네트워크 지연과 패킷 누락을 감추는 기술이다. 보정 오차를 측정하고 스냅·보간·완화 보정을 나누는 실용적인 구현 방법을 정리한다.
왜 위치 동기화만으로는 충분하지 않을까
멀티플레이어 게임에서 서버가 모든 엔티티의 위치를 매 프레임 전송하기는 어렵다. 전송 주기를 낮추면 대역폭은 절약되지만 다른 유저와 몬스터가 끊어져 움직인다. 반대로 전송 주기를 높이면 접속자 수와 엔티티 수에 따라 서버와 네트워크 비용이 빠르게 커진다.
데드레커닝은 마지막으로 받은 상태를 기준으로 클라이언트가 현재 위치를 예측하는 방법이다. 서버가 다음 상태를 전달하기 전까지도 화면에서는 엔티티가 계속 움직인다. 다만 예측은 어디까지나 추정이므로 서버 상태가 도착했을 때 오차를 계산하고 자연스럽게 보정해야 한다.

기본 예측식
가장 단순한 모델은 등속 운동이다. 마지막 스냅샷의 위치를 p0, 속도를 v, 스냅샷 이후 경과 시간을 t라고 하면 예측 위치는 다음과 같다.
가속도를 신뢰할 수 있고 차량처럼 운동 특성이 비교적 연속적인 대상이라면 가속도까지 포함할 수 있다.
그러나 일반적인 몬스터와 플레이어에는 등속 모델부터 적용하는 편이 안전하다. 급회전, 넉백, 경로 재탐색, 이동기처럼 예측을 크게 틀리게 만드는 이벤트가 많기 때문이다. 복잡한 식이 항상 더 좋은 결과를 주는 것은 아니다. 서버가 보내는 값과 클라이언트가 실제로 신뢰할 수 있는 값만 모델에 넣어야 한다.
스냅샷에 담을 정보
보통 서버는 일정 주기마다 엔티티별 상태 스냅샷을 보낸다. 위치만 보내면 클라이언트가 다음 움직임을 추정할 근거가 부족하므로 최소한 속도와 서버 시간을 함께 전송하는 것이 좋다.
public struct EntitySnapshot
{
public uint Sequence;
public double ServerTime;
public Vector3 Position;
public Vector3 Velocity;
public float Yaw;
public bool Teleported;
}
Sequence은 늦게 도착한 패킷을 버리는 데 사용한다. UDP에서는 오래된 스냅샷이 최신 스냅샷보다 뒤늦게 도착할 수 있다. ServerTime은 수신 시점의 로컬 시간이 아니라 서버 기준으로 얼마나 시간이 흘렀는지 계산하는 기준이다.
Teleported처럼 예측을 무효화하는 명시적 상태도 중요하다. 워프, 부활, 맵 이동, 강한 넉백처럼 연속 보정으로 숨기면 오히려 잘못 보이는 변화는 즉시 위치를 맞추는 편이 낫다.
수신부터 화면 반영까지의 흐름
새 스냅샷을 받았다고 즉시 렌더링 위치를 덮어쓰면 패킷 도착 간격에 따라 화면이 튄다. 수신 상태와 화면에 표시하는 상태를 분리해야 한다.
flowchart LR
A[서버 스냅샷 수신] --> B{순번이 최신인가}
B -- 아니오 --> X[폐기]
B -- 예 --> C[서버 시간 기준 목표 위치 추정]
C --> D[현재 표시 위치와 오차 계산]
D --> E{오차가 임계값보다 큰가}
E -- 예 --> F[즉시 스냅 또는 텔레포트 처리]
E -- 아니오 --> G[일정 시간에 걸쳐 보정]
G --> H[렌더링 위치 갱신]
여기서 목표 위치는 수신한 스냅샷의 Position 자체가 아니라 그 스냅샷이 만들어진 뒤 현재까지 진행됐을 것으로 추정한 위치다. 클라이언트가 서버와의 시간 차이를 추정할 수 있다면 다음처럼 계산한다.
시간 동기화가 아직 없다면 수신 시점부터의 로컬 경과 시간을 임시로 쓸 수 있다. 다만 왕복 지연 시간이 변하면 오차가 커지므로 실서비스에서는 서버 시간 추정값을 유지하는 편이 좋다.
보정은 오차 크기에 따라 다르게 처리한다
예측 위치와 서버 기준 목표 위치의 차이를 보정 오차라고 하자.
오차가 작을 때는 몇 프레임에 걸쳐 목표 위치로 가까워지게 만든다. 즉시 이동시키는 방식보다 시각적으로 부드럽다. 오차가 아주 크면 긴 보정 구간 동안 잘못된 위치를 보여 주게 되므로 즉시 스냅하는 편이 낫다.
using UnityEngine;
public sealed class RemoteEntityMotion : MonoBehaviour
{
[SerializeField] private float correctionTime = 0.12f;
[SerializeField] private float snapDistance = 3.0f;
private Vector3 renderedPosition;
private Vector3 correctionVelocity;
private Vector3 latestPosition;
private Vector3 latestVelocity;
private double latestServerTime;
private bool hasSnapshot;
public void ApplySnapshot(EntitySnapshot snapshot)
{
if (snapshot.Teleported ||
Vector3.Distance(renderedPosition, snapshot.Position) > snapDistance)
{
renderedPosition = snapshot.Position;
correctionVelocity = Vector3.zero;
}
latestPosition = snapshot.Position;
latestVelocity = snapshot.Velocity;
latestServerTime = snapshot.ServerTime;
hasSnapshot = true;
}
public void RenderTick(double estimatedServerNow)
{
if (!hasSnapshot)
return;
float elapsed = Mathf.Max(0f, (float)(estimatedServerNow - latestServerTime));
Vector3 target = latestPosition + latestVelocity * elapsed;
float errorDistance = Vector3.Distance(renderedPosition, target);
if (errorDistance > snapDistance)
{
renderedPosition = target;
correctionVelocity = Vector3.zero;
}
else
{
renderedPosition = Vector3.SmoothDamp(
renderedPosition,
target,
ref correctionVelocity,
correctionTime);
}
transform.SetPositionAndRotation(
renderedPosition,
Quaternion.Euler(0f, transform.eulerAngles.y, 0f));
}
}
위 예제의 SmoothDamp는 위치 보정을 완화하는 용도다. 이동 자체는 스냅샷의 속도 기반 예측으로 이어지고 새 스냅샷이 도착할 때 생긴 차이만 부드럽게 줄인다. 단순히 매 프레임 Lerp 비율을 고정하면 프레임률에 따라 보정 감각이 달라질 수 있으므로 시간 기반 함수나 감쇠 함수를 사용하는 편이 낫다.

몬스터와 다른 유저는 같은 방식으로 다루되 기준은 다르게 둔다
원격 플레이어는 입력 지연, 회피기, 점프, 충돌 보정처럼 급격한 상태 변화가 많다. 따라서 스냅 거리와 보정 시간을 보수적으로 잡고 이동 모드와 지상 여부 같은 상태를 함께 동기화하는 것이 좋다.
몬스터는 서버 AI가 이동을 결정하는 경우가 많다. 서버가 내비게이션 결과를 매번 보내기보다 위치와 속도, 행동 상태를 보내고 클라이언트가 데드레커닝으로 보간하는 편이 효율적이다. 다만 공격 시작, 피격 경직, 돌진 종료처럼 판정과 연출이 밀접한 시점은 별도 이벤트 또는 상태 전환으로 전달해야 한다. 위치 예측만으로 공격 타이밍까지 맞추려 하면 애니메이션과 판정이 어긋날 수 있다.
다음처럼 엔티티 종류별 정책을 분리하면 조정이 편하다.
public readonly struct MotionTuning
{
public readonly float CorrectionTime;
public readonly float SnapDistance;
public MotionTuning(float correctionTime, float snapDistance)
{
CorrectionTime = correctionTime;
SnapDistance = snapDistance;
}
}
MotionTuning GetTuning(EntityKind kind) => kind switch
{
EntityKind.Player => new MotionTuning(0.10f, 2.5f),
EntityKind.FastMonster => new MotionTuning(0.08f, 3.5f),
EntityKind.SlowMonster => new MotionTuning(0.16f, 2.0f),
_ => new MotionTuning(0.12f, 3.0f)
};
수치는 예시일 뿐이다. 실제 값은 이동 속도, 스냅샷 전송 주기, 평균 지연 시간, 카메라 거리에서 보이는 민감도를 기준으로 측정해 정해야 한다. 빠른 몬스터일수록 같은 전송 간격에서 오차가 더 크게 쌓이므로 스냅 거리만 키우기보다 전송 주기나 상태 이벤트 설계도 함께 검토해야 한다.
회전과 애니메이션도 따로 보정한다
위치와 회전을 같은 규칙으로 처리하면 회전이 늦게 따라오거나 급하게 꺾일 수 있다. 회전은 대개 Quaternion.Slerp 또는 각속도 제한을 이용해 별도로 보정한다.
private Quaternion renderedRotation;
private void CorrectRotation(float targetYaw, float deltaTime)
{
Quaternion targetRotation = Quaternion.Euler(0f, targetYaw, 0f);
float turnSpeed = 720f;
renderedRotation = Quaternion.RotateTowards(
renderedRotation,
targetRotation,
turnSpeed * deltaTime);
transform.rotation = renderedRotation;
}
애니메이션 파라미터는 렌더링 위치의 프레임별 차이보다 서버 스냅샷의 속도 크기나 동기화된 이동 상태를 우선하는 편이 안정적이다. 보정 때문에 위치가 순간적으로 빨라져도 달리기 애니메이션이 불필요하게 튀는 일을 줄일 수 있다.
자주 발생하는 문제와 점검 항목
패킷 순서가 뒤바뀌는 문제
마지막으로 적용한 Sequence보다 작은 스냅샷은 버린다. 오래된 위치를 다시 적용하면 엔티티가 뒤로 되돌아가는 현상이 생긴다.
모든 오차를 부드럽게 처리하는 문제
서버 판정으로 강제 이동했는데 클라이언트가 이전 위치에서 천천히 따라가면 충돌, 피격, 시야 판정의 인상이 어긋난다. 거리 임계값과 텔레포트 플래그를 두고 즉시 보정할 조건을 명확히 정한다.
예측 위치를 다시 네트워크 상태로 보내는 문제
클라이언트가 화면용으로 계산한 예측 위치를 권위 있는 상태처럼 서버에 재전송하면 오차가 누적될 수 있다. 서버 권위 구조에서는 서버가 확정한 위치와 속도를 기준으로만 스냅샷을 만들고 클라이언트 예측은 표시용 상태로 한정하는 편이 안전하다.
지연을 재현하지 않고 튜닝하는 문제
에디터의 로컬 환경에서는 대부분의 보정 문제가 보이지 않는다. 인위적인 지연, 지터, 패킷 손실을 넣고 다음 값을 기록해 보자.
- 스냅샷 수신 간격과 순서 역전 횟수
- 예측 오차 거리의 평균과 최댓값
- 스냅 발생 횟수
- 엔티티 종류별 보정 완료 시간
이 기록이 있어야 correctionTime과 snapDistance를 감각이 아니라 근거를 두고 조정할 수 있다.
정리
데드레커닝의 핵심은 마지막 서버 상태를 속도로 전진시켜 빈 전송 구간을 메우고 새 서버 상태가 도착할 때 예측 오차를 정책적으로 처리하는 데 있다. 작은 오차는 짧게 완화하고 큰 오차나 불연속 이동은 즉시 맞춘다.
먼저 위치, 속도, 서버 시간, 순번을 포함한 단순한 스냅샷 구조로 시작하자. 그다음 실제 지연 환경에서 오차와 스냅 횟수를 측정해 플레이어와 몬스터의 보정 기준을 나누면 과도한 패킷 전송 없이도 훨씬 안정적인 원격 이동을 만들 수 있다.


