칼로 물 베기

반짇고리·2026년 3월 23일

게임 서버 제작기

목록 보기
5/15


이전 포스팅에서는 Welcome을 소개했다.
서버가 sessionId를 만들고, 그 값을 클라에게 알려주는 첫 패킷. 서버가 먼저 이름을 붙이고, 클라는 그 이름표를 기준으로 다음 대화를 시작한다.

그런데 Welcome을 실제 패킷으로 보내고 나니, 곧바로 다른 질문이 떠올랐다.

TCP에서 패킷 하나는 어디서부터 어디까지인가?

처음에는 조금 당연하게 생각했다. 서버가 12바이트짜리 Welcome을 보냈으면 클라도 12바이트를 읽겠지. 그렇게 생각하는 게 마음 편하니까.

하지만 애석하게도 TCP는 그렇게 친절하지 않았다.

TCP가 보장하는 건 순서 있는 바이트 흐름이지, 내가 머릿속에서 생각한 패킷 단위가 아니다. 서버가 한 번에 보냈다고 해서 상대가 한 번에 읽는 것도 아니고, 반대로 내가 두 번 보낸 게 상대에게 두 덩어리로 도착한다는 뜻도 아니다.

이번 글은 그 불편함을 처리하려고 소켓에서 읽은 바이트와 애플리케이션 패킷 사이에 경계를 둔 이야기다.


먼저

Welcome은 12바이트였다.

Size       2 bytes
Type       2 bytes
sessionId  8 bytes

4편에서는 주로 sessionId를 봤다. 서버가 만든 런타임 식별자를 클라에게 처음 알려준다는 점이 중요했기 때문이다.

그런데 수신측에서 바라보면, 여기서는 sessionId보다 앞에 있는 Size가 먼저 중요해진다.

Size: 이 패킷은 어디까지인가
Type: 이 패킷은 무엇인가

Type은 패킷의 의미를 알려준다. 하지만 그 의미를 읽으려면 먼저 패킷 하나를 온전히 잘라내야 한다. 아직 패킷 끝이 어디인지도 모르는데 Welcome인지, Room command인지, 잘못된 값인지 판단할 수는 없다.

그러면 질문이 바뀐다.

소켓에서 읽은 바이트 덩어리에서
패킷 하나의 경계를 어떻게 다시 찾을 것인가?

나는 이 일을 Room이나 Ready 같은 게임 로직 안에 넣고 싶지 않았다. Room이 바이트를 세고 있으면 이상하다. Ready 처리 코드가 “아직 3바이트밖에 안 왔으니 기다리자”고 말하고 있으면 그것 또한 이상하다.

그래서 먼저 소켓에서 읽는다는 일이 정확히 무엇인지부터 봐야 했다.


소켓에서 읽는다는 것

C++(당연히 C에서도)에서 TCP 소켓을 읽을 때는 보통 recv() 계열 호출을 만난다.

여기서 조심해야 한다. recv()는 게임 서버가 만든 패킷 파서(Packet Parser)가 아니다. Welcome도 모르고, Room도 모르고, sessionId도 모른다.

정확히 말하면 recv()는 운영체제가 제공하는 소켓 API다. 게임 서버 입장에서 하는 일은 꽤 낮은 수준이다. 커널 쪽 TCP receive buffer에 들어온 바이트를 내가 '넘긴' 유저 버퍼로 가져온다.

흐름을 단순화하면 이렇다.

network
-> kernel TCP receive buffer
-> recv()
-> user buffer
-> packet reader
-> application packet

네트워크에서 들어온 바이트는 먼저 커널이 관리하는 TCP 수신 버퍼에 쌓인다. 서버 코드는 recv()를 호출해서 그중 지금 읽을 수 있는 만큼을 내가 준비한 배열로 가져온다.

앞서 말했듯이, 중요한 건 이 함수가 애플리케이션 패킷 경계를 모른다는 점이다.

커널은 “이건 Welcome 하나”, “이건 Room command 하나” 같은 걸 모른다. 그냥 TCP 연결을 통해 순서대로 도착한 바이트를 줄 뿐이다.


recv

