Server programming in use...FiaDot

'RPC'에 해당되는 글 1건

  1. 2008/04/22 메타프로그래밍을 이용한 비동기 RPC 설계와 구현 (3)

[Developer Works | 템플릿 메타프로그래밍 활용] 마이크로소프트웨어 2008년 4월

템플릿 메타프로그래밍 활용

비동기 RPC 설계와 구현

온라인 게임 프로젝트를 진행하다 보면 서로간의 정보를 주고받는 메시지(패킷) 처리 부분에 많은 코드가 사용된다. 이는 코드 형태도 유사하고 코드 양도 상당한데 마땅한 방법이 없어 그냥 사용했거나, 여유가 없어서 더 나은 방법을 찾지 못했기 때문일 수도 있다.

이 글에서는 패킷 처리에 할애하는 많은 코드를 줄이고 나아가 템플릿 메타프로그래밍을 통해 좀 더 효율적으로 메시지를 처리하는 방법을 소개한다.

-------------------------------------

이근호 fiadot@gmail.com, http://www.fiadot.com | 현재 인제대학교 컴퓨터공학과에 재학 중이며, 온라인게임 개발업체인 NeonSoft R&D팀에서 서버 엔진을 개발하고 있다. 다양한 언어 중에서도 특히 C++에 많은 관심을 가지고 있고, 기회가 된다면 많은 서버개발자들에게 도움이 될 게임서버 관련 서적을 집필하고픈 꿈을 가지고 있다.

--------------------------------

최소한의 의미를 담고 있는 패킷의 구성은 패킷의 전체 크기, 구분할 수 있는 타입, 부가적인 데이터로 구분해 표현할 수 있다.(패킷을 구분하는 필드는 command, type, id 등 여러 가지 이름으로 부르는데 여기서는 타입이라고 한다)

일반적인 방법

일반적으로 타입에 따라 해당 데이터를 처리 함수로 분기시키는 데 switch나 if-else 구문을 사용하게 된다. 여기서는 장황한 설명보다는 간단한 코드를 살펴보면서 문제점을 짚어 보도록 한다.

<리스트 1> 로그인 패킷의 일반적인 처리 방법

// 헤더

enum PKT_TYPE

{

PKT_REQ_LOGIN, // 로그인 요청(C->S)

PKT_RES_LOGIN,// 로그인 응답(S->C)

PKT_TOTAL// 패킷 타입의 전체 개수

};

struct S_PKT_HEADER

{

int nLen; // 패킷 크기

int nType;// 패킷 타입

};

struct S_PKT_REQ_LOGIN : public S_PKT_HEADER

{

charszID[12];

charszPwd[12];

S_PKT_REQ_LOGIN(char* _szID, char* _szPwd)

{

nLen = sizeof(*this);

nType = PKT_REQ_LOGIN;

strcpy(szID, _szID);

strcpy(szPwd, _szPwd);

}

};

struct S_PKT_RES_LOGIN : public S_PKT_HEADER

{

// 로그인 실패=0, 성공=1, 에러=2

intnRet;

S_PKT_RES_LOGIN(int _nRet)

{

nLen = sizeof(*this);

nType = PKT_RES_LOGIN;

nRet = _nRet;

}

};

class CLogin : public FIASocket

{

// 중략

void Send_Req_Login(char* pszID, char* pszPassword);

void Send_Res_Login(int nRet);

void ReceivePacket(S_PKT_HEADER* pktHdr);

// 중략

};

// 소스

// send

void CLogin::Send_Req_Login(char* pszID, char* pszPassword)

{

S_PKT_REQ_LOGIN data(pszID, pszPassword);

// do something...

Send(&data, data.nLen);

}

// receive

void CLogin::ReceivePacket(S_PKT_HEADER* pktHdr)

{

switch ( pktHdr->nType )

{

case PKT_REQ_LOGIN:

S_PKT_REQ_LOGIN* pkt = (S_PKT_REQ_LOGIN*)pktHdr;

Send_Res_Login(LoginProcess(pkt->szID, pkt->szPwd));

break;

// TODO : 그 외 타입에 따른 처리

}

}

void CLogin::Send_Res_Login(int nRet)

{

S_PKT_RES_LOGIN data(nRet);

// do something...

Send(&data, data.nLen);

}

일반적인 방법의 문제점

여기에 이동에 관련된 패킷을 추가한다고 생각해 보자.

