[PonsWarp 개념 교실 16] in-flight 윈도우 — BDP와 슬라이딩 윈도우
쉽게 말하면, in-flight 윈도우는 "아직 네트워크를 날아가는 중인 데이터의 최대량"이고, 슬라이딩 윈도우는 일부가 빠져나간 만큼 뒤에서 새 데이터를 채우는 규칙이다.

파일 전송에서 빠르다는 이유로 계속 보내면 브라우저 내부 버퍼가 커진다. PonsWarp는 네트워크가 감당할 수 있는 만큼만 동시에 날려 보낸다.
이게 왜 필요한가
송신자는 두 가지 극단을 피해야 한다.
- 한 번에 하나씩만 보내면 RTT를 기다리느라 회선이 빈다.
- 끝없이 보내면
bufferedAmount와 메모리가 쌓인다.
그래서 통로 안에 허용할 데이터량을 창(window)으로 정한다.
[이미 전송 중인 데이터] [아직 보내지 않은 데이터]
└──── in-flight window ────┘
앞의 데이터가 빠져나가면 창이 오른쪽으로 이동한다.
BDP — RTT가 길수록 창이 커야 한다
BDP(Bandwidth-Delay Product)는 대역폭 × 왕복 지연시간이다. 회선이 100Mbps이고 RTT가 100ms라면, 파이프를 채우려면 대략 1.25MB가 동시에 날아가야 한다.
필요한 in-flight ≈ bandwidth × RTT
RTT가 긴 TURN relay에서 창이 너무 작으면 데이터가 도착하기 전에 송신자가 기다린다. 반대로 LAN에서 너무 크게 잡으면 버퍼만 불어난다.
PonsWarp의 target과 실제 budget
PonsWarp는 경로 진단으로 currentInFlightTargetBytes를 정하고, 실제로 더 보낼 수 있는 양은 현재 bufferedAmount를 뺀다.
typescript// PonsWarp/src/utils/transferFlowControl.ts computeSendBudget({ targetInFlightBytes, bufferedAmountBytes, }); // budget = target - bufferedAmount
typescript// PonsWarp/src/services/swarmManager.ts private getCurrentInFlightTargetBytes(): number { return computeScaledInFlightTargetBytes({ baseInFlightBytes: this.currentInFlightTargetBytes, stripeEnabled: this.stripeEnabled, lanStripeLanes: LAN_STRIPE_LANES, verifiedStripeKeyCount: this.verifiedStripeKeys.size, }); }
현재 큐가 5MiB이고 target이 12MiB라면 약 7MiB만 추가로 요청한다. 창이 가득 차면 생산자를 멈춘다.
경로별 창
host·srflx·relay마다 initialInFlightBytes, maxInFlightBytes, lowWaterBytes가 다르다. 높은 RTT host에서는 BDP 때문에 창을 줄이지 않고 profile의 최대값을 사용한다.
typescriptif (isElevatedHostRtt(selected)) { currentInFlightTargetBytes = Math.max( currentInFlightTargetBytes, currentTransferTuningProfile.maxInFlightBytes ); }
PonsWarp 적용 지점
| 항목 | 역할 | 증거 |
|---|---|---|
| target | 동시에 날릴 목표 바이트 | swarmManager.ts:1287-1293 |
| budget | target - bufferedAmount | transferFlowControl.ts:348-360 |
| 경로 진단 | ICE 경로·RTT·bitrate로 profile 선택 | swarmManager.ts:1316-1322 |
| 고RTT 보정 | BDP를 위해 max window 보장 | swarmManager.ts:1323-1328 |
| low water | bufferedAmount가 내려오면 다시 채움 | peerSession.ts:206,259 |
창을 실제로 운영하는 순서
슬라이딩 윈도우는 숫자 하나를 정해 두는 기능이 아니라, 보낸 바이트와 확인된 바이트의 차이를 계속 계산하는 상태 머신이다. 입력은 다음 패킷과 현재 inFlightBytes, 출력은 send를 허용할지 여부다. 패킷을 전송할 때는 실제 packet bytes를 더하고, ACK나 채널 소비 이벤트가 오면 같은 단위로 뺀다. 이 단위가 payload bytes와 섞이면 암호화 헤더 때문에 창이 조용히 초과한다.
textacked frontier ───────────────┐ │ 새로 보낼 수 있는 구간 sent bytes ─────────────────┼──────────────► │ in-flight = sent - acked in-flight <= cwnd 또는 budget
정상 흐름은 next()가 준비 패킷을 가져오고, sender가 채널에 넘긴 뒤, 소비 시점에 credit을 반환하는 순서다. 창이 가득 찼다고 해서 파일 루프를 종료하면 안 된다. 대기 Promise를 걸어 두고 ACK·bufferedAmount 변화·취소 중 먼저 도착한 이벤트가 루프를 깨워야 한다. 그렇지 않으면 일시적인 혼잡이 영구 정지처럼 보인다.
그냥 한 번에 많이 보내면 안 되나?
DataChannel이 reliable이라고 해서 애플리케이션 메모리까지 무한히 안전한 것은 아니다. SCTP가 재전송을 책임져도 브라우저의 bufferedAmount, 워커 준비 큐, 수신 writer 큐는 별도 자원이다. 한 번에 전부 send()하면 함수 호출은 성공해도 지연된 데이터가 메모리에 남는다. 반대로 패킷 하나씩 ACK를 기다리면 RTT 동안 회선이 빈다.
그래서 창은 파이프라인을 채우되 상한을 둔 절충이다. BDP는 시작값을 고르는 참고치이지 절대 정답이 아니다. RTT가 짧고 회선이 빠른 데스크톱과, 화면이 꺼지고 무선망이 흔들리는 모바일은 같은 창을 공유하면 안 된다.
| 항목 | 의미 | 잘못 세면 생기는 일 |
|---|---|---|
| payload bytes | 파일 데이터 | 암호화 오버헤드 누락 |
| packet bytes | 채널·큐가 실제 보유한 크기 | 안전한 메모리 예산 |
| ACKed bytes | 수신측 연속 확인 위치 | 재전송 기준 오류 |
bufferedAmount | 브라우저 채널 내부 잔량 | 송신 속도 과대평가 |
경계값과 재구축 판단
창이 0이면 전송은 ACK마다 한 패킷씩 움직여 안전하지만 RTT를 그대로 지불한다. 창이 지나치게 크면 순간 처리량은 좋아 보여도 모바일 탭의 메모리와 수신 writer를 압박한다. bufferedAmountLowThreshold 이벤트만 믿지 말고, 이벤트가 누락된 브라우저를 대비해 재확인 타이머나 다음 상태 변화 때의 즉시 점검을 둬야 한다. 취소는 창 대기자와 파일 읽기 루프를 함께 깨워야 한다.
PonsWarp를 다시 만들 때는 cwnd, worker credit, receiver watermark를 한 변수로 합치지 않는다. 각각 “채널에 아직 소비되지 않은 양”, “워커가 미리 준비한 양”, “디스크에 아직 flush되지 않은 양”을 뜻한다. 세 숫자를 분리해 로그에 남겨야 어느 경계가 병목인지 설명할 수 있다.
한 문장 정리
in-flight 윈도우는 동시에 날아갈 데이터량을 제한하고, PonsWarp는 BDP·RTT·ICE 경로와 bufferedAmount를 조합해 창이 비는 만큼만 다시 채운다.
관련 개념: [개념 8 — 청크 크기] · [개념 14 — DataChannel] · [개념 15 — prepare-ahead 크레딧]
다음 글: [개념 17 — 파티션 배리어와 PARTITION_ACK]
왜 패킷 수가 아니라 바이트로 창을 세나
청크마다 크기가 다를 수 있기 때문에 “현재 10개 패킷”만으로는 버퍼 부담을 알 수 없다. 16KiB 조각 10개와 192KiB 조각 10개는 메모리 비용이 열 배 이상 다르다. 그래서 PonsWarp는 bufferedAmount와 target을 모두 바이트로 관리한다.
typescriptconst sendBudget = calculateSendBudget({ targetInFlightBytes: target, bufferedAmountBytes: getHighestBufferedAmount(), paused: hasPausedPeer, });
sendBudget가 0에 가까우면 생산자에게 다음 조각을 요청하지 않는다. 이 정책은 prepare-ahead 크레딧과 다르다. prepare-ahead는 워커 내부 큐의 상한이고, in-flight는 WebRTC 채널 안의 상한이다.
창이 움직이는 실제 순간
데이터가 네트워크를 통과해 bufferedAmount가 줄어들면 창의 앞쪽이 비워진다. 그때만 다음 조각을 넣는다. 이것이 슬라이딩 윈도우다.
text[전송 중 4MiB][전송 중 4MiB][빈 공간] ↑ 새 조각을 채울 자리
너무 자주 경계를 넘나들면 1바이트 단위의 thrash가 생긴다. PonsWarp는 의미 있는 budget이 생겼을 때만 재개하도록 해 작은 변동을 무시한다.
경로 진단과 보수적 선택
candidatePathKind, RTT, available outgoing bitrate를 WebRTC stats에서 읽고 profile을 선택한다. 여러 peer의 진단이 다르면 보수적인 값을 고르는 이유는 가장 느린 수신자가 전체 transfer의 안전성을 결정하기 때문이다.
이 구조에서 성능 튜닝은 하나의 숫자를 찾는 일이 아니라 chunkSize, targetInFlight, lowWater, partitionSize의 조합을 찾는 일이다.
다시 만든다면
처음부터 다음 값을 같은 구조체로 묶고, 측정값과 함께 기록하겠다.
- 목표 window
- 현재 buffered bytes
- RTT
- 예상 대역폭
- 마지막 drain 시각
- 다음 생산자 credit
그러면 “왜 지금 멈췄는가”를 로그 하나로 설명할 수 있다. 현재 구현도 profile 경계를 갖고 있지만, 성능 분석을 위해 이 상태를 하나의 진단 이벤트로 남기는 것이 다음 개선점이다.