
6편에서는 TCP 연결을 계속 유지하면서 연결별로 들어온 바이트를 쌓고 완성된 패킷을 하나씩 잘라내는 흐름까지 만들었다. 이제 서버는 한 번 인사하고 닫는 코드가 아니라, 다음 tick에도 같은 클라의 말을 들을 준비가 된 코드에 가까워졌다.
그렇게 패킷 하나를 꺼낼 수 있게 되자, 질문은 자연스럽게 다음으로 넘어갔다.
이 패킷은 어느 Room의 상태를 바꾸려는가? 그리고 그 판단은 어디에서 해야 하는가?
네트워크 코드 안에서 처리하고 싶은 생각이 들 수도 있다. 패킷을 읽은 그 자리에서 CreateRoom, JoinRoom, LeaveRoom 같은 처리를 호출하면 흐름이 가장 짧아 보이기 때문이다.
하지만 네트워크 코드는 이미 많은 일을 맡고 있었다. 소켓, 연결, 읽은 바이트, 남은 바이트, 끊김, 잘못된 패킷. 여기에 Room 상태까지 넣기 시작하면, 서버는 금방 어디서 무엇을 판단하는지 흐려질 것 같았다.
연결을 유지한다 한들 게임 상태 경계가 자동으로 생기지는 않는다.
서버는 이제 다음 tick에서도 같은 클라를 기억한다. 연결마다 패킷을 읽고 덜 온 바이트를 남겨두고 끊긴 연결도 정리한다. 여기까지 오면 꽤 서버다워진 느낌이 든다.
그런데 아직 게임 서버다운 질문은 남아 있다.
이 패킷을 보낸 session은 어느 Room에 있는가?
그 Room은 아직 존재하는가?
이 session은 이미 다른 Room에 들어가 있지는 않은가?
Room이 꽉 차 있지는 않은가?
나가려는 session은 정말 그 Room 안에 있는가?
이 질문은 바이트를 잘라내던 시절의 질문과는 다르다.
패킷을 온전하게 꺼내는 일은 네트워크 쪽의 책임이다. 하지만 꺼낸 패킷이 실제 게임 상태를 바꿔도 되는지 판단하는 일은 Room 쪽의 책임이어야 했다. 둘은 이어져 있지만 그렇다고 해서 같은 문제는 아니었다.

