본문으로 바로가기
반응형

TCP 기반 서버/클라이언트2

저번 포스팅에서 에코 클라이언트의 문제점을 얘기하고 끊겼었다.

 

문제는 에코 서버에 있지 않고, 에코 클라이언트에 있다. 그런데 코드만 놓고 비교하면 입출력에 사용된 함수 호출문이 동일하기때문에 헷갈릴 수 있다. 먼저 에코 서버의 입출력 문장을 다시 보도록하자.

이는 예제 echo_server.c 의 일부 코드이다.

while((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
	write(clnt_sock, message, str_len);

이어서 에코 클라이언트의 입출력 문장을 다시 보자. 이는 echo_client.c의 일부이다.

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 form server : %s", message);
}

에코 클라이언트는 문자열을 전송한다. 그것도 write 함수 호출을 통해서 한방에 전송한다.

그리고 read 함수호출을 통해 자신이 전송한 문자열 데이터를 한방에 수신하기를 원하고 있다. 

바로 이것이 문제이다.

 

결국엔 에코 클라이언트에게 문자열 데이터가 전부 전송되니까 기다리면 되고, 시간 좀 지나서 read 함수를 호출하면 한방에 문자열 데이터를 수신할 수 있는 것이다.

 

그런데 얼마나 기다려야할지 감이 오지 않는다. 이는 이치에 맞지 않는 클라이언트가 된다. 이치에 맞는 클라이언트라면, 문자열 데이터가 전송되었을때 이를 모두 읽어서 출력해야 할것이다.

 

 

에코 클라이언트의 해결책

에코 클라이언트의 문제점은 초보 프로그래머라면 흔히 실수하는 부분 중 하나이다. 그런데 에코 클라이언트의 경우에는 해결이 쉬운편이다. 왜냐면 클라이언트가 수신해야 할 데이터의 크기를 미리 알고있기 때문이다

예를들어 크기가 20바이트인 문자열을 전송했다면, 20바이트를 수신할때까지 반복해서 read 함수를 호출하면 된다.

// echo_client2
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
   int sock;
   char message[BUF_SIZE];
   int str_len, recv_len, recv_cnt;
   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 ......");

   while(1)
   {
      fputs("Input message(Q to quit) : ", stdout);
      fgets(message, BUF_SIZE, stdin);
      if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
         break;
      
      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;
      }
      message[recv_len] = 0;
      printf("Message from server : %s", message);
   }
   close(sock);
   return 0;
}

void error_handling(char *message)
{
   fputs(message, stderr);
   fputc('\n', stderr);
   exit(1);
}

앞서했던 echo_server.c 를 실행해서 파일을 생성하고 실행해서 서버를 열어주고, echo_clienr2.c를 실행하여 파일을 생성하고 실행해주면 위에서 했었던 예제와 같은 출력 결과를 확인 할 수 있다.

 

참고로 echo_client2.c 코드는 기존의 echo_client.c 코드에서 해당 코드만 추가되었다.

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;
}
message[recv_len] = 0;

이전 예제에서는 read 함수를 한번 호출하고 말았던 것을 이 예제에서는 데이터의 크기만큼 수신하기 위해서 read 함수를 반복호출 하고 있다. 따라서 정확히 전송한 바이트 크기만큼 데이터를 수신할 수 있게 되었다.

 

 

어플리케이션 프로토콜의 정의

에코 클라이언트의 경우에는 수신할 데이터의 크기를 이전에 파악할 수 있지만, 이것이 불가능한 경우가 훨씬 많음을 인식해야한다. 그렇다면 이렇게 수신할 데이터의 크기를 파악하는 것이 불가능한 경우에는 어떻게 데이터를 송수신할까?

이러한 경우에 필요한 것이 바로 어플리케이션 프로토콜의 정의이다. 앞서 구현한 에코 서버, 에코 클라이언트에서는 다음의 프로토콜을 정의하였다.

"Q가 전달되면 연결을 종료한다."

 

마찬가지로 데이터의 송수신 과정에서도 데이터의 끝을 파악 할 수 있는 약속(프로토콜)을 별도로 정의해서 데이터의 끝을 표현하거나, 송수신될 데이터의 크기를 미리 알려줘서 그에 따른 대비가 가능해야 할 것이다.

 

참고로 서버, 클라이언트의 구현과정에서 이렇게 만들어지는 약속을 모아서 '어플리케이션 프로토콜'이라 한다.

