반도의 돌직구녀. 이런게 바로 제대로

반도의 돌직구녀. 이런게 바로 제대로 "배운 녀자"지.


돌직구녀에게 감동을 받아 돌직구 포스트 한번 적어본다.
제목처럼 C/C++로 Socket.IO에게 돌직구 던지는 법이다. Socket.IO가 뭔지, 게임이랑 무슨 상관인지 어쩌구 저쩌구하는 머 그런 대딩같은 질문들은 일체 사절하도록 한다. 나중에 유료 세미나에서 돈내고 듣길 바란다.
돌직구는 언제나처럼 Socket으로 던진며, 당연히 BSD든 Winsock이건 똑같다.
아, Socket.IO 버전은 v.9.

1. 일단 주워보자.
있는 것을 다시 만들 필요없다, Socket.IO 홈페이지(http://socket.io/)와 github(https://github.com/)를 뒤졌다.
우선 홈페이지에는 얼랭, 안드레기, 자바, 루아, 오브젝레기-C, 펄, 고, 피똥, 루비, 플레기, 다 있는데......C++만 없었다. -_-
github와 구글느님께 도움을 요청했다.

아크데몬이 지저분한 소스에 기분이 상해버렸다.

아크데몬이 지저분한 소스에 기분이 상해버렸다.


딱 하나 나왔다.
요즘은 오픈소스도 참 잘 만들고 정리가 잘 되어 있는데 이건 마치 90년대 슬랙웨어를 보는 듯한
푸세식 화장실에서 건져올린 느낌의 C++ 프로젝트가 하나 나왔다.
물론 빌드도 제대로 안되고 에러처리로 제대로 안되어 있어 도저히 사용할 수 없었다.
이런거나 만들라고 공개된 Boost.Asio가 아닐텐데... .
간만에 코딩을 해야할 운명에 처했다.

2. RFC 6455
그래서 다른 언어로 된 Socket.IO 클라이언트를 읽고 C/C++로 포팅할려고 했다.
대부분 이미 구현된 WebSocket 클래스 위에 Socket.IO용 JSON 프로토콜을 올린 식이었다.
하지만 문제는 WebSocket이 이미 Fix된 프로토콜이 아니란 점이다.
소스들이 제각각 달랐다. 이래저래 별 도움이 안됐다. 역시 이 넘들에게 기대한 내가 바보였어.

할수 없이 RFC 6455(http://tools.ietf.org/html/rfc6455) 문서와 Socket.IO 문서(https://github.com/learnboost/socket.io-spec)를 읽었다.
오오미, 신기술답게 "ws://"도 등장한다.
하지만 우리들은 http든 ws든 그게 그거라는 것을 잘 알고 있다. GET이나 POST냐 PUT이냐 그런게 문제지.

이보다 더 자세한 설명이 있었는데 찾질 못하겠다.

이보다 더 자세한 설명이 있었는데 찾질 못하겠다.


하지만 단순히 WebSocket을 구현하는 것만으로는 안된다.
Socket.IO에게 WebSocket으로 접속하는 것 이전에 Socket.IO의 프로토콜의 알아야 하기 때문이다.

3. 뜯기

결국 직접 구현하기로 맘을 먹었다.
나에게 도움을 주실 분은 Fiddler2(http://www.fiddler2.com/fiddler2/)와 TCPView.
Fiddler2는 웹개발자를 위해 탄생한 툴이나 웹개발자보단 내 경험상, 항상 C/C++ 클라이언트 개발자들이 써왔다.

하악하악 섹시한 바이올린으로 갈것 같다.

하악하악 섹시한 바이올린으로 갈것 같다.


서버 소스는 구글에서 가장 먼저 검색된 http://psitsmike.com/2011/09/node-js-and-socket-io-chat-tutorial/ 를 쓰기로 했다.
블로그가 분홍색으로 알록달록하지만 분명 블로그 주인은 넷카마남자일꺼다.
......트위터 털어보니(사실은 링크) 남자분 맞으시네, 포스트 다 적고 인사하고 팔롱해드려야지.
아뭏든 우리의 피들러2느님께서 딱 잡아주신다.


암튼 뒤에 체크한 두개가 소켓으로 구현해야하는 부분이다.
마지막 접속 부분이 중요한데 만약 WebSocket을 지원하지 못하는 브라우저라면,
"/socket.io/1/xhr-polling/15479987321848421359?t=1339783678755" 식으로 XMLHTTPRequest로 접속할 것이다.
이는 실제 소켓 연결이 아니라 계속 요청을 보내 데이터를 가져온다는 뜻이다.
스마트 디바이스에 이런 연결을 하면 이통사에서 존나 싫어할 것 같다.ㅋㅋㅋㅋ

결국 웹브라우저가 아닌 소켓의 입장에서는

1) 접속
2) 클라이언트에서 만든 Timestamp용 랜덤키값 전송
3) Session ID와 사용가능한 Transport ID 받음
4) websocket 혹은 XMLHTTPRequest 방식으로 가져올 것인지 선택
5) XMLHTTPRequst 라면 계속 접속을 하여 데이터 획득
6) WebSocket이라면 실제 전송을 위해 새 Transport 연결 획득
7) 지정된 시간만큼 핸드쉐이킹
8) Socket.IO에서 지정된 JSON 프로토콜대로 데이터 주고받기

