TCP_IP 소켓 프로그래밍

UDP 기반 서버/클라이언트

TIN9 2023. 6. 16.
반응형
이번 포스팅에서는 UDP에 대해 다뤄보고자 합니다.
TCP와는 어떤 차이가 있고 UDP는 어떤 방식으로 구현하는지를 살펴봅시다

UDP에 대한 이해

UDP의 특징
  • 비연결형 프로토콜 :  UDP는 비연결형 프로토콜입니다. 즉, 데이터를 전송하기 전에 목적지와의 연결을 맺지 않고 데이터를 보낼 때에 불필요한 지연이 없으므로 빠른 전송이 가능합니다. 그러나 이로 인해 보내는 과정에 데이터 패킷이  손실될 가능성이 있으니 목적에 따라 TCP와 UDP를 잘 선택해서 사용해야 합니다.

  • 데이터그램 지향적 : UDP는 데이터그램 지향적입니다. 즉, 독립적인 데이터 패킷들을 보내는 방식을 사용합니다. 각 데이터그램은 별개의 데이터 단위로 처리되며, 이전이나 이후의 데이터그램과 연관성이 없다고 합니다.
    이로 인해 UDP는 신뢰성은 떨어지지만, 성능은 높아집니다.
UDP의 내부 동작
  1. 데이터 패킷 전송 : UDP는 송신 측에서 데이터그램을 생성하고, 이를 IP 계층으로 전달합니다. 이 데이터그램은 UDP 헤더와 함께 전송되며, UDP 헤더는 소스 포트 번호, 대상 포트 번호, 길이, 체크섬을 포함합니다.
    그리고 이 정보는 패킷이 정확한 목적지로 전송되고, 오류 없이 도착할 수 있도록 합니다.
  2. 데이터 패킷 수신 : 수신 측에서는 이들 데이터그램을 받아들이고, UDP 헤더 정보를 확인하여 적절한 애플리케이션에 전달합니다.
  3. 오류 확인 : UDP는 체크섬을 사용하여 패킷의 일관성을 검사합니다. 체크섬은 데이터 패킷의 일부를 수정하여 데이터의 오류를 감지하는 데 사용됩니다. 패킷이 손상되거나 데이터가 변조되면 수신측에서 이를 감지할 수 있습니다.
체크섬이란

체크섬(checksum)은 데이터 전송 시 발생할 수 있는 오류를 감지하는 간단한 방법이다.

 

체크섬은 원본 데이터를 일정한 방식으로 연산하여 생성된 고유한 값이라고 합니다.

주로 데이터의 무결성을 검사하는데 사용되는데 데이터 패킷이 송신자로부터 수신자에게 전송될 때, 송신자는 원본 데이터를 기반으로 체크섬을 생성하고, 이 체크섬을 패킷에 포함시켜 전송합니다.

또한 수신자 측에서는 수신된 데이터를 이용해 체크섬을 다시 계산하고, 패킷에 포함된 원래의 체크섬과 비교합니다.

만약 두 체크섬이 일치하지 않는다면, 그 데이터는 전송 과정에서 오류가 발생했음을 나타내며, 이 경우 일반적으로 데이터를 다시 요청하거나 오류를 수정하는 등의 조치가 취해지게 되는데 이를 체크섬이라 합니다.

 

UDP 서버와 클라 연결

UDP클라이언트와 서버는 연결 상태가 존재하지 않는다고 한다.

즉 TCP처럼 데이터를 주고 받기위한 연결 과정이 필요하지 않다는 것이다.

이러한 이유때문에 TCP에서 사용하던 listen, accpet, connect함수의 호출은 필요하지 않다고 합니다.

 

UDP의 효율적 사용

UDP는 어떠한 경우에 효율적인가?

UDP특성상 TCP에보다 정확한 데이터 전송은 불가능하고 손실되는 정보들이 있지만 생각보다 UDP도 신뢰할 만합니다.

서적에는 예를들어 인터넷을 기반으로 실시간 영상 같은 멀티미디어 데이터는 특성상 일부가 손실이 되어도 큰 문제가 되지 않는다고 나와있습니다. 잠깐 동안의 화면 떨림 내지는 아주 작은 잡음 정도의 손실보다 실시간 서비스를 해야 하므로 속도가 더 중요하기 때문입니다.

단 압축 파일같은 경우 파일의 일부만 손실되어도 복원하기 어렵기 때문에 이는 TCP를 선택해야 합니다.

 

UDP에서 connect 함수

