2010년 8월 30일 월요일

패킷 제네레이터 기본 컨셉

저번에는 SendPacket 함수까지 알아보았다

이제는 받기 함수를 소개할 차례인데

참 어려운 고민이 많았다

일단 서버를 생각한다면  IOCP , select, epoll , kqueue 등등  다양한 소켓이 존재했고
그리고 소켓을 캡슐화한 다른 형태의 라이브러리까지 생각한다면 ,
그런것을 고려해 코드를 생성하기는 매우 어려운 일이었다

게다가  Send쪽은 SendPacket은  void* 와 size 를 인자로  하나의 함수만 어플리케이션쪽에서
정의해 주면 끝났지만

받는쪽은 반대로 void* 와 size 를 받아
패킷을 분석해  PT_INTERFACE로 정의되어 있는 구조체를 인자에 맞게 함수를 호출해줘야 했기때문에
더더욱 쉽게 자동화 하긴 어려웠다

그래서  가장 덜 손이 갈수 있겠다 싶은 방법을 생각해 보았다

먼저 해야할것이

 소켓에서 데이타를 받은 데이터를 stream에 입력하고 enumID에 맞게 PT_INTERFACE에 정의한
구조체로 변환 하는 작업이다

예를 들면 이러한 함수 일것이다

// 이함수는 AC_LogIn 이 데이터가 어떻게 쓰일지 모르기에 , 선언만 되어 있음
void ProcessPacket(SOCKET_ID socketID, const AC_LogIn& ptData);