이렇듯 어플리케이션 프로토콜은 목적에 맞는 프로그램의 구현에따라 정의하게되는 약속이라고 볼 수 있다.

 

그럼 어플리케이션 프로토콜의 정의를 경험하기위해 예제를 작성해보자. 이 프로그램에서 서버는 클라이언트로부터 여러 개의 숫자와 연산자 정보를 전달받는다.

그러면 서버는 전달받은 숫자를 바탕으로 덧셈, 뺄셈, 곱셈을 계산해서 그 결과를 클라이언트에게 전달한다.

// 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;
   socklen_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 += recv_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;
}

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

void error_handling(char *message)
{
   fputs(message, stderr);
   fputc('\n', stderr);
   exit(1);
}
// 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.....");

   fputs("Input message(Q to quit) : ", 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)
{
   fputs(message, stderr);
   fputc('\n', stderr);
   exit(1);
}

실행결과가 복잡해보이지만 위의 터미널에서 서버를 열고, 새 터미널에서 클라이언트를 실행하면된다.

그러면 Input message()가 출력되면서 몇개의 숫자를 입력받을건지 물어보고,

입력한 숫자만큼 입력받고, Operator의 입력을 받아서 덧셈, 뺄셈, 곱셈인지 물어보고, 연산의 결과를 출력한다.

 

물론 연산은 클라이언트가 아닌 서버가 진행한 것이며, 클라이언트는 이를 단순히 출력만 해주고 있다.

 

 

TCP의 이론적인 이야기

TCP 소켓에 존재하는 입출력 버퍼

TCP 소켓의 데이터 송수신에는 경계가 없음을 수 차례 설명하였다. 따라서 서버가 한번의 write 함수 호출을 통해 40바이트를 전송해도 클라이언트는 4번의 read 함수 호출을 통해 10바이트씩 데이터를 수신하는 것이 가능하다.

 

그런데 이런 현상에 의문을 가질 수 있다. 서버는 데이터를 한번에 40바이트를 전송했는데, 클라이언트가 이를 여유 있게 조금씩 수신하니 말이다. 클라이언트가 10바이트만 먼저 수신했다면, 서버가 보낸 나머지 30바이트는 어디서 대기하고 있는 것일까?

 

사실 write 함수가 호출되는 순간이 데이터가 전송되는 순간이 아니고, read 함수가 호출되는 순간이 데이터가 수신되는 순간이 아니다. 정확히 말하면 write 함수가 호출되는 순간 데이터는 출력버퍼로 이동하고, read 함수가 호출되는 순간 입력 버퍼에 저장된 데이터를 읽어 들이게 된다.

5-2 TCP 소켓의 입출력 버퍼

 

위 그림이 보이듯이 write 함수가 호출되면 출력 버퍼라는 곳에 데이터가 전달되어서 상황에 맞게 적절히(한번에 보내든 나눠서 보내든) 데이터를 상대방의 입력버퍼로 전송한다. 그러면 상대방은 read 함수 호출을 통해 입력버퍼에 저장된 데이터를 읽게 된다. 이러한 입출력 버퍼의 특성 몇 가지를 정리하면 다음과 같다.

 

- 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재한다.

- 입출력 버퍼는 소켓 생성시 자동으로 생성된다.

- 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이뤄진다.

- 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸된다.

 

클라이언트의 입력버퍼 크기가 50바이트 인데, 서버에서 100바이트를 전송하면 어떤 상황이 발생할까?

이때는 입력버퍼의 크기를 초과하는 분량의 데이터 전송을 발생하지 않는다. 고 답할 수 있다.

 

지금 이 고민하는 상황은 일어나지 않는데, 왜냐하면 TCP가 데이터의 흐름까지 컨트롤하기 때문이다.

TCP에는 '슬라이딩 윈도우(Sliding Window)' 라는 프로토콜이 존재한다.

이 프로토콜의 역할을 대화로 표현하면 다음과 같다.

이렇듯 서로 대화를 주고받으면서 데이터를 송수신하기 때문에, 버퍼가 차고 넘쳐서 데이터가 소멸되는 일이 TCP에서는 발생하지 않는다.

 

 

write 함수가 반환되는 시점

write 함수가, 그리고 윈도우의 send 함수가 반환되는 시점은 상대 호스트로 데이터의 전송이 완료되는 시점이 아닌, 전송할 데이터가 출력버퍼로 이동이 완료되는 시점이다. 그러나 TCP의 경우 출력버퍼로 이동된 데이터의 전송을 보장하기 때문에 "write 함수는 데이터의 전송이 완료되어야 반환이 된다."라고 표현한다.

 

 

