안동민 개발노트 아이콘

안동민 개발노트

11장 : 소켓 프로그래밍

UDP 소켓 프로그래밍

TCP 소켓은 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 순서를 보장합니다. 반면 UDP 소켓은 연결 상태, 재전송, 순서 제어를 기본으로 제공하지 않습니다. 연결 핸드셰이크 없이 데이터그램을 바로 보내고, 도착 여부도 직접 확인하지 않습니다. 그 대신 빠르고 간결합니다.


UDP 소켓의 생명주기

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

서버 쪽에는 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_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는 데이터그램 단위로 전송하므로 sendto()로 보낸 데이터가 recvfrom()에서 하나의 단위로 수신됩니다. 단, 수신 버퍼가 데이터그램보다 작으면 잘릴 수 있으므로 버퍼 크기를 의식해야 합니다.

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

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


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

DNS는 UDP 기반 프로토콜의 대표적인 예입니다. 일반 질의는 UDP 53번 포트를 주로 사용하지만, 응답이 크거나 잘린 경우에는 TCP로 재시도할 수 있습니다. 여기서는 가장 단순한 A 레코드 질의를 UDP 소켓으로 직접 보내보겠습니다.

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이 바로 이런 접근의 대표적인 예입니다.

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