[PonsLink] DataChannel 하나에 모든 메시지를 태우면 왜 위험할까
처음에는 DataChannel을 하나만 열어도 충분하다고 생각했다. 브라우저끼리 직접 붙고, 그 통로로 채팅도 보내고 파일 조각도 보내고 PonsCast 제어 메시지도 보내면 구조가 단순해 보였다. 방 안의 모든 실시간 메시지가 같은 길을 지나가니 디버깅도 쉬울 거라고 봤다. 그제야 제목으로 남긴 '[PonsLink DataChannel 하나에 모든 메시지를 태우면 왜 위험할까'이 기능 홍보가 아니라, 내가 붙잡고 있던 판단 기준에 가깝다는 걸 인정했다.

DataChannel 하나가 커진 문제
그래서 이 글의 결론은 “채널을 반드시 여러 개 열자”가 아니다. 먼저 메시지 정책을 분리하고, 그 정책이 같은 통로에서 버틸 수 없을 때 채널 분리나 broker fallback을 선택해야 한다. 설계 순서는 기능 이름이 아니라 품질 기준이어야 한다. | 흐름 | 예시 | 지켜야 할 품질 | 막혔을 때 정책 | | --- | --- | --- | --- | | 채팅/일반 상태 | text, gif, participant-profile, device-metadata | 즉시성, UI 반응성 | direct가 실패하면 signaling broker fallback을 쓸 수 있다. | | 파일 제어 JSON | file-meta, file-ack, file-cancel, request-missing-chunk | 완료성, 순서, 재전송 가능성 | direct file message로 분류해 일반 fallback과 다르게 본다.
| | 파일 binary packet | type byte 1, 2 | chunk와 end packet의 정확성 | 지연은 허용하지만 손실/누락은 request-missing-chunk로 다룬다. | | PonsCast binary | type byte 9 | 현재성, 낮은 지연, 오래된 frame 정리 | 오래된 frame을 끝까지 살리는 것보다 현재 재생을 지키는 쪽이 낫다. | | 화이트보드/CoWatch | whiteboard-operation, cowatch-control | 맥락 동기화 | 크기는 작아도 뒤로 밀리면 사용자는 방 전체가 늦다고 느낀다. | 처음에는 message type을 잘 정의하면 문제가 풀릴 줄 알았다. 그러나 DataChannel에서 더 먼저 봐야 할 것은 파싱 규칙보다 buffered amount였다.
큐가 이미 차 있는데 메시지 타입을 예쁘게 나눠봐야 늦게 도착하는 건 똑같다. 그래서 PonsCast 쪽은 high-water와 low-water를 두고, 막힌 상태가 풀릴 때까지 drain을 멈추는 식의 hysteresis를 쓴다. 이 구조가 완벽하다는 뜻은 아니다. 중요한 건 책임을 섞지 않으려는 방향이다. PonsCast처럼 오래된 프레임을 계속 쌓아두면 재생이 망가지는 흐름은 broadcaster 쪽에서 stale 교체와 frame drop을 허용한다. 파일 전송처럼 완료성이 중요한 흐름은 chunk, end packet, buffer status를 따로 관리한다.
채팅과 상태 메시지는 작지만 사용자가 바로 보는 신호라 fallback의 의미가 크다.
처음에는 하나면 충분할 줄 알았다
function shouldUseDirectDataChannel(message: Message) { const packetType = getBinaryPacketType(message); if (packetType === 1 || packetType === 2 || packetType === | 테스트 질문 | 통과 기준 | | --- | --- | | 큰 파일 전송 중 채팅을 보내면? | 채팅이 파일 chunk 뒤에서 무기한 밀리지 않아야 한다. | | direct 전송이 실패한 일반 상태 메시지는? | broker fallback으로 방 상태가 복구되어야 한다. | | file ack가 누락되면? | sender worker가 ack를 기다리거나 missing chunk 요청을 처리해야 한다. | | PonsCast가 막히면? | 오래된 frame을 모두 쌓지 않고 현재성을 지키는 방향이어야 한다. |
그래서 나는 이제 작은 메시지일수록 더 신중하게 본다. 작다는 이유로 뒤로 밀리면 사용자는 방 전체가 늦다고 느끼기 때문이다. 이 방식은 작은 데모에서는 설득력이 있다. 채팅 한 줄, 화이트보드 좌표 몇 개, 파일 시작 알림 정도는 같은 큐를 지나가도 티가 잘 나지 않는다. 메시지 타입만 붙이면 라우팅도 쉽다. type: chat, type: media-state, type: poncast-stream-meta처럼 나누면 애플리케이션 레벨에서는 충분히 정리된 것처럼 보인다. 제품 경계도 같이 선명해졌다. 서버는 파일을 들고 나르는 운반자가 아니다. API README에는 large room files를 R2로 offload하고, 브라우저는 제한된 grant를 받는다고 되어 있다. 그러면 DataChannel은 모든 것을 대신하는 마법 통로가 아니라, 방 안에서 직접성이 필요한 일부 흐름을 다루는 한 경로일 뿐이다.

분리 필요가 선명해진 순간
실제 저장소의 peer connection store는 바이너리 packet type을 읽어서 파일 chunk와 PonsCast binary event를 구분한다. type byte가 1 또는 2면 file chunk 흐름으로, 9면 PonsCast binary event로 보낸다. JSON 메시지도 direct file message type이면 직접 DataChannel 전송을 우선한다. 반대로 일반 메시지는 mesh direct 전송이 실패하면 signaling broker 경로로 보낼 수 있다. 이 구분은 화이트보드에도 그대 화이트보드 좌표는 파일 조각처럼 크지는 않지만, 늦게 도착하면 상대방이 보고 있는 맥락이 어긋난다. 채팅은 한두 줄 늦어도 다시 읽을 수 있지만, 화이트보드 커서는 지금 가리키는 위치가 중요하다.
그래서 “작은 메시지는 아무 데나 섞어도 된다”는 생각도 위험했다. 크기와 중요도는 같은 축이 아니었다. 기준을 바꾸고 나서 질문도 달라졌다. “어떤 메시지를 같은 DataChannel에 태울 수 있나?”가 아니라 “어떤 메시지가 다른 메시지를 막으면 안 되나?”를 먼저 물었다. 파일 조각, PonsCast 프레임, 채팅/상태 제어, 메타데이터는 실패 방식이 다르다. 그러면 최소한 애플리케이션 레벨에서라도 우선순위와 fallback, direct/broker 경로를 다르게 봐야 한다.
처음 구조는 단순한 쪽으로 끌렸다. WebRTC mesh를 만들고 peer마다 DataChannel이 생기면, 그 채널로 JSON 메시지도 보내고 바이너리도 보내면 된다. README에 적힌 PonsLink 방은 로비, 룸 셸, 디바이스 설정, 참가자 존재, 채팅, 화이트보드, 파일 공유, 파일 스트리밍까지 한 화면에 모여 있다. 그러니 한 통로가 모든 패널의 공용 배관처럼 보였다. 이 관점은 테스트를 짤 때도 도움이 된다. 단순히 sendToAllPeers가 호출됐는지 보는 테스트는 부족하다.
direct 전송이 실패했을 때 broker fallback을 타야 하는 메시지와, direct 경로 실패 자체를 결과로 받아들여야 하는 메시지를 나눠야 한다. bufferedAmount가 높을 때 frame drop이 늘어나는 것은 버그가 아니라 설계된 완충일 수 있다. 반대로 채팅이 큰 chunk 뒤에 묻힌다면 그건 사용자 경험 버그다. 같은 실패처럼 보여도 판정 기준이 달라진다. 예를 들어 파일 전송의 시작/종료 알림은 작지만 중요하고, 실제 chunk는 크고 압력을 만든다.
PonsCast의 metadata는 JSON 제어 메시지에 가깝지만, frame payload는 현재성을 가진 binary다. 화이트보드도 전체 snapshot과 cursor update가 같은 정책을 가질 필요는 없다. 기능 단위로만 묶으면 이런 차이를 놓친다. 메시지 정책 단위로 보면 DataChannel을 하나 쓰더라도 내부 규칙을 다르게 가져갈 수 있고, 필요하면 채널 분리까지 자연스럽게 이어진다.
채널 경계 나누기
여기서 얻은 교훈은 꽤 현실적이었다. DataChannel 설계는 “보낼 수 있나?”보다 “못 보낼 때 무엇을 늦추고 무엇을 버리고 무엇을 우회할 것인가?”에 가깝다. 이 질문이 없으면 작은 방에서는 잘 되던 구조가 기능이 늘어난 순간 답답해진다. 하지만 PonsLink의 실제 방향은 데모보다 넓었다. API 인프라 README는 BFF가 session token, feature grant, topology, media limit을 발급하고, 미디어 방은 browser WebRTC mesh를 쓴다고 설명한다. 브라우저에는 provider secret이나 admin key가 들어가지 않는다.
이 말은 프론트엔드가 단순히 예쁜 통신 UI가 아니라, 제한된 권한 안에서 여러 기능 흐름을 안전하게 엮는 위치에 있다는 뜻이다. 처음의 나는 DataChannel 하나가 구조를 단순하게 만든다고 생각했다. 지금은 조금 다르다. 하나의 채널은 코드를 단순하게 만들 수 있지만, 제품의 실패를 단순하게 만들지는 못한다. 오히려 실패 신호를 한곳에 섞어 원인을 흐리게 만든다.
또 하나는 장애 대응이다. 하나의 통로에 모든 메시지를 태우면 장애 로그도 한 덩어리로 보인다. bufferedAmount가 높다는 사실은 알 수 있지만, 그 압력이 파일 전송 때문인지, PonsCast 프레임 때문인지, 누군가의 네트워크가 느린 탓인지 바로 설명하기 어렵다. 반대로 흐름별로 책임을 나누면 같은 숫자도 해석이 쉬워진다. 어떤 기능이 방의 반응성을 가져갔는지, 어떤 기능을 낮춰야 하는지 판단할 수 있다.
책임 흐름이 바뀐 지점
이 글의 결론은 “DataChannel을 여러 개 열어라” 같은 단순한 규칙이 아니다. 더 중요한 건 하나의 통로에 모든 책임을 몰아넣지 않는 판단이다. 어떤 흐름은 직접성이 중요하고, 어떤 흐름은 순서와 완료가 중요하고, 어떤 흐름은 오래된 것을 버려야 현재성이 산다. 이 표가 들어가야 독자는 “DataChannel 병목”을 추상 문제가 아니라 실제 제품 정책으로 이해한다.
이 방식의 장점은 급하게 켜졌다 꺼지는 판단을 줄인다는 점이다. 512KB를 넘으면 막혔다고 보고, 256KB 이하로 내려와야 다시 보낸다. 중간값에서 계속 판단을 뒤집지 않는다. PonsCast에서는 이 기준 위에 stale frame 교체가 붙는다. 이미 늦은 프레임을 끝까지 줄 세우는 대신, 혼잡할 때는 최신 상태를 살리는 쪽으로 움직인다. 이때 서버 fallback을 무조건 좋은 것으로 보면 안 된다.
peer connection store는 일반 메시지에 대해 direct 전송이 실패하면 broker 경로를 사용할 수 있지만, direct file message나 PonsCast binary처럼 직접성이 중요한 흐름은 실패를 다르게 다룬다. 서버가 메시지를 대신 운반할 수 있다는 사실과, 서버가 그 기능의 품질 책임을 모두 가져야 한다는 말은 다르다. PonsLink의 BFF는 권한과 제어면이지, 모든 방 데이터를 대신 들고 있는 창고가 아니었다. 파일 전송은 조금 다르다.
chunk-ready 이벤트를 받았을 때 buffered amount가 크면 100ms 뒤로 미룬다. 마지막 chunk 뒤에는 end packet을 따로 보낸다. 이건 재생 흐름처럼 버릴 수 있는 데이터가 아니라, 완료 신호가 중요하기 때문이다. 같은 DataChannel 압력이라도 데이터의 성격에 따라 답이 달라진다. '[PonsLink DataChannel 하나에 모든 메시지를 태우면 왜 위험할까'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다. 
실패 신호는 “파일 전송이 안 된다”처럼 깨끗하게 오지 않는다. 더 애매한 방식으로 온다. 큰 파일 조각을 보내는 중에 채팅 반응이 둔해지고, PonsCast 상태 알림이 늦게 도착하고, 상대방 화면에서는 이미 끝난 동작이 아직 진행 중처럼 보인다. 사용자는 이걸 DataChannel 병목이라고 부르지 않는다. 그냥 방이 답답하다고 느낀다. 내가 처음 놓친 건 DataChannel이 통로라는 사실보다, 그 통로 위에 서로 다른 책임을 섞으면 제품의 실패 신호도 섞인다는 점이었다.
PonsLink는 채팅, 화이트보드, 파일 전송, 파일 스트리밍, CoWatch, PonsCast가 한 방에 붙어 있는 제품이다. 그래서 “하나로 보내면 편하다”는 개발자 기준은 금방 “어떤 메시지가 누구를 막았는지 모른다”는 운영 문제로 바뀌었다. | 내부 관찰값 | 제품 해석 | 사용자 문장 | | --- | --- | --- | | bufferedAmount가 512KB를 넘음 | 지금 더 밀어 넣으면 다른 메시지까지 늦어진다 | “파일/PonsCast 전송 속도를 잠시 낮춰 방 반응을 지키는 중” | | file chunk 전송 후 end packet 대기 | 마지막 조각과 완료 신호를 분리해야 한다 | “파일을 닫고 완료를 확인하는 중” | | request-missing-chunk 발생 | receiver가 누락을 감지했다 | “빠진 조각을 다시 요청하는 중” | | PonsCast frame drop 증가 | 오래된 화면보다 현재 화면을 살린다 | “오래된 프레임을 건너뛰고 현재 재생을 맞추는 중” | ```ts const directFileMessageTypes = new Set([ 'file-meta', 'file-ack', 'file-cancel', 'file-receiver-complete', 'request-missing-chunk',); 코드에서도 이 긴장이 보인다.
PonsCast 전송 쪽은 WebRTC manager의 getMaxBufferedAmount()를 보고 512KB high-water, 256KB low-water 같은 경계로 drain을 멈출지 판단한다. 파일 전송 worker 쪽도 buffered amount가 512KB를 넘으면 chunk-ready 전송을 늦춘다. 이 값들이 의미하는 건 단순하다. 브라우저가 받을 수 있는 속도보다 우리가 더 빨리 밀어 넣으면, DataChannel은 친절하게 모든 메시지를 공평하게 살려주지 않는다. 이 차이는 제품 신뢰와도 연결된다. PonsLink는 작은 그룹의 상담, 코칭, 문서 리뷰, 오디오 커뮤니티 같은 쓰임을 염두에 둔 방이다.
이런 방에서 통신 품질은 단순 속도 경쟁이 아니다. 사용자가 지금 무슨 일이 일어나는지 이해할 수 있어야 한다. DataChannel이 막히는 순간에도 제품은 침묵하면 안 된다.
문제는 공평함이 제품 기준과 다르다는 점이다. 파일 조각은 순서대로 도착해야 하고, 채팅은 작지만 즉시성이 중요하고, PonsCast 프레임은 오래된 것을 끝까지 살리는 것보다 최신 흐름을 유지하는 게 나을 때가 있다. 모두 같은 큐에 있으면 “중요도”가 아니라 “먼저 들어간 순서”가 사용자 경험을 결정한다. 이 분기는 “어떤 메시지가 DataChannel을 쓰는가”를 넘어서 “어떤 메시지는 broker로 우회하면 의미가 바뀌는가”를 묻는다.
파일 chunk와 PonsCast frame은 단순 알림이 아니라 실시간 방의 무거운 payload다. 반대로 작은 상태 메시지는 직접 경로가 실패했을 때 broker를 타는 편이 사용자의 반응성을 지킬 수 있다. 결국 DataChannel 설계에서 내가 배운 건 통로의 수보다 통로의 예절이었다. 빠르게 보내는 것보다 서로를 막지 않게 보내는 것이 더 중요했다. 실시간 방의 품질은 대역폭이 넉넉할 때보다, 부족할 때 어떤 메시지를 지켜내는지에서 더 분명하게 드러난다.
PonsLink를 다시 본다면 나는 먼저 메시지의 책임을 나눈다. 파일 조각은 완료성과 backpressure, PonsCast 프레임은 현재성과 jitter, 채팅과 상태 메시지는 즉시성과 fallback을 기준으로 본다. 그다음에야 같은 경로를 공유할지, 별도 흐름으로 나눌지 판단한다.
마치며
이 글에서 확인한 경계는 다음 변경의 기준으로 남겼다.