가장 간단하고 쉬운 코드는 대체로 이런 모양으로 흘러갈 것이다.
패킷을 읽는다
-> 타입을 본다
-> create room이면 rooms를 직접 만진다
-> join room이면 player list를 직접 만진다
-> leave room이면 player list에서 지운다
처음에는 나쁘지 않아 보인다. 파일 하나에서 흐름이 보이고 패킷을 읽자마자 결과가 나온다.
문제는 그 다음이다.
네트워크 코드가 rooms를 직접 만지기 시작하면, 네트워크 코드는 점점 Room의 규칙을 알아야 한다. 이미 방에 들어간 session인지, 방이 꽉 찼는지, 나가면 방을 지워야 하는지, Ready 상태가 붙으면 그 값을 언제 초기화해야 하는지까지 알아야 한다.
여기서부터 층(layer)이 섞인다.
소켓에서 읽을 데이터가 없다는 판단과, 어떤 session이 현재 Room 안에 없다는 판단이 같은 층에 놓인다. 잘못된 패킷을 보고 연결을 끊는 코드와, 빈 Room을 삭제하는 코드가 같은 손으로 상태를 만진다.
물론 당연히 그렇게 만들 수 있다. LLM과 함께라면 그럭저럭 어떻게 해서든 작동할 것이다. 다만 그건 누가 봐도 오래 버틸 구조처럼 보이지는 않았다.
내가 원한 건 이쪽이었다.
Network: 바이트를 읽고, 패킷 하나를 만든다.
Session: 이 연결이 어떤 런타임 주체인지 알려준다.
RoomManager: 이 session이 어느 Room에 묶였는지 찾는다.
Room: 자기 안의 상태 변경이 가능한지 판단한다.
각자가 모르는 게 있어야 한다 = 각자 맡은 책임이 명확해야 한다.
Room은 소켓에서 몇 바이트가 읽혔는지 몰라도 된다. 네트워크 코드는 Room의 멤버 목록을 직접 뒤지지 않아도 된다. Session은 Room 안의 규칙을 몰라도 된다.
이 무지가 곧 경계가 된다.
2편에서는 Room을 상태 경계로 골랐다. 이번 편에서는 코드로 좀 더 깊게 살펴보자.
Room 쪽에서 먼저 내려온 건 거창한 게임 로직은 아니다. 이 session이 안에 있는지, 들어올 수 있는지, 나갈 수 있는지 정도의 작은 판단이다.
bool Room::contains(uint64_t sessionId) const {
return std::find(playerSessionIds_.begin(), playerSessionIds_.end(), sessionId) !=
playerSessionIds_.end();
}
bool Room::addPlayer(uint64_t sessionId) {
if (contains(sessionId) || isFull()) {
return false;
}
playerSessionIds_.push_back(sessionId);
return true;
}
bool Room::removePlayer(uint64_t sessionId) {
auto it = std::find(playerSessionIds_.begin(), playerSessionIds_.end(), sessionId);
if (it == playerSessionIds_.end()) {
return false;
}
playerSessionIds_.erase(it);
return true;
}
코드는 별거 없지만 이 작은 코드의 방향만큼은 분명하다. Room 안의 플레이어 목록은 Room이 판단한다. 이미 들어온 session이면 다시 넣지 않고 꽉 찼으면 더 받지 않고 없는 session을 나가게 하지 않는다.
Room::addPlayer()는 이 요청이 어떤 소켓에서 왔는지 모른다. 몇 바이트짜리 패킷이었는지도 모른다. 클라가 언제 연결됐는지도 모른다. 그냥 sessionId 하나를 받고 자기 안의 상태 변경을 허용할지 본다.
Ready 상태를 다루기 시작해도 성격은 같다. 그 session이 현재 Room 안에 있는지부터 Room 도메인에서 판단해야 한다.
그런데 Session 때와 마찬가지로 Room만으로는 한 가지가 부족했다.
서버는 패킷을 보낸 쪽을 sessionId로 본다. 하지만 실제 상태는 여러 Room으로 나뉘어 있다. 그러면 서버는 먼저 물어야 한다.
이 sessionId는 어느 Room에 속해 있는가?
간단히 이 질문을 모든 Room에게 매번 던질 수도 있다. 하지만 그건 너무 흐릿하고 비효율적이다. 누가 멤버십 인덱스를 소유하는지 알기 어렵고 중복 참가를 막는 규칙도 여러 곳으로 흩어진다.
그래서 RoomManager가 필요했다.
joinRoom 흐름을 보면 이 경계가 더 잘 보인다.
RoomCommandResult RoomManager::joinRoom(uint64_t sessionId, uint32_t roomId) {
if (findRoomIdForSession(sessionId).has_value()) {
return RoomCommandResult(false, RoomCommandError::kAlreadyInRoom);
}
auto roomIt = rooms_.find(roomId);
if (roomIt == rooms_.end()) {
return RoomCommandResult(false, RoomCommandError::kNotFound);
}
if (roomIt->second.isFull()) {
return RoomCommandResult(false, RoomCommandError::kFull, summarizeRoom(roomIt->second));
}
if (!roomIt->second.addPlayer(sessionId)) {
return RoomCommandResult(false, RoomCommandError::kAlreadyInRoom);
}
// sessionId 하나만으로 roomId를 O(1)으로 찾을 수 있도록 해시맵에 매핑 정보를 저장
sessionToRoomId_.emplace(sessionId, roomId);
return RoomCommandResult(true, RoomCommandError::kNone, summarizeRoom(roomIt->second));
}
여기서 핵심은 sessionToRoomId_ 이다.
이 해시맵은 이름만 봐도 알 수 있듯이RoomManager가 단순히 Room 목록만 들고 있는 게 아니라, “어느 session이 어느 Room에 들어가 있는가”를 따로 기억한다는 뜻이기 때문이다.
이제 각각의 Room은 자기 안의 플레이어 목록만 알면 된다. 하지만 서버 전체 입장에서는 다른 질문이 필요하다.
이 session은 이미 어떤 Room에 들어가 있는가?
JoinRoom이 들어왔을 때 새 Room에 넣어도 되는가?
LeaveRoom이 들어왔을 때 어느 Room에서 빼야 하는가?
이 질문은 Room이 아니라 RoomManager가 답해야 한다.
그래서 RoomManager는 바깥쪽 판단을 맡는다. 이미 다른 Room에 들어가 있는지, target Room이 실제로 있는지, full room이면 어떤 에러를 돌려야 하는지, join이 끝난 뒤 sessionToRoomId_에 어떤 연결을 남길지 판단한다.
하지만 실제로 Room 안의 플레이어 목록을 바꾸는 일은 Room이 맡는다. 그래서 RoomManager가 full room을 먼저 걸러내더라도, 멤버 추가는 마지막에 Room::addPlayer()를 통과해야 한다.
네트워크 코드는 여기까지 들어오지 않는다. 네트워크 코드는 완성된 패킷과 session을 넘길 뿐이고, Room을 찾고 상태를 바꾸는 판단은 게임 도메인 쪽에 남긴다.

