본문 바로가기
컴퓨터

[Network] 프로토콜 설계

by Luyin 2012. 8. 30.

프로토콜 설계에 관해
2002. 11. 30. 마술감자(http://magicpotato.com)

이 강좌는 다음을 위해서 작성했습니다.

1) 프로토콜에 구조체 사용
2) 프로토콜에 팩킹 사용
3) 프로토콜에 비트필드 사용
4) 고정패킷 사이즈 테이블과 가변패킷 혼합 사용
5) 온라인게임 프로토콜의 일반적 특성

이 강좌는.. 5번 바이너리 프로토콜과 6번 설계 가이드라인을 위해서 작성한거라..
이미 어느정도 네트웍 프로그래밍을 하신분들은 1~4를 건너뛰세요.
(어차피 1~4는 대충 작성했으니까요-_-;)

뭐 특별한 강좌 하는것도 아니고 -_- 팁 이니까;
대충 보시면 될듯 합니다.


목차
1. 개요
2. 프로토콜 형태 (바이너리/텍스트/혼합)
3. 프로토콜 구현-텍스트
4. 프로토콜 구현-바이너리
5. 바이너리 프로토콜 팁
6. 온라인게임 프로토콜의 일반적 특성과, 설계 가이드라인

 

1. 개요.

프로토콜 이라는건-_-에 또 그 뭐냐..;
그냥 Client와 Server간에 정보를 주고받기 위한거겠죠.
이걸 어떻게 짜는지 잘 모르는 분들에게 도움이 되었으면,
또 제가 알던걸 한번 정리하는 의미에서 작성합니다.

이 팁은.. IPC나 소켓 프로그래밍에 대해 이미 알고 계신분들에게
도움이 될것 같습니다 -_-;

 

2. 프로토콜의 형태

크게 두가지 프로토콜로 나뉘는데..
바이너리 기반과 텍스트 기반이 있고, 혼합기반이 있습니다.


텍스트는 우리가 읽을수 있는 문자로 구성되어 있는데..
특성은 다음과 같죠.
1) 알아보기 쉽다.
2) 프로토콜을 해석하는 문자열 기반 파서가 필요하다.
3) 바이너리 프로토콜에 비해 느리다.
4) 해킹/간섭당하기 쉽다.
5) 이용예 : IRC(Internet Relay Chat-RFC1459)


바이너리 프로토콜은 말그대로 바이너리가 됩니다. -_-;
일단 자세한 형식은 뒤에서 설명 하고.. 특징은 다음과 같습니다.
1) 신경 쓰는 만큼 패킷이 작아져서 속도가 빨라진다.
2) 디버깅을 하기 위해 따로 텍스트로 변환하는 루틴을 만들어 줘야 한다.
3) 텍스트 프로토콜에 비해 간파당하기 힘들다.
4) 이용예 : 일반 온라인 게임 -_-;


혼합 이라는건..좀 설명하기 까다로운데.. 텔넷을 생각하면 됩니다.
텔넷은 접속하자마자 터미널(텍스트 출력환경)에 대해서 결정을 하는데
이 부분은 바이너리로 통신하고, 그 이후부터는 텍스트가 되지요.
솔직히 텔넷은 바이너리에 가깝다고 생각합니다.
텍스트 통신은..텔넷 이라기 보다는..텔넷 이후의 자체처리하는 그런거니까..


3. 프로토콜 구현-텍스트

이건 그냥 참고만 하세요...처음부터 바이너리를 설명하려고 한거라서-_-;

쓸데없는말 필요 없이 구현을 해봅시다. 소스는 C입니다만,
컴파일러에 안돌려보고 그냥 적습니다 -_-; 알아서 보세욤;
대충 핵심만 적습니다.

IRC같은 경우 "rn"을 한 프로토콜 패킷의 끝으로 구분합니다.
우리도 여기서 똑같이 배껴봅니다. (실제 IRC프로토콜은 안저렇습니다)

=Client=

send(toServer, "NICK_CHANGE test_nickrnLISTrn", ...); // size 생략

=Server=

