[PonsWarp 개념 교실 14] WebRTC DataChannel — SCTP 신뢰성과 bufferedAmount
쉽게 말하면, WebRTC DataChannel은 브라우저 사이에 데이터를 보내는 통로이고, SCTP는 그 통로에 순서·재전송·신뢰성을 붙인다. 하지만 통로가 믿을 만하다고 해서 무한히 밀어 넣을 수 있는 것은 아니다.

PonsWarp는 control 채널과 bulk 채널을 분리하고, bufferedAmount를 보면서 보내는 속도를 조절한다. 이번 글에서는 그 계층을 나눠 본다.
이게 왜 필요한가 — 연결 성공과 전송 성공은 다르다
WebRTC의 ICE 연결과 DataChannel open 이벤트가 성공하면 "연결됐다"고 생각하기 쉽다. 하지만 대용량 파일에서는 곧 다른 문제가 나온다.
- 제어 메시지와 파일 바이트가 같은 큐에서 기다리면 제어가 늦어진다.
- 보낼 데이터를 계속 넣으면 브라우저 내부 버퍼가 커진다.
- 수신측 디스크가 느린데 송신측이 계속 보내면 메모리가 쌓인다.
- 네트워크가 끊기거나 ICE가 재협상될 때 채널 오류가 발생한다.
그래서 PonsWarp는 DataChannel을 하나의 통로로 보지 않고 **제어 평면(control plane)과 데이터 평면(bulk plane)**으로 분리했다.
DataChannel 아래의 SCTP
DataChannel의 전송 계층은 SCTP(Stream Control Transmission Protocol)다. SCTP가 제공하는 기본 기능은 다음과 같다.
- ordered: 보낸 순서대로 전달
- reliable: 손실 시 재전송
- message-oriented: 바이트 스트림이 아니라 메시지 단위
PonsWarp 메시지
↓
RTCDataChannel
↓
SCTP (순서·재전송·체크섬)
↓
DTLS (암호화된 전송 채널)
↓
ICE/UDP/TURN
여기서 주의할 점은 SCTP가 "파일 전송 프로토콜"은 아니라는 것이다. SCTP는 메시지를 안정적으로 전달할 뿐이고, 파일의 offset·resume·저장·암호화는 애플리케이션이 직접 설계해야 한다.
control과 bulk를 왜 나누나
PonsWarp의 PeerSession은 두 종류의 채널을 만든다.
typescript// PonsWarp/src/services/peerSession.ts const control = this.pc.createDataChannel('control', { ordered: true, }); const bulk = this.pc.createDataChannel('bulk-0', { ordered: true, }); bulk.bufferedAmountLowThreshold = this.lowWater;
- control: JSON 제어 메시지 — PAUSE, RESUME, PARTITION_ACK, CRYPTO_SESSION, MANIFEST
- bulk: 암호화된 파일 조각 — ArrayBuffer 바이너리
control은 항상 ordered/reliable을 강제한다. bulk가 아무리 많이 쌓여도 control 메시지는 별도 통로로 빠르게 전달되어야 한다.
control ── JSON ──→ 키 교환 / 일시정지 / 재개 / ACK
bulk-0 ── binary ─→ 암호화된 파일 조각
ordered인데 왜 재정렬 버퍼가 필요한가
"ordered 채널이면 순서가 보장되는데 재정렬 버퍼가 왜 필요하지?"라는 질문이 생긴다.
ordered는 하나의 SCTP 스트림 안에서 순서를 보장한다. 하지만 PonsWarp에는 재개, 재전송, 폴백, 복수 경로 같은 애플리케이션 상태가 있다. 수신측에 조각이 들어오는 순간의 전체 순서를 애플리케이션이 다시 확인해야 한다.
그래서 DataChannel은 전송 순서를 보장하고, 재정렬 버퍼는 파일 offset 기준의 애플리케이션 순서를 보장한다. 계층이 겹쳐 보이지만 책임이 다르다.
bufferedAmount — 보내지 말아야 할 때를 아는 값
RTCDataChannel.bufferedAmount는 아직 브라우저 내부에서 전송되지 않은 바이트 수다.
bufferedAmount = 0 → 더 보내도 됨
bufferedAmount = 5MiB → 큐에 쌓이는 중
bufferedAmount = 14MiB → 송신 중지/대기
PonsWarp는 이 값을 기준으로 송신 예산을 계산한다. 수신측의 writer가 느리면 control을 통해 pause하고, 채널 버퍼가 low watermark로 내려오면 다시 보낸다.
typescript// PeerSession bulk.bufferedAmountLowThreshold = this.lowWater;
bufferedamountlow 이벤트와 bufferedAmount 비교를 통해 "지금 보내도 되는가"를 판단한다. 이 값 하나가 이후 prepare-ahead, in-flight window, backpressure 설계의 입력이 된다.
bulk 채널 수와 현재 기본값
코드는 bulk 채널을 최대 4개까지 설정할 수 있다.
typescriptthis.bulkChannelCount = Math.max( 1, Math.min(4, config.bulkChannelCount ?? 1) );
하지만 현재 기본 경로는 bulk-0 하나를 사용한다. 멀티 채널 striping은 구현 확장점이지만, 여러 채널을 쓰면 순서·공정성·버퍼 합산·재전송 책임이 복잡해진다. 속도만 보고 채널 수를 늘리면 안 되는 이유다.
오류 처리 — 채널 오류가 곧 피어 실패는 아니다
DataChannel의 error 이벤트는 ICE teardown이나 renegotiation 중에도 발생할 수 있다. PonsWarp는 모든 channel error를 즉시 치명 오류로 만들지 않는다.
typescript// PonsWarp/src/services/peerSession.ts channel.onerror = () => { const pcState = this.pc?.connectionState; if (pcState === 'failed' || pcState === 'closed') { this.emit('error', new Error('Control channel error')); } };
채널에서 오류가 나도 RTCPeerConnection이 아직 살아 있으면 복구 가능성을 남긴다. 피어 전체가 failed/closed일 때만 상위에 치명 오류를 알린다.
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| control 채널 | ordered JSON 제어 평면 | peerSession.ts:186-203 |
| bulk 채널 | ordered binary 데이터 평면 | peerSession.ts:199-207 |
| bulk 채널 수 | 기본 1, 최대 4 | peerSession.ts:98-101 |
| binary 타입 | arraybuffer | peerSession.ts:214 |
| 버퍼 수위 | bufferedAmountLowThreshold 설정 | peerSession.ts:206,259 |
| 버퍼 관찰 | bufferedAmount 기반 채널 선택/상태 추적 | peerSession.ts:456,682-692 |
| 오류 승격 | PC가 failed/closed일 때만 치명 오류 | peerSession.ts:230-244 |
| 상위 책임 | offset, resume, 암호, 저장은 앱 계층 | plainPacket.ts, directFileWriter.ts, webRTCService.ts |
지금 돌아보는 판단
세 가지를 정리한다.
첫째, DataChannel은 파일 전송의 일부일 뿐이다. SCTP가 순서와 재전송을 제공해도 파일 패킷 포맷, offset, 암호화, 디스크 저장, 재개는 별도 책임이다.
둘째, control과 bulk 분리는 단순한 최적화가 아니다. 제어 메시지가 파일 데이터 뒤에 갇히면 pause/resume/ACK가 늦어진다. 대용량 전송에서는 제어 지연이 곧 메모리 증가와 복구 실패로 이어진다.
셋째, 여러 채널은 마지막에 추가해야 한다. 기본 1채널로 안정성을 확보한 뒤 striping을 실험하는 것이 맞다. 채널을 늘리면 속도는 올라갈 수 있지만 순서와 버퍼 책임이 같이 복잡해진다.
한 문장 정리
PonsWarp는 SCTP 위의 DataChannel을 control JSON과 bulk binary로 분리하고, ordered/reliable 전달은 하위 계층에 맡기며, bufferedAmount·offset·resume·저장은 애플리케이션 계층에서 직접 제어한다.
관련 개념: [개념 7 — 패킷 헤더] · [개념 8 — 청크 크기] · [개념 9 — 재정렬 버퍼]
다음 글: [개념 15 — prepare-ahead 크레딧: 생산자-소비자 큐]