Skip to content

Latest commit

 

History

History
471 lines (343 loc) · 17.1 KB

서버 - 프로토콜 설계 입문.md

File metadata and controls

471 lines (343 loc) · 17.1 KB

프로토콜 설계 입문

  • 2002-11-30. magicpotato.

이 강좌는 다음과 같은 테크닉을 다룹니다.

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

비트필드의 경우는 요즘(2018) 별로 권장하지 않습니다. 이외에, 패킷 시리얼라이징, 압축, 암호화에 대해서는 별도로 찾아보시길 권장합니다.

목차

  1. 개요
  2. 프로토콜 형태 (바이너리/텍스트/혼합)
  3. 프로토콜 구현-텍스트
  4. 프로토콜 구현-바이너리
  5. 바이너리 프로토콜 팁
  6. 온라인게임 프로토콜의 일반적 특성과, 설계 가이드라인
  7. (부록) [START][END] 붙는 프로토콜의 장점

1..4는 결국 5..6을 위한 내용입니다.

1. 개요

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

2. 프로토콜의 형태

크게 바이너리 기반, 텍스트 기반, 혼합이 있습니다.

텍스트

우리가 읽을수 있는 문자로 구성되어 있는데..특성은 다음과 같죠.

  1. 알아보기 쉽다.
  2. 프로토콜을 해석하는 문자열 기반 파서가 필요하다.
  3. 바이너리 프로토콜에 비해 느리다.
  4. 해킹/간섭당하기 쉽다.
  5. 이용예 : IRC(Internet Relay Chat-RFC1459)

바이너리

자세한 형식은 뒤에서 설명합니다.

  1. 신경 쓰는 만큼 패킷이 작아져서 속도가 빨라진다.
  2. 디버깅을 하기 위해 따로 텍스트로 변환하는 루틴을 만들어 줘야 한다.
  3. 텍스트 프로토콜에 비해 간파당하기 힘들다.
  4. 이용예 : 일반 온라인 게임

혼합

쉬운 예로 텔넷을 생각하면 됩니다. 텔넷은 접속하자마자 터미널(텍스트 출력환경)에 대해서 결정을 하는데 이 부분은 바이너리로 통신하고, 그 이후 컨텐츠 영역은 텍스트가 되지요.

텔넷이 오래된 용어라서 모르시는 분들은 http://www.aardwolf.com/ 을 참고하세요.

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. 프로토콜 구현-바이너리 (가장 기초적인)

바이너리 프로토콜도 다양한 형태로 설계가 가능합니다. 간단히는 다음과 같습니다.

  1. [HEAD][DATA][TAIL]
  2. [HEAD][DATA]

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

패킷 구조는 다음과 같습니다. 패킷의 크기는 학습목적상 32바이트로 고정 입니다. (일단 쉽게)

헤더(1) 데이터(31)
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, 32, 0);

// Server

recv(); // 생략

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

5. 바이너리 프로토콜 팁

목차 0. 가변패킷

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

0. 가변패킷

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

가변 패킷의 기본 구조는 다음과 같습니다.

헤더(1) 헤더(2) 데이터(<64)
Command DataSize Data

1. 구조체 사용

직전 바이너리 프로토콜을 설명할 때 에서 우리는 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);

이 부분은 엄밀함 보다는 전체적인 의도를 봐 주세요. 보통 비 상용 프로토콜은 이정도 수준으로 작성합니다.

2. 팩킹 사용

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

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

5바이트가 되게 하려면 다음과 같이 해야 합니다.

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

왜 처음부터 1바이트로 정렬을 안하냐... 요즘은 또 3D로 게임을 만들죠.. 32비트 CPU에서는 기본 단위인 32비트(4바이트)가 가장 빠르다고들 하더군요. 그런 이유로 네트웍만 1바이트로 하고, 나머진 4바이트로 하게 하는겁니다.

3. 비트필드 사용

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

#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바이트로 할수도 있습니다.

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

TOOD: 2018년 현재는 어떤지 다시 알아봐야겠습니다.

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
};

이런 방식은 2018년 현재 실무에서 거의 사용하지 않지만 개념은 알아야 합니다.

6. 온라인게임 프로토콜 커맨드의 일반적 분류

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

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

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

기본적으로 이 4가지에서 파생되거나, 의미가 혼합됩니다.

Request Reply Notify Command
RQ RP NF CM

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

  • RQ, RP, CM : 행동내용과 패러미터만 있으면 된다.

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

  • RQ : 주로 서버에서만 받는다.

  • RP : 주로 클라이언트에서만 받는다.

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

(부록) [START][END] 붙는 프로토콜의 장점

[START][SIZE][COMMAND][DATA][END] 등의 프로토콜 구조를 왜 하는지 이해하지 못했었는데 최근 알게 되었습니다.

패킷크기 처리가 복잡한 프로토콜의 처리실수 또는 네트웍의 오류검증을 확실히 하는데 도움이 되는것 같습니다.

[SIZE][COMMAND][DATA] 만으로 구성된 프로토콜에서 에러가 나는 경우 우연의 일치로 COMMAND가 올바른 값으로 넘어오게 되더라도, SIZE, DATA는 엉망이 되거나, 아슬아슬하게 맞는 경우가 생길수 있습니다.

물론 이 방법도 완벽하진 않지만 첫 바이트가 무조건 [START] 이고 끝 바이트가 무조건 [END] 인것을 검사한다면 패킷검증이 좀더 강력해 질꺼라고 생각됩니다.

덧붙여 #ifdef _DEBUG 등의 전처리기와 함께 사용해서 디버깅 기간에는 2바이트의 디버그 용도 패킷을 사용하고, 릴리즈가 되면 2바이트 처리를 제외시켜서 패킷을 줄일수 있습니다.

좀더 바이너리 프로토콜을 쉽게 처리하려면 역시 #ifdef _DEBUG 등과 함께 눈으로 보이는 스트링 자체를 패킷에 박아 넣어도 괜찮을것 같습니다.

PenSaku / 음 Start End에 Start# End# 로 일련번호 붙이는게 더 쓸만할듯.

EOF