


12편에서는 BattleStart 다음에 서버가 처음으로 만든 게임 상태를 다뤘다. Room 안에 Monster가 생겼고, 그 몬스터가 죽으면 Drop이 생겼다. 그리고 두 클라가 같은 MonsterDeath와 같은 DropListSnapshot을 보게 했다.
그런데 우리는 서버 권한 게임 서버를 구현 중이다.
그렇다면,
클라는 몬스터가 죽었다고 말할 수 있을까?
패킷 이름이 MonsterDeathRequest니까, 얼핏 보면 클라가 서버에게 이렇게 말하는 것처럼 보인다.
monsterId 7이 죽었음.
하지만 이 요청을 그대로 받아 들이는 건 위험하다. 클라는 결과를 확정하는 쪽이 아니다. 클라가 보낼 수 있는 건 어디까지나 "이 몬스터를 죽이는 처리를 해달라"는 요청이다. 그 요청을 실제 죽음으로 바꿀지, 에러로 돌려보낼지는 서버가 결정해야 한다.
그러니까 이 패킷의 의미는,
클라 -> 서버: monsterId 7을 죽이는 처리를 시도하고 싶다.
서버 -> 클라들: 검증해보니 monsterId 7이 죽었다.
비슷해 보일지라도 엄연히 다른 말이다.

MonsterDeathRequest라는 무슨 패킷인지 한눈에 볼 수 있게 정했다. 몬스터 사망과 관련된 요청인 것이다. 하지만 우리의 게임 서버가 서버 권한 게임 서버임을 모른다면 이 이름 때문에 오해를 살 수도 있다. 그래서 계속 강조를 하는 것이다.
MonsterDeathRequest를 "몬스터가 죽었다는 결과를 클라가 서버에게 알려주는 패킷"처럼 읽으면 안 된다. 그 순간 서버 권한 구조가 흐려진다.
서버 입장에서 이 요청은 아직 결과가 아니다. 아직은 그냥 입력(intent)일 뿐이다.
조금 더 정확히 말하면, 서버가 이미 들고 있는 Room 상태를 바꾸려는 시도다. 이 요청이 들어와도 서버는 바로 MonsterDeath를 broadcast하지 않는다. 그 전에 먼저 물어봐야 한다.
이 질문들을 통과해야 그때서야 서버는 "좋다. 이 몬스터는 죽었다"고 말할 수 있다.
클라는 죽음을 선언하지 않는다. 의사 면허를 갖고 있는 것은 오직 서버다.
MonsterDeathRequest 패킷은 어떻게 구현했을까.
Size 2 bytes
Type 2 bytes
monsterId 4 bytes
전체 8바이트다, 매우 작다. roomId도 없고, sessionId도 없고, dropId도 없다. 누가 때렸는지, 어느 Room에서 일어난 일인지, 죽으면 어떤 아이템이 떨어지는지 아무것도 담지 않는다.
하지만 의도한 바다.
bool serializeMonsterDeathRequestPacket(
uint32_t monsterId,
std::array<uint8_t, kMonsterDeathRequestPacketSize>& outPacket) {
writePacketHeader(TcpPacketType::kMonsterDeathRequest, outPacket);
writeU32BE(monsterId, outPacket.data() + kTcpHeaderSize);
return true;
}
클라가 보내는 건 monsterId 하나뿐이다.
어느 session이 보냈는지는 어차피 TCP 연결에 이미 묶여 있다. 어느 Room에 있는지는 RoomManager가 session을 보고 찾는다. 죽었을 때 만들 dropId는 서버가 발급한다.
오히려 클라가 이 값들을 다 보내기 시작하면 서버는 다시 의심해야 한다.
생각만 해도 피곤하다. 아니 피곤한 정도가 아니라 위험하다.
그래서 요청은 작게 둔다. 클라는 단순히 목표만 말하도록 설계한다.
monsterId 7을 처리하고 싶다.
나머지는 서버가 자기 상태를 보고 붙이는 것이다.
서버 루프에서 이 요청을 받으면 바로 Room을 건드리지 않는다.
흐름은 11편에서 봤던 Ready 처리와 비슷해 보이지만, 의미가 조금 다르다. Ready는 플레이어 상태를 바꾸는 요청이고, MonsterDeath는 Room 안의 전장 상태를 바꾸는 요청이다.
코드로 표현하면 아래와 같다.
case Net::TcpPacketType::kMonsterDeathRequest: {
uint32_t monsterId = 0;
if (!Net::parseMonsterDeathRequestPacket(packet.data(), packet.size(), header, monsterId)) {
markClientForDisconnect(disconnectedClients, connection.clientFd());
return false;
}
const Game::RoomCommandResult result =
roomManager_.defeatMonster(session->sessionId(), monsterId);
if (!result.ok) {
std::array<uint8_t, Net::kErrorPacketSize> errorPacket{};
Net::serializeErrorPacket(
Net::TcpPacketType::kMonsterDeathRequest,
toTcpErrorCode(result.error),
errorPacket);
return sendPacketToClient(
connection.clientFd(),
errorPacket.data(),
errorPacket.size(),
disconnectedClients);
}
if (result.monsterJustDefeated &&
!broadcastMonsterDeath(result, disconnectedClients)) {
return false;
}
if (result.monsterJustDefeated &&
!broadcastDropListSnapshot(result, disconnectedClients)) {
return false;
}
return true;
}
어째 11편에서 봤던 것과 익숙하지 않은가.
parse 실패 -> 연결 정리 후보
domain 실패 -> 요청자에게 Error
domain 성공 -> Room 멤버에게 MonsterDeath
domain 성공 -> Room 멤버에게 DropListSnapshot
MonsterDeath의 실패와 성공 분기도 Ready 때와 마찬가지로 전파되는 범위를 다르게 설계했다.
잘못된 monsterId를 보낸 건 그 요청자의 문제다. 그래서 요청자에게만 Error를 보낸다. 다른 클라에게는 아무것도 보내지 않는다.
반대로 서버가 몬스터 사망을 확정했다면 그건 Room 전체 상태다. 그래서 Room 안의 클라들이 모두 같은 MonsterDeath를 봐야 한다.
실패는 좁게.
성공한 상태 변화는 Room 전체로.

