호외요, 호외

반짇고리·2026년 4월 10일

게임 서버 제작기

목록 보기
9/15


전편에서는 command 패킷을 설계해서 클라가 Room에게 어떤 명령을 어떻게 보낼지 정했다.

좋다. 이제 클라는 적어도 서버에게 방을 만들고 싶다거나, 특정 방에 들어가고 싶다거나, 방에서 나가고 싶다고 말할 수 있다.

이제 다음 문제는,

Room 목록이 바뀌었다는 사실은 누구에게 알려야 하는가?

클라 A가 방을 만들었다고 가정해보자. A에게 CreateRoomResponse를 보내면 A는 자기가 만든 방의 roomId를 알 수 있다.

하지만 B는?

B도 로비에 있다면 새 방이 생겼다는 걸 알아야 한다. 그래야 B가 그 방에 들어갈 수 있다. C도 마찬가지다. Room 목록은 A 한 명의 개인 상태가 아니라, 그 서버에 붙어 있는 클라들이 모두 볼 수 있어야 한다.

그래서 이번 편은 RoomListSnapshot을 요청자에게만 돌려주는 응답이 아니라, 서버가 전체 클라에게 다시 뿌리는 공유 상태로 설계하는 이야기가 될 것이다.


응답과 브로드캐스트

8편에서 command response를 따로 둔 이유를 적었다.

CreateRoomResponse: 네가 만든 Room은 이 roomId다.
JoinRoomResponse: 네가 들어간 Room은 이 roomId다.
LeaveRoomResponse: 네가 나온 Room은 이 roomId다.

이 응답은 요청자에게 돌아간다.

클라 A가 방을 만들었으면 A가 CreateRoomResponse를 받는다. 클라 B가 방에 들어갔으면 B가 JoinRoomResponse를 받는다.

그런데 Room 목록은 다르다.

RoomListSnapshot

이건 특정 클라의 요청 결과라기보다는 서버가 현재 들고 있는 Room 목록이다.

그래서 나는 여기서 일반적인 응답과 브로드캐스트를 나눠야 한다고 봤다.

command response: 요청자에게 보낸다.
room list snapshot: 연결된 클라들에게 보낸다.

이렇게 하지 않으면 이상해질 거라고 생각했다. 예를 들어 클라 A가 방을 만들었다. A는 새 방을 안다. 하지만 B의 목록은 그대로다. 그러면 B 화면에는 아직 방이 없다. B가 뒤늦게 방 목록을 다시 달라고 요청하지 않는 이상 서버와 클라가 서로 다른 로비를 보고 있는 셈이다.

뭐 새로 고침 같은 버튼을 만들어서 수동으로 갱신하는 법도 있겠지만 유저 경험적인 측면에서는 좋을리가 없지 않는가.

Room 목록은 서버가 확정한 공유 상태다. 그렇다면 그 공유 상태가 바뀌었을 때도 서버가 먼저 말해줘야 하는 의무가 있다고 생각했다.


변경 사항이 발생할 때만

그렇다고 매 tick마다 Room 목록을 계속 보내는 건 비효율적이라고 생각했다.

서버 루프는 계속 돈다. TCP 연결을 확인하고, 들어온 바이트를 읽고, 패킷을 꺼내고, 세션 timeout도 본다. 이 루프가 한 번 돌 때마다 Room 목록을 전부 보내면 너무나도 단순해서 생각할 것도 많이 줄어들 것이다.

하지만 너무 무식하다.(너무 과격한 표현이긴 하지만, 아직 100% 단언할 수는 없지만, 지금 상황에서는 아무튼 그렇다.) 아무 방도 바뀌지 않았는데 매번 같은 목록을 보내는 건 의미 없는 설계 아닐까?

지금 당장 필요한 건 거창한 pub/sub 구조가 아니라, 아주 작고 간단한 flag다.

이번 루프에서 Room 목록이 바뀌었는가?

서버 코드에서는 그걸 roomListChanged로 설계했다.

bool roomListChanged = false;

...

if (!handleRoomPacket(connection, framedPacket, disconnectedClients, roomListChanged)) {
    break;
}

...

if (!disconnectedClients.empty() || roomListChanged || roomChangedByDisconnect) {
    broadcastStateSnapshots(
        !disconnectedClients.empty(),
        roomListChanged || roomChangedByDisconnect);
}

여기서 중요한 건 roomListChanged가 Room 목록 broadcast의 트리거라는 점이다.

아무 패킷이나 받았다고 Room 목록을 보내지 않는다. 패킷이 정상이어도 Room 상태가 안 바뀌면 보낼 필요가 없다. 반대로 Room 상태가 바뀌었다면, 그 변화는 요청자 한 명의 일이 아니라 로비 전체의 일이 된다.

아주 작은 flag지만 목적은 확실했다.

Room이 바뀌었을 때만 RoomListSnapshot을 보낸다.


성공한 command만 목록을 바꾼다

그다음에는 더 중요한 기준을 세웠다.

Room command가 들어왔다고 해서 무조건 Room 목록이 바뀌는 건 아니다.

