본문 바로가기

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 에코 서버

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 서버는 하나의 소켓으로 여러 발신자의 데이터그램을 처리합니다. 클라이언트별 소켓을 둘 수 없다는 API 제한은 아닙니다.

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)

C 예제는 최대 1023바이트, Python 예제는 최대 1024바이트를 한 번에 받습니다. 더 큰 데이터그램의 초과 부분은 이 Linux/Unix 수신 예제에서 버려질 수 있습니다. Python 서버는 출력 전에 UTF-8로 decode하므로 잘못된 인코딩의 데이터는 처리하지 못합니다.

이 순차 에코 처리에는 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에 각각 독립적인 6바이트 입력을 둡니다. 이번 수신 뒤 남는 데이터가 서로 다릅니다. 각 단계의 값과 전환 이유를 비교합니다.

4바이트 수신 버퍼 예

TCP는 나머지를 남기고, UDP는 초과분을 버립니다

공통 입력에서 TCP·UDP 두 경로로 갈라집니다. 각 경로의 이번 반환과 호출 뒤 남은 데이터를 함께 비교하세요.

같은 입력에서 갈라지는 TCP와 UDP의 반환·잔여 경로ABCDEF 6바이트를 4바이트 버퍼로 읽는 예에서 두 경로는 이번에 ABCD를 반환하고, 잔여 EF는 TCP 버퍼에 남아 다음 읽기로 이어지지만 UDP에서는 폐기되어 이어 읽지 못합니다.공통 입력ABCDEF · 6 B수신 버퍼 4 BTCP 스트림UDP 데이터그램이번 반환ABCD · 4 BEF · 2 B버퍼에 보존다음 읽기 가능이번 반환ABCD · 4 BEF · 2 B폐기이어 읽기 불가
같은 입력에서 갈라지는 TCP와 UDP의 반환·잔여 경로ABCDEF 6바이트를 4바이트 버퍼로 읽는 예에서 두 경로는 이번에 ABCD를 반환하고, 잔여 EF는 TCP 버퍼에 남아 다음 읽기로 이어지지만 UDP에서는 폐기되어 이어 읽지 못합니다.공통 입력ABCDEF · 6 B수신 버퍼 4 BTCP스트림UDP데이터그램이번 반환ABCD · 4 BEF · 2 B버퍼 보존다음 읽기가능이번 반환ABCD · 4 BEF · 2 B폐기이어 읽기불가

Linux의 일반 수신 예입니다. TCP는 한 번에 4 B보다 짧게 반환할 수도 있습니다. UDP의 초과분 EF는 다음 recvfrom으로 이어서 읽을 수 없습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 도착부터 남은 데이터까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목작은 수신 버퍼에서 TCP와 UDP의 차이
설명·조건4바이트 수신 버퍼 비교
제목·표기TCP의 나머지는 남고, UDP의 초과분은 버려집니다
설명·조건Linux에서 도착한 ABCDEF 6 B를 4 B 버퍼로 읽는 가상 예입니다. TCP는 여기보다 적게 읽을 수도 있고, UDP에서 버린 EF는 다음 recvfrom으로 이어서 읽을 수 없습니다.
제목TCP와 UDP의 수신 경계
관계 설명4바이트 수신 버퍼를 사용하는 Linux의 가상 예에서 TCP 나머지는 남고 UDP 초과분은 버려진다.
노드·선 표기받을 데이터: ABCDEF
노드·선 표기이번 수신
노드·선 표기ABCD
노드·선 표기나머지 EF
노드·선 표기버퍼에 남음
노드·선 표기버려짐
관계 설명위는 TCP, 아래는 UDP의 독립 비교입니다. 각 영역에서 ABCDEF 6바이트를 4바이트 버퍼로 읽은 이번 ABCD와 나머지 EF를 분기해 보여줍니다. TCP 나머지는 남고 UDP 나머지는 버립니다. TCP 수신은 4바이트보다 적을 수도 있습니다.
노드·선 표기받을 데이터
노드·선 표기ABCDEF
노드·선 표기버퍼에
노드·선 표기남음
노드·선 표기Linux의 4 B 버퍼 조건
노드·선 표기UDP 초과분은 다시 읽기 불가
설명·조건Linux에서 6바이트를 4바이트 버퍼로 받는 설명용 예입니다. UDP 초과분은 다음 recvfrom으로 이어서 읽을 수 없습니다. TCP의 한 번 수신 크기는 여기처럼 4바이트보다 작을 수도 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
도착