󰋎 열거형에 PKT_MOVE 타입을 추가

󰋏 S_PKT_HEADER를 상속받는 S_PKT_MOVE 구조체를 추가

󰋐 송수신 함수 구현

󰋑 switch 문에 타입에 따른 case 추가

<리스트 1>의 방식을 이용하면 하나의 패킷을 추가하는 데 이러한 번거로움을 수반하게 된다. 단순히 패킷 하나의 추가는 큰 문제가 되지 않을지 모르겠지만 수정, 추가, 삭제가 빈번하게 일어나는 패킷 처리 부분에서 이런 하나하나는 큰 문제로 부각될 수 있다. 또한 많은 부분의 코드가 중복되고 있음을 확인할 수 있는데, 이에 대해 다음과 같은 몇 가지 해결 방법을 생각해 볼 수 있다.

첫 번째로 매크로를 이용해 중복되는 코드를 매크로 함수로 만드는 방법은 부분적으로는 적용 가능하지만 전체적인 중복성을 제거하는 데에는 무리가 있다.

두 번째로 구조체를 스트림 변경해서 줄이는 방법이 있다. 매크로와 조합했을 때 상당한 효율을 얻을 수 있으나 역시 한계가 있다.

마지막으로 타입과 필요한 인자를 담은 임의의 헤더 파일을 이용해 구조체와 송수신 코드를 자동으로 생성하는 프로토콜용 IDL 컴파일러를 제작하는 방법이 있다. 패킷 추가를 위해 소스 관리와 빌드의 번거로움까지 가지고 가면서 구조의 변경에 민첩하게 대응하기 힘든 별도의 컴파일러를 제작하는 것은 부담스러울 것이다. 게다가 VSS를 이용해 소스 관리를 하면서 프로토콜용 파서를 만들어 사용해 보았지만, 매번 빌드시 CheckOut 후 빌드 CheckIn 과정을 거치며 프로젝트를 빌드하는 건 여간 귀찮은 일이 아니었다. 또한 패킷구조가 완전히 바뀔 때는 적용하기가 힘들었다. 사실 메타프로그래밍을 적용해 프로젝트에서 모든 것을 표현하느냐 아니면 새로운 프로젝트로 하느냐라는 선택의 기로에서 가장 중요하게 고려되었던 것은 조금 더 효율적이고 유지보수하기 쉬운 것이 무엇이냐는 기준으로, 연관된 것은 하나의 그릇에 담는 것을 원칙으로 삼았다.

단순히 코딩의 번거로움뿐만 아니라 효율성도 생각해 보도록 하자. 중복되지 않는 패킷 타입을 구분하는 데 switch를 사용하는 대신에, O(log N) 또는 O(1)로 타입을 구분할 수 있는 hash나 map, vector 같은 자료구조를 이용해 최적화할 수 있다.

원하는 최종 형태

우리가 최종적으로 원하는 형태는 우선 송수신 코드를 완전히 숨기고 정보를 전달할 함수만 호출해서 내부적인 처리를 노출하지 않는 것이다. 또한 하나의 목적을 가지는 패킷이 가지는 데이터 개수와 타입에도 영향을 받지 않는다. 제일 중요한 코드 양을 줄여 패킷 송수신시 필요한 최소한의 정보로 추가와 수정이 용이하게 할 수 있다.

<리스트 2> 비동기 RPC를 이용한 방법

// 헤더

enum PKT_TYPE

{

PKT_REQ_LOGIN, // 로그인 요청(C->S)

PKT_RES_LOGIN,// 로그인 응답(S->C)

PKT_TOTAL// 패킷 타입의 전체 개수

};

class CLogin : public FIASocket

{

public:

CLogin(void);

~CLogin(void);

FIA_RPC_DECL(PKT_RES_LOGIN, CLogin, (int nRet), (nRet));

FIA_RPC_DECL(PKT_REQ_LOGIN, CLogin, (char* szID, char* szPwd), (szID, szPwd));

};

// 소스

FIA_RPC_RECV_IMPL(PKT_RES_LOGIN, CLogin, (int nRet))

{

// 생략

}

FIA_RPC_RECV_IMPL(PKT_REQ_LOGIN, CLogin, (char* szID, char* szPwd))

{

// 생략

}

일반적인 방식에서 패킷 추가 시에 작업의 패킷 타입 수신 코드를 제외한 것들이 사라졌다.

