

우리는 ReadyRoomRequest 하나가 서버 루프 안에서 어떤 출구로 갈라져야 하는지 설계했다.
클라가 Ready를 누르면 먼저 패킷 모양을 확인하고, 연결에 묶인 session을 RoomManager로 넘기고, 실패하면 Error로 빠지고, 성공하면 ReadyRoomResponse를 보낸다. 조건이 맞으면 그 뒤에 BattleStart까지 이어진다.
이제 Ready를 누르는 흐름은 전송 계층의 패킷 문장과 서버 루프의 처리 경로를 둘 다 갖게 됐다. 이제 Room은 배틀을 시작할 준비가 된 것이다.
BattleStart를 받았다고 해서 클라가 곧바로 때릴 몬스터를 얻는 건 아니다. 서버 안에서도 마찬가지다. Room이 전투 상태로 넘어갔다는 사실은 생겼지만, 아직 Room 안에는 몬스터도 없고 드랍도 없다.
물론 앞으로도 할 일이 산더미다. BattleStart를 받은 Room에 클라가 때릴 몬스터도 spawn 해야 한다. 물론 이 또한 서버 권한 게임 서버이기 때문에 서버에 의해 몬스터가 spawn되어야 하고, 서버에 의해 아이템 드랍이 이루어져야 한다.
그러면 다음 질문은 자연스럽게,
BattleStart 다음에 서버는 Room 안에 무엇을 처음 만들어야 하는가?
상식적으로 우리는 Monster와 Drop을 설계할 것이다. 이제 정말 게임 상태처럼 보이기 시작했다. Room이 로비 상태를 지나 아주 작은 전장으로 넘어가기 시작한 것이다.

BattleStart는 중요하다. 이 패킷이 있어야 Room 안의 플레이어들이 같은 시점에 전투 시작을 알 수 있다. 서버 입장에서도 이 Room이 더 이상 단순한 로비 상태가 아니라는 기준점이 된다.
하지만 BattleStart만으로는 게임이라고 할 수 없다. 전투가 시작됐다는 말은 생겼지만 아무 것도 없다. 무엇이 죽을지도 없고, 죽은 뒤 무엇이 떨어질지도 없다.
말하자면 문은 열렸는데 방 안이 텅텅 비어 있는 셈이다.
그래서 BattleStart 다음에는 서버가 Room 안에 첫 번째 게임 객체를 만들어야 했다. 여기서부터는 신호가 아니라 상태다.
BattleStart
-> MonsterSpawn
이 흐름이 붙고 나서야 클라는 "전투가 시작됐다"에서 멈추지 않고 "저 몬스터가 있다"까지 알 수 있다. 여기서 중요한 건 앞에서도 언급했듯이 몬스터를 클라가 정하지 않는다는 점이다.
몬스터의 데이터 id, type, hp 등은 서버가 만든다. 클라는 그 결과를 받아서 보여줄 뿐이다.
클라는 결과를 주장하지 않는다.
서버가 상태를 만들고 알려준다.
처음 Monster는 아주 간단하게 만들었다.
monsterId
monsterTypeId
maxHp
alive
그래도 최소한 서버가 "Room 안에 살아 있는 몬스터가 하나 있다"고 말할 수는 있다.
Room 코드에서 핵심은 이 부분이었다.
bool Room::spawnMonster(uint32_t monsterId, uint32_t monsterTypeId, uint16_t maxHp) {
if (!battleStarted_ || hasAliveMonster() || !drops_.empty()) {
return false;
}
monster_ = Monster{monsterId, monsterTypeId, maxHp, true};
return true;
}
조건은 짧지만 이 짧은 조건 안에 기준이 들어 있다.
아직 battle이 시작되지 않은 Room에는 몬스터를 만들지 않는다. 이미 살아 있는 몬스터가 있으면 또 만들지 않는다. 드랍이 남아 있는 상태에서도 새 몬스터를 만들지 않는다.
아직은 복잡한 spawn table이나 wave 시스템을 만들고 싶지는 않았다. 지금은 그저 "전투가 시작된 Room 안에 서버가 만든 공격 대상이 생긴다"를 보고 싶었다.
몬스터가 보여야
그 몬스터가 죽으면 Room 상태는 어떻게 바뀌는가?
라는 질문에 답을 할 수 있기 때문이다.
나는 원래 말을 이쁘게 하는 편이지만 요즘은 좀 더 이쁘게 말을 하려고 노력 중이다. 그래서 몬스터가 있으면 결국 죽어야 한다는 말을 여러분에게 하는 것이 마음에 꽤나 걸린다.
아무튼, 지금 실제 전투를 깊게 만들지는 않을 것이다.
그저
라는 흐름을 보여주는 것이 중요하다고 생각한다.
아이템 드랍도 단순하게 구현했다.
dropId
itemId
quantity
그냥 Room 안에, 아직 있지도 않은 바닥에 떨어진 아이템이다. 인벤토리, 소유권 같은 건 나중에 구현하자.
생각해 보면, 드랍을 만드는 것과 드랍을 먹는 것은 같은 일이 아니다. 전자는 서버가 전장 상태를 바꾸는 일이고, 후자는 여러 플레이어가 같은 대상을 두고 경쟁할 수 있는 일이다. 비슷해 보여도 큰 차이가 있는 것이다.
코드로 설명하겠다. 서버가 바닥에 무엇을 놓았는지부터 잡자.
bool Room::defeatMonster(uint32_t monsterId, uint32_t dropId, uint32_t itemId, uint16_t quantity) {
if (!hasAliveMonster() || monster_.monsterId != monsterId) {
return false;
}
monster_.alive = false;
drops_.push_back(Drop{dropId, itemId, quantity});
return true;
}
여기서도 기준은 단순하다.
살아 있는 몬스터가 없으면 죽일 수 없다. 요청한 monsterId가 Room 안의 몬스터와 다르면 죽일 수 없다. 이 조건을 통과해야 monster_.alive가 false가 되고, drops_에 새 Drop이 들어간다.
코드 몇 줄 추가했을 뿐인데 Room이 조금 더 게임처럼 보이기 시작한다. 팔은 역시 안으로 굽는 건가보다.
전투 시작
-> 몬스터 생성
-> 몬스터 사망
-> 드랍 생성
보라! 아주 작지만, 드디어 게임 루프 비슷한 게 생겼다.

