23강상태 동기화와 RPC
무엇을 보내고 무엇을 보내지 않을 것인가.
이 강의 목차 (7)
MultiplayerSynchronizer로 상태를 자동 동기화한다.@rpc로 원격 함수를 호출한다.- 무엇을 보내고 무엇을 안 보낼지 판단한다.
두 가지 방법#
네트워크로 정보를 보내는 방법은 크게 둘입니다.
상태 동기화(State Sync) — "지금 값이 이거야"를 주기적으로 계속 보냄
- 위치, 체력, 회전
- 잃어버려도 다음 것이 오므로 괜찮음
- 대역폭을 꾸준히 씀
이벤트(RPC) — "이런 일이 일어났어"를 그 순간에만 보냄
- 총을 쐈다, 문이 열렸다, 채팅
- 놓치면 영원히 모름 → 신뢰성 옵션 필요
- 대역폭을 가끔 씀
- 상태 동기화 = 벽시계. 계속 현재 시각을 보여줌. 한 번 안 봐도 다음에 보면 됨.
- 이벤트 = 알람. 울릴 때 못 들으면 끝.
MultiplayerSynchronizer — 자동 동기화#
노드 하나만 붙이면 지정한 속성이 자동으로 전송됩니다.
Player (CharacterBody2D)
├─ Sprite2D
├─ CollisionShape2D
├─ Camera2D
└─ Synchronizer (MultiplayerSynchronizer)설정 방법
MultiplayerSynchronizer선택- 하단 Replication 패널이 열림
+로 동기화할 속성 추가 (.:position,.:velocity,.:hp)- 각 속성의 동기화 모드 선택
| 모드 | 동작 | 언제 |
|---|---|---|
Always |
매 틱마다 보냄 | 위치처럼 계속 변하는 값 |
On Change |
값이 바뀔 때만 | 체력, 무기 종류 |
Never |
처음 스폰 때만 | 플레이어 이름, 색깔 |
MultiplayerSynchronizer는 그 노드의 권위자가 값을 보내고,
나머지가 받아서 덮어씁니다.
22강에서 set_multiplayer_authority(name.to_int()) 를 했으므로,
각자 자기 캐릭터의 위치를 보내고 남의 캐릭터 위치를 받습니다.
동기화 주기 설정
인스펙터의 Replication Interval:
0— 물리 틱마다 (초당 60회). 정확하지만 대역폭이 큼0.05— 초당 20회. 대부분의 게임에 충분0.1— 초당 10회. 느린 게임용
플레이어 위치 하나가 8바이트라고 하면,
- 60Hz × 8바이트 × 4명 = 초당 약 2KB (별거 아님)
그런데 적 200마리를 동기화하면,
- 60Hz × 8바이트 × 200 = 초당 96KB = 768Kbps
- 여기에 UDP 헤더가 패킷마다 붙습니다
모바일 회선이나 여러 방을 돌리는 서버에서는 금방 한계에 닿습니다. 보내는 빈도를 줄이고, 받는 쪽에서 보간(24강) 하는 게 정석입니다.
실습 1 — 위치 동기화#
extends CharacterBody2D
@export var speed := 300.0
func _ready() -> void:
set_multiplayer_authority(name.to_int())
if is_multiplayer_authority():
$Camera2D.make_current()
func _physics_process(_delta: float) -> void:
if not is_multiplayer_authority():
return
var dir := Input.get_vector(
"move_left", "move_right", "move_up", "move_down")
velocity = dir * speed
move_and_slide()Synchronizer의 Replication에 .:position 을 추가하면 끝입니다.
창 두 개를 띄우면 이제 서로가 보입니다.
- 두 창에서 각자 움직여 보세요. 상대 화면에도 나타납니까?
Replication Interval을0.3으로 올려보세요. 뚝뚝 끊겨 보입니까? (이 문제를 푸는 게 24강입니다).:velocity도 추가해 보세요. 무엇이 달라집니까?
RPC — 원격 함수 호출#
"다른 컴퓨터에서 이 함수를 실행해라" 라고 명령하는 것.
@rpc("any_peer", "call_local", "reliable")
func say(text: String) -> void:
print(text)
# 호출
say.rpc("안녕") # 모두에게
say.rpc_id(1, "안녕") # 서버에게만
say("안녕") # 나만 (평범한 호출)@rpc 옵션 4가지#
@rpc(누가_부를수있나, 나도_실행하나, 신뢰성, 채널)1) 누가 부를 수 있나
| 값 | 의미 |
|---|---|
"authority" |
서버만 부를 수 있음 (기본값) |
"any_peer" |
아무나 부를 수 있음 |
2) 나도 실행하나
| 값 | 의미 |
|---|---|
"call_remote" |
상대만 실행 (기본값) |
"call_local" |
나도 같이 실행 |
3) 신뢰성
| 값 | 의미 | 언제 |
|---|---|---|
"unreliable" |
잃어버려도 재전송 안 함 | 위치 같은 갱신값 |
"unreliable_ordered" |
순서만 보장 | — |
"reliable" |
반드시 도착시킴 | 채팅, 아이템 획득, 사망 |
reliable은 도착 확인을 받고, 안 오면 재전송합니다.
매 프레임 보내는 값에 이걸 쓰면 밀린 패킷이 쌓여 지연이 커집니다.
- 위치·회전 →
unreliable - "죽었다", "아이템 먹었다" →
reliable
실습 2 — 총 쏘기#
나쁜 예 — 클라이언트가 직접 처리
func shoot() -> void:
spawn_bullet.rpc(global_position, rotation)
@rpc("any_peer", "call_local")
func spawn_bullet(pos: Vector2, rot: float) -> void:
var b := BULLET.instantiate()
b.global_position = pos
get_tree().current_scene.add_child(b)동작은 합니다. 하지만 클라이언트가 마음대로 총알을 만듭니다. 조작된 클라이언트가 초당 1000발을 쏠 수 있습니다.
좋은 예 — 서버가 검증
const FIRE_RATE := 0.2
var _last_shot := 0.0
func _unhandled_input(e: InputEvent) -> void:
if not is_multiplayer_authority():
return
if e.is_action_pressed("attack"):
# 1) 요청만 보낸다
request_shoot.rpc_id(1, get_global_mouse_position())
# 2) 서버에서만 실행됨
@rpc("any_peer", "call_local", "reliable")
func request_shoot(target: Vector2) -> void:
if not multiplayer.is_server():
return
# ── 검증 ──────────────────
var sender := multiplayer.get_remote_sender_id()
if sender != get_multiplayer_authority() and sender != 0:
return # 남의 캐릭터로 쏘려 함
var now := Time.get_ticks_msec() / 1000.0
if now - _last_shot < FIRE_RATE:
return # 연사 속도 초과
if global_position.distance_to(target) > 2000.0:
return # 사거리 초과
_last_shot = now
# ── 통과했으면 모두에게 알린다 ──────
var dir := (target - global_position).angle()
do_shoot.rpc(global_position, dir)
@rpc("authority", "call_local", "reliable")
func do_shoot(pos: Vector2, dir: float) -> void:
var b := BULLET.instantiate()
b.global_position = pos
b.rotation = dir
get_tree().current_scene.add_child(b)- 클라이언트는 요청(request) 만 보낸다 →
rpc_id(1, ...) - 서버가 검증(validate) 한다
- 통과하면 서버가 모두에게 명령(command) 을 보낸다 →
rpc()
"any_peer" 는 요청용, "authority" 는 명령용입니다.
이 두 단계를 나누는 것이 온라인 게임 보안의 기본형입니다.
클라이언트가 보내도 되는 것:
"오른쪽으로 가고 싶다" (입력)
"저기를 쏘고 싶다" (의도)
클라이언트가 보내면 안 되는 것:
"내 위치는 (9999, 9999)다" (텔레포트 핵)
"적의 체력을 0으로 만들어라" (원킬 핵)
"골드를 1000000 추가해라" (재화 핵)입력은 받고, 결과는 서버가 정한다. 이 한 줄이 전부입니다.
get_remote_sender_id()
RPC 안에서 "누가 이 함수를 불렀는지" 를 알 수 있습니다.
var sender := multiplayer.get_remote_sender_id()
# 0이면 로컬 호출(call_local), 그 외는 그 피어의 ID이게 없으면 클라이언트 2번이 "3번 플레이어를 죽여라"를 보내도 막을 수 없습니다. 검증이 필요한 모든 RPC에서 반드시 확인하세요.
무엇을 보내고 무엇을 안 보낼까#
이게 넷코드 설계의 절반입니다.
보낸다
- 다른 플레이어가 봐야 하는 것 (위치, 애니메이션 상태, 체력)
- 결과가 모두에게 같아야 하는 것 (누가 이겼나)
안 보낸다
- 각자 계산해도 같은 결과가 나오는 것 (총알 궤적 — 시작점과 방향만 보내면 됨)
- 순수 연출 (파티클, 화면 흔들림 — 각자 만들면 됨)
- 그 플레이어만 아는 것 (UI 상태, 카메라 위치)
| 대상 | 보낼 것 | 안 보낼 것 |
|---|---|---|
| 캐릭터 | 위치, 속도, 상태 | 스프라이트 프레임 번호 |
| 총알 | 발사 시점의 위치·방향 | 매 프레임 위치 |
| 폭발 | "여기서 터졌다" 한 번 | 파티클 하나하나 |
| 체력 | 현재 값 | 체력바 애니메이션 |
| 적 | 위치, 상태 | AI가 뭘 고민 중인지 |
총알을 동기화하지 않는 이유
총알 100발의 위치를 매 틱 보내면 대역폭이 폭발합니다. 대신 "이 위치에서 이 방향으로 이 속도로 발사됐다" 한 번만 보내면, 각자 컴퓨터가 똑같이 계산해서 같은 궤적을 그립니다.
단, 명중 판정은 서버 것만 인정합니다. 그래서 아주 짧은 순간 "내 화면에선 맞았는데 안 맞은" 상황이 생깁니다. 24강 주제입니다.
이 방식의 전제는 "같은 입력이면 같은 결과" 입니다. 25강에서 다룹니다.
대역폭 재보기#
func _process(_delta: float) -> void:
if multiplayer.multiplayer_peer == null:
return
var p := multiplayer.multiplayer_peer
if p is ENetMultiplayerPeer:
var host := (p as ENetMultiplayerPeer).host
# ENet 통계로 왕복 시간과 손실률을 볼 수 있다
$Label.text = "peers: %d" % multiplayer.get_peers().size()Replication Interval을 0 / 0.05 / 0.2 로 바꿔가며 상대 캐릭터 움직임을 보세요.- 적 50마리에
MultiplayerSynchronizer를 붙이고 다시 해보세요. do_shoot의 신뢰성을unreliable로 바꾸고 연사해 보세요. 가끔 총알이 안 나가는 걸 볼 수 있습니까?request_shoot의 연사 속도 검증을 지우고, 클라이언트에서 연사해 보세요.
- 개발자 — RPC 함수 이름 앞에 접두사를 붙이면 읽기 쉽습니다.
request_*(클라 → 서버),s_*(서버 → 클라) 같은 규칙을 정하세요. - 기획자 — "즉발 스킬"과 "투사체 스킬"은 네트워크 비용이 완전히 다릅니다. 또 "내 화면에서 맞은 것 같은데 안 맞았다" 는 버그가 아니라 구조적 결과입니다. 이걸 어디까지 허용할지가 곧 게임 디자인 결정입니다.
- 디자이너 — 연출은 각 클라이언트가 알아서 만들면 됩니다. "폭발이 일어났다"는 신호만 오면 파티클·사운드·흔들림은 로컬에서 붙입니다. 네트워크 비용이 거의 없으니 연출은 마음껏 하세요.
"RPC가 상대에서 실행 안 된다"
→ ① 양쪽 스크립트에 같은 함수와 같은 @rpc 선언이 있어야 함,
② 노드 경로가 양쪽에서 같아야 함, ③ 권한 설정 확인.
"call_local을 안 했는데 나도 실행된다"
→ rpc() 는 자신을 제외한 모두에게 갑니다. 자신도 실행하려면 call_local이 필요합니다.
"Synchronizer가 아무것도 안 보낸다" → Replication 목록이 비었거나, 그 노드의 권위자가 아닙니다.
"동기화된 캐릭터가 순간이동한다" → 정상입니다. 갱신 사이가 비어 있어서입니다. 24강에서 보간으로 해결합니다.
- 상태 동기화와 이벤트(RPC)를 구분한다
-
MultiplayerSynchronizer로 위치를 동기화했다 -
@rpc의 네 가지 옵션을 안다 - "요청 → 검증 → 명령" 구조로 총을 만들었다
- 무엇을 안 보내도 되는지 판단할 수 있다
다음 강의 — 동기화는 됩니다. 그런데 뚝뚝 끊기고, 내 캐릭터가 늦게 반응합니다. 지연을 감추는 기술을 봅니다.