void RecvPacketAC_LogIn(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
{
AC_LogIn ptData;
StreamReaderSelfBuffer stream(pBuffer,bufferSize);
stream & ptData;
ProcessPacket(socketID,ptData);
}

여기서 보면  내부에서 ProcessPacket 함수가 다시 호출되는데 이것은 스트림에서 구조체로
변환한다음  이 구조체를 사용하는 함수를 호출해주는 역활이다

이 구조체로 무엇을 할지 모르기때문에  이 함수의 구현은 어플리케이션 단에서 해줘야한다
그러므로 패킷 생성기에서 생성된 코드를 보면  선언만 되어 있고 구현은 없다

마치 SendPacket를 어플리케이션 단에서 다시 정의해줬던것과 같은것이다
따라서 저러한 함수를 어플리케이션 단에서 구현해줘야 한다  

자 그럼...  어떻게 저러한 함수가 호출하도록 만들것인가를 살펴보자

일단 저함수를 호출할수 있도록 Map에 담아 가져오기로 한다
enumID 를 키로하고   void (SOCKET_ID ,int8* ,uint32 ) 의 함수포인터를 가지는 Map 가져오기로 하는것이다

void GetFuncMap_gene_recv_PT_AC_Data(RecvPacketMap& ptRecvMap);

그럼 이제 이 Map 담긴 함수를 호출해야 하는데   아주 간략하게 코드로  보자면 이렇게 될것이다


// 스트림을 구조체로 바꿔 준후   구조체를 사용하는 함수를 호출해주는 Map얻어오기
// 자동 생성된 코드에서 이 함수가 선언되어 있다
GetFuncMap_gene_recv_PT_AC_Data(g_funcRecvMap);


//------------------------------------------------------------------------------
// 소켓으로부터 버퍼 얻어오기  ( 간단히 만든거므로 딴지 금지 )
// 전체 크기 얻어 오기
uint32 totalSize;
ptEnumType enumID;
int recvSize = recv(g_sockfd,(char*)&totalSize,HEADER_SIZE,0);
if(recvSize <= 0)
 break;

// enum 얻어오기
PG_ASSERT(recvSize == HEADER_SIZE);
recvSize = recv(g_sockfd,(char*)&enumID,PT_ENUM_TYPE_SIZE,0);
if(recvSize <= 0)
 break;

PG_ASSERT(recvSize == PT_ENUM_TYPE_SIZE);

// 데이타 버퍼 얻어오기
char recvBuf[MAX_PACKETLEN];
uint32 ptSize = totalSize - PT_ENUM_TYPE_SIZE;
recvSize = recv(g_sockfd,recvBuf,ptSize,0);
if(recvSize <= 0)
 break;

PG_ASSERT(recvSize == ptSize);
//------------------------------------------------------------------------------


// 위 루틴으로부터  enumID 와 버퍼  버퍼크기를 얻어올수 있었다

try
{
// check enumID valid
RecvPacketMap::iterator it = g_funcRecvMap.find(enumID);

if( it != g_funcRecvMap.end())
g_funcRecvMap[enumID](g_sockfd,recvBuf,ptSize);
}
catch(StreamExceptionUnderflow* )
{
// 패킷 Stream Underflow
}

g_funcRecvMap   함수포인터 맵에 enumID를 넘겨주면
위에서 스트림을 구조체로 변환해주는  함수포인터를 넘겨 받게 된다

그리고 그 넘겨 받은 함수에  버퍼와 크기를 넘겨주면 자동으로  
( g_funcRecvMap[enumID](g_sockfd,recvBuf,ptSize); )

위에서 말한
void ProcessPacket(SOCKET_ID socketID, const AC_LogIn& ptData);
이러한 함수가 호출된다

단 패킷 생성기에서는 선언만 만들기 때문에 구현은 어플리케이션단에서
정의해주어야 하는것이다

자동으로 많은것을 만들려주다보면  소켓 구조에 의존성을 가지게 되어
재활용율이 떨어지게 되므로  이정도까지가 최선이라 생각이 되었다

필요하다면  코드를 수정해 더  필요한만큼 만들어내면  될것이지만
가장 최적으로 재활용 될만한 수준에서 공개하는게 바람직할거 같아 여기까지 해주는 것으로
일단락 하였다

참 그리고 패킷 제네레이터가 해줘야 할 중요한 작업이 하나더 있는데
PT_INTERFACE의 내용 구성물이 바뀔때마다 ( 멤버 데이터의 순서 바뀜 혹은 추가 삭제 )  
버전업을 시켜 줘야 한다 그래서 버전이 다른 서버/클라이언트 간에 통신을 막아주어야 한다

예전 XX 게임에서는 빌드 할때마다 버전업을 시켜 , 클라이언트만 빌드해도 버전업이되어  
서버를 내리고 다시 버전업 시킨 서버를 올려야하는 시스템이 있었다

프로토콜이 바뀌지 않는 이상 그럴 필요는 없으므로 버전은 프로토콜이 바뀔때만 올라가야한다
따라서 패킷 제네레이터는 PT_INTERFACE의 스트림에
담는 구조가 바뀌었는지 파악하기때문에
자동으로 프로토콜 버전업 관리도 하게 된다  
----------------------------------------

 다음버전에는 좀더 최적화된 버전으로 코드를 생성할수도 있게 옵션을 추가 할 예정이다
 
-------

참고 사항 :

스트림 클래스를 boost의 serialization을 쓰려 했으나 너무 무겁고 ,  파일 쓰기에 중심을 맞춘거 같아
스트림 코드를 재작성하였다  ,  그리고 제가 주말에만 취미로 작업을 하는데
일요일 회사 문이 잠겨 있어 T_T    문서화 작업을 끝내지 못해 공개를 조금 미루게 될거 같다


2010년 8월 27일 금요일

패킷 생성기가 왜 필요한가?

저번 글에서   전통적인 두가지 패킷 처리 방법을 보았다

두 방법다 장점과 단점이 존재 하며  ,  완벽한 방법은 없어 보인다

하지만 자연스럽게 합쳐질 수 있는 방법은 없을까?

안타깝게도  두 방법은 공존할수 없는 방법이다 , 하나는 컴파일타임 기반 코드이고
하나는 런타임에 기반코드이기에 공존해 쓸수가 없는것이다

솔직히 근본적으로 C++ 언어차원에서는 해결할 방법이없다
그래서 수많은 프로젝트에서 이 전통전인 방법들을 고수하고 있는게 아닌가?

이쯤 되고 보면 , 이렇게 된거 하나를 선택하고 단점을 줄일 방법을 연구하는게 빠를거 같다

"남들이라고 별수 있겠어?" 라고 속편히 생각하면  이게 가장 현명한 생각인것인 같다는 생각도
드는게 인지상정이다

하지만  구조체를 선택해도 가변적인 처리때문에  코드량 및 복잡도가 높아지고
스트림 처리를 선택하더라도  패킷이 복잡하고나 양이 많아지면 마찬가지로 코드량이
많아지고 복잡해지는건 매한가지다

"나와 내 동료들은  실수 따윈 하지 않아!" 라는 가정하더라도 결국 많은 양의 단순 노가다에 앞에는
장사가 없다. 노가다는 인간의 본성을 거슬리는 일이기때문이다
(공산주의가 망한 해묵은 이유를 설명하진 않아도 되겠지)

"그냥 몸으로 때워!"의 마인드를 가진 팀장이라면 , 팀원들이 생각하며 생산적인 일을
하기보단  노가다하는 시간이 많을것이다
(새벽2-3까지 매일같이 해도 답 안나오는 프로젝트가 아직도 기억이....)

게임 디자이너너 마저도  "단순 노가다는 죽기보다 싫다"며 프로그래머에게 자동화를 요구해
오는 광경은 어렵지 않게 볼수 있다
[ 심지어 맥스 스크립트로 자신의 작업을 자동화해버리는 디자이너도 있다 ]

결론적으로 말해 인간의 본성을 거슬르는 노가다로 프로젝트를 운영하게 되면
프로젝트가 제대로  돌아갈리가 없다

근데 잠시만 디자이너가 자동화?를 요구한다고?

갑자기 드는 생각으로 디자이너의 편의를 위해 자동화 처리를 해주면서  정작 왜 자신의 일인
코드는 자동화 시키지 못하는지 의아스럽기만 하다

굳이 변명을 하자면   C++언어밖에 모르는 입장에선 C++로 디자이너의 일은 자동화 해줄수 있지만
정작 자신은 C++언어의 문법에 묶여 C++ 언어에서 할수 있는거 밖에 생각할수 없지 않은가?
 
그래도 미약하지만 C언어의 표준 전처리기인 메크로를 써 조금이라도 더 편하게 쓸수 있지 않을까라는
생각이 든다

"오 메크로! 그럴듯한데?" 단순 반복작업을 어떻게든 줄여줄수 있는것처럼 느껴진다
메크로로 인해 코드가 어려워 보이겠지만  이 노가다만 줄일 수 있다면 영혼이라도 팔겠다는 심정으로
#define Oh_My_God_Packet  ....
#define Damn_it  ....
#define WASTE_OF_TIME....
이런 코드를 짜기 시작하지만  끝도 없이 정의해야 될것들이 생겨난다.

게다가  이건 내가봐도 외계어가 된 느낌이다 ,
메크로를 정의해 패킷 처리 노가다를 줄여보려 외계어 정의에 더 스트레스 받는 느낌이
이건 뭔가 아니다 싶다

역시 없쩔수 없이  노가다를 인정하겠다는 마음으로  패킷 처리 코드를 짜지만
 , ctrl + V 를 한 코드에서 버그가 다시 발생하자  , 마음이 약해진다

그러는 문득! 실용주의 프로그래머에서 읽었던  자동화가 떠오른다

내용이 좋아 3-4번을 읽었으면서 막상 왜 그 자동화를 실천하지 못했을까?
[물론 그 책은 5년전에 읽었고 이 생각은 4년전부터 해왔다, 생각은 하고 있었으나 역시 실천이 T_T ]

실용주의 프로그래머 원칙
코드를 작성하는 코드를 작성하라

오오라 바로 이것인것이다
[신대륙을 발견한 콜롬버스의 달걀 깨기 처럼 C++ 문법의 한계를 넘어서는게 중요하다]

혹시 누군가 이런걸 만들어 놓지 않았을까 하는 마음에  구글에서 검색을 해보지만
C++에서 맘에 드는 방식으로 바로 쓸수 있는건 드물어 보였다

구글 코드에서 XML로 정의해 C++ 코드를 생성해 내는게 있었지만
XML로 복잡하게 정의해야 했고 , XML 정의가 직관적으로 바로 눈에 들어오진 않았다

그리고 그외 비슷한 것들을 보았지만 내맘에 쏙드는게 없었다

마치 fix_vector를 만들때 기분이랄까?

역시 직접 만들어야겠다는 생각에  프로토콜을 직관적인  구조체로 정의하고  그 코드를 파싱해서
stream 에 입출력하는 코드를 작성하는 생성기를 만들었다

그토록 원한  고전적인  패킷 처리 방법인 구조체 처리 방법과 스트림 처리의 장점을 동시에
가져갈수 있게 된것이다

분명 글 앞단에선 이것이 불가능하다면서  지금은 어떻게 가능하다고 하는 이야기일까?

다시 설명하면
전 처리 단계때  구조체를 보고  send recv  함수를 만들어 낸다면 (제대로 작성했따면)
컴파일 타임 체크 자체가 필요가 없고 ,  send recv 함수 내에는  구조체의 내부 데이터를
스트림에 담아 소켓 버퍼에 보내는 함수를 호출한다면 스트림의 장점을 다 취하는것이다

예를 들어 보겠다
// 파싱의 편의상 및  프로토콜이라는 직관성을 위해  struct 메크로로 재정의 했다

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

#define PT_INTERFACE  struct
#define PG_VECTOR     std::vector

// 이것이 패킷 정의다
PT_INTERFACE CS_LOG_IN
{
    String id;
    String password;
    PG_VECTOR<String> itemList;
};
-----------------------------------------------------------------------

"이건 구조체 방법이 아닌가?" 싶은데  가변 크기를 지정하는 vector가 들어가 있다
vector를 봤을적엔 구조체 방법도 아닌것고 그렇다고 스트림 방법도 아닌거 같다

자 이걸 보고 만약 이런 코드를 생성한다면 어떨까?

void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize);

