약한 네트워크의 스왑 페이지: 3G에서 실제로 깨지는 것 (2026 실측)
Slow 3G에서 스왑 페이지는 1.6초에 '될 것처럼' 보이지만 실제 사용은 6.8초입니다. 실측: 이른 입력은 지워지고, 느린 타이핑은 견적 4회를 부르며, 순서가 뒤바뀐 응답이 표시 가격을 뒤집습니다.
2026년 11월 6일 14:03 UTC, 새 모바일 Chromium 프로필로 veilswap.io/exchange를 열고 Chrome의 Slow 3G(왕복 400ms, 상하향 400kbps)로 제한해 모든 요청을 기록했다. 입력창은 1.6초에 보이지만 첫 데이터 요청 /api/currencies는 6.84초에야 나가고 React는 6.84초에야 동작한다. 그 사이 40개 요청으로 235KB. 가장 큰 JS 청크 세 개(54.7, 47.0, 28.2KB)만 각각 6.09초, 5.91초, 5.04초 걸렸다. 견적 본문은 181바이트, API 자체는 0.44~1.10초 — 가격은 병목이 아니었다. 제한 없으면 첫 요청은 0.589초. 약한 회선에서 페이지가 느린 것은 무거워서다.
실패 1: 폼은 먼저 보이고, 생명은 나중에 붙는다. 1.93초에 100을 입력했다. 서버가 그린 HTML은 화면에 있고 JS는 아직 내려받는 중이었다. 15초 뒤 입력창은 비어 있었고 견적 요청은 0건이었다. 하이드레이션이 React의 초기 상태(빈 문자열)로 DOM 값을 덮어써 입력이 경고 없이 사라졌다. React 버그가 아니라 '폼은 HTML로, 상태는 JS로' 내보내는 하이드레이션의 필연이다. 빠른 회선은 첫 요청이 0.589초, Slow 3G는 6.84초. 통화 수나 환율이 뜰 때까지 입력하지 말고, 사라졌으면 다시 입력하라.
실패 2: 500ms 디바운스는 빨리 치는 사람에게만 유효하다. 코드는 마지막 키 입력 후 500ms를 기다렸다가 /api/quote를 보낸다. 100을 한 번에 fill하면 요청 1개, 1.49초 뒤 수취액이 표시됐다. 한 글자 900ms로 '1000'을 치면 요청 4개 — amount=1이 +0.51초, 10이 +1.42초, 100이 +2.32초, 1000이 +3.23초 — 최대 두 개가 동시에 날아간다. 처음 두 개는 400으로 돌아온다. 1과 10 USDT가 이 페어의 하한 19.219061 USDT 아래이기 때문이다. 왕복은 0.7~1.1초. 견적 API는 IP당 10분에 60회이고 검증 전에 세므로 느린 편집 약 15회면 소진된다. 8초 캐시 키는 from|to|amount. 같은 100 USDT-TRX를 5초 안에 반복하면 240~295ms에 같은 181바이트가 돌아오고, 1센트 바꾸면 미스한다. (XMR→USDT 출구 루트)
실패 3: 응답은 순서를 어기고 도착할 수 있고, 오래된 것이 새것을 덮어쓴다. 견적 effect에는 AbortController도 일련번호도 없으며 모든 응답이 그대로 setQuote를 호출한다. 14:07 UTC 실제 견적 두 개(100 USDT→0.14082928 XMR, 1,000→1.592011)에서 첫 응답을 2.5초, 둘째를 0.1초 지연시켰다. 100→1,000으로 입력하면 화면은 0.140829에 머물고 입력은 1,000 — 11.3배 과소 표시. 반대로 지연시키면 입력 100에 수취 행은 1.592, 환율 행은 0.00159183으로 정확한 0.00140888보다 13.0% 높다. 주문 API는 다시 견적하고 요청 금액으로 주문을 만들므로 체결이 옛 가격이 되지는 않지만 — 화면은 틀린다. 환율이 안정될 때까지 기다리거나 주문 전 새로고침하라. (XMR→USDT 출구 루트)
실패 4: 주문 페이지는 5초마다 폴링하며 타임아웃도, 진행 중 가드도 없다. 코드는 /api/order/<id>를 즉시 호출하고 setInterval(5000)을 돌린다. 각 호출은 독립적이고 AbortController도 '이전 요청이 열려 있으면 이번 틱을 건너뛰기' 판정도 없다. 가짜 waiting_deposit을 0.5초에 반환하고 이후 응답을 모두 붙잡았다. 요청은 1.6, 7.1, 12.1, 17.1, 22.1, 27.1, 32.1초 — 정확히 5.000초 간격 — 마지막에는 6개가 동시에 떠 있었다. 페이지는 '입금 대기'를 계속 보여 주고, 호박색 '상태 동기화 지연' 배너는 뜨지 않았다. 그 배너는 상류가 pollError를 돌려줄 때만 설정되고 fetch가 매달리거나 비2xx여도 뜨지 않는다. 모바일 백그라운드 탭은 타이머를 더 조인다. 주문 자체는 이 페이지에 의존하지 않는다. 입금 주소는 풀 쪽이고 출금은 풀의 시계로 진행된다.
실패 5: 재시도의 값은 보기보다 비싸다. Exchange 버튼은 creating이 true인 동안만 비활성이고 클릭 핸들러에 두 번째 가드가 없다. 진짜 벽은 서버다: 같은 IP에서 같은 fromAsset|toAsset|fromAmount|toAddress가 90초 안에 오면 어떤 풀도 호출하지 않고 429 duplicate order를 돌려준다. 14:06 UTC에 일부러 하한 미만인 1 USDT로 재봤다. 첫 POST는 1.128초에 400 amount below minimum(하한 19.219061, 상한 165,135.014302), 완전히 같은 두 번째 POST는 0.234초에 429 duplicate order. 싸고 정확하다. 비싼 것은 IP당 시간당 6건 리미터가 필드·주소 검증보다 먼저 돌아간다는 점이다. 실패한 시도도 한 칸을 태운다. 환불 주소를 6번 잘못 치면 그 시간엔 진짜 주문을 못 낸다. 주소는 폼에서 고치고, duplicate order를 보면 금액을 건드리지 말고 주문 링크를 열어라. (XMR→USDT 출구 루트)
3G에서 무엇을 할지, 페이지가 무엇을 고쳐야 하는지. 사용자 쪽: 통화 수나 환율이 뜰 때까지 입력하지 않는다. 회선이 느리면 금액은 붙여넣기한다. 편집할 때마다 수취 행이 멈추길 기다리고 '1 USDT ≈' 환율이 금액과 맞는지 확인한다. 스피너가 10초를 넘으면 '아직'이지 '실패'가 아니다. 주문 링크는 챙기되 약한 회선에서 주문 탭을 한 시간 열어 두지 않는다. 페이지 쪽: 디바운스를 RTT에 맞춰 적응시키고, 이전 견적을 abort하며 순서 밖 응답을 무시하고, 71KB 통화 카탈로그를 두 컴포넌트가 각각(압축 13.9KB×2) 받는 것을 한 번으로 합치고, 하이드레이션을 막는 초기 번들을 줄이고, 폴링에 타임아웃·백오프·visibilitychange 즉시 갱신을 넣고, 하이드레이션 완료 전까지 폼을 비활성화한다. (XMR→USDT 출구 루트)
마무리: 스왑은 살아남고, 약한 것은 화면이다. 무-KYC 스왑의 본체는 주소와 입금이다. 자금은 풀 쪽에 있고 풀의 시계로 출금되므로 회선이 나빠도 이미 만든 주문은 취소되지 않는다. 회선이 할 수 있는 일은 숫자를 잘못 보여 주고, 입력을 삼키고, 두 번 누르게 하는 것이다. 작성 시점(2026-11-06 14:0x UTC) 100 USDT-TRX는 0.14082928 XMR(1개 경로, validUntil 120초), 1,000은 1.592011 XMR(2개 경로). 같은 페어 하한 19.219061, 상한 165,135.014302 USDT. 카탈로그는 436개 자산·65개 네트워크. 숫자는 움직이며 페이지의 라이브 견적이 기준이다. (XMR→USDT 출구 루트)