UDP 소켓 프로그래밍
연결 없는 recvfrom·sendto 흐름을 익히고 C와 Python의 UDP 에코 서버 및 간단한 DNS 클라이언트를 구현합니다.
TCP 소켓은 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 순서를 보장합니다.
반면 UDP 소켓은 연결 상태, 재전송, 순서 제어를 기본으로 제공하지 않습니다.
연결 핸드셰이크 없이 데이터그램을 바로 보내고, 도착 여부도 직접 확인하지 않습니다.
그 대신 빠르고 간결합니다.
UDP 소켓의 생명주기
TCP 소켓과 비교하면 UDP의 흐름은 극적으로 단순합니다.
TCP는 연결마다 fd가 생기지만, UDP는 포트에 묶인 하나의 socket이 여러 클라이언트의 datagram을 받는다.
- 입구 listen socket bind된
datagram socket
- 클라이언트 식별 connected fd payload
함께 온 addr
- 수신 호출 accept 후 read
recvfrom
- 응답 호출 connected fd에 write
sendto(addr)
- 경계 바이트 stream이라 앱 프레이밍
필요 datagram 경계는 보존
서버 쪽에는 listen()과 accept()가 없습니다.
TCP의 3-way handshake에 해당하는 단계가 통째로 사라집니다.
서버는 누가 보냈는지를 recvfrom()의 반환값으로 알 수 있고, 그 주소로 sendto()를 호출하여 응답합니다.
다만 UDP 소켓에도 connect()를 호출해 기본 상대 주소를 지정할 수는 있습니다.
이때도 TCP처럼 네트워크 연결을 맺는 것은 아니며, send()/recv()를 쓰기 편하게 만들고 다른 주소에서 온 데이터그램을 걸러내는 의미에 가깝습니다.
C로 구현하는 UDP 에코 서버
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[1024];
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
if (bind(sockfd, (struct sockaddr *)&server_addr,
sizeof(server_addr)) < 0) {
perror("bind failed");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("UDP Echo Server on port 8080\n");
while (1) {
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr *)&client_addr, &client_len);
if (n < 0) continue;
buffer[n] = '\0';
printf("Received from %s:%d: %s\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), buffer);
sendto(sockfd, buffer, n, 0,
(struct sockaddr *)&client_addr, client_len);
}
close(sockfd);
return 0;
}SOCK_DGRAM이 UDP를 나타냅니다.
TCP의 SOCK_STREAM과 대비됩니다.
TCP 서버는 accept()가 클라이언트별 전용 소켓을 반환했습니다.
UDP 서버는 하나의 소켓으로 모든 클라이언트를 처리합니다.
recvfrom()이 데이터와 함께 발신자의 주소를 반환하므로, 그 주소로 sendto()를 호출하면 됩니다.
Python으로 구현하는 UDP 에코 서버
하나의 socket이 payload와 발신자 주소를 함께 받고, 처리 결과를 같은 주소로 되돌려 보낸다.
- 1 client
client sendto(payload, server) 서버 주소로 datagram 전송
- 2 server
server recvfrom() payload와 client addr을 같이 받음
- 3 logic
logic payload 처리 echo라면 그대로 사용
- 4 server
server sendto(payload, addr) 받은 addr로 응답
- 5 client
client recvfrom() 응답 datagram 수신
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("0.0.0.0", 8080))
print("UDP Echo Server on port 8080")
while True:
data, addr = server.recvfrom(1024)
print(f"Received from {addr}: {data.decode()}")
server.sendto(data, addr)TCP 버전과 비교하면 코드가 절반 이하입니다.
accept()도, 스레드도, 연결 관리도 필요 없습니다.
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(3.0) # 3초 타임아웃 (UDP는 응답 보장 없음)
try:
client.sendto(b"Hello, UDP!", ("127.0.0.1", 8080))
data, addr = client.recvfrom(1024)
print(f"Received: {data.decode()}")
except socket.timeout:
print("No response (timeout)")
finally:
client.close()TCP 소켓과의 핵심 차이
-
메시지 경계 보존: TCP는 바이트 스트림이라 메시지 경계를 직접 관리해야 했습니다. UDP는 데이터그램 단위로 전송하므로
sendto()로 보낸 데이터가recvfrom()에서 하나의 단위로 수신됩니다. 단, 수신 버퍼가 데이터그램보다 작으면 잘릴 수 있으므로 버퍼 크기를 의식해야 합니다. -
다중 클라이언트: TCP는 클라이언트마다 전용 연결 소켓이 생기며, 그 소켓을 처리하는 방식은 스레드, I/O 멀티플렉싱, 비동기 중 선택할 수 있습니다. UDP는 하나의 소켓이 모든 클라이언트의 데이터그램을 받고, 발신자 주소로 클라이언트를 구분합니다.
-
데이터 유실: TCP는 유실된 패킷을 재전송합니다. UDP는 보내고 잊습니다. 응답이 오지 않으면 클라이언트가 직접 타임아웃과 재시도를 구현해야 합니다.
UDP 실용 예제: DNS 클라이언트
DNS는 UDP 기반 프로토콜의 대표적인 예입니다.
일반 질의는 UDP 53번 포트를 주로 사용하지만, 응답이 크거나 잘린 경우에는 TCP로 재시도할 수 있습니다.
아래 그림에서는 가장 단순한 A 레코드 질의를 기준으로 DNS 패킷을 Header, Question, Answer 순서로 어떻게 읽는지 먼저 확인합니다.
A 레코드 질의의 바이트열은 세 구획으로 나뉘며, 파서는 길이와 압축 포인터를 따라 4바이트 IPv4 주소까지 이동합니다.
- ID
Header 12 bytes 요청과 응답을 매칭 질의·재귀·응답·오류 상태 Question·Answer 개수
- QNAME
Question query example.com → 7·example·3·com·00 A(1) · IPv4 요청 IN(1) · Internet
- NAME
Answer response 압축 포인터일 수 있음 A · IN인지 확인 캐시 시간과 RDATA 길이 길이 4면 IPv4 주소
- 01
Header 작성 ID·flags·QDCOUNT 1을 network byte order로 pack
- 02
Question 인코딩 QNAME 뒤에 Type A와 Class IN을 붙임
- 03
UDP 전송 resolver:53으로 보내고 응답 timeout을 둠
- 04
Answer 판독 ID·RCODE 확인 → Question skip → pointer 처리 → A/IN/LEN 4 수용
이제 같은 구조를 코드로 직접 만들어 보겠습니다.
import socket
import struct
def build_dns_query(domain):
# DNS 헤더 (12바이트)
tx_id = 0x1234
flags = 0x0100 # 표준 질의, 재귀 요청
header = struct.pack("!HHHHHH", tx_id, flags, 1, 0, 0, 0)
# 질의 섹션
question = b""
for label in domain.split("."):
question += struct.pack("!B", len(label)) + label.encode()
question += b"\x00" # 종료
question += struct.pack("!HH", 1, 1) # Type A, Class IN
return header + question
def parse_dns_response(data):
def skip_name(offset):
while True:
length = data[offset]
if length & 0xC0 == 0xC0: # 압축 포인터
return offset + 2
if length == 0:
return offset + 1
offset += 1 + length
_, _, qdcount, ancount, _, _ = struct.unpack("!HHHHHH", data[:12])
offset = 12
for _ in range(qdcount):
offset = skip_name(offset)
offset += 4 # QTYPE, QCLASS
for _ in range(ancount):
offset = skip_name(offset)
rtype, rclass, _, rdlength = struct.unpack(
"!HHIH", data[offset:offset + 10]
)
offset += 10
rdata = data[offset:offset + rdlength]
offset += rdlength
if rtype == 1 and rclass == 1 and rdlength == 4: # A, IN
return ".".join(str(b) for b in rdata)
raise ValueError("A record not found")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5.0)
query = build_dns_query("example.com")
sock.sendto(query, ("8.8.8.8", 53))
response, _ = sock.recvfrom(512)
ip = parse_dns_response(response)
print(f"example.com → {ip}")
sock.close()이런 특성 때문에 UDP 위에서 신뢰성이 필요한 경우, 애플리케이션 레벨에서 시퀀스 번호, 재전송, 확인 응답 등을 직접 구현합니다.
7장에서 다룬 QUIC이 바로 이런 접근의 대표적인 예입니다.
마지막으로 UDP 소켓을 구현할 때 확인해야 하는 상태, 메시지 경계, 재시도 책임을 정리합니다.
UDP가 생략한 순서 보장과 재전송은 필요할 때 애플리케이션 메시지 필드와 타이머로 복원한다.
- 11 send
send seq와 payload 전송 요청 식별자 부여
- 22 timer
timer timeout 시작 응답 대기 시간 제한
- 33 ack
ack seq 확인 맞는 응답만 완료 처리
- 44 retry
retry 재전송 또는 포기 횟수와 backoff 적용
UDP 소켓 프로그래밍에서는 소켓 상태, 송수신 경계, 오류 반환, 재시도 조건을 확인합니다.
UDP는 데이터그램을 보내지만 도착, 순서, 중복 제거를 보장하지 않습니다. 필요한 신뢰성은 애플리케이션 프로토콜이 timeout, sequence, retry, idempotency로 설계해야 합니다.
- 1요청 ID와 deadline을 붙인다
send 요청 ID와 deadline을 붙인다 응답을 매칭할 ID와 기다릴 시간을 정합니다.
- 2응답 없으면 timeout
wait 응답 없으면 timeout 무한 대기하지 않고 재시도나 실패로 전환합니다.
- 3재시도는 중복을 만들 수 있다
retry 재시도는 중복을 만들 수 있다 서버 작업이 여러 번 실행돼도 안전한지 확인합니다.
- 4늦은 응답과 순서를 판정
accept 늦은 응답과 순서를 판정 오래된 응답, 중복 응답, 순서가 바뀐 응답을 처리합니다.
- 5질의는 작고 재시도가 쉽다
DNS 질의는 작고 재시도가 쉽다 응답이 없으면 다른 resolver나 TCP로 다시 시도할 수 있습니다.
- 6중복 실행이 위험하면 UDP 단독은 피한다
결제/주문 중복 실행이 위험하면 UDP 단독은 피한다 재시도될 수 있는 요청은 idempotency 설계가 먼저 필요합니다.
- 7UDP 위에 필요한 전송 기능을 다시 만든다
QUIC UDP 위에 필요한 전송 기능을 다시 만든다 재전송, 흐름 제어, 암호화를 프로토콜이 직접 제공합니다.
다음 절에서는 클라이언트마다 스레드를 만드는 모델의 한계를 극복하는 I/O 멀티플렉싱을 살펴보겠습니다.