본문 바로가기
프로그래밍/C

윈도우 소켓 ] TCP 에코 서버, 클라이언트 및 동작원리

by eteo 2023. 1. 1.

 

 

TCP 서버의 함수 호출 순서

 

TCP 서버에서 listen 함수호출을 통해 연결요청 대기상태에 들어가면 클라이언트가 connect() 함수호출을 통해서 연결요청을 할 수 있다.

 

 

int listen(SOCKET s, int backlog);
  • s : 연결요청 대기상태에 두고자 하는 소켓 전달. 이 함수 인자로 전달된 소켓이 서버 소켓(리스닝 소켓)이 된다.
  • backlog : 연결요청 대기 큐의 크기정보 전달, ex. 5가 전달되면 클라이언트의 연결요청을 5개까지 대기시킬 수 있다. 적절한 값은 실험적 결과에 의존해 결정하면 되고 일반적으로 웹서버와 같이 잦은 연결요청을 받는 서버의 경우 15 이상을 전달한다.

 

 

listen 함수 호출이후 클라이언트의 연결요청이 들어왔다면, 들어온 순서대로 연결요청을 수락하고 클라이언트와 데이터를 주고받을 수 있는 상태가 되는데, 데이터를 주고받기 위해서는 소켓이 필요하다.

서버소켓은 문지기 역할을 하는 소켓이니까 클라이언트와 데이터 송수신을 위해서는 연결요청 수락과 함께 소켓을 하나 더 만들어야 한다.

아래 accept() 함수호출을 통해 클라이언트와 송수신을 위한 소켓이 만들어지고 연결요청을 한 클라이언트 소켓과 자동으로 연결된다.

SOCKET accept(SOCKET s, struct sockaddr *addr, int *addrlen);
  • s : 서버 소켓
  • addr : 연결요청한 클라이언트의 주소정보를 담을 구조체 변수의 주소 값 전달. 함수호출 이후 두번째 변수에 클라이언트 주소 정보가 담긴다.
  • addrlen : 두번째 매개변수의 크기를 바이트 단위로 int 변수에 저장한 다음 주소를 전달. 호출 이후에는 클라이언트 주소정보 길이가 채워진다.

 

 

TCP 클라이언트의 함수 호출 순서

 

❗ 네트워크를 통해 데이터를 송수신하려면 IP와 Port가 반드시 할당되어야 하는데 서버 소켓과 달리 클라이언트 소켓은 bind() 과정을 거치지 않는다. 그럼 언제 어떻게 할당했던 것일까?

 

✔ connect() 함수가 호출될 때 커널(운영체제)에서 IP는 컴퓨터에 할당된 IP로 Port는 임의로 선택해서 할당해주기 때문에 클라이언트 프로그램을 구현할 때는 bind() 함수를 명시적으로 호출할 필요가 없다.

 

 

✔ connect() 함수가 호출되면 다음 둘 중 한가지 상황이 되어야만 함수가 반환된다.

1. 서버에 의해 연결요청이 접수되었다. (서버의 연결요청 대기 큐에 등록된 상황을 의미하는 것이기 때문에 connect 함수가 반환되었더라도 당장 서비스가 이뤄지지 않을수도 있다.)

2. 네트워크 단절 등 오류상황이 발생해서 연결요청이 중단되었다.

 

int connect(SOCKET s, const struct sockaddr *name, int namelen);
  • s : 클라이언트 소켓
  • name : 연결할 서버 주소정보를 담은 구조체변수의 주소값 전달
  • namelen : 두번째 매개변수에 전달된 변수 크기

 

 

윈도우 기반 입출력 함수

int send(SOCKET s, const char * buf, int len, int flags);
  • s : 데이터 전송 대상과 연결을 의미하는 소켓 핸들 전달
  • buf : 전송할 데이터를 저장하고 있는 버퍼의 주소값 전달
  • len : 전송할 바이트 수 전달
  • flags : 데이터 전송 시 적용할 다양한 옵션정보 전달
int recv(SOCKET s, char * buf,  int len, int flags);
  • s : 데이터 수신 대상과 연결을 의미하는 소켓 핸들 전달
  • buf : 수신할 데이터를 저장할 버퍼의 주소값 전달
  • len : 수신할 수 있는 최대 바이트 수 전달
  • flags : 데이터 수신 시 적용할 다양한 옵션정보 전달

 

 

함수호출 관계

서버가 listen 함수 호출한 이후에 클라이언트의 connect 함수호출이 유효하다. 그리고 클라이언트가 connect 함수호출하기에 앞서 서버가 먼저 accept 함수를 호출할 수 있다. 물론 이때는 클라이언트가 connect 함수를 호출할 때까지 서버는 accept 함수가 호출된 위치에서 블로킹 상태에 놓이게 된다.

 

 

 