UDP  클라이언트를 구현할 때 일반적으로 connect 함수의 호출이 필요 없다고 위에서 설명했다.

하지만 적절한 상황에서 사용을 한다면 성능 향상의 큰 이점을 얻을 수 있습니다.

 

UDP소켓에서 sendto, recvfrom함수를 사용하여 함수 호출 시 연결을 하고(함수에 관련된 내용은 아래 확인) 함수 호출이 끝나는 경우 연결을 종료한다.

하지만 이 작업이 UDP 패킷을 송 수신하는데 걸리는 전체 시간의 1/3을 차지합니다.

그래서 이러한 부분의 효율성을 높이기 위해 connect함수를 호출해 IP와 Port를 소켓에 할당해 놓아 사용하기도 합니다.

connect를 사용하게되면 sendto, recvfrom 함수가 아닌 send, recv기존의 함수 그대로 사용할 수 있게 되는 장점도 있습니다.

 

UDP 기반의 데이터 입 출력 함수

UDP소켓은 연결 상태를 유지하지 않기 때문에 데이터를 전송할 때 반드시 보내고자 하는 곳의(clntAddr) 주소 정보를 포함해야 한다.

기존의 send, recv함수 -> sendto, recvfrom 함수로 변경

연결이 안되어있기 때문에 함수이름을 보면 예상할 수 있는 것처럼 to와 from에 대한 정보를 추가한 개념이라고 생각하면 된다.

int WSAAPI sendto(
  [in] SOCKET         s,
  [in] const char     *buf,
  [in] int            len,
  [in] int            flags,
  [in] const sockaddr *to,
  [in] int            tolen
);
  • s (SOCKET) : 전송에 사용할 소켓 식별자입니다. SOCKET은 소켓을 식별하기 위한 유일한 값으로, socket 함수를 호출하여 생성된 소켓을 나타냅니다.
  • buf (const char*) : 전송할 데이터의 버퍼 포인터입니다. 데이터는 buf 포인터가 가리키는 메모리 영역에 저장되어 있어야 합니다.
  • len (int) : 전송할 데이터의 길이입니다. buf가 가리키는 데이터의 길이를 바이트 단위로 지정합니다.
  • flags (int) : 전송에 대한 특정 옵션을 설정하는 데 사용되는 플래그입니다. 일반적으로 0으로 설정하거나 필요한 경우 특정 플래그를 사용할 수 있습니다.
  • to (const sockaddr*) : 데이터를 전송할 목적지 주소 정보입니다. sockaddr 구조체에 목적지 주소 및 포트 정보가 포함되어야 합니다. 주소 정보의 형식은 소켓의 유형(AF_INET, AF_INET6 등)에 따라 달라질 수 있습니다.
  • tolen (int) : to 매개변수에 전달된 sockaddr 구조체의 크기입니다. 주로 sizeof(sockaddr)와 같이 사용됩니다.

sendto 함수는 buf에 저장된 데이터를 s 소켓을 통해 to에 지정된 목적지로 전송합니다. 전송에 성공하면 전송된 데이터의 길이를 반환하고, 실패하면 SOCKET_ERROR(-1)를 반환합니다.

int WSAAPI recvfrom(
  [in]                SOCKET   s,
  [out]               char     *buf,
  [in]                int      len,
  [in]                int      flags,
  [out]               sockaddr *from,
  [in, out, optional] int      *fromlen
);
  • s (SOCKET) : 데이터를 수신할 소켓 식별자입니다. SOCKET은 소켓을 식별하기 위한 유일한 값으로, socket 함수를 호출하여 생성된 소켓을 나타냅니다.
  • buf (char*) : 수신한 데이터를 저장할 버퍼의 포인터입니다. 수신한 데이터는 buf 포인터가 가리키는 메모리 영역에 저장됩니다.
  • len (int) : 수신할 데이터의 최대 길이입니다. buf 버퍼의 크기를 바이트 단위로 지정합니다. 받고자 하는 데이터의 최대 길이를 설정합니다.
  • flags (int) : 수신에 대한 특정 옵션을 설정하는 데 사용되는 플래그입니다. 일반적으로 0으로 설정하거나 필요한 경우 특정 플래그를 사용할 수 있습니다.
  • from (sockaddr*) : 송신자의 주소 정보를 받을 구조체 포인터입니다. sockaddr 구조체에 송신자의 주소 및 포트 정보가 저장됩니다.
  • fromlen (int*) : from 매개변수로 전달된 sockaddr 구조체의 크기를 나타내는 변수입니다. 이 매개변수는 입력으로 사용되고, 함수가 호출되면 해당 변수에 송신자의 주소 정보의 실제 크기가 저장됩니다. 이 매개변수는 선택적으로 사용될 수 있으며, 필요한 경우 널 포인터로 전달할 수 있습니다.