TCP와 UDP에 각각 독립적인 6바이트 입력을 둡니다.

  • TCP·UDP 데이터 1: TCP ABCDEF
  • TCP·UDP 데이터 2: UDP datagram ABCDEF
  • 4바이트 수신 버퍼로 읽을 준비
이번 읽기

설명 모형에서 이번 읽기는 ABCD를 반환합니다.

  • TCP·UDP 데이터 1: TCP ABCD
  • TCP·UDP 데이터 2: UDP ABCD
  • TCP·UDP 모두 4바이트를 복사; TCP 반환 크기는 항상 4가 아님
남은 데이터

이번 수신 뒤 남는 데이터가 서로 다릅니다.

  • TCP·UDP 데이터 1: TCP EF 남음
  • TCP·UDP 데이터 2: UDP EF 버림
  • TCP는 EF를 유지; UDP는 잘린 EF를 버리고 다음 데이터그램을 기다림

작은 수신 버퍼에서 TCP와 UDP의 차이

TCP와 UDP에 각각 독립적인 6바이트 입력을 둡니다. 이번 수신 뒤 남는 데이터가 서로 다릅니다. 각 단계의 값과 전환 이유를 비교합니다.

4바이트 수신 버퍼 예

TCP는 나머지를 남기고, UDP는 초과분을 버립니다

공통 입력에서 TCP·UDP 두 경로로 갈라집니다. 각 경로의 이번 반환과 호출 뒤 남은 데이터를 함께 비교하세요.

같은 입력에서 갈라지는 TCP와 UDP의 반환·잔여 경로ABCDEF 6바이트를 4바이트 버퍼로 읽는 예에서 두 경로는 이번에 ABCD를 반환하고, 잔여 EF는 TCP 버퍼에 남아 다음 읽기로 이어지지만 UDP에서는 폐기되어 이어 읽지 못합니다.공통 입력ABCDEF · 6 B수신 버퍼 4 BTCP 스트림UDP 데이터그램이번 반환ABCD · 4 BEF · 2 B버퍼에 보존다음 읽기 가능이번 반환ABCD · 4 BEF · 2 B폐기이어 읽기 불가
같은 입력에서 갈라지는 TCP와 UDP의 반환·잔여 경로ABCDEF 6바이트를 4바이트 버퍼로 읽는 예에서 두 경로는 이번에 ABCD를 반환하고, 잔여 EF는 TCP 버퍼에 남아 다음 읽기로 이어지지만 UDP에서는 폐기되어 이어 읽지 못합니다.공통 입력ABCDEF · 6 B수신 버퍼 4 BTCP스트림UDP데이터그램이번 반환ABCD · 4 BEF · 2 B버퍼 보존다음 읽기가능이번 반환ABCD · 4 BEF · 2 B폐기이어 읽기불가