void SendPacket(SOCKET socket , const CS_LOG_IN& packet )
{
        // 이 스트림 클래스엔 String과 Vector를 처리할수 있는 operator << 가 정의 되어 있다
PacketStream stream;
stream << packet.id;
stream << packet.password;
stream << packet.itemList;
SendPacket(socket,stream.GetBuffer(),stream.GetSize());
}

감이 있다면 바로 느꼈을것이다.  바로 이거라고....

구조체로 패킷을 정의하고 , 코드 생성기로 컴파일 타임체크의 역활 이상을 대신하며
스트림의 장점을 가지는 이런 코드 생성..... 아 내가 원한것이 바로 이런것인것이다

참고로
void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize)  이 함수는
선언만 되어 있고 구현은 없다  

이유는 프로젝트마다 선택되는 소켓의 처리 방식이 다를수 있으며
암호화가 추가될수도 있기에  특정 코드의 종속성을 방지 하기 위해
어플리케이션 코드단에서 정의를 해줘야 하기때문이다

예를 들면
void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize)  
{
//간단한 XOR 암호
XOR_Encode(pBuffer,bufSize);
// 현재는 간단히 버클리 소켓을 쓴다
send(socket,(char*)pBuffer,bufSize,0);
}

 패킷 생성기가 유연하고 독립성을 가지려면  위와 같이 어플리케이션 종속적인 코드를
