[소켓 #06] UDP 기반 Server/Client

이석환·2023년 4월 16일

Socket Programming

목록 보기
7/18

1. UDP에 대한 이해

신뢰성을 보장하지 않고 데이터를 송수신하는 프로토콜이다.
교재의 예시를 빌려 설명하자면 마치 편지와 같다.
편지를 보내기 위해서는 일단 편지봉투에다가 보내는 사람과 받는 사람의 주소 정보를 쓴다.
그리고 우표를 붙여서 우체통에 넣으면 끝이다.
다만 편지의 특성상 보낸 후 수신자의 확인여부를 알 수 없다.
또한 전송 도중에 편지가 분실될 확률도 존재한다.
즉, 위에서 서술한대로 신뢰할 수 없는 전송방법이다.

신뢰성만 놓고 보자면 TCP가 훨씬 좋은 프로토콜이다.
하지만 UDP는 TCP보다 훨씬 간결한 구조로 설계되어있다.
ACK와 같은 응답 메시지를 보내는 일이 없으며, SEQ와 같이 패킷에 번호를 부여하지 않아도 된다.
구현 또한 용이하다. 게다가 UDP도 데이터의 손실이 생각보다 자주 발생하지는 않는다.
그래서 신뢰성보다 성능을 우선시할 경우 UDP가 더 나은 선택이 될 수도 있다.
그렇다면 UDP의 역할은 어디까지 인가 ?
TCP는 신뢰성 없는 IP를 기반으로 신뢰성 있는 데이터의 송수신을 위해 흐름제어 (Flow Control)를
책임진다. 하지만 UDP에는 이 흐름제어가 없다.

TCP와 UDP의 차이점은 흐름제어의 차이이다.

그리고 UDP가 TCP보다 훨씬 빠르다고 하였지만, 송수신하는 데이터의 크기에 따라 속도에 차이가 별로 나지 않는 경우가 있다.
예를 들어 한 번에 많은 양의 데이터를 송수신하는 경우 TCP는 UDP랑 비슷한 성능을 자랑한다.

정리

  • 위 그림에서 보면 IP의 역할은 호스트 B를 떠난 패킷이 호스트 A까지 찾아가게 하는 것이다.
    그리고 UDP의 역할은 전달된 UDP를 호스트 A에 목적지 UDP 소켓으로 도달하게 하는 것이다.
    즉, UDP 역할 중 가장 중요한 것은 호스트로 수신된 패킷을 PORT 정보를 참조하여 최종 목적지인 UDP 소켓에 전달하는 것이다.

  • UDP의 효율적 사용
    UDP는 생각보다 데이터의 손실이 크지 않다. 하지만 10000개의 데이터 중에 1개라도 손실이 일어나면 큰일나는 데이터의 경우에는 사용하면 안 된다. 예를 들어 압축파일인 경우 TCP를 기반으로 송수신이 이루어져야 한다. 일부라도 손실되면 압축의 해제가 안 되기 때문이다.

    UDP는 실시간 전송이 중요한 경우 예를 들어, 스트리밍이 있다.
    TCP는 연결설정 및 해제과정, 신뢰성 보장을 위한 흐름제어때문에 UDP보다 느리다.
    즉, 송수신하는 데이터의 양은 작으면서 잦은 연결이 필요한 경우 UDP가 TCP보다 훨씬 효율적이다.

2. UDP 기반 Server/Client 구현

"UDP 소켓은 연결이라는 개념이 존재하지 않는다."
UDP 서버, 클라이언트는 TCP와 같이 연결된 상태로 데이터를 송수신하지 않는다.
따라서 서버 소켓과 클라이언트 소켓의 구분이 없다.
연결의 개념이 존재하지 않으므로, 하나의 소켓으로 둘 이상의 영역과 데이터 송수신이 가능하다.

  • 즉 TCP와 달리 listen 함수와 accept 함수의 호출은 불필요하다.
    TCP에서는 소켓과 소켓의 관계가 1대1이었다.
    서버에서 열 개의 클라이언트에게 서비스를 제공하려면 문지기의 역할을 하는 서버 소켓을 제외하고
    10개의 서버 소켓이 필요하였다. 그러나 UDP는 서버건 클라이언트건 하나의 소켓만 있으면 된다.
    위에 예시를 들었듯이, 우체통 하나를 통해 어디든 보낼 수 있는 우체통이 많을 필요가 있을까 ?
    마찬가지로 UDP 소켓이 하나 있다면 어디건 데이터를 전송할 수 있다.
    즉, UDP 소켓은 하나만 있으면 둘 이상의 호스트와의 통신이 가능하다.

2-1. UDP 기반의 데이터 입출력 함수

TCP 소켓을 생성하고 나서 데이터를 전송하는 경우에는, 주소 정보를 따로 추가하는 과정이 필요 없다.
TCP 소켓은 서로 연결된 상태이기 때문이다. 즉, 목적지의 주소 정보를 이미 알고 있는 상태이다.
그러나 UDP 소켓은 연결상태를 유지하지 않으므로 목적지의 주소정보를 별도로 추가해야 한다.
이는 우체통에 우편물을 넣을 때 주소정보를 써 넣는 것에 비유할 수 있다.

  • 데이터를 보낼 때
#include <sys/socket.h>

ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, 
										struct sockaddr *to, socklen_t addrlen);
                                                  
