개발 회고

[PonsWarp] - backpressure를 구현하면서 배운 것

AC 2026. 6. 24. 00:22

PonsWarp가 backpressure를 구현하면서 배운 것은 “빠르게 보내는 법”보다 “상대가 감당하지 못할 때 멈추는 법”에 가까웠습니다.

파일 전송 제품에서 신뢰는 예쁜 진행률 막대에서 나오지 않습니다. 진행률이 거짓말하지 않고, 브라우저가 버티고, 받는 쪽 저장이 끝날 때까지 상태를 정확히 붙잡는 데서 나옵니다. 2025년 12월 3일의 54cf5f409161ce는 sender가 더 영리하게 밀어 넣는 단계에서, receiver와 브라우저 저장 계층이 실제로 받아낼 수 있는 속도까지 제품이 책임지는 단계로 넘어간 기록입니다.

PonsWarp receiver backpressure watermark loop
receiver write speed가 밀리면 high watermark에서 pause가 걸리고, low watermark 아래에서 resume이 돌아옵니다.

🧯 빠른 전송은 빠른 메모리 누적으로 바뀔 수 있었습니다

💡 Backpressure는 성능 옵션이 아니라 제품 신뢰 옵션이었습니다. 받는 쪽이 못 써내는 순간 sender도 늦춰야 하고, 그 신호가 UI보다 먼저 정확해야 했습니다.

post-10의 Push/AIMD 흐름은 sender를 덜 굶기기 위한 시도였습니다. 6e635f3에서 sendLoop, pendingChunks, network-congestion 같은 구조가 등장했고, 이후 현재 코드의 networkAdaptiveController.ts는 RTT와 bufferedAmountcwnd를 조절합니다. 이 방향 자체는 필요했습니다. 빠른 링크에서 sender가 계속 쉬면 사용자는 연결이 느리다고 느끼기 때문입니다.

하지만 sender를 쉬지 않게 만드는 일은 곧 다른 질문을 부릅니다. receiver가 디스크나 브라우저 저장 경로에 충분히 빨리 써내지 못하면 어떻게 할 것인가. DataChannel이 받아 준 것과 파일이 안전하게 저장된 것은 같은 사건이 아닙니다. 이 차이를 무시하면 속도 최적화는 곧 메모리 압력과 완료 오판으로 돌아옵니다.

현재 PonsWarp/src/utils/constants.ts의 보수적인 값은 이 학습의 흔적처럼 읽힙니다. MAX_BUFFERED_AMOUNT는 4MB로 제한되고, BATCH_SIZE_MAX는 1로 묶여 있습니다. 주석은 DataChannel bufferedAmount는 sender 로컬 큐일 뿐 receiver 저장 완료를 의미하지 않는다고 못 박습니다. 이 문장 하나만으로도 backpressure가 왜 단순 튜닝이 아니었는지 설명됩니다.

🔁 high watermark와 low watermark는 제품의 브레이크였습니다

멈추지 않고 계속 보내는 것이 언제나 좋은 경험은 아닙니다. Backpressure에서 pause는 제품이 포기했다는 뜻이 아니라, 거짓말하지 않기 위해 잠깐 속도를 접었다는 뜻입니다.

DirectFileWriter 계층은 저장을 단순 출력으로 보지 않습니다. 기존 글에서 확인한 현재 코드 기준으로 pendingBytesInBuffer를 추적하고, 32MB 이상 쌓이면 PAUSE, 16MB 이하로 내려가면 RESUME하는 high/low watermark를 둡니다. 8MB 배치 쓰기 기준도 함께 등장합니다. 저장 계층이 느려질 수 있다는 현실을 숫자로 받아들인 설계입니다.

반대로 resume은 단순 재시작 버튼이 아닙니다. receiver 쪽 pending bytes가 충분히 낮아졌고 writer가 다시 따라올 수 있다는 신호입니다. high watermark와 low watermark 사이의 간격은 흔들림을 줄이는 완충 구간입니다. 너무 자주 pause/resume이 오가면 진행률은 더 불안해지고, 너무 늦게 pause하면 메모리가 먼저 무너집니다.

DataChannel bufferedAmount is not receiver storage completion
bufferedAmount는 sender 로컬 큐의 신호일 뿐, receiver 저장 완료의 증거가 아닙니다.

📦 bufferedAmount만 믿으면 경계를 잘못 봅니다

WebRTC의 bufferedAmount는 중요한 신호입니다. sender 로컬 큐가 얼마나 쌓였는지 보여 주고, 너무 커지면 network congestion이나 window 축소의 근거가 됩니다. 6e635f3webRTCService.tsbufferedAmount를 보고 worker에 network-congestion을 되돌려 주던 흐름, 현재 networkAdaptiveController.tsbufferedAmount > cwnd를 혼잡으로 보는 흐름은 모두 타당합니다.

문제는 그 값이 전체 전송의 진실이 아니라는 데 있습니다. bufferedAmount가 낮아졌다는 것은 sender의 로컬 큐가 비었다는 뜻이지, receiver가 파일 시스템에 안전하게 써냈다는 뜻이 아닙니다. receiver worker의 decode와 verify, main thread로 넘어가는 write-chunk, DirectFileWriter의 batch write, 브라우저별 StreamSaver/File System Access/Blob/OPFS fallback까지 지나야 실제 저장에 가까워집니다.

