데이터그램 소켓이란?
데이터그램 소켓 => Datagram Socket 스트림 소켓 => Stream Socket
데이터그램 소켓을 생성하는 것은 스트림 소켓을 생성하는 것과 유사합니다. 주요 차이점은 데이터그램 소켓이 TCP 대신 UDP 프로토콜을 사용한다는 점입니다. 즉, 클라이언트와 서버가 데이터를 보내기 전에 연결을 설정할 필요가 없습니다.
이는 연결을 유지할 필요가 없는 애플리케이션에 유용합니다. 클라이언트와 서버는 먼저 연결을 설정하지 않고도 데이터를 주고받을 수 있기 때문입니다. 예를 들어, DNS 서버는 UDP 프로토콜을 사용합니다. 연결을 유지할 필요 없이 데이터를 보내고 응답만 받으면 되기 때문입니다.
작동 방식

image
이미지 출처: UDP 서버-클라이언트 구현 (C 언어)
서버는 어떻게 작동하나요?
리스너는 소켓을 생성하고, 포트에 바인딩한 후, 들어오는 메시지를 기다리는 방식으로 작동합니다.
먼저, 리스너는 소켓 생성에 필요한 정보(예: 프로토콜 패밀리, 소켓 유형, 포트 번호)를 포함하는 hints 구조체를 생성합니다. 그런 다음
getaddrinfo()함수를 호출하여 주소 정보를 가져와servinfo연결 리스트에 저장합니다.그런 다음, 리스너는
servinfo연결 리스트를 순회하며 사용 가능한 주소 정보를 찾습니다.socket()함수를 통해 소켓을 생성하고, 성공하면 소켓 디스크립터를 반환하고 실패하면 -1을 반환합니다.그런 다음, 리스너는
bind함수를 호출하여 소켓을 지정된 포트에 바인딩합니다. 이를 통해 리스너는 해당 포트로 전송된 메시지를 수신할 수 있습니다.소켓 바인딩이 성공하면, 프로그램은
recvfrom함수를 호출하여 들어오는 메시지를 기다립니다.recvfrom은 메시지가 도착할 때까지 대기하며, 수신한 메시지는buf버퍼에 저장하고 발신자의 주소 정보는their_addr에 저장합니다. 마지막으로 수신된 메시지와 발신자의 IP 주소를 화면에 출력합니다.마지막으로, 리스너는 소켓을 닫고 종료합니다.
클라이언트는 어떻게 작동하나요?
토커는 소켓을 설정하고, 리스너에게 메시지를 보내고, 소켓을 닫는 방식으로 작동합니다.
먼저, 토커는 주소 정보(예: 프로토콜 패밀리, 소켓 유형, 포트 번호)를 저장하기 위한 hints 구조체를 생성합니다. 그런 다음
getaddrinfo()함수를 호출하여 주소 정보를 가져와servinfo연결 리스트에 저장합니다.그런 다음, 토커는
servinfo연결 리스트를 순회하며 사용 가능한 주소 정보를 찾습니다.socket()함수를 통해 소켓을 생성하고, 성공하면 소켓 디스크립터를 반환하고 실패하면 -1을 반환합니다.소켓 생성에 성공하면, 토커는
sendto()함수를 호출하여 소켓을 통해 메시지를 보냅니다. 성공하면 전송된 바이트 수를 반환하고 실패하면 -1을 반환합니다.마지막으로, 토커는 소켓을 닫습니다.
서버 코드
/*
** listener.c: a datagram sockets "server" demo - 데이터그램 소켓 리스너
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define MYPORT "4950" /* 리스닝 포트 번호, 즉 서버 포트 번호, 클라이언트가 연결할 포트 번호 */
#define MAXBUFLEN 100 /* 최대 패킷 크기 */
/* 프로토콜 패밀리에 따라 sockaddr 구조체에서 IPv4 또는 IPv6 주소 반환 */
void *get_in_addr(struct sockaddr *sa)
{
// 주소 패밀리 확인
if (sa->sa_family == AF_INET)
{
// IPv4 주소 구조체 반환
return &(((struct sockaddr_in *)sa)->sin_addr);
}
// IPv6 주소 구조체 반환
return &(((struct sockaddr_in6 *)sa)->sin6_addr);
}
/* 서버 메인 함수 */
int main(void)
{
int sockfd; // 리스닝 소켓
struct addrinfo hints; // hints는 getaddrinfo() 함수에 사용됨 - 주소 정보 지정용
struct addrinfo *servinfo; // servinfo는 getaddrinfo() 함수가 반환한 연결 리스트 저장용 - 주소 정보 저장
struct addrinfo *p; // servinfo 연결 리스트 순회용
int rv; // getaddrinfo() 함수 반환값 저장용 - 오류 코드
int numbytes; // recvfrom() 함수 반환값 저장용 - 수신된 바이트 수
struct sockaddr_storage their_addr; // 클라이언트 주소 정보 저장용 - sockaddr_storage는 IPv4 또는 IPv6 모든 주소 저장 가능
char buf[MAXBUFLEN]; // 수신된 데이터 저장용 - 패킷
socklen_t addr_len; // 클라이언트 주소 정보 길이 저장용 - sockaddr_storage 구조체 길이
char s[INET6_ADDRSTRLEN]; // 클라이언트 주소 정보 저장용 - 주소 문자열
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_INET6; // IPv6 사용
hints.ai_socktype = SOCK_DGRAM; // UDP 프로토콜 사용
hints.ai_flags = AI_PASSIVE; // 로컬 IP 사용
// 주소 정보 servinfo 구축
if ((rv = getaddrinfo(NULL, MYPORT, &hints, &servinfo)) != 0)
{
// gai_strerror는 오류 코드를 오류 메시지 문자열로 변환
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// servinfo 연결 리스트를 순회하며 사용 가능한 주소 정보를 찾을 때까지 반복
for (p = servinfo; p != NULL; p = p->ai_next)
{
// 리스닝 소켓 생성
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1)
{
perror("listener: socket");
continue;
}
// 소켓 바인딩
if (bind(sockfd, p->ai_addr, p->ai_addrlen) == -1)
{
close(sockfd);
perror("listener: bind");
continue;
}
break;
}
// servinfo 연결 리스트를 모두 순회했는데도 사용 가능한 주소 정보를 찾지 못한 경우 오류 출력 후 종료
if (p == NULL)
{
fprintf(stderr, "listener: failed to bind socket\n");
return 2;
}
// servinfo 연결 리스트 메모리 해제
freeaddrinfo(servinfo);
printf("listener: waiting to recvfrom...\n");
// addr_len은 클라이언트 주소 정보의 길이를 저장
addr_len = sizeof their_addr;
// 데이터 패킷을 수신하여 buf에 저장하고, 수신된 패킷의 길이는 numbytes에 저장
if ((numbytes = recvfrom(sockfd, buf, MAXBUFLEN - 1, 0,
(struct sockaddr *)&their_addr, &addr_len)) == -1)
{
perror("recvfrom");
exit(1);
}
// inet_ntop은 클라이언트 주소 정보를 문자열로 변환
printf("listener: got packet from %s\n",
inet_ntop(their_addr.ss_family,
get_in_addr((struct sockaddr *)&their_addr),
s, sizeof s));
printf("listener: packet - 그런 다음, listener는 %d bytes long\n", numbytes);
// 데이터 패킷의 끝을 '\0'으로 설정하여 출력 가능하게 함
buf[numbytes] = '\0';
// 데이터 패킷 출력
printf("listener: packet contains \"%s\"\n", buf);
// 소켓 닫기
close(sockfd);
return 0;
}
클라이언트 코드
/*
** talker.c -- a datagram "client" demo - 데이터그램 소켓 송신자
** from Beej's Guide to Network Programming Using Internet Sockets
** https://beej.us/guide/bgnet/html/#datagram
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#define SERVERPORT "4950" // 서버 포트
int main(int argc, char *argv[])
{
int sockfd; // 소켓 디스크립터
struct addrinfo hints; // hints는 우리가 원하는 주소 유형을 지정하는 데 사용
struct addrinfo *servinfo; // servinfo는 반환된 주소 연결 리스트를 가리키는 데 사용
struct addrinfo *p; // p는 servinfo의 각 주소를 가리키는 데 사용
int rv; // rv는 getaddrinfo의 반환값을 저장하여 오류 여부를 판단하는 데 사용
int numbytes; // numbytes는 sendto의 반환값을 저장하여 전송된 바이트 수를 기록하는 데 사용
// 인자 개수 확인
if (argc != 3)
{
fprintf(stderr, "usage: talker hostname message\n");
exit(1);
}
memset(&hints, 0, sizeof hints); // hints 구조체 초기화
hints.ai_family = AF_INET6; // IPv6 프로토콜 사용
hints.ai_socktype = SOCK_DGRAM; // UDP 프로토콜 사용
// getaddrinfo()는 주소 정보를 가져와서 결과를 servinfo에 저장
if ((rv = getaddrinfo(argv[1], SERVERPORT, &hints, &servinfo)) != 0)
{
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rv));
return 1;
}
// servinfo의 각 주소를 순회하며 소켓 생성 시도
for (p = servinfo; p != NULL; p = p->ai_next)
{
// 소켓 생성
if ((sockfd = socket(p->ai_family, p->ai_socktype,
p->ai_protocol)) == -1)
{
perror("talker: socket");
continue;
}
break;
}
// p가 NULL이면 사용 가능한 주소를 찾지 못한 것
if (p == NULL)
{
fprintf(stderr, "talker: failed to create socket\n");
return 2;
}
// 데이터 전송 및 전송된 바이트 수 기록
if ((numbytes = sendto(sockfd, argv[2], strlen(argv[2]), 0,
p->ai_addr, p->ai_addrlen)) == -1)
{
perror("talker: sendto");
exit(1);
}
// servinfo 메모리 해제
freeaddrinfo(servinfo);
// 전송된 바이트 수 출력
printf("talker: sent %d bytes to %s\n", numbytes, argv[1]);
// 소켓 닫기
close(sockfd);
return 0;
}
프로그램 컴파일
gcc -o talker talker.c
gcc -o listener listener.c
실행 및 출력
./listener
# talker 실행 전까지 listener는 계속 대기
listener: waiting to recvfrom...
# 다른 터미널에서 talker를 실행하면 listener에 다음과 같이 출력
listener: got packet from ::1
listener: packet is 5 bytes long
listener: packet contains "hello"
# 다른 터미널에서 talker 실행
./talker localhost hello
talker: sent 5 bytes to localhost
잠재적 문제점
오류 처리: 두 프로그램 모두 강력한 오류 처리가 없습니다. 예를 들어, 클라이언트 프로그램에서
sendto가 오류를 반환하면 오류 메시지만 출력하고 종료할 뿐 오류로부터 복구를 시도하지 않습니다.타임아웃: 서버가 실행 중이 아니거나 전송 중 메시지가 손실되면 클라이언트 프로그램은 응답을 영원히 기다리며 중단됩니다. 이를 방지하려면
sendto및recvfrom호출에 타임아웃을 설정하는 것이 좋습니다.제한된 메시지 크기: 프로그램은 최대 100바이트의 메시지만 허용합니다. 더 큰 메시지를 보내야 하는 경우 더 작은 청크로 분할하여 별도로 보내야 합니다.
보안 부족: 프로그램은 암호화나 인증을 사용하지 않으므로 네트워크상의 누구든지 메시지를 가로채거나 위조할 수 있습니다. 통신을 보호해야 하는 경우 TLS 또는 SSH를 사용하여 메시지를 암호화하는 것을 고려해야 합니다.
요약
이 글의 코드는 간단한 UDP 클라이언트 및 서버 프로그램을 구현합니다. 클라이언트 프로그램은 talker이고 서버 프로그램은 listener입니다.
listener 프로그램을 실행한 다음 talker 프로그램을 실행합니다. listener 프로그램은 talker 프로그램의 메시지를 기다립니다. talker 프로그램이 실행되면 listener 프로그램에 메시지를 보내고, listener 프로그램은 이 메시지를 출력합니다.
listener 프로그램이 실행 중이 아니라면 talker가 메시지를 보내도 이를 받을 대상이 없으므로 해당 메시지는 손실됩니다.
UDP 프로토콜은 간단하고 가벼운 프로토콜로 사용하기 쉽습니다. 그러나 오류 검사나 복구를 제공하지 않으며 메시지가 목적지에 전달된다는 것을 보장하지 않으므로 안정적인 통신이 필요한 애플리케이션에는 적합하지 않습니다.