나는 이 recv()receiveFromClient()라는 함수로 wrapping 했다. 어쨌든 안쪽에서 하는 일은 소켓에서 바이트를 읽는 것이다.

ReceiveStatus TcpListener::receiveFromClient(
    int clientFd,
    uint8_t* buffer,
    size_t bufferSize,
    size_t& outReceived) {
    outReceived = 0;
    if (clientFd < 0 || buffer == nullptr || bufferSize == 0) {
        return ReceiveStatus::kError;
    }

    ssize_t received = ::recv(clientFd, buffer, bufferSize, 0);
    if (received == 0) {
        return ReceiveStatus::kClosed;
    }

    if (received < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
            return ReceiveStatus::kWouldBlock;
        }
        return ReceiveStatus::kError;
    }

    outReceived = static_cast<size_t>(received);
    return ReceiveStatus::kReceived;
}

이 코드에서 ::recv()가 실제 socket API 호출이다. 결과가 0이면 연결이 닫힌 것으로 보고, 음수면 아직 읽을 수 없거나 에러라고 본다. 그리고 정상적으로 읽었다면 outReceived에 이번에 읽힌 바이트 수를 담는다.

여기서 중요한 값은 outReceived다.

이 값은 패킷 크기가 아니다. 그냥 이번 호출에서 유저 버퍼로 복사된 바이트 수다. 이 값이 3일 수도 있고, 12일 수도 있고, 24일 수도 있다.

서버 루프에서는 이 값을 이렇게 받았다.

size_t received = 0;	// 이번 read에서 몇 바이트 읽었는지 담을 변수

Net::ReceiveStatus status = listener_.receiveFromClient(
    connection.clientFd(),
    buffer.data(),
    buffer.size(),
    received);

if (status == Net::ReceiveStatus::kWouldBlock) {
    continue;
}

if (status == Net::ReceiveStatus::kClosed ||
    status == Net::ReceiveStatus::kError) {
    disconnectedClients.push_back(connection.clientFd());
    continue;
}

// 이번에 읽은 received 바이트를 이 connection의 수신 버퍼 뒤에 붙이기
if (!connection.packetReader().appendBytes(buffer.data(), received)) {
    disconnectedClients.push_back(connection.clientFd());
    continue;
}

처음에는 이 차이가 작아 보였다. 어쨌든 읽었고, 바이트가 있고, 그걸 해석하면 되는 것 아닌가 싶었다. 하지만 다양한 케이스에서 애매해질 수가 있었다.

덜 온 패킷을 처리하면 안 된다. 반대로 패킷 두 개가 붙어서 왔는데 하나만 처리하고 멈춰도 안 된다.

recv()가 돌려준 건 패킷이 아니라 재료였다. 그 재료를 모아서 패킷 하나로 잘라내는 일이 따로 필요했다.


세 가지 경우

Welcome 하나는 12바이트였다.

그런데 소켓에서 읽을 때는 이런 일이 모두 가능하다.

case 1: 12 bytes가 한 번에 온다
case 2: 3 bytes + 9 bytes로 나뉘어 온다
case 3: 12 bytes + 12 bytes가 한 번에 온다

셋 다 이상한 상황이 아니다. 모두 TCP 위에서는 자연스러운 상황이다.

첫 번째는 가장 편한 경우다. Welcome 하나가 한 번에 들어왔으니 그대로 읽으면 된다.

하지만 두 번째는 아직 패킷이 덜 왔다. 3바이트만 보고는 헤더조차 완성되지 않는다.

세 번째는 반대로 너무 많이 왔다. 패킷 하나를 처리하고 나서도 뒤에 다음 패킷이 남아 있다.

문제는 이 셋을 전부 같은 서버 루프가 처리해야 한다는 점이었다.


Reader

그래서 TcpPacketReader를 뒀다.

이름 그대로 TCP에서 읽은 바이트를 패킷으로 잘라내는 작은 객체다. 간단하다. 바이트를 누적하고, 헤더를 확인하고, 패킷 하나를 꺼낼 수 있으면 꺼낸다.

당시 핵심은 이 함수 쪽에 있었다.