recvfrom 함수는 s 소켓으로부터 데이터를 수신하여 buf 버퍼에 저장하고, 송신자의 주소 정보를 from 구조체에 저장합니다. 수신한 데이터의 길이를 반환하며, 실패 시 SOCKET_ERROR(-1)를 반환합니다.

 

UDP 기반 에코 서버 구현

// 필요한 헤더 파일들
#include <stdio.h>          // 표준 입력/출력 헤더
#include <stdlib.h>         // 표준 라이브러리 헤더
#include <string.h>         // 문자열 처리 라이브러리
#include <WinSock2.h>       // 윈도우 소켓 2 헤더
#include <WS2tcpip.h>       // WinSock2에 추가 기능을 제공하는 헤더
#pragma comment(lib, "ws2_32.lib")  // WinSock2를 위한 링커 지시문

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

int main(int argc, char* argv[])
{
	WSADATA		wsaData;
	SOCKET		hServSock;
	char message[BUFSIZE];
	int strLen;
	SOCKADDR_IN	servAddr;
	SOCKADDR_IN	clntAddr;
	int clntAddrSize;

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

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

	// UDP이기 때문에 타입을 SOCK_DGRAM으로 지정
	hServSock = socket(PF_INET, SOCK_DGRAM, 0);

	if (hServSock == INVALID_SOCKET)
		ErrorHandling("socket() error");
	
    // servAddr초기화
	memset(&servAddr, 0, sizeof(servAddr));
	servAddr.sin_family = AF_INET;
	servAddr.sin_port = htons(atoi(argv[1]));
	servAddr.sin_addr.s_addr = htonl(INADDR_ANY);

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

	// 또한 UDP이기 때문에 bind이후 listen과 같은 부분 x

	while (1)
	{
		clntAddrSize = sizeof(clntAddr);

		strLen = recvfrom(hServSock, message, BUFSIZE, 0, (SOCKADDR*)&clntAddr, &clntAddrSize);

		sendto(hServSock, message, strLen, 0, (SOCKADDR*)&clntAddr, sizeof(clntAddr));
	}

	closesocket(hServSock);
	WSACleanup();

	return 0;
}

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

UDP 기반 에코 클라이언트 구현(connect 기반)

/*
* connect 함수를 호출을 통한 성능 향상에 대한 에코 클라이언트 코드
*/

// 필요한 헤더 파일들
#include <stdio.h>          // 표준 입력/출력 헤더
#include <stdlib.h>         // 표준 라이브러리 헤더
#include <string.h>         // 문자열 처리 라이브러리
#include <WinSock2.h>       // 윈도우 소켓 2 헤더
#include <WS2tcpip.h>       // WinSock2에 추가 기능을 제공하는 헤더
#pragma comment(lib, "ws2_32.lib")  // WinSock2를 위한 링커 지시문

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

int main(int argc, char* argv[])
{
	WSADATA wsaData;
	SOCKET	hSocket;
	char message[30];
	int strLen;

	SOCKADDR_IN servAddr;

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

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

	hSocket = socket(PF_INET, SOCK_DGRAM, 0);

	if (hSocket == INVALID_SOCKET)
		ErrorHandling("socket() error");

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


	// connect 함수를 호출해서 UDP 소켓에 클라이언트의 IP와 Port를 할당해 주고 있다.
	// 따라서 데이터를 송 수신하기 위해서 커널이 소켓에 연결되는 과정이 필요하지 않으므로
	// 성능 향상을 기대할 수 있고, 또한 TCP 소켓의 입 출력 함수인 send와 recv를 호출할 수 있다.
	if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
		ErrorHandling("connect() error");

	while (1)
	{
		fputs("전송할 메시지를 입력 하세요 (q to quit) : ", stdout);
		fgets(message, sizeof(message), stdin);

		if (!strcmp(message, "q\n"))
			break;
		
        // connect를 사용했기 때문에 send와 recv를 그대로 사용
		send(hSocket, message, strlen(message), 0);

		strLen = recv(hSocket, message, sizeof(message) - 1, 0);
		message[strLen] = 0;
		printf_s("서버로부터 전송된 메시지 : %s", message);
	}

	closesocket(hSocket);
	WSACleanup();

	return 0;
}

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

댓글