recv(fromClient, buffer, ...);
pointerPacketHead = buffer;
while( ... )
{
      pointerOfPacketEnd = strstr(pointerPacketHead, "rn");
      *pointerOfPacketEnd  = NULL;
      ProcPacket(pointerOfPacketEnd);
      pointerPacketHead = pointerOfPacketEnt+2;
}

=해설=
client에서 send()를 사용하여 패킷을 보내는데..
잘 보면 아시겠지만 한번에 2개의 프로토콜 패킷을 보냅니다. (rn이 2개)
소켓은 1번 보낸다고 그게 1번에 도착하는게 아닐수도 있으니
이어받기 처리를 잘 해줘야 겠죠.
그리고 서버는 받아서 rn을 찾아서 자른다음, 파서로 보내는 과정을 보여줍니다.

 

4. 프로토콜 구현-바이너리 (가장 기초적인)

바이너리 프로토콜도 다양한 형태로 설계가 가능합니다.

[HEAD][DATA][TAIL] 으로도 가능하고
[HEAD][DATA] 식으로도 가능합니다.
물론 패킷에 따라서 [DATA]가 없을수도 있지요.
우리는 여기서 [HEAD][DATA]를 사용합니다.


  프로토콜 구조는 다음과 같습니다.
  프로토콜 패킷은 무조건 32바이트 입니다. (일단 쉽게-_-)

        :헤더 부분        :데이터 부분
        [Command][Data]


=Client=

#define PACKET_CMD_REQUEST_NICK_CHANGE          0       // 패킷 커맨드 정의
#define PACKET_CMD_REPLY_NICK_CHANGE_OK         1
#define PACKET_CMD_REPLY_NICK_CHANGE_NO         2
#define PACKET_CMD_REQUEST_CHATROOM_LIST        3

char sPacket[32];       // 전송에 사용됨

ZeroMemory(sPacket, 32);
sPacket[0]  = PACKET_CMD_REQUEST_NICK_CHANGE;
strcpy(&sPacket[1], myNickname);
send(toServer, &sPacket, 64, 0);

=Server=

recv(); // 생략

switch(*pPacketHead)
{
        case PACKET_CMD_REQUEST_NICK_CHANGE :
                func_NickChange(pPackHead+1);
                break;
        case PACKET_CMD_REPLY_NICK_CHANGE_OK :
                break; // 생략
}

= 해설=

자 우리는 바이너리를 사용해 봤습니다.
1바이트로 어떤걸 원하는지 결정했고, int(윈도우-4바이트로 가정)를
사용해서 크기를 지정했습니다.

혹시 char[] "30" 과 int 30 의 차이를 잘 모르시는분은 이 강좌를 보면 안됩니다

 

5. 바이너리 프로토콜 팁

목차
1) 구조체 사용
2) 팩킹 사용
3) 비트필드 사용
4) 고정패킷 사이즈 테이블과 가변패킷 혼합 사용


0) 가변패킷

C/S의 성능에 따라서 프로토콜을 가볍게, 또는 타이트하게 설계합니다.
저같은 경우 고작 1:1 바둑 만드는데 여러가지 기법을 사용하진 않습니다.
시간낭비니까요, 필요한 곳에 필요한 기술을 적절히 써주면 됩니다.
즉, 이 다음의 내용을 항상 적용할 필요가 없다는겁니다.
효율에 대해서도 생각해야 하니까요 ^^;

에 근데, 여기서 부터는 가변패킷을 사용하는것을 기준으로 적습니다.
공부 하는거자나요 *^^*
패킷의 기본 구조는..

    [Protocol-Command] [DataSize] [Data]  가 됩니다.


1) 구조체 사용

4번 바이너리 프로토콜에서 우리는 char 배열을 사용했습니다.
이거 상당히 귀찮은 방법이죠.
그냥 이렇게 하면 됩니다.

저는 프로토콜 접두어나 접미사(?)로 RQ/RP/NF/CM을 사용합니다.
이건 6장에서 설명할껀데.. 대충 보시죠 -_-;

enum PKCMD { 
        ptLogin_RQ,             // Request
        ptLogin_RP_OK,
        ptLogin_RP_NO,
        ptCharacterList_NF,     // Notify
        ...
}; // 상용게임은 프로토콜 갯수가 100개를 넘기기도 합니다.