또한 별도의 send, receive 함수 호출이 사라진 것을 볼 수 있다.

기본적인 지식 이해

비동기 RPC를 구현하기 위해 필요한 몇 가지 준비사항이 있다. 우선적으로 RPC에 대한 기본적인 이해와 매크로 및 템플릿에 대한 지식이 필요하다.

RPC는 원격에 존재하고 있는 함수를 실행하고 결과를 받을 수 있는 인터페이스라고 할 수 있다. 이미 많이 알려진 범용 RPC들이 존재하지만 사용하기가 어렵고 동기방식이며, 부가적인 데이터가 많이 들어간다. 즉, 최소한의 필요한 정보만 가지는 형태가 아니므로 효율성을 높이기 위한 방법으로서 기존의 RPC는 비효율적이다.

<리스트 3> 비동기 RPC에 대한 간략한 흐름

Remote Procedure Call -> Proxy -> Network -> Stub -> Local Procedure Call

원격에 있는 함수를 호출할 때 Proxy 부분을 통해 네트워크로 전송될 수 있는 데이터 형태로 가공하고 원격지의 수신부에 해당하는 Stub에서 어떠한 프로시저를 호출할 것인지를 판단해 인자의 타입과 개수에 맞춰 호출하게 된다. 동기식 RPC에서는 원격지의 프로시저가 제대로 호출되었는지와 결과는 무엇인지를 다시 호출자에게 전달하게 되는데, 이는 우리가 원하는 바가 아니다. 즉, 파일을 전송한다고 할 때 하나의 패킷을 보내고 제대로 됐는지 응답하는 과정을 반복하는 것보다 여러 패킷을 보내고 그동안 제대로 왔는지 중간 중간 체크하는 방법이 더 효율적일 것이다. 특히나 온라인게임 같은 경우에는 비동기 RPC가 필수이다.

메타프로그래밍이란?

메타프로그래밍이란 컴파일러가 코드를 만들면서 실행하는 것을 의미한다. 즉 컴파일 시간 상수에 처리와 평가, 확장이 이뤄지기 때문에 오류를 미리 예측할 수 있고 일반화된 코드를 만들어 낼 수 있다. 우리가 알고 있는 간단한 min, max 매크로 등도 메타프로그래밍이라고 할 수 있다.

다만 템플릿의 난해함으로 인해 익숙하지 않은 프로그래머에게는 곤란함을 안겨줄 수 있고, 컴파일 시간이 늘어난다는 단점을 지닌다. 또한 매크로가 덕지덕지 붙어 있는 코드를 분석해야 한다면 어려움을 겪게 될 것이고, 디버깅 과정에도 불편함이 따른다.

그렇다면 왜 메타프로그래밍을 해야 하는가에 대해 의문이 생기지 않을 수 없다. 대표적인 이유는 다음과 같다. 송수신시에 사용되는 함수 포인터를 객체로 관리하고 스트림 자동화를 위해 템플릿이 필수적이며 Send, Receive 함수 선언과 구현을 위한 최소한의 매크로 함수가 사용되기 때문이다.

RPC를 위한 기본! 소켓, 스트림 클래스

이제 본격적인 모듈 구성과 소스 설명에 들어가 보도록 한다. 비동기 RPC는 크게 Socket Wrapper, Functor, Stream, Functor Dispatch map, 송수신 함수 매크로로 구성된다. 지금부터 하나씩 차례대로 살펴보도록 하자. 하나의 패킷은 TCP나 UDP를 이용해 전달되기 때문에 소켓클래스가 필요하다.

이 글의 목적은 비동기 RPC를 구성하는 방법에 대해 서술하는 데 있다. 따라서 Socket의 틀만 갖추고 Send, Recv는 실제 버퍼를 주고받을 때의 상황만 연출해 놓았다. 패킷 구조는 버퍼의 처음 4바이트를 타입으로 정의하고 그 뒤에는 모두 데이터로 정의했다. TCP에서는 패킷이 뭉쳐오는 경우도 있으므로 패킷 사이즈도 필수적이지만, 이해의 편의를 위해 여기서는 생략하도록 한다.

<리스트 4> Socket 기본 클래스

class FIASocket

{

public:

FIASocket(void) {};

~FIASocket(void) {};

// connect, listen, accept 등의 socket관련 부분은 생략

boolSend(char* pData, DWORD dwSize);

boolRecv(char* pData, DWORD dwSize);

const SOCKET&GetSocket() const { return m_Socket; }

private:

SOCKETm_Socket;

};

