iPad에서만 비어 있는 페이지가 뜨던 문제를 추적한 끝에, webrtc-rs의 하드코딩된 MTU 상수와 IPv6 조각을 버리는 Tailscale의 동작이 함께 만든 함정이 원인이었음을 밝혀낸 디버깅 기록입니다.
p2claw가 어떻게 동작하는지 익숙하지 않다면, 이 글을 읽기 전에 how it works 블로그 글을 먼저 보는 것이 좋습니다.
내 iPad에서 p2claw 앱 하나를 열었더니 빈 페이지가 나왔다. 같은 URL은 Mac, linux 박스, 그리고 휴대폰에서는 잘 작동하고 있었다. 같은 wifi, 같은 브라우저 엔진, 같은 네트워크였다.
훌륭한 추리 소설처럼, 우리는 여러 용의자를 세웠다 [iPad, 그다음 WebKit, 그다음 Tailscale]. 그리고 모두 무죄로 밝혀졌다. 어느 정도는. 알고 보니 이건 코트를 걸친 두 개의 버그였다. 하나는 webrtc-rs의 하드코딩된 상수였고, 다른 하나는 우리가 순전한 고집으로 찾아낸 Tailscale의 한 줄짜리 설계 결정이었다. 우회 패치는 같은 날 넣었지만, 우리가 정확히 무엇을 패치한 건지 이해하는 데는 그 뒤로도 2주가 더 걸렸다.
앱은 로딩 상태를 그릴 만큼의 HTML은 불러왔지만 그 뒤로 멈췄다. 눈에 띄는 콘솔 에러는 없었고, Service Worker는 등록되었고, WebRTC 핸드셰이크도 끝났고, 데이터 채널도 열렸다 [dc.readyState === "open"]. 그런데 그다음이 없었다. 브라우저는 첫 번째 GET /를 데이터 채널로 보내고 응답을 영원히 기다렸다.
반대편의 box agent는 모든 것이 정상이라고 생각하고 있었다. 응답을 이미 서빙했고 바이트도 채널 위로 밀어 넣었다. 단지 그것들이 iPad까지 절대 도달하지 않았을 뿐이었다.
이것만으로도 충분히 까다로운데, 더 문제였던 건 이게 하이젠버그였다는 점이다. 미친 듯이 새로고침하면 페이지가 가끔은 로드되었다.
우리가 처음으로 한 유용한 일은 연결 양쪽 끝에서 로그를 남기고, 시계 시간 기준으로 나란히 맞춰 보는 것이었다. 박스가 보낸 모든 청크, 브라우저가 받은 모든 청크, 그리고 결정적으로 박스가 송신 버퍼에 쥔 채 전달 확인을 기다리고 있던 데이터의 양까지 기록했다. 덕분에 데이터가 상대편까지 도달하지 못하는 지점을 파악할 수 있었다.
webrtc 핸드셰이크까지의 모든 가능성을 버린 뒤에는 거의 지푸라기라도 잡는 심정이었다. webrtc 특유의 몇 가지 제한을 확인했고 네트워크 안정성도 다시 점검했다.
maxMessageSize)을 읽어 왔다. iPad는 64kb를 보고했고, 이는 Mac과 정확히 같았으며 우리가 보내던 7-8kb 청크보다 훨씬 컸다. 이 시점에서 우리는 메시지 청크 크기를 범인 후보에서 제외했다고 느꼈는데, 결과적으로는 이 판단이 진짜 원인에 도달하기를 더 어렵게 만들었다.문제는 분명 iPad에 특화된 무언가였지만, 그게 무엇인지는 전혀 감이 없었다.
요청 하나마다 박스는 세 개의 청크를 보냈다. 220바이트 헤더, 7,874바이트 본문, 199바이트 꼬리였다. 새로 넣은 계측은 송신 측의 송신 버퍼가 약 8kb까지 올라간 뒤 멈추는 모습을 보여 주었다. 이미 “보낸” 본문을 버퍼에 쥔 채, 그것이 도착했다는 확인만 영원히 받지 못하고 있었던 것이다. iPad에서 새로고침할 때도 완전히 같은 패턴이 반복되었다.
WebRTC 데이터 채널은 손실 가능한 UDP 위에서 순서 보장을 제공하므로, 청크 하나가 빠지면 그 뒤 메시지들이 전부 막힌다. iPad에서는 브라우저 js 콘솔에서 정확히 하나의 청크만 수신되는 것이 보였다 [220바이트 헤더]. 그 뒤로는 아무것도 없었다. 본문도 보이지 않았고, 그다음 요청들의 작은 헤더들조차 보이지 않았다.
문제가 모든 ios 브라우저에서 발생했기 때문에 [그리고 ios 브라우저는 전부 내부적으로 webkit을 쓴다] WebKit 문제일 수 있다고 짐작해 Mac의 Safari에서도 테스트해 보았지만, Mac은 8kb와 11kb 청크를 아무 문제 없이 받고 있었다.
WebKit 가설을 붙잡고 2시간쯤 보낸 뒤, 나는 Mac과 달리 iPad에는 Tailscale이 켜져 있다는 사실을 깨달았다.
Tailscale은 VPN이고, VPN은 트래픽을 여분의 계층으로 한 번 더 감싸므로 각 패킷 안에 들어갈 수 있는 여유 공간이 줄어든다. 그래서 큰 응답은 Mac으로 갈 때보다 iPad로 갈 때 더 많은, 더 작은 조각들로 나뉘었다. WebKit은 데이터 채널을 사용자 공간에서 직접 구현하며, 패킷에 실려 오는 큰 메시지를 다시 조립하는 일도 포함한다. 우리의 가설은 점점 webkit의 메시지 재조립 버그 쪽으로 기울었다.
우리는 박스의 메시지 크기를 800바이트로 제한했다. 그러면 각각이 단일 패킷 하나에 실릴 만큼 충분히 작아진다. 그랬더니 iPad는 Tailscale이 켜져 있든 꺼져 있든 즉시 로드되었다. 사건 종료처럼 느껴졌다 [사실 처음에는 Claude의 계산 도움을 받아 맞을 거라고 생각한 1,200바이트를 먼저 시도했는데, 이상하게도 그건 동작하지 않았다. 이건 잠시 뒤에 다시 나온다].
돌이켜 보면, 우리는 바로 그 순간 문제가 VPN이라는 사실을 발견한 셈이었다. 그런데도 WebKit 가설에 매달렸다. 우리의 맥락 과부하 때문에 [내 것도, agent들의 것도, 어쨌든 이건 AI 시대의 트러블슈팅이니까] Tailscale 발견은 WebKit 가설에 흡수되어 버렸고, 가설을 흔드는 역할을 하지 못했다. 네트워크와 webrtc sender를 볼 수도 있었지만, 대신 브라우저 잘못이라는 이유 하나를 더 얻었다고 받아들였다. 그래서 사건 기록을 iOS Safari 버그 [기기는 패킷을 받지만 앱을 위해 다시 조립하지 못한다]로 정리했고, 이를 증명할 독립적인 재현 환경을 만들기 시작했다.
그 뒤 2주 동안 이 버그는 JavaScript sender로는 재현되지 않았고, 그래서 webrtc-rs 기반 Rust sender로 방향을 돌렸다. 그래도 아무 일도 없었다. 데이터 채널 청크의 형태와 크기를 맞췄고, Linux와 iPad 양쪽에서 실제 브라우저 receiver를 사용했으며, Tailscale을 켠 경우와 끈 경우를 모두 시험했다. 그래도 매번 모든 것이 전달되었다. 결국 우리는 우리 스스로 모아 둔 증거를 다시 읽을 수밖에 없었다 [정확히는 Anthropic이 Fable을 출시했고, 나는 그걸 시켜 원래 디버깅 세션의 jsonl 로그를 다시 파내게 했다].
결정적인 숫자는 WebRTC 자체의 getStats() 카운터에 들어 있었다. 우리 클라이언트는 이 값을 콘솔에 기록하며, 사건 당시 우리는 그 화면을 사진으로 찍어 두었었다. iPad의 candidate pair는 총 18개 패킷에 대해 수신 바이트 2,144에서 멈춰 있었고, 데이터 채널은 정확히 하나의 메시지만 전달한 상태였다 [266바이트, 즉 220바이트 헤더에 프레이밍이 더해진 것]. 박스는 그 큰 패킷을 내내 재전송하고 있었다. 만약 Safari가 그 패킷들을 받고는 단지 _메시지로 다시 꿰매는 데 실패_하고 있었던 것이라면, 메시지가 멈춰 있는 동안에도 재전송이 일어날 때마다 transport 카운터는 또 1킬로바이트 이상씩 올라가야 했다. 하지만 전혀 움직이지 않았다. 패킷은 아예 도착하지 않고 있었다.