// 1차적인 패킷
typedef struct __Packet
{
        unsigned char   Cmd;
        unsigned short  DataSize;
        char            Data[64];
} stPacket;

stPacket myPack;

myPack.Cmd = (u_char)ptLogin_RQ;
myPack.DataSize = strlen(myID)+strlen(myPass)+2; // NULL 두개
strcpy(myPack.Data, myID);
strcpy(myPack.Data+strlen(myID)+1, myPass);

send(toServer, &myPack, ...);


짜잔~ 위에보다 좀 쉬워졌습니다.
근데 뭔가 부족하죠. ID와 PASS부분이 소스가 더러워 졌습니다.
저같은 경우는 패킷커맨드별로 패킷구조체를 하나씩 다 만듭니다.
(제가 작업하는 게임의 네트웍 구조체 갯수가 50개가 넘죠)

typedef struct __PacketHead
{
        unsigned char   Cmd;
        unsigned short  DataSize;
} stHead;

typedef struct __ptLogin_RQ
{
        char    sID[DEFINED_ID_LEN],
                sPW[DEFINED_PW_LEN];
} pkLogin_RQ;

struct __stLogin_RQ
{
        stHead          Head;
        pkLogin_RQ      Data;
} tForLogin;

tForLogin.Head.Cmd      = ptLogin_RQ;
tForLogin.Head.DataSize = ...;
strcpy(tForLogin.Data.sID, myID);
strcpy(tForLogin.Data.sPW, myPW);

소스가 좀 의심쩍은 부분이 많을껍니다.
주로 가변에 대한거겠죠 ^^; 귀찮아서 그냥 ;;;;;;;;
(이런걸 날림 강좌라고 하는겁니다)

보통 비상용 프로토콜은 이정도 수준으로 작성합니다.
제가 작업하는 경우는..
char sID, sPW 로 선언하지 않고
char sIDnPass 로 선언해서, nIdPassLen 을 사용해서..
[ID][NULL][PASS]가 되도록 프로토콜을 짰습니다.
패킷 줄이려고 별짓을 다 해놨죠 -_-;
(이런 문제는 여러가지 해결법이 있으니 그냥 참고입니다 참고~~ 참고 보세요-ㅂ-)


2) 팩킹 사용

보통 컴파일러는 구조체를 4바이트나 8바이트에 맞춥니다.(윈도우라 가정)
그래서 struct { char c; }; 이런 구조체도 sizeof 하면 4바이트가 되죠.

struct 
{
        char Command;
        int  Size;
}; // 요건 8바이트가 됩니다.

우리가 알기론 5바이트일텐데 -ㅂ- 안좋죠 안좋아~
이걸 5바이트로 만들려면 다음과 같이 합니다.

#pragma pack(1) // 컴파일러 지시자 (바이트 정렬을 최소 1바이트로 해라)
struct { char cmd; int size; }; // 귀찮아서 한줄에 -_-
#pragma pack()  // 바이트 정렬을 옵션에 설정된 디폴트로 복구


에 또... 왜 처음부터 1바이트로 정렬을 안하냐...
요즘은 또 3D로 게임을 만들죠..
32비트 CPU에서는 32비트(4바이트)데이터가 가장 빠르다고들 하더군요 ㅡ_ㅡ
암튼 고런 이유로 네트웍만 1바이트로 하고, 나머진 4바이트로 하게 하는겁니다.
음 대충 이정도-_-; 좀 허전하다..;


3) 비트필드 사용

프로토콜 커맨드 갯수가 총 15개라고 가정하고, 뒤에 딸리는 데이터 크기가
15바이트 미만이라고 할때.. 우리는 헤더 크기를 1바이트로 줄일수 있습니다.
(위에까진 커맨드+데이터크기로 5바이트를 날려먹었죠)

#pragma pack(1)
struct
{
        unsigned char   cmd:4,  // 4비트로 0~15까지 표현 가능
                        size:4; // 역시 4비트로 0~15까지 표현 가능
}; // 이건 1바이트가 됩니다.
#pragma pack()


