WebRTC
WebRTC는 웹 브라우저 간에 피어 투 피어(Peer-to-Peer, P2P) 방식의 통신으로 클라이언트간 서버를 거치지 않고 실시간으로 데이터를 전송합니다.
이미지 출처: https://www.wowza.com/
The Embedded Video Platform for Solution Builders | Wowza
Wowza is a video platform with industry-leading technology delivering quality live and VOD streaming with integrated CMS, analytics and more.
www.wowza.com
WebRTC의 핵심 구성 요소
- MediaStream (getUserMedia): 사용자의 카메라와 마이크에 접근하여 미디어 스트림을 캡처
- RTCPeerConnection: 두 사용자 간의 직접적인 데이터 스트림 연결을 설정하고 유지
- RTCDataChannel: 일반 데이터를 P2P 방식으로 교환하기 위한 채널을 제공
WebRTC를 사용하기 위해서는 클라이언트간 서로의 정보를 전달 받아야 됩니다. 그래서 RTCPeerConnection들이 서로 정보들을 주고 받도록 할 수 있게 하는 과정을 시그널링이라고 하고, 정보를 전달해주는 매개체로 시그널링 서버를 사용할 수 있습니다. 그리고 클라이언트 각각 양방향 통신을 해야 됩니다.
WebRTC는 클라이언트간의 지연 시간을 줄이는 것이 가장 중요할 때 사용하며, 지연 시간이 긴 스트리밍은 같은 경우에는 덜 효과적입니다.
그럼 시그널링 서버는 왜 필요한 걸까요?
시그널링 서버
WebRTC는 P2P 방식으로 peer간 데이터 통신을 하려면, 서로의 주소를 알고 있어야 되는데 그때 상대방의 주소를 전달해주는 역할을 하는게 시그널링 서버입니다.
시그널링 과정에서는 SDP(Session Description Protocol) 메시지를 교환하여 미디어 형식, IP 주소, 포트 번호 등의 정보를 공유합니다. 또한, ICE(Interactive Connectivity Establishment) 후보를 통해 NAT Traversal과 방화벽을 넘어 서로 연결할 수 있는 최적의 경로를 찾게 됩니다.
시그널링 서버는 웹소켓 서버로 구축할 수 있습니다.
SDP (Session Description Protocol)
SDP, 즉 세션 설명 프로토콜은 인터넷에서 멀티미디어 세션을 설명하기 위한 형식입니다. 이 프로토콜은 실제 데이터 전송을 다루지 않으며, 멀티미디어 세션의 초기 설정에 필요한 정보를 교환하는 데 사용됩니다. SDP는 주로 WebRTC와 같은 실시간 통신 기술에서 사용되며, 통신할 준비가 된 피어 간에 미디어 포맷, 네트워크 정보, 인코딩 세부 정보 등을 알려주는 역할을 합니다.
- v= (프로토콜 버전)
- o= (세션 소유자 및 식별자)
- s= (세션 이름)
- t= (세션 활성 시간)
- m= (미디어 이름 및 전송 포트, 프로토콜, 포맷)
- a= (속성, 예: a=recvonly, a=sendrecv, a=sendonly)
- c= (연결 정보 - 일반적으로 IP 주소)
SDP는 WebRTC 연결 과정에서, 피어 간에 미디어 스트림 타입(오디오, 비디오), 코덱 선택, 사용 가능한 포트 정보 등을 교환하는 데 사용됩니다. 또한, ICE 후보 정보를 포함하여 네트워크 연결성 정보도 교환할 수 있습니다.
NAT (Network Address Translation)
NAT, 즉 네트워크 주소 변환은 공용 IP 주소와 사설 네트워크 내의 여러 장치 간의 IP 주소를 매핑하는 기술입니다. NAT는 사설 IP 주소를 사용하는 로컬 네트워크 내의 장치들이 인터넷과 같은 공용 네트워크에 접속할 수 있게 해주며, 동시에 외부에서 내부 네트워크로의 직접적인 접근을 제한하여 보안성을 높여줍니다.
NAT의 주요 기능
- IP 주소의 재사용: NAT는 사설 IP 주소를 공용 IP 주소로 변환함으로써, 제한된 수의 공용 IP 주소를 효율적으로 사용할 수 있게 해줍니다.
- 보안: NAT는 내부 네트워크와 외부 네트워크 사이의 경계를 설정하고, 내부 네트워크에 대한 직접적인 접근을 차단합니다.
- 포트 포워딩: 특정 외부 요청을 내부 네트워크의 특정 장치로 전달하도록 설정할 수 있습니다. 이를 통해 내부 서버나 서비스에 대한 외부 접근을 가능하게 합니다.
NAT는 WebRTC와 같은 P2P 통신에 있어서 중요한 고려 사항입니다. NAT 뒤에 있는 장치들이 서로 직접 통신하기 위해서는, ICE와 같은 기술을 사용하여 NAT를 효과적으로 우회하고, 최적의 연결 경로를 찾아내야 합니다.
ICE (Interactive Connectivity Establishment)
ICE는 인터넷 연결 설정을 위한 프레임워크로, NAT(Network Address Translator)나 방화벽 뒤에 위치한 디바이스 간에 직접적인 통신 경로를 설정할 수 있도록 합니다. ICE는 여러 기술(STUN, TURN)을 사용하여 최적의 네트워크 경로를 찾고, 가능한 한 가장 직접적인 연결을 선호합니다.
- STUN (Session Traversal Utilities for NAT): 공개 인터넷 상에서 디바이스의 공개 IP 주소와 포트 번호를 발견하는 메커니즘을 제공합니다. STUN 서버는 내부 네트워크에 있는 디바이스가 자신의 공개적으로 접근 가능한 주소를 알 수 있도록 도와줍니다.
- TURN (Traversal Using Relays around NAT): STUN을 사용해도 직접 연결을 할 수 없는 경우, TURN 서버는 중계 서버로 작동하여 모든 데이터를 중계함으로써 통신을 가능하게 합니다. 이는 더 많은 리소스를 소비하지만, 연결이 반드시 성립되어야 하는 경우 유용합니다.
ICE 프로세스는 WebRTC 기반 애플리케이션의 신뢰성과 범용성을 보장하는 데 중요합니다. NAT과 방화벽은 인터넷의 핵심 구성 요소이지만, 이들은 직접적인 P2P 연결을 어렵게 만듭니다. ICE는 이러한 네트워크 제약을 극복하고, 가능한 한 최적의 경로를 통해 피어 간에 직접 연결을 설정할 수 있도록 합니다. 이는 지연 시간을 최소화하고, 높은 품질의 실시간 통신을 가능하게 합니다.
// WebRTC 피어 연결 생성
const peerConnection = new RTCPeerConnection();
// ICE 후보 수집 완료 이벤트 핸들러
peerConnection.onicecandidate = event => {
if (event.candidate) {
// 시그널링 서버를 통해 상대방에게 ICE 후보 전송
sendToPeer('iceCandidate', event.candidate);
}
};
// 상대방으로부터 ICE 후보 수신
onSignal('iceCandidate', iceCandidate => {
// 받은 ICE 후보를 현재 피어 연결에 추가
peerConnection.addIceCandidate(new RTCIceCandidate(iceCandidate));
});
DTLS (Datagram Transport Layer Security)
DTLS는 Datagram Transport Layer Security의 약자로, 데이터그램 기반 통신을 위한 보안 프로토콜입니다. TLS(Transport Layer Security)가 연결 지향적인 프로토콜(예: TCP)에 대한 보안을 제공하는 반면, DTLS는 연결이 없는 프로토콜(예: UDP)에 대한 보안 통신을 가능하게 합니다. 이는 WebRTC와 같이 실시간 데이터 전송이 필요한 애플리케이션에서 중요한 역할을 합니다.
DTLS는 데이터 무결성, 데이터 기밀성, 그리고 엔드포인트 인증을 제공합니다. 이는 패킷이 변조되지 않았음을 보증하고, 전송 중인 데이터가 암호화되어 보호되며, 통신하는 양쪽 당사자가 서로를 인증할 수 있음을 의미합니다.
예시: WebRTC 연결에서, 두 피어 간의 미디어 데이터(오디오 및 비디오 스트림)와 데이터 채널 메시지는 UDP를 통해 전송됩니다. DTLS는 이러한 UDP 패킷이 암호화되고, 각 피어의 신원이 인증될 수 있도록 보장하여, 안전한 통신 채널을 구축합니다.
WebRTC 통신 과정
- SDP 교환을 통한 통신 초기화: WebRTC 연결이 시작되면, 각 피어는 자신의 미디어 정보와 네트워크 정보를 SDP 형식으로 포장하여 상대방에게 전송합니다. 이 정보에는 사용 가능한 미디어 코덱, 포트 번호, ICE 후보 정보 등이 포함됩니다.
- ICE를 통한 최적 경로 탐색: SDP를 통해 교환된 ICE 후보들을 바탕으로, 피어 간에는 연결성 검사를 시작합니다. 이 과정에서는 STUN 서버를 사용해 공용 IP 주소를 확인하거나, 필요한 경우 TURN 서버를 통해 미디어 데이터를 중계합니다. 이를 통해 NAT 뒤에 있는 피어들도 서로 통신할 수 있는 경로를 찾을 수 있습니다.
- 연결성 검사와 경로 선정: 각 피어는 상대방으로부터 받은 ICE 후보를 바탕으로 연결성 검사를 수행하며, 이 과정을 통해 양방향 통신이 가능한 최적의 경로를 선정합니다. 일단 연결 경로가 확립되면, 미디어 데이터의 전송이 시작됩니다.
- 미디어 스트림 전송: 선정된 연결 경로를 통해 두 피어 간에는 암호화된 미디어 스트림이 전송됩니다. DTLS는 이 과정에서 미디어 데이터의 암호화와 인증을 담당하며, SRTP(Secure Real-time Transport Protocol)를 통해 안전한 미디어 전송이 이루어집니다.
- 동적 네트워크 조건에 대응: 네트워크 상황이 변동 되는 경우, WebRTC 연결은 이에 동적으로 대응할 수 있습니다. 예를 들어, 네트워크 연결이 불안정해지거나 끊어지는 경우, ICE 프로세스는 새로운 연결 경로를 탐색하고 재 연결을 시도할 수 있습니다.
위와 같이 WebRTC는 빠른 통신을 위해 UDP 프로토콜을 사용하며, UDP의 약점인 보안을 DTLS와 SRTP를 통해 예방합니다. 또한 연결 초기에 SDP 정보를 통한 후 ICE로 최적의 경로를 찾고 복잡한 네트워크 환경에서 P2P 연결에 신뢰성을 더합니다.
예제 코드
클라이언트
const socket = new SockJS('/signal');
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
stompClient.subscribe('/topic/greetings', function(greeting){
alert(JSON.parse(greeting.body).content);
});
});
function sendName() {
const name = document.getElementById('name').value;
stompClient.send("/app/hello", {}, JSON.stringify({'name': name}));
}
let pc = new RTCPeerConnection({
iceServers: [
// Google의 공개 STUN 서버
{ urls: 'stun:stun.l.google.com:19302' },
// TURN 서버 구성 예시 (실제 사용 시 자신의 TURN 서버 정보로 교체 필요)
{ urls: 'turn:your.turn.servers:3478', username: 'turnUsername', credential: 'turnPassword' }
]
});
// ICE Candidate를 수집하는 이벤트 핸들러
pc.onicecandidate = event => {
if (event.candidate) {
sendSignal('ice_candidate', JSON.stringify(event.candidate));
}
};
// Offer를 생성하고 서버에 전송
function createOffer() {
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
sendSignal('offer', JSON.stringify(offer));
});
}
// 서버로부터 신호를 받았을 때 처리
function onSignalReceived(message) {
switch (message.type) {
case 'offer':
// Offer를 받았을 경우
pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(message.content)));
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
sendSignal('answer', JSON.stringify(answer));
});
break;
case 'answer':
// Answer를 받았을 경우
pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(message.content)));
break;
case 'ice_candidate':
// ICE Candidate를 받았을 경우
pc.addIceCandidate(new RTCIceCandidate(JSON.parse(message.content)));
break;
}
}
// 서버에 신호를 전송하는 함수
function sendSignal(type, content) {
// WebSocket을 사용하여 서버에 신호 전송
}
서버(스프링 부트)
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
// WebSocketConfig.java
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 클라이언트가 WebSocket을 통해 연결할 엔드포인트 "/ws" 등록
registry.addEndpoint("/ws").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 메시지를 보내는 요청의 prefix는 "/app"으로 설정
registry.setApplicationDestinationPrefixes("/app");
// 메시지를 구독하는 요청의 prefix는 "/topic"으로 설정하여, 단순 메시지 브로커 활성화
registry.enableSimpleBroker("/topic");
}
}
public class Message {
private String type; // 메시지 유형 (offer, answer, ice_candidate)
private String content; // 메시지 내용 (SDP 정보 또는 ICE 후보)
private String sender; // 보내는 사람 ID
private String receiver; // 받는 사람 ID
// 생성자, getters 및 setters 생략
}
// SignalController.java
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
@Controller
public class SignalController {
@MessageMapping("/signal") // 클라이언트가 메시지를 보낼 주소
@SendTo("/topic/messages") // 메시지를 구독하는 클라이언트에게 보낼 주소
public Message send(@Payload Message message) {
// 간단한 예제로, 받은 메시지를 그대로 모든 클라이언트에게 전송
// 실제로는 여기에서 메시지 유형에 따라 다른 로직을 수행할 수 있습니다.
return message;
}
@MessageMapping("/message")
public void handleMessage(Message message, SimpMessageHeaderAccessor headerAccessor) {
switch (message.getType()) {
case OFFER:
// Offer 메시지 처리 로직
break;
case ANSWER:
// Answer 메시지 처리 로직
break;
case ICE_CANDIDATE:
// ICE Candidate 메시지 처리 로직
break;
default:
// 기타 유형 처리
break;
}
// 메시지를 적절한 대상에게 라우팅
}
}