Room은 자기 안의 상태 조건을 본다. 하지만 실제 서버 흐름에서는 monsterId와 dropId도 필요하다. 이 값들은 클라가 마음대로 정해서 보내면 안 된다. 서버가 만들어야 한다.
RoomManager 쪽에서는 이런 흐름으로 구현했다.
const uint32_t monsterId = nextMonsterId_;
if (!room.spawnMonster(monsterId, kDefaultMonsterTypeId, kDefaultMonsterMaxHp)) {
return RoomCommandResult(false, RoomCommandError::kNotFound, summarizeRoom(room));
}
++nextMonsterId_;
nextMonsterId_는 몬스터 spawn이 성공했을 때만 증가한다. 생성에 실패했는데 id만 앞으로 밀리면, 실패한 상태 변경이 서버 id 흐름에 흔적을 남길 수 있다. 물론 id가 반드시 연속이어야 하는 건 아니겠지만, 최소 구조에서는 성공한 생성만 다음 번호를 쓰는 편이 더 읽기 좋았다고 판단했다.
아이템 드랍도 비슷하다.
const uint32_t dropId = nextDropId_;
if (!room.defeatMonster(monsterId, dropId, kDefaultDropItemId, kDefaultDropQuantity)) {
return RoomCommandResult(false, RoomCommandError::kNotFound, summarizeRoom(room));
}
++nextDropId_;
여기서도 성공해야 다음 dropId로 넘어간다.
결국 역할은 이렇게 나뉜다.
RoomManager: 어떤 Room인지 찾고, 서버가 붙일 id를 준비한다.
Room: 지금 이 상태 변경을 받아도 되는지 판단한다.
Server: 그 결과를 packet으로 만들어 클라에게 보낸다.
이 설계는 책임이 한 곳에 뭉개지지 말아야 한다는 원칙 위에 세워졌다. 네트워크 코드는 몬스터를 직접 만들지 않는다. RoomManager는 패킷 바이트를 만들지 않는다. Room은 서버 전체 id 발급 정책을 알 필요가 없다.
각자 볼 것만 본다. 그 외에는 무지, 그것도 의도된 무지를 갖는다. 이런 분리된 설계가 있어야 이후에 문제가 생겼을 때 어느 층을 봐야 하는지 헷갈리지 않을 것이라고 생각했다.

