UDP 소켓 프로그래밍
연결 없이 recvfrom과 sendto로 주고받는 UDP 소켓의 흐름을 C와 Python으로 정리하고, 잘린 데이터그램을 알아채는 방법과 타임아웃과 재시도로 손실을 다루는 코드를 봅니다. DNS 질의를 직접 만들어 응답을 읽어 보며, 필드만 읽는 파서와 응답을 검증하는 파서의 차이를 확인합니다.
7장 1편에서 UDP는 연결도 재전송도 없이 데이터그램을 보낸다고 했습니다. 소켓 코드도 그만큼 단순해서, 서버에는 listen()과 accept()가 없고 클라이언트에는 핸드셰이크가 없습니다. 하지만 코드가 단순한 대신 TCP가 해 주던 일, 곧 응답이 오지 않을 때 기다릴 시간을 정하고, 다시 보내고, 받은 데이터가 정말 내가 기다리던 응답인지 확인하는 일을 애플리케이션이 직접 해야 합니다. 이 글에서는 그 일을 코드로 따라갑니다.
UDP 소켓의 흐름
UDP 서버는 소켓을 만들어 포트에 바인드한 뒤, recvfrom()으로 데이터그램과 보낸 쪽의 주소를 함께 받고, 그 주소로 sendto()를 불러 답합니다. 소켓 하나로 모든 클라이언트를 받으므로 연결마다 소켓을 만들 필요가 없습니다. 클라이언트는 소켓을 만들어 바로 sendto()로 보내고, 응답을 recvfrom()으로 기다립니다. 바인드하지 않은 소켓으로 처음 보내면 운영체제가 임시 포트를 골라 붙여 줍니다.
UDP 소켓에도 connect()를 부를 수 있지만 핸드셰이크는 일어나지 않습니다. 7장 1편에서 본 것처럼 기본 목적지를 정하고 그 주소에서 온 데이터그램만 받게 할 뿐입니다.
C로 쓰는 UDP 에코 서버
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
int main(void) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
char buf[2048];
for (;;) {
struct sockaddr_in peer;
socklen_t peer_len = sizeof(peer); /* 부를 때마다 버퍼 크기로 다시 맞춘다 */
ssize_t n = recvfrom(fd, buf, sizeof(buf), MSG_TRUNC,
(struct sockaddr *)&peer, &peer_len);
if (n < 0) {
if (errno == EINTR) continue;
perror("recvfrom");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
if ((size_t)n > sizeof(buf)) { /* MSG_TRUNC: 원래 길이가 버퍼보다 컸다 */
fprintf(stderr, "%s:%u sent %zd bytes, dropped\n", ip, (unsigned)ntohs(peer.sin_port), n);
continue;
}
if (sendto(fd, buf, (size_t)n, 0, (struct sockaddr *)&peer, peer_len) < 0) {
perror("sendto");
}
}
}recvfrom()의 마지막 인자인 주소 길이는 부를 때는 버퍼의 크기를, 돌아올 때는 실제 주소의 길이를 담는 값이므로(recv(2)), 반복할 때마다 다시 맞춰 두어야 합니다. 주소의 종류가 섞이는 서버에서 이를 빠뜨리면 주소가 잘려 엉뚱한 곳으로 답하게 됩니다.
데이터그램이 버퍼보다 크면 넘치는 부분은 버려집니다. Linux에서는 MSG_TRUNC 플래그를 주면 recvfrom()이 버퍼에 담은 길이가 아니라 데이터그램의 원래 길이를 돌려주므로, 그 값이 버퍼보다 큰지로 잘렸는지를 알 수 있습니다. 잘린 데이터그램을 그대로 처리하면 메시지의 뒷부분이 없는 것을 모르고 엉뚱하게 해석하게 되므로, 위 코드처럼 버리거나 오류로 다룹니다. 버퍼는 프로토콜이 정한 최대 메시지보다 크게 잡는 것이 원칙입니다.
데이터그램과 스트림의 차이
같은 6바이트가 TCP와 UDP로 각각 도착했고 4바이트 버퍼로 읽는다고 하면, 두 소켓의 동작이 갈립니다.
작은 수신 버퍼에서 TCP와 UDP의 차이
TCP와 UDP에 각각 독립적인 6바이트 입력을 둡니다. 이번 수신 뒤 남는 데이터가 서로 다릅니다. 각 단계의 값과 전환 이유를 비교합니다.
4바이트 수신 버퍼 예
TCP는 나머지를 남기고, UDP는 초과분을 버립니다
공통 입력에서 TCP·UDP 두 경로로 갈라집니다. 각 경로의 이번 반환과 호출 뒤 남은 데이터를 함께 비교하세요.
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바이트 입력을 둡니다. |
|
|
| 이번 읽기 설명 모형에서 이번 읽기는 ABCD를 반환합니다. |
|
|
| 남은 데이터 이번 수신 뒤 남는 데이터가 서로 다릅니다. |
|
|
작은 수신 버퍼에서 TCP와 UDP의 차이
TCP와 UDP에 각각 독립적인 6바이트 입력을 둡니다. 이번 수신 뒤 남는 데이터가 서로 다릅니다. 각 단계의 값과 전환 이유를 비교합니다.
4바이트 수신 버퍼 예
TCP는 나머지를 남기고, UDP는 초과분을 버립니다
공통 입력에서 TCP·UDP 두 경로로 갈라집니다. 각 경로의 이번 반환과 호출 뒤 남은 데이터를 함께 비교하세요.
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바이트 입력을 둡니다. |
|
|
| 이번 읽기 설명 모형에서 이번 읽기는 ABCD를 반환합니다. |
|
|
| 남은 데이터 이번 수신 뒤 남는 데이터가 서로 다릅니다. |
|
|
TCP에서는 읽지 않은 EF가 수신 버퍼에 남아 다음 recv()로 이어 읽히지만, UDP에서는 그 데이터그램의 EF가 버려지고 다음 recvfrom()은 새 데이터그램을 기다립니다. 메시지의 경계에서도 둘은 반대입니다. UDP에서는 0바이트 데이터그램도 정상적인 메시지여서 recvfrom()이 빈 데이터를 돌려줄 수 있는데, TCP에서 빈 데이터는 상대가 연결을 닫았다는 EOF입니다. 같은 "빈 바이트열"을 두 소켓에서 다르게 읽어야 합니다.
recvfrom()이 돌려준 주소도 그대로 믿으면 안 됩니다. UDP에는 핸드셰이크가 없어 출발지 주소를 위조하기 쉬우므로, 10장 4편의 반사 공격처럼 위조된 주소로 큰 응답을 보내게 될 수 있습니다. 보낸 쪽의 신원이 중요한 프로토콜은 주소가 아니라 메시지 안의 인증 정보로 확인해야 합니다.
응답이 오지 않을 때를 정해 둔다
UDP는 데이터그램이 사라져도 알려 주지 않으므로, 클라이언트는 응답을 기다릴 시간을 정하고 그 안에 오지 않으면 다시 보내야 합니다. 다음 코드는 첫 데이터그램을 일부러 버리는 서버를 띄우고, 클라이언트가 0.2초의 타임아웃과 재시도로 이를 넘어서는 과정을 보여 줍니다.
import socket
import threading
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("127.0.0.1", 0))
def serve():
seen = 0
while True:
data, addr = server.recvfrom(2048)
seen += 1
if seen == 1:
continue # 첫 데이터그램은 사라진 것으로 친다
server.sendto(b"ECHO " + data, addr)
threading.Thread(target=serve, daemon=True).start()
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.settimeout(0.2) # 응답을 기다릴 최대 시간
for attempt in range(1, 4): # 최대 세 번까지 보낸다
client.sendto(b"hello", server.getsockname())
try:
data, _ = client.recvfrom(2048)
print(f"{attempt}번째 시도: {data}")
break
except socket.timeout:
print(f"{attempt}번째 시도: 응답 없음")
client.close()1번째 시도: 응답 없음
2번째 시도: b'ECHO hello'첫 시도는 0.2초 동안 응답이 없어 타임아웃이 났고, 다시 보낸 두 번째 시도가 응답을 받았습니다. 타임아웃이 났다는 것만으로는 질의가 사라졌는지, 서버가 느렸는지, 응답이 사라졌는지 알 수 없습니다. 응답이 사라진 경우라면 서버는 같은 요청을 두 번 처리했으므로, 다시 처리해도 괜찮은 요청인지를 7장 2편에서처럼 따져야 합니다. 실제로는 재시도의 간격을 점점 늘리고, 전체 시도에 걸리는 시간에도 상한을 두며, 늦게 도착한 앞의 응답을 어떻게 다룰지도 정합니다.
DNS 질의를 직접 만들어 보기
DNS는 UDP를 쓰는 대표적인 프로토콜입니다. 질의와 응답은 8장에서 본 형식(RFC 1035 §4.1)을 따르는 바이트열이므로, 직접 만들고 읽을 수 있습니다. 다음 코드는 example.com의 A 레코드를 묻는 질의를 만들고, 네트워크에 보내는 대신 그 질의에 대한 응답을 손으로 만든 뒤, 응답에서 첫 A 레코드만 찾아 읽는 간단한 파서를 네 가지 응답에 적용합니다. 네 응답은 정상 응답, 끝의 2바이트가 잘린 응답, ID가 다른 응답, 잘렸다는 표시(TC)가 켜진 응답입니다.
import struct
def build_query(domain, qid):
qname = b"".join(bytes([len(label)]) + label.encode("ascii") for label in domain.split(".")) + b"\x00"
question = qname + struct.pack("!HH", 1, 1) # 타입 A, 클래스 IN
return struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + question, question
query, question = build_query("example.com", 0x1234)
response = (
struct.pack("!HHHHHH", 0x1234, 0x8180, 1, 1, 0, 0) # 응답, 재귀 가능, 오류 없음, 답 1개
+ question # 질문을 그대로 되풀이한다
+ struct.pack("!HHHIH", 0xC00C, 1, 1, 300, 4) # 이름은 오프셋 12(헤더 바로 뒤)를 가리키는 압축 포인터
+ bytes([192, 0, 2, 44])
)
cases = [("정상", response),
("RDATA 두 바이트 누락", response[:-2]),
("ID가 다른 응답", b"\x99\x99" + response[2:]),
("TC가 켜진 응답", response[:2] + struct.pack("!H", 0x8380) + response[4:])]
def parse_first_a(data):
def skip_name(offset):
while True:
length = data[offset]
if length & 0xC0 == 0xC0:
return offset + 2 # 압축 포인터는 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) + 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:
return ".".join(str(b) for b in rdata)
raise ValueError("A 레코드가 없다")
print(f"{len(query)}바이트 질의, {len(response)}바이트 응답")
for name, data in cases:
print(f"{name}: {parse_first_a(data)}")29바이트 질의, 45바이트 응답
정상: 192.0.2.44
RDATA 두 바이트 누락: 192.0
ID가 다른 응답: 192.0.2.44
TC가 켜진 응답: 192.0.2.44질의는 헤더 12바이트, example.com을 길이 바이트와 함께 적은 13바이트, 타입과 클래스 4바이트로 29바이트입니다. 응답은 질문을 되풀이한 뒤 답 하나를 붙여 45바이트가 되며, 답의 이름 자리에는 이름을 다시 적는 대신 메시지의 처음에서 12바이트 떨어진 곳, 곧 헤더 바로 뒤에서 시작하는 질문의 이름을 가리키는 압축 포인터 0xC00C를 넣었습니다. 문제는 나머지 세 줄입니다. 이 파서는 필드를 읽을 뿐 응답을 검증하지 않으므로, 잘린 응답에서는 실제로 남은 두 바이트로 192.0이라는 주소를 만들어 냈고, 내가 보낸 질의의 응답이 아닌 것과 TCP로 다시 물으라는 응답도 그대로 받아들였습니다.
DNS 예제 코드의 범위
필드를 읽는 것과, 신뢰할 응답을 검증하는 것은 다릅니다
원문은 첫 A 주소를 찾는 교육용 파서입니다. 응답 ID·주소·질의 일치·길이 경계와 잘림 여부 등을 검증하고 TCP로 재시도하는 완전한 resolver는 아닙니다.
| 구획 | 현재 코드가 하는 일 | 완전한 응답 검증과의 차이 |
|---|---|---|
| 헤더 | 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 재시도나 재전송을 하지 않습니다.
응답을 믿으려면 그 응답이 내 질의의 응답인지, 끝까지 온 응답인지, 오류는 없는지부터 확인해야 합니다. 다음 코드는 앞의 코드에 이어서 그 검사를 더한 파서를 같은 네 응답에 적용합니다.
def parse_a(query, data):
(qid,) = struct.unpack("!H", query[:2])
rid, flags, _, ancount, _, _ = struct.unpack("!HHHHHH", data[:12])
if rid != qid:
raise ValueError("ID가 다르다")
if not flags & 0x8000:
raise ValueError("응답이 아니다")
if flags & 0x0200:
raise ValueError("잘린 응답이다: TCP로 다시 물어야 한다")
if flags & 0x000F:
raise ValueError(f"RCODE {flags & 0x000F}")
q = query[12:]
if data[12:12 + len(q)] != q:
raise ValueError("질문이 다르다")
offset = 12 + len(q)
for _ in range(ancount):
if data[offset] & 0xC0 != 0xC0:
raise ValueError("이 예제는 압축된 이름만 다룬다")
offset += 2
if offset + 10 > len(data):
raise ValueError("레코드 헤더가 잘렸다")
rtype, rclass, ttl, rdlength = struct.unpack("!HHIH", data[offset:offset + 10])
offset += 10
if offset + rdlength > len(data):
raise ValueError("RDATA가 잘렸다")
rdata = data[offset:offset + rdlength]
offset += rdlength
if rtype == 1 and rclass == 1 and rdlength == 4:
return ".".join(str(b) for b in rdata), ttl
raise ValueError("A 레코드가 없다")
for name, data in cases:
try:
ip, ttl = parse_a(query, data)
print(f"{name}: {ip}, TTL {ttl}초")
except ValueError as e:
print(f"{name}: 거부({e})")정상: 192.0.2.44, TTL 300초
RDATA 두 바이트 누락: 거부(RDATA가 잘렸다)
ID가 다른 응답: 거부(ID가 다르다)
TC가 켜진 응답: 거부(잘린 응답이다: TCP로 다시 물어야 한다)이제 정상 응답만 받아들이고, 나머지는 이유와 함께 거부합니다. ID가 다른 응답을 걸러 내는 것은 8장 3편의 캐시 포이즈닝을 막는 첫 단계이므로, 실제 클라이언트는 ID를 0x1234 같은 고정값이 아니라 secrets.randbelow(65536)으로 질의마다 무작위로 고르고, 응답이 질의를 보낸 서버의 주소와 포트에서 왔는지도 확인합니다. TC가 켜진 응답을 받으면 같은 질의를 TCP로 다시 보내고, 정해진 시간 안에 응답이 없으면 앞 절처럼 다시 보냅니다. 이 모든 것을 갖춘 것이 운영체제의 리졸버이므로, 실제 프로그램에서 이름을 해석할 때는 직접 만들지 말고 socket.getaddrinfo()를 씁니다.
정리
- UDP 서버는 소켓 하나로
recvfrom()과sendto()를 되풀이하며, 연결마다 소켓을 만들지 않습니다. recvfrom()의 주소 길이는 부를 때마다 다시 맞추고, Linux에서는MSG_TRUNC로 데이터그램이 버퍼보다 커서 잘렸는지 알아냅니다.- TCP에서는 읽지 않은 바이트가 다음 읽기로 이어지지만 UDP에서는 잘린 부분이 버려지며, UDP의 빈 데이터그램은 정상 메시지이고 TCP의 빈 데이터는 EOF입니다.
- UDP 클라이언트는 타임아웃과 재시도를 직접 두어야 하고, 타임아웃만으로는 무엇이 사라졌는지 알 수 없으므로 같은 요청이 두 번 처리될 수 있음을 고려합니다.
- 응답을 읽을 때는 필드를 읽는 것에 그치지 않고 ID, 응답 여부, 잘림, 오류 코드, 질문의 일치, 길이의 범위를 검증해야 합니다.
연습 문제
1. 버퍼보다 큰 데이터그램
ABCDEF 6바이트 데이터그램을 4바이트 버퍼로 받아 ABCD를 얻었고, 다음 데이터그램은 아직 오지 않았습니다. 다음 recvfrom()으로 EF를 이어 읽을 수 있나요? 같은 6바이트를 TCP로 받았을 때와, 0바이트 UDP 데이터그램을 받았을 때도 비교해 보세요.
해설 보기
이어 읽을 수 없습니다. UDP에서는 데이터그램이 버퍼보다 크면 넘친 EF가 그 호출에서 버려지므로, 다음 recvfrom()은 새 데이터그램이 올 때까지 기다립니다. TCP에서는 EF가 수신 버퍼에 남아 다음 recv()로 읽히지만, 첫 recv()가 꼭 4바이트를 돌려준다는 보장도 없습니다. 0바이트 UDP 데이터그램을 받으면 recvfrom()은 빈 데이터와 보낸 쪽의 주소를 돌려주며, 이는 정상적인 메시지이지 TCP의 EOF처럼 연결이 끝났다는 뜻이 아닙니다.
2. 질의와 응답의 크기
본문의 build_query로 www.example.com의 A 레코드 질의를 만들면 몇 바이트인가요? 그 질의에 대해 본문과 같은 방식으로 압축 포인터를 쓴 A 레코드 답 하나를 붙인 응답은 몇 바이트인가요?
해설 보기
이름은 www(1 + 3), example(1 + 7), com(1 + 3), 마지막 0으로 17바이트이고, 타입과 클래스 4바이트, 헤더 12바이트를 더해 질의는 33바이트입니다. 8장 3편에서 DoH의 URL을 만들 때 본 33바이트와 같습니다. 응답은 헤더 12바이트와 질문 21바이트에 답 하나를 붙이는데, 답은 압축 포인터 2바이트, 타입, 클래스, TTL, 길이를 합친 10바이트, 주소 4바이트로 16바이트이므로 모두 49바이트입니다.
3. 늦게 도착한 첫 응답
DNS 클라이언트가 질의를 보내고 1초 안에 응답이 없자 같은 질의를 다시 보냈습니다. 그런데 곧이어 응답 두 개가 차례로 도착했습니다. 두 응답을 모두 받아들여도 되나요? 이 클라이언트가 DNS가 아니라 결제 요청을 UDP로 보내는 프로그램이었다면 무엇이 달라지나요?
해설 보기
첫 질의가 사라진 것이 아니라 늦었을 뿐이어서 서버가 두 질의에 모두 답한 것입니다. 클라이언트는 아직 답을 받지 못한 질의의 ID와 질문을 기억해 두었다가, 맞는 응답 하나를 받으면 그 질의를 끝내고 나머지 응답은 버리면 됩니다. DNS 조회는 두 번 처리되어도 같은 답을 돌려줄 뿐이므로 문제가 없습니다. 결제 요청이라면 서버가 같은 결제를 두 번 처리했을 수 있으므로, 요청마다 고유한 ID를 붙여 서버가 같은 ID의 요청을 한 번만 처리하게 하고, 클라이언트도 그 ID로 결과를 확인해야 합니다.
다음 글 I/O 멀티플렉싱에서는 스레드 하나로 수많은 소켓을 함께 다루는 select, poll, epoll과, 준비된 소켓에 쓸 때의 출력 버퍼 관리를 살펴봅니다.