본문으로 건너뛰기

안동민 개발노트

본문 시작

UDP 소켓 프로그래밍

연결 없는 recvfrom·sendto 흐름을 익히고 C와 Python의 UDP 에코 서버 및 간단한 DNS 클라이언트를 구현합니다.

TCP 소켓은 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 순서를 보장합니다.

반면 UDP 소켓은 연결 상태, 재전송, 순서 제어를 기본으로 제공하지 않습니다.

연결 핸드셰이크 없이 데이터그램을 바로 보내고, 도착 여부도 직접 확인하지 않습니다.

그 대신 빠르고 간결합니다.


UDP 소켓의 생명주기

TCP 소켓과 비교하면 UDP의 흐름은 극적으로 단순합니다.

UDP 서버는 accept 없이 한 socket에서 주소와 데이터를 함께 받는다

TCP는 연결마다 fd가 생기지만, UDP는 포트에 묶인 하나의 socket이 여러 클라이언트의 datagram을 받는다.

  1. 입구 listen socket bind된

    datagram socket

  2. 클라이언트 식별 connected fd payload

    함께 온 addr

  3. 수신 호출 accept 후 read

    recvfrom

  4. 응답 호출 connected fd에 write

    sendto(addr)

  5. 경계 바이트 stream이라 앱 프레이밍

    필요 datagram 경계는 보존

서버 쪽에는 listen()accept()가 없습니다.

TCP의 3-way handshake에 해당하는 단계가 통째로 사라집니다.

서버는 누가 보냈는지를 recvfrom()의 반환값으로 알 수 있고, 그 주소로 sendto()를 호출하여 응답합니다.

다만 UDP 소켓에도 connect()를 호출해 기본 상대 주소를 지정할 수는 있습니다.

이때도 TCP처럼 네트워크 연결을 맺는 것은 아니며, send()/recv()를 쓰기 편하게 만들고 다른 주소에서 온 데이터그램을 걸러내는 의미에 가깝습니다.


C로 구현하는 UDP 에코 서버

udp_echo_server.c
#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 에코 서버

UDP 에코 서버는 받은 addr을 그대로 응답 주소로 쓴다

하나의 socket이 payload와 발신자 주소를 함께 받고, 처리 결과를 같은 주소로 되돌려 보낸다.

  1. 1 client

    client sendto(payload, server) 서버 주소로 datagram 전송

  2. 2 server

    server recvfrom() payload와 client addr을 같이 받음

  3. 3 logic

    logic payload 처리 echo라면 그대로 사용

  4. 4 server

    server sendto(payload, addr) 받은 addr로 응답

  5. 5 client

    client recvfrom() 응답 datagram 수신

udp_echo_server.py
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()도, 스레드도, 연결 관리도 필요 없습니다.

udp_echo_client.py
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는 독립 데이터그램이다

TCP와 UDP의 차이는 “빠르다/느리다”가 아니라 상태를 누가 책임지는가입니다. TCP는 연결과 순서를 프로토콜이 관리하고, UDP는 애플리케이션이 필요한 책임을 직접 설계합니다.

  1. 짧은 질의/응답은 UDP에 잘 맞는다

    DNS 짧은 질의/응답은 UDP에 잘 맞는다 응답이 크거나 잘리면 TCP 재시도를 둡니다.

  2. 순서와 연결 상태가 중요하면 TCP가 편하다

    chat 순서와 연결 상태가 중요하면 TCP가 편하다 대신 메시지 경계는 애플리케이션이 복원해야 합니다.

  3. 늦은 데이터가 가치 없으면 UDP 설계를 고려한다

    realtime 늦은 데이터가 가치 없으면 UDP 설계를 고려한다 게임, 음성, QUIC처럼 필요한 보장을 위 계층에서 선택합니다.

  • 메시지 경계 보존: TCP는 바이트 스트림이라 메시지 경계를 직접 관리해야 했습니다. UDP는 데이터그램 단위로 전송하므로 sendto()로 보낸 데이터가 recvfrom()에서 하나의 단위로 수신됩니다. 단, 수신 버퍼가 데이터그램보다 작으면 잘릴 수 있으므로 버퍼 크기를 의식해야 합니다.

  • 다중 클라이언트: TCP는 클라이언트마다 전용 연결 소켓이 생기며, 그 소켓을 처리하는 방식은 스레드, I/O 멀티플렉싱, 비동기 중 선택할 수 있습니다. UDP는 하나의 소켓이 모든 클라이언트의 데이터그램을 받고, 발신자 주소로 클라이언트를 구분합니다.

  • 데이터 유실: TCP는 유실된 패킷을 재전송합니다. UDP는 보내고 잊습니다. 응답이 오지 않으면 클라이언트가 직접 타임아웃과 재시도를 구현해야 합니다.


