기존 클라이언트 1 에서 에코 클라이언트의 문제점이 발생하였다.
Serverwhile((str_len = read(clnt_sock, msg, BUF_SIZE)) != 0) write(clnt_sock, msg, str_len);
- 서버는 데이터의 경계를 구분하지 않고 수신된 데이터를 그대로 전송할 의무만 갖는다.
TCP가 본래 데이터의 경계가 없는 프로토콜이므로, 두 번의 write 함수 호출을 통해서 데이터를 전송하건, 세 번의 write 함수 호출을 통해서 데이터를 전송하건, 문제가 되지 않는다.
Clientwrite(sock, msg, strlen(msg)); str_len = read(sock, msg, BUF_SIZE-1);
- 반면, 클라이언트는 문장 단위로 데이터를 송수신하기 때문에, 데이터의 경계를 구분해야 한다.
때문에 이와 같은 데이터 송수신 방식은 문제가 된다.
TCP의 read & write 함수호출은 데이터의 경계를 구분하지 않기 때문이다.
- 즉, client의 문제점은 write 함수 호출을 통해서 한 방에 전송하고 read 함수 호출을 통해서 한 방에 수신받기를 원하는 것이다.
문제점 개선
해당 코드를 개선하기 위한 문제점은 굉장히 쉽다.
echo 임을 기억하자 ! client가 송신한 문자열을 server를 통해서 다시 수신 받는다.
즉, 본인이 송신하기 때문에 수신할 문자열의 크기를 알고 있다.
그렇다면 송신한 문자열의 크기만큼 while문을 통해 한 글자씩 받으면 된다.
str_len = write(sock, msg, strlen(msg));
recv_len = 0;
while(recv_len < str_len)
{
recv_cnt = read(sock, &msg[recv_len], BUF_SIZE-1);
if(recv_cnt == -1)
error_handling("read() error!");
recv_len += recv_cnt;
}
msg[recv_len] = 0;
printf("Message from server : %s", message);
write 함수 호출을 통해서 전송한 데이터의 길이만큼 읽어 들이기 위한 반복문의 삽입이 필요하다.
이것이 TCP를 기반으로 데이터를 구분지어 읽어 들이는데 부가적으로 필요한 부분이다.
에코 클라이언트 이외의 경우에는? Application Protocol
에코 클라이언트의 경우에는 수신할 데이터의 크기를 미리 파악할 수 있었기 때문에 쉽게 개선할 수 있었다.
하지만 에코가 아닌 경우가 훨씬 많음을 인지하자. 그렇다면 수신할 데이터의 크기를 파악이 불가능할 것이다.
이러한 경우에는 데이터를 어떻게 송수신 해야할까 ?
이럴 때 필요한 게 어플리케이션 프로토콜의 정의이다.
이전에 구현한 에코 서버와 클라이언트는 다음의 프로토콜을 정의하였다.
"Q가 입력되어 전달되면 연결을 종료한다."
이와 같이 데이터의 송수신 과정에서도 데이터의 끝을 파악할 수 있는 약속(프로토콜)을 정의하여 데이터의 끝을 표현하거나, 송수신될 데이터의 크기를 미리 알려주는 것이 중요하다.
서버와 클라리언트의 구현과정에서 이러한 약속들을 모아서 Application Protocol이라고 한다
이를 바탕으로 밑에서는 클라이언트에서 입력한 수식을 서버에서 연산자를 토대로 계산하는 프로토콜을 정의하여 구현해보겠다.
계산기 서버 클라이언트의 예 Calculator Server/Client
구현에 앞서 미리 요구사항(프로토콜)을 정의하겠다.
예제 구현에 필요한 최소한의 프로토콜 정의이다.
1. 클라이언트는 서버에 접속하자마자 피연산자의 개수정보를 1byte 정수형태로 전달한다.
2. 클라이언트는 서버에 전달하는 정수 하나는 4byte로 표현한다.
3. 정수를 전달한 다음에는 연산의 종류를 전달한다. 연산정보는 1byte로 전달한다.
4. 문자 +, -, * 중 하나를 선택해서 전달한다.
5. 서버는 연산결과를 4바이트 정수의 형태로 클라이언트에게 전달한다.
6. 연산결과를 얻은 클라이언트는 서버와의 연결을 종료한다.
Calculator Client
#include <다른 예제의 헤더선언과 동일함으로 생략>
#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("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)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
#define RLT_SIZE 4
#define OPSZ 4
피연산자의 바이트 수와 연결결과의 바이트 수를 상수화했다.
int opmsg[BUF_SIZE];
데이터의 송수신을 위한 메모리 공간은 이렇듯 배열을 기반으로 생성하는 것이 좋다. 데이터를 누적해서 송수신해야하기 때문.
scanf("%d", &opnd_cnt);
opmsg[0]=(char)opnd_cnt;
프로그램 사용자로부터 피연산자의 개수정보를 입력 받은 후 이를 배열 opmsg에 저장하고 있다 char형으로의 형변환은 피연산자의 개수정보를 1바이트 정수형태로 전달한다. 라고 정의한 프로토콜에 근거한 형변환이다. 때문에 1바이트로 표현 불가능한 범위의 정수가 입력되면 안 된다. 참고로 여기서는 부호 있는 정수의 형태로 예제를 작성하였지만, 피연산자의 개수정부는 음수가 될 수 없으므로 부호 없는 양의 정수형태로 예제를 작성하는 것이 보다 합리적이다.
for(i=0; i<opnd_cnt; i++)
{
printf("Operand %d: ", i+1);
scanf("%d", (int*)&opmsg[i*OPSZ+1]);
}
프로그램 사용자로부터 정수를 입력 받아서 배열 opmsg에 이어서 저장하고 있다 char 형 배열에 4바이트 int형 정수를 저장하기 때문이 int형 포인터로 형변환을 하고 있다. 만약에 이 부분이 이해가되지 않는다면 포인터에 대한 별도의 학습이 필요한 상태임을 인식하기 바란다.
fgetc(stdin);
문자를 입력 받아야 하는데 이에 앞서 버퍼에 남아있는 \n 문자의 삭제를 위해 fgetc함수가 호출되고 있다.
scanf("%c", &opmsg[opnd_cnt*OPSZ+1]);
마지막으로 연산자 정보를 입력 받아서 배열 opmsg에 저장하고 있다.
write(sock, opmsg, opnd_cnt*OPSZ+2);
드디어 write 함수호출을 통해서 opmsg에 저장되어 있는 연산과 관련된 정보를 한방에 전송하고 있다. 이렇듯 한번의 write함수 호출을 통해서 묶어서 보내도 되고 여러 번의 write 함수호출을 통해서 나눠서 보내도 된다. 여러 차례 강조했듯이 TCP는 데이터의 경계가 존재하지 않기 때문.
read(sock, &result, RLT_SIZE);
서버가 전송해주는 연산결과의 저장과정을 보이고 있다. 수신할 데이터의 크기가 4바이트이기 때문에 이렇게 한번의 read 함수 호출로 충분히 수신이 가능하다.
Calculator Server
#include <"다른 예제의 헤더선언과 동일하므로 생략>
#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);
}
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);
}
- 5개의 클라이언트 연결 요청을 수용하기 위해 for문이 구성되었다.
- 제일 먼저 피연산자의 개수정보를 수신하고 있다.
- 위에서 확인한 피연산자의 개수정보를 바탕으로 피연산자 정보를 수신하고 있다.
- calculate 함수를 호출하면서, 피연산자의 정보와 연산자 정보를 인자로 전달하고 있다.
- calculate 함수가 반환한 연산결과를 클라이언트에게 전송하고 있다.
TCP의 이론적인 이야기
블로그를 처음부터 보았으면 알테지만, TCP 소켓의 데이터 송수신에는 경계가 없음을 수차례 언급하였다.
따라서 서버가 한 번의 write 함수 호출을 통해서 50 byte를 송신해도 클라이언트는 5번의 read 호출을 통해서 10byte씩 수신하는 것이 가능하다.
여기서 의문점이 생긴다.
서버는 한 번에 50 byte를 송신하는데 client는 10byte씩 수신한다면 남는 40byte는 어디서 머무는 걸까 ?
사실 write 함수가 호출되는 순간이 데이터가 전송되는 순간이 아니고,
read 함수가 호출되는 순간이 데이터가 수신되는 순간이 아니다.
정확히 말하면 write 함수가 호출되는 순간 데이터는 출력 버퍼로 이동을 하고
read 함수가 호출되는 순간 입력 버퍼에 저장된 데이터를 읽어 들이게 된다.
이와 같은 버퍼가 존재하기 떄문에 데이터의 슬라이딩 원도우 프로토콜의 적용이 가능하고 이로 인해서 버퍼가 차고 넘치는 상황은 발생하지 않는다.
위 그림에 보이듯이 write 함수가 호출되면 출력버퍼라는 곳에 데이터가 전달되어서 상황에 맞게 적절히 데이터를 상대방의 입력 버퍼로 전송한다.
그러면 상대방은 read 함수 호출을 통해서 입력버퍼에 저장된 데이터를 읽게 된다.
결론은 다음과 같다.
- 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재한다.
- 입출력 버퍼는 소켓 생성시 자동으로 생성된다.
- 소켓을 닫아도 출력 버퍼에 남아있는 데이터는 계속해서 전송이 이뤄진다.
- 소켓을 닫으면 입력 버퍼에 남아있는 데이터는 소멸되어 버린다.
여기서 궁금증이 발생한다.
만약 클라이언트의 입력버퍼가 50byte라고 가정하자.
서버에서 write 함수로 100byte를 전송하면 어떤 일이 발생하겠는가 ?
결론부터 말하자면 "입력 버퍼의 크기를 초과하는 분량의 데이터 전송은 발생하지 않는다"
위와 같은 상황은 절대 발생하지 않는다.
TCP는 데이터의 흐름까지 컨트롤하기 때문이다.
TCP는 슬라이딩 윈도우(Sliding Window)라는 프로토콜이 존재한다.
이렇듯 서로 대화를 주고 받으면서 데이터를 송수신하기 때문에 버퍼가 차고 넘쳐서 데이터가 소멸되는 일이
TCP에서는 발생하지 않는다.
※버퍼란?
write 함수 그리고 window의 send 함수가 반환되는 시점은 상대 호스트로 데이터의 전송이 완료되는 시점이 아니라 전송할 데이터가 출력 버퍼에 이동이 완료되는 시점이다.
하지만 TCP의 경우에는 출력버퍼로 이동된 데이터의 전송을 보장하기 때문에
"Write 함수는 데이터의 전송이 완료되어야 반환이 된다" 라고 표현한다.
TCP의 내부동작 원리1. 상대 소켓과의 연결
TCP 소켓의 생성에서 소멸의 과정까지 거치게 되는 일을 크게 나누면 3가지로 구분이 된다.
1. 상대 소켓과의 연결
2. 상대 소켓과의 데이터 송수신
3. 상대 소켓과의 연결 종료
상대 소켓과의 연결
실제로 TCP 소켓은 연결 과정에서 총 3번의 대화를 주고 받는다.
이를 가리켜 Three-way handshaking이라고 한다.
상대 소켓과의 데이터 송수신
상대 소켓과의 연결 종료
'스터디 > TCP 와 IP' 카테고리의 다른 글
7. 도메인 이름과 인터넷 주소 (0) | 2024.09.03 |
---|---|
6. 소켓의 우아한 연결 종료 (0) | 2024.09.03 |
4. TCP 기반 서버/ 클라이언트 1 (0) | 2024.09.03 |
3. 주소체계와 데이터 정렬 (1) | 2024.09.03 |
2. 소켓의 타입과 프로토콜의 설정 (0) | 2024.09.02 |