위 로직에 대한 짧은 테스트를 진행했다. 짧지만, 당연히 방향은 명확해야 했다.
Room은 고유한 roomId로 생성된다.
Room 정원이 차면 join은 실패한다.
이미 Room에 있는 session은 다시 Room을 만들 수 없다.
session이 leave하면 멤버 수가 줄어든다.
마지막 session이 leave하면 빈 Room은 제거된다.
Room 목록은 안정적인 순서로 나온다.
여전히 게임 로직은 1도 없다. 하지만 이 테스트들은 Room 경계가 지켜야 할 최소한을 증명했다. 특히 이미 Room에 있는 session을 다시 넣지 않는 것, 없는 session을 나가게 하지 않는 것, 비어버린 Room을 정리하는 것은 이후 기능이 붙어도 계속 중요해질 기준이었다.
여기서부터 서버는 조금씩 이런 말을 할 수 있게 된다.
패킷은 읽었다.
session도 안다.
하지만 상태 변경은 Room 규칙을 통과해야 한다.
들 때가 있다.
이번에 느낀 것은, 경계를 나눌 때 가장 어려운 건 “어디서 시작하는가”보다 “어디서 멈추는가”인 것 같다.
네트워크 코드는 패킷을 읽는 데서 멈춰야 한다. 정확히는 완성된 패킷과 그 패킷을 보낸 session을 다음 단계로 넘길 수 있으면 충분하다.
그 다음부터는 Room 도메인의 질문이다.
CreateRoom: 이 session은 이미 Room에 있는가?
JoinRoom: target Room은 존재하고, 아직 자리가 있는가?
LeaveRoom: 이 session은 실제로 Room에 속해 있는가?
Ready: 이 session의 준비 상태는 현재 Room 안에서 의미가 있는가?
이 질문들은 소켓이나 수신 버퍼가 답할 수 없다.
반대 또한 마찬가지다. Room도 네트워크의 질문에 답하면 안 된다. Room이 연결 끊김의 원인을 알고 있거나, 패킷이 몇 조각으로 들어왔는지 알고 있거나, 소켓이 읽을 수 없는 상태인지 판단하기 시작하면 경계가 거꾸로 새기 시작한다.
그래서 나는 이 무지를 의도적으로 설계하고 싶었다.
네트워크는 패킷을 만든다.
RoomManager는 session과 Room의 연결을 찾는다.
Room은 자기 안의 상태 변경만 판단한다.
아직 완벽한 구조라고 말할 수는 없다. 실제로 뒤로 갈수록 이런저런 기능이 붙으며 여러가지 문제가 더 붙을 것이다. 하지만 최소한 이 단계에서 Room을 네트워크 코드 안에 넣지 않겠다는 방향은 잡혔다.