boolFIASocket::Send(char* pData, DWORD dwSize)

{

printf("Send : pData=0x%p, size=%d \n", pData, dwSize);

Recv(pData, dwSize);

return true;

}

boolFIASocket::Recv(char* pData, DWORD dwSize)

{

printf("Recv : pData=0x%p, size=%d \n", pData, dwSize);

DWORD dwType = (DWORD)pData[0];// 처음 4바이트는 타입

FIAFuncDispatch::Instance().Call(this, dwType, &pData[sizeof(DWORD)]);

return true;

}

다음은 패킷의 내용을 담는 Stream 클래스이다. Stream은 기본적으로 패킷 버퍼에 실제 데이터를 Read, Write할 때 사용하는 클래스이다. 이는 일반적으로 이해하고 있는 Stream과 동일한 기능을 한다.

<리스트 5> Stream 기본 클래스

class FIAStream

{

public:

FIAStream(VOID) : m_pBuff(NULL), m_nWritePos(0), m_nReadPos(0)

{

}

~FIAStream(VOID) { }

private:

char*m_pBuff;// 외부에서 받은 포인터

UINTm_nWritePos;// 쓰기 위치

UINTm_nReadPos;// 읽기 위치

inlinevoidReadBuffer(VOID* pData, UINT nSize)

{

memcpy(pData, m_pBuff + m_nReadPos, nSize);

m_nReadPos += nSize;

}

inlinevoidWriteBuffer(VOID* pData, UINT nSize)

{

memcpy(m_pBuff + m_nWritePos, pData, nSize);

m_nWritePos += nSize;

}

public:

boolSetBuffer(char *buffer)

{

if ( NULL == buffer )

return false;

m_nReadPos = 0;

m_nWritePos = 0;

return true;

}

UINTGetReadPos(){ return m_nReadPos;}

UINTGetWritePos(){ return m_nWritePos;}

template <typename T>voidRead(T *data)

{

ReadBuffer(data, sizeof(T));

}

// 템플릿 특화 부분

template <>voidRead(INT *data)

{

ReadBuffer(data, sizeof(INT));

}

template <>voidRead(char** pData)

{

UINT nLen = 1;

ReadBuffer(&nLen, sizeof(UINT));

*pData = (char*)m_pBuff + m_nReadPos;

m_nReadPos += nLen;

}

template <typename T>voidWrite(T data)

{

WriteBuffer(&data, sizeof(T));

}

template <>voidWrite(INT data)

{

WriteBuffer(&data, sizeof(INT));

}

template <>voidWrite(char* data)

{

UINT nLen = (UINT)strlen(data) + 1;

WriteBuffer(&nLen, sizeof(UINT));

WriteBuffer(data, nLen);

}

};

Read, Write 부분에 인자의 타입에 따라 동적 바인딩하는 부분을 살펴보도록 하자.

<리스트 6> Stream 클래스의 Read 템플릿 함수의 일부

template <typename T>voidRead(T *data)

{

ReadBuffer(data, sizeof(T));

}

template <>voidRead(INT *data)

{

ReadBuffer(data, sizeof(INT));

}

템플릿으로 Read를 구현한 뒤 INT형에 대한 템플릿 특화 부분을 구현한다. 그러면 T가 INT형일 때 자동적으로 두 번째의 Read를 호출하게 된다. 바로 이 부분이 동적바인딩의 한 예라고 할 수 있다.

<리스트 7> Stream 클래스의 char 관련 Read, Write 메소드

template <>voidRead(char** pData)

{

UINT nLen = 1;

ReadBuffer(&nLen, sizeof(UINT));

*pData = (char*)m_pBuff + m_nReadPos;

m_nReadPos += nLen;

}

template <>voidWrite(char* data)

{

UINT nLen = (UINT)strlen(data) + 1;

WriteBuffer(&nLen, sizeof(UINT));

WriteBuffer(data, nLen);

}

char형 같은 경우는 길이가 가변적이기 때문에 Write나 Read 메소드 호출시에 앞에 4바이트를 길이로 지정해서 사용했다.

송수신을 위한 Proxy/Stub, 포인터의 새로운 세계 Functor

