
이전 포스팅에서는 TCP가 메시지 기반이 아닌 byte stream 기반 프로토콜이라는 이야기를 했다.
서버가 Welcome 12바이트를 보냈다고 해서 클라가 항상 12바이트씩 읽지는 않았다. 그래서 Size를 보고, 바이트를 모으고, 패킷 하나를 다시 잘라내는 TcpPacketReader가 필요했다.
그렇게 패킷 경계를 잡고 나니 질문이 하나 더 남았다.
지금 꺼낸 패킷은 어느 클라의 연결에서 온 것인가?
처음에는 접속이 들어오면 Welcome을 보내는 것만으로도 꽤 진도를 낸 기분이 들었다. 클라가 붙었고, 서버가 sessionId를 줬고, 그 값이 실제 바이트로 오갔다.
하지만 여전히 게임 서버라기보다는 그저 짧은 인사다.
게임 서버에서의 연결은 한 번 인사하고 사라지는 대상이 아니다. 클라는 같은 연결 위에서 Room에 들어가고, Ready를 누르고, 그 다음 입력을 계속 보낸다. 서버도 그 연결로 계속 응답하고, 중간에 끊기면 상태를 정리해야 한다.
그래서 이번 글은 연결을 "한 번 처리할 이벤트"가 아니라 "다음 tick에도 남아 있어야 하는 대상"으로 잡기 시작한 이야기다.

지금까지 흐름을 아주 짧게 줄이면 이렇다.
서버와 클라가 연결됨
-> 서버가 Session을 만듦
-> 서버가 Welcome을 보냄(SessionId)
-> 클라가 패킷 bytes를 읽음
-> 패킷 reader가 bytes에서 패킷을 잘라냄
여기까지는 그래도 이해하기 쉽다.
문제는 그 다음이었다.
클라는 Welcome을 받았다고 해서 서버와의 관계가 끝나는 것이 아니다. 오히려 이제부터가 시작이다. 서버가 sessionId를 알려줬다면, 그 다음 패킷들도 같은 연결과 같은 세션을 기준으로 처리해야 한다.
그러려면 서버는 클라를 계속 기억해야 한다.
Welcome 이후 서버는 같은 클라를 다음 tick에서도 어떻게 기억할 것인가?
소켓 번호만 들고 있으면 될까. sessionId만 있으면 될까. 패킷이 반만 들어왔을 때 남은 바이트는 어디에 둬야 할까. 마지막으로 데이터를 받은 시각은 어디에 업데이트해야 할까. 연결이 끊기면 Session은 언제 지워야 할까.
질문이 한꺼번에 밀려왔다.
나는 여기서 ClientConnection이라는 경계를 하나 더 두기 시작했다.
가장 편한 흐름은 사실 따로 있다.
새 클라가 접속한다. 서버는 remoteKey로 Session을 찾거나 만들고, Welcome 패킷을 보낸다.
그리고 닫는다.
accept
-> findOrCreate session
-> send Welcome
-> close client
이 흐름도 테스트는 할 수 있다. 클라가 붙었을 때 Welcome을 받는지, 여러 클라가 서로 다른 sessionId를 받는지 확인하면 된다.
하지만 이 상태에서는 그 뒤가 없다.
클라가 다시 패킷을 보내도 이미 연결은 닫혀 있다. 서버 입장에서도 "이 클라가 방금 전에 Welcome을 받은 바로 그 클라인가?"라는 질문에 답하기 어렵다. 매번 새 연결로 보고, 새 인사만 반복하는 셈이다.
그 흐름은 내가 만들려던 Room 서버와 맞지 않았다.
Room에 들어가려면 연결이 살아 있어야 한다. Ready를 누르려면 연결이 살아 있어야 한다. 연결이 끊겼을 때 Room에서 빼려면, 서버가 그 연결이 어떤 Session과 묶여 있었는지도 알아야 한다.
짧은 인사만으로는 부족했다.
그래서 연결 자체를 따로 붙잡아두기로 했다.
3편에서 말한 Session은 서버가 붙인 런타임 정체성이다. 하지만 실제 TCP 연결에는 그보다 낮은 층의 상태도 같이 붙는다.
class ClientConnection {
public:
ClientConnection(
int clientFd,
uint64_t sessionId,
std::string remoteKey,
Util::TimePoint now);
int clientFd() const;
uint64_t sessionId() const;
const std::string& remoteKey() const;
Util::TimePoint lastHeard() const;
void updateLastHeard(Util::TimePoint now);
Net::TcpPacketReader& packetReader();
private:
int clientFd_;
uint64_t sessionId_;
std::string remoteKey_;
Util::TimePoint lastHeard_;
Net::TcpPacketReader packetReader_;
};
clientFd는 실제 소켓을 가리킨다. sessionId는 이 연결 위에서 서버가 발급한 런타임 식별자다. remoteKey는 SessionManager에서 같은 연결을 찾는 기준으로 쓰이고, lastHeard는 마지막으로 이 연결에서 소식을 들은 시각이다. 그리고 packetReader가 있다.
여기서 중요한 건 packetReader였다. 5편에서 본 것처럼 TCP는 메시지 경계를 보존하지 않는다. 그러면 연결마다 "아직 덜 온 바이트"를 들고 있어야 한다. 그 버퍼를 전역으로 두면 이상해지고, Room에 두면 더 이상해진다.
연결마다 자기 몫의 TcpPacketReader를 갖는 쪽이 자연스러웠다.