예를 들어 클라 B가 존재하지 않는 roomIdJoinRoomRequest를 보낼 수 있다. 그 요청은 실패한다. 실패했다면 Room 목록은 그대로다.

이때 서버가 해야 할 일은 당연하지만 RoomListSnapshot broadcast가 아니라 B에게 Error를 보내는 것이다.

JoinRoomRequest
-> 실패
-> Error(NotFound)
-> RoomListSnapshot broadcast 없음

반대로 A가 방 만들기에 성공했다면 그때는 다르다.

CreateRoomRequest
-> 성공
-> CreateRoomResponse
-> RoomListSnapshot broadcast

코드에서도 그 선은 꽤 분명했다.

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);
if (!sendPacketToClient(
        connection.clientFd(),
        responsePacket.data(),
        responsePacket.size(),
        disconnectedClients)) {
    return false;
}

outRoomListChanged = true;
return true;

outRoomListChanged = true는 성공한 뒤에만 나온다.

실패한 command는 실패로 말한다. 성공한 command는 요청자에게 응답을 보낸 뒤, 공유 목록이 바뀌었다고 표시한다. 그러니까 Room 목록 broadcast는 command를 받았다는 단순한 response가 아니다.

Room 상태가 실제로 바뀌었다는 증거다.


누가 목록을 만들까

Room 목록을 broadcast하기로 했으면 다음 질문은 이것이다.

그 목록은 누가 만든 목록인가?

클라가 자기 목록을 만들어서 보내면 안 된다. 그건 이 게임 서버의 방향과 맞지 않는다. Room 목록은 서버가 들고 있는 Room 상태에서 나와야 한다.

서버 코드에서는 collectRoomEntries()가 그 역할을 했다.

std::vector<Net::TcpRoomEntry> Server::collectRoomEntries() const {
    const std::vector<Game::RoomSummary> rooms = roomManager_.roomList();
    std::vector<Net::TcpRoomEntry> entries;
    entries.reserve(rooms.size());
    for (const Game::RoomSummary& room : rooms) {
        entries.push_back(Net::TcpRoomEntry{room.roomId, room.playerCount, room.maxPlayers});
    }
    return entries;
}

여기서도 각 계층마다 경계를 확인할 수 있는데 roomManager_.roomList()는 게임 도메인의 요약이고, 서버는 그 요약을 TCP로 보낼 수 있는 TcpRoomEntry로 바꾼다.

즉, RoomManager가 네트워크 패킷을 직접 만들지 않는다. 반대로 네트워크 코드가 Room 내부 상태를 마음대로 뒤지지도 않는다. RoomManager가 요약을 내주고, 서버가 그 요약을 packet entry로 바꾼다.

대단한 추상화가 필요한 단계는 아닌 것 같다. 그저

  • Room 목록이 바뀌었는지 표시하는 flag
  • 그리고 필요할 때 현재 Room 요약을 다시 모으는 함수

이 정도면 이 정도 단계에서는 충분하지 않을까?


모든 연결에게 보낸다

broadcast 부분 코드를 가져와봤다.

void Server::broadcastStateSnapshots(bool clientListChanged, bool roomListChanged) {
    while (!connections_.empty() && (clientListChanged || roomListChanged)) {
        std::vector<uint8_t> clientSnapshotPacket;
        std::vector<uint8_t> roomSnapshotPacket;

        if (roomListChanged) {
            const std::vector<Net::TcpRoomEntry> rooms = collectRoomEntries();
            if (!Net::serializeRoomListSnapshotPacket(rooms, roomSnapshotPacket)) {
                return;
            }
        }

        std::vector<int> failedClients;
        for (const auto& entry : connections_) {
            if (roomListChanged &&
                !listener_.sendToClient(
                    entry.first,
                    roomSnapshotPacket.data(),
                    roomSnapshotPacket.size())) {
                failedClients.push_back(entry.first);
            }
        }

        if (failedClients.empty()) {
            return;
        }

        clientListChanged = true;
        roomListChanged = true;
        for (int clientFd : failedClients) {
            disconnectClient(clientFd);
        }
    }
}

먼저 Room 목록이 바뀌었는지 본다. 바뀌었다면 서버가 현재 Room 목록을 모으고, RoomListSnapshot packet으로 만든 후, 그다음 connections_를 돌면서 연결된 클라들에게 같은 snapshot을 보낸다.

거창한 fan-out 시스템이 필요 없는 것은, 아직은 TCP 서버 안에 살아 있는 connection map만이 곧 보낼 대상기 때문이다.

물론 여기서도 실패 처리는 피하지 않았다.

어떤 클라에게 보내는 데 실패하면 그 fd를 failedClients에 넣는다. 그리고 그 클라는 disconnect 처리한다. disconnect가 다시 client list나 room list를 바꿀 수 있으니(예를 들어 혼자 있는 방의 방장이 disconnect가 된다면 그 방은 사라질 것이다.), flag를 다시 true로 세우고 루프를 한 번 더 돈다.