C/C++ 세계가 다른 언어와 차별화되는 대표적인 요소로 직접적인 메모리 접근이 가능한 포인터를 꼽을 수 있다. 포인터는 변수뿐만 아니라 함수에도 적용이 가능한데, 이런 함수포인터를 사용해서 내부 메소드를 외부에서 호출할 수도 있고, 원형이 동일한 다른 함수를 호출하는 것도 가능함을 익히 알고 있을 것이다. 함수 포인터를 객체화시킨 함수자(functor)는 상태를 포함하는 함수포인터 객체라고 할 수 있다. 객체란 단어는 많은 의미를 내포하는데, 우리가 사용하는 함수자는 템플릿과 다형성을 기반으로 버퍼에 읽고 쓰는 작업을 자동화시키고, 함수포인터를 호출하는 데 유용하게 사용된다.

이러한 함수자와 Proxy, Stub 부분집합에 해당하는 Stream의 조합으로 인자의 전달을 자동화할 수 있다. Proxy 과정을 보면 Stream에 Functor의 내부변수를 Set하게 되고 후에 Write가 호출됨으로써 버퍼에 인자의 개수만큼 타입에 맞춰 Write하게 되면 송신할 패킷이 완성된다. Stub의 부분에서는 버퍼를 Stream에서 받아 해당 함수자를 호출하면 Set하게 되고, 내부에 저장한 뒤 Read를 호출함으로써 Stream에서 각각의 인자를 뽑아오게 된다. 그 후에 함수자의 함수 포인터를 호출할 때 뽑아온 인자를 넘김으로써 dispatch 과정이 종료된다. <그림 1>은 함수자 원형(FIAFunctor)을 각각의 함수 템플릿과 인자 템플릿에 맞춰 구현 상속하는 것을 나타낸다.

<그림 1> Functor의 상속 구조

<리스트 8> Functor 클래스

class FIAFunctor

{

public:

FIAFunctor() {}

virtual ~FIAFunctor() {}

virtual void Read(FIAStream &stream) = 0; // 스트림에서 함수인자들 읽음

virtual void Write(FIAStream &stream) = 0; // 스트림에 함수인자들 쓰기

virtual void Dispatch(void *_pInst) = 0;

};

template <class T>

class FIAFunctorImpl : public FIAFunctor

{

public:

FIAFunctorImpl(){}

void Set() {}

void Read(FIAStream &stream) {}

void Write(FIAStream &stream) {}

void Dispatch(void *_pInst) {}

};

template <class T>

class FIAFunctorImpl<void (T::*)()> : public FIAFunctor

{

public:

typedef void (T::*FuncPtr)();

FIAFunctorImpl(FuncPtr p) : ptr(p) {}// 함수포인터 저장

void Set() {}

void Read(FIAStream &stream) {}

void Write(FIAStream &stream) {}

void Dispatch(void *_pInst) { (((T*)_pInst)->*ptr)(); }

private:

FuncPtr ptr;

};

template <class T, class A>

struct FIAFunctorImpl<void (T::*)(A)> : public FIAFunctor

{

public:

typedef void (T::*FuncPtr)(A);

FIAFunctorImpl(FuncPtr p) : ptr(p) {}

void Set(A &_a) { a = _a; }// 인자를 내부에 저장

void Read(FIAStream &stream) { stream.Read(&a); }

void Write(FIAStream &stream) { stream.Write(a); }

void Dispatch(void *_pInst) { (((T*)_pInst)->*ptr)(a); }

private:

FuncPtr ptr; A a;

};

template <class T, class A, class B>

struct FIAFunctorImpl<void (T::*)(A, B)> : public FIAFunctor

{

public:

typedef void (T::*FuncPtr)(A, B);

FIAFunctorImpl(FuncPtr p) : ptr(p) {}

void Set(A &_a, B &_b) { a = _a; b = _b; }

void Read(FIAStream &stream) { stream.Read(&a); stream.Read(&b); }

void Write(FIAStream &stream) { stream.Write(a); stream.Write(b); }

void Dispatch(void *_pInst) { (((T*)_pInst)->*ptr)(a,b); }

private:

FuncPtr ptr; A a; B b;

};

// 위와 같은 방법으로 필요한 인자 개수 만큼 추가해 사용한다. 10개 정도를 추가한다면 충분할 것 이다.