이 코드는 사실상 보관함에 가깝다. 그런데 이 보관함이 없으면 서버 루프가 금방 지저분해진다. 소켓 번호, sessionId, 수신 버퍼, 마지막 활동 시각이 서로 다른 곳에 흩어지고, 연결이 끊겼을 때 무엇을 지워야 하는지도 흐려진다.
나는 이런 작고 밋밋한 객체가 오히려 서버를 버티게 한다고 생각했다.
연결을 보관하기 시작하면 서버를 한 번 실행하고 끝나는 휘발성 있는 함수처럼 보기는 어려워질 것이다.
그리고 당연하게도 서버는 계속 돈다.
새 클라가 있으면 받고, 이미 붙어 있는 클라에서 읽을 수 있는 바이트가 있으면 읽는다. 오래 말이 없는 Session도 확인하고, 끊어진 연결도 정리해야 한다.
나는 이 루프를 tick loop라고 칭하기로 했다.
void Server::tickOnce() {
Util::TimePoint now = Util::now();
acceptNewClients(now);
processActiveConnections(now);
sessionManager_.tick(now);
sessionCountSnapshot_.store(
sessionManager_.size(),
std::memory_order_relaxed);
}
이 몇 줄은 생각보다 많은 걸 보여준다.
acceptNewClients(now)는 새 연결을 받아서 Welcome을 보내고, 연결을 connections_에 보관한다.
processActiveConnections(now)는 이미 살아 있는 연결들을 훑는다. 읽을 수 있는 바이트가 있으면 읽고, 그 바이트를 각 연결의 packetReader에 붙인다. 패킷이 완성되면 다음 처리기로 넘길 수 있는 상태가 된다.
sessionManager_.tick(now)는 오래 말이 없는 Session을 정리한다.
짧지만 큰 변화를 주는 코드라고 생각했다. 서버가 더 이상 "접속 하나를 처리하고 종료하는 코드"가 아니라, 살아 있는 연결들의 상태를 계속 갱신하는 코드가 됐기 때문이다.

