이번 글에서는 Unity와의 연동이 아닌, Visual Studio 환경에서 동작하는 클라이언트 단독 코드를 통해 UDP/IP 통신을 다룹니다. 구현 언어는 C++이지만, 개념 이해에 집중할 수 있도록 C 스타일의 코드 형식으로 작성하여 관련 기능들을 단계적으로 학습해보겠습니다.
이전 글에서 TCP에 대해서만 자세히 다뤘기에 본격적인 프로그래밍에 들어가기 전에, 먼저 UDP의 동작 원리에 대한 개념 설명을 간단히 정리한 뒤 구현을 진행하겠습니다.
(이 글은 여러 기술 문서와 교재 등 다양한 자료를 참고하였습니다)
UDP(User Datagram Protocol)는 보내는 쪽에서 전달한 데이터를 있는 그대로 전송하려고 시도하는 프로토콜입니다. TCP와 달리 데이터 전송 전에 별도의 연결 과정이 필요 없으며, sendto(), recvfrom() 함수만으로 데이터를 주고받을 수 있습니다.
UDP는 데이터를 보내기 전에 상대방과 연결(Connection)을 맺지 않습니다. 이러한 특성 때문에 UDP는 비연결형(connectionless) 프로토콜이라고 불립니다.
연결 과정이 없기 때문에 통신 절차가 단순하고 오버헤드가 적으며, 그만큼 빠른 전송이 가능합니다. 하지만 그 대가로, 보낸 데이터가 상대방에게 도착했는지, 순서가 맞는지, 중복되었는지 등을 보장하지 않습니다.
즉 UDP란? 속도와 단순성을 중시하는 비연결형 프로토콜입니다.
위에 UDP 특징 + 알파로 자세히 설명해보겠습니다.