아래 Iterative 에코 서버 모델은 반복적으로 accept 함수를 호출하고 accept 된 소켓을 대상으로 read/write 후에 close 함수를 호출해 한 클라이언트에 대한 서비스를 완료한다. 그 후 계속해서 클라이언트의 요청을 수락할 수 있지만, 쓰레드를 사용하지 않기 때문에 동시에 여러 클라이언트에게 서비스를 제공할 수 있는 모델은 아니다.

 

 

TCP 에코 서버

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <WinSock2.h>

#define BUF_SIZE	1024
void ErrorHandling(char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;

	SOCKET hServSock, hClntSock;
	char message[BUF_SIZE];
	int strLen, i;

	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)
		ErrorHandling("WSAStartup() error!");

	hServSock = socket(PF_INET, SOCK_STREAM, 0);
	if(hServSock == INVALID_SOCKET)
		ErrorHandling("socket() error!");

	memset(&servAdr, 0, sizeof(servAdr));	// 구조체 멤버 sin_zero를 0으로 초기화 하기 위해 전부 0으로 초기화함
	servAdr.sin_family = AF_INET;
	servAdr.sin_addr.s_addr = htonl(INADDR_ANY);
	servAdr.sin_port = htons(atoi(argv[1]));

	if(bind(hServSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
		ErrorHandling("bind() error!");

	if(listen(hServSock, 5) == SOCKET_ERROR)
		ErrorHandling("listen() error!");

	clntAdrSize = sizeof(clntAdr);

	for (i = 0; i < 5; i++)
	{
		hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &clntAdrSize);
		if (hClntSock == -1)
			ErrorHandling("accpet() error!");
		else
			printf("Connected client %d \n", i + 1);

		while ((strLen = recv(hClntSock, message, BUF_SIZE, 0)) != 0)
			send(hClntSock, message, strLen, 0);
		closesocket(hClntSock);
	}
	
	closesocket(hServSock);
	WSACleanup();

	return 0;
}

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

 

 

 

 

TCP 에코 클라이언트

#include <stdio.h>
#include <string.h>
#include <WinSock2.h>

