전편 한 줄 요약:
네트워크 코드 |
Room 도메인 경계
네트워크 코드는 바이트를 읽고, 패킷을 만들고, 그 패킷을 보낸 session을 다음 단계로 넘긴다. RoomManager는 그 session이 어느 Room과 이어져 있는지 보고, Room은 자기 안의 상태 변경을 판단한다.
좋다.
그럼 이어지는 질문은
그럼 클라는 Room에게 어떤 명령을, 어떻게 보낼 것인가?
CreateRoom, JoinRoom, LeaveRoom는 코드에서 함수다. 하지만 클라와 서버 사이에서는 함수 호출이 아니라 바이트가 전송된다.
클라가 보낸 바이트를 서버가 같은 의미로 읽어야 하고, 서버가 보낸 바이트도 클라가 같은 의미로 읽어야 한다. 어느 값이 요청이고, 어느 값이 응답인지, 실패했을 때는 무엇을 보내야 하는지도 정해야 한다.
그래서 이번 글은 Room command를 TCP 패킷 계약으로 묶은 내용을 적어보려고 한다.
서버 코드 안에서 Room command 함수들을 대략 이렇게 구성했다.
roomManager.createRoom(sessionId)
roomManager.joinRoom(sessionId, roomId)
roomManager.leaveRoom(sessionId)
당연하게도 이건 서버 내부에서만 가능한 표현이다. roomManager라는 객체도 서버 안에 있고, createRoom이라는 함수도 서버 코드 안에 있다. sessionId 역시 서버가 이미 알고 있는 값이다. 그래서 아주 기초적인 영어만 할 줄 안다면 코드를 전혀 모르는 사람일지라도 이 코드 한 줄이면 뜻을 파악할 수 있을 것이다.
이 session으로 새 Room을 만든다.
이 session을 특정 Room에 넣는다.
이 session을 Room에서 뺀다.
하지만 클라는 서버의 함수를 직접 부르지 않는다. 클라가 할 수 있는 일은 TCP 연결 위로 바이트를 보내는 것뿐이다.
그러면 서버와 클라 사이에는 다른 약속이 필요하다.
0x0101이면 CreateRoomRequest
0x0102이면 CreateRoomResponse
0x0103이면 JoinRoomRequest
...
물론 어떤 형태의 약속으로 설계해야 할지에 대해서는 고민을 많이 했다. Room은 상태 경계다. 그런데 그 Room으로 들어가는 말이 애매하다면, 결국 상태 경계도 같이 애매해질 것이 분명하다. 그래서 일단 먼저 패킷 계약을 작게 고정하기로 결정했다.

설계한 Room packet의 종류는 초기 버전임을 감안해 지금 딱 필요하다고 생각하는 것들만 구성했다.
enum class TcpPacketType : uint16_t {
kWelcome = 0x0001,
kClientListSnapshot = 0x0002,
kCreateRoomRequest = 0x0101,
kCreateRoomResponse = 0x0102,
kJoinRoomRequest = 0x0103,
kJoinRoomResponse = 0x0104,
kLeaveRoomRequest = 0x0105,
kLeaveRoomResponse = 0x0106,
kRoomListSnapshot = 0x0107,
kError = 0x01FF,
};
struct TcpRoomEntry {
uint32_t roomId{0};
uint16_t playerCount{0};
uint16_t maxPlayers{0};
};