UDP 통신은 데이터를 보내기 전에 상대방과 연결을 맺지 않습니다. 즉, TCP에서 필수적인 3-way handshake 과정이 존재하지 않습니다.
송신자는 상대방의 IP 주소와 포트 번호만 알고 있다면 언제든지 데이터를 전송할 수 있으며, 수신자는 도착한 데이터를 단순히 받아 처리합니다.
이러한 구조로 인해 UDP에서는 “상대방이 실제로 존재하는지”, “지금 통신 가능한 상태인지”를 사전에 확인할 수 없습니다.
UDP에서는 연결이라는 개념 자체가 존재하지 않습니다.
UDP는 IP가 제공하는 비신뢰적인 특성을 그대로 사용합니다. 패킷 유실, 중복 수신, 순서 뒤바뀜이 발생하더라도 이를 복구하거나 재전송하지 않습니다.
UDP는 데이터에 대해 ACK(Acknowledgement)를 요구하지 않으며, 재전송 메커니즘도 존재하지 않습니다. 따라서 데이터가 중간에 유실되더라도 송신자는 이를 알 수 없습니다.
이러한 특성 때문에 UDP는 신뢰성보다 속도와 실시간성이 중요한 상황에서 주로 사용됩니다. 신뢰성이 필요한 경우, UDP 위에 응용 계층에서 직접 신뢰성 로직을 구현해야 합니다.
UDP는 특정 대상에게만 보내는 Unicast뿐만 아니라, 여러 대상에게 동시에 보내는 Multicast,
네트워크 전체에 전송하는 Broadcast 통신을 지원합니다.
이는 UDP가 연결을 기반으로 하지 않기 때문에 가능한 구조입니다. 이러한 특성 덕분에 UDP는 실시간 스트리밍, 게임, 음성 통신 등에서 널리 사용됩니다.
UDP는 TCP와 달리 메시지 단위(Message-based) 통신을 수행합니다. 즉, 한 번의 sendto() 호출로 보낸 데이터는 하나의 Datagram으로 취급됩니다.
수신 측에서는 recvfrom() 호출 시, 하나의 Datagram 단위로 데이터를 수신하며 송신 측에서 보낸 데이터의 경계가 유지됩니다
Datagram 동작 예시
sendto("aaa");
sendto("bbb");
sendto("ccc");
수신 측에서는 다음과 같이 받습니다.
recvfrom() -> "aaa"
recvfrom() -> "bbb"
recvfrom() -> "ccc"
UDP에서는 TCP처럼 데이터가 합쳐지거나 쪼개져 수신되는 일이 없습니다. 단, Datagram의 크기가 수신 버퍼보다 크면 잘려서 수신될 수 있습니다.
Q1. 그러면 UDP는 항상 안전한가요?
UDP는 데이터 경계를 보장하지만, 전달 자체를 보장하지는 않습니다. 즉, 메시지가 유실되거나 순서가 뒤바뀔 수 있으며, 중복 수신도 발생할 수 있습니다.
UDP 역시 인터넷 프로토콜(IP) 위에서 동작합니다. OSI 7계층 모델 기준으로 보면, UDP는 4계층(Transport Layer)에 위치합니다.
IP는 기본적으로 다음과 같은 특징을 가집니다.
UDP는 이러한 IP의 특성을 거의 그대로 노출하는 전송 계층 프로토콜입니다. TCP는 IP의 한계를 보완하지만, UDP는 IP의 특성을 그대로 활용합니다.
UDP Datagram에는 TCP와 같은 시퀀스 번호 기반의 순서 제어 기능이 존재하지 않습니다. 네트워크 상황에 따라 Datagram이 뒤섞여 도착하거나 중복되어 도착하거나 아예 도착하지 않을 수 있습니다 이 경우, UDP는 이를 정렬하거나 제거하지 않으며, 수신된 그대로 응용 프로그램에 전달합니다.
따라서 순서 보장이 필요하다면, 응용 계층에서 직접 시퀀스 번호를 설계해야 합니다.
UDP의 개념을 이해했으니, 이제 실제 코드로 확인해 볼 차례입니다. 이번 글의 예제 코드는 개념을 최대한 직관적으로 전달하기 위해 C++이지만 C 스타일로 작성되었으며, 싱글 스레드 기준으로 동작합니다.
(추후 멀티 스레드 환경, IOCP 모델, C++ 스타일, Unity 연동 등등 점점 발전시켜 보겠습니다.)