TcpPacketReadResult TcpPacketReader::tryReadPacket(
    std::vector<uint8_t>& outPacket) {
    if (buffer_.size() < kTcpHeaderSize) {
        return TcpPacketReadResult::kNeedMoreData;
    }

    TcpPacketHeader header;
    if (!peekTcpPacketHeader(buffer_.data(), buffer_.size(), header)) {
        return TcpPacketReadResult::kInvalidPacket;
    }

    if (header.size < kTcpHeaderSize ||
        header.size > kMaxTcpPacketSize) {
        return TcpPacketReadResult::kInvalidPacket;
    }

    if (buffer_.size() < header.size) {
        return TcpPacketReadResult::kNeedMoreData;
    }

    outPacket.assign(buffer_.begin(), buffer_.begin() + header.size);
    buffer_.erase(buffer_.begin(), buffer_.begin() + header.size);
    return TcpPacketReadResult::kPacketReady;
}

이 코드를 넣고 나서야 Size가 왜 필요한지 더 분명해졌다.

버퍼에 헤더 4바이트도 없으면 기다린다. 헤더가 있으면 Size를 본다. Size가 말이 안 되면 잘못된 패킷으로 본다. Size는 정상인데 아직 그만큼 안 모였으면 또 기다린다. 충분히 모였을 때만 패킷 하나를 잘라낸다.

흐름을 줄이면 이렇다.

buffer < 4 bytes        -> wait
Size < 4                -> invalid
Size > max packet size  -> invalid
buffer < Size           -> wait
buffer >= Size          -> cut one packet

Size는 그냥 단순한 필드가 아니었다. 스트림 위에서 패킷 경계를 되찾기 위한 첫 기준이었다.


세 가지 결과

TcpPacketReader가 내놓는 결과는 세 가지로 충분했다.

NeedMoreData
PacketReady
InvalidPacket

NeedMoreData는 아직 기다리라는 뜻이다. 버퍼에 바이트는 들어왔지만, 패킷 하나를 만들 만큼은 아니다. 이때는 연결을 끊으면 안 된다. 그냥 다음 바이트를 기다리면 된다.

PacketReady는 패킷 하나가 완성됐다는 뜻이다. 이때만 다음 처리기로 넘긴다. 패킷 처리 코드는 더 이상 “이게 반쪽짜리 패킷인가?”를 고민하지 않아도 된다.

InvalidPacket은 기다려도 해결되지 않는 경우다. 예를 들어 Size가 헤더보다 작거나, 최대 패킷 크기보다 크면 더 받을 이유가 없다. 이미 계약을 벗어났기 때문이다.

이 셋을 나눠두니 서버 루프도 훨씬 읽기 쉬워졌다.

while (true) {
    Net::TcpPacketReadResult readResult =
        connection.packetReader().tryReadPacket(framedPacket);

    if (readResult == Net::TcpPacketReadResult::kNeedMoreData) {
        break;
    }

    if (readResult == Net::TcpPacketReadResult::kInvalidPacket) {
        disconnectedClients.push_back(connection.clientFd());
        break;
    }

    // 다음 단계의 packet handler로 넘길 자리
}

여기서 while이 필요했다.

한 번의 소켓 read에 패킷이 하나만 들어온다는 보장이 없기 때문이다. 두 개가 붙어서 들어왔다면 첫 번째 패킷만 처리하고 멈추면 안 된다. reader는 패킷 하나를 잘라낸 뒤 남은 바이트를 버퍼에 남겨두고, 루프는 다시 한 번 읽어본다.

반대로 패킷이 덜 왔다면 루프는 멈춘다.
덜 온 패킷은 기다려야 하고, 붙어서 온 패킷은 계속 꺼내야 한다. 둘 다 TCP에서는 평범한 상황인데, 서버 코드는 둘을 다르게 다뤄야 했다.

처음에는 그냥 truefalse 정도로도 표현할 수 있지 않을까 싶었다. 하지만 그러면 false가 너무 많은 뜻을 갖게 된다.

아직 덜 온 것인지.
정말 잘못된 것인지.
아니면 패킷은 정상인데 처리할 게 없는 것인지.

이걸 섞으면 나중에 로그를 봐도 애매하고, 테스트를 봐도 애매할 것이다. 그래서 결과를 조금 더 노골적으로 나누는 쪽이 낫다고 봤다.