##중요사항##

MS의 VC++과 Borland의 C++Builder는 packing 방식이 다릅니다.
개인적으로는 Borland C++Builder를 지지하지만.. 대부분 VC++을 쓰죠 -_-;;
여튼 차이점은 다음과 같습니다.

#pragma pack(1)
struct
{
        int     i:7,
                j:8;
}
#pragma pack()

위의 구조체는..
VS++ 에서 4바이트가 됩니다.
C++Builder에서는 2바이트가 됩니다.

그래도 좀 호환되게 하려면 .. int 를 short로 변경해야 합니다.

VC++은 '주어진 자료형'내에서 비트필드가 맞아야 되고,
'팩킹'은 그거랑 전혀 별도로, int는 4바이트니까 최소가 4바이트고,
char은 1바이트니까 최소 1바이트가 되는겁니다.

Borland C++Builder는.. '비트필드'까지 신경써서 최고의 팩킹을 해줍니다.
int라고 해도, 내부에 선언된 비트수의 합이 8비트면 1바이트가 되는거죠.

아참 그리고.. 제가 위에서 enum을 사용해서 프로토콜 패킷을 정의했는데..
VC++은 enum은 무조건 4바이트가 됩니다. (구조체 안에 비트4만 써도 4바이트)
BCB는 enum을 사용한 비트만큼까지만 줄일수도 있고, 4바이트로 할수도 있습니다.

개인적으로 네트웍 프로그래밍에는 빌더를 추천하고 싶네요 -_-;

 

4) 고정패킷 사이즈 테이블과 가변패킷 혼합 사용

참고로 제가 작업하는 게임의 패킷과 헤더를 공개하자면..

typedef struct __pkHeader { // Default Header
      unsigned char     Cmd:?;
} pkHeader; // 1byte
typedef struct __pkQHeader {   // for Sent Queue Header
      unsigned char     Cmd:?;
      char              v[0];
} pkQHeader;
typedef struct __pkHeaderXY {  // Client <-> GameServer
      unsigned int      Cmd:?,
                        x:?,
                        y:?;  
} pkHeaderXY;     // 4bytes


?로 처리한건 -_- 보안상 입니다; 해킹당하면 안되자나요 ;ㅁ; 에 어쨌든..;
제가 작성한 프로토콜은.. Request에 있는 정보는 Reply에 없습니다.
즉.. 클라이언트가 송신 정보를 큐에 다 담아놓고.. 응답 받으면
더해서 사용합니다.

그리고 C->S '내가 이동하겠다' 라든지 S->C '너가 이동해라'는
pkHeader 를 사용합니다.

S->C '어디어디의 누가 이동했다'는 pkHeaderXY 를 사용합니다.

저 헤더 뒤에 커맨드별 구조체가 다시 붙습니다.
그 '커맨드별 구조체'는 항상 같은 크기인것도 있고, 가변인것도 있지요.

가변 구조체들은 맨 첫 인자가 모두 같은 크기의 'DataSize'가 됩니다.
즉 이런거죠..

struct v_pack_1 {
        PKSIZE  Size;
        int i, j, k; // 대충-_-;
};

struct v_pack_2 {
        PKSIZE  Size;
        char a, b, c; // 역시 다른 패킷 -_-;
};

그래서 헤더 뒤에 가변구조체인 경우 항상 SIZE에 접근할수 있게 되어있습니다.

여러 패킷이 한 버퍼에 뭉쳐서 들어올때 Size에 맞게 잘라서 처리해 줘야 되는데
여기서 말하는 '고정패킷'은 패킷별로 크기가 다 다릅니다.
'모든 패킷의 크기가 같다'라는게 아니라..
'각각의 패킷의 크기는 다 다른데, 그 크기는 변하지 않는다.' 라는거죠.

그래서-_-; 패킷별 사이즈 테이블을 사용합니다. (그냥 노가다-_-)
대충 뭔뜻인지 이해는 하셨을테니 소스 일부만 적고 끝내렵니다.

// 패킷 커맨드
enum PKCMD {      // unsigned char 로 캐스팅해서 사용할 것.
                  // __VS : Variable Size Packet - 02.11.15 현재 9개
                  //      PKSIZE는 '자신을 포함한 크기'를 사용.
                  
