[소켓 #05] [이론] TCP 기반 Server/Client

이석환·2023년 4월 16일

Socket Programming

목록 보기
6/18

1. Echo Client의 문제점

[소켓 #04]에서 에코 클라이언트의 문제점이 발생하였다.
Server

while((str_len = read(clnt_sock, msg, BUF_SIZE)) != 0)
	write(clnt_sock, msg, str_len);
  • 서버는 데이터의 경계를 구분하지 않고 수신된 데이터를 그대로 전송할 의무만 갖는다.
    TCP가 본래 데이터의 경계가 없는 프로토콜이므로, 두 번의 write 함수 호출을 통해서 데이터를 전송하건, 세 번의 write 함수 호출을 통해서 데이터를 전송하건, 문제가 되지 않는다.

    Client
write(sock, msg, strlen(msg));
str_len = read(sock, msg, BUF_SIZE-1);
  • 반면, 클라이언트는 문장 단위로 데이터를 송수신하기 때문에, 데이터의 경계를 구분해야 한다.
    때문에 이와 같은 데이터 송수신 방식은 문제가 된다.
    TCP의 read & write 함수호출은 데이터의 경계를 구분하지 않기 때문이다.
  • 즉, client의 문제점은 write 함수 호출을 통해서 한 방에 전송하고 read 함수 호출을 통해서 한 방에 수신받기를 원하는 것이다.

2. 문제점 개선

해당 코드를 개선하기 위한 문제점은 굉장히 쉽다.
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를 기반으로 데이터를 구분지어 읽어 들이는데 부가적으로 필요한 부분이다.

3. Application Protocol

에코 클라이언트의 경우에는 수신할 데이터의 크기를 미리 파악할 수 있었기 때문에 쉽게 개선할 수 있었다.
하지만 에코가 아닌 경우가 훨씬 많음을 인지하자. 그렇다면 수신할 데이터의 크기를 파악이 불가능할 것이다.
이러한 경우에는 데이터를 어떻게 송수신 해야할까 ?
이럴 때 필요한 게 어플리케이션 프로토콜의 정의이다.
이전에 구현한 에코 서버와 클라이언트는 다음의 프로토콜을 정의하였다.
"Q가 입력되어 전달되면 연결을 종료한다."

이와 같이 데이터의 송수신 과정에서도 데이터의 끝을 파악할 수 있는 약속(프로토콜)을 정의하여 데이터의 끝을 표현하거나, 송수신될 데이터의 크기를 미리 알려주는 것이 중요하다.
서버와 클라리언트의 구현과정에서 이러한 약속들을 모아서 Application Protocol이라고 한다.

이를 바탕으로 밑에서는 클라이언트에서 입력한 수식을 서버에서 연산자를 토대로 계산하는 프로토콜을 정의하여 구현해보겠다.

4. Calculator Server/Client

구현에 앞서 미리 요구사항(프로토콜)을 정의하겠다.
1. 클라이언트는 서버에 접속하자마자 피연산자의 개수정보를 1byte 정수형태로 전달한다.
2. 클라이언트는 서버에 전달하는 정수 하나는 4byte로 표현한다.
3. 정수를 전달한 다음에는 연산의 종류를 전달한다. 연산정보는 1byte로 전달한다.
4. 문자 +, -, * 중 하나를 선택해서 전달한다.
5. 서버는 연산결과를 4바이트 정수의 형태로 클라이언트에게 전달한다.
6. 연산결과를 얻은 클라이언트는 서버와의 연결을 종료한다.

Format

하나의 배열에 다양한 종류의 데이터를 저장해서 전송하기 위해 char형 배열을 선언하였다.

4-1. Calculator Server

#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 calculate(int opnum, int opnds[], char op); 

int main(int argc, char* argv[]){
	int serv_sock;
	int clnt_sock;
	int opmsg[BUF_SIZE];
	int op_len = 0;
	char op;
	int result, opnd_cnt, i;
	struct sockaddr_in serv_addr;
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_size;
	
	if(argc!=2){
		printf("Usage : %s <IP> <port>\n",argv[0]);
		exit(1);
	}

	serv_sock = socket(PF_INET, SOCK_STREAM, 0);
	if(serv_sock == -1)
		error_handling("Socket() error");
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	serv_addr.sin_port = htons(atoi(argv[1]));

	if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
		error_handling("bind() error");

	if(listen(serv_sock,5)==-1)
		error_handling("listen() error");
	clnt_addr_size = sizeof(clnt_addr);
	
	for(int i = 0; i< 5; i++)
	{
		int op_len = 0;
		opnd_cnt = 0;
		clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
		if(clnt_sock == -1)
			error_handling("accept() error");
	
		read(clnt_sock, &opnd_cnt, 1);
		
		while(op_len < opnd_cnt)
			read(clnt_sock, &opmsg[op_len++], sizeof(int));

		read(clnt_sock, &op, sizeof(char));
		result = calculate(opnd_cnt, opmsg, op);

		write(clnt_sock, &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];
		case '*':
			for(i = 1; i < opnum; i++)
				result *= opnds[i];
	}
	return result;
}

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

4-2. Calculator Client

#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("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);
}
  • 필자는 Server <-> Client 에서의 데이터 전송과정을 교재와 다르게 코딩하였다.
    교재에 코드는 이해는 가는데 수식에 인덱싱할 때 변수로 접근하는 부분이 헷갈려서 다시 코딩하였다.
    내가 다시 보았을 때 이해할 수 있게 다시 작성하였다.
    데이터 전송과정을 제외하고는 거의 차이가 없다.

5. 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 함수는 데이터의 전송이 완료되어야 반환이 된다" 라고 표현한다.

6. TCP의 내부동작 원리

TCP 소켓의 생성에서 소멸의 과정까지 거치게 되는 일을 크게 나누면 3가지로 구분이 된다.
1. 상대 소켓과의 연결
2. 상대 소켓과의 데이터 송수신
3. 상대 소켓과의 연결 종료

해당 동작 원리는 Data Communcation 과목에서 상세하게 배워서 자세히 기술하지는 않겠다.
이해가 안 가신다면 데이터 통신을 공부하고 오는 걸 추천한다.

6-1. 상대 소켓과의 연결

  • 실제로 TCP 소켓은 연결 과정에서 총 3번의 대화를 주고 받는다.
    이를 가리켜 Three-way handshaking이라고 한다.

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

6-3. 상대 소켓과의 연결 종료

참고 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍
Git : https://github.com/im2sh/Socket_Programming/tree/main/lab04

profile
반갑습니다.

0개의 댓글