Linux의 일반 수신 예입니다. TCP는 한 번에 4 B보다 짧게 반환할 수도 있습니다. UDP의 초과분 EF는 다음 recvfrom으로 이어서 읽을 수 없습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 도착부터 남은 데이터까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목작은 수신 버퍼에서 TCP와 UDP의 차이
설명·조건4바이트 수신 버퍼 비교
제목·표기TCP의 나머지는 남고, UDP의 초과분은 버려집니다
설명·조건Linux에서 도착한 ABCDEF 6 B를 4 B 버퍼로 읽는 가상 예입니다. TCP는 여기보다 적게 읽을 수도 있고, UDP에서 버린 EF는 다음 recvfrom으로 이어서 읽을 수 없습니다.
제목TCP와 UDP의 수신 경계
관계 설명4바이트 수신 버퍼를 사용하는 Linux의 가상 예에서 TCP 나머지는 남고 UDP 초과분은 버려진다.
노드·선 표기받을 데이터: ABCDEF
노드·선 표기이번 수신
노드·선 표기ABCD
노드·선 표기나머지 EF
노드·선 표기버퍼에 남음
노드·선 표기버려짐
관계 설명위는 TCP, 아래는 UDP의 독립 비교입니다. 각 영역에서 ABCDEF 6바이트를 4바이트 버퍼로 읽은 이번 ABCD와 나머지 EF를 분기해 보여줍니다. TCP 나머지는 남고 UDP 나머지는 버립니다. TCP 수신은 4바이트보다 적을 수도 있습니다.
노드·선 표기받을 데이터
노드·선 표기ABCDEF
노드·선 표기버퍼에
노드·선 표기남음
노드·선 표기Linux의 4 B 버퍼 조건
노드·선 표기UDP 초과분은 다시 읽기 불가
설명·조건Linux에서 6바이트를 4바이트 버퍼로 받는 설명용 예입니다. UDP 초과분은 다음 recvfrom으로 이어서 읽을 수 없습니다. TCP의 한 번 수신 크기는 여기처럼 4바이트보다 작을 수도 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
도착

TCP와 UDP에 각각 독립적인 6바이트 입력을 둡니다.

  • TCP·UDP 데이터 1: TCP ABCDEF
  • TCP·UDP 데이터 2: UDP datagram ABCDEF
  • 4바이트 수신 버퍼로 읽을 준비
이번 읽기

설명 모형에서 이번 읽기는 ABCD를 반환합니다.

  • TCP·UDP 데이터 1: TCP ABCD
  • TCP·UDP 데이터 2: UDP ABCD
  • TCP·UDP 모두 4바이트를 복사; TCP 반환 크기는 항상 4가 아님
남은 데이터

이번 수신 뒤 남는 데이터가 서로 다릅니다.

  • TCP·UDP 데이터 1: TCP EF 남음
  • TCP·UDP 데이터 2: UDP EF 버림
  • TCP는 EF를 유지; UDP는 잘린 EF를 버리고 다음 데이터그램을 기다림
  • 메시지 경계: TCP는 스트림을 프레임으로 해석해야 하고, UDP는 도착한 데이터그램의 경계를 유지합니다. 전송한 데이터그램이 모두 도착한다는 보장은 없습니다. 0바이트 UDP 데이터그램도 유효하므로 TCP의 EOF와 구분합니다.

  • 다중 클라이언트: TCP는 클라이언트마다 전용 연결 소켓이 생기며, 그 소켓을 처리하는 방식은 스레드, I/O 멀티플렉싱, 비동기 중 선택할 수 있습니다. 이 UDP 예제는 하나의 소켓에서 발신자 주소를 보고 응답 대상을 정합니다. 주소를 받았다는 사실만으로 발신자의 신원이 인증되지는 않습니다.

  • 데이터 유실: TCP는 유실된 패킷을 재전송합니다. UDP 자체는 전송 후 도착 여부를 확인하지 않습니다. 응답이 오지 않으면 클라이언트가 직접 타임아웃과 재시도를 구현해야 합니다.


수신 조건을 독립적으로 바꾸기 Linux의 위 수신 방식에서 ABCDEF 6바이트 데이터그램을 4바이트 버퍼로 받았고 다음 데이터그램은 아직 없다고 합시다. 처음에는 ABCD를 얻습니다. 다음 recvfrom으로 EF를 이어 읽을 수 있을까요? 같은 크기의 TCP 스트림을 읽는 경우와, 0바이트 UDP 데이터그램을 받은 경우도 비교해 보세요.

