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

22강멀티플레이어의 기본 구조

심판은 한 명이어야 한다 — 권위의 문제.

이 강의 목차 (7)
  1. 왜 온라인 게임은 갑자기 어려워지는가
  2. 권위 — 심판은 한 명이어야 한다
  3. 네트워크 구조 3종
  4. Godot의 멀티플레이어 API
  5. 실습 — 접속시키기
  6. 플레이어 스폰하기
  7. 내 캐릭터만 조종하기
이번 강의의 목표
  • 권위(authority) 가 무엇이고 왜 한 곳에 있어야 하는지 안다.
  • 호스트-클라이언트 / 전용 서버 구조를 구분한다.
  • 같은 PC에서 창 두 개를 띄워 접속시킨다.

왜 온라인 게임은 갑자기 어려워지는가#

싱글 게임에서는 진실이 하나입니다. 메모리에 있는 값이 곧 진실입니다.

온라인이 되는 순간 컴퓨터가 여러 대가 됩니다. 각자 자기 메모리에 게임 상태를 들고 있고, 그 값들이 서로 다릅니다.

세 가지 근본 문제
  1. 빛보다 빠를 수 없다 — 정보가 오가는 데 시간이 걸린다 (지연)
  2. 정보가 사라진다 — 인터넷은 패킷을 잃어버린다 (손실)
  3. 아무도 못 믿는다 — 클라이언트는 조작될 수 있다 (치팅)

이 셋은 없앨 수 없습니다. 온라인 게임 기술은 전부 이걸 감추는 기술입니다.

권위 — 심판은 한 명이어야 한다#

축구 경기

선수 22명이 각자 "지금 2대 1이야"라고 주장하면 경기가 성립하지 않습니다. 심판이 한 명 있고, 그 사람의 판정이 곧 사실입니다.

선수들은 자기 눈으로 보고 판단해서 뛰지만(예측), 심판이 "오프사이드"라고 하면 되돌립니다(보정).

권위(Authority)

어떤 값에 대해 "진짜"를 정하는 주체.

  • 플레이어 A의 위치에 대한 권위가 서버에 있다 → 서버가 정한 위치가 진짜
  • 권위가 A 자신에게 있다 → A가 보낸 위치가 진짜 (믿는 구조)

규칙 하나: 하나의 값에는 정확히 하나의 권위만 있어야 합니다.

네트워크 구조 3종#

1) 호스트-클라이언트 (Listen Server)#

text
   플레이어1 (호스트 = 서버 + 클라이언트)
      ↑↓        ↑↓
  플레이어2   플레이어3
  • 플레이어 중 한 명이 서버 역할도 함
  • 가장 만들기 쉽고 서버 비용이 0
  • 호스트가 나가면 게임이 끝남
  • 호스트가 지연 0이라 유리함 (호스트 어드밴티지)

적합 — 친구끼리 하는 협동 게임, 소규모 파티 게임

2) 전용 서버 (Dedicated Server)#

text
        서버 (게임 로직만, 화면 없음)
     ↑↓    ↑↓    ↑↓
    P1    P2    P3
  • 모두가 클라이언트, 공평함
  • 치팅 방어에 유리 (서버가 다 검증)
  • 서버 비용과 운영이 필요

적합 — 경쟁 게임, MMO, 상용 서비스

3) P2P (Peer to Peer)#

text
   P1 ←→ P2
    ↖   ↗
      P3
  • 모두가 모두에게 직접 연결
  • 지연이 낮을 수 있음
  • 인원이 늘면 연결 수가 폭발 (n×(n-1)/2)
  • 치팅 방어가 거의 불가능
  • NAT 통과 문제

적합 — 격투 게임 1대1 (롤백 넷코드), 소규모

이 강좌는 호스트-클라이언트로 간다

가장 배우기 쉽고, Godot의 고수준 API가 이 구조를 전제로 만들어져 있습니다. 여기서 배우는 개념(권위, 동기화, 예측)은 전용 서버로 옮겨도 그대로 쓰입니다.

Godot의 멀티플레이어 API#

Godot은 두 층으로 제공합니다.

무엇 언제
저수준 (PacketPeerUDP, StreamPeerTCP) 직접 패킷을 만듦 완전한 제어가 필요할 때
고수준 (MultiplayerAPI, @rpc) 노드 단위로 자동 처리 대부분의 경우

이 강좌는 고수준 API를 씁니다.

핵심 개념 — 고유 ID#

gdscript
multiplayer.get_unique_id()      # 내 ID
# 서버는 항상 1
# 클라이언트는 2 이상의 랜덤한 값

multiplayer.is_server()          # 내가 서버인가
multiplayer.get_peers()  # 접속한 다른 ID 목록
노드의 권위자

모든 노드는 권위자 ID를 가집니다. 기본값은 1(서버)입니다.

gdscript
node.set_multiplayer_authority(peer_id)
node.get_multiplayer_authority()
node.is_multiplayer_authority()  # 내가 이 노드의 주인인가

"플레이어 A의 캐릭터 노드는 A가 조종한다"를 표현하는 방법입니다.

실습 — 접속시키기#

1) 네트워크 매니저#

gdscript
# 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")
ENet이란