이 구성은 단순해 보이나 꽤 많은 선을 그어냈다.
클라가 새 방을 만들고 싶으면 CreateRoomRequest를 보낸다. 서버가 그 요청을 받아들여 방을 만들었다면 CreateRoomResponse로 roomId와 현재 인원수를 알려준다.
클라가 방에 들어가고 싶으면 JoinRoomRequest를 보낸다. 이때는 target roomId가 필요하다. 서버가 받아들였다면 JoinRoomResponse를 보낸다.
클라가 방에서 나가고 싶으면 LeaveRoomRequest를 보낸다. 서버가 처리했다면 LeaveRoomResponse로 어느 Room에서 빠져나왔는지 알려준다.
그리고 전체 Room 목록은 RoomListSnapshot으로 보낸다.
여기서 중요한 건 request와 response를 분리했다는 점이었다.
Request: 클라가 시도한다.
Response: 서버가 받아들인 결과를 알려준다.
Snapshot: 서버가 보는 현재 목록을 알려준다.
Error: 서버가 거절한 이유를 알려준다.
이 게임 서버는 Server-Origin Server임을 잊지 말자. 클라가 JoinRoomRequest를 보냈다? 그것이 클라가 Room에 들어갔다는 것이 아니다. 그건 아직 요청일 뿐이다. 서버가 Room 상태를 보고 확인하고 받아들여야 그때부터 입장이다. 서버 권한 구조 게임 서버 제작 입장에서는 아주 기본적인 정책 방향이다.
1편에서 잡은 서버 권한 원칙이 바로 이곳에서 패킷 타입들로 나타난 셈이다.
사실 처음에는
CreateRoomRequest를 보냈고 성공했으면 그냥 RoomListSnapshot만 보내도 되지 않을까?
JoinRoomRequest도 성공하면 목록에 인원이 늘어날 테니 그걸 보면 되지 않을까?
이런 생각을 했다. 물론 가능은 하다.
하지만 그러면 클라 입장에서 자기 요청이 어떻게 끝났는지 애매해진다. 서버-클라 구조가 1:1 구조가 아닌 1:N 구조임을 떠올린다면, 클라 입장에서는 방 목록이 바뀐 건 알겠는데 그 변화가 내 요청 때문인지, 다른 클라가 방을 만든 건지, 내가 실제로 그 방에 들어간 건지 구분하기 어렵다.
그래서 일종의 command ACK에 가까운 응답을 따로 둔 것이다. 참고로 여기서 ACK(Acknowledgement)는 대단히 거창한 말이 아니라, "네 요청은 이렇게 처리됐다"는 짧은 확인에 가깝다.
CreateRoomResponse: 네가 만든 Room은 이 roomId다.
JoinRoomResponse: 네가 들어간 Room은 이 roomId다.
LeaveRoomResponse: 네가 나온 Room은 이 roomId다.
여기서도 Room 상태를 전부 설명하려고 하지는 않았다. 응답은 말 그대로 command의 결과를 알려주는 최소한의 대답이다.
구조도 아직은 아주 간단하다.
CreateRoomResponse = roomId + playerCount
JoinRoomResponse = roomId + playerCount
LeaveRoomResponse = roomId
또 한 번 강조하지만 Room의 모든 정보를 여기에 넣지 않는다. 닉네임도 없고, Ready 상태도 없다. 아직은 이 command가 성공했는지, 성공했다면 어느 Room과 이어졌는지만 알려주면 된다고 생각한다.
나는 이 작고 간단한 응답이 꽤 중요하다고 생각했다.

Room List는 위에서 언급한 Response들과는 조금 다른 문제였다.
CreateRoomResponse나 JoinRoomResponse는 요청자 개인에게 돌아가는 결과에 가깝다. 하지만 방 목록은 여러 클라가 집단으로 같이 보는 서버 상태다.
그래서 별도 packet으로 뺐다.
RoomListSnapshot
count
RoomEntry * count
RoomEntry 하나는 이 정도만 담았다.
roomId
playerCount
maxPlayers
일단 지금은 이것만으로 충분하다 생각했다. 어떤 방이 있고, 몇 명이 들어가 있고, 최대 몇 명까지 들어갈 수 있는지만 알면 목록 화면 비슷한 것은 만들 수 있을 것이다.
여기서 핵심은 Snapshot이라는 단어였다.
방이 하나 생겼다거나 플레이어 한 명이 Room에 입장했다는 delta event(변경된 부분만 알리는 이벤트)를 보내는 게 아니라 서버가 현재 들고 있는 Room 목록 전체를 다시 보낸다.
초기에는 아래처럼 구성하는 것이 훨씬 단순하긴 했다.
RoomCreated event
RoomJoined event
RoomLeft event
하지만 이런 식으로 쪼개면 클라가 이벤트를 차곡차곡 적용해야 한다. 중간에 하나를 놓치면 목록이 틀어질 수 있고, 재접속한 클라에게 현재 상태를 다시 설명하기도 까다로워진다.
반면 snapshot은 이벤트 방식보다는 더 많은 바이트를 보내지만 의미는 분명하다.
서버가 지금 보는 Room 목록은 이거다.