경계

중요한 건 TcpPacketReader가 게임을 몰라야 한다는 점이다. TcpPacketReader는 Ready도 모르고, Battle도 모르고, 클라가 어떤 버튼을 눌렀는지도 모른다. 이 객체가 아는 건 버퍼에 쌓인 바이트와 TCP 패킷 헤더뿐이다.

나는 이 설계가 마음에 들었다.

소켓은 바이트를 읽는다.
reader는 바이트에서 패킷을 잘라낸다.
패킷 처리 코드는 완성된 패킷의 의미를 본다.
Room은 그 의미가 게임 상태를 바꿀 수 있는지 판단한다.

이렇게 나눠두면 당연하게도 각자가 모르는 게 생긴다.

Room은 소켓 read가 몇 바이트를 돌려줬는지 몰라도 된다. reader는 Room에 누가 들어가 있는지 몰라도 된다. 소켓은 Welcome이 어떤 의미인지 몰라도 된다.

이런 무지가 오히려 서버 구조를 편하게 만든다고 생각했다.

물론 아직 이 단계에서 모든 문제가 끝난 건 아니다. 실제 서버에서는 언제든지 연결이 끊길 수도 있고, 이상한 타입이 들어올 수도 있고, 패킷을 읽은 뒤에도 세션과 Room 상태를 다시 검증해 할 것이다.

그래도 적어도 한 가지는 명확히 분리됐다.

TCP bytes != application packet

이걸 코드 위에 남겨둔 게 이번 단계에서 가장 큰 수확이었다.


테스트

테스트도 이 세 가지 상황을 그대로 따라갔다.

첫 번째는 분할 수신이었다.

Welcome 패킷 12바이트 중 앞의 3바이트만 먼저 넣는다. 이 상태에서는 패킷이 나오면 안 된다. 아직 헤더 4바이트도 다 모이지 않았기 때문이다. 그다음 나머지 9바이트를 넣으면 그때서야 패킷 하나가 나온다.

두 번째는 붙어서 들어오는 경우였다.

Welcome 두 개를 이어 붙인 뒤 한 번에 넣는다. reader는 첫 번째 패킷을 먼저 꺼내고, 다음 호출에서 두 번째 패킷을 꺼내야 한다. 순서가 바뀌면 안 되고, 중간에 바이트가 사라져도 안 된다.

세 번째는 잘못된 크기였다.

헤더의 Size가 3이라고 되어 있으면 그건 기다려도 정상 패킷이 될 수 없다. TCP 헤더 자체가 4바이트인데, 패킷 전체 크기가 3바이트라는 말은 계약 밖에 있다.

이 테스트들이 마음에 들었던 이유는 단순하다.

TCP에서 실제로 골치 아픈 지점을 그대로 찔렀다. 예쁜 성공 케이스만 보는 게 아니라, 덜 온 바이트와 너무 많이 온 바이트와 말이 안 되는 바이트를 같이 봤다.


맺으며

Welcome은 서버와 클라가 같은 기준으로 대화를 시작하게 만든 첫 옹알이 같은 문장이었다.

그런데 서로 문장을 주고받으려면 먼저 어디까지가 한 문장인지 알아야 했다. TCP는 그 경계를 기억해주지 않았다. 그냥 순서 있는 바이트만 흘려보냈다.

소켓 API는 그 바이트를 유저 버퍼로 가져와준다. 하지만 “이게 패킷 하나다”라고 말해주지는 않는다.

그래서 서버가 다시 경계를 찾아야 했다.
헤더를 기다리고, Size를 읽고, 충분히 모이면 하나를 잘라내고, 남은 바이트가 있으면 다시 반복한다.

작은 일처럼 보이지만, 이걸 하지 않으면 게임 서버는 계속 반쪽짜리 패킷과 너덜거리는 패킷 사이에서 흔들릴 것이다.

나는 여기서 TCP 서버가 단순히 소켓을 읽는 코드가 아니라는 걸 조금 더 체감했다.
소켓은 바이트를 준다. 서버는 그 바이트를 패킷으로 다시 읽어야 한다.

profile
Sapere Aude!

0개의 댓글