2D Godot 2D 게임 기초
P6 서버와 시뮬레이션

24강지연과 예측

고무줄 현상은 어디서 오는가.

이 강의 목차 (6)
  1. 지연은 없앨 수 없다
  2. 순진한 구조에서 무슨 일이 일어나나
  3. 해결 1 — 클라이언트 예측
  4. 해결 2 — 서버 보정 (Reconciliation)
  5. 해결 3 — 보간 (다른 플레이어)
  6. 실습 — 인위적 지연 걸기
이번 강의의 목표
  • 지연(latency)이 게임 경험에 어떻게 나타나는지 안다.
  • 보간으로 남의 캐릭터를 부드럽게 만든다.
  • 클라이언트 예측 + 서버 보정의 원리를 이해한다.
  • 고무줄 현상을 직접 만들어 본다.

지연은 없앨 수 없다#

왕복 시간 (RTT / Ping)

내가 보낸 것이 서버에 갔다가 답이 돌아오는 데 걸리는 시간.

상황 RTT
같은 PC (127.0.0.1) 0~1ms
같은 와이파이 5~20ms
국내 서버 20~50ms
일본/싱가포르 50~100ms
미국 서부 130~180ms
유럽 250~350ms
모바일 LTE (혼잡) 100~500ms, 불규칙

서울↔뉴욕은 빛의 속도로도 왕복 37ms입니다. 물리 법칙이라 줄일 수 없습니다.

순진한 구조에서 무슨 일이 일어나나#

23강에서 "클라이언트는 요청만, 서버가 결정"이라고 했습니다. 이걸 이동에 그대로 적용하면 이렇게 됩니다.

text
0ms    [클라] 오른쪽 키를 누름 → 서버에 전송
75ms   [서버] 받음. 이동 계산. 결과 전송
150ms  [클라] 결과 도착. 이제서야 캐릭터가 움직임
150ms의 입력 지연은 게임을 망가뜨린다

사람은 100ms 정도부터 "반응이 느리다"를 느낍니다. 150ms면 조작이 물속에서 하는 것처럼 느껴집니다.

절대 이렇게 만들면 안 됩니다.

해결 1 — 클라이언트 예측#

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

서버의 답을 기다리지 않고, 내 화면에서는 먼저 움직인다.

  1. 키를 누르면 즉시 내 캐릭터를 움직인다 (지연 0)
  2. 동시에 서버에 입력을 보낸다
  3. 서버의 답이 오면 내 예측과 비교한다
  4. 같으면 무시, 다르면 고친다
지도 앱의 파란 점

내비게이션에서 GPS 신호는 1초에 한 번 옵니다. 그런데 지도의 파란 점은 끊김 없이 부드럽게 움직이죠.

앱이 "지금 속도로 계속 간다면 여기쯤 있겠지"를 예측해서 그리고 있기 때문입니다. 실제 GPS가 오면 조용히 보정합니다. 이게 클라이언트 예측입니다.

해결 2 — 서버 보정 (Reconciliation)#

예측이 틀리면 어떻게 될까요? 그냥 서버 값으로 덮으면 캐릭터가 뒤로 순간이동합니다.

서버 보정 (Server Reconciliation)
  1. 내가 보낸 모든 입력에 번호를 매겨 보관한다
  2. 서버가 "42번 입력까지 처리했고, 결과 위치는 여기"라고 답한다
  3. 내 42번 시점 예측과 비교한다
  4. 다르면 → 서버 위치에서 시작해 43번부터 지금까지의 입력을 다시 적용한다
gdscript
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)
이 코드의 핵심 3줄
  1. _apply(input)클라이언트와 서버가 똑같이 실행한다
  2. 클라이언트는 아직 확인 안 된 입력을 _pending에 보관한다
  3. 어긋나면 서버 위치에서 다시 계산한다

같은 함수를 양쪽에서 쓰는 게 결정적입니다. 계산이 다르면 계속 어긋납니다. 25강의 결정론이 여기서 필요해집니다.

