24강지연과 예측
고무줄 현상은 어디서 오는가.
이 강의 목차 (6)
- 지연(latency)이 게임 경험에 어떻게 나타나는지 안다.
- 보간으로 남의 캐릭터를 부드럽게 만든다.
- 클라이언트 예측 + 서버 보정의 원리를 이해한다.
- 고무줄 현상을 직접 만들어 본다.
지연은 없앨 수 없다#
내가 보낸 것이 서버에 갔다가 답이 돌아오는 데 걸리는 시간.
| 상황 | RTT |
|---|---|
| 같은 PC (127.0.0.1) | 0~1ms |
| 같은 와이파이 | 5~20ms |
| 국내 서버 | 20~50ms |
| 일본/싱가포르 | 50~100ms |
| 미국 서부 | 130~180ms |
| 유럽 | 250~350ms |
| 모바일 LTE (혼잡) | 100~500ms, 불규칙 |
서울↔뉴욕은 빛의 속도로도 왕복 37ms입니다. 물리 법칙이라 줄일 수 없습니다.
순진한 구조에서 무슨 일이 일어나나#
23강에서 "클라이언트는 요청만, 서버가 결정"이라고 했습니다. 이걸 이동에 그대로 적용하면 이렇게 됩니다.
0ms [클라] 오른쪽 키를 누름 → 서버에 전송
75ms [서버] 받음. 이동 계산. 결과 전송
150ms [클라] 결과 도착. 이제서야 캐릭터가 움직임사람은 100ms 정도부터 "반응이 느리다"를 느낍니다. 150ms면 조작이 물속에서 하는 것처럼 느껴집니다.
절대 이렇게 만들면 안 됩니다.
해결 1 — 클라이언트 예측#
서버의 답을 기다리지 않고, 내 화면에서는 먼저 움직인다.
- 키를 누르면 즉시 내 캐릭터를 움직인다 (지연 0)
- 동시에 서버에 입력을 보낸다
- 서버의 답이 오면 내 예측과 비교한다
- 같으면 무시, 다르면 고친다
내비게이션에서 GPS 신호는 1초에 한 번 옵니다. 그런데 지도의 파란 점은 끊김 없이 부드럽게 움직이죠.
앱이 "지금 속도로 계속 간다면 여기쯤 있겠지"를 예측해서 그리고 있기 때문입니다. 실제 GPS가 오면 조용히 보정합니다. 이게 클라이언트 예측입니다.
해결 2 — 서버 보정 (Reconciliation)#
예측이 틀리면 어떻게 될까요? 그냥 서버 값으로 덮으면 캐릭터가 뒤로 순간이동합니다.
- 내가 보낸 모든 입력에 번호를 매겨 보관한다
- 서버가 "42번 입력까지 처리했고, 결과 위치는 여기"라고 답한다
- 내 42번 시점 예측과 비교한다
- 다르면 → 서버 위치에서 시작해 43번부터 지금까지의 입력을 다시 적용한다
extends CharacterBody2D
const SPEED := 300.0
var _seq := 0
var _pending: Array = [] # 서버 확인을 기다리는 입력들
func _physics_process(delta: float) -> void:
if not is_multiplayer_authority():
return
_seq += 1
var input := {
"seq": _seq,
"dir": Input.get_vector("move_left", "move_right",
"move_up", "move_down"),
"dt": delta,
}
# 1) 즉시 내 화면에서 적용 (예측)
_apply(input)
_pending.append(input)
# 2) 서버에 전송
send_input.rpc_id(1, input)
func _apply(input: Dictionary) -> void:
velocity = input.dir * SPEED
move_and_slide()
# ── 서버 쪽 ──────────────────
@rpc("any_peer", "unreliable_ordered")
func send_input(input: Dictionary) -> void:
if not multiplayer.is_server():
return
var sender := multiplayer.get_remote_sender_id()
if sender != get_multiplayer_authority():
return
# 서버도 같은 계산을 한다
_apply(input)
# 결과를 원래 주인에게 돌려준다
confirm.rpc_id(sender, input.seq, global_position)
# ── 클라이언트가 서버 답을 받음 ────────
@rpc("authority", "unreliable_ordered")
func confirm(seq: int, server_pos: Vector2) -> void:
# 확인된 것보다 오래된 입력은 버린다
while not _pending.is_empty() and _pending[0].seq <= seq:
_pending.pop_front()
# 예측과 서버가 크게 다르면 고친다
if global_position.distance_to(server_pos) < 2.0:
return # 오차 무시 (작으면 그냥 둔다)
global_position = server_pos
# 아직 확인 안 된 입력들을 다시 적용 (재시뮬레이션)
for input in _pending:
_apply(input)_apply(input)를 클라이언트와 서버가 똑같이 실행한다- 클라이언트는 아직 확인 안 된 입력을
_pending에 보관한다 - 어긋나면 서버 위치에서 다시 계산한다
같은 함수를 양쪽에서 쓰는 게 결정적입니다. 계산이 다르면 계속 어긋납니다. 25강의 결정론이 여기서 필요해집니다.
부동소수점 계산은 컴퓨터마다 미세하게 다를 수 있습니다. 0.001픽셀 차이로 매번 보정하면 캐릭터가 계속 떨립니다.
"이 정도는 무시"라는 문턱값이 필요합니다. 보통 1~5픽셀 정도로 잡습니다.
해결 3 — 보간 (다른 플레이어)#
내 캐릭터는 예측으로 해결했습니다. 남의 캐릭터는 어떻게 할까요?
남의 위치는 초당 20번쯤 옵니다. 그냥 받은 값을 대입하면 초당 20번 순간이동합니다.
받은 위치를 바로 적용하지 않고, 조금 과거를 재생한다.
받은 데이터: A(0ms) ──── B(50ms) ──── C(100ms)
↑
지금 여기를 그린다 (A와 B 사이)남의 캐릭터를 100ms 과거로 보여주는 대신 부드럽게 만드는 것입니다.
extends CharacterBody2D
const BUFFER_MS := 100.0 # 얼마나 과거를 보여줄까
var _history: Array = [] # [{t: 시각, pos: 위치}, ...]
@rpc("authority", "unreliable_ordered")
func update_state(pos: Vector2, t: float) -> void:
_history.append({"t": t, "pos": pos})
# 너무 오래된 건 버린다
while _history.size() > 30:
_history.pop_front()
func _process(_delta: float) -> void:
if is_multiplayer_authority():
return # 내 캐릭터는 예측을 쓴다
if _history.size() < 2:
return
var render_time := _now() - BUFFER_MS / 1000.0
# render_time을 감싸는 두 지점을 찾는다
for i in range(_history.size() - 1):
var a: Dictionary = _history[i]
var b: Dictionary = _history[i + 1]
if a.t <= render_time and render_time <= b.t:
var span: float = b.t - a.t
var f: float = 0.0 if span <= 0.0 \
else (render_time - a.t) / span
global_position = (a.pos as Vector2).lerp(b.pos, f)
return
# 데이터가 안 왔으면 마지막 위치 유지(또는 외삽)
global_position = _history[-1].pos
func _now() -> float:
return Time.get_ticks_msec() / 1000.0| 대상 | 기법 | 목적 |
|---|---|---|
| 내 캐릭터 | 예측 + 보정 | 입력 지연 0 |
| 남의 캐릭터 | 보간 | 부드러움 (대신 100ms 과거) |
| 판정 | 서버 권위 | 공정성 |
셋이 각자 다른 문제를 풉니다. 하나만으로는 안 됩니다.
지연 보상 (Lag Compensation) — 히트 판정의 딜레마
문제 상황입니다.
- 내 화면: 적이 A 지점에 있음 → 조준하고 쏨
- 서버: 그 사이 100ms가 흘러 적은 B 지점에 있음 → 빗나감 판정
내 화면에선 분명 맞췄는데 안 맞습니다.
해결 — 서버가 시간을 되감는다
- 서버가 모든 플레이어의 과거 위치를 저장해 둔다 (1초 분량)
- 클라이언트가 "내 시각 T에 쐈다"를 함께 보낸다
- 서버가 T 시점의 상태로 되감아서 판정한다
부작용 — "엄폐물 뒤로 숨었는데 죽었다". 쏜 사람의 화면에서는 아직 노출돼 있었기 때문입니다.
어느 쪽을 택할 것인가는 기술이 아니라 게임 디자인 결정입니다.
- 쏘는 사람 편 → 지연 보상 강하게 (FPS 대부분)
- 피하는 사람 편 → 지연 보상 약하게 (일부 경쟁 게임)
실습 — 인위적 지연 걸기#
이 강의의 핵심 실습입니다. 문제를 직접 만들어 봐야 이해됩니다.
Godot에는 네트워크 시뮬레이션 기능이 내장돼 있습니다.
디버그 → 네트워크 프로파일러 또는 프로젝트 설정:
Network → Debug
Simulate Packet Loss = 0.05 (5% 손실)
Simulate Packet Delay = 0.15 (150ms 지연)코드로도 켤 수 있습니다.
# 개발 빌드에서만
if OS.is_debug_build():
var peer := multiplayer.multiplayer_peer
# ENet은 상황에 따라 API가 다르므로
# 프로젝트 설정 쪽을 쓰는 게 확실하다
pass가장 확실한 방법은 직접 지연을 흉내 내는 것입니다.
@export var fake_delay_ms := 150.0
func send_with_delay(callable: Callable, args: Array) -> void:
if fake_delay_ms > 0.0:
await get_tree().create_timer(
fake_delay_ms / 1000.0).timeout
callable.callv(args)- 지연 0 — 예측 코드 없이 서버 왕복만으로 이동. 느낌이 어떻습니까?
- 지연 150ms, 예측 없음 — 키를 누르고 얼마 뒤에 움직입니까? 이게 왜 못 쓰는 구조인지 몸으로 알게 됩니다.
- 지연 150ms, 예측 켬 — 즉각 반응합니까?
- 지연 150ms, 예측 켬 + 서버가 다른 값을 주도록 조작 → 고무줄 현상을 직접 만듭니다. 캐릭터가 앞으로 갔다가 뒤로 끌려옵니다.
- 패킷 손실 20% — 남의 캐릭터가 어떻게 보입니까? 보간을 켜면 나아집니까?
서버 쪽 _apply의 속도를 클라이언트보다 조금 느리게 만들어보세요.
# 서버에서만 속도를 다르게 (일부러 어긋나게)
var s := SPEED * (0.8 if multiplayer.is_server() else 1.0)
velocity = input.dir * s클라이언트는 빠르게 가고 서버는 느리게 계산하니, 계속 보정이 일어나 앞으로 갔다가 뒤로 당겨집니다. 실제 게임의 고무줄 현상과 똑같습니다.
이게 바로 25강(결정론)이 필요한 이유입니다. 양쪽 계산이 다르면 아무리 좋은 보정도 소용없습니다.
- 개발자 — 예측 코드는 입력 처리와 이동 계산을 순수 함수로 분리해야 만들 수 있습니다.
_physics_process안에 다 섞여 있으면 재시뮬레이션이 불가능합니다. 온라인 계획이 있다면 처음부터apply_input(state, input) -> state형태로 짜세요. - 기획자 — "즉발 판정" 스킬은 온라인에서 비쌉니다. 0.2초 선딜이 있는 스킬은 그 시간 동안 지연을 감출 수 있지만, 즉발은 감출 여지가 없습니다. 경쟁 게임의 스킬에 선딜이 있는 건 밸런스만이 아니라 넷코드 이유도 큽니다.
- 디자이너 — 지연을 연출로 감출 수 있습니다. 버튼을 누르는 즉시 이펙트·사운드·캐릭터 준비 모션을 재생하면, 실제 판정이 100ms 뒤에 나도 플레이어는 즉각 반응했다고 느낍니다. 이건 실제 상용 게임이 널리 쓰는 기법입니다.
"보정할 때마다 캐릭터가 떤다" → 오차 허용 문턱값이 없거나 너무 작습니다.
"예측을 켰더니 벽을 뚫는다" → 클라이언트에서만 이동하고 서버 검증이 없습니다. 서버도 같은 충돌 계산을 해야 합니다.
"보간했더니 조작이 느려졌다" → 내 캐릭터에도 보간을 적용했습니다. 내 캐릭터는 예측, 남의 캐릭터는 보간입니다.
"패킷이 순서 없이 도착해서 위치가 튄다"
→ unreliable 대신 unreliable_ordered를 쓰거나, 순번(seq)으로 오래된 패킷을 버리세요.
- 왕복 시간이 게임에 어떻게 나타나는지 안다
- 클라이언트 예측의 4단계를 설명할 수 있다
- 내 캐릭터는 예측, 남의 캐릭터는 보간
- 인위적 지연을 걸고 고무줄을 직접 만들어봤다
- 지연 보상의 트레이드오프를 안다
다음 강의 — 예측과 보정의 전제는 "같은 입력이면 같은 결과" 였습니다. 그 전제가 깨지면 어떻게 될까요? 결정론적 시뮬레이션과 리플레이입니다.