iPad가 Tailscale에 연결돼 있었다: WebRTC 디버깅 이야기
Source: Hacker News
p2claw가 어떻게 동작하는지 잘 모른다면, 이 글을 읽기 전에 동작 원리 블로그 글을 한 번 살펴보세요.
iPad에서 p2claw 앱 중 하나를 열었는데 빈 페이지가 나왔습니다. 같은 URL은 Mac, Linux 머신, 그리고 휴대폰에서는 정상적으로 동작했습니다. 같은 Wi‑Fi, 같은 브라우저 엔진, 같은 네트워크 환경이었죠.
좋은 탐정 소설처럼, 우리는 여러 용의자를 잡아냈습니다 — iPad, WebKit, Tailscale — 그리고 모두 무죄로 밝혀졌습니다. 정확히는 두 개의 버그가 트렌치코트를 입고 있었습니다: webrtc‑rs에 하드코딩된 상수와, Tailscale에 있는 한 줄짜리 설계 결정. 같은 날 우회 패치를 만들었지만, 실제로 무엇을 고친 건지 이해하는 데는 추가로 2주가 걸렸습니다.
The complaint
앱은 로딩 상태를 그릴 정도의 HTML은 로드했지만 그 이후 멈춰버렸습니다. 관련된 콘솔 오류는 없었고, Service Worker도 등록됐으며, WebRTC 핸드셰이크도 완료됐고, 데이터 채널도 열렸습니다 (dc.readyState === "open"). 그 뒤로는 아무 일도 일어나지 않았습니다. 브라우저는 데이터 채널을 통해 첫 번째 GET / 요청을 보냈지만, 응답을 영원히 기다렸습니다.
반대편에 있는 박스 에이전트는 모든 것이 정상이라고 생각했습니다. 응답을 전송하고 바이트를 채널에 푸시했지만, 그 바이트는 iPad에 도착하지 않았습니다.
여기에 더해 상황은 헷갈리게 만들었습니다: 페이지를 무작위로 새로 고치면 가끔은 로드가 되었습니다. 즉, 히젠버그였습니다.
When in doubt, instrument
우리가 처음으로 유용하게 한 일은 양쪽 끝의 로그를 기록하고 시계 시간으로 정렬하는 것이었습니다: 박스가 보낸 모든 청크, 브라우저가 받은 모든 청크, 그리고 중요한 박스가 확인된 전송을 기다리며 아웃바운드 버퍼에 보관하고 있는 데이터 양을 함께 보았습니다. 이를 통해 데이터가 어느 쪽에서 사라지는지 파악할 수 있었습니다.
Dead ends
WebRTC 핸드셰이크까지 포함한 모든 것을 배제한 뒤, 우리는 막다른 골목에 서 있었습니다. 몇 가지 WebRTC 전용 제한을 확인하고 네트워크 안정성을 재점검했습니다.
-
메시지 크기 제한. WebRTC에서는 두 피어가 데이터를 교환하기 전에 각 피어가 받아들일 수 있는 가장 큰 청크 크기에 합의합니다. 이보다 큰 데이터를 보내면 일부 브라우저는 조용히 연결을 끊어버립니다. 우리는 양쪽 장치에서
maxMessageSize값을 읽어보았습니다. iPad는 64 KB를 보고했으며, 이는 Mac과 동일하고 우리가 보내던 7~8 KB 청크보다 훨씬 컸습니다. 이때문에 우리는 메시지 청크 크기를 원인에서 제외했으며, 이는 실제 진단을 더 어렵게 만들었습니다. -
불안정한 Wi‑Fi. 가장 저렴한 설명: 무선 전파에서 패킷이 손실되는 경우. 박스에서는
ifstat와tcpdump가 깨끗했고, 같은 Wi‑Fi에 연결된 제 폰에서도 동일한 문제가 나타나지 않았습니다.
뭔가 iPad에만 특화된 문제가 있어야 했지만, 정확히 무엇인지는 알 수 없었습니다.
What the numbers actually said
요청당 박스는 세 개의 청크를 보냈습니다: 220 바이트 헤더, 7,874 바이트 본문, 그리고 199 바이트 꼬리. 새로운 계측을 통해 송신 측 아웃바운드 버퍼가 약 8 KB까지 올라가 멈추는 것을 확인했습니다. 즉, “보낸” 본문을 보관하고 있었지만 도착 확인을 받지 못하고 있었습니다. iPad를 새로 고칠 때도 동일한 패턴이 반복되었습니다.
WebRTC 데이터 채널은 손실이 가능한 UDP 위에 순서 보장을 제공하므로, 하나의 청크가 누락되면 이후 메시지는 모두 차단됩니다. iPad의 브라우저 JS 콘솔에서는 정확히 하나의 청크(220 바이트 헤더)만 수신되고 그 뒤는 아무것도 보이지 않았습니다. 본문이나 이후 요청들의 작은 헤더도 보이지 않았습니다.
Mac의 Safari에서도 테스트했는데, WebKit 때문일 수도 있다는 생각에 iOS 모든 브라우저가 WebKit 기반이므로 WebKit 문제일 가능성을 검토했지만, Mac에서는 8 KB·11 KB 청크가 문제없이 수신되었습니다.
”It was Tailscale”
WebKit 이론에 두 시간을 보낸 뒤, 나는 iPad가 Mac과 달리 Tailscale을 활성화하고 있다는 사실을 깨달았습니다.
Tailscale은 VPN이며, VPN은 트래픽을 추가 레이어로 감싸기 때문에 각 패킷에 남는 공간이 줄어듭니다. 따라서 큰 응답이 iPad로 가는 길에서는 더 작은 조각들로 나뉘어 전송됩니다. WebKit은 사용자 공간에서 데이터 채널을 구현하고, 이를 전송하는 패킷들을 다시 조립하는 역할도 담당합니다. 우리는 버그가 WebKit의 메시지 재조립에 있다고 추정했습니다.
박스의 메시지를 800 바이트 이하로 제한했더니, 각 메시지가 단일 패킷에 실려 iPad가 즉시 로드되었습니다. Tailscale을 켜든 끄든 차이가 없었습니다. 일단 사건이 해결된 듯했죠[실제로는 1,200 바이트로 처음 시도했을 때는 Claude가 계산해 준 대로 맞아야 하는데도 이상하게 동작하지 않았습니다. 그때는 기억해 두세요].
돌이켜 보면, 우리는 VPN이 문제라는 사실을 이미 발견했음에도 WebKit 이론에 집착했습니다. AI 시대의 트러블슈팅이라는 맥락 속에서, Tailscale 발견이 WebKit 이론에 흡수돼 버렸고, 네트워크와 webrtc 송신자를 직접 살펴보지 못했습니다. 대신 브라우저가 잘못됐다는 추가 근거로 삼아 iOS Safari 버그로 문서화하고(“디바이스는 패킷을 받지만 앱을 위해 재조립하지 않는다”) 독립적인 재현을 만들기 시작했습니다.
The repro that wouldn’t repro
그 후 2주 동안 JavaScript 송신기로는 버그가 재현되지 않아, webrtc‑rs 기반 Rust 송신기로 전환했습니다. 여전히 아무 일도 일어나지 않았습니다. 데이터 채널 청크 형태와 크기를 동일하게 맞추고, Linux와 iPad 양쪽에서 실제 브라우저 수신기를 사용했으며, Tailscale 유무에 관계없이 테스트했습니다. 매번 모든 것이 정상적으로 전달되었습니다. 결국 우리는 스스로 수집한 증거를 다시 살펴볼 수밖에 없었습니다[실제로는 Anthropic이 Fable을 공개했고, 나는 원본 디버깅 세션의 jsonl 로그를 찾아냈습니다].
결정적인 숫자는 WebRTC 자체 getStats() 카운터에 있었으며, 우리는 이를 콘솔에 로그하고 사고 발생 중에 화면 캡처를 저장했습니다. iPad의 후보 쌍은 18개의 패킷을 통해 2,144 바이트만 수신했으며, 데이터 채널은 정확히 하나의 메시지(266 바이트, 220 바이트 헤더 + 프레이밍)만 전달했습니다. 박스는 큰 패킷을 계속 재전송하고 있었습니다. 만약 Safari가 패킷을 받고 단지 메시지를 재조립하지 못한다면, 재전송마다 전송 카운터가 킬로바이트 단위로 증가했어야 했지만 전혀 증가하지 않았습니다. 즉, 패킷 자체가 도착하지 않은 것이었습니다.