UDP 실용 예제: DNS 클라이언트

DNS는 UDP 기반 프로토콜의 대표적인 예입니다.

일반 질의는 UDP 53번 포트를 주로 사용하지만, 응답이 크거나 잘린 경우에는 TCP로 재시도할 수 있습니다.

아래 그림에서는 가장 단순한 A 레코드 질의를 기준으로 DNS 패킷을 Header, Question, Answer 순서로 어떻게 읽는지 먼저 확인합니다.

DNS 응답은 Header를 검증하고 Question을 건너 Answer를 읽는다

A 레코드 질의의 바이트열은 세 구획으로 나뉘며, 파서는 길이와 압축 포인터를 따라 4바이트 IPv4 주소까지 이동합니다.

  1. ID

    Header 12 bytes 요청과 응답을 매칭 질의·재귀·응답·오류 상태 Question·Answer 개수

  2. QNAME

    Question query example.com → 7·example·3·com·00 A(1) · IPv4 요청 IN(1) · Internet

  3. NAME

    Answer response 압축 포인터일 수 있음 A · IN인지 확인 캐시 시간과 RDATA 길이 길이 4면 IPv4 주소

  4. 01

    Header 작성 ID·flags·QDCOUNT 1을 network byte order로 pack

  5. 02

    Question 인코딩 QNAME 뒤에 Type A와 Class IN을 붙임

  6. 03

    UDP 전송 resolver:53으로 보내고 응답 timeout을 둠

  7. 04

    Answer 판독 ID·RCODE 확인 → Question skip → pointer 처리 → A/IN/LEN 4 수용

이제 같은 구조를 코드로 직접 만들어 보겠습니다.

simple_dns_client.py
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 신뢰성은 앱 프로토콜이 sequence와 timeout으로 덧씌운다

UDP가 생략한 순서 보장과 재전송은 필요할 때 애플리케이션 메시지 필드와 타이머로 복원한다.

  1. 1
    1 send

    send seq와 payload 전송 요청 식별자 부여

  2. 2
    2 timer

    timer timeout 시작 응답 대기 시간 제한

  3. 3
    3 ack

    ack seq 확인 맞는 응답만 완료 처리

  4. 4
    4 retry

    retry 재전송 또는 포기 횟수와 backoff 적용

UDP 소켓 프로그래밍에서는 소켓 상태, 송수신 경계, 오류 반환, 재시도 조건을 확인합니다.

UDP 애플리케이션은 손실, 순서, 재시도 책임을 직접 정한다

UDP는 데이터그램을 보내지만 도착, 순서, 중복 제거를 보장하지 않습니다. 필요한 신뢰성은 애플리케이션 프로토콜이 timeout, sequence, retry, idempotency로 설계해야 합니다.

  1. 1
    요청 ID와 deadline을 붙인다

    send 요청 ID와 deadline을 붙인다 응답을 매칭할 ID와 기다릴 시간을 정합니다.

  2. 2
    응답 없으면 timeout

    wait 응답 없으면 timeout 무한 대기하지 않고 재시도나 실패로 전환합니다.

  3. 3
    재시도는 중복을 만들 수 있다

    retry 재시도는 중복을 만들 수 있다 서버 작업이 여러 번 실행돼도 안전한지 확인합니다.

  4. 4
    늦은 응답과 순서를 판정

    accept 늦은 응답과 순서를 판정 오래된 응답, 중복 응답, 순서가 바뀐 응답을 처리합니다.

  5. 5
    질의는 작고 재시도가 쉽다

    DNS 질의는 작고 재시도가 쉽다 응답이 없으면 다른 resolver나 TCP로 다시 시도할 수 있습니다.

  6. 6
    중복 실행이 위험하면 UDP 단독은 피한다

    결제/주문 중복 실행이 위험하면 UDP 단독은 피한다 재시도될 수 있는 요청은 idempotency 설계가 먼저 필요합니다.

  7. 7
    UDP 위에 필요한 전송 기능을 다시 만든다

    QUIC UDP 위에 필요한 전송 기능을 다시 만든다 재전송, 흐름 제어, 암호화를 프로토콜이 직접 제공합니다.

다음 절에서는 클라이언트마다 스레드를 만드는 모델의 한계를 극복하는 I/O 멀티플렉싱을 살펴보겠습니다.