//성공 시 전송된 바이트 수, 실패시 -1 반환

/*
sock : 데이터 전송에 사용될 UDP 소켓의 파일 디스크립터를 인자로 전달
buff : 전송할 데이터를 저장하고 있는 버퍼의 주소 값 전달
nbytes : 전송할 데이터 크기를 바이트 단위로 전달
flags : 옵션 지정에 사용되는 매개변수, 지정할 옵션이 없다면 0 전달
to : 목적지 주소 정보를 담고 있는 sockaddr 구조체 변수의 주소 값 전달
addrlen : 매개변수 to로 전달된 주소 값의 구조체 변수 크기 전달
*/

UDP 소켓은 연결의 개념이 있지 않으므로, 데이터를 전송할 때마다 목적지에 대한 정보를 전달해야 한다.

  • 데이터를 받을 때
#include <sys/socket.h>

ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, 
										struct sockaddr *from, socklen_t addrlen);
                                                  
//성공 시 수신한 바이트 수, 실패시 -1 반환

/*
sock : 데이터 수신에 사용될 UDP 소켓의 파일 디스크립터를 인자로 전달
buff : 데이터 수신에 사용될 버퍼의 주소 값 전달
nbytes : 수신할 최대 바이트 수 전달, 때문에 매개 변수 buff가 가리키는 버퍼의 크기를 넘을 수 없다.
flags : 옵션 지정에 사용되는 매개변수, 지정할 옵션이 없다면 0 전달
to : 발신지 정보를 채워 넣을 sockaddr 구조체 변수의 주소 값 전달
addrlen : 매개변수 from으로 전달된 주소 값의 구조체 변수 크기 전달
*/

UDP 소켓은 연결의 개념이 있지 않으므로, 데이터의 전송지가 둘 이상이 될 수 있다.
따라서 데이터 수신 후 전송지가 어디인지 확인할 필요가 있다.

2-2. UDP 기반의 Echo Server/Client 구현

UDP 기반 프로그램에서는 Server와 Client의 표현이 부적절하다.
연결 요청과 그에 따른 수락의 과정이 존재하지 않기 때문이다.
하지만 편의상 Server와 Client로 부르겠다.

  • 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_sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t clnt_adr_sz;
	
	struct sockaddr_in serv_adr, clnt_adr;
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}

	serv_sock = socket(PF_INET, SOCK_DGRAM, 0);
	if(serv_sock==-1)
		error_handling("UDP socket creation 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");

	while(1)
	{
		clnt_adr_sz = sizeof(clnt_adr);
		str_len = recvfrom(serv_sock, message, BUF_SIZE, 0, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
		sendto(serv_sock,message, str_len, 0, (struct sockaddr*)&clnt_adr, clnt_adr_sz);
	}
	close(serv_sock);
	return 0;
}

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

UDP 에코 서버는 수신한 데이터의 전송지 정보를 참조하여 데이터를 에코 함에 주의하자
즉, 반복문 안에서 recvfrom으로 얻은 데이터를 전송한 주소정보를 sendto로 다시 송신한다.

  • 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 sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 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]));

	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		
		sendto(sock, message, strlen(message),0,(struct sockaddr*)&serv_adr, sizeof(serv_adr));
		adr_sz = sizeof(from_adr);

		str_len = recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&from_adr, &adr_sz);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

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

