5장 TCP 기반 서버/클라이언트2
- 에코 클라이언트의 완벽 구현
에코 서버는 문제가 없고 에코 클라이언트만 문제가 있나요?
문제는 에코 서버에 있지 않고, 에코 클라이언트에 있다. 먼저 에코 서버의 입출력 문자을 보면 아래와 같다
while((str_len = read(clnt_sock, message, BUF_SIZE)) != 0 )
write(clnt_sock, message, str_len);
에코 클라이언트의 입출력 문장은 아래와 같다
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
둘 다 read 함수와 write 함수를 반복 호출하는데 차이가 없다. 실제로 앞서 보인 에코 클라이언트는 자신이 서버로 전송한 데이터를 100% 수신한다. 다만 수신하는 단위에 문제가 있을 뿐이다. 클라이언트 코드를 조금 더 보겠다.
while(1)
{
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
...
write(sock, message, strlen(message));
str_len = read(sock ,message, BUF_SIZE-1);
message[str_len] = 0;
printf("Message from server: %s", message);
}
에코 클라이언트는 문자열을 전송한다. 그것도 write 함수호출을 통해서 한방에 전송한다. 그리고 read 함수호출을 통해서 자신이 전송한 문자열 데이터를 한방에 수신하기를 원하고 있다. 바로 이것이 문제이다!
"결국엔 에코 클라이언트에게 문자열 데이터가 전부 전송되니까 기다리면 되겠네요. 시간 좀 지나서 read 함수를 호출하면 한방에 문자열 데이터를 수신할 수 있는거 맞죠?"
맞다! 시간이 좀 지나면 수신할 수 있다. 그런데 얼마나 기다려야 하겠는가? 한 10분쯤 기다려 보겠는가? 그렇다면 이는 이치에 맞지 않는 클라이언트가 된다.
- 에코 클라이언트의 해결책
클라이언트가 수신해야 할 데이터의 크기를 미리 알고 있다. 예를 들어 크기가 20바이트인 문자열을 전송했다면 20바이트를 수신할 때까지 반복해서 read함수를 호출하면 된다.
- echo_client2.c
|
이전 예제에서는 단순히 read 함수를 한 번 호출하고 말았던 것을 이 예제에서는 전송한 데이터의 크기만큼 데이터를 수신하기 위해서 read 함수를 반복 호출 하고 있다. 따라서 정확히 전송한 바이트 크기만큼 데이터를 수신할 수 있게 되었다.
- 에코 클라이언트 이외의 경우에는? 어플리케이션 프로토콜의 정의
수신한 데이터의 크기를 파악하는 것이 불가능한 경우 필요한 것이 어플리케이션 프로토콜의이다. 앞서 구현한 에코 서버, 에코 클라이언트에서는 다음의 프로토콜을 정의하였다.
"Q가 전달되면 연결을 종료한다."
마찬가지로 데이터의 송수신 과정에서도 데이터의 끝을 파악할 수 있는 약속(프로토콜)을 별도로 정의해서 데이터의 끄티을 표현하거나, 송수신될 데이터의 크기를 미리 알려줘서 그에 따른 대비가 가능해야 한다.
서버, 클라이언트의 구현과정에서 이렇게 하나, 둘씩 만들어지는 약속을 모아서 '어플리케이션 프로토콜'이라 한다. 이렇듯 어플리케이션 프로토콜은 대단한 것이 아니다. 목적에 맞는 프로그램의 구현에 따라서 정의하게 되는 약속에 지나지 않는다.
- 계산기 서버, 클라이언트의 예
- 클라이언트는 서버에 접속하자마자 피연산자의 개수정보를 1바이트 정수형태로 전달한다
- 클라이언트가 서버에 전달하는 정수 하나는 4바이트로 표현한다
- 정수를 전달한 다음에는 연산의 종류를 전달한다. 연산정보는 1바이트로 전달한다.
- 문자 +, -, * 중 하나를 선택해서 전달한다
- 서버는 연산결과를 4바이트 정수의 형태로 클라이언트에게 전달한다
- 연산결과를 얻은 클라이언트는 서버와의 연결을 종료한다.
이 정도 프로토콜만 정의하면, 프로그램의 반은 구현한 것이나 다름없다. 그만큼 네트워크 프로그래밍에서는 어플리케이션 프로토콜의 정의가 중요하다. 프로토콜만 잘 정의하면, 구현은 큰 문제되지 않는다. 그리고 close 함수가 호출되면 상대방에게 EOF가 전달된다는 사실도 기억하고 활용하기 바란다.
- op_client.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h>
#define BUF_SIZE 1024 #define RLT_SIZE 4 #define OPSZ 4 void error_handling(char* message);
int main(int argc, char* argv[]) { int sock; char opmsg[BUF_SIZE]; int result, opnd_cnt, i; struct sockaddr_in serv_adr; if( argc != 3 ) { printf("Usage : %s<IP> <port>\n", argv[0]); exit(1); }
sock = socket(PF_INET, SOCK_STREAM, 0); if( sock == -1 ) error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = inet_addr(argv[1]); serv_adr.sin_port = htons(atoi(argv[2]));
if( connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1 ) error_handling("connect() error!"); else puts("connected......");
puts("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); fputs("Operator: ", stdout); scanf("%c", &opmsg[opnd_cnt*OPSZ+1]); write(sock, opmsg, opnd_cnt*OPSZ+2]); read(sock, &result, RLT_SIZE);
printf("Operation result: %d \n", result); close(sock); return 0; }
void error_handling(char* message) { } |
이렇듯 하나의 배열에 다양한 종류의 데이터를 저장해서 전송하려면, char형 배열을 선언해야 한다. 뿐만 아니라, 다소 과도한 포인터 및 배열 연산이 등장하기도 한다.
- op_server.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h>
#define BUF_SIZE 1024 #define OPSZ 4 void error_handling(char* message); int calculate(int opnum, int opnds[], char oprator);
int main(int argc, char* argv[]) { int serv_sock, clnt_sock; char opinfo[BUF_SIZE]; int result, opnd_cnt, i; int recv_cnt, recv_len; struct sockaddr_in serv_adr, clnt_adr; socklent_t clnt_adr_sz; if( argc != 2 ) { printf("Usage : %s <port>\n", argv[0]); exit(1); }
serv_sock = socket(PF_INET, SOCK_STREAM, 0); if( serv_sock == -1 ) { error_handling("socket() error"); }
memset(&serv_adr, 0, sizeof(serv_adr)); serv_adr.sin_family = AF_INET; serv_adr.sin_addr.s_addr = htonl(INADDR_ANY); serv_adr.sin_port = htons(atoi(argv[1]));
if( bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1 ) error_handling("bind() error");
if( listen(serv_sock, 5) == -1 ) error_handling("listen() error");
clnt_adr_sz = sizeof(clnt_adr);
for( i = 0 ; i < 5 ; i++ ) { opnd_cnt = 0; clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); 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 += reccv_cnt; }
result = calculate(opnd_cnt, (int*)opinfo, opinfo[recv_len-1]); write(clnt_sock, (char*)&result, sizeof(result)); close(clnt_sock); }
close(serv_sock); return 0; }
void error_handling(char* message) { } int calculate(int opnum, int opnds[], char op) { int result = opnd[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의 이론적인 이야기
- TCP 소켓에 존재하는 입출력 버퍼
TCP 소켓의 데이터 송수신에는 경계가 없다. 따라서 서버가 한번의 write 함수호출을 통해서 40바이트를 전송해도 클라이언트는 네 번의 read 함수호출을 통해서 10바이트씩 데이터를 수신하는 것이 가능하다. 그런데 이러한 현상에 의문을 가질 수 있다. 서버는 데이터를 한 번에 40바이트를 전송했는데, 클라이언트가 이를 여유 있게 조금씩 수신하니 말이다. 클라이언트가 10바이트만 먼저 수신했다면, 서버가 보낸 나머지 30바이트는 어디서 대기하고 있는 것일가?
사실 write 함수가 호출되는 순간이 데이터가 전송되는 순간이 아니고, read 함수가 호출되는 순간이 데이터가 수신되는 순간이 아니다. 정확히 말하면 write 함수가 호출되는 순간 데이터는 출력버퍼로 이동하고, read 함수가 호출되는 순간 입력버퍼에 저장된 데이터를 읽어 들이게 된다.
위 그림이 보이듯이 write 함수가 호출되면 출력버퍼라는 곳에 데이터가 전달되어서 상황에 맞게 적절히 데이터를 상대방의 입력버퍼로 전송한다. 그러면 상대방은 read 함수호출을 통해서 입력버퍼에 저장된 데이터를 읽게 되는 것이다. 이러한 입출력 버퍼의 특성 몇 가지를 정리하면 다음과 같다
- 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재한다.
- 입출력 버퍼는 소켓생성시 자동으로 생성된다.
- 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이뤄진다.
- 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸되어 버린다.
"입력버퍼의 크기를 초과하는 분량의 데이터 전송은 발생하지 않는다" 왜냐하면 TCP가 데이터의 흐름까지 컨트롤하기 때문이다. TCP에는 '슬라이딩 윈도우(Sliding Window)'라는 프로토콜이 존재한다. 이 프로토콜의 역할을 대화로 표현하면 다음과 같다
- 소켓a : 50바이트까지는 보내도 괜찮아
- 소켓b : OK
- 소켓a : 내가 20바이트 비웠으니까 70바이트까지 괜찮아
- 소켓b : OK
이렇게 서로 대화를 주고받으면서 데이터를 송수신하기 때문에 버퍼가 차고 넘쳐서 데이터가 소멸되는 일이 TCP에서는 발생하지 않는다.
- TCP의 내부 동작원리1 : 상대 소켓과의 연결
TCP 소켓의 생성에서 소멸의 과정까지 거치게 되는 일을 크게 나누면 다음 세가지로 구분할 수 있다.
- 상대 소켓과의 연결
- 상대 소켓과의 데이터 송수신
- 상대 소켓과의 연결종료
연결설정 과정에서 두 소켓이 주고받는 대화의 내용을 간단히 정리하면 다음과 같다
[shake1] 소켓a : 소켓b, 내가 전달할 데이터가 있으니 우리 연결 하자
[shake2] 소켓b : OK 준비 됨, 연결 ㄱㄱ
[shake3] 소켓a : Good, Thanks;
실제로 TCP 소켓은 연결설정 과정에서 총 세 번의 대화를 주고 받는다 이를 가리켜 Three-way handshaking이라 한다.
소켓은 전 이중(Full-duplex) 방식으로 동작하므로 양방향으로 데이터를 주고받을 수 있다. 따라서 데이터 송수신에 앞서 준비과정이 필요하다. 먼저 연결요청을 하는 호스트A가 호스트B에게 다음 메시지를 전달하고 있다.
[SYN] SEQ : 1000, ACK: -
이는 SEQ가 1000, ACK는 비어있음을 뜻하는데 SEQ1000이 의미하는 바는 다음과 같다
"내가 지금 보내는 이 패킷에 1000이라는 번호를 부여하지, 잘 받았으면 다음에는 1001번 패킷을 전달하라고 내게 말해달라!"
이는 처음 연결요청에 사용되는 메시지이기 때문에 이 메시지를 가리켜 SYN이라 한다. 그리고 SYN은 Synchronization의 줄임 말로써, 데이터 송수신에 앞서 전송되는 '동기화 메시지'라는 의미를 담고 있다. 이어서 호스트B가 호스트A에게 다음 메시지를 전달하고 있다.
[SYN+ACK] SEQ: 2000, ACK: 1001
이는 SEQ가 2000, ACK가 1001임을 뜻하고, 여기서 SEQ 2000이 의미하는 바는 다음과 같다
"내가 지금 보내는 이 패킷에 2000이라는 번호를 부여하니, 잘받았으면 다음에 2001번 패킷을 전달해조"
그리고 ACK 1001이 의미하는 바는 다음과 같다
"좀 전에 전송한 SEQ가 1000인 패킷은 잘받았으니 다음에 SEQ가 1001인 패킷을 전송하기 바란다"
즉, 처음 호스트A가 전송한 패킷에 대한 '응답 메시지(ACK 1001)'와 함께 호스트B의 데이터 전송을 위한 '동기화 메시지(SEQ 2000)'을 묶어서 보내고 있다. 이러한 유형의 메시지를 가리켜 SYN+ACK라고 한다.
이렇게 패킷에 번호를 부여해서 확인하는 절차를 거치기 때문에 손실된 데이터의 확인 및 재전송이 가능한 것이고, 때문에 TCP는 손실없는 데이터의 전송을 보장하는 것이다.
- TCP의 내부 동작원리2: 상대 소켓과의 데이터 송수신
처음 진행한 Three-way handshaking을 통해서 데이터의 송수신 준비가 끝났으니, 이제 본격적으로 데이터를 송수신할 차례가 되었다. 데이터 송수신의 기본방식은 다음과 같다
위 그림은 호스트A가 호스트B에게 총 200바이트를 두 번에 나눠서 전송하는 과정을 보인 것이다. 먼저 호스트A가 100바이트의 데이터를 하나의 패킷에 실어 전송하였는데, 패킷의 SEQ를 1200으로 부여하고 있다. 때문에 호스트B는 이를 근거로 패킷이 제대로 수신되었음을 알려야 하기에, ACK1031 메시지를 담은 패킷을 호스트A에 전송하고 있다.
이 때 ACK번호가 1201이 아닌 1301인 이유는 ACK번호를 전송된 바이트 크기만큼 추가로 증가시켰기 때문이다. 이렇듯 ACK 번호를 전송된 바이트 크기만큼 추가로 증가시키지 않으면 패킷의 전송은 확인할 수 있을지 몰라도, 패킷에 담긴 100바이트가 전부 전송되었는지, 아니면 그 중 일부가 손실되고 80바이트만 전송되었는지 알 방법이 없다. 그래서 다음의 공식을 기준으로 ACK메시지를 전송한다.
ACK번호 -> SEQ번호 + 전송된 바이트 크기 + 1
마지막에 1을 더한 이유는 Three-way handshaking에서도 보였듯이, 다음 번에 전달될 SEQ의 번호를 알리기 위함이다.
이번에는 중간에 패킷이 소멸되는 상황을 보이겠다.
위 그림은 SEQ 1301인 패킷에 100바이트 데이터를 실어서 호스트B로 전송되고 있음을 보이고 있다. 그런데 중간에 문제가 발생해서 호스트 B에 전송되지 못했다. 이러한 경우 호스트A는 일정시간이 지나도 SEQ1301에 대한 ACK메시지를 받지 못하기 대문에 재전송을 진행한다. 이렇듯 데이터 손실에 대한 재전송을 위해서, TCP소켓은 ACK응답을 요구하는 패킷 전송 시에 타이머를 동작시킨다. 그리고 해당 파이머가 Time-out!되었을 때 패킷을 재전송한다.
- TCP의 내부 동작원리3 : 상대 소켓과의 연결종료
TCP소켓은 연결종료도 매우 우아하게(?) 진행한다. 그냥 연결을 뚝 귾어버리면, 상대방이 전송할 데이터가 남았을 때 문제가 되기 때문에 상호간에 연결종료의 합의과정을 거치게된다. 다음은 연결종료 과정에서 주고받는 메시지를 대화로 표현한 것이다.
소켓A : 전 연결을 끊고자 합니다
소켓B : 아! 잠시만요
소켓B : 저도 준비 끝났습니다. 그럼 연결 끊으시지요
소켓A : 네 그동안 즐거웠습니다.
먼저 소켓A가 종료 메시지를 소켓B에 전달하고, 소켓B는 해당 메시지의 수신을 소켓A에게 알린다. 그리고 이어서 소켓B가 종료 메시지를 소켓A에게 전달하고, 소켓A는 해당 메시지의 수신을 소켓B에게 알리며 종료의 과정을 마치게 된다.
위 그림에서 패킷 안에 삽입되어 있는 FIN은 종료를 알리는 메시지를 뜻한다. 즉, 상호간에 FIN메시지를 한번씩 주고 받고서 연결이 종료되는데, 이 과정이 네 단계에 걸쳐서 진행되기 때문에 이를 가리켜 Four-way handshaking이라고 부른다.
- 윈도우 기반으로 구현하기
- op_client_win.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib")
#define BUF_SIZE 1024 #define RLT_SIZE 4 #define OPSZ 4
void error_handling(char* message);
int main(int argc, char* argv[]) { WSADATA wsaData; SOCKET sock; char opmsg[BUF_SIZE]; int result, opndCnt, i; SOCKADDR_IN servAdr;
if( argc != 3 ) { printf("Usage : %s <IP> <PORT>\n", argv[0]); exit(1); }
if ( WSAStartup(MAKEWORD(2,2), &wsaData) != 0 ) error_handling("WSAStartup() error");
sock = socket(PF_INET, SOCK_STREAM, 0); if(sock == INVALID_SOCKET) error_handling("socket() error");
memset(&servAdr, 0, sizeof(servAdr)); servAdr.sin_family = AF_INET; servAdr.sin_addr.s_addr = inet_addr(argv[1]); servAdr.sin_port = htons(atoi(argv[2]));
if( connect(sock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR ) error_handling("connect() error"); else puts("Connected....");
fputs("operand count: ", stdout); scanf("%d", &opndCnt); opmsg[0] = (char)opndCnt;
for( i = 0 ; i < opndCnt ; i++ ) { printf("Operand %d: ", i+1); scanf("%d", (int*)&opmsg[i*OPSZ+1]); }
fgetc(stdin); fputs("Operator: ", stdout); scanf("%c", &opmsg[opndCnt*OPSZ+1]); send(sock, opmsg, opndCnt*OPSZ+2, 0); recv(sock, &result, RLT_SIZE, 0);
printf("Operation result: %d\n", result); closesocket(sock); WSACleanup();
return 0; }
void error_handling(char* message) { fputs(message, stderr); fputc('\n', stderr); exit(1); } |
- op_server_win.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib")
#define BUF_SIZE 1024 #define OPSZ 4
void error_handling(char* message); int calculate(int opnum, int opnds[], char op);
int main(int argc, char* argv[]) { WSADATA wsaData; SOCKET servSock, clntSock; char opinfo[BUF_SIZE]; int result, opndCnt, i; int recvCnt, recvLen; SOCKADDR_IN servAdr, clntAdr; int clntAdrSize;
if( argc != 2 ) { printf("Usage : %s <port>\n", argv[0]); exit(1); }
if( WSAStartup(MAKEWORD(2,2), &wsaData) != 0 ) error_handling("WSAStartup() error");
servSock = socket(PF_INET, SOCK_STREAM, 0); if( servSock == INVALID_SOCKET ) error_handling("socket() error");
memset(&servAdr, 0, sizeof(servAdr)); servAdr.sin_family = AF_INET; servAdr.sin_addr.s_addr = htonl(INADDR_ANY); servAdr.sin_port = htons(atoi(argv[1])); if( bind(servSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR) error_handling("bind() error");
if( listen(servSock, 5) == SOCKET_ERROR ) error_handling("listen() error"); clntAdrSize = sizeof(clntAdr);
for( i = 0 ; i < 5 ; i++ ) { opndCnt = 0; clntSock = accept(servSock, (SOCKADDR*)&clntSock, &clntAdrSize); recv(clntSock, (char*)&opndCnt, 1, 0);
recvLen = 0; while( (opndCnt*OPSZ+1) > recvLen ) { recvCnt = recv(clntSock, &opinfo[recvLen], BUF_SIZE-1, 0); recvLen += recvCnt; }
result = calculate(opndCnt, (int*)opinfo, opinfo[recvLen-1]); send(clntSock, (char*)&result, sizeof(result), 0); closesocket(clntSock); }
closesocket(servSock); WSACleanup();
return 0; }
void error_handling(char* message) { fputs(message, stderr); fputc('\n', stderr); exit(1); }
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; } |
- op_client_win.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <winsock2.h> #pragma comment(lib, "ws2_32.lib")
#define BUF_SIZE 1024 #define RLT_SIZE 4 #define OPSZ 4
void error_handling(char* message);
int main(int argc, char* argv[]) { WSADATA wsaData; SOCKET sock; char opmsg[BUF_SIZE]; int result, opndCnt, i; SOCKADDR_IN servAdr;
if( argc != 3 ) { printf("Usage : %s <IP> <PORT>\n", argv[0]); exit(1); }
if ( WSAStartup(MAKEWORD(2,2), &wsaData) != 0 ) error_handling("WSAStartup() error");
sock = socket(PF_INET, SOCK_STREAM, 0); if(sock == INVALID_SOCKET) error_handling("socket() error");
memset(&servAdr, 0, sizeof(servAdr)); servAdr.sin_family = AF_INET; servAdr.sin_addr.s_addr = inet_addr(argv[1]); servAdr.sin_port = htons(atoi(argv[2]));
if( connect(sock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR ) error_handling("connect() error"); else puts("Connected....");
fputs("operand count: ", stdout); scanf("%d", &opndCnt); opmsg[0] = (char)opndCnt;
for( i = 0 ; i < opndCnt ; i++ ) { printf("Operand %d: ", i+1); scanf("%d", (int*)&opmsg[i*OPSZ+1]); }
fgetc(stdin); fputs("Operator: ", stdout); scanf("%c", &opmsg[opndCnt*OPSZ+1]); send(sock, opmsg, opndCnt*OPSZ+2, 0); recv(sock, &result, RLT_SIZE, 0);
printf("Operation result: %d\n", result); closesocket(sock); WSACleanup();
return 0; }
void error_handling(char* message) { fputs(message, stderr); fputc('\n', stderr); exit(1); } |