이다. 랜덤키를 클라이언트에서 만드는게 독특하다. 그리고 랜덤키가 없어도 세션ID는 돌아온다.
세션 ID는 Socket.IO 모듈의 manager.js의 691 라인의 이 부분이라 추측된다.
그냥 현재 시각을 이용한 랜덤이다.

/**
* Generates a session id.
*
* @api private
*/

Manager.prototype.generateId = function () {
return Math.abs(Math.random() * Math.random() * Date.now() | 0).toString()
+ Math.abs(Math.random() * Math.random() * Date.now() | 0).toString();
};

문제는 헤더에 실수가 있을 경우 에러값도 전혀 없이 접속을 종료한다. 즉 접속하고 아무 데이터나 보내면 그냥 close시켜버린다.
처음 이것 때문에 고생 좀 했다.

4. 구현
4.1 접속 및 세션 ID얻기
웹브라우저가 아닌 Winsock으로 하니 피들러2가 보여준 HTTP 요청들이 대거 축소되었다.
몇번이나 예제로 나간 MFCClient.sln을 고쳐 HTTP 요청을 보내도록 하였다.
주소 이후 URL은 "socket.io/1/?t=타임스탬프(랜덤)13자리&jsonp=0" 이다.

CString strHead = "GET " + strFileURL + " HTTP/1.1\n\n";
m_pClientSocket->Send(strHead, strHead.GetLength(), 0);

참고로 1.1로 요청하면 HTTP 연결형으로 접속이 된다.
1.0으로 요청하면 응답 후 소켓이 끊어지는 것을 눈으로 확인할 수 있다(직접 확인해보길 바란다.)
대부분이 잘못 알고 있는 것과 달리 현재 HTTP는 매회 요청시 Connection 요청을 하지 않는다.
따라서 WebSocket, 아니 서버에서 클라이언트로 메시지를 뿌리는 것도 가능한 것이다.
사실 WebSocket 자체는 결코 신기한 기술이 아니다.
HTTP 1.1부터는 1.0과 달리 연결이 항상 맺어져있다고 생각하면 무엇이든 이해가 될 것이다. 끊어지면 다시 연결하면 되는거고.

Connection: Keep-Alive에 주목! 그리고 세션 ID를 얻어왔다.

Connection: Keep-Alive에 주목! 그리고 세션 ID를 얻어왔다.


세션 ID(149821928277990092)를 얻어왔다.

세션 ID와 함께 날라오는 것은 Transport ID로 websocket, htmlfile, xhr-polling, jsonp-polling 이란 메시지이다.
이것은 세션 ID만큼 중요한데 이 클라이언트가 구현할수 있고, 서버가 응답가능한 통신 프로토콜들 리스트다.
즉, 이 포스트의 핵심인 websocket도 쓸수 있고, 계속 HTTP 요청으로 무식하게 htmlfile을 요청할수도 있고,
XMLHTTPRequest나 JSON Padding으로 구현할수도 있다는 이야기다.
물론 이것은 Socket.IO에서 설정한 대로 나오게 된다.