위 사진이 UDP Server와 CLient에 대한 다이어그램입니다. TCP Server/Client와 마찬가지로 나씩 설명해보며 최종적으로 코드까지 이해할 수 있도록 해보겠습니다. 먼저 함수에 대한 간략한 설명입니다.
bind() : 소켓의 지역IP주소와 지역 포트 번호를 결정합니다.connect() : 서버와 논리적 연결을 설정합니다.sendto(), recvfrom() : 데이터 전송 함수로 클라이언트와 통신을 수행한 후 closesocket() 함수로 소켓을 닫습니다.그러면 UDPServer..cpp 전체 코드를 보며 하나씩 설명해보겠습니다.
#include<winsock2.h>
#include<ws2tcpip.h>
#include<iostream>
#pragma comment(lib, "ws2_32.lib")
#define SERVERPORT 9000
#define BUFSIZE 512
using std::cout;
using std::endl;
int main()
{
int retval;
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return 1;
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == SOCKET_ERROR)
{
//TODD : 애러 구현
}
//SO_REUSEADDR 소켓 옵션 설정
DWORD optval = 1;
retval = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&optval, sizeof(optval));
if (retval == SOCKET_ERROR)
{
//TODO : 에러 처리
}
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(sock, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
if (sock == SOCKET_ERROR)
{
//TODD : 애러 구현
}
struct sockaddr_in clientaddr;
int addrlen;
char buf[BUFSIZE + 1];
while (true)
{
//데이터 받기
addrlen = sizeof(clientaddr);
retval = recvfrom(sock, buf, BUFSIZE, 0, (struct sockaddr*)&clientaddr, &addrlen);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
}
buf[retval] = '\0';
char addr[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &clientaddr.sin_addr, addr, sizeof(addr));
cout << "[UDP/" << addr << ":" << ntohs(clientaddr.sin_port) << "] " << buf << endl;
//데이터 보내기
retval = sendto(sock, buf, retval, 0, (struct sockaddr*)&clientaddr, sizeof(clientaddr));
if (retval == SOCKET_ERROR)
{
//TOD : 애러 구현
}
}
closesocket(sock);
WSACleanup();
return 0;
}
전처리기, 원속 초기화 & 종료, socket() 함수에 대해서는 이전 글에 설명했기에 TCPServer.cpp와 비교하며 설명을 해보겠습니다.
TCPServer.cpp에서는 소켓 생성 시 listen_sock을 선언하여 연결을 해주었습니다. 하지만 UDP는 설명했듯이 연결 과정이 필요없기에 변수명을 sock으로 선언했습니다.
bind() 함수는 지역IP주소와 지역 포트 번호를 결정하기에 이에 맞는 인자값을 변수로 선언합니다.
sockaddr_in 구조체는 내부에 포트 번호, IP주소, 주소 체계 등을 가지고 있기에 아래와 같이 선언합니다. 또한 Byte Order을 맞추기 위해 hton()함수를 사용하였습니다.
INADDR_ANY : 서버 소켓을 시스템의 모든 사용 가능한 네트워크 인터페이스(IP 주소)에 바인딩하도록 지시하는 값입니다.sin_addr.s_addr : IPv4 주소를 담는 필드이며, IPv6에서는 sockaddr_in6의 sin6_addr를 사용한다hton : Host Byte Order로 저장된 값을 입력받아 Network Byte Order로 변환한 값을 리턴합니다.ntoh : Network Byte Order로 저장된 값을 입력받아 Host Byte Order로 변환한 값을 리턴합니다.s: 16bit값을 입력으로 받습니다.l : 32bit값을 입력으로 받습니다.struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(SERVERPORT);
retval = bind(listen_sock, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
UDPServer.cpp를 보았으니 UDPClient.cpp 전체 코드를 보겠습니다.
#include<winsock2.h>
#include<ws2tcpip.h>
#include<iostream>
#pragma comment(lib, "ws2_32.lib")
char* SERVERIP = (char*)"127.0.0.1";
#define SERVERPORT 9000
#define BUFSIZE 512
using std::cout;
using std::endl;
int main()
{
int retval;
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return 1;
SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock == SOCKET_ERROR)
{
//TODD : 애러 구현
}
//SO_REUSEADDR 소켓 옵션 설정
DWORD optval = 1;
retval = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&optval, sizeof(optval));
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, SERVERIP, &serveraddr.sin_addr);
serveraddr.sin_port = htons(SERVERPORT);
//데이터 통신에 사용할 변수
struct sockaddr_in peeraddr;
int addrlen;
char buf[BUFSIZE + 1];
int len;
while (true)
{
cout << "[보낼 데이터] ";
if (fgets(buf, BUFSIZE + 1, stdin) == NULL)
break;
len = (int)strlen(buf);
if (buf[len - 1] == '\n')
buf[len - 1] = '\0';
if (strlen(buf) == 0)
break;
//데이터 보내기
retval = sendto(sock, buf, (int)strlen(buf), 0, (struct sockaddr*)&serveraddr, sizeof(serveraddr));
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
}
cout << "[UDP Client] " << retval << "바이트를 보냈습니다." << endl;
//데이터 받기
addrlen = sizeof(peeraddr);
retval = recvfrom(sock, buf, BUFSIZE, 0, (struct sockaddr*)&peeraddr, &addrlen);
if (retval == SOCKET_ERROR)
{
//TODD : 애러 구현
}
//송신자 IP 주소 체크
if (memcmp(&peeraddr, &serveraddr, sizeof(peeraddr)))
{
cout << "[오류] 잘못된 데이터입니다." << endl;
break;
}
buf[retval] = '\0';
cout << "[UDP Client] " << retval << "바이트를 받았습니다." << endl;
cout << "[받을 데이터] " << buf << endl << endl;
}
closesocket(sock);
WSACleanup();
return 0;
}
UDP 통신에서도 데이터 송수신은 핵심이지만, TCP와는 몇 가지 중요한 차이점이 있습니다.
UDP는 연결(Connection) 개념이 없기 때문에, 송신 및 수신 시마다 통신 대상의 주소 정보를 함께 전달해야 합니다.
OS는 UDP 소켓 역시 내부적으로 소켓 객체를 관리하며, TCP와 마찬가지로 송신 버퍼와 수신 버퍼를 포함합니다. 다만 UDP의 경우, 이 버퍼에 저장되는 데이터는 스트림이 아닌 Datagram(메시지) 단위로 관리됩니다.
송신 버퍼(Send Buffer)는 응용 프로그램이
sendto()로 전달한 Datagram을 실제 네트워크로 전송하기 전까지 임시로 저장하는 영역입니다.
UDP 역시 sendto() 함수가 리턴했다고 해서 데이터가 즉시 네트워크로 전송되었다고 보장할 수는 없습니다. 전송은 하부 프로토콜(IP)을 통해 비동기적으로 이루어집니다.
수신 버퍼(Receive Buffer)는 네트워크로부터 도착한 Datagram을 응용 프로그램이
recvfrom()로 읽어가기 전까지 저장하는 영역입니다.
TCP와 달리, UDP는 데이터 경계를 유지하므로 한 번의 recvfrom() 호출은 최대 하나의 Datagram만을 반환합니다.
#include <winsock2.h>
int sendto
(
SOCKET sock,
const char *buf,
int len,
int flags,
const struct sockaddr *addr,
int addrlen
)
sock : UDP 통신에 사용할 소켓입니다.buf : 보낼 데이터를 담고 있는 응용 프로그램 버퍼의 주소입니다.len : 보낼 데이터의 크기(Byte 단위)입니다.flags : sendto() 함수의 동작을 바꾸는 옵션으로, 대부분 0을 사용합니다.addr : 목적지 주소를 담고 있는 소켓 주소 구조체입니다. UDP의 경우, 특정 호스트나 라우터 주소는 물론이고 Broadcast나 Multicast 주소를 사용할 수 있습니다.addrlen : 목적지 주솔르 담고 있는 소켓 주소 구조체의 크기(Byte 단위)입니다.sendto() 함수는 응용 프로그램의 데이터를 하나의 Datagram으로 취급하여 OS의 송신 버퍼에 복사한 후 즉시 리턴합니다. UDP는 연결 상태를 확인하지 않기 때문에, sendto() 호출이 성공하더라도 데이터가 실제로 상대방에게 도착했는지는 알 수 없습니다.
#include <winsock2.h>
int recvfrom
(
SOCKET sock,
char *buf,
int len,
int flags,
struct sockaddr *from,
int *fromlen
)
sock : UDP 통신에 사용할 소켓입니다.buf : 수신한 데이터를 저장할 응용 프로그램 버퍼의 주소입니다.len : 수신할 수 있는 최대 데이터 크기(Byte 단위)입니다.flags : recvfrom() 함수의 동작을 바꾸는 옵션입니다.from : 데이터를 보낸 상대방의 주소 정보를 저장할 소켓 주소 구조체입니다.fromlen : 소켓 주소 구조체의 크기를 저장하는 변수의 주소입니다.recvfrom() 함수는 OS의 수신 버퍼에 도착한 하나의 Datagram을 응용 프로그램 버퍼에 복사합니다. 이때, len이 Datagram의 실제 크기보다 작으면 데이터는 잘려서 수신되며, 나머지 데이터는 복구할 수 없습니다.
UDP는 메시지 단위 전송을 보장하지만, 데이터의 유실, 중복, 순서 뒤바뀜은 보장하지 않습니다. 따라서 이러한 처리가 필요하다면, 반드시 응용 계층에서 직접 구현해야 합니다.
논외로,
send(),recv()함수가TCP전용,sendto(),recvfrom()함수가UDP전용인 것은 아닙니다. 실제로sendto()와recvfrom()은 TCP 소켓에서도 사용할 수 있으며, 반대로send()와recv()역시 UDP 소켓에서 사용 가능합니다.다만, 이러한 사용 방식은 연결 상태와 주소 처리 방식에 대한 이해가 선행되지 않으면 혼동을 유발할 수 있기 때문에, 글에서는 설명의 명확성을 위해 제외하였습니다. 자세한 내용은 아래 관련 링크를 걸어두었습니다.
실행시 문제없이 잘 작동합니다.
UDPServer.cpp
UDPClient.cpp
이때 CMD로 진입하여 netstat -a -n -p tcp | findstr 9000 명령을 실행하면 아래와 같이 출력됩니다.