server, client의 sendto함수와 recvfrom 함수의 호출횟수가 같아야 한다.

어느 프로그램이 먼저 실행되는지는 중요하지 않다.
다만 sendto 함수 호출 이전에 sendto 함수의 목적지에 해당하는 프로그램이 실행되어 있으면 된다.

TCP Client 소켓은 connect 함수가 호출될 때 IP와 PORT 정보가 자동으로 할당된다.
그럼 UDP Client 소켓은 IP와 PORT 정보가 언제 할당되는가 ?

모든 소켓에는 IP와 PORT가 할당되어야 한다.
UDP는 데이터의 경계가 존재하기 때문에 한 번의 recvfrom 함수 호출을 통해서 하나의 메시지를 완전히
읽어들인다.
그리고 sendto 함수 호출 시 IP와 PORT 번호가 자동으로 할당되기 때문에 일반적으로 UDP의 클라이언트에서는 주소정보를 할당하는 별도의 과정이 불필요하다.
IP는 호스트의 IP, PORT는 사용하지 않는 PORT번호 하나를 임의로 골라서 할당한다.
이렇듯 sendto 함수 호출 시 IP와 PORT 번호가 자동으로 할당되기 때문에 일반적으로
UDP의 클라이언트 프로그램에서는 주소 정보를 할당하는 별도의 과정이 불필요하다.

3. UDP의 데이터 송수신 특성과 UDP에서의 connect 함수 호출

UDP 소켓에서는 데이터의 경계가 존재한다고 언급하였다.
반대로 TCP 소켓에서는 경계가 존재하지 않는다고 여러차례 언급하였다.
즉, TCP 소켓은 "데이터 송수신 과정에서 호출하는 입출력함수의 호출횟수는 의미를 지니지 않는다."

하지만 UDP는 데이터의 경계가 존재하므로 호출횟수에 의미를 가진다.
위에 코드에서 언급했듯이 입력함수의 호출횟수와 출력함수의 호출횟수가 완벽히 일치해야한다.

예를 들어 세 번의 출력 함수 호출을 통해서 전송된 데이터는 반드시 세 번의 입력함수 호출이 있어야 데이터 전부를 수신할 수 있다.
간단히 두 개의 코드를 통해 확인해 보겠다.

3-1. bound_host1

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

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

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	struct sockaddr_in my_adr, your_adr;
	socklen_t adr_sz;
	int str_len, i;

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

	sock = socket(PF_INET, SOCK_DGRAM, 0);
	if(sock == -1)
		error_handling("socket() error");

	memset(&my_adr,0,sizeof(my_adr));
	my_adr.sin_family = AF_INET;
	my_adr.sin_addr.s_addr = htonl(INADDR_ANY);
	my_adr.sin_port = htons(atoi(argv[1]));

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

	for(i = 0; i < 3; i++){
		sleep(5);
		adr_sz=sizeof(your_adr);
		str_len = recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&your_adr, &adr_sz);
		printf("Message %d: %s \n", i+1,message);

	}


	close(sock);
	return 0;
}

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

여기서 주목할 점은 for문이다.
반복문 안에서 sleep 함수를 호출하고 있는데 이는 process를 5초간 멈추는 기능을 제공한다.
즉, for문 안에 recvfrom 함수는 5초 간격으로 호출이 되고 있다.
추후에 실행을 지연시킨 이유를 설명하겠다.
bound_host1은 수신 받는 코드이다.

3-2. bound_host2

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

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

int main(int argc, char *argv[])
{
	int sock;
	char msg1[] = "Hi I'm Seokhwan!";
	char msg2[] = "I'm another UDP host!";
	char msg3[] = "Nice to meet you";

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

	sock = socket(PF_INET, SOCK_DGRAM, 0);
	if(sock == -1)
		error_handling("socket() error");

	memset(&your_adr,0,sizeof(your_adr));
	your_adr.sin_family = AF_INET;
	your_adr.sin_addr.s_addr = inet_addr(argv[1]);
	your_adr.sin_port = htons(atoi(argv[2]));

	sendto(sock, msg1, sizeof(msg1), 0, (struct sockaddr*)&your_adr, sizeof(your_adr));
	sendto(sock, msg2, sizeof(msg2), 0, (struct sockaddr*)&your_adr, sizeof(your_adr));
	sendto(sock, msg3, sizeof(msg3), 0, (struct sockaddr*)&your_adr, sizeof(your_adr));

	close(sock);
	return 0;
}

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

