UDP 소켓 프로그래밍
연결 없는 recvfrom·sendto 흐름을 익히고 C와 Python의 UDP 에코 서버 및 간단한 DNS 클라이언트를 구현합니다.
TCP 소켓은 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 순서를 보장합니다.
반면 UDP 소켓은 연결 상태, 재전송, 순서 제어를 기본으로 제공하지 않습니다.
연결 핸드셰이크 없이 데이터그램을 바로 보내고, 도착 여부도 직접 확인하지 않습니다.
연결 핸드셰이크와 전송 계층 상태 관리 부담이 작습니다. 다만 실제 지연과 처리량이 TCP보다 항상 좋다는 뜻은 아닙니다.
UDP 소켓의 생명주기
이 에코 예제의 UDP 호출 흐름은 TCP보다 단순합니다.
서버 쪽에는 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 서버는 하나의 소켓으로 여러 발신자의 데이터그램을 처리합니다. 클라이언트별 소켓을 둘 수 없다는 API 제한은 아닙니다.
recvfrom()이 데이터와 함께 발신자의 주소를 반환하므로, 그 주소로 sendto()를 호출하면 됩니다.
Python으로 구현하는 UDP 에코 서버
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)C 예제는 최대 1023바이트, Python 예제는 최대 1024바이트를 한 번에 받습니다. 더 큰 데이터그램의 초과 부분은 이 Linux/Unix 수신 예제에서 버려질 수 있습니다. Python 서버는 출력 전에 UTF-8로 decode하므로 잘못된 인코딩의 데이터는 처리하지 못합니다.
이 순차 에코 처리에는 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의 차이
Linux에서 6바이트를 4바이트 버퍼로 받는 설명용 예입니다. UDP 초과분은 다음 recvfrom으로 이어서 읽을 수 없습니다. TCP의 한 번 수신 크기는 여기처럼 4바이트보다 작을 수도 있습니다.
-
메시지 경계: TCP는 스트림을 프레임으로 해석해야 하고, UDP는 도착한 데이터그램의 경계를 유지합니다. 전송한 데이터그램이 모두 도착한다는 보장은 없습니다. 0바이트 UDP 데이터그램도 유효하므로 TCP의 EOF와 구분합니다.
-
다중 클라이언트: TCP는 클라이언트마다 전용 연결 소켓이 생기며, 그 소켓을 처리하는 방식은 스레드, I/O 멀티플렉싱, 비동기 중 선택할 수 있습니다. 이 UDP 예제는 하나의 소켓에서 발신자 주소를 보고 응답 대상을 정합니다. 주소를 받았다는 사실만으로 발신자의 신원이 인증되지는 않습니다.
-
데이터 유실: TCP는 유실된 패킷을 재전송합니다. UDP는 보내고 잊습니다. 응답이 오지 않으면 클라이언트가 직접 타임아웃과 재시도를 구현해야 합니다.
UDP 실용 예제: DNS 클라이언트
DNS는 UDP 기반 프로토콜의 대표적인 예입니다.
일반 질의는 UDP 53번 포트를 주로 사용하지만, 응답이 크거나 잘린 경우에는 TCP로 재시도할 수 있습니다.
아래 표는 이 원문 DNS 예제가 실제로 읽는 필드와 검증하지 않는 조건을 구분합니다.
DNS 예제의 필드 읽기와 빠진 검증
| 구획 | 현재 코드가 하는 일 | 완전한 응답 검증과의 차이 |
|---|---|---|
| 헤더 | 12바이트를 풀고 Question·Answer 개수를 읽습니다. | 응답 ID, QR·RCODE·TC와 질의 일치를 검사하지 않습니다. |
| 이름과 질의 | 레이블 길이를 건너뛰고 압축 포인터이면 2바이트를 넘깁니다. | 포인터가 가리키는 이름을 복원·검증하지 않으며 경계 검사도 생략했습니다. |
| 답변 | Type A·Class IN·선언된 길이 4인 첫 RDATA를 문자열로 바꿉니다. | 실제 RDATA 4바이트 확보, 소유 이름과 질의의 관계를 확인하지 않습니다. |
| 전송 | UDP로 512바이트까지 받고 5초 timeout을 둡니다. | 응답 주소를 버리며 잘린 응답의 TCP 재시도나 재전송을 하지 않습니다. |
- 구획: 헤더
- 현재 코드가 하는 일: 12바이트를 풀고 Question·Answer 개수를 읽습니다.
- 완전한 응답 검증과의 차이: 응답 ID, QR·RCODE·TC와 질의 일치를 검사하지 않습니다.
- 구획: 이름과 질의
- 현재 코드가 하는 일: 레이블 길이를 건너뛰고 압축 포인터이면 2바이트를 넘깁니다.
- 완전한 응답 검증과의 차이: 포인터가 가리키는 이름을 복원·검증하지 않으며 경계 검사도 생략했습니다.
- 구획: 답변
- 현재 코드가 하는 일: Type A·Class IN·선언된 길이 4인 첫 RDATA를 문자열로 바꿉니다.
- 완전한 응답 검증과의 차이: 실제 RDATA 4바이트 확보, 소유 이름과 질의의 관계를 확인하지 않습니다.
- 구획: 전송
- 현재 코드가 하는 일: UDP로 512바이트까지 받고 5초 timeout을 둡니다.
- 완전한 응답 검증과의 차이: 응답 주소를 버리며 잘린 응답의 TCP 재시도나 재전송을 하지 않습니다.
다음 코드는 example.com 같은 ASCII 도메인의 A 질의를 구성하고 응답의 첫 A 주소를 찾는 필드 읽기 예제입니다. 실제 resolver를 대신하지 않습니다. IDNA·도메인 길이 검증과 예외 시 close는 생략했고, 이 교재에서는 외부 DNS로 실행하지 않았습니다.
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이 바로 이런 접근의 대표적인 예입니다.
재시도는 같은 요청을 중복 실행시킬 수 있습니다. 요청 식별자, 전체 deadline, 재시도 횟수와 backoff, 오래된 응답 처리 및 멱등성을 함께 설계해야 합니다.
다음 절에서는 클라이언트마다 스레드를 만드는 모델의 한계를 극복하는 I/O 멀티플렉싱을 살펴보겠습니다.