P2P file transfer (with WebRTC, RxJS)
왜 P2P로 파일을 전송하는가?
HTTP라는 아주 훌륭한 프로토콜이 있음에도 불구하고 우리는 왜 WebRTC DataChannel을 이용하여 사용자의 브라우저끼리 직접 파일을 전송할 수 있도록 했을까? 이유는 아래와 같은 P2P 통신이 갖는 이점을 취하고 싶어서였다. 더군다나 PageCall은 이미 사용자간의 모든 통신을 WebSocket이나 HTTP가 아닌 WebRTC를 통해 처리하고 있었으므로 파일 또한 P2P로 주고 받을 수 있어야 완전한 커뮤니케이션의 분산화가 가능했다.
- 보안
파일정보가 서버에 잠깐 동안이라도 저장되지 않는다. 법률, 금융 업계에서 PageCall이 활용될 때 고객의 민감한 정보가 담긴 파일이 당사자들을 제외한 어떤 물리적 장치에도 업로드되지 않게 할 수 있다. - 속도
파일서버를 사용한다면 A에서 B로 파일을 전송할 때, A가 먼저 파일 서버에 파일을 업로드한 뒤, B가 그 파일을 다운로드 받게 된다. 하지만 P2P로 직접 전송할 수 있게 되면 A의 업로드가 곧 B의 다운로드가 된다. 여기 더해서 만약 두 당사자가 논리적, 물리적으로 가까운 네트워크(ex. 동일한 건물)에 위치한다면 데이터가 더 효율적인 경로로 이동할 수 있게 된다. - 비용
별도의 파일 서버를 관리하는데 들어가는 물리적, 인적 비용을 고려하지 않아도 된다.
사전 지식 1 — WebRTC
WebRTC(Web Real-Time Communication)는 browser, native mobile application에서 API를 통해 P2P로 실시간 통신을 가능하게 하는 오픈소스 프로젝트이다. 이 글에서는 브라우저에서의 WebRTC를 다루고, 그 중에서도 P2P로 데이터 통신을 가능하게 하는 DataChannel에 중점을 두고 설명한다.
Chrome, Firefox 등의 브라우저에는 RTCPeerConnection
이라는 클래스가 글로벌하게 노출되어있는데 RTCPeerConnection
의 인스턴스를 통해 P2P로 영상/음성 미디어 스트림과 임의의 데이터를 브라우저에서 브라우저로 직접 통신할 수 있다. 이 때 데이터 통신은 RTCDataChannel
이라는 인터페이스를 이용하여 이뤄지며 이 글에서는 RTCDataChannel
의 특성을 설명하고 이것을 이용한 파일 전송을 다룬다.
RTCDataChannel
의 사용법은 매우 간단하다. 송신자측에서 RTCDataChannel.send()
로 패킷을 보내면 수신자측에서 RTCDataChannel.onmessage
이벤트 핸들러를 통해 패킷을 받을 수 있도록 쉽게 설계되어있다.
이 글에서 생략된 부분은 P2P 연결 초반에 필요한 Signaling단계와 미디어 스트림의 통신이다. WebRTC와 관련된 더 자세한 내용은 아래 홈페이지에서 학습할 수 있다. https://webrtc.org/
사전 지식 2 — RxJS
RxJS는 Reactive Extensions 의 JS 버전으로 Observable 패턴을 활용해 비동기처리를 쉽게 구현할 수 있게 도와주는 라이브러리이다. 이 글에서는 valve$를 구현하기 위해 BehaviorSubject
를 활용했다. RxJS를 이용하여 파이프로 흘러가는 비동기적인 데이터들을 직관적으로 처리할 수 있었다.
RxJS에 대한 더 자세한 내용은 아래 공식 홈페이지에서 확인할 수 있다.
https://rxjs-dev.firebaseapp.com/
RTCDataChannel의 특성
- packet 크기 제한
RTCDataChannel.send()
로 보낼 수 있는 데이터의 크기에 제한이 있다. 브라우저 마다 다를 수 있겠지만 64kb 이상의 패킷을 보낼 경우 데이터가 손실될 위험이 있다. - buffer 크기 제한
RTCDataChannel.send()
로 시간당 보낼 수 있는 데이터의 크기에 제한이 있다. 너무 많은 데이터를 한꺼번에 보내면 buffer가 가득차서 데이터가 유실될 수 있다.RTCDataChannel.bufferedAmount
를 통해 buffer 사용량을 알 수 있으며,RTCDataChannel.onbufferedamountlow
를 통해 buffer 수위가 특정값 이하로 낮아졌음을 알 수 있다.
DrainablePacket 구현
파일과 그 메타데이터를 넘겨주면 적절한 크기의 Chunk로 쪼개어 적당한 속도로 방출하는 DrainablePacket
을 구현하였다. DrainablePacket
의 valve$
는 DrainablePacket
을 사용하는 부분에서 buffer의 상태에 따라 조절하게 된다.
아래 DataChannel.ts
에서 DrainablePacket
을 어떤 식으로 생성하고 valve$
를 조절하는지 알 수 있다. packet을 보낼 수 있을 때 밸브를 열고, 보낼 수 없을 때는 밸브를 닫아 packet이 더 이상 나오지 않게 통제한다.
RTCDataChannel
의 buffer가 항상 일정한 수준으로 유지되도록 밸브를 조절하는 방식이 꽤나 재밌다.
아래는 실제로 우리제품에 적용한 상수들이다. DrainSpeed
는 buffer의 수위가 적정수준으로 유지될 수 있게 하는 Chunk 생성 속도이며 한번에 15개의 Chunk들이 만들어지도록 세팅되어있다.
DrainablePacket의 작동 방식
DrainablePacket
의 가장 큰 특징은 외부에서 조절되는 밸브로 흐름이 통제된다는 것이다. 밸브는 주로 RTCDataChannel
의 buffer 수위에 따라 조절되는데 buffer의 수위가 BufferMax
보다 높아지면 밸브가 잠기고 BufferMin
보다 낮아지면 다시 열리게 되어있다.
또한 File의 일부분을 Chunk로 만드는 작업을 DrainablePacket
이 초기화 될 때 한꺼번에 다 처리하는 것이 아니라 필요할 때마다 즉시 Chunk를 만들어서 내보내준다. 만약 초기에 모든 Chunk들을 한번에 준비해두면 Javascript의 heap용량을 많이 차지해서 1GB 이상의 대용량 파일을 처리할 때 heap용량이 부족해지는 사태가 벌어질 수 있다.
P2P 파일 전송 사용 후기
현재 구동되고 있는 PageCall 제품에서 이러한 P2P 파일 전송 기능이 사용되고 있다. 기대했던 대로 월등한 속도의 향상이 있었다. 기존에는 사용자가 상대방에게 파일을 보내주고 싶을 때 다음과 같은 차례대로 파일이 전송되었다.
- 원하는 파일 AWS S3에 업로드
- 업로드 완료되면
RTCDataChannel
을 통해서 상대방에게 파일 URL이 담긴 시그널을 전송 - 시그널을 받은 상대방은 S3에서 해당 파일 다운로드
P2P로 파일전송을 할때는 이 모든 단계가 하나로 줄었다.
- 원하는 파일 상대방에게 전송. 끝.
사실 실제 제품에 이 아이디어를 적용하기 위해서 글에 나와있는 것보다 훨씬 귀찮고 까다로운 작업들이 필요했다. 예를 들어 파일로부터 적절한 Chunk들을 생성하기, 수신자측에서 Chunk들을 바르게 조립하기, 그리고 이러한 P2P 파일 전송 프로토콜을 한 단계감싸서 마치 서버에 HTTP요청을 보내듯 상대방에게 파일을 요청할 수 있는 상위 레벨 프로토콜을 구현하는 것 까지 굉장히 많은 작업이 필요했다.
하지만 우리의 이러한 작업들이 다 쓸모없는 일이 되어도 좋으니 언젠가 WebRTC의 표준으로 대용량 파일전송 API가 들어가면 좋겠다. 그렇게 된다면 많은 웹개발자들이 행복해질 것이다.