bound_host2는 총 3회의 sendto 함수 호출을 통해서 데이터를 전송한다.
위에서 말했듯이 recvfrom 함수는 5초 간격으로 호출된다.
그렇다면 recvfrom 함수가 호출되기 전에 sendto 함수 3번이 모두 호출이 되어서
데이터는 이미 bound_host에 전송된 상태가 된다.
TCP라면 이 상황에서 단 한번의 입력함수 호출을 통해 모든 데이터를 읽어들일 수 있다.
하지만 UDP는 TCP와 달리 3번의 recvfrom 함수 호출이 요구 된다.
또한 TCP와 다른 점은 사용하는 함수가 다르고 전달할 목적지 정보를 매 호출시마다 지정해야 한다.

  • 이로써 UDP 기반의 데이터 송수신 과정에서는 입출력 함수의 호출횟수를 일치시켜야 한다.

    참고 내용: UDP Datagram
    UDP 소켓이 전송하는 패킷을 가리켜 데이터그램이라고도 표현한다.
    사실 데이터 그램도 패킷의 일종이다. 다만 TCP 패킷과 달리 데이터의 일부가 아니라
    그 자체가 하나의 데이터로 의미를 가질 때 데이터그램이라고 표현한다.
    이는 UDP의 데이터 전송특성과 관계가 있다.
    UDP는 데이터의 경계가 존재하기 때문에 하나의 패킷이 하나의 데이터로 간주된다.
    따라서 데이터그램이라고 표현한다.

4. Connected UDP

TCP 소켓은 데이터를 전송할 때 목적지의 IP와 PORT번호를 등록하고 사용한다.
반면 UDP는 데이터를 전송할 목적지의 IP와 PORT번호를 등록하지 않는다.
때문에 sendto 함수호출을 통해 데이터의 전송 과정은 다음과 같이 3단계로 나뉜다.

즉, sendto 함수가 호출될 때마다 위의 과정을 반복한다.
목적지의 주소정보가 계속해서 변경되기 때문에 하나의 UDP 소켓을 통해 다양한 목적지로 데이터 전송이 가능하다.

그리고 이렇게 목적지 정보가 등록되어 있지 않은 소켓을 가리켜 "unconnected socket"이라고 한다.
반면 목적지가 등록되어 있는 소켓을 가리켜 "connect 소켓"이라고 한다.

위의 설명을 보면 알겠지만 기본적으로 UDP는 unconnected socket이다.
하지만 다음과 같은 상황에서 매우 불필요한 동작을 반복한다.

"IP 211.210.147.82, PORT 82번으로 준비된 총 세 개의 데이터를 세 번의 sendto 함수호출을 통해서 전송한다."

이 경우 위에서 정리한 전송 단계를 3회 반복한다.
그래서 하나의 호스트와 오랜 시간 데이터를 송신해야 한다면, UDP 소켓을 connected socket으로
만드는 것이 효율적이다.

  • 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 sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 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]));
	
	// 목적지에 대한 정보 등록
	connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));

	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		
        
        // 전송
        
        //sendto(sock, message, strlen(message), 0, 
       								(struct sockaddr*)&serv_adr, sizeof(serv_adr);
        
		write(sock, message, strlen(message));
		
        
        // 수신

        // adr_sz = sizeof(from_adr);
		//recvfrom(sock, message, BUF_SIZE, 0, 
       								(struct sockaddr*)&from_adr, sizeof(adr_sz);
                                    
		str_len=read(sock, message, sizeof(message)-1);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

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

소켓을 생성하는 과정을 보면 TCP와 유사하게 보이지만 socket의 두번 째 인자를 주목하자.
connect 함수를 호출한다고 해서 목적지의 UDP 소켓과 연결설정 과정을 거친다거나 하지는 않는다.
즉, 상대 소켓과의 연결을 의미하지는 않는다.
UDP 소켓에 목적지의 IP와 PORT번호만 등록될 뿐이다.

이로써 이후부터는 TCP 소켓과 마찬가지로 sendto 함수가 호출될 때마다 데이터의 전송의 과정만 거치게 된다.
뿐만 아니라 송수신의 대상이 정해졌기 때문에 write, read 함수의 호출로도 데이터를 송수신할 수 있다.

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

profile
반갑습니다.

0개의 댓글