오차 허용 범위(2.0)가 왜 필요한가

부동소수점 계산은 컴퓨터마다 미세하게 다를 수 있습니다. 0.001픽셀 차이로 매번 보정하면 캐릭터가 계속 떨립니다.

"이 정도는 무시"라는 문턱값이 필요합니다. 보통 1~5픽셀 정도로 잡습니다.

해결 3 — 보간 (다른 플레이어)#

내 캐릭터는 예측으로 해결했습니다. 남의 캐릭터는 어떻게 할까요?

남의 위치는 초당 20번쯤 옵니다. 그냥 받은 값을 대입하면 초당 20번 순간이동합니다.

보간 (Interpolation)

받은 위치를 바로 적용하지 않고, 조금 과거를 재생한다.

text
받은 데이터: A(0ms) ──── B(50ms) ──── C(100ms)
                     ↑
              지금 여기를 그린다 (A와 B 사이)

남의 캐릭터를 100ms 과거로 보여주는 대신 부드럽게 만드는 것입니다.

gdscript
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. 서버가 모든 플레이어의 과거 위치를 저장해 둔다 (1초 분량)
  2. 클라이언트가 "내 시각 T에 쐈다"를 함께 보낸다
  3. 서버가 T 시점의 상태로 되감아서 판정한다

부작용 — "엄폐물 뒤로 숨었는데 죽었다". 쏜 사람의 화면에서는 아직 노출돼 있었기 때문입니다.

어느 쪽을 택할 것인가는 기술이 아니라 게임 디자인 결정입니다.

  • 쏘는 사람 편 → 지연 보상 강하게 (FPS 대부분)
  • 피하는 사람 편 → 지연 보상 약하게 (일부 경쟁 게임)

실습 — 인위적 지연 걸기#

이 강의의 핵심 실습입니다. 문제를 직접 만들어 봐야 이해됩니다.

Godot에는 네트워크 시뮬레이션 기능이 내장돼 있습니다.

디버그 → 네트워크 프로파일러 또는 프로젝트 설정:

text
Network → Debug
  Simulate Packet Loss  = 0.05    (5% 손실)
  Simulate Packet Delay = 0.15    (150ms 지연)

코드로도 켤 수 있습니다.

gdscript
# 개발 빌드에서만
if OS.is_debug_build():
	var peer := multiplayer.multiplayer_peer
	# ENet은 상황에 따라 API가 다르므로
	# 프로젝트 설정 쪽을 쓰는 게 확실하다
	pass

가장 확실한 방법은 직접 지연을 흉내 내는 것입니다.

gdscript
@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)
단계별 실험 (반드시 해보세요)
  1. 지연 0 — 예측 코드 없이 서버 왕복만으로 이동. 느낌이 어떻습니까?
  2. 지연 150ms, 예측 없음 — 키를 누르고 얼마 뒤에 움직입니까? 이게 왜 못 쓰는 구조인지 몸으로 알게 됩니다.
  3. 지연 150ms, 예측 켬 — 즉각 반응합니까?
  4. 지연 150ms, 예측 켬 + 서버가 다른 값을 주도록 조작고무줄 현상을 직접 만듭니다. 캐릭터가 앞으로 갔다가 뒤로 끌려옵니다.
  5. 패킷 손실 20% — 남의 캐릭터가 어떻게 보입니까? 보간을 켜면 나아집니까?
고무줄 만드는 법

서버 쪽 _apply의 속도를 클라이언트보다 조금 느리게 만들어보세요.

gdscript
# 서버에서만 속도를 다르게 (일부러 어긋나게)
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단계를 설명할 수 있다
  • 내 캐릭터는 예측, 남의 캐릭터는 보간
  • 인위적 지연을 걸고 고무줄을 직접 만들어봤다
  • 지연 보상의 트레이드오프를 안다

다음 강의 — 예측과 보정의 전제는 "같은 입력이면 같은 결과" 였습니다. 그 전제가 깨지면 어떻게 될까요? 결정론적 시뮬레이션과 리플레이입니다.