지금까진 단순히 "Hello World"라는 문자열을 주고받기만 했다.
이대로라면 통신은 되지만 서로 원하는 데이터를 확실하게 주고 받지는 못할 것이다.
클라이언트에서 특정 처리에 대한 요청을 보내는데 TCP 특성 상,
그 내용이 완전한 형태로 한 번에 서버에 도착한다는 보장이 없다.
따로 와버린 데이터에 대해 서버가 그 요청에 맞는 처리를 할 수 없는 것은 당연하다.
따라서 헤더를 가지는 「패킷」을 활용하여 통신되는 데이터를 구분할 수 있게 할 것이다.
1. 클래스 작성
패킷을 사용한다고 해서 기존의 통신 방식과 완전히 다른 것을 사용하는 것은 아니다.
「Hello Wolrd」라는 데이터 앞에 이 데이터의 크기는 얼마고,
이 패킷은 뭘 하는 패킷이다라는 것을 알려주는 부분을 추가할 뿐이다.
웹 통신에서 쓰이는 Header 같은 것 추가한다고 볼 수 있겠다.
그 헤더를 구조체로 선언한다.
struct PacketHeader
{
uint16 size; // 가변 사이즈 데이터를 위해 필요
uint16 id; // 프로토콜ID (ex. 1=로그인, 2=이동요청)
};
size는 말 그대로 그 패킷 전체의 크기에 대한 정보를 갖고 있을 것이다.
id는 이 패킷이 어떤 내용을 담고 있는지 한 번에 확인할 수 있게 하기 위한 고유 값을 가진다.
1번 ID로 오면 "이 패킷은 로그인에 관련한 데이터를 갖고 있구나~"라고 바로 알 수 있고,
ID에 맞게 분기해 데이터를 정상적으로 처리할 수 있게 된다.
1-1. 왜 size가 필요하지?
"ID로 패킷을 구분할 수 있다면 SIZE에 대한 내용은 필요 없는 것이 아닐까?"
어차피 ID로 패킷의 내용물을 구분한다면 굳이 사이즈에 대한 내용이 필요 없을 것 같긴 하다.
하지만 size는 필요하다.
왜 필요한가?
가변 사이즈의 데이터를 처리하기 위해 필요하다.
우린 모든 패킷 ID에 대해 사이즈를 정의하지 않았다.
채팅을 생각해 보자.
이 채팅 서버는 채팅에 대한 사이즈를 정해두었고, 어떤 내용이든 딱 그만큼의 길이의 패킷만 받는다.
이 채팅에선 최대 4096바이트만큼 보낼 수 있다.
그러나 모든 채팅을 4096바이트만큼 꽉 채워서 보낼 수는 없고 그럴 일도 거의 없다.
실제 채팅은 "안녕?"이지만 그만큼의 제외한 나머지는 쓰레기값이 된다. 하지만 보내야 한다.
서버에서 정해진 만큼의 데이터가 아니면 문제가 있다고 생각하고 처리하지 않을 것이기 때문이다.
그러면 쓰레기값의 크기만큼 자원의 낭비가 일어나는 것이다.
하지만 사전에 정해진 크기 없이 필요한 만큼의 데이터를 보낼 수 있다면, 큰 절약이 될 것이다.
"안녕?"을 보내든, "ㅇㅇ"를 보내든 딱 그만큼만 쓸 수 있어 효율적이다.
특히 필드 채팅의 경우는 Broadcasting을 할 수밖에 없는데, 4096바이트를 다 뿌린다고 생각하면...
돌아와서 Packet Session 클래스를 작성하자.
class PacketSession : public Session
{
public:
PacketSession();
virtual ~PacketSession();
PacketSessionRef GetPacketSessionRef() { return static_pointer_cast<PacketSession>(shared_from_this()); }
protected:
virtual int32 OnRecv(BYTE* buffer, int32 len) sealed; // 자식 클래스에서 오버라이드 불가
virtual int32 OnRecvPacket(BYTE* buffer, int32 len) abstract;
};
이전과 동일하게 클래스에 대한 포인터 변수를 갖는다.
그리고 OnRecv()와 OnRecvPacket()을 선언했다.
OnRecv()엔 sealed 키워드를 통해 자식 클래스가 상속받더라도 오버라이드 하지 못하게 했다.
그리고 OnRecvPacket()이라는 추상 함수를 선언했다.
생성자와 소멸자엔 특별히 구현할 사항이 없다.
1-2. Sealed 키워드를 사용한 이유
"PacketSession을 상속받는 클래스에서도 당연히 OnRecv를 호출해야 하는 게 맞지 않나?"
라는 의문이 나도 들었다.
이유를 나름대로 생각해 보던 중 다시 OnRecv() 함수의 정의를 보고 있으니 떠올랐다.
// [size(2)][id(2)][data....][size(2)][id(2)][data....]
int32 PacketSession::OnRecv(BYTE* buffer, int32 len)
{
int32 processLen = 0;
while (true)
{
int32 dataSize = len - processLen;
// 최소한 헤더는 파싱할 수 있어야 한다
if (dataSize < sizeof(PacketHeader))
break;
PacketHeader header = *(reinterpret_cast<PacketHeader*>(&buffer[processLen]));
// 헤더에 기록된 패킷 크기를 파싱할 수 있어야 한다
if (dataSize < header.size)
break;
// 패킷 조립 성공
OnRecvPacket(&buffer[processLen], header.size);
processLen += header.size;
}
return processLen;
}
OnRecv() 함수는 중간에 헤더만이라도 제대로 왔는지 확인하고,
헤더의 크기보다 많은 사이즈의 데이터가 왔으면 그제야 OnRecvPacket()을 호출한다.
그렇다면 OnRecv()는 수신 시 호출되긴 하지만 완전히 마지막으로 처리하는 무언가는 아닌 것이다.
여기서 생각해 볼 점은 TCP 프로토콜의 특성이다.
TCP는 데이터를 완전한 형태로 보낸다는 보장이 없다.
헤더조차 제대로 오지 않을 수 있고, 헤더는 왔지만 데이터가 완전하지 않을 수 있다.
그렇다면 완전하지 않은 데이터를 OnRecvPacket()으로 넘길 수 없을 것이다.
만약 패킷이 분산돼 왔다면, 분산된 패킷을 모아서 이것들을 하나로 만든 후 넘겨야 할 것이다.
이 패킷이 완전한 형태인지 아닌지 판별하는 작업을 OnRecv()가 하는 것이다.
패킷이 다 조립이 됐다면 OnRecvPacket()으로 데이터를 넘겨줘 클라 세션에서 처리를 하게 된다.
자 이제 정리해 보자.
OnRecv()는 패킷이 정상적인지 판단한다.
OnRecvPacket()은 컨텐츠 코드에서 오버라이드 되어 사용된다.
패킷이 정상적인지 컨텐츠 코드에서 판단할 이유가 있을까?
전혀 없다. 그냥 멀쩡한 패킷을 넘겨주면 그걸 그대로 처리할 뿐이다.
따라서 PacketSession을 상속받은 클래스에서 실수로라도 OnRecv()가 오버라이드 되어 기능이 망가지는 일이 없도록,
「sealed」 키워드를 통해 오버라이드를 막은 것이다.
2. 수정사항 반영
이제 GameSession을 비롯한 각 세션 구조체들은 Session이 아니라 PacketSession을 상속받게 될 것이다.
따라서 OnRecvPacket() 함수를 오버라이드 해 줘야 한다.
OnRecv() 함수는 사용하지 않을 것이기 때문에 관련한 내용은 다 제거한다.
int32 GameSession::OnRecvPacket(BYTE* buffer, int32 len)
{
PacketHeader header = *((PacketHeader*)buffer);
cout << "Packet ID : " << header.id << " // Size : " << header.size << endl;
SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
BYTE* tempBuffer = sendBuffer->Buffer();
((PacketHeader*)tempBuffer)->size = (sizeof(buffer) + sizeof(PacketHeader));
((PacketHeader*)tempBuffer)->id = 1; // 1 : Hello Msg
::memcpy(&tempBuffer[4], buffer, sizeof(buffer));
sendBuffer->Close((sizeof(buffer) + sizeof(PacketHeader)));
Send(sendBuffer);
this_thread::sleep_for(1s);
return len;
}
서버 쪽 GameSession의 OnRecvPacket() 함수는 위와 같이 구현됐다.
사실 원래 에코 서버 생각은 없었는데...
에코 서버 형태로 구현하지 않으면 저 로그가 찍히지 않을 것이라 로그를 보기 위해서 일단 에코 서버 형태로 하기로 한다.
더미 클라이언트의 OnRecvPacket() 함수도 아래와 같이 작성했다.
virtual int32 OnRecvPacket(BYTE* buffer, int32 len) override
{
PacketHeader header = *((PacketHeader*)buffer);
//cout << "Packet ID : " << header.id << "Size : " << header.size << endl;
// 내용물 출력
char recvBuffer[4096];
::memcpy(recvBuffer, &buffer[4], header.size - sizeof(PacketHeader));
cout << recvBuffer << endl;
SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
BYTE* tempBuffer = sendBuffer->Buffer();
((PacketHeader*)tempBuffer)->size = (sizeof(tempBuffer) + sizeof(PacketHeader));
((PacketHeader*)tempBuffer)->id = 1; // 1 : Hello Msg
::memcpy(&tempBuffer[4], buffer, sizeof(buffer));
sendBuffer->Close((sizeof(buffer) + sizeof(PacketHeader)));
Send(sendBuffer);
this_thread::sleep_for(1s);
return len;
}
그리고 일단 데이터를 처음에 보내줄 뭔가가 필요하므로 서버 메인 스레드에서 Broadcasting을 때려주도록 하자.
for (int32 i = 0; i < 5; i++)
{
GThreadManager->Launch([=]()
{
while (true)
{
service->GetIocpCore()->Dispatch();
}
});
}
char sendData[1000] = "Hello World";
while (true)
{
SendBufferRef sendBuffer = GSendBufferManager->Open(4096);
BYTE* buffer = sendBuffer->Buffer();
((PacketHeader*)buffer)->size = (sizeof(sendData) + sizeof(PacketHeader));
((PacketHeader*)buffer)->id = 1; // 1 : Hello Msg
::memcpy(&buffer[4], sendData, sizeof(sendData));
sendBuffer->Close((sizeof(sendData) + sizeof(PacketHeader)));
GSessionManager.Broadcast(sendBuffer);
this_thread::sleep_for(250ms);
}
1초는 너무 긴 거 같고 0.25초마다 쏜다.
3. 실행
클라는 버퍼의 내용을 출력하도록 했고, 서버에선 패킷 ID와 그 사이즈를 출력하게 했다.
글엔 따로 얘기하지 않았지만 청크가 제대로 순환하고 있는지 살펴보기 위해 로그를 찍게 했다.
패킷도 잘 보내지고 그 내용으로 에코잉도 잘 이루어지고 있다. 메모리 누수도 보이지 않는다.
4. 문제상황
클라이언트의 개수를 1000개 정도로 늘려서 테스트해 보자.
적은 클라이언트가 연결된 상황에선 잘 동작할지 몰라도 동접이 많아지면 문제가 생기기 마련이다.
개수를 늘린 후 갑자기 클라이언트의 연결이 끊기는 상황을 테스트해 보자.
문제가 생겼다.
정말 이거만 봐선 무슨 문제인지 모르겠다.
하지만 일단 메모리 오염이 일어난 것만은 확실한 것 같다.
콜 스택을 따라 예외가 일어나기 전 어떤 함수가 호출됐는지 찾아보자.
Broadcast() 함수에서 뭔가 문제가 생겼다는 것을 알았다.
void GameSessionManager::Broadcast(SendBufferRef sendBuffer)
{
WRITE_LOCK;
for (GameSessionRef session : _sessions)
{
session->Send(sendBuffer);
}
}
Broadcast() 함수는 이게 전부다.
여기서 무슨 일이 일어났다는 것일까?
메모리 오염이긴 하지만 어디서 일어났는지 바로 알 수는 없기 때문에 일단 Send()를 타고 가보자.
일단 에러가 생긴 것이기 때문에 HandleError()로 빠졌을 가능성이 높을 것 같다.
void Session::HandleError(int32 errorCode)
{
switch (errorCode)
{
case WSAECONNRESET:
case WSAECONNABORTED:
Disconnect(L"HandleError");
break;
default:
// TODO : Log
cout << "Handle Error : " << errorCode << endl;
break;
}
}
클라이언트를 일방적으로 강제 종료 시킨 것이기 때문에 Disconnect()가 호출되어 넘어갔을 것이다.
void Session::Disconnect(const WCHAR* cause)
{
if (_connected.exchange(false) == false)
return;
// TEMP
wcout << "Disconnect : " << cause << endl;
OnDisconnected(); // 컨텐츠 코드에서 재정의
GetService()->ReleaseSession(GetSessionRef());
RegisterDisconnect();
}
여기선 OnDisconnect()로 넘어간다.
GameSession의 OnDisconnect()는 아래와 같다.
void GameSession::OnDisconnected()
{
GSessionManager.Remove(static_pointer_cast<GameSession>(shared_from_this()));
}
세션매니저의 Remove() 함수를 호출한다.
또 이 함수를 타고 가면
void GameSessionManager::Remove(GameSessionRef session)
{
WRITE_LOCK;
_sessions.erase(session);
}
void GameSessionManager::Broadcast(SendBufferRef sendBuffer)
{
WRITE_LOCK;
for (GameSessionRef session : _sessions)
{
// ...
}
}
Broadcast() 함수를 만날 수 있다.
그렇다면 사실상 이 에러상황에서의 Broadcast() 함수의 코드 동작은 아래와 같다고 볼 수 있다.
void GameSessionManager::Broadcast(SendBufferRef sendBuffer)
{
WRITE_LOCK;
for (GameSessionRef session : _sessions)
{
// 갑자기 클라이언트를 꺼버리면 에러가 생긴다
// 돌고돌고 Remove()로 돌아오게 됨
session->Send(sendBuffer);
_sessions.erase(session)
}
}
Send를 하면서 세션들을 지워버린다.
1000개의 세션에 대해 Broadcasting이 끝났는지도 모르는데 메모리들을 해제해 버리니,
당연하게도 큰 문제가 생길 수밖에 없었다.
이렇게 타고 가면서 문제을 찾는 방법이 물론 좋지만,
무작정 찾기보단 에러에서 주는 힌트를 볼 수 있다면 더 빨리 문제 해결에 도달할 수 있을 것 같다.
다시 예외 발생 상황을 보면,
xtree에서 발생한 문제였다.
지난 때에 트리를 사용한 게 있었나 생각해 보자.
세션 관련해선 일일이 트리구조를 사용해 만든 것은 없었다.
일단 매니저가 관리하는 세션에 관한 문제였으므로 매니저의 헤더 파일을 들여다보자.
private:
USE_LOCK;
Set<GameSessionRef> _sessions;
아...!
GameSessionRef를 Set을 사용해 들고 있도록 했다.
Set(Multiset)은 Binary Serach Tree구조로 이루어져 있기 때문에 트리와 관련한 부분에서 문제가 생긴 것이었다.
만약 저 예외에서 트리 구조에 관련한 문제라는 걸 파악한 후,
Set을 사용해 GameSessionRef를 갖고 있게 했던 것을 떠올렸다면?
보다 빠르게 문제를 찾아 신속한 문제 해결을 가능케 했을 것이다.
4-1. 에러 수정
여하튼 우리는 모든 Send()가 마무리되기 전에 세션을 지워버려 생긴 문제라는 것을 알았다.
OnDisconnect()가 호출되던 곳을 다시 보자.
void Session::Disconnect(const WCHAR* cause)
{
if (_connected.exchange(false) == false)
return;
// TEMP
wcout << "Disconnect : " << cause << endl;
OnDisconnected(); // 컨텐츠 코드에서 재정의
GetService()->ReleaseSession(GetSessionRef());
RegisterDisconnect();
}
Disconnect()를 걸지도 않았는데, 그전에 OnDisconnect()가 호출되어 Remove() 함수를 호출하게 된다.
그렇다면 Disconnect가 제대로 걸린 후 ProcessDisconnect()가 호출됐다면,
모든 연결이 정상적으로 종료됐다는 것을 의미한다.
달리 말하면 모든 Send가 정상적으로 수행된 후, 메모리 오염의 우려가 없는 상태가 됐다는 것이다.
OnDisconnect()와 세션을 릴리즈 해 주는 부분을 ProcessDisconnect()로 옮겨주자.
void Session::ProcessDisconnect()
{
_disconnectEvent.owner = nullptr; // RELEASE_REF
OnDisconnected(); // 컨텐츠 코드에서 재정의
GetService()->ReleaseSession(GetSessionRef());
}
이러면 괜찮을 것이다.
4-2. 테스트
예외가 생기지 않는다.
따로 클라이언트를 실행시켜도 서버에 잘 붙는 것을 보니 문제가 없다고 볼 수 있겠다.
에러를 찾고 고쳐나가는 과정은 분명 라이브 서버에선 쉽지 않을 것이다.
최근 블루아카이브 일본 서버에서 일어난 사건을 생각하면 오싹한 일이다.
하지만 그 과정이 어려운 만큼 해결했을 때의 쾌감 또한 있을 것이다.
아니면 그 에러가 저주스럽든가...
'Study > C++ & C#' 카테고리의 다른 글
[C++] Buffer Helpers (0) | 2023.07.07 |
---|---|
[C#] PingPlugin (0) | 2023.07.06 |
[C++] SendBuffer Pooling (0) | 2023.06.30 |
[C++] SendBuffer (0) | 2023.06.29 |
[C++] RecvBuffer (0) | 2023.06.28 |