문제 정의
채팅 서버 구현을 위해 해결해야 할 사항들
목표
유니티 프로젝트에 외부 서버를 사용한 채팅 기능 구현.
목표가 아닌 것
게임 로직을 Server-Driven 하게 전면 재작성.
지금 당장의 목표는 아니지만 할 필요는 있음.
구현을 위한 순서
- 디자인 결정
- 패킷 설계
- 채팅 로직 구현
- C++ 더미 채팅 클라이언트로 테스트
- C# 더미 채팅 클라이언트로 테스트
- 유니티 프로젝트에 실장
1. 디자인 결정
서버 세션에 여러 클라이언트가 접속해 있고, 이 세션 안에서 PartySession
이라는 부분집합을 구현한다.
PartySession
에만 Broadcast 되는 정보는 외부의 다른 클라이언트에겐 닿지 않는다.
그러나 PartySession
은 서버 세션 전체에 Broadcast 되는 정보는 받을 수 있다.
클라이언트가 보내는 보통의 채팅과, 서버에서 보내는 MotD 등을 구분할 것이다.
해당 기능은 패킷에 이를 구분하기 위한 ID를 부여해 구현한다.
처리 흐름을 생각해 보자.
- 클라이언트가 서버에 접속한다. (Log In)
PartySession
에 접속한다. (Join Party)- 하지 않을 수도 있다.
PartySession
은 접속한 세션의 정보를 수집해 보관한다.- 갖고 있지 않으면 Broadcast 할 대상이 누군지 알 수 없음.
PartySession
은 클라이언트로부터 채팅이 입력되면 그 채팅을 Broadcast.
그럼 PartySession은 어떻게 구현해야 하는가?
다음과 같은 기능을 가져야 한다.
- 클라이언트 세션을 파티에 추가
- 파티에서 클라이언트 제거
- 채팅 Broadcasting
- Broadcasting을 위한 세션 정보 저장
2. 패킷 설계
A. 채팅 타입 구분
enum MsgType
{
MSG_TYPE_NONE = 0;
MSG_TYPE_MOTD = 1;
MSG_TYPE_COMMON = 2;
}
MotD와 일반 채팅으로 구분.
B. 채널 구분
enum MsgChannel
{
MSG_CHANNEL_NONE = 0;
MSG_CHANNEL_GLOBAL = 1;
MSG_CHANNEL_PARTY = 2;
}
일반 채팅인지 파티 채팅인지 구분.
C. 채팅 패킷
message S_CHATMSG
{
MsgType chatType = 1;
MsgChannel channel = 2;
bytes sendCharName = 3;
bytes content = 4;
}
타입 및 채널, 보내는 캐릭터의 이름과 그 내용을 담는다.
클라이언트도 동일한 구조의 패킷을 가진다.
D. 접속 및 로그인 관련 패킷
message S_LOGIN
{
bool result = 1;
bytes playerName = 2;
uint64 Id = 3;
}
message C_LOGIN
{
}
message S_JOIN_PARTY
{
bool result = 1;
}
message C_JOIN_PARTY
{
}
message S_LEAVE_PARTY
{
bool result = 1;
}
message C_LEAVE_PARTY
{
}
서버에서 로그인 및 파티 참가에 대한 피드백만 주게 된다.
패킷은 Protocol Buffer와 jinja2를 활용해 설계한 대로 자동 생성된다.
3. 채팅 로직 구현
패킷 핸들링을 구현하기 전에 필요한 클래스를 먼저 작성한다.
3-1. Player
#pragma once
class Player
{
public:
uint64 Id = 0; // 원래 DB에서 가져와야 함
string name;
GameSessionRef owner;
};
사실 플레이어의 ID나 닉네임은 DB에서 가져오는 게 맞지만, 일단 DB가 없고 테스트기 때문에 나중에 적당히 지어주기로 하자.
3-2. PartySession
#pragma once
class PartySession
{
public:
void Add(GameSessionRef session);
void Remove(GameSessionRef session);
void Broadcast(GameSessionRef gameSession, SendBufferRef sendBuffer);
private:
USE_LOCK;
Set<GameSessionRef> _players;
};
extern PartySession GPartySession;
이전의 GameSessionManager의 구조를 거의 그대로 갖다 박았다.
3-3. 패킷 핸들
1. Server
1-1. 로그인
bool Handle_C_LOGIN(PacketSessionRef& session, Protocol::C_LOGIN& pkt)
{
GameSessionRef gSession = static_pointer_cast<GameSession>(session);
// ID 부여
static Atomic<uint64> idCounter = 1;
string nameTmp;
nameTmp.append("테스트");
nameTmp.append(to_string(idCounter));
// 로그인 확인 패킷
Protocol::S_LOGIN loginPkt;
loginPkt.set_result(true);
loginPkt.set_id(idCounter);
loginPkt.set_playername(nameTmp);
// 세션에 정보 저장
PlayerRef pRef = MakeShared<Player>();
pRef->Id = idCounter++;
pRef->name = loginPkt.playername();
pRef->owner = gSession;
gSession->_player = pRef;
// 패킷 전송
auto sendBuffer = ClientPacketHandler::MakeSendBuffer(loginPkt);
session->Send(sendBuffer);
return true;
}
사실 이름은 append
가 아니라 C++20에 새로 생긴 format
으로 하려고 했는데, 문법적으로 문제가 없음에도 불구 이상한 에러를 뱉더라. 이건 나중에 알아보기로 했다.
1-2. 파티 참가 / 탈퇴
bool Handle_C_JOIN_PARTY(PacketSessionRef& session, Protocol::C_JOIN_PARTY& pkt)
{
GameSessionRef gSession = static_pointer_cast<GameSession>(session);
// 파티에 세션 추가
GPartySession.Add(gSession);
// 세션에 성공 여부 전송
Protocol::S_JOIN_PARTY joinPkt;
joinPkt.set_result(true);
auto sendBuffer = ClientPacketHandler::MakeSendBuffer(joinPkt);
gSession->Send(sendBuffer);
// 알림 메세지 작성해 파티에 Broadcast
Protocol::S_CHATMSG notyPkt;
string welcomeMsg;
notyPkt.set_chattype(Protocol::MSG_TYPE_MOTD);
welcomeMsg.append(gSession->_player->name);
welcomeMsg.append(" 파티 참가");
notyPkt.set_content(welcomeMsg);
sendBuffer = ClientPacketHandler::MakeSendBuffer(notyPkt);
GPartySession.Broadcast(nullptr, sendBuffer);
return true;
}
bool Handle_C_LEAVE_PARTY(PacketSessionRef& session, Protocol::C_LEAVE_PARTY& pkt)
{
GameSessionRef gSession = static_pointer_cast<GameSession>(session);
// 파티에서 세션 제거
GPartySession.Remove(gSession);
// 세션에 성공 여부 전송
Protocol::S_LEAVE_PARTY leveaPkt;
leveaPkt.set_result(true);
auto sendBuffer = ClientPacketHandler::MakeSendBuffer(leveaPkt);
gSession->Send(sendBuffer);
// 알림 메세지 작성해 파티에 Broadcast
Protocol::S_CHATMSG notyPkt;
string leaveMsg;
notyPkt.set_chattype(Protocol::MSG_TYPE_MOTD);
leaveMsg.append(gSession->_player->name);
leaveMsg.append(" 파티에서 탈퇴");
notyPkt.set_content(leaveMsg);
sendBuffer = sendBuffer = ClientPacketHandler::MakeSendBuffer(notyPkt);
GPartySession.Broadcast(nullptr, sendBuffer);
return true;
}
파티에 있는 다른 세션들에게도 참가 및 탈퇴 여부를 전해주게 했다.
두 패킷 구조는 동일하며 파티에 참가하는 것인가 아닌가의 차이만 있다.
구조가 동일하다는 것은 하나의 핸들러로 합쳐서 플래그를 통해 분기할 수 있다는 뜻이기도 하다.
1-3. 채팅
bool Handle_C_CHATMSG(PacketSessionRef& session, Protocol::C_CHATMSG& pkt)
{
GameSessionRef gSession = static_pointer_cast<GameSession>(session);
// 패킷 내용물 채우기
Protocol::S_CHATMSG sendPkt;
sendPkt.set_chattype(pkt.chattype());
sendPkt.set_channel(pkt.channel());
sendPkt.set_sendcharname(gSession->_player->name);
sendPkt.set_content(pkt.content());
SendBufferRef sendBuffer = ClientPacketHandler::MakeSendBuffer(sendPkt);
// 채널에 따라 Broadcast 할 곳 분기
switch (pkt.channel())
{
case Protocol::MSG_CHANNEL_GLOBAL:
GSessionManager.Broadcast(sendBuffer);
break;
case Protocol::MSG_CHANNEL_PARTY:
GPartySession.Broadcast(gSession, sendBuffer);
break;
default:
break;
}
// LOG
cout << gSession->_player->name << "이 채팅 전송." << endl;
return true;
}
채널이라는 구분을 두었기 때문에 이에 따라 switch
를 통해 분기할 수 있다.
void PartySession::Broadcast(GameSessionRef gameSession, SendBufferRef sendBuffer)
{
// 결국 세션 정보가 있어야 Broadcast 가능
WRITE_LOCK;
auto pos = _players.find(gameSession);
if (pos != _players.end() || gameSession == nullptr)
{
for (auto& player : _players)
{
player->Send(sendBuffer);
}
}
else
{
Protocol::S_CHATMSG infoPkt;
infoPkt.set_chattype(Protocol::MSG_TYPE_MOTD);
infoPkt.set_content("파티에 소속되어 있지 않음.");
SendBufferRef infoSendBuffer = ClientPacketHandler::MakeSendBuffer(infoPkt);
gameSession->Send(infoSendBuffer);
}
}
PartySession
에의 Broadcasting은 위와 같이 이루어진다.
파티에 소속되지 않은 세션이 해당 명령어로 보내려고 하면 이를 검출해 피드백한다.
nullptr
의 세션 인자를 받을 수 있게 한 것은 별도의 파티 소속 검증 없이 Broadcasting 할 때를 위해 필요하기 때문이다.
2. Client
클라이언트 측은 더 단순하다.
bool Handle_S_LOGIN(PacketSessionRef& session, Protocol::S_LOGIN& pkt)
{
if(!pkt.result())
return false;
return true;
}
bool Handle_S_JOIN_PARTY(PacketSessionRef& session, Protocol::S_JOIN_PARTY& pkt)
{
if (pkt.result())
cout << "파티 참가 성공!" << endl;
return true;
}
bool Handle_S_LEAVE_PARTY(PacketSessionRef& session, Protocol::S_LEAVE_PARTY& pkt)
{
if (pkt.result())
cout << "파티 탈퇴 완료" << endl;
else
return false;
return true;
}
bool Handle_S_CHATMSG(PacketSessionRef& session, Protocol::S_CHATMSG& pkt)
{
string nameTmp = pkt.sendcharname();
string contentTmp = pkt.content();
switch (pkt.chattype())
{
case Protocol::MSG_TYPE_MOTD:
cout << "<From Server> : " << contentTmp << endl;
break;
case Protocol::MSG_TYPE_COMMON:
if(pkt.channel() != Protocol::MSG_CHANNEL_PARTY)
cout << "[GLOBAL]" << " " << "<" << nameTmp << "> : " << contentTmp << endl;
else
cout << "[PARTY]" << " " << "<" << nameTmp << "> : " << contentTmp << endl;
break;
default:
break;
}
return true;
}
메세지의 타입과 채널을 구분해 그에 맞게 채팅을 출력할 수 있도록 했다.
4. C++ 더미 채팅 클라이언트로 테스트
클라이언트는 채팅을 위한 별도의 스레드를 생성한다.
// Chat Handle Thread
GThreadManager->Launch([=]()
{
// Default
Protocol::C_CHATMSG pkt;
pkt.set_chattype(Protocol::MSG_TYPE_COMMON);
pkt.set_channel(Protocol::MSG_CHANNEL_GLOBAL);
SendBufferRef sendBuffer;
while (true)
{
// 사용자로부터 채팅 인풋
string input;
getline(cin, input);
// 명령어 건져내기
istringstream ss(input);
string strBuf;
getline(ss, strBuf, ' ');
// C++11에 추가된 constexpr를 사용한 Hash 함수를 사용할까 했지만...
// 당장은 분기가 그리 많지 않아 점프 테이블을 사용하는데 큰 이점이 없다고 봤다
// 나중에 GUI를 통한 프로그램이 되면 다른 방식으로 구현될 것이다
if (strBuf == "/p")
{
input.erase(0, strBuf.size() + 1);
pkt.set_channel(Protocol::MSG_CHANNEL_PARTY);
pkt.set_content(input);
sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
service->Send(sendBuffer);
}
else if (input == "/join")
{
Protocol::C_JOIN_PARTY joinPkt;
auto sendBuffer = ServerPacketHandler::MakeSendBuffer(joinPkt);
service->Send(sendBuffer);
}
else if (input == "/leave")
{
Protocol::C_LEAVE_PARTY leavePkt;
auto sendBuffer = ServerPacketHandler::MakeSendBuffer(leavePkt);
service->Send(sendBuffer);
}
else
{
pkt.set_channel(Protocol::MSG_CHANNEL_GLOBAL);
pkt.set_content(input);
sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
service->Send(sendBuffer);
}
}
});
if문이 늘어져 있는 것이 썩 보기 좋은 광경은 아니다.
일단은 위와 같은 방식으로 채팅과 명령어를 구분하도록 했다.
1. Global 전송
2. Party 전송
PartySession
이 제 기능을 하고 있다.
일단 C++ 상에서 의도한 대로 통신이 이루어짐을 확인할 수 있었다.
5. TODO
이제 동일한 IOCP 라이브러리를 이용해 C#으로 채팅 프로그램을 작성한다.
GUI로 넘어가기 전에 콘솔 프로그램 형태로 만들어 보자.
'Study > C++ & C#' 카테고리의 다른 글
[C++] JobQueue (1 / 3) (0) | 2023.08.16 |
---|---|
[C++/C#] C# 채팅 클라이언트 간보기 (0) | 2023.08.01 |
[C++/Python] 패킷 자동화 (0) | 2023.07.13 |
[C++] Protobuf (0) | 2023.07.13 |
[C++] Packet Serialization (0) | 2023.07.12 |