안녕하세요? Rhea입니다.
누군지 모르신다고요?
아...흠....그러니까..... (;゜∇゜);;;;;;;;
한때 네트워크 강좌를 적다가 잠적했던 백수 히키코모리인데요...
그것도 추가 강좌를 적겠다고 맘먹고 놀기 바빠 사라졌는데요......
으으윽 잘못했습니다. ㅠㅠ
성실하게 살겠습니다.
때리시면 맞겠습니다... ㅠㅠ
지난 시간 Serialize, 직렬화에 대해 언급했습니다.
그리고 직렬화를 이뤄지는 라이브러리에는 상용도 있고 공개된 버전도 있다고 하였습니다.
물론 Boost.Serialization을 잊지 말아야겠죠, 이 강좌에서 사용하는 엔진이 ASIO이고 ASIO는 Boost 답게 Boost.Serialization과 멋진 궁합을 보여줍니다.
1. 패킷 정의
이번에 보내볼 패킷은 다음과 같습니다. 대강 그럴싸하게 만든 엉터리 패킷입니다. (* ̄∇ ̄)/
// MySerializePacket.h
//
class ANS_LOGIN
{
public:
// 로긴성공
BOOL isOK;
// 사용자 식별번호
UINT uUserSerialNumber;
// 사용자 이름
string strName;
// 경험치
long lExp;
// 레벨
UINT uLevel;
// 소유 장비
map<long, long> mapEquipment;
// 친구목록
vector<UINT> vtGuildFriend;
}
이것은 로비에 접속되었다고 가정했을때, 로긴 성공 메시지로 알려주는 내용입니다. BOOK isOK로 성공했다고 알려주고, UINT uUserSerialNumber 라고 사용자 식별번호도 알려줍니다.
UINT uUserSerialNumber 같은 INT형 사용자 식별번호는 시중의 책에서는 거의 나오지 않습니다(아니 그전에 로긴 방법을 소개하는 게임 제작 책이 있던가요?) 예전에 만든 아바타 채팅에서는 단순하게 사용자별 Key값을 단순히 ID로 했습니다. 그러나 실제 서비스되고 있는 상용 서버(게임 이외에도) 내에서는 string 형태의 ID를 사용하지 않고 INT형으로 분류합니다.
string에서 INT로 바뀌면 개발자에게 상당히 편합니다. 개발자 뿐일까요, string으로 검색하지 않고 INT로 검색하니 컴퓨터에게도 빠르고 좋습니다. 당연히 DB에서도 Primary Key값은 이 UserSerialNumber 같은 INT값입니다!!! 이렇게 짤때 따라오는 코딩의 편리함에 대해서는 따로 이야기하지 않겠습니다. 여기에 그치지 않고 서비스의 성격부터 바꿀수 있습니다.
이것에 대한 예로는 트위터를 들까 합니다.
트위터는 표시되는 프로필이름(대화명)을 바꿀수 있습니다.
계정 메뉴에서 아이디를 바꿀수 있습니다.
그리고 무려 이메일 주소도 바꿀수 있습니다!!!
그럼 사용자 구분은 뭘로 할까요, <ID>라는 별도의 INT형(실은 LONG 정도 되겠죠?) 필드로 따로 관리합니다. 물론 사용자에겐 보여지지 않습니다.
JAWITTER = Joint Assault Windows Interface for twiTTER 라는 4달째 개발"중"인 툴입니다. 필자의 ID가 보입니다.
모든 멤버쉽 서비스는 이렇게 돌아갑니다. 이메일이나 ID를 변경하느냐 못하느냐는 기획과 사업적인 문제이지 개발적인 문제가 아닙니다.
다음을 보죠, 드디어 STL string이 나타났습니다. char에 담은게 아닙니다. 그리고 장비목록은 STL map에 담았고 친구리스트는 STL vector에 담았습니다.
과연 제대로 날라갈수 있을까요?
2. Boost.Archive
Boost.Serailize의 컨셉은 Boost.Archive란 유틸리티 클래스에 기반합니다.
Archive는 직렬화를 하는 text_oarchive 클래스와 풀어주는 text_iarchive 로 구분됩니다.
이건 코드를 보는 편이 훨씬더 빠릅니다.
#include <boost/archive/text_oarchive.hpp>
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <fstream>
void save()
{
std::ofstream file("archiv.txt");
boost::archive::text_oarchive oa(file);
int i = 1;
oa << i;
}
void load()
{
std::ifstream file("archiv.txt");
boost::archive::text_iarchive ia(file);
int i = 0;
ia >> i;
std::cout << i << std::endl;
}
int main()
{
save();
load();
}
간단한 파일 세이브/로드지만 막강합니다. << 로 파일 스트림으로 쓰고 >>로 파일 스트림에서 읽어왔습니다.
이제는 일반 변수가 아닌 클래스를 갖고 놀아보죠. 왜냐면 패킷도 클래스이기 때문입니다.
#include <boost/archive/text_oarchive.hpp>
#include <boost/archive/text_iarchive.hpp>
#include <iostream>
#include <sstream>
std::stringstream ss;
class person
{
public:
person()
{
}
person(int age)
: age_(age)
{
}
int age() const
{
return age_;
}
private:
friend class boost::serialization::access;
template <typename Archive>
void serialize(Archive &ar, const unsigned int version)
{
ar & age_;
}
int age_;
};
void save()
{
boost::archive::text_oarchive oa(ss);
person p(31);
oa << p;
}
void load()
{
boost::archive::text_iarchive ia(ss);
person p;
ia >> p;
std::cout << p.age() << std::endl;
}
int main()
{
save();
load();
}
person이라는 클래스를 직렬화했습니다!!
이것을 해주는 것이 Boost안의 serailization.hpp의 인라인함수인 serialize()입니다.
//
// serailization.hpp
//
// default implementation - call the member function "serialize"
template<class Archive, class T>
inline void serialize(
Archive & ar, T & t, const BOOST_PFTO unsigned int file_version
){
access::serialize(ar, t, static_cast<unsigned int>(file_version));
}
이를 위해서는 person안에 serialize()를 오버라이딩해줘야 합니다.
이 모습은 우리에게 친숙한 MFC에서도 발견할수 있습니다. MFC의 맨상위 클래스인 CObject를 보시면 Serialize()이란 가상함수가 존재하고 있음을 발견하게 됩니다.
// afx.h
//
virtual void Serialize(CArchive& ar);
이는 MFC에서 상당히 중요한 의미를 가지는데 MFC의 모든 클래스에서 직렬화를 구현할수 있다는 의미입니다.
Boost에서는 따로 선언을 해줘야 합니다. 위에서는 friend class boost::serialization::access;로 끌어댕겼습니다.
지금보면 MFC는 Native C++과 생소한 Windows 자료형, 그리고 리소스가 당시의 부족했던 기술로 조합하거나 너무 큰 기술로 결합되어 있고 지금도 그 Legacy가 제대로 걷혀지지도 못했습니다.
누군가 제 블로그를 MFC 예제 사이트라고 오래 전에 멋대로 적은 바람에 그 이후엔 블로그에서는 MFC 사용도 안하고 있습니다.
다만 MFC은 무려 1992년에 나온 거의 최초의 상업용 C++ 클래스이자 프레임워크입니다(볼랜드는 OWL).
그안에 들어간 개념과 철학들은 C++의 역사라고 봐도 무방합니다.
그리고 후대의 많은 프레임워크들에게 엄청난 영향을 주었습니다. 아이폰 앱을 만드는 Cocoa 프레임워크도 MFC의 영향, 엄~~~~~~~~청나게 받았습니다.제가 말씀드리고 싶은 것은 바로 코앞에 있는 MFC의 개념을 잘 이해하면 자신만의 프레임워크를 만들기도 편하고 다른 프레임워크를 이해하기도 무척 편하다는 말입니다.
MFC가 없었으면 C++도 없었어!!
3. Asio.Serialization
직렬화의 비밀은 Archive에 있다고 알려졌습니다. 이제는 Archive를 ASIO, 즉 소켓으로 보내볼 차례입니다.
뜬금없지만 평소 적고 싶었던 말
그전에 잠깐 하고픈 말 하나 할께요,
파일과 소켓은 엄청 닮았습니다. 아니 둘은 똑같습니다. 원조 OS인 UNIX는 소켓도 파일로 칩니다. 아니, UNIX는 죄다 파일 개념입니다.
이게 윈도우도 올라와서는 어떨까요?
넵, 둘다 핸들입니다!!!!
일단 핸들만 얻게되면 OS는 파일에 쓰듯 데이터 스트림을 보내고(쓰기), 받습니다(읽기).
혹시 모르시는 분들을 위해 설명드리면 윈속의 send/read 함수 대신 WriteFile/ReadFile을 써도 잘됩니다.
파일에 대해 비동기로 쓰고 읽는 WriteFileEx()는 어떨까요?
__in HANDLE hFile,
__in_opt LPCVOID lpBuffer,
__in DWORD nNumberOfBytesToWrite,
__inout LPOVERLAPPED lpOverlapped,
__in_opt LPOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
왠지 엄청 친숙한 녀석들이 들어있지 않나요?
비동기 데이터를 보내는 WSASend()를 볼까요?
__in SOCKET s,
__in LPWSABUF lpBuffers,
__in DWORD dwBufferCount,
__out LPDWORD lpNumberOfBytesSent,
__in DWORD dwFlags,
__in LPWSAOVERLAPPED lpOverlapped,
__in LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
인자 하나가 다르고 똑같습니다. 이거 그대로 써도 작동 됩니다.
첫 강좌에서 말한대로 비동기 입출력은 원래 파일을 위한 것이었습니다. 그 개념 그대로 소켓에 적용시킨 것이죠.
코딩에 앞서 뿌니뿌니~♡
먼저 패킷 클래스에 직렬화 함수를 추가합니다.
// MySerializePacket.h
//
class ANS_LOGIN
{
public:
// 로긴성공
BOOL isOK;
// 사용자 식별번호
UINT uUserSerialNumber;
// 사용자 이름
string strName;
// 경험치
long lExp;
// 레벨
UINT uLevel;
// 소유 장비
map<long, long> mapEquipment;
// 친구목록
vector<UINT> vtGuildFriend;
template <typename Archive>
void serialize(Archive& ar, const unsigned int version)
{
ar& isOK;
ar& uUserSerialNumber;
ar& strName;
ar& lExp;
ar& uLevel;
ar& mapEquipment;
ar& vtGuildFriend;
}
};
후훗, 추가되었습니다.
다음은 핵심인 직렬화 과정입니다.
이번 회의 소스는 ASIO 예제사이트인 http://www.boost.org/doc/libs/1_48_0/doc/html/boost_asio/examples.html 에 있는 Serialization 항목에서 가져왔습니다. 그런데 해당 예제는 서버에 Session 클래스가 없습니다. 실제 세션을 열어 데이터를 주고 받을 수 없기 때문에 http://www.boost.org/doc/libs/1_48_0/doc/html/boost_asio/example/serialization/connection.hpp 을 가져와 CSerializeEngine이라는 이름을 붙였습니다. 무려 클래스명에 Engine씩이나 붙인 이유는 이 정도 작업을 해주는 클래스는 정말로 서버 Engine 레이어이기 때문입니다.
다음은 실제 패킷을 직렬화하고 소켓으로 보내는 부분입니다.
/// Asynchronously write a data structure to the socket.
template <typename T, typename Handler>
void async_write(const T& t, Handler handler)
{
// Serialize the data first so we know how large it is.
std::ostringstream archive_stream;
boost::archive::text_oarchive archive(archive_stream);
archive << t;
outbound_data_ = archive_stream.str();
// Format the header.
std::ostringstream header_stream;
header_stream << std::setw(header_length)
<< std::hex << outbound_data_.size();
if (!header_stream || header_stream.str().size() != header_length)
{
// Something went wrong, inform the caller.
boost::system::error_code error(boost::asio::error::invalid_argument);
socket_.get_io_service().post(boost::bind(handler, error));
return;
}
outbound_header_ = header_stream.str();
// Write the serialized data to the socket. We use "gather-write" to send
// both the header and the data in a single write operation.
std::vector<boost::asio::const_buffer> buffers;
buffers.push_back(boost::asio::buffer(outbound_header_));
buffers.push_back(boost::asio::buffer(outbound_data_));
boost::asio::async_write(socket_, buffers, handler);
}
이제까지와 마찬가지로 앞부분에 헤더를 붙이는 과정을 일단 생략하면 Boost.Serialzation 예제와 똑같습니다.
직렬화로 보내고 받는 함수는 Session 클래스에 있던 함수 대신 CSerializeEngine에 있는 함수들을 써야 합니다.
그래서 실제로 이 부분은 서버의 Engine 레이어라는 것입니다.
ANS_LOGIN 패킷을 꼭꼭 채워봅시다.
// RheaGameSession.cpp
//
ANS_LOGIN ansLogin;
ansLogin.isOK = TRUE;
ansLogin.uUserSerialNumber = 10001;
ansLogin.strName = _T("레아스트라이크");
ansLogin.lExp = 68000L;
ansLogin.uLevel = 99;
ansLogin.mapEquipment.insert(Long_Pair(1, 101));
ansLogin.mapEquipment.insert(Long_Pair(2, 102));
ansLogin.mapEquipment.insert(Long_Pair(3, 103));
ansLogin.mapEquipment.insert(Long_Pair(4, 104));
ansLogin.mapEquipment.insert(Long_Pair(5, 105));
ansLogin.vtGuildFriend.push_back(10011);
ansLogin.vtGuildFriend.push_back(10012);
ansLogin.vtGuildFriend.push_back(10013);
ansLogin.vtGuildFriend.push_back(10014);
ansLogin.vtGuildFriend.push_back(10015);
AnsLoginVector.push_back(ansLogin);
m_connection.async_write(AnsLoginVector, boost::bind(&CRheaGameSession::handle_write_serialization, this, boost::asio::placeholders::error ));
string은 변환없이 string으로 채웠고 map과 vector에도 데이터를 넣었습니다.
앞서 말한대로 CSerializeEngine::async_write()를 통해 데이터를 보냅니다. ASIO에서는 데이터를 보낼때 기본적으로 vector에 담아 보냅니다. 이는 대단히 편리한데 만약 여러사용자에 대한 데이터를 보낸다면 그대로 vector에 담아 보낼수 있겠죠.
STL 컨테이너들의 실제 Archive는 여러 파일에 나눠져 있습니다. \boost\boost_1_47\boost\serialization 폴더에 보시면 보낼수 있는 컨테이너들이 들어있습니다. 정말이지 이런 것을 공짜로 작업하신 훌륭하신 분들에게 감사의 말씀을 드립니다.
실제로 데이터가 갈까요?
결과를 확인해보죠.
// ClientSocket.h
//
void handle_read(const boost::system::error_code& e)
{
if (!e)
{
// Print out the data that was received.
for (std::size_t i = 0; i < AnsLoginVector.size(); ++i)
{
BOOL bIsOK = AnsLoginVector[i].isOK;
UINT uUSN = AnsLoginVector[i].uUserSerialNumber;
string strName = AnsLoginVector[i].strName;
int iEquipmentSize = AnsLoginVector[i].mapEquipment.size();
int iFriendsSize = AnsLoginVector[i].vtGuildFriend.size();
}
}
else
{
// An error occurred.
e.message();
OnClose();
}
}
귀찮습니다, 그냥 Watch창으로 확인해보죠!
넵, 그대로 날라왔습니다. type값 역시 그대롭니다. 클라이언트에도 같은 클래스로 Archive하여 그대로 나왔습니다만,
마치 마법과도 같습니다.
우리는 string과 map과 vector를 그대로 소켓에도 쏘고 그대로 받은 것입니다!!!!!
4. 직렬화가 가져온 것
지난 강좌에도 말씀드렸지만 이런 네트워크 직렬화는 결코 쉬운게 아니었습니다. 엄청난 노가다의 결실입니다. MFC의 CSocket도 물론 네트워크 직렬화를 해줍니다만, WSAAsyncSelect 모델이라 서버로 사용할만한 것은 아니었습니다.
그러나 아직까지 개선해볼 사항이 있습니다. 역시 지난 강좌에 소개한 IDL 컴파일러 같은 것이죠.
이 과정들이 자동화 될 부분이 있습니다.
보셨겠지만 직렬화 함수는 단순합니다. 패킷 클래스의 멤버들을 파싱하여 자동으로 직렬화 함수를 만들수 있지 않을까요?
혹은 애시당초 별도의 자신만의 스크립트 형태로 만들어 빌드가능한 클래스로 자동 생성시키는 방법이 있습니다.
2) 각 패킷별 데이터 수신 함수를 자동으로 만들어주기
1)에 연장하여 각 패킷 이름을 파싱해 수신 핸들러 함수를 만드는 것입니다.
OnAnsLogin() 식으로 만들수 있을 것입니다.
이는 결코 어렵지 않습니다, 힌트 다 드렸잖아요.
그리고 우리는 여기서 아주 중요한 아키텍트를 하나 추리해 낼수 있습니다.
게임에 사용되는 사용자 클래스를 그대로 네트워크로 내보낼 수 있으니 게임용 클래스와 네트워크 패킷을 따로 만들지 않아도 된다는 것과
독립된 서버 I/O 모듈과 게임 엔진이 잘 작동한다면 클라이언트 개발자, 혹은 서버 개발자가 아닌 컨텐츠 개발자가 혼자 게임 로직을 만들수 있게 된다는 점입니다.
이는 생산 측면에서 아주 유용합니다. 이런 모델을 추천합니다. 하지만 제가 이 컨텐츠 개발자라면 클라이언트와 서버 코드, 둘다 제것으로 만들고 공부할 것입니다. 이런 개발 모델은 편한 작업 환경에서 팀생산성을 위한 것이지 개발자에게 서로 독립된 레이어니까 전혀 몰라도 된다~라는 의미는 아니라고 생각합니다.
5. 더 생각해볼 꺼리
평소에 자신의 소스는 항상 너무 빨리 빌드가 되어 불만이셨던 분 계십니까?
상용 게임 빌드는 몇시간씩 걸린다는데 나는 언제 그런 빌드 타임 걸려보냐라구요?
그런 고민, 이번 강좌를 통해 말끔히 해결됩니다,
아마 Boost.Serialize가 추가된 순간부터 눈에 띄는 빌드 속력 저하를 느끼셨을 것입니다.
이 짧은 소스도 앗! 하는 느낌이 올껍니다!
그리고 어떻게 하면 극복할 것인지 생각해보시길 바랍니다.
직렬화 관련 참고자료 : http://en.highscore.de/cpp/boost/serialization.html
내용은 맘대로 퍼갈수 있지만 동의없는 수정은 안되며 출처(http://www.gamedevforever.com/ , http://rhea.pe.kr/)를 명시해주세요.
'윈도우 서버에 대해서' 카테고리의 다른 글
네트워크 게임 튜터리얼 2기 - 컨텐츠편 1화 (0) | 2012.09.25 |
---|---|
C/C++로 Socket.IO에게 돌직구를 던져보았다. (0) | 2012.09.25 |
네트워크 게임 튜터리얼 4 - 로비, 그리고 할말이 많다 (0) | 2012.09.25 |
네트워크 게임 튜터리얼 3 - 데이터베이스 그리고 C/S (0) | 2012.09.25 |
네트워크 게임 튜터리얼 2 - 아키텍트 돋는 첫걸음 (0) | 2012.09.25 |