나의 브을로오그으

#7. 소켓의 우아한 연결종료 본문

네트워크/열혈 TCP_IP 소켓프로그래밍

#7. 소켓의 우아한 연결종료

__jhp_+ 2022. 8. 3. 10:20

지금까지의 종료는 그저 close(), closesocket()함수를 호출하여 상대방의 의사와 상관 없이 일방적으로 연결을 끊었었다.

 

#1. TCP기반의 Half-close

TCP에서는 사실 연결 보다 연결 종료과정이 더 중요하다. 연결과정에서는 큰 변수가 발생하지 않지만 종료과정에서는 예상치 못한 일이 발생할 수 있기 때문이다.

 

일방적인 연결종료의 문제점

리눅스의 close(), 윈도우의 closesocket() 함수호출은 소켓의 완전종료를 의미한다.(송수신 불가)

양방향으로 통신하고 있는 두 호스트간에 한 호스트가 연결종료를 하면 데이터가 존재해도 수신측 호스트는 데이터 수신이 불가능하다. 이를 해결하기위해 송수신에 사용되는 스트림의 일부만 종료(half-close)하는 방법이 제공되고 있다.

(half-close는 송신 불가-수신 가능 또는 송신 가능-수신 불가 상태를 의미한다.)

 

소켓과 스트림(stream)

두 소켓이 성공적으로 연결이 되면 상호간에 데이터 송수신이 가능한 상태가 된다 .이러한 상태를 '스트림이 형성된 상태'라고 한다. 즉, 두 소켓이 연결되어서 데이터의 송수신이 가능한 상태를 일종의 스트림으로 보는 것이다.

스트림은 물의 흐름을 의미하면, 흐름은 한방향으로 흐른다. 

소켓은 양방향 통신이기 때문에 스트림이 두 개 필요하다. 때문에 두 호스트간에 소켓이 연결되면 호스트별로 입력 스트림과 출력 스트림이 형성된다.

(호스트 A 출력 스트림 - 호스트 B 입력 스트림, 호스트 A 입력 스트림 - 호스트 B 출력 스트림)

그리고 여기서 우아한 종료(half-stream)는 스트림을 둘다 끊어버리는 것이 아닌 하나의 스트림만 끊는 경우를 의미한다.

리눅스의 close(), 윈도우읜 closesocket()은 두 스트림을 모두 끊는 함수이므로 우아한 종료와 거리가 멀다.

 

우아한 종료를 위한 shutdown 함수

#include <sys/socket.h>

int shutdown(int sock, int howto);
-> 성공 시 0, 실패 시 -1 반환

sock : 종료할 소켓의 파일 디스크립터 전달.

howto : 종료방법에 대한 정보 전달.

 

위 함수 호출 시 2번째 인자에 따라 종료 방법이 달라진다.

- SHUT_RD       : 입력 스트림 종료 (데이터 수신 불가)

- SHUT_WR      : 출력 스트림 종료 (데이터 전송 불가)

(단!!!! 출력 버퍼에 데이터가 남아있다면 이 데이터까지는 목적지로 전송함)

- SHUT_RDWR : 입출력 스트림 종료 (이건 사실 shutdown()함수를 SHUT_RD, SHUT_WR로 2번 호출한 것과 동일)

 

Half-close가 필요한 이유

이런 상황을 생각해보자.

"클라이언트가 서버에 접속하면 서버는 약속된 파일을 클라이언트에게 전송하고, 클라이언트는 파일을 잘 수신했다는 의미로 문자열 "Thank you"를 서버에 전송한다고 해보자."

서버 입장에서는 파일 데이터를 연속적으로 클라이언트에게 보내지만, 클라이언트는 언제까지 이 파일을 수신해야 할지 모른다. 무턱대고 무조건 입력함수를 호출 할 수도 없다. (만약 입력 버퍼가 비어있는 상황에서 입력 함수를 호출하게 되면 블로킹 상태에 빠져버린다.)

"그러면 어플리케이션 프로토콜을 정하면 되지 않나요? 전송 마지막에 문자 하나를 보내기로 약속하면 되지 않나?"

그런데 이것도 맞지 않는 상황이다. 만약 파일의 마지막 문자가 종료시 보내기로한 문자와 일치한다면? 어떻게 할것인가??? 그래서 이를 해결하기 위해 서버는 파일 전송이 끝났음을 알리는 EOF 신호를 보낸다. 클라이언트는 EOF의 수신을 함수의 반환 값을 통해서 확인이 가능하기 때문에 파일에 저장된 데이터와 중복될 일도 없다. 그럼 이제 문제는 하나다!!!

어떻게 EOF 신호를 전달할까?

