icon

안동민 개발노트

11장 : 소켓 프로그래밍

UDP 소켓 프로그래밍


TCP 소켓은 연결을 수립하고, 데이터를 신뢰성 있게 전달하며, 순서를 보장합니다. 반면 UDP 소켓은 이 모든 것을 생략합니다. 연결 없이 데이터그램을 바로 보내고, 도착 여부도 확인하지 않습니다. 그 대신 빠르고 간결합니다.


UDP 소켓의 생명주기

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

TCP vs UDP 소켓 흐름
TCP 서버                           UDP 서버
  socket(SOCK_STREAM)                socket(SOCK_DGRAM)
  bind()                             bind()
  listen()          ← 없음!
  accept()          ← 없음!
  recv() / send()                    recvfrom() / sendto()
  close()                            close()

TCP 클라이언트                      UDP 클라이언트
  socket(SOCK_STREAM)                socket(SOCK_DGRAM)
  connect()         ← 없음!
  send() / recv()                    sendto() / recvfrom()
  close()                            close()

TCP: 7단계 (서버)    UDP: 4단계 (서버)

listen(), accept(), connect()가 없습니다. TCP의 3-way handshake에 해당하는 단계가 통째로 사라집니다. 서버는 누가 보냈는지를 recvfrom()의 반환값으로 알 수 있고, 그 주소로 sendto()를 호출하여 응답합니다.


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 vs UDP 소켓 비교
항목            TCP (SOCK_STREAM)           UDP (SOCK_DGRAM)
────────────────────────────────────────────────────────────
연결            connect() 필수              연결 없음
서버 소켓       accept() → 클라이언트별     하나의 소켓으로 전부
데이터 전송     send() / recv()             sendto() / recvfrom()
메시지 경계     보존 안 됨 (바이트 스트림)  보존됨 (데이터그램)
다중 클라이언트 스레드 필요                 recvfrom이 발신자 구분
신뢰성          커널이 재전송               애플리케이션이 구현
순서            커널이 보장                 보장 안 됨
  • 메시지 경계 보존: TCP는 바이트 스트림이라 메시지 경계를 직접 관리해야 했습니다. UDP는 데이터그램 단위로 전송하므로 sendto()로 보낸 데이터가 recvfrom()에서 그대로 하나의 단위로 수신됩니다.

  • 다중 클라이언트: TCP는 클라이언트마다 전용 소켓과 스레드가 필요했습니다. UDP는 하나의 소켓이 모든 클라이언트를 처리합니다.

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


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

DNS는 UDP 기반 프로토콜의 대표적인 예입니다. 간단한 DNS 질의를 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):
    # 응답에서 IP 추출 (간소화)
    # 마지막 4바이트가 A 레코드 IP
    ip = ".".join(str(b) for b in data[-4:])
    return ip

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 멀티플렉싱을 살펴보겠습니다.

목차