좀 더 쉽게 설명하자면 Room 목록을 보내다가 실패한 연결이 생겼다면, 그 연결은 더 이상 같은 로비를 보는 클라라고 말하기 어렵다. 그러면 서버는 그 연결을 정리하고, 정리된 결과까지 다시 snapshot으로 맞춰야 한다.

처음에는 단순한 broadcast처럼 보였는데, 막상 설계를 이어 가다 보니 연결 실패와 상태 정리까지 같이 따라왔다.


disconnect도 Room 목록을 바꾼다

방금 언급했지만, disconnect도 신경써야 했다. Room 목록은 command로만 바뀌지 않는다는 얘기다.

클라가 LeaveRoomRequest를 보내면 당연히 Room 목록이 바뀔 수 있다. 그런데 클라가 모종의 이유로 조용히 연결이 끊어져도 Room 목록은 바뀔 수 있다.

예를 들어 B가 Room에 들어가 있다가 소켓을 닫아버렸다고 해보자. B가 친절하게 LeaveRoomRequest를 보내지 않았더라도, 서버 입장에서는 B를 Room에서 빼야 한다.

그래서 disconnect 처리도 Room 상태와 연결된다.

bool Server::disconnectClient(int clientFd) {
    auto it = connections_.find(clientFd);
    if (it == connections_.end()) {
        return false;
    }

    const bool roomChanged = roomManager_.leaveRoom(it->second->sessionId()).ok;
    sessionManager_.remove(it->second->remoteKey());
    listener_.closeClient(clientFd);
    connections_.erase(it);
    activeConnectionCount_.store(connections_.size(), std::memory_order_relaxed);
    sessionCountSnapshot_.store(sessionManager_.size(), std::memory_order_relaxed);
    return roomChanged;
}

여기서 roomChanged가 중요하다.

disconnect는 네트워크 계층에서 일어난 사건이다. 하지만 그 session이 Room 안에 있었다면, disconnect는 Room 상태 변경이기도 하다.

그래서 disconnectClient()는 단순히 소켓만 닫지 않는다. RoomManager에게 해당 session을 Room에서 빼달라고 요청한다. 그리고 실제로 Room 상태가 바뀌었는지 bool로 돌려준다.

그 값은 다시 broadcast 판단으로 이어진다.

bool roomChangedByDisconnect = false;
for (int clientFd : disconnectedClients) {
    roomChangedByDisconnect = disconnectClient(clientFd) || roomChangedByDisconnect;
}

if (!disconnectedClients.empty() || roomListChanged || roomChangedByDisconnect) {
    broadcastStateSnapshots(
        !disconnectedClients.empty(),
        roomListChanged || roomChangedByDisconnect);
}

이건 꽤 게임 서버다운 문제였다고 생각한다. 클라가 예쁘게 손 들고 나가주면 너무 좋다. 하지만 서버는 그런 걸 곧이곧대로 믿으면 안 된다.


테스트로 본 흐름

Room lifecycle 테스트를 진행했고, 말로 설명하자면 아래와 같다.

client A 접속
client B 접속

A가 방 생성
-> A는 CreateRoomResponse 수신
-> A와 B는 RoomListSnapshot 수신

B가 A의 방에 입장
-> B는 JoinRoomResponse 수신
-> A와 B는 playerCount = 2인 RoomListSnapshot 수신

client C 접속
C가 같은 방에 입장 시도
-> C는 Error(Full) 수신
-> RoomListSnapshot은 성공 변경처럼 다루지 않음

B가 방에서 나감
-> A, B, C는 playerCount = 1인 RoomListSnapshot 수신

A가 방에서 나감
-> A, B, C는 빈 RoomListSnapshot 수신

이 테스트의 요점은 요청자만 보는 것이 아니라 연결된 클라들도 봐야한다는 것이다.

A가 방을 만들었을 때 B도 snapshot을 받아야 한다. B가 들어갔을 때 A도 바뀐 인원수를 받아야 한다. B가 나갔을 때는 그 방을 보고 있던 모든 클라가 다시 같은 목록을 봐야 한다.

이제 Room 목록이 공유 상태라는 게 더 분명해졌다.


맺으며

쑥스럽게도 이번 편 또한 딱히 엄청난 내용은 없다.

그냥 Room 목록을 보내는 일이다. 하지만 막상 설계를 시작하면 질문이 계속 붙을 수 밖에 없었다.

누가 목록을 만드는가?
언제 목록을 다시 보내는가?
성공한 command와 실패한 command를 어떻게 구분하는가?
disconnect로 Room이 바뀌면 어떻게 되는가?
요청자만 볼 것인가, 전체 클라가 볼 것인가?

이 질문들을 따라가다 보면 결국 다시 서버 권한 구조로 돌아온다.

Room 목록은 클라들이 각자 추측해서 맞춰가는 값이 아니다. 서버가 현재 들고 있는 Room 상태를 보고, 바뀌었을 때 다시 알려주는 공유 상태라는 것이다.

profile
Sapere Aude!

0개의 댓글