netstat 명령은 네트워크 연결과 통계 정보를 표시하는 유틸리티로, Window에서 기본으로 제공합니다. '-a' 옵션은 모든 연결과 연결 대기 포트를 표시합니다.'-n' 옵션은 주소와 포트 번호를 숫자 형식으로 표시합니다. '-p tcp' 옵션을 사용하면 TCP 상태만 골라서 볼 수 있습니다. findstr명령은 특정 패턴을 찾을 때 사용하는데, 여기서는 포트 번호 9000을 포함하는 줄만 골라내는 기능을 합니다. '|('파이프'라 부름)은 두 명령을 연결하여 앞쪽 명령의 출력을 다음 명령의 입력으로 넘기는 역할을 합니다.위 사진을 보면 자신의 IP 주소와 포트 번호는 표시되지만, 통신 상대의 IP주소와 포트 번호는 *로 출력됩니다.
위 그림에서 자신의 IP 주소와 포트 번호는 표시되지만, 통신 상대의 IP 주소와 포트 번호는 *로 출력됩니다. 이는 UDP가 연결(Connection) 개념이 없는 비연결형 프로토콜이며, 해당 소켓이 특정 통신 상대와 바인딩되어 있지 않기 때문입니다.
이번 글에서는 UDP/IP의 개념과 최소 구현에 대해 알아보았습니다.
TCP/IP와 UDP/IP를 직접 구현해 보면서, Unity 연동 이전에 Visual Studio 환경에서 동작하는 클라이언트 단독 코드를 통해 소켓 프로그래밍의 기본 흐름을 익히셨을 것이라 생각합니다. 소켓 생성부터 데이터 송수신까지의 과정을 한 번이라도 직접 경험해보는 것은, 이후 네트워크 프로그래밍을 확장해 나가는 데 매우 중요한 기반이 됩니다.
Unity와의 연동을 바로 진행하기보다는, 그에 앞서 소켓 옵션, 논블리킹(Non-blocking) I/O, 멀티스레드 환경 등 네트워크 프로그래밍에서 필수적으로 알아야 할 요소들을 먼저 학습하는 것이 좋다고 판단했습니다. 이러한 개념들을 충분히 이해한 상태에서 Unity 연동을 진행해도 늦지 않으며, 오히려 구조를 더 명확히 이해할 수 있습니다.
다음 글에서는 이러한 흐름에 맞춰, 소켓 옵션(Socket Options)에 대해 하나씩 살펴보도록 하겠습니다.
[교보문구]
https://product.kyobobook.co.kr/detail/S000061352062
https://product.kyobobook.co.kr/detail/S000001792817