피해야 재활용 가능한 패킷 생성기를 이용할수 있게 된다

to be continue...

2010년 8월 26일 목요일

Packet Generator 패킷 생성기

온라인 프로그램 패킷의 전통적 처리 방법 ( 장 단 점 )

온라인 프로그램을 짜는 프로그래머라면   누구나 프로토콜을 정의해 패킷을 만들고

클라이언트와 서버간에 통신하는 모듈을 만들어보았을것이다

그리고 이것은 일찍감치 프로그래머들에게 의해  2가지 패턴으로 정형화 되었는데 .....

첫째로는  구조체를 이용하는 방법이고 두째로는 스트림 클래스를 이용하는 방법이다

잘 이해가 안갈수 있으니 예를한번 살펴보자.

첫번째 방법: 구조체 이용하기

struct HEADER
{
int type;
int size;
}

enum {ID_CS_LOG_IN = 25 };

struct CS_LOG_IN : public HEADER
{
CS_LOG_IN() { type = ID_CS_LOGIN; size = sizeof(*this); }
char id[32];
char password[32];
};


이렇게 struct를 정의하고 해당하는 enum값을 정의 한다음
struct 의 생성자에서  필요한 값을 넣어준다

그다음  내부 내용을 복사해 채워 넣은후 소켓함수로 데이타를 보내는게 일반적인것이다

CS_LOG_IN packet;
strcpy(id,"myID");
strcpy(password,"mypassword")

SendPacket(socket,(char*)&packet,sizeof(CS_LOG_IN));