RoomListSnapshot를 구성하고 나니 바로 또 다른 질문이 생겼다.
count가 2라고 쓰여 있는데 실제 RoomEntry가 1개면 어떻게 할 것인가?
이건 사소해 보이지만 패킷 계약에서는 꽤 중요하다.
패킷 안에 count가 있으면, 그 값은 뒤따르는 payload 길이와 맞아야 한다. 맞지 않으면 어디까지가 RoomEntry인지 확신할 수 없다. 서버도 클라도 마찬가지다.
그래서 parser는 count와 실제 길이를 같이 봤다.
size_t roomListSnapshotPacketSize(size_t roomCount) {
return kTcpHeaderSize + kRoomListCountFieldSize + (roomCount * kRoomEntrySize);
}
bool parseRoomListSnapshotPacket(
const uint8_t* data,
size_t size,
TcpPacketHeader& outHeader,
std::vector<TcpRoomEntry>& outRooms) {
// 출력 인자인 outRooms 비우기
outRooms.clear();
if (!parseTcpPacketHeader(data, size, outHeader)) {
return false;
}
if (outHeader.type != TcpPacketType::kRoomListSnapshot ||
size < kTcpHeaderSize + kRoomListCountFieldSize) {
return false;
}
const uint16_t count = readU16BE(data + kTcpHeaderSize);
const size_t expectedSize = roomListSnapshotPacketSize(count);
if (expectedSize != size) {
return false;
}
// 미리 메모리 확보
outRooms.reserve(count);
const uint8_t* payload = data + kTcpHeaderSize + kRoomListCountFieldSize;
for (uint16_t i = 0; i < count; ++i) {
const uint8_t* entry = payload + (static_cast<size_t>(i) * kRoomEntrySize);
outRooms.push_back(TcpRoomEntry{
readU32BE(entry),
readU16BE(entry + kRoomIdFieldSize),
readU16BE(entry + kRoomIdFieldSize + kPlayerCountFieldSize),
});
}
return true;
}
여기서 중요한 건expectedSize != size다. 이 한 줄에는 참 많은 의미가 내포되어 있다.
count를 그냥 믿지 않는다. header의 Size도 그냥 믿지 않는다. 둘을 서로 맞춰본다. count가 말하는 RoomEntry 개수와 실제 packet 길이가 어긋나면 그 packet은 Room 목록이 아니다.
이게 없으면 이상한 일이 생긴다.
count = 2
실제 entry = 1개
남은 바이트 = 다음 packet의 일부
이런 상태에서 억지로 읽기 시작하면 Room 목록뿐 아니라 다음 패킷 경계까지 같이 흔들리는 일이 발생해버린다.
5편에서는 Size를 보고 패킷 하나를 잘라냈다면, 8편에서는 payload 안의 count까지 다시 확인한 셈이다.
바이트 경계가 맞다 한들 payload 계약이 틀릴 수 있으니 엄연히 이 둘은 다른 문제라고 볼 수 있겠다.