MonsterDeathRequest에는 monsterId만 있다고 했다.
그럼 서버는 이 패킷에 무엇을 더 붙여서 사용할까?
먼저 sessionId다. 이 값은 클라가 보낸 payload에서 꺼내지 않는다. 이미 서버가 연결에 묶어둔 session을 사용한다.
const Game::RoomCommandResult result =
roomManager_.defeatMonster(session->sessionId(), monsterId);
클라는 "어떤 몬스터를 처리하고 싶은지"만 말한다. 서버는 "누가 보냈는지"를 자기 연결 상태에서 붙인다. 그리고 RoomManager는 그 session이 어느 Room에 있는지 찾는다.
RoomManager::defeatMonster() 쪽으로 들어가면 이 흐름이 더 직접적으로 보인다.
RoomCommandResult RoomManager::defeatMonster(uint64_t sessionId, uint32_t monsterId) {
auto mappingIt = sessionToRoomId_.find(sessionId);
if (mappingIt == sessionToRoomId_.end()) {
return RoomCommandResult(false, RoomCommandError::kNotInRoom);
}
auto roomIt = rooms_.find(mappingIt->second);
if (roomIt == rooms_.end()) {
sessionToRoomId_.erase(mappingIt);
return RoomCommandResult(false, RoomCommandError::kNotFound);
}
Room& room = roomIt->second;
if (!room.contains(sessionId)) {
sessionToRoomId_.erase(mappingIt);
return RoomCommandResult(false, RoomCommandError::kNotInRoom);
}
const uint32_t dropId = nextDropId_;
if (!room.defeatMonster(monsterId, dropId, kDefaultDropItemId, kDefaultDropQuantity)) {
return RoomCommandResult(false, RoomCommandError::kNotFound, summarizeRoom(room));
}
++nextDropId_;
return RoomCommandResult(
true,
RoomCommandError::kNone,
summarizeRoom(room),
room.playerSessionIds(),
false,
false,
true,
room.monster(),
room.drops());
}
함수명만 봤을 때는 단순히 monsterId 하나 처리하는 함수처럼 보이지만 실제로는 훨씬 많은 것을 확인할 수 있다.
session이 Room에 없으면 실패한다. session과 Room 매핑이 깨졌으면 정리하고 실패처리 한다. Room 안에 그 session이 없으면 실패한다. 여기까지 통과해야 실제 Room 상태를 다룰 수 있는 것이다.
그리고 dropId도 여기서 붙이게 된다. 클라는 그저 몬스터 사망 요청을 보냈을 뿐이지만, 그 결과로 어떤 dropId가 생기는지는 서버가 정하게 된다.
클라 측 payload: monsterId
↳ 서버 측 identity: sessionId
↳ 서버 측 state: roomId
↳ 서버 측 result: dropId
각 값의 출처가 다르게끔 설계했는데, 이걸 섞어서 뭉개버리면 금방 이상해지기 때문이다. 클라가 sessionId도 보내고, roomId도 보내고, dropId도 보내기 시작하면 서버는 요청을 처리하는 게 아니라 클라의 주장을 일일이 확인하고 반박하는 코드가 된다.
RoomManager가 session과 Room을 찾고 서버 id를 준비한다면, Room은 자기 안의 상태만 본다.
bool Room::defeatMonster(uint32_t monsterId, uint32_t dropId, uint32_t itemId, uint16_t quantity) {
if (!hasAliveMonster() || monster_.monsterId != monsterId) {
return false;
}
if (quantity == 0) {
return false;
}
const auto duplicateDropIt = std::find_if(
drops_.begin(),
drops_.end(),
[dropId](const Drop& drop) { return drop.dropId == dropId; });
if (duplicateDropIt != drops_.end()) {
return false;
}
monster_.alive = false;
drops_.push_back(Drop{dropId, itemId, quantity});
return true;
}
Room은 누가 요청을 보냈는지 모른다. 어떤 fd에서 왔는지도 모른다. 패킷이 몇 바이트였는지도 모른다.
그냥 자기 상태를 본다.
나중에는 대미지, 공속, 사거리 같은 기준들이 더 붙을 수 있겠지만 여기서 증명하고 싶었던 건 클라가 monsterId를 보냈고, 서버가 Room 상태를 보고 거절하거나 확정한다는 서버 권한 게임 서버였다.
Battle에 관한 글이지만, 사실 일반적으로 생각하는 전투는 없다. 하지만 지금 우리에게 중요한 것은 죽었다는 말은 클라가 하는 게 아니라 서버가 한다는 것이다.