sender는 자기 큐만 보면 너무 낙관적이고, receiver는 자기 저장 큐만 보면 sender가 왜 계속 밀어 넣는지 알 수 없습니다. pause/resume 신호가 있어야 두 계층이 같은 현실을 공유합니다.

🧱 receiver write speed가 사용자 신뢰를 결정했습니다

대용량 파일 전송에서 사용자는 내부 큐를 보지 않습니다. 사용자는 “왜 40%에서 멈춘 것처럼 보이지?”, “왜 완료라고 했는데 저장이 늦지?”, “왜 브라우저가 갑자기 버벅이지?” 같은 증상만 봅니다. 이 증상들은 UI를 다듬는다고 사라지지 않습니다. writer가 실제로 처리하는 속도와 receiver buffer가 쌓이는 속도가 맞지 않으면, 화면은 결국 거짓말하거나 버벅임을 숨기게 됩니다.

PonsWarp의 저장 계층은 이 문제를 피하려고 점점 더 많은 책임을 떠안았습니다. directFileWriter.ts는 batch write와 pending byte 추적을 두고, receiver worker는 packet verify 뒤 main thread write로 넘기며, completion semantics는 save-complete handshake와 partial recovery 쪽으로 확장됩니다. 이 흐름은 21dc9ba의 저장 완료 대기, 54cf5f4의 receiver-side backpressure, 나중의 b185563까지 이어집니다.

중요한 것은 이 기능들이 따로 떨어져 있지 않다는 점입니다. 저장 완료 handshake는 끝났다는 말을 늦추는 기능이고, backpressure는 너무 빨리 밀어 넣지 않게 하는 기능이며, partial recovery는 끊겼을 때 실패 상태를 회복 가능한 계약으로 바꾸는 기능입니다. 셋 다 제품 신뢰를 지키기 위해 시간의 경계를 다시 정의합니다.

PonsWarp trust control stack from UI to storage
Backpressure는 UI 아래의 전송·저장·복구 계층이 같은 완료 의미를 갖도록 맞추는 제어였습니다.

🎛️ UI보다 제어 루프가 먼저였습니다

이 단계에서 더 화려한 UI를 붙이는 것은 쉬운 유혹이었을 겁니다. 속도 숫자를 더 예쁘게 보여 주고, progress bar 애니메이션을 부드럽게 만들고, 상태 문구를 세분화할 수 있습니다. 하지만 backpressure가 없는 상태에서 그런 UI는 문제를 해결하지 못합니다. 오히려 내부 상태가 흔들릴수록 더 그럴듯한 거짓말이 됩니다.

PonsWarp에서 필요한 것은 “사용자가 덜 불안해 보이게 하는 화면”보다 “실제로 덜 불안한 전송 엔진”이었습니다. bufferedAmount가 커지면 sender를 늦추고, receiver buffer가 high watermark를 넘으면 pause를 걸고, low watermark 아래로 내려오면 resume하며, 저장 완료가 확인될 때까지 완료라고 말하지 않는 구조가 먼저였습니다.

이 선택은 제품의 말투도 바꿉니다. “빠릅니다”보다 “감당 가능한 만큼만 보냅니다”가 더 정직합니다. “전송이 끝났습니다”보다 “저장까지 확인했습니다”가 더 어렵지만 더 믿을 만합니다. backpressure는 바로 그 정직한 문장을 가능하게 하는 내부 제어였습니다.

🔍 이 글에서 보수적으로 다룬 부분

이 글은 공개된 회고 시리즈의 증거 인덱스와 완료된 post-07, post-08, post-10의 코드 앵커를 기준으로 작성했습니다. 54cf5f409161ce는 evidence index에 있는 커밋 제목과 날짜를 근거로 삼았고, 세부 구현 설명은 이미 시리즈에서 확인된 현재 코드 앵커인 directFileWriter.ts, networkAdaptiveController.ts, swarmManager.ts, constants.ts의 역할을 기준으로 제한했습니다.

확인 가능한 범위에서 말할 수 있는 것은 분명합니다. backpressure는 sender가 빠르게 보내는 문제의 후속 튜닝이 아니라, receiver 저장 속도와 브라우저 메모리 압력을 제품 신뢰로 연결한 제어 장치였습니다.

📚 읽은 코드
PonsWarp commit 54cf5f4 — receiver-side backpressure가 명시적으로 등장한 지점
PonsWarp commit 09161ce — RTT와 전송 제어가 같은 시기 다시 정리된 지점
PonsWarp commit 6e635f3 — Push 방식과 AIMD 혼잡 제어 도입으로 sender 제어 문제가 먼저 드러난 지점
PonsWarp/src/services/directFileWriter.ts:46-52 — receiver 저장 계층의 high/low watermark 정의
PonsWarp/src/services/directFileWriter.ts:96-104 — batch write와 pendingBytesInBuffer 추적 지점
PonsWarp/src/services/networkAdaptiveController.ts:38-215 — RTT와 bufferedAmount 기반 cwnd 조절
PonsWarp/src/utils/constants.ts:18-29 — sender local queue와 receiver 저장 완료의 차이를 남긴 보수화 지점
PonsWarp/src/workers/file-receiver.worker.ts — packet decode/verify 이후 저장 계층으로 넘기는 receiver worker 책임
LIST