아무리 코드를 잘 짜더라도 Room command는 실패할 수밖에 없다. 너무 단언하는 것 같지만 세상에는 완벽한 코드가 없다고 하지 않는가.
아무튼. 플레이어는 '존재하지 않는 방'에 들어가려고 할 수 있다. 이미 방에 들어가 있는 플레이어가 그 안에서 방을 또 만들려고 할 수 있다.(UI적으로는 말이 안 되겠지만) 최대 플레이어 수를 꽉 채운 방에 들어가려고 할 수도 있다. 방에 속하지도 않았는데 나가겠다고 할 수도 있다.
이 실패를 서버 로그에만 남기면 클라는 아무것도 모른다. 반대로 실패마다 제각각 다른 응답을 만들면 클라 쪽 처리가 금방 지저분해진다.
그래서 실패도 하나의 packet으로 고정했다.
Error
failedType
errorCode
failedType은 어떤 요청이 실패했는지 알려준다.
errorCode는 왜 실패했는지 알려준다.
아래와 같이 필수적인 구성만 챙겨봤다.
Full
NotFound
AlreadyInRoom
NotInRoom
여기서도 핵심은 서버가 실패를 숨기지 않는다는 점이었다.
클라가 JoinRoomRequest를 보냈는데 대상 Room이 없다면 서버는 조용히 무시하는 것이 아니라 Error를 보내고, 그 안에 실패한 요청 타입과 원인을 넣는다.
bool serializeErrorPacket(
TcpPacketType failedType,
TcpErrorCode errorCode,
std::array<uint8_t, kErrorPacketSize>& outPacket) {
writePacketHeader(TcpPacketType::kError, outPacket);
writeU16BE(static_cast<uint16_t>(failedType), outPacket.data() + kTcpHeaderSize);
writeU16BE(static_cast<uint16_t>(errorCode), outPacket.data() + kTcpHeaderSize + 2);
return true;
}
이것도 별거 아닌 코드처럼 보이지만 이 작은 Error가 있어야 클라와 서버가 실패를 같은 언어로 말할 수 있다.
JoinRoomRequest 실패
이유는 NotFound
그냥 실패가 아니라 어떤 요청이 어떤 이유로 실패했는지 남긴다.
나중에 좀 더 자세히 언급하겠지만, 게임 서버에서 실패는 예외 상황이 아니라 정상적인 계약의 일부다.(우리네 인생에도 실패는 정상적인 것이라 믿는다😉) 오히려 실패를 어떻게 말하는지가 서버 권한 구조를 더 도드라지게 만들 것이다.
패킷 계약은 serializer와 parser에서 끝나지 않는다.(serializer는 코드 안의 값을 TCP로 보낼 바이트로 바꾸는 쪽이고, parser는 TCP로 받은 바이트를 다시 코드가 이해할 값으로 읽어내는 쪽이다.) 결국 서버 루프 안에서 실제 Room command로 이어져야 한다.
이 루프 흐름을 간략히 표현하자면 아래와 같다.
switch (header.type) {
case Net::TcpPacketType::kCreateRoomRequest: {
// packet.data()는 실제 바이트 시작 주소
if (!Net::parseCreateRoomRequestPacket(packet.data(), packet.size(), header)) {
markClientForDisconnect(disconnectedClients, connection.clientFd());
return false;
}
// byte 검증 완료 후 도메인 검증 시작
const Game::RoomCommandResult result = roomManager_.createRoom(session->sessionId());
if (!result.ok) {
std::array<uint8_t, Net::kErrorPacketSize> errorPacket{};
Net::serializeErrorPacket(
Net::TcpPacketType::kCreateRoomRequest,
toTcpErrorCode(result.error),
errorPacket);
return sendPacketToClient(
connection.clientFd(),
errorPacket.data(),
errorPacket.size(),
disconnectedClients);
}
// 방 생성 성공
std::array<uint8_t, Net::kRoomStatusPacketSize> responsePacket{};
Net::serializeCreateRoomResponsePacket(
result.room.roomId,
result.room.playerCount,
responsePacket);
...
}
먼저 패킷 parser가 byte 형태를 확인한다. CreateRoomRequest가 맞는지, 길이가 맞는지 본다. 이 단계는 진짜 byte 형태만 확인하고 아직 Room 규칙을 보지 않는다.
그다음 RoomManager가 도메인 규칙을 본다. 이 session이 이미 Room에 들어가 있는지, 새 Room을 만들 수 있는지 판단한다. 같은 요청이라도 현재 Room 상태에 따라 결과가 달라질 수 있다.
실패하면 Error를 보낸다. 성공하면 CreateRoomResponse를 보낸다.
packet shape 검증
-> Room domain 검증
-> success response 또는 Error
이렇게 보면 7편에서 말한 경계가 packet 처리 코드 안에서도 드러난다.
네트워크 코드는 Room의 내부 목록을 직접 만지지 않는다. 하지만 아무 패킷이나 RoomManager로 넘기지도 않는다. 먼저 TCP packet contract를 통과해야 한다.
RoomManager도 마찬가지다. 패킷이 잘 생겼다고 해서 무조건 상태를 바꾸지 않는다. 현재 Room 상태를 다시 본다.
두 번의 문이 생긴 셈이다.

이 단계에서의 테스트는 두 계층으로 나눴다.
첫 번째는 packet 자체 테스트다.
CreateRoomResponse를 만들고 다시 읽는다.
JoinRoomRequest를 만들고 다시 읽는다.
RoomListSnapshot을 만들고 다시 읽는다.
RoomListSnapshot의 count와 실제 길이가 다르면 거부한다.
RoomListSnapshot이 최대 packet size를 넘으면 만들지 않는다.
Error packet을 만들고 failedType/errorCode를 다시 읽는다.
이 테스트들은 RoomManager를 보지 않는다. 소켓도 열지 않는다. 그냥 바이트 계약이 맞는지만 본다. 방에 들어가는 로직이 맞는지 보기 전에, 방에 들어가겠다는 말을 서로 같은 모양으로 주고받을 수 있는지부터 닫은 셈이다.
두 번째는 서버 통합 테스트였다.
client A connect
-> Welcome 수신
-> CreateRoomRequest 송신
-> CreateRoomResponse 수신
-> RoomListSnapshot 수신
-> LeaveRoomRequest 송신
-> LeaveRoomResponse 수신
-> 빈 RoomListSnapshot 수신
그리고 다른 흐름도 봤다.
client B가 존재하지 않는 roomId를 이용해 JoinRoomRequest 송신
-> Error(failedType=JoinRoomRequest, errorCode=NotFound) 수신
여기서부터 조금 안심되기 시작했다.
단순히 RoomManager::createRoom()이 동작한다는 테스트가 아니다. 클라가 실제 TCP 연결 위에서 Room command packet을 보내고, 서버가 그 packet을 읽고, RoomManager를 거쳐, 다시 packet으로 응답하는 흐름을 본다.

막상 글의 막바지에 오니 구성했던 Room packet이 왜 이렇게 작아 보이는지 모르겠다.
방 제목도 없다. 닉네임도 없다. 방장 정보도 없다.
그런데 사실 오히려 그게 이 시점의 핵심이었다. 처음부터 Room의 모든 정보를 packet 하나에 넣으려고 하면 금방 커진다. 그리고 커진 packet은 이상하게 많은 책임을 갖기 시작한다.
처음에는 더 작은 질문이 필요했다.
방을 만들 수 있는가?
방에 들어갈 수 있는가?
방에서 나갈 수 있는가?
현재 방 목록을 같은 모양으로 볼 수 있는가?
실패했을 때 같은 언어로 말할 수 있는가?
이 질문들만 먼저 닫기로 결정했다.
그래서 Room packet 계약은 화려하지 않지만 네트워크와 Room 도메인이 처음으로 같은 말을 쓰게 만든 작은 사전 같은 무언가가 되었다.
7편에서 경계를 그었다면, 8편에서는 그 경계를 통과할 수 있는 단어들을 정한 셈이다.
잊지 말자, 서버 권한 구조 서버.