LabHub
Get started
배우기 러닝패스 코스

Real-Time Communication — WebSocket, gRPC Streaming and WebRTC

Connect two WebRTC peers from signaling to data channel

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

WebSocket 시그널링 서버와 aiortc 피어 둘을 만들어 제안·응답을 주고받고, 신뢰·순서를 고른 데이터 채널로 메시지를 오가게 한 뒤, ICE 후보가 어디서 생기고 STUN 이 막히면 무엇이 느려지는지 잽니다.

왜 중요한가

WebRTC 는 브라우저와 휴대폰 사이에서 음성을 가장 짧은 지연으로 나르는 표준입니다. 그런데 연결을 맺는 방법은 명세가 정하지 않아서, 시그널링·ICE 서버 설정·후보 수집을 서비스가 직접 책임집니다. "회사 망에서만 연결이 안 된다", "연결까지 5초가 걸린다" 는 장애의 대부분이 이 부분에서 납니다. 같은 파드 안이라 NAT 는 없지만, 그 덕분에 후보와 STUN 의 역할을 군더더기 없이 볼 수 있습니다.

단계

  1. 시그널링 서버를 만든다 — /root/rt/webrtc/signaling.py 에 run(host, port) 코루틴을 만드세요. websockets 서버로 /room/<이름> 경로를 받아, 같은 방에 붙은 두 연결 사이에서 텍스트 메시지를 그대로 건네줍니다. 상대가 아직 없을 때 온 메시지는 모아 두었다가 상대가 붙는 순간 순서대로 넘깁니다. 세 번째로 붙는 연결은 닫기 코드 4001, 이유 "room full" 로 닫고, 한 쪽이 나가면 남은 쪽에 {"type": "bye"} 를 보냅니다.
  2. 제안 SDP 를 만든다 — /root/rt/webrtc/peer.py 에 make_offer() 코루틴을 만드세요. ICE 서버 목록을 비운 RTCConfiguration(iceServers=[]) 으로 RTCPeerConnection 을 만들고, "chat" 데이터 채널을 만든 뒤 createOffer 와 setLocalDescription 을 거쳐 localDescription.sdp 문자열을 돌려줍니다. 돌려주기 전에 연결은 close 합니다.
  3. SDP 를 읽는다 — /root/rt/webrtc/sdp.py 에 summarize_sdp(sdp) 를 만드세요. SDP 문자열을 읽어 dict 를 돌려줍니다. 키는 ufrag(첫 a=ice-ufrag 값), fingerprint(첫 a=fingerprint 의 해시 이름을 소문자로, 예: sha-256), setup(첫 a=setup 값), media(m= 줄의 매체 종류를 나온 순서대로 담은 list), candidates({"host": n, "srflx": n, "relay": n} — a=candidate 줄의 typ 별 개수, 없는 종류도 0 으로) 다섯 개입니다. 줄 끝은 \r\n 이나 \n 모두 옵니다.
  4. 응답하는 피어 — /root/rt/webrtc/peer.py 에 answer_peer(signal_url, room, out_path) 코루틴을 추가하세요. signal_url/room/ 에 붙어 {"type": "offer", "sdp": ...} 를 기다리고, 받으면 setRemoteDescription → createAnswer → setLocalDescription 뒤 {"type": "answer", "sdp": ...} 를 보냅니다. 상대가 연 데이터 채널의 메시지에는 앞에 "echo:" 를 붙여 돌려주고, 채널의 label·ordered·maxRetransmits 를 JSON 으로 out_path 에 적습니다. {"type": "bye"} 를 받으면 연결을 닫고 끝냅니다.
  5. 제안하는 피어 — /root/rt/webrtc/peer.py 에 offer_peer(signal_url, room, count) 코루틴을 추가하세요. "chat" 데이터 채널을 만들어 제안을 보내고, answer 를 받아 setRemoteDescription 한 뒤, 채널이 열리면 "m0" 부터 count 개를 하나씩 보내 echo 를 받을 때마다 왕복 시간을 잽니다. {"sent": count, "echoed": 받은 수, "rtt_ms": 왕복 밀리초 list} 를 돌려줍니다.
  6. 늦은 데이터는 버리는 채널 — offer_peer 의 데이터 채널을 ordered=False, maxRetransmits=0 으로 만드세요. 채점기의 기준 응답자가 받은 채널의 속성을 기록해 확인합니다.
  7. 후보가 어디서 오는지 본다 — /root/rt/webrtc/peer.py 에 gather(stun=None) 코루틴을 추가하세요. stun 이 주어지면 RTCIceServer(stun) 하나를, 아니면 빈 목록을 ICE 서버로 쓰고, 데이터 채널 하나를 만들어 createOffer·setLocalDescription 에 걸린 밀리초를 잽니다. {"elapsed_ms": 밀리초, "candidates": [(typ, 주소, 포트), ...]} 를 돌려줍니다. 채점기는 STUN 없이, 같은 파드 안의 STUN 서버로, 닿지 않는 STUN 주소로 세 번 부릅니다.

참고

시그널링 서버를 만든다

/root/rt/webrtc/signaling.py 에 run(host, port) 코루틴을 만드세요. websockets 서버로 /room/<이름> 경로를 받아, 같은 방에 붙은 두 연결 사이에서 텍스트 메시지를 그대로 건네줍니다. 상대가 아직 없을 때 온 메시지는 모아 두었다가 상대가 붙는 순간 순서대로 넘깁니다. 세 번째로 붙는 연결은 닫기 코드 4001, 이유 "room full" 로 닫고, 한 쪽이 나가면 남은 쪽에 {"type": "bye"} 를 보냅니다.

