Delphi WebRTC: 두 애플리케이션 사이의 오디오와 비디오, 데이터
서로 다른 두 네트워크에 있는 두 Delphi 애플리케이션이 채팅 채널과 마이크 스트림, 카메라 스트림을 직접 주고받아요. 중간에 미디어 서버도 없고, 프로세스 안에 브라우저를 심지도 않고, JavaScript 브리지도 없어요. 이 페이지는 첫 시그널링 메시지부터 첫 디코딩된 오디오 프레임까지 작업 전체를 따라가요. 실제 배포되는 소스에 있는 API만 써서요.
서로 다른 두 네트워크에 있는 두 Delphi 애플리케이션이 채팅 채널과 마이크 스트림, 카메라 스트림을 직접 주고받아요. 중간에 미디어 서버도 없고, 프로세스 안에 브라우저를 심지도 않고, JavaScript 브리지도 없어요. 이 페이지는 첫 시그널링 메시지부터 첫 디코딩된 오디오 프레임까지 작업 전체를 따라가요. 실제 배포되는 소스에 있는 API만 써서요.
WebRTC는 하나의 이름을 쓰고 있는 네 가지 별개의 문제예요. 그중 미디어에 관한 건 하나뿐이고, 그게 제일 쉬워요. 풀어야 하는 순서대로 네 가지를 소개할게요.
한쪽이 offer를 만들어요. 어떤 미디어를 보내려 하는지, 어떤 코덱을 쓰는지, 제시할 인증서의 지문은 무엇인지, 어떤 ICE 자격 증명을 쓸지 적은 텍스트 문서(SDP)예요. 다른 쪽은 그중 받아들일 부분만 담아 answer로 답해요. CreateOffer와 CreateAnswer가 그 문서를 만들고, SetRemoteDescription이 그것을 받아들여요.
WebRTC는 offer가 상대 피어에 어떻게 닿는지를 일부러 정하지 않아요. 그 통로를 시그널링이라고 하고, 그건 여러분의 몫이에요. 양방향으로 수백 바이트짜리 텍스트면 되니까 작은 릴레이로 가는 WebSocket 연결이면 충분하고, sgcWebSockets가 그 양쪽 절반을 이미 제공해요.
두 피어 모두 자기 공인 주소를 모르고, 보통은 둘 다 라우터 뒤에 있어요. ICE는 피어가 도달 가능할 만한 주소를 전부 모아서 나오는 대로 시그널링 채널로 보내고, 통하는 조합이 나올 때까지 짝마다 탐색해요. STUN은 공인 주소를 찾아주고, 직접 연결이 전혀 되지 않을 때는 TURN이 중계를 제공해요.
후보 짝 하나가 선정되면 그 위에서 DTLS 핸드셰이크가 이뤄지고, 그다음부터는 모든 것이 암호화돼요. 데이터 채널은 그 DTLS 전송 위의 SCTP이고, 오디오나 비디오 트랙은 그 위의 SRTP예요. 둘은 하나의 연결과 하나의 열린 포트를 함께 써요.
미디어 경로에는 없어요. 그게 핵심이에요. 두 애플리케이션이 서로를 찾고 나면 오디오와 비디오, 데이터는 둘 사이를 직접 오가요. 여러분이 운영하는 어떤 것도 페이로드를 보지 않고, 사용자가 통화에 쓰는 시간에 맞춰 확장해야 할 것도 없어요.
그래도 그림 안에는 서버가 두 개 있어요. 자주 혼동되니 각각이 무슨 일을 하는지 정확히 짚어두면 좋아요.
시그널링 서버는 여러분의 것이에요. 통화가 시작되기 전에 두 피어 사이로 텍스트 메시지 몇 개를 중계하고 나면 조용해져요. 미디어는 결코 보지 않아요. 이 안내에서는 TsgcWebSocketServer 위에 올린 Delphi 코드 열다섯 줄이에요.
STUN과 TURN 서버는 WebRTC 때문이 아니라 NAT 때문에 존재해요. STUN 서버는 "이 패킷은 어떤 공인 주소에서 왔는가"라는 질문 하나에만 답하고 그게 전부예요. TURN 서버는 다른 방법으로는 서로에게 닿을 수 없는 짝을 위해 패킷을 중계해요. 그래서 미디어를 실어 나르는 유일한 조각이고, 그것도 필요한 통화에서만 그래요. 공개 STUN 서버는 무료로 많이 있고 TURN은 직접 운영해야 하는데, 별도 데몬을 돌리고 싶지 않다면 sgcWebSockets Enterprise에 STUN 서버와 TURN 서버 컴포넌트가 모두 들어 있어요.
App A App B
| |
|---- offer / answer / candidate --->| your signalling
|<-------- (WebSocket relay) --------| server, text only
| |
|---> "what is my public address?" | STUN, once per
| (STUN binding request) | candidate
| |
|====== audio, video and data ======>| direct, encrypted,
|<===================================| no server involved
| |
|== only when nothing direct works ==| TURN relay,
| (relayed candidate pair) | your server
피어 연결과 미디어 엔진은 라이선스 단계가 서로 달라요. 코드를 쓰기 전에 이걸 정리해 두는 게 좋아요. 그러지 않으면 컴파일러가 API의 절반을 아예 보지 못하거든요.
| 하려는 일 | 필요한 것 | 어디에 들어 있는지 |
|---|---|---|
| 공인 주소를 알아내는 STUN 클라이언트 | TsgcSTUNClient |
sgcWebSockets Standard 이상 |
| 직접 STUN 서버 운영하기 | TsgcSTUNServer |
sgcWebSockets Professional 이상 |
| WebSocket 시그널링 채널 중계하기 | TsgcWebSocketServer |
서버 절반은 sgcWebSockets Professional 이상이에요. 클라이언트 절반인 TsgcWebSocketClient는 Standard부터 제공돼요. |
| ICE, TURN 클라이언트와 서버, 그리고 피어 연결 컴포넌트 자체 | TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection |
sgcWebSockets Enterprise |
| Offer와 answer, 데이터 채널, 오디오와 비디오 트랙 | CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack |
Enterprise 위에 얹는 sgcWebRTC 팩이에요. All-Access에도 포함돼요. |
sgcVer.inc의 Enterprise 블록이 SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION, SGC_TURN을 정의해요. 그게 TsgcRTCPeerConnection을 팔레트에 올리고 ICE와 TURN 전송을 붙여줘요.
이 페이지가 실제로 다루는 내용은 그보다 한 단계 더 안쪽에 있어요. SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP, SGC_SRTP를 정의하는 건 SGC_PACK_WEBRTC이고, 이건 ICE와 DTLS, 피어 연결이 이미 있는지 먼저 확인해요. 수동 시그널링 메서드와 데이터 채널 API, 미디어 API는 각각 {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL}, {$IFDEF SGC_RTP} 안에 들어 있어서, Enterprise만으로는 컴파일되지 않아요.
CreateOffer가 해석되지 않는다면 바로 그것 때문이에요. 제품에 들어 있는 데이터 채널 데모는 조용히 실패하는 대신 그 사실을 분명히 알려줘요.
{$IFDEF SGC_PACK_WEBRTC} { PACK WEBRTC }
{$IFDEF SGC_INDY_LIB}
{$IFDEF SGC_ICE} { requires ICE + DTLS + RTCPeerConnection }
{$IFDEF SGC_DTLS}
{$IFDEF SGC_RTCPEERCONNECTION}
{$DEFINE SGC_SDP}
{$DEFINE SGC_SCTP}
{$DEFINE SGC_DATACHANNEL}
{$DEFINE SGC_RTP}
{$DEFINE SGC_SRTP}
{$DEFINE SGC_CODEC_OPUS}
{$DEFINE SGC_CODEC_VP8}
{$DEFINE SGC_CODEC_H264}
{$ENDIF}
{$ENDIF}
{$ENDIF}
{$ENDIF}
{$ENDIF}
sgcP2P는 TsgcRTCPeerConnection을 공개하고 핸들러 타입을 다시 내보내는 배럴 유닛이에요. 컴포넌트를 선언하는 데는 이것으로 충분하지만, 열거형 상수는 그 타입을 선언한 유닛에서 오기 때문에 rtctkAudio나 cctAudioOpus를 쓸 때는 그 유닛도 함께 넣어야 해요.
이 안내에 나오는 두 애플리케이션은 같은 프로그램에서 다른 버튼을 누른 것일 뿐이에요. 아래 내용은 둘 다에 들어가요.
uses
Classes, SysUtils,
// sgc
sgcWebSocket, // signalling carrier
sgcWebSocket_Classes, // TsgcWSConnection
sgcJSON, // wraps the SDP and the candidates
sgcP2P, // TsgcRTCPeerConnection
sgcP2P_RTCPeerConnection, // TsgcRTCConnectionState
sgcP2P_DataChannel, // TsgcRTCDataChannel
sgcP2P_RTC_Media, // TsgcRTCTrack, rtctkAudio
sgcP2P_Codec_Types, // cctAudioOpus, TsgcVideoFrame
sgcP2P_Media_Factory, // sgcCreateAudioCapture
sgcP2P_MediaCapture, // TsgcMediaCaptureSource
sgcP2P_MediaRenderer; // TsgcMediaRenderer
메시지 종류 셋, 단순한 릴레이 하나면 돼요. 모든 WebRTC 튜토리얼이 손짓만 하고 넘어가는 부분이자, 실제로 직접 작성해야 하는 부분이에요.
시그널링 서버는 자기가 전달하는 내용을 단 한 바이트도 이해할 필요가 없어요. 한 피어가 보낸 텍스트를 받아 다른 피어에게 건네줄 뿐이죠. Broadcast에는 이미 연결 Guid를 받는 Exclude 매개변수가 있어서 "보낸 사람만 빼고 모두에게 보내기"는 한 줄이에요.
그 정도로 단순하게 두세요. 릴레이가 SDP를 파싱하기 시작하는 순간, 코덱이 바뀔 때마다 갱신해야 하는 컴포넌트가 되고, 브라우저로 가는 통화를 중계하지 못하게 돼요.
운영 환경에서는 통화 두 건이 겹치지 않도록 방 식별자를 기준으로 릴레이를 나누고, TLS 뒤에 두게 될 거예요. TsgcWebSocketServer는 라이브러리의 나머지와 똑같은 TLSOptions, Authentication, WatchDog 인터페이스를 갖고 있어요.
procedure TFormServer.Start;
begin
FServer := TsgcWebSocketServer.Create(nil);
FServer.Port := 5000;
FServer.OnMessage := OnSignallingMessage;
FServer.Active := True;
end;
procedure TFormServer.OnSignallingMessage(
Connection: TsgcWSConnection; const Text: string);
begin
// relay verbatim to the other peer. The server never
// parses the SDP, so it never learns about codecs.
FServer.Broadcast(Text, '', '', Connection.Guid);
end;
각 애플리케이션은 그 릴레이로 TsgcWebSocketClient를 열고 세 단어짜리 어휘, 즉 offer, answer, candidate만 써요. 직렬화는 같은 라이브러리의 TsgcJSON이 맡으니 의존성이 늘어나지 않아요.
분기가 어디로 가는지 눈여겨보세요. 들어온 offer는 원격 기술로 설정한 뒤 곧바로 답해요. 들어온 answer는 설정만 하고요. 들어온 candidate는 AddIceCandidate로 넘기는데, 기술보다 먼저 올 수도 나중에 올 수도 있어요. 그게 바로 trickle ICE의 요점이에요.
procedure TFormPeer.OnSignallingMessage(
Connection: TsgcWSConnection; const Text: string);
var
oJSON: TsgcJSON;
vKind: string;
begin
oJSON := TsgcJSON.Create(nil);
try
oJSON.Read(Text);
vKind := oJSON.Node['kind'].Value;
if vKind = 'offer' then
begin
FPeer.SetRemoteDescription('offer',
oJSON.Node['sdp'].Value);
FPeer.CreateAnswer; // fires OnLocalDescription
end
else if vKind = 'answer' then
FPeer.SetRemoteDescription('answer',
oJSON.Node['sdp'].Value)
else if vKind = 'candidate' then
FPeer.AddIceCandidate(oJSON.Node['candidate'].Value,
oJSON.Node['sdpMid'].Value,
oJSON.Node['sdpMLineIndex'].Value);
finally
FreeAndNil(oJSON);
end;
end;
두 애플리케이션에서 동일해요. 전체 교환에서 유일하게 비대칭인 것은 어느 쪽이 통화 버튼을 누르느냐뿐이에요.
RTCOptions.ICEServers는 W3C의 iceServers 목록이에요. AddURL은 stun: 또는 turn: URL을 받아 스킴에서 종류와 호스트, 포트, TLS 플래그를 채워 넣고, TURN이라면 사용자 이름과 자격 증명도 선택적으로 받아요.
RTCOptions.DTLS의 기본값은 False예요. 그대로 두면 암호화도 없고 SRTP 키 재료도 없어서 미디어가 동작하지 않아요. CreateDataChannel은 이 값을 대신 켜줘요. 데이터 채널은 DTLS 위의 SCTP이고 DTLS를 끈 상태로는 유효한 설정이 없기 때문이에요. AddTrack은 켜주지 않으니 미디어를 추가할 때는 직접 설정하세요.
인증서 파일은 필요 없어요. RTCOptions.DTLSOptions.CertFile을 비워 두면 컴포넌트마다 한 번씩 메모리 안에서 자체 서명 인증서가 생성돼요. 그게 바로 WebRTC 모델이에요. 신원은 체인이 아니라 SDP의 a=fingerprint 줄에 걸려 있어요. 지문이 없는 원격 기술은 아무것과나 핸드셰이크하도록 허용되는 대신 거부돼요.
아래의 모든 이벤트는 워커 스레드, 즉 ICE나 네트워크, 타이머 스레드에서 발생해요. 메인 스레드에서는 절대 발생하지 않아요. 컨트롤을 건드리기 전에 TThread.Queue로 넘기세요.
procedure TFormPeer.CreatePeer;
begin
FPeer := TsgcRTCPeerConnection.Create(nil);
FPeer.RTCOptions.ICEServers.AddURL(
'stun:stun.l.google.com:19302');
FPeer.RTCOptions.ICE.STUN := True;
FPeer.RTCOptions.ICE.TURN := False; // no TURN server yet
FPeer.RTCOptions.DTLS := True; // default is False
FPeer.OnLocalDescription := OnLocalDescription;
FPeer.OnIceCandidate := OnIceCandidate;
FPeer.OnConnectionStateChange := OnConnectionStateChange;
FPeer.OnDataChannel := OnDataChannel;
FPeer.OnTrack := OnTrack;
FPeer.OnError := OnError;
FSignalling := TsgcWebSocketClient.Create(nil);
FSignalling.Host := 'signalling.example.com';
FSignalling.Port := 5000;
FSignalling.OnMessage := OnSignallingMessage;
FSignalling.Active := True;
end;
OnLocalDescription은 종류, 즉 'offer'나 'answer' 문자열과 SDP 자체를 건네줘요. 두 피어가 같은 핸들러를 쓰고, 그 핸들러가 하는 일은 하나예요. 그걸 시그널링 채널에 올리는 거죠.
SDP는 이미 완성된 상태로 도착해요. CreateOffer는 먼저 ICE 후보를 모으면서 RTCOptions.GatheringTimeout 밀리초, 기본값 3000까지 기다리고, 새 후보 없이 GatheringIdleTimeout 밀리초, 기본값 500이 지나면 일찍 끝내요. 이게 trickle을 쓰지 않는 경로예요.
TrickleICE를 True로 설정하면 기술이 즉시 나가고 후보가 그 뒤를 따라가요. 그럴 일은 드물어요. RTCOptions.TrickleICEAuto가 기본값 True라서, 모든 브라우저가 그렇듯 원격 기술에 a=ice-options:trickle이 있으면 컴포넌트가 알아서 전환하고 수집 대기 시간을 낭비하지 않아요.
procedure TFormPeer.OnLocalDescription(Sender: TObject;
const aType, aSDP: string);
var
oJSON: TsgcJSON;
begin
oJSON := TsgcJSON.Create(nil);
try
oJSON.AddPair('kind', aType); // 'offer' or 'answer'
oJSON.AddPair('sdp', aSDP);
FSignalling.WriteData(oJSON.Text);
finally
FreeAndNil(oJSON);
end;
end;
// App A only. App B answers from OnSignallingMessage.
procedure TFormPeer.btnCallClick(Sender: TObject);
begin
FPeer.CreateDataChannel('chat'); // forces DTLS on
FPeer.CreateOffer;
end;
피어 투 피어 연결이 실패하는 지점이자, 그 실패를 읽어내기가 가장 어려운 지점이에요. 후보는 세 종류이고, 각각 존재하는 이유가 있어요.
컴퓨터가 자기 자신에게서 볼 수 있는 주소로, 네트워크 인터페이스마다 하나씩 있어요. 공짜이고 즉시 얻을 수 있으며, 두 애플리케이션이 같은 LAN이나 같은 VPN에 있다면 이것으로 충분해요. 두 Delphi 애플리케이션이 늘 한 사무실 안에서만 돈다면 host 후보만 있으면 되고 STUN은 아예 건너뛰어도 돼요.
STUN 서버가 본, 피어의 패킷이 도착한 공인 주소예요. 평범한 가정용 라우터 뒤에 있는 두 피어를 직접 대화하게 만들어 주는 게 이것이고, 실제 연결의 대다수를 감당해요. 비용은 STUN 서버로 가는 왕복 한 번뿐이고, 그 뒤로는 트래픽이 그 서버를 거치지 않아요.
피어로 전달해 주는 TURN 서버상의 주소예요. 대칭형 NAT, 제한이 엄격한 회사 방화벽, 일부 이동통신사에서 필요해요. 통화의 모든 바이트가 여러분의 TURN 서버를 지나가므로 비싼 경로이고, 다른 방법이 없을 때만 물러나는 선택지예요.
OnIceCandidate는 후보가 발견될 때마다 한 번씩, 후보 줄과 그것의 sdpMid, sdpMLineIndex와 함께 발생해요. 이 세 필드가 바로 브라우저 API가 기대하는 것이라서, 상대편이 Delphi든 Chrome이든 같은 JSON이 통해요.
하나가 나올 때마다 바로 보내세요. 기다리지도, 묶어 보내지도 마세요. 원격 기술보다 먼저 도착한 후보는 보관됐다가 기술이 도착할 때 적용되므로 순서는 여러분이 신경 쓸 문제가 아니에요.
짝이 최종 선정되면 SelectedLocalCandidate와 SelectedRemoteCandidate가 어느 두 주소가 이겼는지 알려줘요. 그 로그 한 줄이면 "이 통화가 왜 내 TURN 서버를 지나가지"라는 질문에 무엇보다 빠르게 답할 수 있어요.
procedure TFormPeer.OnIceCandidate(Sender: TObject;
const aCandidate, aSdpMid: string;
aSdpMLineIndex: Integer);
var
oJSON: TsgcJSON;
begin
oJSON := TsgcJSON.Create(nil);
try
oJSON.AddPair('kind', 'candidate');
oJSON.AddPair('candidate', aCandidate);
oJSON.AddPair('sdpMid', aSdpMid);
oJSON.AddPair('sdpMLineIndex', aSdpMLineIndex);
FSignalling.WriteData(oJSON.Text);
finally
FreeAndNil(oJSON);
end;
end;
procedure TFormPeer.OnConnectionStateChange(Sender: TObject;
aState: TsgcRTCConnectionState);
begin
// rtccsNew, rtccsGathering, rtccsConnecting, rtccsConnected,
// rtccsDisconnected, rtccsFailed, rtccsClosed
if aState = rtccsConnected then
Log(FPeer.SelectedLocalCandidate + ' -> ' +
FPeer.SelectedRemoteCandidate);
end;
ICEServers의 turn: 항목은 자체 호스트와 포트, 사용자 이름, 자격 증명을 담고 있고, 할당은 그 값을 써요. 추가하는 건 AddURL 한 번 더면 돼요.
함정은 반대 방향에 있어요. RTCOptions.ICE.TURN의 기본값이 True인데, 서버 목록에 TURN 항목이 하나도 없으면 수집이 RTCOptions.ICE의 단일 서버로 돌아가요. 그 호스트의 기본값은 127.0.0.1, 포트는 3478이고요. 그래서 STUN URL만 설정한 피어도 localhost를 상대로 TURN 할당을 시도하고, 실패하고, 그 사실을 보고해요. 결함이라기보다 잡음이지만 로그에서는 심각해 보이고 엉뚱한 곳을 뒤지게 만들어요. 실제로 TURN 서버를 갖추기 전까지는 RTCOptions.ICE.TURN := False로 두세요.
RTCOptions.ICE.STUN도 똑같이 동작하고 기본값 역시 True예요.
// STUN for the public address, TURN for the fallback relay
FPeer.RTCOptions.ICEServers.AddURL(
'stun:stun.example.com:3478');
FPeer.RTCOptions.ICEServers.AddURL(
'turn:turn.example.com:3478', 'user', 'secret');
FPeer.RTCOptions.ICE.STUN := True;
FPeer.RTCOptions.ICE.TURN := True;
// a stalled call is usually a candidate problem. Lower the
// gathering waits on a LAN, where there is nothing to gather.
FPeer.RTCOptions.GatheringTimeout := 1000;
FPeer.RTCOptions.GatheringIdleTimeout := 200;
두 애플리케이션 사이로 텍스트와 바이너리를 주고받아요. 신뢰성은 채널마다 고를 수 있고요. 보통 가장 먼저 동작시키게 되는 것이고, 전송 전체가 잘 되는지를 증명해 줘요.
CreateDataChannel을 호출한 피어는 객체를 즉시 돌려받아요. 호출하지 않은 피어는 OnDataChannel을 통해 같은 채널을 받고요. 두 곳 모두에 핸들러를 붙이세요. 세션 중 어느 시점에든 어느 쪽이든 채널을 열 수 있으니까요.
채널은 만든 즉시 쓸 수 있는 게 아니에요. SCTP 연관이 올라오고 DTLS 역할이 정해질 때까지 Id는 할당되지 않은 채로 있고, 열리지 않은 동안 Send는 False를 반환해요. OnOpen을 기다리세요.
신뢰성은 생성 시점에 정해져요. 기본값은 순서가 보장되고 완전히 신뢰할 수 있는, TCP 같은 채널이에요. 순서 없는 전달을 원하면 aOrdered = False를 넘기고, 부분 신뢰성을 원하면 aMaxRetransmits나 aMaxPacketLifeTime을 넘기세요. 위치 갱신처럼 늦게 오는 패킷이 잃어버린 패킷보다 나쁜 경우에 부분 신뢰성이 적합해요.
채널을 해제하지 마세요. 소유자는 피어 연결이에요. Close가 종료를 시작하고, 상대편이 동의하면 OnClose가 발생해요.
// caller: ordered and reliable, the default
FChannel := FPeer.CreateDataChannel('chat');
AttachChannel(FChannel);
// unordered, give up after 3 retransmits
FState := FPeer.CreateDataChannel('state', False, 3);
// callee: the same channel arrives here
procedure TFormPeer.OnDataChannel(Sender: TObject;
aChannel: TsgcRTCDataChannel);
begin
AttachChannel(aChannel);
end;
procedure TFormPeer.AttachChannel(
aChannel: TsgcRTCDataChannel);
begin
FChannel := aChannel;
FChannel.OnOpen := OnChannelOpen;
FChannel.OnMessage := OnChannelText;
FChannel.OnMessageBinary := OnChannelBinary;
FChannel.OnClose := OnChannelClose;
FChannel.OnError := OnChannelError;
end;
procedure TFormPeer.OnChannelText(Sender: TObject;
const aText: string);
begin
// fires on the SCTP thread, queue before touching a control
TThread.Queue(nil,
procedure
begin
memoChat.Lines.Add(aText);
end);
end;
Send는 문자열을, SendBytes는 TBytes를 받아요. 채널이 열려 있지 않으면 둘 다 예외를 던지는 대신 False를 반환해요. 그래서 정리 중에 보낸 요청은 워커 스레드의 예외가 아니라 반환된 False가 돼요.
MaxMessageSize는 상대 피어가 받겠다고 밝힌 최대 메시지 크기예요. 그 피어 기술의 a=max-message-size 속성에서 읽어요. 그 한도를 넘는 메시지는 회선에 올라가는 대신 로컬에서 거부돼요. 회선에 올라가면 연관 전체가 중단되면서 다른 채널까지 함께 끊기거든요. 0이면 어떤 기술도 한도를 명시하지 않았다는 뜻이고 검사는 꺼져 있어요.
BufferedAmount는 이 스트림을 위해 SCTP에 쌓여 있고 아직 확인되지 않은 바이트 수예요. 파일을 스트리밍할 때 이 값을 지켜보세요. 임계치를 넘을 때까지 보내다가, 기가바이트를 메모리에 쌓아 두는 대신 빠질 때까지 기다리면 돼요.
procedure TFormPeer.btnSendClick(Sender: TObject);
begin
if not Assigned(FChannel) then
Exit;
if not FChannel.Send(txtMessage.Text) then
Log('channel not open');
end;
procedure TFormPeer.SendChunk(const aBytes: TBytes);
begin
if (FChannel.MaxMessageSize > 0) and
(Cardinal(Length(aBytes)) > FChannel.MaxMessageSize) then
begin
Log('too big for this peer, split it');
Exit;
end;
if FChannel.BufferedAmount < 262144 then
FChannel.SendBytes(aBytes);
end;
트랙은 그림이 담긴 데이터 채널이 아니에요. 전송도 다르고, 실패 양상도 다르고, 코드도 달라요. 대부분이 처음에 헷갈리는 구분이에요.
| 데이터 채널 | 미디어 트랙 | |
|---|---|---|
| 무엇으로 운반되나 | DTLS 위의 SCTP (RFC 8831) | 같은 DTLS 전송 위의 SRTP (RFC 3711) |
| 전달 방식 | 완전 신뢰 + 순서 보장부터 보내고 잊기까지 여러분이 골라요 | 설계상 늘 손실을 허용해요. 늦는 게 잃는 것보다 나쁘니 무한정 재전송하지 않아요 |
| 작업 단위 | 메시지예요. 언제 보낼지는 여러분이 정해요 | 시계예요. 오디오는 20ms 프레임으로, 비디오는 프레임 레이트에 맞춰 들어가요 |
| 여는 방법 | 세션 중 언제든 CreateDataChannel |
AddTrack, 공개하려면 새 offer가 필요해요 |
| 받는 경로 | OnDataChannel, 이어서 채널 자신의 OnMessage |
OnTrack, 이어서 트랙의 OnAudio나 OnVideoFrame |
| DTLS를 켜야 하나 | 네. CreateDataChannel이 대신 설정해 줘요 |
네. 그런데 AddTrack은 설정해 주지 않아요. RTCOptions.DTLS를 직접 설정하세요 |
| 어디에 쓰나 | 채팅, 파일 전송, 원격 제어, 게임 상태, 텔레메트리 | 마이크, 카메라, 화면 공유, 타임라인이 있는 모든 것 |
AddTrack은 종류와 코덱을 받아 TsgcRTCTrack을 반환해요. 오디오 코덱은 cctAudioOpus, cctAudioPCMU, cctAudioPCMA이고, 비디오 코덱은 cctVideoVP8, cctVideoVP9, cctVideoH264, cctVideoJPEG예요.
캡처는 별도 객체예요. 플랫폼 마이크를 아예 쓰고 싶지 않을 수도 있으니까요. sgcCreateAudioCapture는 코드가 컴파일된 플랫폼에 맞는 구현을 만들어요. Windows에서는 waveIn, Linux에서는 ALSA, Android에서는 AudioRecord, iOS와 macOS에서는 VoiceProcessingIO Audio Unit이죠. 그래서 코드 어디에서도 플랫폼 클래스를 직접 이름으로 부르지 않아요. 구현이 없는 타깃에서는 nil을 반환하니 결과를 확인하세요.
SendPCM은 인코더의 샘플레이트와 채널 수에 맞춘 16비트 부호 있는 인터리브 PCM을 원해요. Opus는 48000Hz, G.711은 8000Hz예요. 캡처 소스는 실제로 내보내는 값을 AudioSampleRate, AudioChannels, AudioFrameDurationMs로 공개하니 짐작하지 말고 확인하면 돼요.
세션이 이미 올라온 뒤에 트랙을 추가하면 협상 필요 플래그가 서고 OnNegotiationNeeded가 발생해요. 공개하려면 CreateOffer를 다시 호출하면 되고, 재offer는 전송을 건드리지 않고 만들어져요.
procedure TFormPeer.StartCall;
begin
FPeer.RTCOptions.DTLS := True; // SRTP keys come from DTLS
FAudioTrack := FPeer.AddTrack(rtctkAudio, cctAudioOpus);
FVideoTrack := FPeer.AddTrack(rtctkVideo, cctVideoVP8);
FCapture := sgcCreateAudioCapture;
if Assigned(FCapture) then
begin
FCapture.OnAudioCapture := OnAudioCaptured;
FCapture.Start;
if not FCapture.Active then
Log(FCapture.LastError);
end;
FRenderer := sgcCreateAudioRenderer;
if Assigned(FRenderer) then
FRenderer.Start;
FPeer.CreateOffer;
end;
procedure TFormPeer.OnAudioCaptured(Sender: TObject;
const aPCM: TBytes;
aSampleRate, aChannels, aSamplesPerChannel: Integer);
begin
if Assigned(FAudioTrack) then
FAudioTrack.SendPCM(aPCM, aSamplesPerChannel);
end;
OnTrack은 원격 기술이 미디어 라인을 하나 가져올 때마다 한 번씩 발생해요. 넘겨주는 트랙의 소유자는 피어 연결이므로 이벤트만 연결하고 절대 해제하지 마세요.
오디오는 디코더가 만들어 낸 샘플레이트와 채널 수 그대로, 디코딩된 PCM으로 OnAudio를 통해 도착해요. sgcCreateAudioRenderer가 만든 TsgcMediaRenderer에 그대로 넘기면 돼요. 장치를 그 형식으로 열 수 없었다면 렌더러가 형식을 변환해 줘요.
비디오는 디코딩된 TsgcVideoFrame으로 OnVideoFrame을 통해 도착해요. Data에 원시 픽셀이 있고 Width, Height, Format, Stride가 함께 와요. 형식은 vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24, vffBGRA32이고, BGR 두 가지는 Windows GDI 바이트 순서를 따라요. 그래서 vffBGR24 프레임을 비트맵에 옮기는 건 변환이 아니라 메모리 복사예요.
패킷 손실로 프레임이 깨진 채 도착하면 RequestKeyFrame으로 보낸 쪽에 새 프레임을 요청하세요.
procedure TFormPeer.OnTrack(Sender: TObject;
aTrack: TsgcRTCTrack);
begin
if aTrack.Kind = rtctkAudio then
aTrack.OnAudio := OnRemoteAudio
else
begin
FRemoteVideo := aTrack;
aTrack.OnVideoFrame := OnRemoteVideoFrame;
end;
aTrack.OnEnded := OnRemoteTrackEnded;
end;
procedure TFormPeer.OnRemoteAudio(Sender: TObject;
const aPCM: TBytes;
aSampleRate, aChannels, aSamplesPerChannel: Integer);
begin
if Assigned(FRenderer) then
FRenderer.RenderAudio(aPCM, aSampleRate, aChannels,
aSamplesPerChannel);
end;
procedure TFormPeer.OnRemoteVideoFrame(Sender: TObject;
const aFrame: TsgcVideoFrame);
begin
// aFrame.Data holds Width x Height pixels in aFrame.Format
if aFrame.Format = vffBGR24 then
BlitToBitmap(aFrame);
end;
오디오 캡처는 지원하는 모든 플랫폼에 구현이 있어서 팩토리 뒤로 추상화돼 있어요. 비디오 캡처는 그렇지 않아서 플랫폼 클래스를 직접 이름으로 불러야 해요. Windows에서는 sgcP2P_MediaCapture_Win의 TsgcVideoCapture_Win이고, Video for Windows를 구동하며 기반 클래스가 선언한 것과 같은 OnVideoCapture 이벤트로 프레임을 전달해요.
이웃 유닛인 sgcP2P_ScreenCapture_Win에는 TsgcScreenCapture_Win과 TsgcWindowCapture_Win이 있고, 둘 다 TsgcMediaCaptureSource의 자손이에요. 그래서 화면 공유도 생성자만 다를 뿐 같은 세 줄이면 돼요.
uses
sgcP2P_MediaCapture_Win; // MSWINDOWS only
procedure TFormPeer.StartCamera;
begin
FVideoCapture := TsgcVideoCapture_Win.Create(640,
480, 30);
FVideoCapture.DeviceIndex := 0;
FVideoCapture.OnVideoCapture := OnVideoCaptured;
FVideoCapture.Start;
end;
procedure TFormPeer.OnVideoCaptured(Sender: TObject;
const aFrame: TsgcVideoFrame);
begin
if Assigned(FVideoTrack) then
FVideoTrack.SendVideoFrame(aFrame);
end;
피어 투 피어 연결은 오류를 전혀 남기지 않는 방식으로 실패해요. 그래서 어려운 거예요. 가장 자주 나오는 것들을 모았어요.
RTCOptions.DTLS가 False예요. 그게 기본값이고, CreateDataChannel은 켜주지만 AddTrack은 켜주지 않아요. 그래서 미디어만 싣는 세션은 DTLS 핸드셰이크를 아예 하지 않고, 따라서 SRTP 키도 받지 못해요. 직접 설정하세요.
RTCOptions.ICE.TURN의 기본값은 True이고, 서버 목록에 TURN 항목이 없으면 127.0.0.1:3478로 돌아가요. 진짜로 TURN 서버를 갖추기 전까지는 False로 두세요. 그러지 않으면 지금 문제와 아무 상관 없는 할당 실패로 로그가 가득 차요.
a=fingerprint가 없는 기술은 곧바로 거절되고 OnError로 보고돼요. WebRTC 신뢰 모델에서 그 줄만이 피어를 인증하기 때문에, 그것 없는 기술을 받아들이면 아무 인증서로나 핸드셰이크가 완료될 수 있어요.
피어 연결과 데이터 채널, 트랙의 모든 이벤트는 워커 스레드, 즉 ICE나 네트워크, SCTP, 틱 스레드에서 발생해요. 메인 스레드에서는 절대 발생하지 않고요. 거기서 VCL이나 FMX 컨트롤을 직접 건드리면 동작이 정의돼 있지 않아요. TThread.Queue로 감싸세요.
그걸 glare라고 하고, W3C의 perfect negotiation 규칙으로 해결해요. 무례한 쪽, 즉 Polite = False인 피어는 자기 offer를 지키고 들어온 offer는 OnError로 보고해요. 정중한 쪽은 자기 것을 되돌리고 답해요. 한 세션의 두 피어가 모두 정중해서는 안 돼요.
피어의 a=max-message-size를 넘는 메시지는 SCTP 연관 전체를 중단시키고 다른 데이터 채널까지 함께 끊어요. Send와 SendBytes는 MaxMessageSize를 확인해서 대신 로컬에서 거부해요. 큰 페이로드는 직접 나눠 보내세요.
그건 trickle을 쓰지 않을 때의 대기 시간인 RTCOptions.GatheringTimeout이에요. 후보가 더 오지 않으면 GatheringIdleTimeout이 일찍 끝내주고, 원격 기술이 trickle을 알리면 TrickleICEAuto가 trickle 모드로 전환해요. LAN에서는 두 대기 시간을 모두 낮추세요.
캡처 장치와 인코더 사이의 샘플레이트나 채널 수가 어긋난 거예요. Opus는 48000Hz, G.711은 8000Hz로 협상돼요. 장치가 요청한 값을 지켰다고 짐작하지 말고 캡처 소스에서 AudioSampleRate와 AudioChannels를 다시 읽어 보세요.
두 애플리케이션을 피어 투 피어로 연결하기 전에 개발자들이 묻는 것들이에요.
TsgcRTCPeerConnection이 W3C 피어 연결 인터페이스를 Object Pascal로 직접 구현한 것이에요. CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack을 제공하고 그 아래에 ICE와 DTLS, SCTP, SRTP가 있어요. 프로세스 안에 내장 Chromium도, TWebBrowser도, JavaScript 브리지도 없어요.TsgcWebSocketServer와 TsgcWebSocketClient로 열다섯 줄쯤 되는 릴레이를 만들어요. Broadcast에 보낸 쪽의 Connection.Guid를 제외 대상으로 넘겨 모든 메시지를 상대 피어로 중계하죠. 릴레이를 아예 직접 쓰고 싶지 않다면, sgcWebSockets에 미리 만들어진 시그널링 프로토콜 컴포넌트 TsgcWSPServer_RTCPeerConnection도 들어 있어요. RTCOptions.WebSocket과 GatherCandidates를 통해 교환을 대신 진행해 줘요.RTCOptions.ICEServers에 넣어 두면 ICE가 실제로 연결되는 것 중 가장 저렴한 짝을 골라요.SGC_ICE, SGC_DTLS, SGC_TURN, SGC_RTCPEERCONNECTION을 정의하고, 그게 TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient, TsgcTURNServer를 팔레트에 올려요. offer와 answer API, 데이터 채널, 미디어 트랙은 SGC_SDP, SGC_DATACHANNEL, SGC_RTP 뒤에 가려져 있는데, 이 셋을 정의하는 건 SGC_PACK_WEBRTC뿐이에요. 그게 sgcWebRTC 애드온이고 All-Access에도 포함돼요. 그 아래로는 STUN 클라이언트가 Standard에, STUN 서버와 WebSocket 서버 컴포넌트가 Professional에 들어 있어요.sdpMid와 sdpMLineIndex를 담고 있으며, offer와 answer 상태 기계는 RFC 8829를 따라요. 그래서 릴레이가 전달하는 JSON이 양방향 모두에서 수정 없이 통해요. 브라우저는 첫 밀리초부터 후보를 흘려보내는데, 그게 바로 RTCOptions.TrickleICEAuto가 감지하는 것이에요. 원격 기술에서 a=ice-options:trickle을 보면 수집 대기 시간을 다 쓰는 대신 즉시 답해요.OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack, OnError는 ICE나 네트워크, 타이머 스레드에서 도착해요. 데이터 채널 이벤트는 SCTP 연관을 구동하는 스레드에서, 트랙의 오디오와 비디오 이벤트는 네트워크나 틱 스레드에서 도착하고요. 어느 것도 메인 스레드로 대신 넘겨주지 않으니, 컨트롤을 건드리는 코드는 TThread.Queue로 감싸세요. 제품에 들어 있는 데모가 그렇게 하고 있어요.RTCOptions.DTLSOptions.CertFile을 비워 두면 자체 서명 인증서와 키가 컴포넌트마다 한 번씩 메모리에서 생성되고 모든 피어에 재사용돼요. 그 지문이 로컬 기술의 a=fingerprint 속성으로 공개되고, 그것이 상대편에게 여러분을 인증해 줘요. 그게 WebRTC 신뢰 모델이에요. 체인은 결코 검증되지 않고, 시그널링 채널로 전달된 지문이 기준점이 돼요. 안정적인 신원이 필요하다면 CertFile과 KeyFile을 직접 준비한 PEM 파일로 지정해도 돼요.CreateDataChannel이 RTCOptions.DTLS를 무조건 True로 설정하거든요. RTCDataChannel은 정의상 DTLS 위의 SCTP이고, 그것 없이는 유효한 설정이 존재하지 않기 때문이에요.TsgcVideoCapture_Win이고, 화면 공유에는 TsgcScreenCapture_Win과 TsgcWindowCapture_Win이 함께 있어요.AddTrack을 호출하면 협상이 필요하다고 표시되고 OnNegotiationNeeded가 발생해요. CreateOffer를 다시 호출하면 재offer가 동기적으로 만들어지는데, 새 ICE 수집도 없고 새 DTLS나 SCTP 핸드셰이크도 없어서 전송이 끊기지 않아요. 기존 미디어 라인은 위치와 mid를 그대로 유지하고, 새 라인이 끝에 추가돼요. RemoveTrack은 반대 방향으로 똑같이 동작해요. 미디어 라인은 남고 recvonly나 inactive로 다시 공개돼요.이 페이지가 쓰는 조각마다 각자의 레퍼런스 페이지가 있고, 대부분은 속성과 메서드, 이벤트 전체 목록을 담은 별도의 기술 PDF도 있어요.
이 페이지 전체가 다루는 컴포넌트예요. Offer와 answer, ICE, DTLS, SCTP 데이터 채널, RTP 미디어를 한 클래스에 담았어요.
컴포넌트 페이지 →미디어 엔진 팩이에요. SDP, SCTP, RTP, SRTP, Opus와 G.711 오디오, VP8과 H.264 비디오, 대역폭 추정을 담고 있어요.
제품 페이지 →sgcWebSockets 안의 WebRTC를 라이브러리 수준에서 조망해요. 시그널링 프로토콜 컴포넌트와 데모 목록도 함께 있어요.
자세히 알아보기 →이 페이지는 Delphi 사용 사례 중 하나예요. 각 페이지가 하나의 작업을 처음부터 끝까지 다뤄요. 지금까지 나온 다른 페이지는 Delphi에서 LLM 호출하기와 OAuth2와 PKCE로 사용자 로그인 처리하기예요.
전체 사용 사례 →| RTCPeerConnection 기술 문서 (PDF) 피어 연결 컴포넌트만을 위한 속성과 메서드, 이벤트, 코드 예제를 담았어요. | 열기 | |
| ICE 클라이언트 기술 문서 (PDF) 후보 수집과 검사 목록, ICE 서버 컬렉션을 자세히 다뤄요. | 열기 | |
| TURN 클라이언트 기술 문서 (PDF) 할당과 권한, 채널 바인드를 다뤄요. relay 후보를 위해 ICE가 구동하는 클라이언트예요. | 열기 | |
| STUN 클라이언트 기술 문서 (PDF) 바인딩 요청과 재전송을 다뤄요. 자기 공인 주소를 알아내는 가장 저렴한 방법이에요. | 열기 | |
| 데모 프로젝트 Demos\35.P2P\05.RTCPeerConnection과 Demos\35.P2P\06.DataChannel이 패키지 안에 들어 있어요. | 열기 |