[소켓 #07] 소켓의 종료

이석환·2023년 4월 16일

Socket Programming

목록 보기
8/18

1. 소켓의 종료

제목을 보면 무엇인가 할 것이다.
왜냐면 지금까지 소켓을 종료할 땐 그냥 close(socket)으로 종료했기 때문이다.
TCP에서는 연결과정보다 중요한 것이 종료과정이다.
연결과정에서는 큰 변수가 발생하지 않지만 종료 과정에서는 예상치 못한 일이 발생할 수 있다.
따라서 종료과정은 명확해야 한다.

리눅스의 close와 윈도우의 closesocket 함수 호출은 완전 종료를 의미한다.
완전 종료라는 것은 데이터를 전송하는 것은 물론, 수신하는 것 조차 더 이상 불가능한 상황을 의미한다.
때문에 한쪽에서 일방적인 close는 경우에 따라 옳지 않을 수 있다.

  • 소켓의 완전 소멸을 의미
  • 소켓이 소멸되므로 더 이상의 입출력이 불가능
  • 상대방의 상태에 상관 없이 일방적인 종료의 형태
  • 상대 호스트의 데이터 송수신이 아직 완료되지 않은 상황이라면 문제 발생
  • 이러한 문제의 대안으로 Half-close가 존재

2. 소켓의 Half-close

위의 문제점을 해결하기 위한 방법이다.
데이터의 송수신에 사용되는 스트림의 일부만 종료(Half-close)하는 방법이 제공된다.
일부를 종료한다는 것은 전송은 가능하지만 수신은 불가능한 상황,
혹은 수신은 가능하지만 전송은 불가능한 상황을 뜻한다.
말 그대로 스트림의 반만 닫는 것이다.

소켓을 통해서 두 호스트가 연결되면, 그 다음부터는 상호간에 데이터의 송수신이 가능한 상태가 된다.
이러한 상태를 가리켜 '스트림이 형성된 상태'라고 한다.
스트림은 물의 흐름을 의미한다. 알다시피 물의 흐름은 단방향이다.
마찬가지로 스트림도 한 쪽 방향으로만 이동이 가능하다. 때문에 양방향 통신을 위해서 아래 그림과 같이 두 개의 스트림이 필요하다.

한 호스트의 입력 스트림은 다른 호스트의 출력 스트림으로 이어지고
한 호스트의 출력 스트림은 다른 호스트의 입력 스트림으로 이어진다.
우아한 종료라는 것은 한 번에 이 두 스트림을 끊어버리는 것이 아니라 이 중 하나의 스트림을 끊는 것이다.

Half-close

  • 종료를 원한다는 것은, 더 이상 전송할 데이터가 존재하지 않는 상황
  • 따라서 출력 스트림은 종료를 시켜도 된다.
  • 다만 상대방도 종료를 원하는지 확인되지 않은 상황이므로, 출력 스트림은 종료시키지 않을 필요가 있다.
  • 때문에 일반적으로 Half-close라 하면, 입력 스트림만 종료하는 것을 의미한다.
  • Half-close를 가리켜 '우아한 종료'라고도 한다.

3. 우아한 종료를 위한 shutdown 함수

Half-close에 사용되는 함수이다.
shutdown 함수는 스트림의 일부를 종료하는데 사용되는 함수이다.

#include <sys/socket.h>

int shutdown(int sock, int howto);

// 성공 시 0, 실패 시 -1 반환

/*
sock : 종료할 소켓의 파일 디스크립터 전달
howto : 종료방법에 대한 정보 전달
*/
  • 함수 호출 시 두 번째 매개 변수에 전달되는 인자에 따라서 종료의 방법이 결정된다.

    SHUT_RD가 전달되면 입력 스트림이 종료되어 더 이상 데이터를 수신할 수 없는 상태가 된다.
    혹, 데이터가 입력 버퍼에 전달되더라도 그냥 지워져 버릴 뿐만 아니라 입력 관련 함수의 호출도 더 이상은 허용이 안 된다.

    SHUT_WR는 출력 스트림이 종료되어 더 이상의 데이터 전송이 불가능해진다.
    단, 출력 버퍼에 아직 전송되지 못한 상태로 남아있는 데이터가 존재하면 해당 데이터는 목적지로 전송된다.

    SHUT_RDWR은 입력 스트림과 출력 스트림이 모두 종료되는데, 이는 shutdown 함수를 한 번은 SHUT_RD를 인자로, 한 번은 SHUT_WR을 인자로 두 번 호출하는 것과 같다.

4. Half-close가 필요한이유

"클라이언트가 서버에 접속하면 서버는 약속된 파일을 클라이언트에게 전송하고, 클라이언트는 파일을 잘 수신했다는 의미로 문자열 "Thank you"를 서버에 전송한다.
연결 종료 직전에 클라이언트가 서버에 전송해야 할 데이터가 존재하는 상황으로 확대해석해보자.
이 상황에 대한 프로그램의 구현은 쉽지 않다.
파일을 전송하는 서버는 단순히 파일 데이터를 연속해서 전송하면 되지만, 클라이언트는 언제까지 데이터를 수신해야 할지 알 수 없기 때문이다.
클라이언트 입장에서는 무턱대고 게속해서 입력함수를 호출할 수도 없다.
그랬다가는 블로킹 상태(호출된 함수가 반환하지 않는 상태)에 빠질 수 있기 때문이다.

파일의 끝을 의미하는 문자를 약속할 수도 없다.
그러한 문자와 동일한 데이터가 전송될 수도 있기 때문이다.
이러한 문제를 해결하기 위해서 서버는 파일의 전송이 끝났음을 알리는 EOF를 마지막에 전송해야 한다. 클라이언트는 EOF의 수신을 함수의 반환 값을 통해서 확인이 가능하기 때문에 파일에 저장된 데이터와 중복될 일도 없다.

이때 EOF를 전달하기 위해서 사용하는 방법으로 출력 스트림을 종료하면 상대 호스트로 EOF를 전송하는 방법을 생각해보자.
물론 close 함수를 통해 입출력 스트림을 모두 종료해도 EOF는 전송되지만, 이럴 경우 상대방이 전송하는 데이터를 더 이상 수신 못한다는 문제가 있다.
즉, close 함수를 통해 스트림으 종료하면 클라이언트가 마지막으로 보내는 "Thank you"를 수신할 수 없다.
따라서 shutdown 함수를 통해 서버의 출력 스트림만 Half-close 해야 한다.
이럴 경우 EOF도 전송되고 입력 스트림은 여전히 살아 있어서 데이터의 수신도 가능하다.

5. Half-Close 코드

5-1. Server

#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[0]);
		exit(1);
	}
	
	fp=fopen("file_server.cpp", "rb"); 
	serv_sd=socket(PF_INET, SOCK_STREAM, 0);   
	
	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]));
	
	bind(serv_sd, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
	listen(serv_sd, 5);
	
	clnt_adr_sz=sizeof(clnt_adr);    
	clnt_sd=accept(serv_sd, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
	
	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);
}

Client에게 Write를 종료하겠다.
read는 계속해서 된다.
즉, WR을 닫음

5-2. Client

#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("receive.dat", "wb");
	sd=socket(PF_INET, SOCK_STREAM, 0);   

	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]));

	connect(sd, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
	
	while((read_cnt=read(sd, buf, BUF_SIZE ))!=0)
		fwrite((void*)buf, 1, read_cnt, fp);
	
	puts("Received file data");
	write(sd, "Thank you", 10);
	fclose(fp);
	close(sd);
	return 0;
}

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

해당 코드를 실행 후, 클라이언트 영역에 생성된 파일 receive.dat를 열어보면 정상적인 데이터 수신을 확인할 수 있다.
특히 실행경과를 통해 클라이언트가 마지막으로 전송한 메시지 'Thank you'를 서버가 수신했음을 알 수 있다.

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

profile
반갑습니다.

0개의 댓글