var serverSocket = socketio.listen(server);
serverSocket.set('transports', ['websocket', 'flashsocket', 'xhr-polling']);

이런 식으로 말이다.
이게 바로 Socket.IO의 역활이다. 어떤 웹브라우저라도 연결된 것처럼 써먹을 수 있게 해주겠다는 것이다.

물론 우리는 다른 것 다 필요없다, 웹브라우저가 아니니까 오직 WebSocket만 쓰면 된다.
위에도 적었지만 어떤 이유로 폴링 방식을 쓰겠다면 서버에도 xhr-polling 을 선언해주고 클라이언트는 "/socket.io/1/xhr-polling/세션ID?t=아까 정한 랜덤키"로 요청을 계속하면 된다.

4.2 Transport connection or Switching Protocols
드디어 HTTP가 아닌 "ws://" 프로토콜을 구현할 시간이다.
앞서 말한 것처럼 그래봤자 GET으로 요청하는 것에 불과하다.
위키피디아(http://en.wikipedia.org/wiki/WebSocket)에서 가져온 헤더들을 적자면,
요청시

GET /mychat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Version: 13
Origin: http://example.com

로 보내면 서버는

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

라고 보내준다고 한다.
핸드쉐이킹을 위해 클라이언트에서 랜덤하게 생성한 Sec-WebSocket-Key가 어떤 알고리즘으로 Sec-WebSocket-Accept로 바뀌는지는 역시 RFC 6455 에 친절히 설명되어 있다. WebSocket을 위한 GUID인 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"붙여 SHA-1과 Base64로 지지고 볶는데 서버를 만들 것이 아니라면 큰 관심을 가질 필요는 없을 것 같다.
다만 클라이언트에서 정하는 Sec-WebSocket-Key는 16자리 랜덤키를 Base64로 뽑아낸 것임은 기억할 필요가 있다.

테스트 삼아 대충 아래와 같이 헤더를 만들어 보냈다.
소켓은 더 만들 필요없고 첫 HTTP 접속에 사용한 소켓을 그대로 사용하면 된다.

CString strSessionID;
GetDlgItem(IDC_ADDRESS2)->GetWindowText(strSessionID);

CString strHead2;
strHead2 = "Upgrade: websocket\n"
"Connection: Upgrade\n"
"Host: localhost:8080\n"
"Origin: http://127.0.0.1:8080\n"
"Sec-WebSocket-Key: MTIzNDU2Nzg5MDEyMzQ1Ng==\n"
"Sec-WebSocket-Version: 13\n\n"
"Sec-WebSocket-Extensions: x-webkit-deflate-frame\n";

CString strHead = "GET /socket.io/1/websocket/" + strSessionID + " HTTP/1.1\n" + strHead2;
TRACE ("%s\n", strHead);

m_pClientSocket->Send(strHead, strHead.GetLength(), 0);

거지같은 소스지만 연결은 훌륭하게 잘된다.
문자열 파싱 좀 붙여 주소랑 포트, 그리고 랜덤하게 Sec-WebSocket-Key만 정해주면 훌륭해질 것이다.

WebSocket 연결 성공

WebSocket 연결 성공


4.3 Socket.IO Data Frame(or Framing)
정말로 채팅이 될까? 기존 웹채팅이랑 통신해보자.

서버와 접속이 된 상태이니 당연히 메시지를 받는다.

폴링따윈 껒!!

폴링따윈 껒!!


이제 데이터를 보내보자, 진정한 돌직구다.
결론적으로 Socket.IO에서 설명하고 있는 JSON 형식을 그대로 보내서는 안된다. 또 연결이 끊길 것이다.
그러나 WebSocket이 연결된 이후 데이터 전송에 대해서는 Fidder2로도 잡을수 없다.
왜냐면 WebSocket으로 Switching이 된 이후부터는 HTTP 가 아니라 일반 소켓 접속이기 때문이다.
크롬 + 외부 서버 + WireShark를 돌려봤지만 이렇다 할 답은 찾지 못했다. 크롬이 일반 binary로 통신하기에 데이터 모습도 모른체 binary를 뜯기는 너무나 힘이 든다.

Switching Protocol을 받은 이후(WebSocket으로 구현된 후), 패킷 데이터 구조체는 역시 RFC 6455 문서에 적혀있다.

WebSocket Data Framing

WebSocket Data Framing


 

아... 솔까 이런 그림만 보고 코딩하라면 나도 당황스럽다...

아... 솔까 이런 그림만 보고 코딩하라면 나도 당황스럽다...


이것을 C/C++로 옮겨보면,

string payload = "Socket.IO에 정의된 JSON 문자열";
payload = AnsiToUTF8(payload); // UTF-8로 보낸다.

char frame[131];

frame[0] = '\x81';
frame[1] = 128 + payload.length();
frame[2] = '\x00';
frame[3] = '\x00';
frame[4] = '\x00';
frame[5] = '\x00';

_snprintf(frame+6, 125, "%s", payload.c_str());


int iRet = m_pClientSocket->Send(&frame, payload.length() + 6, 0);

이런 형태가 된다.
아래 짤이 구현된 모습이다. C/C++소켓에서 보낸 "Hi, Hi"란 메시지가 웹브라우저에게 나타나고 있다.

돌직구 완성.

돌직구 완성.


아쉽게도 WebSocket은 한번에 최대 125byte밖에 보내지 못한다.
가장 앞의 "0x81"은 이 패킷이 단일 패킷(single-frame)이라고 상대에게 알려주는 것이다.
따라서 125 이상되는 내용을 보내고자 한다면 Socket.IO에게 보낼 JSON 데이터(상기 소스에서 string payload)를 길이에 맞게 나눠 가공하는 작업이 필요하며, 첫 패킷의 앞부분은 "0x01", 두번째 패킷부터는 "0x80"로 보내야 한다.
binary 데이터를 보낼려면 "0x82"를 붙여야 하지만 node.js의 속력을 감안하면 binary는 IIS같은 웹서버를 이용하는게 좋을 것 같다.

close()쪽은 특별히 신경쓸게 없는 것 같다. 그냥 close() 호출해줘도 node.js나 Socket.IO에서 에러는 나지 않았다.

5. 결론
이 포스팅을 하는 현재, 그 어느 소스도 맘에 들게 정상적으로 작동하는 것을 보지 못했다.
오픈 소스 대부분이 외국 학생들의 숙제였던 경우가 많고 WebSocket 프로토콜과 Data-Framing 규약이 그간 엄청나게 빠른 속도로 바뀌었었기 때문이다.

지난 일주일...

지난 일주일...


나 역시도 인터넷 검색하며 오래전 규약의 문서(현재 Socket.IO v9이 사용하는 WebSocket에 맞지 않는)들을 보며 수많은 함정에 빠졌었다.
마지막엔 WebSocket이 아닌 Socket.IO의 변태같은 JSON 프로토콜에 이틀밤을 낭비했다.
조금더 정리해서 나두 github같은데 올려볼까 한다.

전부 완성하고 보면 언제나 그렇듯 별 게 없다.
WebSocket 프로토콜과 Socket.IO도 또 언제 바뀔진 모른다.
그러나 힘들게 한번 구현하고 나니 앞으로 바뀔 모습도 예상되고 빨리 적용할 것 같다.
가장 중요한 것은 웹브라우저로 서비스할 것이 아니니 버전을 Fix 시켜놓고 그것만으로 운영해도 무리는 없을 것 같다.
이 엉망인 문서도 혹시 WebSocket을 쓰고자하는 진취적인 개발자들에게 작은 도움이 되길 바란다.
아무것도 아니지만, 나름 일주일 고생했다. 레알.

분석 마쳤으니 나는 다시 잉여질이나 해야지~

분석 마쳤으니 나는 다시 잉여질이나 해야지~



 

WebSocket 프로토콜의 비밀은 파헤친 아크데몬, 아니 하바라가 해맑게 웃고 있다.

WebSocket 프로토콜의 비밀은 파헤친 아크데몬, 아니 하바라가 해맑게 웃고 있다.

 

출처 : http://rhea.pe.kr/515

+ Recent posts