TCP 내부 동작원리 1 : 상대 소켓과의 연결

TCP 소켓의 생성에서 소멸의 과정까지 거치게 되는 일을 크게 나누면, 다음 3가지로 구분할 수 있다.

- 상대 소켓과의 연결

- 상대 소켓과의 데이터 송수신

- 상대 소켓과의 연결 종료

 

그럼 먼저 상대 소켓과의 연결이 어떻게 이뤄지는지 알아보자.

연결설정 과정에서 두 소켓이 주고받는 대화의 내용을 간단히 정리하면 다음과 같다.

소켓의 대화

 

실제로 TCP 소켓은 연결설정 과정에서 총 3번의 대화를 주고 받는다. 그래서 이를 가리켜 쓰리웨이 핸드쉐이킹(Three-way handshaking)이라 한다. 즉, 3번 악수 했다는 의미인데, 그럼 연결 설정 과정에서 주고받는 메시지의 형태를 다음 그림을 통해 실제 형태를 지켜보자.

5-3 TCP 소켓의 연결설정 과정

 

소켓은 전 이중(Full-duplex) 방식으로 동작하므로 양방향으로 데이터를 주고 받을 수 있다.

따라서 데이터 송수신에 앞서 준비과정이 필요하다. 먼저 연결 요청을 하는 호스트 A가 호스트 B에게 다음 메세지를 전달한다.

 

[SYN] SEQ : 1000, ACK : -

 

이는 SEQ가 1000, ACK는 비어있음을 뜻하는데, 여기서 SEQ 1000이 의미하는 바는 다음과 같다.

 

이는 처음 연결요청에 사용되는 메시지이기 때문에 이 메시지를 가리켜 SYN이라 한다. 그리고 SYN은 Synchronization의 줄임 말로써, 데이터 송수신에 앞서 전송되는 '동기화 메세지'라는 의미를 담고 있다.

 

이어서 호스트 B가 호스트 A에게 다음 메세지를 전달하고 있다.

 

[SYN+ACK] SEQ : 2000, ACK : 1001

 

이는 SEQ가 2000, ACK가 1001임을 뜻하는데, 여기서 SEQ 2000이 의미하는 바는 다음과 같다.

 

 

그리고 ACK 1001이 의미하는 바는 다음과 같다.

 

 

즉, 처음 호스트 A가 전송한 패킷에 대한 '응답 메세지(ACK 1001)'와 함께 호스트 B의 데이터 전송을 위한 '동기화 메시지(SEQ 2000)'를 함께 묶어서 보내고 있다. 그래서 이러한 유형의 메시지를 가리켜 SYN+ACK라 한다.

 

이렇듯 데이터의 송수신에 앞서, 송수신에 사용되는 패킷에 번호를 부여하고, 이 번호정보를 상대방에게 알리는 이유는 데이터의 손실을 막기 위함이다. 이렇게 패킷에 번호를 부여해서 확인하는 절차를 거치기 때문에 손실된 데이터의 확인 및 재전송이 가능한 것이고, 때문에 TCP는 손실 없는 데이터의 전송을 보장하는 것이다.

 

그럼 마지막으로 호스트 A가 호스트 B에게 전송한 메세지를 살펴보자

 

[ACK] SEQ : 1001, ACK : 2001

 

이미 앞서 한차례씩 송수신한 패킷에서 보였듯이 TCP의 연결과정에서 패킷을 보낼때에는 항상 번호를 부여한다.

그래서 SEQ 1001이 부여되었다. 앞서 보낸 패킷의 SEQ가 1000이었으니, 이번에는 이보다 1이 증가한 1001이 부여된 것이다. 그리고 이 패킷은 다음의 메시지 전달을 목적으로 전송되었다.

 

"좀 전에 전송한 SEQ가 2000인 패킷은 잘 받았으니, 다음 번에는 SEQ가 2001인 패킷을 전송하기 바란다!"

 

때문에 ACK 2001이 추가된 형태의 ACK 메세지가 전송되었다. 이로써 호스트 A, 호스트 B 상호간에 데이터 송수신을 위한 준비가 모두 되었음을 서로 인식하게 되었다.

 

 

TCP의 내부 동작 원리 2 : 상대 소켓과의 데이터 송수신

