신뢰성을 직접 더하기
UDP는 도착도 순서도 보장하지 않습니다. 그렇다면 UDP 위에서 "이 데이터는 꼭 도착해야 한다"고 요구하려면 어떻게 해야 할까요. 답은 간단합니다. 보장이 필요하면 우리가 직접 만들면 됩니다. 이 장에서 가장 기본적인 신뢰성 장치를 손으로 만들어 봅니다. 그리고 그 과정에서 놀라운 깨달음을 얻게 됩니다. 우리가 만드는 것이 사실은 TCP의 축소판이라는 사실입니다.
도착을 확인하는 법
데이터가 도착했는지 어떻게 알 수 있을까요. 받는 쪽이 "잘 받았다"고 답해 주면 됩니다. 이 답장을 ACK, 곧 확인 응답이라 부릅니다.
보내는 쪽은 데이터를 보낸 뒤 ACK를 기다립니다. ACK가 오면 도착한 것이니 다음으로 넘어갑니다. 그런데 한참을 기다려도 ACK가 오지 않으면 어떻게 할까요. 데이터그램이 사라졌거나 ACK가 사라진 것입니다. 둘 중 무엇이든, 보내는 쪽이 할 일은 같습니다. 다시 보내는 것입니다. 이를 재전송이라 합니다. 일정 시간 안에 ACK가 안 오면 다시 보내는 것, 이 단순한 규칙이 신뢰성의 핵심입니다.
여기에 한 가지를 더합니다. 각 데이터에 번호를 붙이는 것입니다. 이 번호를 시퀀스 번호라 합니다. 번호가 있으면 받는 쪽은 "몇 번을 기다리고 있는지" 알 수 있고, 재전송으로 같은 번호가 두 번 와도 중복을 가려낼 수 있습니다.
정리하면 세 가지 장치입니다. 데이터마다 시퀀스 번호를 붙이고, 받으면 ACK로 답하고, ACK가 제때 안 오면 재전송합니다.
직접 만들어 보기
이 세 장치를 갖춘 작은 프로그램을 만듭니다. 손실을 흉내 내기 위해, 서버가 받은 데이터그램의 일부를 일부러 버리도록 했습니다. 그래야 재전송이 실제로 동작하는 모습을 볼 수 있습니다.
# 새 파일: reliable.pyimport socketimport structimport threadingimport timeimport randomDROP_RATE = 0.4 # 받은 패킷의 40%를 일부러 버려 손실을 흉내 냅니다def server(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(("127.0.0.1", 9600)) received = [] expected = 0 while expected < 3: data, addr = s.recvfrom(1024) seq = struct.unpack(">I", data[:4])[0] if random.random() < DROP_RATE: print(f" [서버] seq={seq} 패킷 손실 흉내(무시)") continue if seq == expected: received.append(data[4:].decode()) print(f" [서버] seq={seq} 정상 수신, ACK 전송") expected += 1 s.sendto(struct.pack(">I", seq), addr) print(f" [서버] 최종 수신: {received}") s.close()t = threading.Thread(target=server)t.start()time.sleep(0.3)client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)client.settimeout(0.5)server_addr = ("127.0.0.1", 9600)for seq, message in enumerate(["측정1", "측정2", "측정3"]): packet = struct.pack(">I", seq) + message.encode() while True: client.sendto(packet, server_addr) try: ack, _ = client.recvfrom(1024) if struct.unpack(">I", ack)[0] == seq: print(f"[클라] seq={seq} ACK 확인") break except socket.timeout: print(f"[클라] seq={seq} ACK 시간초과, 재전송")client.close()t.join()
클라이언트를 보면, 각 메시지 앞에 시퀀스 번호를 4바이트로 붙여 보냅니다. 그리고 안쪽의 while True가 신뢰성의 심장입니다. 보낸 뒤 ACK를 기다리되, settimeout(0.5)로 0.5초 제한을 걸어 두었습니다. ACK가 오고 번호가 맞으면 반복을 빠져나가 다음 메시지로 갑니다. ACK가 제때 안 와 타임아웃이 나면, 반복이 한 바퀴 더 돌며 같은 패킷을 다시 보냅니다. 이것이 재전송입니다.
서버는 받은 패킷의 번호를 확인하고, 기다리던 번호면 받아들이고 ACK를 보냅니다. 일부러 버린 패킷에는 ACK를 보내지 않으니, 클라이언트는 타임아웃 끝에 재전송하게 됩니다.
실행 결과
실행하면 손실과 재전송이 어우러진 장면이 펼쳐집니다. 손실은 무작위라 매번 다르지만, 한 예는 이렇습니다.
[서버] seq=0 패킷 손실 흉내(무시)
[클라] seq=0 ACK 시간초과, 재전송
[서버] seq=0 정상 수신, ACK 전송
[클라] seq=0 ACK 확인
[서버] seq=1 정상 수신, ACK 전송
[클라] seq=1 ACK 확인
[서버] seq=2 정상 수신, ACK 전송
[서버] 최종 수신: ['측정1', '측정2', '측정3']
[클라] seq=2 ACK 확인
첫 번째 패킷이 손실되자 클라이언트가 타임아웃 끝에 재전송했고, 두 번째 시도에서 무사히 도착했습니다. 결국 세 메시지가 모두, 순서대로, 빠짐없이 전달되었습니다. UDP 위에 우리가 신뢰성을 얹은 것입니다.
우리가 다시 발명한 것
여기서 잠시 멈춰 생각해 봅시다. 우리가 방금 만든 것은 무엇일까요. 시퀀스 번호로 순서를 매기고, ACK로 도착을 확인하고, 타임아웃으로 재전송하는 장치. 이것은 바로 TCP가 하는 일의 핵심입니다.
우리는 UDP에서 출발해 신뢰성이 필요해지자, 한 걸음씩 TCP를 다시 발명한 셈입니다. 이 경험이 주는 교훈은 깊습니다. TCP의 복잡함은 괜한 것이 아니라, 신뢰성이라는 목표를 위해 치러야 하는 정당한 비용이라는 것입니다. 그래서 신뢰성이 통째로 필요하다면, 우리가 어설프게 다시 만들기보다 잘 만들어진 TCP를 쓰는 편이 낫습니다.
그렇다면 UDP에 신뢰성을 더하는 일은 헛수고일까요. 그렇지 않습니다. 핵심은 필요한 만큼만 더한다는 데 있습니다. TCP의 모든 보장이 아니라 도착 보장만 필요하다든지, 순서는 상관없이 중요한 메시지 몇 개만 확실히 전하고 싶다든지 할 때, UDP 위에 딱 필요한 만큼의 신뢰성을 얹으면 TCP보다 가볍고 빠르게 만들 수 있습니다. 실제로 웹을 더 빠르게 만들기 위해 등장한 최신 프로토콜이 바로 이 길을 택해, UDP 위에 자기만의 신뢰성과 보안을 정교하게 쌓아 올렸습니다. 그 이야기는 책의 마지막에서 다시 꺼내겠습니다.
신뢰성을 직접 다뤄 보았으니, 다음 장에서는 UDP만이 잘하는 또 다른 일, 한 번에 여러 상대에게 데이터를 뿌리는 법을 배웁니다.