ENetMultiplayerPeerUDP 위에 신뢰성 옵션을 얹은 게임용 라이브러리입니다.

  • UDP — 빠르지만 순서·도착 보장 없음
  • TCP — 보장되지만 하나가 늦으면 뒤가 다 밀림 (head-of-line blocking)
  • ENet — 채널별로 "이건 꼭 보내줘 / 이건 잃어도 돼"를 골라서 지정 가능

게임에 적합한 절충안이라 Godot의 기본입니다.

2) 로비 화면#

gdscript
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) 창 두 개로 테스트하기#

한 대의 PC에서 테스트

Godot에는 여러 인스턴스를 동시에 실행하는 기능이 있습니다.

  1. 디버그 → 실행 인스턴스 개수(Run Multiple Instances)2개
  2. F5 실행 → 창이 두 개 뜸
  3. 한 창에서 호스트, 다른 창에서 접속 클릭
  4. 출력 패널에 접속 로그가 뜨면 성공

폴드에서 확인하려면 메인 화면에서 창을 나란히 띄우거나, PC에서 호스트를 띄우고 폰에서 같은 와이파이의 PC IP로 접속하세요.

다른 기기에서 접속하려면
  • 같은 와이파이라면 호스트 PC의 내부 IP (예: 192.168.0.15)
  • 인터넷 너머라면 공유기 포트포워딩 또는 릴레이 서버가 필요합니다
gdscript
# 내 내부 IP 찾기
for ip in IP.get_local_addresses():
	if ip.begins_with("192.168.") or ip.begins_with("10."):
		print("내부 IP: ", ip)

플레이어 스폰하기#

접속한 사람마다 캐릭터를 만들어야 합니다. Godot 4에는 전용 노드가 있습니다.

text
Arena              (Node2D)
├─ TileMapLayer
├─ Players         (Node2D)     ← 여기에 캐릭터들이 생김
└─ Spawner         (MultiplayerSpawner)

MultiplayerSpawner 설정:

  • Spawn Path../Players
  • Auto Spawn Listplayer.tscn 추가
gdscript
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.0
노드 이름이 곧 주소다
gdscript
p.name = str(id)

이 한 줄이 매우 중요합니다.

Godot의 멀티플레이어 API는 노드 경로로 상대를 찾습니다. /root/Arena/Players/2 가 클라이언트 2번의 캐릭터입니다. 모든 컴퓨터에서 노드 경로가 똑같아야 동기화가 됩니다.

add_child(p, true)true는 "사람이 읽기 좋은 이름 유지"입니다. 이걸 빼면 Godot이 @Node2D@3 같은 이름을 붙여서 경로가 어긋납니다.

내 캐릭터만 조종하기#

gdscript
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()

지금은 각자 자기 화면에서만 움직입니다. 상대 화면에서는 가만히 있죠. 그걸 맞추는 게 다음 강의입니다.

직접 확인하기
  1. 창 두 개를 띄우고 각자 움직여 보세요. 자기 창에서만 움직이고 상대 창에서는 안 움직입니까? (정상입니다)
  2. is_multiplayer_authority() 검사를 지워보세요. 한 창에서 두 캐릭터가 동시에 움직입니까?
  3. 클라이언트 창을 닫아보세요. 호스트 쪽에서 캐릭터가 사라집니까?
  • 개발자 — 멀티플레이어 코드는 "이 코드는 누구 컴퓨터에서 도는가" 를 항상 의식하며 써야 합니다. if multiplayer.is_server(): 를 주석처럼 쓰세요.
  • 기획자 — 온라인 기능은 비용이 몇 배입니다. "일단 싱글로 만들고 나중에 온라인 붙이죠"는 거의 항상 실패합니다. 구조가 완전히 다르기 때문입니다. 온라인이 필요하면 처음부터 결정해야 합니다.
  • 디자이너 — 다른 플레이어의 상태(접속 중, 지연, 준비 완료)를 보여주는 UI가 필요합니다. 또 "연결이 끊겼습니다" 화면은 반드시 만들어야 합니다. 실제로 자주 일어납니다.
자주 나는 오류

"접속이 안 된다" → ① 포트가 방화벽에 막힘, ② 주소가 틀림, ③ 서버를 먼저 안 띄움. 127.0.0.1(같은 PC)부터 되는지 확인하세요.

"Node not found" 오류가 쏟아진다" → 노드 경로가 컴퓨터마다 다릅니다. add_child(p, true) 를 확인하세요.

"한 창에서 모든 캐릭터가 움직인다" → 권한 검사를 안 했습니다.

"창을 두 개 띄우는 옵션이 안 보인다"디버그 메뉴 안입니다. Godot 4.2 이상에서 실행 여러 인스턴스.

여기까지 확인
  • 권위가 무엇인지 설명할 수 있다
  • 세 가지 네트워크 구조의 차이를 안다
  • 창 두 개로 접속에 성공했다
  • 각자 자기 캐릭터만 조종한다
  • 노드 이름이 왜 중요한지 안다

다음 강의 — 이제 상대 화면에서도 움직이게 만듭니다. 무엇을 보내고 무엇을 안 보낼지 결정하는 게 핵심입니다.