사건 당일 밤 실제로 찍은 사진이다. 중요한 숫자는 모두 화면에 들어와 있었지만, 그것들을 이해하는 데 2주가 걸렸다.
그래서 우리는 브라우저 버그를 재현하려는 시도를 멈추고, 대신 _네트워크_를 재현했다.
webrtc-rs는 우리 box가 사용하는 Rust WebRTC 스택인데, 송신 데이터 채널 메시지를 다음 기준에 맞춰 패킷으로 자른다.
// sctp/src/association/mod.rs
pub(crate) const INITIAL_MTU: u32 = 1228;
이 값은 설정으로 바꿀 수 없고, 어디에서도 갱신되지 않는다. 1,228바이트 패킷에 그것을 감싸는 암호화 계층까지 더하면 실제 선로 위에서는 1,265바이트가 된다. 여기에 UDP와 IPv4 헤더 28바이트를 더하면 IPv4에서는 1,293바이트 패킷이 되고, IPv6라면 48바이트를 더해 1,313바이트가 된다. Tailscale 터널은 최대 1,280까지만 실을 수 있다.
흥미롭게도 패킷이 너무 크다는 사실 자체만으로는 치명적이지 않다. 커널이 큰 패킷을 터널 쪽으로 라우팅할 때는, IP 계층이 1980년대부터 해 오던 정중한 일을 한다. 조각화다. 선로에는 제한 이하의 두 조각으로 나누어 보내고, 반대편에서 다시 조립된다. 우리는 tcpdump로 이것을 확인했다. 조각들은 박스를 떠난다. 경로가 건강하다면 모든 것이 도착하고 버그는 보이지 않는다. 바로 이 점 때문에 우리의 독립 재현 환경이 계속 통과했던 것이다.
우리는 다시 원점으로 돌아왔다. 재현 환경에서는 패킷이 조각화되었다가 깔끔하게 재조립되었지만, 실제 사건에서는 iPad가 멈췄다. 그렇다면 질문은 왜 패킷이 너무 컸느냐가 아니었다. 질문은 이거였다. 조각들은 어디로 갔는가?
그 답을 얻기 위해 우리는 실제 환경으로 돌아갔다. box agent의 청크 제한을 다시 8kb까지 올리고, 실제 앱을 그것을 통해 서빙한 다음, Tailscale을 거친 iPad에서 앱을 로드하면서 터널 인터페이스를 캡처했다.
예상대로 그대로 멈췄고, 이번에는 두 계층을 동시에 보고 있었다. agent의 송신 버퍼는 13kb에서 멈췄다 [앱과 페이로드가 달라서 8kb가 아니라 13kb였다]. 선로에서는 같은 1,265바이트 페이로드가 두 개의 IPv6 조각으로 나가고, SCTP의 교과서적인 백오프 일정에 따라 재전송되었다: +1.2s, +2s, +4s, +8s. 매번 완전히 같은 조각들이 나갔고, 한 번도 확인 응답을 받지 못했다. 그런데도 그 와중에 작은 패킷들은 아무 문제도 없다는 듯 양방향으로 계속 흘렀다. 하트비트, 오래된 데이터에 대한 ack, 연결성 검사, 모두 정상이었다. 실제 데이터 페이로드만 빼고는 연결이 완벽하게 건강해 보였다.
그러다가 같은 tailnet에 있는 Linux 노트북이 같은 터널을 통해 같은 앱을 아무 문제 없이 불러왔다. 이로써 전체 문제를 여는 실험 하나가 손에 들어왔다.
조각들이 iPad로 가는 경로 어딘가에서 죽고 있다면, 그걸 증명하는 데 WebRTC는 필요 없었다. 우리는 ping을 시도했다.
1,400바이트 ping은 1,280바이트 터널을 통과할 때 조각화를 강제한다. 100바이트 ping은 그렇지 않다. 둘 다 주소 계열별로 실행하면 진실표 하나가 나온다.
ping -s 100 <ipad over IPv4> 3/3 received
ping -s 1400 <ipad over IPv4> 3/3 received fragments fine
ping -s 100 <ipad over IPv6> 3/3 received
ping -s 1400 <ipad over IPv6> 0/3, 100% loss fragments gone
IPv4 조각은 재조립된다. IPv6 조각은 사라진다. 매번, 결정적으로 그렇다.
이건 iOS만의 문제가 아니었다. 우리가 이 실험을 해 본 모든 Tailscale 기기에서 같은 패킷 손실이 나타났다. 모든 플랫폼의 Tailscale 내부 어딘가에 IPv6 조각을 먹어 치우는 무언가가 있었다.
Tailscale 클라이언트는 진단용 카운터를 유지하는데, 수신 기기에서 ping -s 1400을 IPv6로 보내면 그중 하나가 증가한다. tailscale metrics print 출력에는 다음이 포함된다.
tailscaled_inbound_dropped_packets_total{reason="acl"} 6
세 번의 ping, 각 두 개의 조각, 총 여섯 번의 드롭. 우리가 확인한 모든 기기에서 계산이 정확히 맞아떨어졌다. 커널 자체의 IPv6 재조립 카운터는 내내 0이었으므로, 조각들은 운영체제가 보기도 전에 드롭되고 있었다.
reason="acl"은 패킷 필터가 정책 위반으로 패킷을 드롭했다는 뜻이다. 그런데 접근 정책이 _전부 허용_인 개인 tailnet에서 이런 값을 보는 건 이상한 일이다. 그래서 우리는 소스를 보기 위해 github로 갔다 [Tailscale 클라이언트는 오픈 소스이고, 덕분에 이런 추적이 가능했다]. 거기서 알게 된 사실은 이렇다. 그들의 IPv6 파서는 조각을 파싱하지 않는다. IPv6 Fragment 헤더를 가진 패킷은 모두 “알 수 없는 프로토콜”로 분류되고, 알 수 없는 프로토콜 패킷은 어떤 허용 규칙과도 매칭될 수 없으므로 기본 거부가 발동한다. 코드 주석은 이렇게 말한다.
Note that this means we don't support fragmentation in IPv6. This is fine, because IPv6 strongly mandates that you should not fragment.
겉보기에는 그럴듯한 문장이지만, 나는 이것이 오해라고 생각한다. IPv6는 _라우터_가 전송 중 패킷을 조각내는 것을 금지한다. 하지만 _송신자_의 조각화는 완전히 허용하며, 명세는 수신 측이 그 조각들을 다시 합치도록 요구한다. 우리 송신 커널은 규칙을 지키고 있었다. Tailscale의 필터는 커널이 만든 것을 설계상 조용히 드롭하고, 그 이유를 “acl”로 분류한다. 참고로 IPv4 조각은 제대로 처리되어 문제없이 통과하는데, 이것도 우리의 하이젠버그를 더 헷갈리게 만든 요인이었다.
https:// 트래픽은 애초에 패킷 크기를 협상한다 [TCP MSS clamping]. 그래서 초과 전송을 하지 않는다. WebRTC가 쓰는 종류의 트래픽에는 그런 협상이 없고, 그 밖의 대부분 소프트웨어도 자체 MTU 처리를 하지 않은 채 v6 위로 큰 UDP를 보내지 않으므로, 이런 함정은 webrtc-rs 같은 것이 걸려들기 전까지는 조용히 숨어 있다.두 줄짜리 버전은 tailnet 위 기기 두 대만 있으면 된다.
ping -s 100 <any tailscale IPv6 address> works
ping -s 1400 <any tailscale IPv6 address> 100% loss
전체 WebRTC 버전은 github.com/phact/mtu-webrtc-bug에 있다. 큰 패킷을 드롭하는 작은 relay [조각을 먹는 경로를 결정론적으로 흉내 내는 대역 배우], 실제 터널에서의 캡처, 그리고 원인 위치를 좁혀 가는 과정에 대한 정리 diagnostics/who-loses-the-packets.md가 포함되어 있다. 다음으로 우리는 webrtc-rs 유지보수자들에게 이 상수와 제안하는 수정안을 보고할 예정이고, tailscale repo에는 조각 드롭 문제에 대한 이슈를 올릴 예정이다.
경로가 감당할 수 없는 너무 큰 패킷이 조용히 사라지고, 왜 그런지 아무에게도 알려 주지 않는 문제는 인터넷에서 가장 오래된 문제 중 하나다. 이건 해결되었다기보다 덮어 두어진 채로 남아 있고, 새로운 소프트웨어가 경로가 받아들일 수 있는 크기를 확인하지도 않은 채 직접 패킷을 보내기 시작할 때마다 다시 떠오른다. 그런 종류의 것을 만들고 있다면 [화상 통화, 게임, 무엇이든 peer-to-peer], 예상보다 작은 경로 위에 있는 사용자가 실제로 꽤 된다고 가정하고, 패킷을 보수적으로 작게 유지하거나 신뢰하기 전에 먼저 탐지하라.
여기서 어느 프로젝트도 미친 짓을 한 것은 아니다. webrtc-rs는 상수 하나를 골랐고 [참고로 이것은 Chrome보다 겨우 28바이트 더 낙관적일 뿐이다], 네트워크가 알아서 감당하리라 믿었다. Tailscale은 IPv6 조각은 지원할 가치가 없다고 판단했고, 정당한 트래픽이라면 그런 걸 보내지 않으리라 믿었다. 각각 따로 보면 방어 가능한 결정이다. 하지만 둘이 합쳐지면 에러 메시지도 없는 함정이 된다. 증상이라고는 특정 기기 하나에서 빈 페이지만 뜨는 것뿐인데, 이 문제 하나로 1주일이나 2주일을 날릴 수도 있다.
한편으로는, 우리가 이 문제를 맞닥뜨린 건 p2claw가 것들을 이상한 방식으로 쓰기 때문이라고 말하고 싶기도 하다. 그건 사실이고, 동시에 바로 그것이 p2claw의 존재 이유이기도 하다. p2claw는 agent들이 회원가입 없이 self host할 수 있도록, vibe coder들이 단 한 번의 cli 호출로 oauth와 함께 배포할 수 있도록, 웹 앱이 peer to peer가 될 수 있도록 존재한다. 이를 위해 우리는 대부분의 소프트웨어가 인터넷에 참여하기 위해 의존하는 여러 기계장치를 우회한다. 프로그래밍의 본질이 바로 이것이다. 시스템과 표준을 자기 뜻대로 휘게 만드는 것. 더 많이 휘게 만들수록 버그도 더 괴상해진다.
이번에 내가 가져가려는 디버깅 교훈은 두 가지다. 첫째, 송신 측 패킷 캡처는 패킷이 _떠났다는 것_만 증명한다. 우리는 박스에서 tcpdump로 조각이 흐르는 것을 “확인”하고 경로가 건강하다고 결론내렸지만, 조각들은 아름답게 떠나서 매번 도착 즉시 죽고 있었다. 수신 측을 봐야 한다 [receiver가 탈옥하지 않은 iPad일 때는 말처럼 쉽지 않지만, 요지는 같다]. 둘째, 버그가 한 기기에서만 나타난다면, 그 기기를 탓하기 전에 그 기기만 타는 _경로_가 무엇인지 물어라.
iPad는 멀쩡했다. iPad는 단지 Tailscale에 연결되어 있었을 뿐이다. 그리고 Tailscale은 단지 주석에 적힌 그대로를 하고 있었을 뿐이다.
업데이트: 두 문제 모두 이슈로 등록되었다. webrtc-rs 상수는 webrtc-rs/webrtc#806이고, IPv6 조각 드롭은 tailscale/tailscale#20083이다.