Read, Write를 자동화하기 위해 Set과 Dispatch 부분에 많은 코드가 사용됨을 볼 수 있다. 스트림에 인자의 타입과 개수에 무관하게 작동될 수 있도록 하며 해당 인스턴스의 함수 포인터를 호출할 수 있는 Functor이다. 이 또한 중복되는 코드로 불필요하게 느껴지겠지만 한번만 수고를 하면 지속적으로 사용 가능한 부분이다. 좀 더 효율적으로 하고자 한다면 typelist를 이용할 수도 있으니 참고하길 바란다.

<리스트 9> switch에 해당하는 functor dispatch map

class FIAFuncDispatch

{

public:

static FIAFuncDispatch*m_pInstance;

inline static FIAFuncDispatch& Instance()

{

// TODO : Multi thread를 지원하려면

// Double Checked Locking Pattern을 적용

if (NULL == m_pInstance )

{

m_pInstance = new FIAFuncDispatch;

atexit(Destroy);

}

return const_cast<FIAFuncDispatch&>(*m_pInstance);

}

private:

static void Destroy()

{

delete m_pInstance;

m_pInstance = NULL;

}

private:

FIAFuncDispatch()

{

};

~FIAFuncDispatch()

{

while( !m_mapFunctor.empty() )

{

std::map<DWORD,FIAFunctor*>::iterator iter = m_mapFunctor.begin();

FIAFunctor* pFuncImpl = iter->second;

delete pFuncImpl;

m_mapFunctor.erase(iter);

}

};

public:

voidReg(DWORD nProtocol, FIAFunctor* pFuncImpl)

{// 함수자 등록

m_mapFunctor.insert(std::make_pair(nProtocol, pFuncImpl));

}

boolCall(void *pPeer, DWORD nType, char* pData)

{// 실제 함수포인터가 호출되는 부분

std::map<DWORD,FIAFunctor*>::iterator iter = m_mapFunctor.find(nType);

if ( iter != m_mapFunctor.end() )

{

FIAStream Stream;

Stream.SetBuffer(pData);

(*iter->second).Read(Stream);

(*iter->second).Dispatch(pPeer);

return true;

}

return false;

};

public:

std::map<DWORD,FIAFunctor*>m_mapFunctor;

};

FIAFuncDispatch는 함수 포인터를 가지는 함수자를 타입에 따라 등록하고, 타입에 맞는 함수자를 호출해 주는 부분이다. Call()은 서두에 얘기했던 일반적인 방법의 Switch에 해당하는 부분으로, 함수자의 Read 메소드에서 스트림을 통해 해당 인자를 자동으로 뽑아오고 Dispatch를 통해 해당 인스턴스에 인자를 전달하게 된다.

<리스트 10> Send, Recv에 필요한 매크로 선언

// RPC Send 선언, 구현부

#define FIA_RPC_DECL(PKT_ID, CLASS_NAME, ARGS, ARG_NAME)public:\

voidSEND_##PKT_ID## ARGS { \

FIAFunctorImpl<void (CLASS_NAME::*) ARGS> func(&CLASS_NAME::SEND_##PKT_ID); \

char buff[1024] = {0, }; \

FIAStream Stream; \

Stream.SetBuffer(buff);\

Stream.Write(PKT_ID); \

func.Set ARG_NAME;func.Write(Stream);\

Send(buff, Stream.GetWritePos()); \

} \

voidRECV_##PKT_ID## ARGS ; \// 수신부 선언

class RPC_##PKT_ID\