꽤 많이 헷갈렸던 부분이다.
소켓에서 읽으려고 했는데 지금 읽을 게 없을 수 있다. 그렇다고 항상 에러는 아니다.
서버는 non-blocking 소켓을 쓰고 있었다. 읽을 데이터가 없다고 해서 그 자리에서 서버 전체가 멈춰 기다리지 않는 방식이다. 한 클라에서 데이터가 안 온다고 서버 전체가 같이 멈추면 곤란하니까.
그래서 receiveFromClient()는 단순히 성공/실패만 돌려주지 않았다.
enum class ReceiveStatus {
kReceived,
kWouldBlock,
kClosed,
kError,
};
kReceived는 바이트를 읽었다는 뜻이다.
kWouldBlock은 지금 당장 읽을 데이터가 없다는 뜻이다. 이때는 연결을 끊으면 안 된다. 그냥 이번 tick을 넘어가면 된다.
kClosed는 상대가 연결을 닫았다는 뜻이다.
kError는 진짜 에러다.
서버 루프에서는 이 차이를 이렇게 다뤘다.
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;
}
게임 서버에서는 읽을 데이터가 없다는 이유로 정상적인 연결을 끊어버리면 안 된다. 반대로 이미 닫힌 연결을 계속 붙잡고 있어도 곤란하다.
둘 다 "이번 read에서 패킷을 처리하지 못했다"는 점은 비슷해 보인다. 하지만 서버가 해야 할 행동은 완전히 다르다. 기다릴 연결은 기다리고, 정리할 연결은 정리한다.
바이트를 읽었다고 해서 곧바로 게임 로직으로 가지는 않는다.
5편에서 본 것처럼 recv()가 돌려준 건 패킷 하나가 아니라 이번에 읽힌 바이트 덩어리다. 그래서 이 바이트는 그 연결의 packetReader에 붙는다.
connection.updateLastHeard(now);
auto session = sessionManager_.find(connection.remoteKey());
if (session) {
session->updateLastHeard(now);
}
if (!connection.packetReader().appendBytes(buffer.data(), received)) {
disconnectedClients.push_back(connection.clientFd());
continue;
}
여기서는 두 가지 일을 같이 한다.
첫째, 이 연결에서 소식을 들었으니 lastHeard를 갱신한다. 연결도 갱신하고, 그 연결과 묶인 Session도 갱신한다.
둘째, 읽은 바이트를 그 연결의 packetReader에 붙인다. 다른 클라의 버퍼와 섞이면 안 된다. A 클라에게서 덜 온 패킷의 앞부분과 B 클라에게서 온 뒷부분이 합쳐지는 건 말이 안 되는 일이다.
connection A bytes -> connection A packetReader
connection B bytes -> connection B packetReader
이렇게 연결마다 자기 수신 상태가 있어야 한다. 그래서 ClientConnection 안에 packetReader를 넣었다.
연결을 오래 들고 있으면 끊김도 다뤄야 한다.
일회성 흐름에서는 Welcome을 보내고 닫으면 끝이다. 하지만 유지형 연결에서는 클라가 먼저 닫을 수도 있고, 에러가 날 수도 있고, 서버가 잘못된 패킷을 보고 끊어야 할 수도 있다.
그래서 끊긴 연결은 그냥 흘려보내지 않고, 서버 상태에서 정리했다.
void Server::disconnectClient(int clientFd) {
auto it = connections_.find(clientFd);
if (it == connections_.end()) {
return;
}
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);
}
여기서도 경계가 드러난다.
소켓을 닫는 것만으로는 부족하다. 서버가 기억하던 연결도 지워야 하고, SessionManager에 있던 Session도 지워야 한다. 서버가 보고 있는 연결 수와 세션 수도 다시 맞춰야 한다.
이런 정리가 빠지면 서버는 유령 같은 상태를 갖게 된다.
소켓은 닫혔는데 Session은 남아 있다. 클라는 사라졌는데 접속자 목록에는 남아 있다. 이 상태에서 Room까지 붙으면 더 이상해진다.
그 전에 끊김을 서버 상태 변화로 다뤄야 했다.
연결을 유지하기 시작하자 바로 작은 동기화 문제가 생겼다.
지금 서버에 누가 붙어 있는가.
아직 Room 목록도 아니고, 게임 참가자 목록도 아니다. 그냥 현재 살아 있는 Session 목록에 가깝다.
하지만 이 목록도 서버가 말해야 했다. 클라가 "나는 지금 A, B, C가 접속해 있다고 생각함"이라고 주장하는 흐름은 이상하다. 접속자 목록 역시 서버가 가진 연결 상태에서 나온 snapshot이어야 했다.
그래서 연결이 들어오거나 나갈 때 Session ID 목록을 만들어 전체 클라에게 보냈다.
std::vector<uint64_t> Server::collectActiveSessionIds() const {
std::vector<uint64_t> sessionIds;
sessionIds.reserve(connections_.size());
for (const auto& entry : connections_) {
sessionIds.push_back(entry.second->sessionId());
}
std::sort(sessionIds.begin(), sessionIds.end());
return sessionIds;
}
마지막에는 정렬까지 넣었다. 사소해 보이지만 테스트에서는 이런 게 중요하다. 같은 접속자 목록인데 순서가 매번 달라지면 검증이 귀찮아지고, 클라 쪽에서도 불필요한 변화처럼 보일 수 있다.
그리고 이 코드는 "현재 살아 있는 연결들"을 기준으로 클라들에게 같은 snapshot을 보낸다는 점에서, 다음 단계로 가기 전 좋은 연습이 됐다.