처음 진행한 Three-way handshaking을 통해 데이터의 송수신 준비가 끝났으니, 이제 본격적으로 데이터를 송수신할 차례가 되었다. 데이터 송수신의 기본방식은 다음과 같다.

5-4 TCP 소켓의 데이터 송수신 과정

 

위 그림은 호스트 A가 호스트 B에게 총 200바이트를 두 번에 나눠서(두 개의 패킷에 나눠서) 전송하는 과정을 보인 것이다. 먼저 호스트 A가 100바이트의 데이터를 하나의 패킷에 실어 전송하였는데, 패킷의 SEQ를 1200으로 부여하고 있다.

때문에 호스트 B는 이를 근거로 패킷이 제대로 수신되었음을 알려야하기에, ACK 1301 메시지를 담은 패킷을 호스트 A에 전송하고 있다.

 

이때 ACK 번호가 1201이 아닌 1301인 이유는 ACK 번호를 전송된 바이트 크기만큼 추가로 증가시켰기 때문이다.

이렇듯 ACK 번호를 전송된 바이트 크기만큼 추가로 증가시키지 않으면, 패킷의 전송은 확인할 수 있을지 몰라도, 패킷에 담긴 100바이트가 전부 전송되었는지, 아니면 그 중 일부가 손실되고 80바이트만 전송되었는지 알 방법이 없을지도 모른다. 그래서 다음의 공식을 기준으로 ACK 메세지를 전송한다.

 

ACK 번호 -> SEQ 번호 + 전송된 바이트 크기 + 1

 

마지막에 1을 더한 이유는 Three-way handshaking에서도 보였듯이, 다음 번에 전달될 SEQ의 번호를 알리기 위함이다. 그럼 이번에는 중간에 패킷이 소멸되는 상황을 보이겠다. 

5-5 TCP 소켓의 데이터 송신 오류

위 그림은 SEQ 1301인 패킷에 100바이트 데이터를 실어서 호스트 B로 전송되고 있음을 보이고 있다.

그런데 중간에 문제가 발생해서 호스트 B에 전송되지 못했다. 이러한 경우 호스트 A는 일정시간이 지나도 SEQ 1301에 대한 ACK 메세지를 받지 못하기 때문에 재전송을 진행한다. 이렇듯 데이터의 손실에대한 재전송을 위해서, TCP 소켓은 ACK 응답을 요구하는 패킷 전송 시에 타이머를 동작시킨다.

그리고 해당 타이머가 Time-out 되었을때 패킷을 전송한다.

 

 

TCP의 내부 동작원리 3 : 상대 소켓과의 연결 종료

TCP 소켓은 연결종료도 매우 우아하게(?) 진행한다. 그냥 연결을 뚝 끊어버리면, 상대방이 전송할 데이터가 남아있을때 문제가 되기 때문에 상호간의 연결종료의 합의 과정을 거치게 된다. 다음은 연결종료 과정에서 주고 받는 메세지를 대화로 표현한 것이다.

 

먼저 소켓 A가 종료 메세지를 소켓 B에게 전달하고, 소켓 B는 해당 메세지의 수신을 소켓 A에게 알린다.

그리고 이어서 소켓 B가 종료 메세지를 소켓 A에게 전달하고, 소켓 A는 해당 메세지의 수신을 소켓 B에게 알리며 종료과정을 마치게 된다.

5-6 TCP 소켓의 연결종료 과정

 

위 그림 안에서 패킷 안에 삽입되어 있는 FIN은 종료를 알리는 메세지를 뜻한다.

즉, 상호간에 FIN 메세지를 한번씩 주고 받고서 연결이 종료되는데, 이 과정이 네 단계에 걸쳐서 진행된다.

이를 가리켜 Four-way handsahking이라고 부른다. 그리고 SEQ와 ACK의 의미는 앞서 설명한 내용과 다르지 않다.

 

다만 ACK 5001이 호스트 A에 두 번 전달된 것이 이상하게 생각될 수 있다. 그러나 FIN 메세지에 포함된 ACK 5001은 앞서 전송한 ACK 메세지가 수신된 이후로 데이터 수신이 없었기 때문에 재전송된 것이다.

 

지금까지 TCP 프로토콜의 기본이 되는 "TCP 흐름제어(Flow Control)'를 조금 설명하였는데, 이 내용이 앞서 언급한 TCP의 데이터 전송특성을 이해하는데 도움이 될 것이다.

반응형