{\

public:\

RPC_##PKT_ID()\

{ FIAFuncDispatch::Instance().Reg(PKT_ID, new FIAFunctorImpl<void (CLASS_NAME::*) ARGS>(&CLASS_NAME::RECV_##PKT_ID)); }\

}; \

RPC_##PKT_ID m_RPC_INIT_##PKT_ID;

<리스트 10>에서 보는 것처럼 아주 복잡해 보이는 매크로가 등장했다. 우리가 직접적으로 호출할 송수신 함수를 선언하는 매크로인데 여기서 하나씩 살펴보도록 하자.

#define FIA_RPC_DECL(PKT_ID, CLASS_NAME, ARGS, ARG_NAME)

패킷타입, 클래스명, 인자들, 인자이름들 순서로 들어가게 된다. 즉, 다음과 같이 사용한다.

FIA_RPC_DECL(PKT_RES_LOGIN, CLogin, (int nRet), (nRet));

ARG_NAME은 조금 불필요해 보이는데 Send시에는 해당 함수자에 인자를 넘길 방법이 필요하므로, 이는 곧 Macro의 (int a), (a)에서 뒤의 (a)가 필요한 이유가 된다. 즉, 인자를 무의미하게 정의하면 저런 부분까지 자동화할 수 있지만, 의미 부여가 필요한 인자를 함부로 할 수 없기 때문에 구성이 좀 부적절하게 되었다.

하지만 컴파일 단계에서 (int a)와 (a)가 이름 또는 개수가 다르거나 일치하지 않으면 에러를 발생시키기 때문에 일반적인 사용법에서 보았던 수고에 비하면 참을 수 있을 정도라고 생각한다. 그 다음은 Send에 해당하는 메소드를 선언하고 함수자에 함수 포인터를 집어넣고 스트림에 버퍼를 할당하고 타입, RPC의 인자를 넣은 다음 실제 데이터를 송신하는 Send를 호출하는 부분으로 이뤄져 있다.

여기까지가 Proxy 부분이 해당된다고 볼 수 있다. 매크로를 사용한 가장 큰 이유는 func.Set에 있는데 인자를 동적 바인딩하면서 캐스팅 없이 집어넣을 수 있기 때문이다. 수신부에 해당하는 Stub 부분은 클래스를 선언하고 다시 함수자를 등록하는 부분으로 구성되어 있다. 수신부는 클래스 생성시 등록되어야 하는 부분인데 내부클래스를 사용해 생성자에서 선언함으로써 자동적으로 호출되도록 구성했다.

<리스트 11> RPC 수신부 구현

#define FIA_RPC_RECV_DECL(PKT_ID, CLASS_NAME, ARGS)\

voidCLASS_NAME::RECV_##PKT_ID## ARGS

수신부 구현에서 매크로는 RPC임을 명시적으로 표시하기 위해 사용한다. 매크로 대신에 바로 표현해도 상관없지만 이는 일관성을 위해 추가한 부분이다.

샘플

이로써 비동기 RPC를 구성하는 기본 모듈들을 완성했다. 이를 테스트 해볼 수 있는 간단한 코드를 보자.

<리스트 12> 테스트 코드

int _tmain(int argc, _TCHAR* argv[])

{

CLogin login

login.SEND_PKT_REQ_LOGIN("leejh", "kangsw");

_getch();

return 0;

}

<화면 1> 실행 결과

추가로 생각할 부분

이상으로 비동기 RPC를 구현하는 방법에 대해 살펴봤다. 각종 템플릿이 난무하고 매크로가 판을 치는 이런 소스 코드 내에서 자그마한 힌트를 얻어 좀 더 효율적인 방법을 생각해 보길 바란다.

이 글을 마무리하기 전에 조금 더 최적화해야 할 부분이 있기에 다시 한 번 짚어 보도록 한다. RPC 선언 매크로에서는 <리스트 13>과 같이 인자를 써줘야 하는 불편함이 눈에 들어올 것이다.

<리스트 13> RPC 선언 매크로

FIA_RPC_DECL(PKT_RES_LOGIN, CLogin, (int nRet), (nRet));

이외에도 패킷 타입에 해당하는 부분을 열거형이 아니라 따로 분리해서 자동으로 증가시키면 외부로 노출시킬 필요가 없겠지만, 설명의 목적에 부합해야 하고 또한 디버깅시의 편리함을 고려해야 하므로 이 정도로 마무리 지었다. 조금 더 최적화할 부분을 찾는다면 다음과 같다. functor를 관리하는 map을 hash로 변경해 관리하고, 스트림 버퍼를 생성하는 부분을 메모리 풀로 구성하면 보다 나은 성능으로 구현할 수 있을 것이다.

-------------------------------

참고 자료

1. Modern C++ - Andrei Alexandrescu

2. GPG5 - 빠르고 효율적인 원격 프로시저 호출 구현(배현직)

3. TNL - www.opentnl.com

4. Raknet - http://www.rakkarsoft.com

------------------------------

asyncRPC_src.zip

<소스 다운로드>


2008/04/22 20:36 2008/04/22 20:36
Posted by FiaDot
마이크로소프트웨어 l 2008/04/22 20:36
1 

카테고리

전체 (676)
개인 (1)
Technical Article (273)
Diary (125)
Book (2)
Music (176)
DSP (19)
Tmp.Box (5)
Hardware (7)
Idea (60)
마이크로소프트웨어 (7)

달력

«   2010/09   »
      1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30