받는쪽도 그냥 통째로 받아 헤더의 type를 보고 CS_LOG_IN 으로 캐스팅 처리하면 된다


장점 :
1. 대단히 직관적이고 쉽다.
프로토콜 패킷정의를   구조체로 볼수 있고, 구조체는 누구나 쉽게 바로 파악할수 있기 때문이다

2. 직관적이기 때문에 고전적으로 많이 쓰였다. 따라서 누구나 쉽게 소스를 구할수 있고
편하게 접근 할수 있다

3. 컴파일 타임때 구조체 크기가 결정되므로 패킷의 버퍼 오버플로우를 컴파일 타임때 체크할수 있다

4. 구조체 내용이 바뀌면  컴파일타임때 패킷을 쓰는 부분에서 에러를 내뱃어주므로
   컴파일때 패킷처리의 오류를 고치기 쉽다

단점 :
1.  일단 구조체 이므로 바이트 정렬에 신경 써야 한다.
컴파일러는 기본 정렬이 4-16 사이에  정의 되어 있으므로
#pragma pack(1) 같은  컴파일러에 영향주는 옵션을 줘야 하고
저걸 제대로 안했을경우 (클라이언트/서버) 구조체 데이터가 서로 호환이 안될수도 있기에
바이트 정렬 자체를 신경써야 한다는거 자체가 부담스럽다

2. 1번과 비슷한 문제지만 서버와 클라이언트의 플레폼이 다를 경우 CPU의 (빅/리틀)엔디안
때문에 호환이 되지 않는 경우가 발생할수도 있다.  Marshal(마샬) 같은 작업이 필요할수가 있는것이다

3. 구조체는 컴파일 타임때 크기가 결정되므로  항상 최대 크기의 데이터를 보내야한다
예를 들면  유저가 채팅방에 0-100명까지 들어갈수 있다고 보자
입장할때 실제 방안에 있는 사람 숫자만큼만 데이터를 줄수 있어야 하지만
구조체는 항상 최대 크기인 100명을 소켓에 출력할수 밖에 없다

따라서 런타임시에 , 가변적인 크기에 대응하기 위해선
꽁수를 써야한다(이건 설명을 생략),  하지만  근본적으로 구조체 방법은  컴파일타임때 결정되는
구조이므로 꽁수로써 쓰다보면  프로토콜 자체가 점점 복잡해지며 처리 방법이 지저분해진다
패킷이 가변적인지 아닌지를 신경 써야 하는것이다.  


결론 :

구조체 방법은 나름 편리하기도 하지만 가변적인 문제는 치명적이다

따라서 네트웍 부하를 고려해야하는 온라인 게임에는 일반적으로 적합하지 않다

하지만 꽁수를 씀으로써  극복하려는 프로젝트를 상당히 많이 보았으며 상용 게임에서도

꽤 많이 쓰이곤 한다. 하지만 꽁수로 극복해야 하는 것들이 발목잡으며 상당히 거슬리곤 한다

익숙해지면 괜찬다고 하지만  ..........


결론적으로는  네트웍 대역폭을 별로 신경 쓸 필요 없는 프로젝트에서 대부분 많이 쓰인다

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

두번째 방법: 스트림 클래스 이용하기

c++ 프로그래머라면 누구나  std::cout<<"hello world"<<std::endl;  프로그램을 짜봤으리라
의심치 않는다

하지만 누구나 스트림의 대표격인 cout를 쓰면서도 스트림 클래스을 제대로 이해하고  활용하는 사람들은
일반적으로 많진 않은 편이다 , 나 역시 스트림을 그리 즐겨 사용하진 않는다

그렇지만 패킷 처리에 있어서만큼은 필수라 할만큼 꼭 필요한 방법인데 예를 한번 보자

(스트림 클래스의 구현은 인터넷에 많이 있다 모르는분은 찾아보자)
class PacketStream
{
 // value to buffer
  PacketStream& operator << ( int  value );
 // buffer to value
  PacketStream& operator >> ( int  value );

        -------------------------------------------
 char m_buffer[1024];
}

enum { ID_LOBBY_USER_LIST = 26 };

struct User
{
 char id[32];
 int    flag;
}

--------------             보내는쪽             ------------------------

