22강멀티플레이어의 기본 구조
심판은 한 명이어야 한다 — 권위의 문제.
이 강의 목차 (7)
- 권위(authority) 가 무엇이고 왜 한 곳에 있어야 하는지 안다.
- 호스트-클라이언트 / 전용 서버 구조를 구분한다.
- 같은 PC에서 창 두 개를 띄워 접속시킨다.
왜 온라인 게임은 갑자기 어려워지는가#
싱글 게임에서는 진실이 하나입니다. 메모리에 있는 값이 곧 진실입니다.
온라인이 되는 순간 컴퓨터가 여러 대가 됩니다. 각자 자기 메모리에 게임 상태를 들고 있고, 그 값들이 서로 다릅니다.
- 빛보다 빠를 수 없다 — 정보가 오가는 데 시간이 걸린다 (지연)
- 정보가 사라진다 — 인터넷은 패킷을 잃어버린다 (손실)
- 아무도 못 믿는다 — 클라이언트는 조작될 수 있다 (치팅)
이 셋은 없앨 수 없습니다. 온라인 게임 기술은 전부 이걸 감추는 기술입니다.
권위 — 심판은 한 명이어야 한다#
선수 22명이 각자 "지금 2대 1이야"라고 주장하면 경기가 성립하지 않습니다. 심판이 한 명 있고, 그 사람의 판정이 곧 사실입니다.
선수들은 자기 눈으로 보고 판단해서 뛰지만(예측), 심판이 "오프사이드"라고 하면 되돌립니다(보정).
어떤 값에 대해 "진짜"를 정하는 주체.
- 플레이어 A의 위치에 대한 권위가 서버에 있다 → 서버가 정한 위치가 진짜
- 권위가 A 자신에게 있다 → A가 보낸 위치가 진짜 (믿는 구조)
규칙 하나: 하나의 값에는 정확히 하나의 권위만 있어야 합니다.
네트워크 구조 3종#
1) 호스트-클라이언트 (Listen Server)#
플레이어1 (호스트 = 서버 + 클라이언트)
↑↓ ↑↓
플레이어2 플레이어3- 플레이어 중 한 명이 서버 역할도 함
- 가장 만들기 쉽고 서버 비용이 0
- 호스트가 나가면 게임이 끝남
- 호스트가 지연 0이라 유리함 (호스트 어드밴티지)
적합 — 친구끼리 하는 협동 게임, 소규모 파티 게임
2) 전용 서버 (Dedicated Server)#
서버 (게임 로직만, 화면 없음)
↑↓ ↑↓ ↑↓
P1 P2 P3- 모두가 클라이언트, 공평함
- 치팅 방어에 유리 (서버가 다 검증)
- 서버 비용과 운영이 필요
적합 — 경쟁 게임, MMO, 상용 서비스
3) P2P (Peer to Peer)#
P1 ←→ P2
↖ ↗
P3- 모두가 모두에게 직접 연결
- 지연이 낮을 수 있음
- 인원이 늘면 연결 수가 폭발 (n×(n-1)/2)
- 치팅 방어가 거의 불가능
- NAT 통과 문제
적합 — 격투 게임 1대1 (롤백 넷코드), 소규모
가장 배우기 쉽고, Godot의 고수준 API가 이 구조를 전제로 만들어져 있습니다. 여기서 배우는 개념(권위, 동기화, 예측)은 전용 서버로 옮겨도 그대로 쓰입니다.
Godot의 멀티플레이어 API#
Godot은 두 층으로 제공합니다.
| 층 | 무엇 | 언제 |
|---|---|---|
저수준 (PacketPeerUDP, StreamPeerTCP) |
직접 패킷을 만듦 | 완전한 제어가 필요할 때 |
고수준 (MultiplayerAPI, @rpc) |
노드 단위로 자동 처리 | 대부분의 경우 |
이 강좌는 고수준 API를 씁니다.
핵심 개념 — 고유 ID#
multiplayer.get_unique_id() # 내 ID
# 서버는 항상 1
# 클라이언트는 2 이상의 랜덤한 값
multiplayer.is_server() # 내가 서버인가
multiplayer.get_peers() # 접속한 다른 ID 목록모든 노드는 권위자 ID를 가집니다. 기본값은 1(서버)입니다.
node.set_multiplayer_authority(peer_id)
node.get_multiplayer_authority()
node.is_multiplayer_authority() # 내가 이 노드의 주인인가"플레이어 A의 캐릭터 노드는 A가 조종한다"를 표현하는 방법입니다.
실습 — 접속시키기#
1) 네트워크 매니저#
# res://autoload/net.gd
extends Node
const PORT := 8910
const MAX_PLAYERS := 4
signal player_joined(id: int)
signal player_left(id: int)
var players: Dictionary = {} # id -> {name, ...}
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_left)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_failed)
multiplayer.server_disconnected.connect(_on_server_gone)
func host() -> bool:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(PORT, MAX_PLAYERS)
if err != OK:
push_error("서버 생성 실패: %d" % err)
return false
multiplayer.multiplayer_peer = peer
players[1] = {"name": "호스트"}
print("서버 시작. 포트 ", PORT)
return true
func join(address := "127.0.0.1") -> bool:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, PORT)
if err != OK:
push_error("접속 실패: %d" % err)
return false
multiplayer.multiplayer_peer = peer
print("접속 시도 → ", address)
return true
func leave() -> void:
if multiplayer.multiplayer_peer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
players.clear()
# ── 콜백 ────────────────────
func _on_peer_connected(id: int) -> void:
print("접속함: ", id)
players[id] = {"name": "플레이어 %d" % id}
player_joined.emit(id)
func _on_peer_left(id: int) -> void:
print("나감: ", id)
players.erase(id)
player_left.emit(id)
func _on_connected() -> void:
print("서버에 연결됨. 내 ID = ",
multiplayer.get_unique_id())
func _on_failed() -> void:
push_error("서버에 연결하지 못했습니다")
multiplayer.multiplayer_peer = null
func _on_server_gone() -> void:
push_error("서버와의 연결이 끊겼습니다")
multiplayer.multiplayer_peer = null
get_tree().change_scene_to_file("res://scenes/title.tscn")ENetMultiplayerPeer는 UDP 위에 신뢰성 옵션을 얹은 게임용 라이브러리입니다.
- UDP — 빠르지만 순서·도착 보장 없음
- TCP — 보장되지만 하나가 늦으면 뒤가 다 밀림 (head-of-line blocking)
- ENet — 채널별로 "이건 꼭 보내줘 / 이건 잃어도 돼"를 골라서 지정 가능
게임에 적합한 절충안이라 Godot의 기본입니다.
2) 로비 화면#
extends Control
func _ready() -> void:
$VBox/HostBtn.pressed.connect(_on_host)
$VBox/JoinBtn.pressed.connect(_on_join)
Net.player_joined.connect(_refresh)
Net.player_left.connect(_refresh)
func _on_host() -> void:
if Net.host():
get_tree().change_scene_to_file(
"res://scenes/arena.tscn")
func _on_join() -> void:
var addr: String = $VBox/AddressEdit.text
if addr.is_empty():
addr = "127.0.0.1"
Net.join(addr)
func _refresh(_id := 0) -> void:
$VBox/PlayerList.text = ""
for id in Net.players:
$VBox/PlayerList.text += "%d: %s\n" % [
id, Net.players[id].name]3) 창 두 개로 테스트하기#
Godot에는 여러 인스턴스를 동시에 실행하는 기능이 있습니다.
디버그 → 실행 인스턴스 개수(Run Multiple Instances)→ 2개F5실행 → 창이 두 개 뜸- 한 창에서
호스트, 다른 창에서접속클릭 - 출력 패널에 접속 로그가 뜨면 성공
폴드에서 확인하려면 메인 화면에서 창을 나란히 띄우거나, PC에서 호스트를 띄우고 폰에서 같은 와이파이의 PC IP로 접속하세요.
- 같은 와이파이라면 호스트 PC의 내부 IP (예:
192.168.0.15) - 인터넷 너머라면 공유기 포트포워딩 또는 릴레이 서버가 필요합니다
# 내 내부 IP 찾기
for ip in IP.get_local_addresses():
if ip.begins_with("192.168.") or ip.begins_with("10."):
print("내부 IP: ", ip)플레이어 스폰하기#
접속한 사람마다 캐릭터를 만들어야 합니다. Godot 4에는 전용 노드가 있습니다.
Arena (Node2D)
├─ TileMapLayer
├─ Players (Node2D) ← 여기에 캐릭터들이 생김
└─ Spawner (MultiplayerSpawner)MultiplayerSpawner 설정:
Spawn Path→../PlayersAuto Spawn List에player.tscn추가
extends Node2D
const PLAYER := preload("res://scenes/player.tscn")
func _ready() -> void:
if not multiplayer.is_server():
return
# 이미 접속해 있는 사람들
_spawn(1)
for id in multiplayer.get_peers():
_spawn(id)
# 나중에 들어오는 사람들
multiplayer.peer_connected.connect(_spawn)
multiplayer.peer_disconnected.connect(_despawn)
func _spawn(id: int) -> void:
var p := PLAYER.instantiate()
p.name = str(id) # 이름 = ID (중요!)
p.position = _spawn_point(id)
$Players.add_child(p, true)
func _despawn(id: int) -> void:
var p := $Players.get_node_or_null(str(id))
if p:
p.queue_free()
func _spawn_point(id: int) -> Vector2:
var a := (id % 8) * TAU / 8.0
return Vector2(400, 300) + Vector2.RIGHT.rotated(a) * 180.0p.name = str(id)이 한 줄이 매우 중요합니다.
Godot의 멀티플레이어 API는 노드 경로로 상대를 찾습니다.
/root/Arena/Players/2 가 클라이언트 2번의 캐릭터입니다.
모든 컴퓨터에서 노드 경로가 똑같아야 동기화가 됩니다.
add_child(p, true) 의 true는 "사람이 읽기 좋은 이름 유지"입니다.
이걸 빼면 Godot이 @Node2D@3 같은 이름을 붙여서 경로가 어긋납니다.
내 캐릭터만 조종하기#
extends CharacterBody2D
func _ready() -> void:
# 노드 이름이 곧 주인의 ID
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 * 300.0
move_and_slide()지금은 각자 자기 화면에서만 움직입니다. 상대 화면에서는 가만히 있죠. 그걸 맞추는 게 다음 강의입니다.
- 창 두 개를 띄우고 각자 움직여 보세요. 자기 창에서만 움직이고 상대 창에서는 안 움직입니까? (정상입니다)
is_multiplayer_authority()검사를 지워보세요. 한 창에서 두 캐릭터가 동시에 움직입니까?- 클라이언트 창을 닫아보세요. 호스트 쪽에서 캐릭터가 사라집니까?
- 개발자 — 멀티플레이어 코드는 "이 코드는 누구 컴퓨터에서 도는가" 를
항상 의식하며 써야 합니다.
if multiplayer.is_server():를 주석처럼 쓰세요. - 기획자 — 온라인 기능은 비용이 몇 배입니다. "일단 싱글로 만들고 나중에 온라인 붙이죠"는 거의 항상 실패합니다. 구조가 완전히 다르기 때문입니다. 온라인이 필요하면 처음부터 결정해야 합니다.
- 디자이너 — 다른 플레이어의 상태(접속 중, 지연, 준비 완료)를 보여주는 UI가 필요합니다. 또 "연결이 끊겼습니다" 화면은 반드시 만들어야 합니다. 실제로 자주 일어납니다.
"접속이 안 된다"
→ ① 포트가 방화벽에 막힘, ② 주소가 틀림, ③ 서버를 먼저 안 띄움.
127.0.0.1(같은 PC)부터 되는지 확인하세요.
"Node not found" 오류가 쏟아진다"
→ 노드 경로가 컴퓨터마다 다릅니다. add_child(p, true) 를 확인하세요.
"한 창에서 모든 캐릭터가 움직인다" → 권한 검사를 안 했습니다.
"창을 두 개 띄우는 옵션이 안 보인다"
→ 디버그 메뉴 안입니다. Godot 4.2 이상에서 실행 여러 인스턴스.
- 권위가 무엇인지 설명할 수 있다
- 세 가지 네트워크 구조의 차이를 안다
- 창 두 개로 접속에 성공했다
- 각자 자기 캐릭터만 조종한다
- 노드 이름이 왜 중요한지 안다
다음 강의 — 이제 상대 화면에서도 움직이게 만듭니다. 무엇을 보내고 무엇을 안 보낼지 결정하는 게 핵심입니다.