# 093 — 연결 없는 링크: 핸드셰이크 없이, 재시작을 모르게 2026-09-28. [설계 011](../design/011-pure-transport.md)의 첫 단계다. 착수 기준: - **바꿀 결정.** 011의 모델이 실제로 재시작을 견디는지 본다. 링크의 오래 가는 상태는 앱이 이미 가진 것(보내는 앱의 outbox, 받는 앱의 들은 번호)이고, 키는 두 노드의 키에서 나온다. 견디지 못하면 설계를 다시 본다. - **최소 확인.** 두 `pure-serve`를 UDP 링크로 잇는다. 네 클라이언트가 주문하는 동안 받는 노드, 보내는 노드, 둘 다를 차례로 `kill -9`로 죽였다 살린다. 받는 앱이 보내는 앱이 저장한 주문을 정확히 한 번씩 적용했는지 본다. 손실과 재정렬 아래에서도 본다. - **중단 기준.** 한 번이라도 잃거나 겹치면 되돌린다. 재시작 뒤 이어지는 데 핸드셰이크(왕복)가 필요하면 다시 설계한다. ## 쉬운 말로 - 알아낸 것: 두 노드가 연결을 맺지 않고 전달한다. 어느 쪽을 `kill -9` 해도 다시 뜬 뒤 첫 패킷으로 이어진다. 잃거나 겹친 주문은 없었다. 손실 3 %, 재정렬 10 %에서도 그렇다. - 아직 모르는 것: QUIC(quinn, ngtcp2)과 댄 수치다. 쉬는 링크의 메모리, 재시작에 드는 시간과 CPU, 전달당 CPU를 아직 재지 않았다. - 사용 경험의 차이: 노드를 다시 띄워도 상대 노드와 사용자는 모른다. 재연결도, 다시 맺는 암호 교환도 없다. ## 한 것 **키.** - 두 노드의 정적 키로 X25519를 하고, HKDF-SHA256을 거쳐 AES-128-GCM 키를 만든다. - 보내는 쪽은 실행마다 무작위 run(64비트)을 패킷 머리에 싣는다. 키가 run마다 다르므로 재시작해도 논스가 겹치지 않는다. - 저장할 키도 예약할 번호도 없다. 011은 OSCORE처럼 패킷 번호를 예약해 저장하는 방법을 후보로 적었다. run 소금이 더 단순하다. 쓰기가 하나도 없다. - 받는 쪽은 처음 보는 run의 첫 패킷에서 키를 만든다. 그 패킷이 열려야만 자리를 준다. 그래서 위조 패킷이 정상 run을 밀어내지 못한다. **링크의 오래 가는 상태는 앱이 이미 가진 것이다.** - 보내는 앱의 outbox, 받는 앱의 들은 번호다. 들은 번호를 읽는 `pure_app_heard`만 코어에 더했다. - 새로 저장하는 전송 상태는 없다. 011이 적은 "링크의 행(약 100 B)"도 필요 없었다. 첫 에폭에서 링크가 저장소에 더하는 것은 0 바이트다. **보내는 쪽([serve/link.c](../../serve/link.c)의 `PureLink`).** - 전달선과 같은 자리에 붙는다. `--send OUTPUT=pure://host:port/INPUT`로 열고, 받는 노드의 키는 `--seal`로 준다. - outbox의 기록을 패킷에 채워 보낸다. 한 패킷에 여러 기록이 들고, 큰 기록은 1000 B 조각으로 나눈다. - 손실 탐지는 RFC 9002를 따른다: 패킷 셋, 또는 RTT의 9/8이 지나면 잃은 것으로 본다. PTO는 받는 쪽이 없는 동안 1초를 넘지 않게 했다. 혼잡 제어는 NewReno다. - 확인은 두 단계다. 패킷 ACK는 받았다는 뜻이라 그 기록을 다시 보내지 않는다. STORED는 저장했다는 뜻이라 그때 outbox에서 덜어 낸다. - 받았다는 기록이 1초 동안 저장되지 않으면 다시 보낸다. 받는 쪽이 저장 전에 죽은 경우다. - 흐름 허가는 저장되지 않은 가장 오래된 기록부터 센다. 재시작한 보내는 쪽은 받는 쪽이 어디까지 저장했는지 아직 모르기 때문이다. **받는 쪽(리스너, `--link-port`).** - 기록을 번호순으로 모아, 이어지는 것만 소유자에게 넘긴다. - 소유자는 그 출처의 들은 번호 다음부터 적용하고, 빈틈을 만나면 멈춘다. 재시작한 리스너는 들은 번호를 모르므로, 그것을 소유자에게 묻는 셈이다. - 저장이 끝나면 STORED(들은 번호, 빈틈 표시)를 돌려보낸다. 빈틈 표시를 받은 보내는 쪽은 그 뒤를 다시 보낸다. - STORED는 모든 ACK에 실어 보낸다. 그래서 잃은 STORED도 다음 패킷에서 다시 전해진다. **저장된 바이트가 곧 선 위의 바이트다.** 보내는 출력과 받는 입력의 모양이 같으면(모양 요약값으로 확인한다), 받는 쪽은 JSON도 필드 표도 없이 저장된 바이트를 그대로 `pure_app_deliver`에 넘긴다. ## 검사 - `server_links_udp`: 착수 기준의 확인 그대로다. 다섯 번 연달아 통과했다. 회당 주문은 10,063–10,954건이다. 끝에는 거절되는 주문 (수량 150)과 그 뒤의 주문을 보내, 거절이 뒤를 막지 않는 것까지 본다. - `server_links_udp_lossy`: 같은 것을 비특권 네트워크 이름공간(`unshare -rn`)에서 한다. 링크의 UDP만 netem을 거친다(지연 5±2 ms, 손실 3 %, 재정렬 10 %). 세 번 통과했고, 회당 주문은 10,954–11,982건이다. - 재정렬 때문에 잃었다고 잘못 판정해 다시 보내는 것이 많다. 한 실행에서 패킷 395개에 다시 보낸 조각이 739개였다. - 정확성에는 영향이 없다. 효율은 다음 일이다. ## 첫 수치 루프백, 주문 50건을 하나씩 보냈다. - 보내는 쪽: 패킷 50개, 기록 50개, 다시 보냄 0, 확인 50. - 받는 쪽: 소유자에게 넘김 49번, 답 패킷 96개(ACK와 STORED). ## 다음 - **M1.** 쉬는 링크 1만–100만의 메모리를 quinn·ngtcp2의 연결당 메모리와 댄다. 받는 쪽의 링크 상태(run 슬롯과 흐름)를 활성인 것만 두고 쉬는 것은 비우는 일부터 한다. - **quinn·ngtcp2 하네스.** 같은 의미로 만든다. 그 위에서 M2(재시작)와 M4(처음 만남)를 잰다. - **효율.** 재정렬에 맞춰 손실 판정의 문턱을 넓힌다(RFC 9002의 적응, RACK). 받는 쪽 ACK를 늦춰 묶는다. 한 노드 쌍의 링크들이 혼잡 제어를 나눠 쓰게 한다(지금은 링크마다 하나). - **보안.** 에폭 교체(전방 비밀성), 새 주소의 확인과 증폭 한도, 부하 때의 쿠키. - **한계.** 첫 에폭은 정적 키뿐이라 전방 비밀성이 없다. 정적 키끼리의 DH라서, 받는 노드의 키를 훔친 사람은 보내는 노드로 행세할 수 있다(KCI). 지금의 HPKE auth 모드 봉인과 같은 한계다.