// 서버에 있는 유저 데이타 리스트
std::vector< User > m_serverUserList;

PacketStream stream;

// 먼저 ID를 입력해주고
stream << ID_LOBBY_USER_LIST;

// 가변 크기의 입력
stream << m_serverUserList.size();
for(size_t i=0; i < m_serverUserList.size(); ++i)
{
 // 갯수만큼 내용을 스트림에 입력
 stream << m_serverUserList.id;
 stream << m_serverUserList.flag;
}

SendPacket( socket, stream.GetBuffer(), stream.size() );

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

--------------             받는 쪽              ------------------------

PacketStream stream;

// 소켓으로 부터 복사된 버퍼를 받아 stream에 입력한다
stream.CopyBuffer( socketBuffer, socketSize);

// id 출력해 파싱한다
int  type;
stream >> type;

switch( type )
{
case ID_LOBBY_USER_LIST:
{
std::vector< User > m_clientUserList;
size_t userCount;
  stream >> userCount;
m_clientUserList.resize( userCount );

for(size_t i=0; i < m_serverUserList.size(); ++i)
{
stream >> m_clientUserList[i].id;
  stream >> m_clientUserList[i].flag;
  }
  }
}
--------------------------------------------------------------------------

참고 : stream에서 STL를 지원하게 만들면 stream 작업이 상당히 간편해진다


장점:
1. 구조체의 가장 큰단점인 고정크기로 부터 자유롭다

2. 고정크기에 자유롭다는것만으로 온라인게임에서 가장 적합한 크기의 패킷을
만들어 낼수가 있다

3. stream 클래스의 도움(
마샬링)
을 받으므로
 
구조체에서 나온 바이트 정렬과  cpu 엔디안구조에
자유롭다


단점 :  

1. 런타임때 크기를 알수 있기때문에  런타임시에 버퍼 오버플로우와 언더플로우를 체크해야한다

2. 정확히  보내는 쪽과 받는쪽이 짝을 이루어야 한다

실수로  한쪽만 고치던가 다르게 고치면  컴파일 타임때는 잡아낼수 없고

런타임때  스트림이 꼬여 크래쉬를 내거나  눈에 띄는 버그를 발생 시킬때나 인식할수 있으므로

실수는 치명적이다.

[ 예를 들면 보내는쪽과 받는쪽의 타입 크기가 다르게 실수로 설정하던가 순서가 바뀌던가 하는... ]

이러한 실수는 적당히 스트림이 꼬여버려 애매한 버그를 만들어 버리고
마치 잘못된 메모리 참조 마냥 ......  충격으로  다가오곤 한다.
[차라리 바로 크래쉬를 내주면 고맙다]

게다가 반복 노가다적인  패킷 설정 작업은 구조체 설정에 비해 상당히 불편한 편이며
패킷의 복잡도에 따라 나름 상당한 노가다 이기에 복사 붙여넣기를 무조건?하게 유발한다


또한 구조체에 비해 직관성이 떨어진다

따라서 작업을 하다보면  스트림 꼬이는 버그는 근본적으로 피하긴 어렵다

결론
:

가변적 크기를 다루는 내용이 많고 , 네트웍 대역폭 낭비가 적어야 하는 온라인 게임이라면
스트림 방법은 선택이 아니라  필수다

구조체 방법과 비교하자면

구조체방법에서는 꽁수로 구조체에 제한된 스트림 방식을 적용하므로 프로토콜 처리
복잡도가 올라간 반면

스트림 처리는 깔끔은 하지만 컴파일타임때 체크는 포기해야 하므로  버그가 괴롭다

그 버그와,  편리함 때문에,  온라인게임에서  프로토콜이 복잡해지는것을 감수하고도
 꽁수가 추가된 구조체 방법을 많이 이용하게 되는것이다
[하지만 꽁수를 볼때마다  스트림 방식이 근대적인 방법임을 확신하게 된다]

비율은 조사를 해보진 않았으나 회사를 옮기는곳마다  의외로 구조체 방법을 고수하는곳이 많았다


하지만  구조체와 스트림 방식의 장점을 모두 취할수 있고 , 단점은 날려 버릴 방법은 없을것인가?

그래서 생각한것이 바로 패킷 생성기다


지면 관계상 Packet Generator 이야기는 다음으로....