   // 첫부분 보안상 삭제, 대부분의 패킷은 삭제했습니다-_-볼 일도 없으니;;

   ptGAMETIME_RQ,             // 게임시간 동기 요청
   ptGAMETIME_RP,             // 게임시간 동기 응답
   ptTIMEOUT_RP,              // 타임아웃 패킷 (GS->NI)
   ptTIMEOUT_RQ,              // 타임아웃 패킷 (NI->GS)

   ptNICK_CHANGE_RQ__VS,      // 닉네임 변경 요청 (NULL로 판단, 패킷이 줄어듬)
   ptNICK_CHANGE_RP_OK,       // 닉네임 변경 응답 (NULL로 판단, 패킷이 줄어듬)
   ptNICK_CHANGE_RP_NO,       // 닉네임 변경 거부 (NULL로 판단, 패킷이 줄어듬)
   ptNICK_CHANGE_NF__VS,      // 닉네임 변경 통지 (NULL로 판단, 패킷이 줄어듬)

   ptSTEP_RQ,                 // 스텝 요청
   ptSTEP_RP_OK,              // 스텝 응답
   ptSTEP_RP_NO,              // 스텝 거부
   ptSTEP_NF,                 // 스텝 통보

   ptEXPUP_CM,                // 경험치 오름 명령
   ptPARAMUP_CM,              // 패러미터 오름 명령

   ptMAXCOMMAND               // 프로토콜 커맨드 갯수
};

// 샘플용 구조체 하나 보여드림 -_-;
typedef struct __ptDirection
{
      uchar       ucDir:?;    // 이동하고자 하는 방향
} pkSTEP_RQ,
  pkLOOK_RQ,
  pkLOOK_NF,
  pkPROV_NF,
  pkEVASION_RQ;

// -1인것은 가변패킷으로, 헤더뒤의 Size에 접근해서 얻어내면 된다.
const int naPkSize[ptMAXCOMMAND] =
{
   0,                                           // ptGAMETIME_RQ
   sizeof(pkGAMETIME_RP),                       // ptGAMETIME_RP
   0,                                           // ptTIMEOUT_RP
   0,                                           // ptTIMEOUT_RQ

   -1,                                          // ptNICK_CHANGE_RQ__VS
   0,                                           // ptNICK_CHANGE_RP_OK
   0,                                           // ptNICK_CHANGE_RP_NO
   -1,                                          // ptNICK_CHANGE_NF__VS

   sizeof(pkSTEP_RQ),                           // ptSTEP_RQ
   sizeof(pkSTEP_RP_OK),                        // ptSTEP_RP_OK
   0,                                           // ptSTEP_RP_NO
   sizeof(pkSTEP_NF),                           // ptSTEP_NF

   sizeof(pkEXPUP_CM),                          // ptEXPUP_CM
   sizeof(pkPARAMUP_CM)                         // ptPARAMUP_CM
};


6. 온라인게임 프로토콜의 일반적 특성

온라인게임에서 사용하는 프로토콜은 기본적인 특성이 있습니다.

내가 행동하기 위해서 '요청'하는것과
그것에 대해서 서버가 '응답'하는것이 있고.

내가 아닌 다른 사람이 행동했다고 '통보'가 오는것이 있고
자의에 상관없이 서버로 부터의 데이터 수정 '명령'이 있습니다.
('명령'은..예를 들자면.. 남이 날 때려서 HP를 수정해야 한다든지 이런거..)

기본적으로 이 4가지에서 파생되거나, 의미가 혼합됩니다.
- Request, Reply, Notify, Command(compulsion)
> RQ, RP, NF, CM

그리고 이 패킷들의 특성은 다음과 같습니다.

RQ, RP, CM : 행동내용과 패러미터만 있으면 된다.
NF         : '누구인가'와 행동내용과 패러미터만 있으면 된다.

RQ      : 주로 서버에서만 받는다.
RP      : 주로 클라이언트에서만 받는다.
CM, NF  : 반드시 클라이언트만 받는다. (분산서버 예외)