테스트는 실패 case를 위주로 다뤘다.
ASSERT_TRUE(sendMonsterDeathRequestPacket(battle.clientA, battle.monsterId + 1));
Net::TcpPacketType failedType = Net::TcpPacketType::kWelcome;
Net::TcpErrorCode errorCode = Net::TcpErrorCode::kNone;
ASSERT_TRUE(recvErrorPacket(battle.clientA, failedType, errorCode));
EXPECT_EQ(failedType, Net::TcpPacketType::kMonsterDeathRequest);
EXPECT_EQ(errorCode, Net::TcpErrorCode::kNotFound);
ASSERT_TRUE(setReceiveTimeout(battle.clientB, std::chrono::milliseconds(50)));
std::vector<uint8_t> unexpectedPacket;
EXPECT_FALSE(recvPacket(battle.clientB, unexpectedPacket));
ASSERT_TRUE(setReceiveTimeout(battle.clientB, std::chrono::milliseconds(500)));
간단하지만 중요하다.
실패한 요청은 Room 전체 사건이 아니다. 잘못된 요청을 다른 클라에게 알려줄 필요가 없다. 아니, 알려주면 더 이상하다. B 입장에서는 아무 일도 일어나지 않았기 때문이다.
서버가 상태 변화를 확정하지 않았으면 broadcast도 없어야 한다.
이번에는 같은 몬스터를 다시 죽이려는 요청이다.
ASSERT_TRUE(sendMonsterDeathRequestPacket(battle.clientA, battle.monsterId));
uint32_t deathRoomId = 0;
uint32_t deadMonsterId = 0;
ASSERT_TRUE(recvMonsterDeathPacket(battle.clientA, deathRoomId, deadMonsterId));
ASSERT_TRUE(recvMonsterDeathPacket(battle.clientB, deathRoomId, deadMonsterId));
uint32_t dropRoomId = 0;
std::vector<Net::TcpDropEntry> drops;
ASSERT_TRUE(recvDropListSnapshotPacket(battle.clientA, dropRoomId, drops));
ASSERT_TRUE(recvDropListSnapshotPacket(battle.clientB, dropRoomId, drops));
ASSERT_TRUE(sendMonsterDeathRequestPacket(battle.clientB, battle.monsterId));
ASSERT_TRUE(recvErrorPacket(battle.clientB, failedType, errorCode));
EXPECT_EQ(failedType, Net::TcpPacketType::kMonsterDeathRequest);
EXPECT_EQ(errorCode, Net::TcpErrorCode::kNotFound);
처음 요청은 성공한다. A와 B가 모두 MonsterDeath를 받고, 둘 다 DropListSnapshot도 받는다.
그 다음 B가 같은 monsterId로 다시 요청한다. 이번에는 Error다.
서버가 한 번 죽었다고 확정한 몬스터는 다시 죽지 않는다. 당연한 말 같지만, 이 당연함을 코드와 테스트로 잡아두지 않으면 나중에 이상한 버그가 생길지도 모른다.
예를 들어 같은 몬스터에서 drop이 두 번 생기면 어떻게 될까.
처음에는 그냥 "어? 아이템이 하나 더 생겼네?" 정도로 보일 수 있다. 하지만 이건 루팅 경쟁, 인벤토리, 정산까지 다 흔드는 문제로 퍼질 수도 있다.
서버가 몬스터 사망을 확정하면 그건 Room 전체 상태 변화다. 그래서 Room 안의 멤버들에게 MonsterDeath를 보낸다.
bool Server::broadcastMonsterDeath(
const Game::RoomCommandResult& result,
std::vector<int>& disconnectedClients) {
std::array<uint8_t, Net::kMonsterDeathPacketSize> packet{};
Net::serializeMonsterDeathPacket(result.room.roomId, result.monster.monsterId, packet);
bool allSucceeded = true;
for (const auto& entry : connections_) {
if (!isRoomMember(result, entry.second->sessionId())) {
continue;
}
if (!sendPacketToClient(entry.first, packet.data(), packet.size(), disconnectedClients)) {
allSucceeded = false;
}
}
return allSucceeded;
}
그리고 드랍 목록도 보낸다.
bool Server::broadcastDropListSnapshot(
const Game::RoomCommandResult& result,
std::vector<int>& disconnectedClients) {
std::vector<uint8_t> packet;
const std::vector<Net::TcpDropEntry> drops = toTcpDropEntries(result.drops);
if (!Net::serializeDropListSnapshotPacket(result.room.roomId, drops, packet)) {
return false;
}
bool allSucceeded = true;
for (const auto& entry : connections_) {
if (!isRoomMember(result, entry.second->sessionId())) {
continue;
}
if (!sendPacketToClient(entry.first, packet.data(), packet.size(), disconnectedClients)) {
allSucceeded = false;
}
}
return allSucceeded;
}
모든 연결에 보내는 게 아니라 Room 멤버에게만 보내는 것이다. 당연히 이 Room 안에 없는 클라는 이 몬스터가 죽었는지 알 필요가 없다.
하나 더 언급하고 싶은 것은 먼저 몬스터가 죽었다는 event를 보고, 그 다음 바닥에 무엇이 남았는지 보도록 설계했다는 것이다.
이 순서가 있어야 클라 입장에서도 말이 된다. 죽지도 않은 몬스터에서 드랍 목록이 먼저 날아오면 이상하다. 서버가 상태를 어떻게 바꿨는지와 클라가 상태를 어떻게 보게 되는지가 같은 방향으로 흘러야 한다고 생각했다.
MonsterDeathRequest는 이름에 Death가 들어가지만 결과가 아니라 클라가 보낸 의도(intent)다.
서버는 그 시도에 session을 붙이고, Room을 찾고, 살아 있는 몬스터인지 확인하고, 맞는 monsterId인지 본다. 그 뒤에야 MonsterDeath와 DropListSnapshot을 Room 멤버에게 보낸다.
틀린 요청은 요청자에게만 Error로 돌아간다. 이미 죽은 몬스터를 다시 죽이려 해도 Error다. 서버가 확정한 상태 변화만 모두에게 퍼진다.
전편에서 서버는 처음으로 Monster와 Drop을 만들었다.
이번 편에서는 그 상태를 클라가 마음대로 바꾸게 두지 않으며 다시 한 번 서버 권한 게임 서버의 존재감을 보여주었다.