테스트도 Welcome을 받는지에서 멈추지 않았다.
클라가 붙는다. Welcome을 받는다. 그리고 연결이 그대로 살아 있어야 한다.
connect
-> receive Welcome
-> activeConnectionCount == 1
-> sessionCount == 1
-> 해당 연결이 여전히 살아있어야 한다
여러 클라가 붙으면 sessionId가 서로 달라야 하고, 서버는 그 연결들을 계속 들고 있어야 한다.
그리고 연결이 닫히면 반대로 줄어야 한다.
clients connect: activeConnectionCount == N
clients close: activeConnectionCount == 0
sessions close: sessionCount == 0
접속자 목록도 확인했다.
A가 들어오면 A는 [A]를 받는다. B가 들어오면 A와 B는 [A, B]를 본다. C가 들어오면 셋 모두 [A, B, C]를 본다. C가 나가면 다시 [A, B]가 된다.
이 테스트가 마음에 들었던 이유는 서버 상태가 눈에 보였기 때문이다. 연결을 만들고, 유지하고, 여러 클라에게 같은 snapshot을 보내고, 끊긴 뒤 정리되는지 확인한다.
아직 Room도 아니고, Ready도 아니다. 하지만 서버가 "살아 있는 연결들"을 상태로 다루기 시작했다.
여기까지 오면 경계가 조금 더 선명해졌다.
Socket: OS가 다루는 연결 통로
Session: 서버가 붙인 런타임 정체성
ClientConnection: 소켓과 Session을 묶은 현재 연결 상태
TcpPacketReader: 연결별 수신 바이트를 패킷으로 자르는 도구
Server loop: 새 연결, 읽기, 정리, timeout을 반복하는 곳
이 다섯 개는 비슷해 보이지만 같지 않다.
소켓은 sessionId를 모른다. Session은 덜 온 바이트를 모른다. Reader는 Room을 모른다. Room은 recv()가 몇 바이트를 돌려줬는지 몰라도 된다.
즉, 각자가 모르는 영역을 의도적으로 설계했다.
처음에는 객체가 하나 더 늘어나는 것처럼 보여서 살짝 부담스러웠다. 하지만 이렇게 나누고 나니 다음 질문이 더 또렷해졌다.
서버는 이제 연결을 붙잡고 있을 수 있다.
연결마다 패킷을 읽을 수 있다.
끊긴 연결을 정리할 수 있다.
접속자 snapshot도 보낼 수 있다.
이제 클라가 한 번 인사한 뒤에도 다시 말을 걸 자리가 생겼다.
Welcome은 서버가 클라에게 처음으로 "너는 이 sessionId로 보인다"고 말하는 패킷이었다.
TcpPacketReader는 TCP 바이트 흐름에서 패킷 하나를 다시 잘라내는 장치였다.
그 다음에는 연결을 계속 들고 있어야 했다.
한 번 인사하고 끝나는 서버로는 Room을 만들 수 없다. 클라가 다음에 보내는 말을 들으려면, 서버는 그 클라를 다음 tick에서도 기억해야 한다.
그래서 ClientConnection을 만들고, loop 안에서 새 연결과 기존 연결을 같이 다루기 시작했다.
아직 대단한 게임 로직은 없지만 조금은 더 서버다워졌다. 소켓을 열고 인사하는 단순한 코드에서, 살아 있는 연결들을 관리하는 코드로 넘어왔기 때문이다.