WebRTC 명세는 SDP 를 어떻게 건넬지 정하지 않습니다(JSEP, RFC 9429 도 그 부분을 애플리케이션에 맡깁니다). 그래서 거의 모든 서비스가 이런 작은 중계 서버를 따로 둡니다. 4000~4999 는 RFC 6455 가 애플리케이션 몫으로 남겨 둔 닫기 코드입니다.

제안 SDP 를 만든다

/root/rt/webrtc/peer.py 에 make_offer() 코루틴을 만드세요. ICE 서버 목록을 비운 RTCConfiguration(iceServers=[]) 으로 RTCPeerConnection 을 만들고, "chat" 데이터 채널을 만든 뒤 createOffer 와 setLocalDescription 을 거쳐 localDescription.sdp 문자열을 돌려줍니다. 돌려주기 전에 연결은 close 합니다.

SDP 에 m= 줄이 생기려면 제안 전에 무엇을 보낼지(데이터 채널이나 미디어 트랙) 정해 두어야 합니다. aiortc 는 ICE 서버를 지정하지 않으면 공개 STUN 서버를 쓰는데, 바깥 UDP 가 막힌 곳에서는 그 응답을 기다리느라 후보 수집이 몇 초 멈춥니다.

SDP 를 읽는다

/root/rt/webrtc/sdp.py 에 summarize_sdp(sdp) 를 만드세요. SDP 문자열을 읽어 dict 를 돌려줍니다. 키는 ufrag(첫 a=ice-ufrag 값), fingerprint(첫 a=fingerprint 의 해시 이름을 소문자로, 예: sha-256), setup(첫 a=setup 값), media(m= 줄의 매체 종류를 나온 순서대로 담은 list), candidates({"host": n, "srflx": n, "relay": n} — a=candidate 줄의 typ 별 개수, 없는 종류도 0 으로) 다섯 개입니다. 줄 끝은 \r\n 이나 \n 모두 옵니다.

host 는 자기 인터페이스 주소, srflx(server reflexive)는 STUN 서버가 본 바깥 주소, relay 는 TURN 서버가 빌려준 중계 주소입니다. fingerprint 는 DTLS 인증서의 해시이고, 시그널링으로 건너온 이 값과 핸드셰이크 때 받은 인증서가 맞아야 연결이 됩니다.

응답하는 피어

/root/rt/webrtc/peer.py 에 answer_peer(signal_url, room, out_path) 코루틴을 추가하세요. signal_url/room/ 에 붙어 {"type": "offer", "sdp": ...} 를 기다리고, 받으면 setRemoteDescription → createAnswer → setLocalDescription 뒤 {"type": "answer", "sdp": ...} 를 보냅니다. 상대가 연 데이터 채널의 메시지에는 앞에 "echo:" 를 붙여 돌려주고, 채널의 label·ordered·maxRetransmits 를 JSON 으로 out_path 에 적습니다. {"type": "bye"} 를 받으면 연결을 닫고 끝냅니다.

데이터 채널은 제안한 쪽이 만들고 응답하는 쪽은 datachannel 이벤트로 받습니다. 응답하는 피어에서도 ICE 서버 목록은 비워 두세요. 채점기의 기준 제안자가 여러분의 시그널링 서버를 거쳐 붙습니다.

제안하는 피어

/root/rt/webrtc/peer.py 에 offer_peer(signal_url, room, count) 코루틴을 추가하세요. "chat" 데이터 채널을 만들어 제안을 보내고, answer 를 받아 setRemoteDescription 한 뒤, 채널이 열리면 "m0" 부터 count 개를 하나씩 보내 echo 를 받을 때마다 왕복 시간을 잽니다. {"sent": count, "echoed": 받은 수, "rtt_ms": 왕복 밀리초 list} 를 돌려줍니다.

채널의 open 이벤트 전에 보내면 메시지가 사라집니다. ICE 확인 → DTLS 핸드셰이크 → SCTP 연결이 모두 끝나야 open 입니다. 여기까지 걸린 시간이 WebRTC 의 연결 준비 비용입니다.

늦은 데이터는 버리는 채널

offer_peer 의 데이터 채널을 ordered=False, maxRetransmits=0 으로 만드세요. 채점기의 기준 응답자가 받은 채널의 속성을 기록해 확인합니다.

음성 프레임이나 커서 위치처럼 20ms 뒤에는 쓸모없는 데이터는, 잃어버린 한 조각을 다시 보내느라 뒤의 것까지 붙잡는 것보다 버리는 편이 낫습니다. SCTP 는 채널마다 순서와 재전송을 따로 정할 수 있고, 이것이 TCP 위의 WebSocket 으로는 흉내 낼 수 없는 점입니다.

후보가 어디서 오는지 본다

/root/rt/webrtc/peer.py 에 gather(stun=None) 코루틴을 추가하세요. stun 이 주어지면 RTCIceServer(stun) 하나를, 아니면 빈 목록을 ICE 서버로 쓰고, 데이터 채널 하나를 만들어 createOffer·setLocalDescription 에 걸린 밀리초를 잽니다. {"elapsed_ms": 밀리초, "candidates": [(typ, 주소, 포트), ...]} 를 돌려줍니다. 채점기는 STUN 없이, 같은 파드 안의 STUN 서버로, 닿지 않는 STUN 주소로 세 번 부릅니다.

a=candidate 줄은 공백으로 나누면 다섯 번째가 주소, 여섯 번째가 포트, typ 다음이 종류입니다. 같은 파드 안에는 NAT 가 없으니 srflx 의 주소가 무엇과 같게 나올지 먼저 예상해 보세요.