Room 안에 Monster와 Drop이 생겼다면, 클라도 그 상태를 알아야 한다. 그래서 전투 시작 직후에는 MonsterSpawn을 보낸다.
서버 -> 클라: MonsterSpawn(roomId, monsterId, monsterTypeId, maxHp)
몬스터가 죽고 드랍이 생기면 MonsterDeath와 DropListSnapshot을 보낸다.
서버 -> 클라: MonsterDeath(roomId, monsterId)
서버 -> 클라: DropListSnapshot(roomId, drops)
여기서 MonsterDeathRequest도 등장하지만, 어쨌든 지금 보고 싶은 건 요청 자체가 아니라, 서버가 Room 안의 상태를 바꾼 뒤에 어떤 결과를 모두에게 보여주는가다.
그 흐름은 아래와 같다
A CreateRoom
B JoinRoom
A Ready
B Ready
A/B BattleStart
A/B MonsterSpawn
A MonsterDeathRequest
A/B MonsterDeath
A/B DropListSnapshot
이 흐름이 구현되자 Room은 단순히 "들어가고, 준비하고, 시작하는 곳"에서 한 단계 더 나아갔다.
이제 Room은 서버가 만든 몬스터를 가지고 있고, 그 몬스터가 죽으면 서버가 만든 드랍을 가진다. 로비에서 전장으로 넘어가는 최소 루프다.

이제는 함수 하나 호출하고 박수 치며 좋아하는 수준이 아니게 되어버렸다.
서버를 띄우고, 클라 A와 B가 붙고, 방을 만들고, 들어가고, Ready를 누른다. 그 다음 두 클라가 모두 BattleStart를 받고, 같은 몬스터를 받는다.
그 뒤 A가 몬스터 사망 요청을 보내면 A와 B가 모두 같은 MonsterDeath를 받고, 같은 DropListSnapshot을 받는다.
테스트의 핵심은 이 부분이다.
ASSERT_TRUE(recvMonsterDeathPacket(battle.clientA, deathRoomId, deadMonsterId));
EXPECT_EQ(deadMonsterId, battle.monsterId);
ASSERT_TRUE(recvMonsterDeathPacket(battle.clientB, deathRoomId, deadMonsterId));
EXPECT_EQ(deadMonsterId, battle.monsterId);
ASSERT_TRUE(recvDropListSnapshotPacket(battle.clientA, dropRoomIdA, dropsA));
ASSERT_TRUE(recvDropListSnapshotPacket(battle.clientB, dropRoomIdB, dropsB));
EXPECT_EQ(dropsB[0].dropId, dropsA[0].dropId);
EXPECT_EQ(dropsB[0].itemId, dropsA[0].itemId);
EXPECT_EQ(dropsB[0].quantity, dropsA[0].quantity);
이 테스트는 "서버 안에서 객체가 생겼다"로 끝나지 않는다. 두 클라가 같은 몬스터 사망을 보고, 같은 드랍을 본다는 것이 이 테스트의 중점이다.
게임 서버에서 상태를 서버가 소유한다고 말하려면, 결국 클라들이 서버가 확정한 같은 결과를 받아야 한다. A에게는 dropId 1이 보이고 B에게는 dropId 2가 보이면, 서버가 상태를 하나로 소유한다고 말하기 어렵다.
그래서 이 테스트는 생각보다 훨씬 더 중요한 것을 확인하게 됐다.
서버가 만든 전장 상태를 두 클라가 같은 값으로 관측한다.
이 전제 위에서 드랍 경쟁이든 인벤토리든 무언가를 올릴 수 있을 것이다.
BattleStart는 시작을 알렸고, 서버는 몬스터와 아이템 드랍 상태를 만들어 배틀을 꾸몄다.
이번 단계에서 서버는 처음으로 Room 안에 Monster를 만들었고, 그 몬스터가 죽으면 Drop을 만들었다. 그리고 두 클라가 같은 MonsterDeath와 같은 DropListSnapshot을 보게 했다.
작은 루프지만 이 작은 루프가 생기면서 Room은 로비보다 전장에 한 걸음 더 가까워졌다.
이전에 방을 만들고, 들어가고, 준비하고, 시작하는 흐름만으로는 아직 게임 서버라고 말하기 어려웠다. 하지만 이 순간부터는 조용히, 아주 조용히 이건 게임 서버라는 주장을 할 수 있을 것 같다.