사고 당일 실제 사진. 중요한 모든 숫자가 프레임 안에 있지만, 이를 이해하는 데 2주가 걸렸습니다.
그래서 우리는 브라우저 버그를 재현하려는 시도를 멈추고, 네트워크 자체를 재현했습니다.
Suspect number one: webrtc-rs
webrtc-rs – 우리 박스가 사용하는 Rust WebRTC 스택 – 은 아웃바운드 데이터 채널 메시지를 다음 상수에 맞춰 패킷으로 나눕니다:
// sctp/src/association/mod.rs
pub(crate) const INITIAL_MTU: u32 = 1228;
이 값은 설정이 불가능하고 절대 업데이트되지 않습니다. 1,228 바이트 패킷에 암호화 레이어가 추가되면 와이어 상에서는 1,265 바이트가 됩니다. 여기에 UDP와 IPv4 헤더 28 바이트(IPv6는 48 바이트)를 더하면 IPv4 기준 1,293 바이트, IPv6 기준 1,313 바이트가 됩니다. Tailscale 터널은 최대 1,280 바이트만 전달합니다.
패킷이 너무 커서 자체적으로 치명적인 오류가 발생하는 것은 아니었습니다. 커다란 패킷이 터널로 라우팅될 때 커널은 1980년대부터 해온 예의 바른 동작을 수행합니다: 분할(fragmentation). 제한 이하의 두 조각으로 나누어 전송하고, 상대편에서 다시 조립합니다. 우리는 tcpdump로 이를 확인했습니다. 조각은 박스에서 나가고, 정상적인 경로라면 모두 도착해 버그가 보이지 않게 됩니다. 바로 이 점이 우리 독립 재현이 계속 성공한 이유였습니다.
우리는 다시 설계 단계로 돌아갔습니다. 재현에서는 패킷이 깔끔히 분할·재조립됐지만, 실제 사고에서는 iPad가 … (이하 내용은 원문에 이어지지 않음).