[RTMP] TCP echo 서버와 커스텀 프로토콜 서버
반복적으로 요청/응답 가능한 클라이언트/서버
accept()의 timeout
서버는 listen()을 한 번 호출한 뒤 accept()를 호출하고 대기합니다. 기다렸다가 연결요청 대기 큐에서 기다리고 있는 요청이 있으면 이에 대응하는 소케을 내부적으로 만들고 accept()를 리턴합니다. 서버의 accept()는 보통 무한루프안에 있기 때문에 다시 accept()를 호출하고 기다리는 상태가 됩니다.
accept()가 while(true)안에 위치함으로써 반복적인 응답이 가능해집니다. 이때 accept()는 클라이언트의 요청에 대응하는 소켓이 만들어졌을 때만 return하기 때문에 while loop이 무한정 계속 돌고 있지는 않습니다.
채팅 서버의 경우 사용자가 접속하기 전에는 할 일이 없기 때문에 while loop에 특별한 작업을 해주지 않아도 됩니다. 그런데 게임 서버의 경우 일정한 주기마다 데이터를 주고 받아야하는 특성이 있기 때문에 accept()를 일정한 주기마다 깨워줍니다.
while(true){
timeout = 10second;
clnt_sock = socket(serv_sock, ..., timeout);
do_something_1();
do_something_2();
}
accept()을 일정 시간마다 깨워주기 위해 전달하는 시간을 timeout이라고 합니다. RTMP 서버는 채팅 서버와 같이 송출자의 연결이 없다면 기다리고 있으면 되기 때문에 특별히 timeout을 지정해줄 필요가 없습니다.
Iterative 에코 서버/클라이언트
반복적으로 요청을 하고 응답하는 에코 서버/클라이언트는 feature/echo 브랜치에 있습니다.
$ git clone https://github.com/niklasjang/cpp-rtmp-relay
$ git checkout feature/echo
에코 클라이언트의 문제점
echo client에는 문제점이 존재합니다. read() write() 함수가 호출될 때마다 문자열 단위로 실제 입출력이 이루어지는 문제입니다. 이전 포스팅에서 언급했던 TCP의 특징 중에는 전송되는 데이터에 경계가 없다는 것입니다.
write(sock, message, strlen(message));
str_len=read(sock, message, BUF_SIZE-1);
message[str_len]=0;
printf("Message from server: %s", message);
즉, write()를 호출했다고 해서 message의 데이터가 모두 전송되었다는 보장이 없습니다. 클라이언트는 TCP 클라이언트이기 때문에 둘 이상의 write 함수호출로 전달된 문자열 정보가 묶여서 한 번에 서버에 전송될 수 있습니다. 이 경우 클라이언트는 서버로부터 잘못된 정보를 받아서 출력할 수 있습니다. 또 서버는 문자열을 한 번 받았는데 문자열이 길어서 두 개의 패킷으로 나누어서 보낼 수도 있습니다. 이 경우 클라이언트는 모든 정보를 받지 않았을 때 read()를 호출할 수도 있습니다.
사실 위 예제는 간단해서 오류가 발견되기가 쉽지 않지만 이와 같은 특성을 기억하고 넘어가야 RTMP 패킷을 전달할 때 어려움을 덜 수 있습니다.
에코 클라이언트 문제 해결 방법
해결 방법은 전송한 데이터의 크기만큼 서버로부터 다시 읽기까지 계속 read()를 수행하면 됩니다. BUF_SIZE-1만큼 읽기를 요청했지만 전송받은 데이터가 BUS_SIZE-1보다 작은 경우를 커버할 수 있도록 하기 위함입니다.
str_len=write(sock, message, strlen(message));
recv_len=0;
while(recv_len<str_len)
{
recv_cnt=read(sock, &message[recv_len], BUF_SIZE-1);
if(recv_cnt==-1)
error_handling("read() error!");
recv_len+=recv_cnt;
}
‘recv_len != str_len’를 사용하지 않고 ‘recv_len<str_len’을 사용하는 이유는 recv_len이 str_len보다 커지는 경우에도 무한루프에 빠지지 않기 위함입니다.
프로토콜을 정의하고 바이트배열을 주고 받는 op 서버
op 서버/클라이언트
op 서버/클라이언트는 다음과 같이 동작합니다. 지금부터 정하는 내용이 서버와 클라이언트가 정보를 주고 받는 규칙을 정의하는 과정이고 이를 프로토콜이라고 부릅니다. RTMP 서버를 개발할 때는 RTMP 프로토콜 문서를 보고 구현해야하기 때문에, 여기에서 프로토콜을 정하고 이를 기반으로 서버를 개발하는 연습을 합니다.
- 클라이언트는 서버에 접속하자마다 피연산자 갯수정보를 1바이트 정수형태로 전달
- 클라이언트가 서버에 전달하는 정수 하나는 4바이트로 표현
- 정수를 전달한 다음에는 연산자 종류를 전달한다. 연산자 정보는 1바이트로 전달된다.
- 문자 +,-, * 중 하나를 선택해서 전달한다.
- 서버는 연산 결과를 4바이트 정수의 형태로 클라이언트에게 전달한다.
- 연산결과를 얻은 클라이언트는 서버와의 연결을 종료한다.
$ git clone https://github.com/niklasjang/cpp-rtmp-relay
$ git checkout feature/op
아래의 내용은 int형으로 피연산자의 갯수를 받아서 1바이트 부분만 잘라서 저장하기 위해서 char형으로 타입 변환을 한 뒤 opmsg[0]에 저장합니다. 그리고 뒤에는 피연산자를 받는데 이때는 4바이트를 모두 사용해서 opmsg에 저장합니다. (int)&opmsg[iOPSZ+1]의 +1은 opmsg[0] 1바이트가 피연산자의 갯수가 저장되어 있어서 덮어쓰지 않기 위함입니다.
//op_clinet.c
fputs("Operand count: ", stdout);
scanf("%d", &opnd_cnt);
opmsg[0]=(char)opnd_cnt;
for(i=0; i<opnd_cnt; i++)
{
printf("Operand %d: ", i+1);
scanf("%d", (int*)&opmsg[i*OPSZ+1]);
}
fgetc(stdin)은 마지막 scanf입력을 받은 뒤 ‘\n’가 입력받아진 것을 제거하기 위함입니다. 그리고 마지막으로 연산자를 입력받고, write을 합니다. opnd_cnt*OPSZ+2의 +2는 맨 처음 저장한 피연산자의 갯수 1바이트, 연산자 1바이트를 의미합니다.
//op_client.c
fgetc(stdin);
fputs("Operator: ", stdout);
scanf("%c", &opmsg[opnd_cnt*OPSZ+1]);
write(sock, opmsg, opnd_cnt*OPSZ+2);
read(sock, &result, RLT_SIZE);
서버의 경우 1바이트를 먼저 일어서 피연산자의 갯수를 알아야지 얼마나 긴 입력을 받을지 결정할 수 있습니다. 다음으로는 피연산자의 갯수 * 4바이트 + 연산자 1바이트를 입력받습니다. 입력받는 문자열은 전무 opinfo배열에 저장됩니다. 중간에 전달받는 패킷이 잘라져서 올 수도 있기 때문에 opinfo에서 어디부터 데이터를 저장해야하는지를 나타내는 recv_len을 사용합니다.
//op_server.c
read(clnt_sock, &opnd_cnt, 1);
recv_len=0;
while((opnd_cnt*OPSZ+1)>recv_len)
{
recv_cnt=read(clnt_sock, &opinfo[recv_len], BUF_SIZE-1);
recv_len+=recv_cnt;
}
result=calculate(opnd_cnt, (int*)opinfo, opinfo[recv_len-1]);
write(clnt_sock, (char*)&result, sizeof(result));
피연산자의 갯수가 N개이면 연산은 N-1번만 하면 됩니다. char 배열을 int*형 배열로 형변환해서 전달하여 opnds배열에서 index를 사용할 때 4바이트 크기로 읽는다는 것을 알려줍니다.
int calculate(int opnum, int opnds[], char op)
{
int result=opnds[0], i;
switch(op)
{
case '+':
for(i=1; i<opnum; i++) result+=opnds[i];
break;
case '-':
for(i=1; i<opnum; i++) result-=opnds[i];
break;
case '*':
for(i=1; i<opnum; i++) result*=opnds[i];
break;
}
return result;
}
TCP를 더 깊게 이해하기 위한 이론
버퍼와 handshake에 대한 부분은 RTMP서버를 개발할 때에도 사용됩니다. 지금은 이론상으로 이해하고 추후에 RTMP의 handshake에서 코드레벨에서 이해해보겠습니다.
read()와 write()의 호출 횟수가 일치하지 않는 이유, 입출력 버퍼
write()/read()가 호출되는 시점이 데이터가 전송/수신되는 순간이 아닙니다. 정확히는 write()를 호출하면 데이터를 출력버퍼로 이동하고, read()를 수행하면 입력버터에 저장된 데이터를 읽어옵니다.
입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재합니다. 소캣 생성시에 자동으로 생성됩니다. 특이점은 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 되고, 입력버퍼에 남아있는 데이터는 소멸되어버립니다.
입력버퍼의 크기보다 더 큰 데이터를 전송하는 경우는 존재하지 않습니다. TCP에서 슬라이딩 윈도우라는 프로토콜을 사용해서 입력 버퍼의 크기를 주고받아서 여유가 있는만큼만 데이터를 전송합니다. 즉, 버퍼가 차고 넘쳐서 데이터가 소멸되는 일은 TCP에서 없습니다.
TCP의 내부 동작원리1: 상대 소켓과의 연결(3-way handshake)
TCP 소켓 생성에서 소멸의 과정을 거치면서 아래의 단계를 지납니다.
- 상대 소켓과의 연결
- 상대 소켓과의 데이터 송수신
- 상대 소켓과의 연결종료
3-way handshake를 간단하게 표현하면 다음과 같습니다.
- A : 연결하자 : [SYN] SEQ: 1000, ACK : -
- 내가 지금 보내는 패킷에 1000이라는 번호를 부여하니, 잘 받았으면 다음에는 1001번 패킷을 전달하라고 내게말해줘
- B : 준비됐어 : [SYN+ACK] SEQ: 2000, ACK : 1001
- 내가 지금 보내는 패킷에 2000이라는 번호를 부여하니, 잘 받았으면 다음에는 2001번 패킷을 전달하라고 내게말해줘
- 좀 전에 전송한 SEQ가 1000인 패킷은 잘 받았으니, 다음 번에는 SEQ가 1001인 패킷을 전송하기 바란다.
- A : 알겠어 : [ACK] SEQ: 1001, ACK : 2001
- 1001 보낼게
- 2000 잘 받았고 다음부터 2001 보내
TCP의 내부 동작원리2: 상대 소켓과의 데이터 송수신
호스트 A가 호스트 B에게 총 200바이트를 두 번에 나눠서 보내는 경우는 다음과 같이 동작합니다. A가 B에게 보낸 패킷에 대한 ACK을 받지 못하면 A는 같은 패킷을 다시 전송합니다.
- A: [SEQ] 1200, 100 byte data
- B: [ACK] 1301
- A: [SEQ] 1301, 100 byte data
- B: [ACK] 1402
- ACK 번호 = SEQ 번호 + 전송된 바이트 크기 + 1
- +1은 다음번에 전달될 SEQ의 번호를 알리기 위함
- A: [SEQ] SEQ: 1301, 100 byte data를 B가 제대로 받지 못하면 timeout이 걸리고 A는 같은 패킷을 재전송
TCP의 내부 동작원리3: 상대 소켓과의 연결종료(Four-way handshaking)
상호간에 FIN 메시지를 주고 받으면 연결이 종료되는데 이 과정이 네 단계를 거쳐서 진행됩니다.
- A: 연결 끊고싶습니다. [FIN] SEQ 5000, ACK -
- B: 잠시만요 [ACK] SEQ 7500, ACK 5001
- B: 저도 끊을 준비가 됐습니다. 끊으세요. [FIN] SEQ 7501, ACK 5001
- A: 네 연결 끊겠습니다. [ACK] SEQ 5001, ACK 7502