WebRTC 라이브러리를 사용하여 어떻게 통신을 하는지 자세하게 알아보았다.
먼저 WebRTC에 사용되는 각종 객체들을 만드는데 사용되는 EglBase와 PeerConnectionFactory를 만들어야 한다.
private static EglBase eglBaseInstance;
...
eglBaseInstance = EglBase.create();
private PeerConnectionFactory peerConnectionFactory;
...
public void createPeerConnectionFactory() {
PeerConnectionFactory.InitializationOptions initializationOptions = PeerConnectionFactory
.InitializationOptions.builder(context)
.createInitializationOptions();
PeerConnectionFactory.initialize(initializationOptions);
DefaultVideoEncoderFactory defaultVideoEncoderFactory = new DefaultVideoEncoderFactory(
eglBase.getEglBaseContext(), /* enableIntelVp8Encoder */true, /* enableH264HighProfile */true);
DefaultVideoDecoderFactory defaultVideoDecoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
peerConnectionFactory = PeerConnectionFactory.builder().setVideoEncoderFactory(defaultVideoEncoderFactory).setVideoDecoderFactory(defaultVideoDecoderFactory).createPeerConnectionFactory();
Log.d(TAG, "PeerConnectionFactory 생성됨");
}
EglBase는 수신/발신 할 비디오의 처리에 사용된다.
PeerConnectionFactory를 만들 때 EglBase를 사용하여 인코더와 디코더를 설정해준다.
DefaultVideoEncoderFactory defaultVideoEncoderFactory = new DefaultVideoEncoderFactory(
eglBase.getEglBaseContext(), /* enableIntelVp8Encoder */true, /* enableH264HighProfile */true);
DefaultVideoDecoderFactory defaultVideoDecoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext());
PeerConnection은 WebRTC 통신을 관리하는 중요한 객체이다.
private HashMap<String, PeerConnection> peerConnections = new HashMap<>();
...
public void createPeerConnection(String peerName) {
if (peerConnections.containsKey(peerName)) {
deletePeerConnection(peerName);
}
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(Collections.singletonList(new PeerConnection.IceServer("stun:stun.l.google.com:19302")));
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
PeerConnection peerConnection = peerConnectionFactory.createPeerConnection(config, new CustomPeerConnectionObserver(peerName));
peerConnections.put(peerName, peerConnection);
Log.d(TAG, "PeerConnection 생성됨: " + peerName);
}
private class CustomPeerConnectionObserver implements PeerConnection.Observer {
...
}
이 코드에선 peerConnections 라는 HashMap 객체를 사용해 여러 PeerConnection 객체들을 관리하도록 만들었다.
PeerConnection.RTCConfiguration 에서 사용할 STUN, TURN 서버를 등록한다.
여기서는 STUN 서버만 사용하였다.
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(Collections.singletonList(new PeerConnection.IceServer("stun:stun.l.google.com:19302")));
SDP 표현 방식을 표준 SDP 방식으로 설정하였다.
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
다음은 PeerConnection의 각종 콜백 메소드들을 정의한 PeerConnection.Observer 클래스이다.
private class CustomPeerConnectionObserver implements PeerConnection.Observer {
private String peerName;
public CustomPeerConnectionObserver(String peerName){
this.peerName = peerName;
}
@Override
public void onSignalingChange(PeerConnection.SignalingState signalingState) {
Log.d(TAG, "onSignalingChanged : " + signalingState.toString());
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {
Log.d(TAG, "onIceConnectionChange : " + iceConnectionState.toString());
}
@Override
public void onIceConnectionReceivingChange(boolean b) {
Log.d(TAG, "onIceConnectionReceivingChange : " + b);
}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {
Log.d(TAG, "onIceGahteringChange : " + iceGatheringState.toString());
}
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
...
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {
Log.d(TAG, "onIceCandidatesRemoved");
}
@Override
public void onAddStream(MediaStream mediaStream) {
// 사용되지 않음
}
@Override
public void onRemoveStream(MediaStream mediaStream) {
// 사용되지 않음
}
@Override
public void onDataChannel(DataChannel dataChannel) {
...
}
@Override
public void onRenegotiationNeeded() {
// 필요 시 구현
}
@Override
public void onAddTrack(RtpReceiver rtpReceiver, MediaStream[] mediaStreams) {
}
@Override
public void onTrack(RtpTransceiver transceiver) {
...
}
}
해당 콜백 메소드들을 사용해 Ice Candidate 가 생성됬을때, Data Channel에서 수신받았을 때, VideoTrack을 수신받았을 때 등의 상황에 맞는 코드를 작성한다.
해당 내용들은 아래의 각각의 부분에서 서술하였다.
WebRTC 통신으로 영상을 공유하기 위해선 VideoTrack을 만들고, 자신의 PeerConnection 객체에 해당 VideoTrack을 addTrack 해줘야 한다.
다음은 VideoTrack을 만드는 코드이다.
private VideoCapturer videoCapturer;
private EglBase eglBase;
private SurfaceTextureHelper surfaceTextureHelper;
private VideoSource videoSource;
private VideoTrack localVideoTrack;
const val LOCAL_VIDEO_TRACK_ID="localVideoTrack"
const val CAPTURE_WIDTH=1280
const val CAPTURE_HEIGHT=720
const val CAPTURE_FPS=30
...
surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext());
...
private void startVideoCapture() {
if (videoCapturer == null) {
videoCapturer = createVideoCapturer();
if (videoCapturer == null) {
Log.e(TAG, "VideoCapturer 생성 실패");
return;
}
videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());
localVideoTrack = peerConnectionFactory.createVideoTrack(LOCAL_VIDEO_TRACK_ID, videoSource);
videoCapturer.initialize(surfaceTextureHelper, getApplicationContext(), videoSource.getCapturerObserver());
}
try {
videoCapturer.startCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT, CAPTURE_FPS);
} catch (Exception e) {
Log.e(TAG, "비디오 캡처 시작 중 오류 발생", e);
}
}
private VideoCapturer createVideoCapturer() {
if (Camera2Enumerator.isSupported(this)) {
return createCameraCapturer(new Camera2Enumerator(this));
} else {
return createCameraCapturer(new Camera1Enumerator(true));
}
}
private VideoCapturer createCameraCapturer(CameraEnumerator enumerator) {
final String[] deviceNames = enumerator.getDeviceNames();
for (String deviceName : deviceNames) {
if (enumerator.isFrontFacing(deviceName) {
VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
if (videoCapturer != null) {
return videoCapturer;
}
}
}
for (String deviceName : deviceNames) {
if (!enumerator.isFrontFacing(deviceName) {
VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
if (videoCapturer != null) {
return videoCapturer;
}
}
}
return null;
}
createVideoCapturer() 메소드로 Camera2Enumerator 혹은 Camera1Enumerator 둘중 하나를 선택하고,
선택한 Enumerator를 사용해 createCameraCapturer() 메소드를 실행, 전면 카메라가 있다면 전면 카메라를 사용하고 없다면 다른 카메라를 사용해 VideoCapturer 객체를 만든다.
startVideoCapture() 메소드에서 위에서 만든 VideoCapturer 객체를 사용해 VideoSource 객체를 만들고 이를 사용해 VideoTrack 객체를 만든다.
PeerConnection 객체에 해당 VideoTrack을 추가하려면 addTrack() 메소드를 사용하면 된다.
peerConnection.addTrack(localVideoTrack);
만약 해당 VideoTrack을 화면에 띄워 보고싶다면 layout에 SurfaceViewRenderer를 추가하고 SurfaceViewRenerer 객체를 만든 후 VideoTrack에 addSink() 해주면 된다.
// ----- SurfaceViewRenderer 초기화 -----
private void initSurfaceViewRenderer() {
surfaceViewRenderer = findViewById(R.id.local_video_view);
eglBase = EglBase.create();
surfaceViewRenderer.init(eglBase.getEglBaseContext(), null);
surfaceViewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
surfaceViewRenderer.setZOrderMediaOverlay(true);
}
...
localVideoTrack.addSink(surfaceViewRenderer);
연결 후 상대가 보내는 영상을 보고 싶다면 PeerConnection 객체 생성 시 등록한 Observer의 onTrack() 콜백 메소드에서 SurfaceViewRenerer 객체를 addSink() 해준다.
@Override
public void onTrack(RtpTransceiver transceiver) {
PeerConnection.Observer.super.onTrack(transceiver);
// SurfaceViewRenderer에 비디오 트랙 연결
if (transceiver.getReceiver().track().kind().equals(MediaStreamTrack.VIDEO_TRACK_KIND)) {
remoteVideoTrack = (VideoTrack) transceiver.getReceiver().track();
remoteVideoTrack.addSink(surfaceViewRenderer);
}
}
AudioTrack 또한 VideoTrack 과 같은 방법으로 생성하고 사용한다.
// 오디오 트랙 생성
audioSource = peerConnectionFactory.createAudioSource(new MediaConstraints());
localAudioTrack = peerConnectionFactory.createAudioTrack("localAudioTrack", audioSource);
peerConnection.addTrack(localAudioTrack);
@Override
public void onTrack(RtpTransceiver transceiver, String peerName) {
if (transceiver.getReceiver().track().kind().equals(MediaStreamTrack.VIDEO_TRACK_KIND)) {
VideoTrack remoteVideoTrack = (VideoTrack) transceiver.getReceiver().track();
remoteVideoTrack.addSink(surfaceViewRenderer);
} else if (transceiver.getReceiver().track().kind().equals(MediaStreamTrack.AUDIO_TRACK_KIND)) {
AudioTrack remoteAudioTrack = (AudioTrack) transceiver.getReceiver().track();
}
}
Data Channel을 사용하면 피어 간 간단한 데이터를 주고 받을 수 있다.
다음은 DataChannel을 생성하고, Observer를 등록해 메세지 수신 시 처리하는 코드이다.
private HashMap<String, DataChannel> dataChannels = new HashMap<>();
...
public void createDataChannel(String peerName) {
if (!peerConnections.containsKey(peerName)) {
Log.e(TAG, "DataChannel 생성 불가; PeerConnection이 존재하지 않음: " + peerName);
return;
}
DataChannel.Init dcInit = new DataChannel.Init();
dcInit.ordered = true;
DataChannel dataChannel;
try {
dataChannel = peerConnections.get(peerName).createDataChannel(peerName, dcInit);
} catch (Exception e){
Log.e(TAG, "Data Channel 생성 실패", e);
return;
}
dataChannel.registerObserver(new DataChannel.Observer() {
@Override
public void onBufferedAmountChange(long l) {
// 필요 시 구현
}
@Override
public void onStateChange() {
// 필요 시 구현
}
@Override
public void onMessage(DataChannel.Buffer buffer) {
ByteBuffer data = buffer.data;
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
String receivedMessage = new String(bytes, StandardCharsets.UTF_8);
// 받은 메세지 처리 부분
}
});
deleteDataChannel(peerName);
dataChannels.putIfAbsent(peerName, dataChannel);
Log.d(TAG, "Data Channel 생성 성공");
}
DataChannel도 PeerConnection 처럼 HashMap을 사용해 관리하도록 만들었다.
초기화 시 설정을 구성하는 DataChannel.Init와, PeerConnection 객체의 createDatachannel() 메소드로 DataChannel을 생성했다.
DataChannel.Init dcInit = new DataChannel.Init();
dcInit.ordered = true;
DataChannel dataChannel;
try {
dataChannel = peerConnections.get(peerName).createDataChannel(peerName, dcInit);
} catch (Exception e){
Log.e(TAG, "Data Channel 생성 실패", e);
return;
}
다음으로 Observer를 등록하여 해당 DataChannel에서 메세지를 수신 시의 동작을 구현하였다.
dataChannel.registerObserver(new DataChannel.Observer() {
@Override
public void onBufferedAmountChange(long l) {
// 필요 시 구현
}
@Override
public void onStateChange() {
// 필요 시 구현
}
@Override
public void onMessage(DataChannel.Buffer buffer) {
ByteBuffer data = buffer.data;
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
String receivedMessage = new String(bytes, StandardCharsets.UTF_8);
// 받은 메세지 처리 부분
}
});
DataChannel을 만든쪽이 아닌 피어에서 메세지를 받고 처리하기 위해선 PeerConnection 객체 생성 시 등록한 Observer 의 onDataChannel() 콜백 메소드에서 DataChannel을 받은 후 Observer를 등록해 구현하면 된다.
@Override
public void onDataChannel(DataChannel dataChannel) {
Log.d(TAG, "Data Channel 수신함");
dataChannel.registerObserver(new DataChannel.Observer() {
@Override
public void onBufferedAmountChange(long l) {
// 필요 시 구현
}
@Override
public void onStateChange() {
// 필요 시 구현
}
@Override
public void onMessage(DataChannel.Buffer buffer) {
ByteBuffer data = buffer.data;
byte[] bytes = new byte[data.remaining()];
data.get(bytes);
String receivedMessage = new String(bytes, StandardCharsets.UTF_8);
// 받은 메세지 처리 부분
}
});
if (dataChannels.containsKey(peerName)) {
deleteDataChannel(peerName);
}
dataChannels.put(peerName, dataChannel);
}
DataChannel로 데이터를 보낼려면 DataChannel 객체의 send() 메소드를 사용한다.
public void sendData(String data) {
byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
for (String peerName : dataChannels.keySet()) {
DataChannel dataChannel = dataChannels.get(peerName);
if (dataChannel.state() == DataChannel.State.OPEN) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
DataChannel.Buffer dataBuffer = new DataChannel.Buffer(buffer, false);
dataChannel.send(dataBuffer);
Log.d(TAG, "데이터 전송됨: " + data + " -> " + peerName);
}
}
}
여기부터는 PeerConnection 객체를 사용해 피어 간 연결을 하는데 사용되는 기능들이다.

Offer SDP 는 WebRTC 연결에 있어서 가장 먼저 사용된다.
한쪽 Peer 에서 Offer SDP를 생성하고, LocalDescription으로 등록하고, 이를 상대 Peer 에게 전송하기 된다.
peerConnection.createOffer(new CustomSdpObserver("createOffer") {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
super.onCreateSuccess(sessionDescription);
peerConnection.setLocalDescription(new CustomSdpObserver("setLocalDescription") {
@Override
public void onSetSuccess() {
super.onSetSuccess();
Log.d(TAG, "setLocalDescription 성공");
// Signaling Server를 사용해 상대 Peer 에게 Offer SDP 전달 구현 부분
// String offer_SDP = sessionDescription.description;
}
}, sessionDescription);
}
}, new MediaConstraints());
...
private abstract class CustomSdpObserver implements SdpObserver {
private final String tag;
CustomSdpObserver(String tag){
this.tag = tag;
}
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
}
@Override
public void onSetSuccess() {
}
@Override
public void onCreateFailure(String s) {
Log.e(TAG, tag + " onCreateFailure: " + s);
}
@Override
public void onSetFailure(String s) {
Log.e(TAG, tag + " onSetFailure: " + s);
}
}
PeerConnection 객체의 createOffer() 메소드로 Offer SDP를 생성한다.
이 때 SdpObserver를 등록하여 생성 성공/실패 콜백 메소드를 등록한다.
peerConnection.createOffer(new CustomSdpObserver("createOffer") {
...
}, new MediaConstraints());
생성에 성공했다면 PeerConnection 객체의 setLocalDescription() 메소드로 Offer SDP를 LocalDescription으로 등록한다.
이 때도 SdpObserver를 등록하여 등록 성공/실패 콜백 메소드를 등록한다.
peerConnection.createOffer(new CustomSdpObserver("createOffer") {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
super.onCreateSuccess(sessionDescription);
peerConnection.setLocalDescription(new CustomSdpObserver("setLocalDescription") {
...
}, sessionDescription);
}
}, new MediaConstraints());
등록에 성공했다면 Signaling Server를 사용해 상대 Peer에게 Offer SDP를 전달해줘야 한다.
peerConnection.createOffer(new CustomSdpObserver("createOffer") {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
super.onCreateSuccess(sessionDescription);
peerConnection.setLocalDescription(new CustomSdpObserver("setLocalDescription") {
@Override
public void onSetSuccess() {
super.onSetSuccess();
Log.d(TAG, "setLocalDescription 성공");
// Signaling Server를 사용해 상대 Peer 에게 Offer SDP 전달 구현 부분
// String offer_SDP = sessionDescription.description;
}
}, sessionDescription);
}
}, new MediaConstraints());
상대 Peer는 전달받은 Offer SDP를 PeerConnection 객체의 setRemoteDescription() 메소드를 사용해 RemoteDescription으로 등록해줘야 한다.
이 때도 SdpObserver를 등록하여 등록 성공/실패 콜백 메소드를 등록한다.
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.OFFER, offer_SDP);
peerConnection.setRemoteDescription(new CustomSdpObserver("setRemoteDescription") {
@Override
public void onSetSuccess() {
super.onSetSuccess();
Log.d(TAG, "setRemoteDescription 성공");
// Answer SDP 생성 구현 부분
}
}, remoteSdp);
Offer SDP를 전달받아 RemoteDescription으로 등록이 끝났다면 받은 쪽에서 Answer SDP를 생성해 보내줘야 한다.
Offer SDP를 전달받아 RemoteDescription으로 등록을 했다면, 해당 Peer는 Answer SDP를 생성해서 LocalDescription으로 등록하고, 상대 Peer에게 전달해줘야 한다.
Offer SDP 때와 동일하게 Answer SDP를 수신한 측 Peer는 Answer SDP를 RemoteDescription으로 등록해줘야 한다.
방법은 Offer SDP 때와 동일하다.
createOffer() 대신 createAnswer() 를 사용하고, String으로 변환되어 넘어온 SDP를 SessionDescription으로 변환할 때 SessionDescription.Type.OFFER 대신 SessionDescription.Type.ANSWER 를 사용한다
peerConnection.createAnswer(new CustomSdpObserver("createAnswer") {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
super.onCreateSuccess(sessionDescription);
peerConnection.setLocalDescription(new CustomSdpObserver("setLocalDescription") {
@Override
public void onSetSuccess() {
super.onSetSuccess();
Log.d(TAG, "setLocalDescription 성공");
// Signaling Server를 사용해 상대 Peer 에게 Answer SDP 전달 구현 부분
// String answer_SDP = sessionDescription.description;
}
}, sessionDescription);
}
}, new MediaConstraints());
SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.ANSWER, answer_SDP);
peerConnection.setRemoteDescription(new CustomSdpObserver("setRemoteDescription") {
@Override
public void onSetSuccess() {
super.onSetSuccess();
Log.d(TAG, "setRemoteDescription 성공");
}
}, remoteSdp);
Offer SDP와 Answer SDP를 생성하고 교환하는 과정에서 각자의 Ice Candidates가 생성된다.
Ice Candidates는 Peer 간 연결 경로를 제공한다.
이를 위해 자신의 Ice Candidates를 Signailing Server를 통해 상대 Peer 에게 넘겨줘야하고, 넘겨받은 Peer는 이를 addIceCandidate() 메소드로 등록해줘야 한다.
Ice Candidates는 PeerConnection 객체에 등록한 Observer의 onIceCandidate(IceCandidate iceCandidate) 메소드에서 생성될 때마다 얻을 수 있다.
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
Log.d(TAG, "IceCandidate 생성됨 : " + iceCandidate);
// 상대 Peer 에게 생성된 iceCandidate 넘겨주기
}
Ice Candidates를 넘겨받은 Peer는 이를 addIceCandidate() 메소드로 등록해줘야 한다.
peerConnection.addIceCandidate(iceCandidate);
Ice Candidates는 확인한 바로는
기본적으로 생성되는 같은 네트워크에 있는 Peer끼리 연결할 때 쓰이는 HOST 타입.
STUN 서버를 사용하면 생성되는 SRFLX 타입.
TURN 서버를 사용하면 생성되는 RELAY 타입이 있다.
해당 과정을 마치면 Peer간 연결이 성공해 비디오/음성/데이터 를 주고받을 수 있게 된다.