"출력 스트림을 종료하면 상대 호스트로 EOF가 전송된다."

물론 close() 함수를 호출하면 모든 입출력 스트림을 종료하므로 EOF가 전달된다. 그런데 이렇게 하면 일방적으로 종료하는 것이기 때문에 상대방이 전송하는 데이터를 더 이상 수신 못한다는 문제가 있다. 따라서 shutdown 함수 호출을 통해서 서버의 출력 스트림만 Half-close 해야 하는 것이다. 이럴 경우 EOF도 전송되고, 입력 스트림은 여전히 살아있어서 클라이언트로부터 잘 받았다는 메시지도 수신 가능하다.

 

Half-close 기반의 파일전송 프로그램

[file-server.c]

/* linux */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE   30
void error_handling(char* message);

int main(int argc, char* argv[])
{
	int serv_sd, clnt_sd;
	FILE* fp;
	char buf[BUF_SIZE];
	int read_cnt;

	struct sockaddr_in serv_adr, clnt_adr;
	socklen_t clnt_adr_sz;

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

	fp = fopen(fp, "rb");
	if (fp = NULL)
	{
		error_handling("fopen() error");
	}
	serv_sd = socket(PF_INET, SOCK_STREAM, 0);
	if (serv_sd == -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_adr, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
	{
		error_handling("bind() error");
	}

	if (listen(serv_adr, 5) == -1)
	{
		error_handling("listen() error");
	}

	clnt_adr_sz = sizeof(clnt_adr);
	clnt_sd = accept(serv_sd, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
	if (clnt_sd == -1)
	{
		error_handling("accept() error");
	}

	while (1)
	{
		read_cnt = fread((void*)buf, 1, BUF_SIZE, fp);
		if (read_cnt < BUF_SIZE)
		{
			write(clnt_sd, buf, read_cnt);
			break;
		}
		write(clnt_sd, buf, BUF_SIZE);
	}
	shutdown(clnt_sd, SHUT_WR);
	read(clnt_sd, buf, BUF_SIZE);
	printf("Message from client: %s \n", buf);

	fclose(fp);
	close(clnt_sd); close(serv_sd);
	return 0;
}

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

1. 파일전송 후에 출력 스트림에 대한 Half-close를 진행하고 있다. 이로써 클라이언트에게는 EOF가 전송되고, 이를 통해서 클라이언트는 파일전송이 완료되었음을 인식 가능하다.

2. 출력 스트림만 닫았기 때문에 클라이언트의 수신 완료 문자를 입력 스트림을 통해 데이터 수신 가능하다.

 

[file_client.c]

/* linux */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE   30
void error_handling(char* message);

int main(int argc, char* argv[])
{
	int sd;
	FILE* fp;

	char buf[BUF_SIZE];
	int read_cnt;
	struct sockaddr_in serv_adr;

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

	fp = fopen(fp, "wb");
	if (fp == NULL)
	{
		error_handling("fopen() error");
	}
	sd = socket(PF_INET, SOCK_STREAM, 0);
	if (sd == -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(sd, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
	{
		error_handling("connect() error");
	}

	while ((read_cnt = read(sd, message, BUF_SIZE)) != 0)
		fwrite(buf, 1, read_cnt, fp);

	puts("Received file data");
	write(sd, "Thank you", 10);
	
	fclose(fp);
	close();
	return 0;
}

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

1. EOF 신호까지 수신한 후에 서버쪽에 잘받았다는 메시지를 전송하고 스트림을 전부 끊어서 종료한다.

2. 참고로 수신 완료 메시지(Thank you)는 서버쪽의 입력 스트림이 닫혀있지 않았다면, 수신 가능할 것이다.

 

실행결과 : file_server.c

root@my_linux:/tcpip# gcc file_server.c -o fserver
root@my_linux:/tcpip# fserver 9190
Message from client: Thank you
root@my_linux:/tcpip#

 

실행결과 : fild_client.c

root@my_linux:/tcpip# gcc file_client.c -o fclient
root@my_linux:/tcpip# fclient 127.0.0.1 9190
Received file data
root@my_linux:/tcpip#

 

 

#2. 윈도우 기반으로 구현하기

#include <WinSock2.h>

int shutdown(SOCKET sock, int howto);
-> 성공 시 0, 실패 시 SOCKET_ERROR 반환

sock : 종료할 소켓의 핸들.

howto : 종료방법에 대한 정보 전달.

 

SD_RECEIVE : 입력 스트림 종료

SD_SEND       : 출력 스트림 종료

SD_BOTH       : 입출력 스트림 종료

 

[file_server_win.c]

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

#pragma comment(lib, "ws2_32.lib")
#define BUF_SIZE 30
void ErrorHandling(const char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET hServSock, hClntSock;
	FILE* fp;
	char buf[BUF_SIZE];
	int readCnt;

	SOCKADDR_IN servAdr, clntAdr;
	int clntAdrSz;

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

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

	errno_t err = fopen_s(&fp, "file_server_win.c", "rb");
	if (err != 0)
	{
		ErrorHandling("fopen_s() error");
	}
	hServSock = socket(PF_INET, SOCK_STREAM, 0);
	if (hServSock == INVALID_SOCKET)
	{
		ErrorHandling("socket() error");
	}

	memset(&servAdr, 0, sizeof(servAdr));
	servAdr.sin_family = AF_INET;
	servAdr.sin_addr.S_un.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");
	}

	clntAdrSz = sizeof(clntAdr);
	hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &clntAdrSz);
	if (hClntSock == INVALID_SOCKET)
	{
		ErrorHandling("accept() error");
	}

	while (1)
	{
		readCnt = fread_s(buf, BUF_SIZE, 1, BUF_SIZE, fp);
		if (readCnt < BUF_SIZE)
		{
			send(hClntSock, buf, readCnt, 0);
			break;
		}
		send(hClntSock, buf, BUF_SIZE, 0);
	}

	shutdown(hClntSock, SD_SEND);
	recv(hClntSock, buf, BUF_SIZE, 0);
	printf("Message from client: %s\n", buf);

	fclose(fp);
	closesocket(hClntSock);
	closesocket(hServSock);
	WSACleanup();
	return 0;
}


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

 

[file_client_win.c]

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

#pragma comment(lib, "ws2_32.lib")

#define BUF_SIZE 30
void ErrorHandling(const char* message);

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET hSocket;
	FILE* fp;

	char buf[BUF_SIZE];
	SOCKADDR_IN servAdr;
	int readCnt;

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

	if (WSAStartup(MAKEWORD(2, 2), &wsaData) == INVALID_SOCKET)
	{
		ErrorHandling("WSAStartup() error");
	}

	errno_t err = fopen_s(&fp, "receive.dat", "wb");
	if (err != 0)
	{
		ErrorHandling("fopen_s() 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_un.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");
	}

	while ((readCnt = recv(hSocket, buf, BUF_SIZE, 0)) != 0)
		fwrite((void*)buf, 1, readCnt, fp);

	puts("Received file data");
	send(hSocket, "Thank you", 10, 0);

	fclose(fp);
	closesocket(hSocket);
	return 0;
}

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

 

 

내용 확인문제

01. TCP에서의 스트림 형성이 의미하는 바가 무엇인지 설명해보자. 그리고 UDP에서도 스트림이 형성되었다고 할 수 있는 요소가 있는지 없는지 말해보고, 그 이유에 대해서도 설명해보자.

답)