#define BUF_SIZE	1024
void ErrorHandling(char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;

	SOCKET hSocket;
	char message[BUF_SIZE];
	int strLen;

	SOCKADDR_IN servAdr;

	if (argc != 3)
	{
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
		ErrorHandling("WSAStartup() error!");

	hSocket = socket(PF_INET, SOCK_STREAM, 0);
	if (hSocket == INVALID_SOCKET)
		ErrorHandling("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(hSocket, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
		ErrorHandling("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;

		send(hSocket, message, strlen(message), 0);
		strLen = recv(hSocket, message, BUF_SIZE - 1, 0);
		message[strLen] = 0;
		printf("Message from Server: %s", message);
	}

	closesocket(hSocket);
	WSACleanup();
	return 0;
}

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

 

 

 

다만 위 코드에는 문제가 있다.

송수신 데이터 크기가 작고 하나의 컴퓨터 또는 근거리에서 테스트 하다보니 오류가 발생하지 않은것 뿐이다.

TCP는 데이터의 경계가 존재하지 않으므로 클라이언트가 둘 이상의 write 함수호출로 전달한 문자열 정보를 묶어 한번에 서버로 전송할 수도 있고, 클라이언트가 한번의 write 함수 호출로 전송한 문자열을 두 개의 패킷에 나눠 전송하는 상황도 가정할 필요가 있다.

 

에코 서버의 입출력

		while ((strLen = recv(hClntSock, message, BUF_SIZE, 0)) != 0)
			send(hClntSock, message, strLen, 0);

에코 클라이언트의 입출력

		send(hSocket, message, strlen(message), 0);
		strLen = recv(hSocket, message, BUF_SIZE - 1, 0);

 

에코서버는 데이터의 경계를 구분하지 않고 수신된 데이터를 그대로 전송할 의무만 갖는다. 반면 클라이언트는 문장 단위로 데이터를 송수신하기 때문에 데이터의 경계를 구분해야 한다. 때문에 에코 클라이언트의 코드는 문제가 된다.

 

 

 

 

 

에코 클라이언트의 경우 간단한 해결이 가능하다. 수신할 데이터의 크기를 미리 알고 있기 때문에 그만큼 수신할 때까지 반복해서 recv 함수를 호출하면 된다.

	int strLen, recvLen, recvCnt;

//...

	while (1)
	{
		fputs("Input message(Q to quit): ", stdout);
		fgets(message, BUF_SIZE, stdin);

		if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
			break;

		strLen = send(hSocket, message, strlen(message), 0);

		recvLen = 0;
		while (recvLen < strLen)
		{
			recvCnt = recv(hSocket, &message[recvLen], BUF_SIZE - 1, 0);
			if(recvCnt == -1)
				ErrorHandling("read() error!");
			recvLen += recvCnt;
		}
		message[strLen] = 0;
		printf("Message from Server: %s", message);
	}

 

 

이 외의 경우에는 어플리케이션 프로토콜을 정의하는 것이 필요하다.

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

 

 

 

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

read 함수가 호출되는 순간이 데이터가 수신되는 순간이 아니라 입력버퍼에 저장된 데이터를 읽어들이는 것이고, write 함수가 호출되는 순간이 데이터가 전송되는 순간이 아니라 데이터를 출력버퍼로 이동시키는 것이다.

  • 입출력 버퍼는 TCP 소켓 각각에 대해 별도로 존재한다.
  • 입출력 버퍼는 소켓 생성시 자동으로 생성된다.
  • 소켓을 닫아도 출력버퍼에 남아있는 데이터는 계속해서 전송이 이뤄진다.
  • 소켓을 닫으면 입력버퍼에 남아있는 데이터는 소멸되어버린다.

 

TCP에는 Sliding Window 라는 프로토콜이 존재기 때문에 입력 버퍼의 여유분 크기를 초과하는 전송은 이뤄지지 않는다.

 

 

 

 

 

Three way handshaking : 연결

TCP 소켓은 연결설정 과정에서 총 세번의 대화를 주고 받기 때문에 Three way handshaking 이라고 한다.

1단계 SYN

2단계 SYN+ACK

3단계 ACK

연결요청을 하는 클라이언트가 아래 메시지를 전달한다

[SYN] SEQ: 100, ACK: -

 

SEQ 100이 의미하는 바는 지금 보내는 패킷에 시퀀스 100이라는 번호를 부여하니 잘 받았다면 다음에는 101번 패킷을 전달하라고 얘기해달라는 것이다.

그리고 SYN은 Synchronization의 줄임말로 데이터 송수신에 앞서 전송되는 동기화 메시지 라는 의미가 담겨있다.

 

이어서 서버가 클라이언트에게 아래 메시지를 전달한다.

[SYN+ACK] SEQ: 200, ACK:101

 

여기서 시퀀스 번호 SEQ가 의미하는 바는 위와 같고 ACK가 의미하는 바는 좀전에 전송한 SEQ가 100인 패킷은 잘 받았으니 다음번엔 SEQ가 101인 패킷을 전송해달라는 의미이다.

여기선 클라이언트가 처음 전송한 패킷에 대한 ACK와 함께 자신의 데이터 전송을 위한 동기화 SEQ 메시지도 함께 묶어 보내고 있다. 이런 메시지 유형을 SYN+ACK라고 한다.

마지막으로 클라이언트가 서버에게 아래 메시지를 전송한다.

[ACK] SEQ: 101, ACK: 201

 

이로써 클라이언트와 서버 상호간에 데이터 송수신을 위한 준비가 되었음을 서로 인식하게 되었다.

 

 

 

 

 

Three way handshaking 을 통해 데이터 송수신 준비가 끝나면 이제 아래와 같은 방식으로 데이터를 송수신한다.

 

이때 ACK의 값은 패킷의 전송 유무뿐 아니라 데이터의 손실 유무까지 확인하기 위해 다음 공식으로 ACK 메시지를 전송한다. 여기서 1을 더한 이유는 다음번에 전달된 SEQ 번호를 알리기 위함이다.

 

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

 

만약 아래와 같이 호스트A가 SEQ 1301인 패킷을 호스트B에 전송했는데 일정 시간 동안 ACK메시지를 받지 못했다면 재전송을 진행한다. 이렇듯 TCP는 패킷 전송 시 타이머을 동작하여 Time out 시간동안 ACK 응답을 받지 못했을 때 재전송을 한다.

 

 

Four-way handshaking : 연결 종료

일방적 종료로 인한 데이터 손실을 막기위해 다음의 과정을 거친다.

호스트A가 종료를 알리는 메시지인 FIN 플래그를 포함하여 호스트B에 전달하고 호스트B는 해당 메시지 수신을 알린 후 이어서 종료메시지를 호스트A에 전달하고 호스트A가 해당메시지 수신을 알리면서 연결 종료의 과정을 마치게 된다.

즉, 상호간에 FIN 메시지를 한번씩 주고받는데 이 과정이 네 단계에 걸쳐 진행되기 때문에 Four-way handshaking 이라고 부른다.

 

 

출처 : 윤성우의 열혈 TCP/IP 소켓 프로그래밍