데이터그램 경계의 풀이

UDP의 초과분 EF는 이 호출에서 버려져 다음 recvfrom은 새 데이터그램을 기다립니다. TCP에서는 아직 읽지 않은 바이트가 스트림에 남지만, 한 번에 반드시 4바이트를 받는다는 보장도 없습니다. 0바이트 UDP 데이터그램은 유효한 메시지이며 TCP의 수신 EOF가 아닙니다. 또한 원 클라이언트의 3초 timeout은 그 시간 안에 응답을 받지 못했다는 뜻이지, 질의 유실·서버 오류·응답 유실 중 어느 원인인지를 확정하지 않습니다.

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

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

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

아래 표는 이 원문 DNS 예제가 실제로 읽는 필드와 검증하지 않는 조건을 구분합니다.

DNS 예제 코드의 범위

필드를 읽는 것과, 신뢰할 응답을 검증하는 것은 다릅니다

원문은 첫 A 주소를 찾는 교육용 파서입니다. 응답 ID·주소·질의 일치·길이 경계와 잘림 여부 등을 검증하고 TCP로 재시도하는 완전한 resolver는 아닙니다.

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로 실행하지 않았습니다.

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()

예시: 질의 길이와 파서의 확인 범위 example.com의 QNAME은 example 길이 한 바이트+문자 7바이트, com 길이 한 바이트+문자 3바이트, 끝의 0 한 바이트로 13바이트입니다. QTYPE·QCLASS 네 바이트와 DNS 헤더 12바이트를 더하면 원 함수가 만든 질의는 29바이트입니다. 바이트 배열을 만드는 계산이며 외부 DNS에서 얻은 관측값은 아닙니다.

응답 파서는 선언된 rdlength가 4인지 검사한 뒤 rdata 조각의 바이트를 점으로 연결합니다. 하지만 잘린 배열에서 실제로 그 네 바이트가 모두 존재하는지는 검사하지 않습니다. 원 코드가 문자열을 반환했다는 사실만으로 거래 ID·응답 출발지·질문 일치·RCODE·잘림 여부까지 검증했다는 뜻도 아닙니다. DNS 메시지의 헤더와 자원 레코드 형식을 원 코드가 읽는 필드와 대조해 보세요.

짧은 응답을 독립 판단하기 필드 읽기가 가능한 헤더와 질문·응답 이름·타입 필드는 갖췄지만, A/IN의 rdlength=4 뒤에 실제 RDATA 두 바이트 192와 0만 있고 배열이 끝납니다. 원 파서가 반환할 수 있는 문자열은 무엇이며, 정상 IPv4 답변으로 채택해도 될까요?

선언된 길이와 실제 길이의 풀이

이 조건에서는 슬라이스에 실제 있는 두 바이트만 들어가 192.0을 반환할 수 있습니다. 네 바이트 IPv4 주소를 검증한 결과가 아니므로 정상 답변으로 채택하면 안 됩니다. 필드의 선언값과 실제 남은 길이를 함께 검사하고 요청과 대응한 응답인지도 확인해야 합니다. 이 예제를 실제 resolver로 바꾸는 수정 실습은 아니며, 외부로 보내지 않고 코드의 누락 조건을 읽는 문제입니다.

이런 특성 때문에 UDP 위에서 신뢰성이 필요한 경우, 애플리케이션 레벨에서 시퀀스 번호, 재전송, 확인 응답 등을 직접 구현합니다.

7장에서 다룬 QUIC이 바로 이런 접근의 대표적인 예입니다.

재시도는 같은 요청을 중복 실행시킬 수 있습니다. 요청 식별자, 전체 deadline, 재시도 횟수와 backoff, 오래된 응답 처리 및 멱등성을 함께 설계해야 합니다.

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