TCP에서의 스트림 형성은 두 소켓이 서로 연결되어 데이터 송수신이 가능한 상태를 의미한다.

그렇다면 UDP 소켓의 경우 두 소켓이 연결되는 것이 아니기 때문에 스트림 형성과정이 존재하지 않는다고 볼 수 있다.

 

02. 리눅스에서의 close 함수 또는 윈도우에서의 closesocket 함수 호출은 일방적인 종료로써 상황에 따라서 문제가 되기도 한다. 그렇다면 일방적인 종료가 의미하는 바는 무엇이며, 어떠한 상황에서 문제가 되는지 설명해 보자.

답)

일방적으로 종료는 두 스트림의 연결을 끊어서  송,수신이 불가한 상태를 의미한다. 

일반적으로 연결 종료는 데이터 송신이 끝났을 때 하게 되는데, 이 때 반대쪽에서 아직 송신 할 데이터가 남아있다면 문제가 될 수 있다.

 

03. Half-close는 무엇인가? 그리고 출력 스트림에 대해서 Half-close를 진행한 호스트는 어떠한 상태에 놓이게 되며, 출력 스트림의 Half-close 결과로 상대 호스트는 어떠한 메시지를 수신하게 되는가?

답)

Half-close는 소켓의 모든 스트림 연결을 일방적으로 끊는 것이 아닌, 선택적으로 끊을 수 있는 연결 종료 방법이다.

출력 스트림에 대해 Half-close 진행 시 해당 출력 스트림과 상대방 입력 스트림의 연결이 끊겨, 우리 호스트는 데이터 송신, 상대 호스트는 데이터 수신이 불가해진다.(단, 우리 호스트 데이터 수신, 상대 호스트 데이터 송신은 가능함)

Half-close를 호출한 쪽에서 상대 호스트로 스트림을 끊기전에 EOF를 전송한다.