배경 지식은 이전 포스팅에서 설명을 얼추 한 것 같습니다. 이후 포스팅부터는 실제적으로 어떻게 1:N 스트리밍을 구현했는지 코드와 함께 설명하도록 하겠습니다. 처음 WebRTC를 접하시는 분들이 많이 읽으실 수 있을 것 같아서 다양한 Example 코드를 읽으면서 제가 공부한 내용과 고민했던 부분, 제 나름의 고민의 답을 같이 적어보겠습니다.
Signaling을 다시 말하자면 WebRTC를 위한 P2P(Peer to Peer) 연결 과정입니다.
이를 위해 Signaling Server와 Peer가 통신을 해야합니다. 이것은 클라이언트 - 서버 통신을 생각하시면 됩니다. Signaling을 통해서 P2P 연결이 이루어지고 나면 그 때는 Peer끼리 알아서 통신을 하게됩니다. 그 연결까지는 Peer가 Signaling Server를 통해서 상대 Peer와 SDP와 Ice Candidate를 교환하게 됩니다.
일반적인 웹 서비스에서 클라이언트 - 서버는 HTTP Request, Response로 설계하게 됩니다. 요청이 있어야 응답이 있죠. 그런데 이 방법은 Signaling에 적용하기에는 문제가 있습니다. Peer와 Signaling Server가 순서없이 데이터를 주고 받기 때문입니다. 물론 SDP는 순서가 있지만 Ice Candidate과정에서는 누가 응답을 보내고 요청을 받는 순서가 정해지기 어렵고 잦은 데이터 교환 과정에서 3-handshake,4-handshake가 일종의 비용이 되기 때문입니다. Ice Candidate가 이루어지는 것을 추후 log로 찍어보시면 이해가 되실 겁니다.
그래서 시그널링을 WebSocket을 통해서 구현하게 됩니다. WebSocket으로 데이터를 주고 받고 각 피어는 EventHandler를 통해서 Signaling을 관리합니다.
WebSocket 여기서 웹 소켓이 무엇인지 한 번 살펴보세요!!
자 그러면 WebSocket을 이용해서 통신을 구현해보기 전에 우리가 알아야할게 있습니다. WebSocket은 누구나 쓸 수 있지만 목적에 따라 편하게 쓰기 쉬운 라이브러리가 있습니다. 보통 백엔드가 NodeJS일 때는 SocketIO를 사용하고 Spring 환경에서는 STOMP를 사용합니다. 여기서는 STOMP를 사용합니다.
STOMP까지 설명하는 것은 논지에서 벗어난 것 같아서 생략합니다. 사용할 때 겪었던 어려움만 말씀드리고 자세하게는 말씀드리지 않겠습니다. 공식문서를 찾아보세요.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker(new String[]{"/topic","/busker","/audience"}); // sub
config.setApplicationDestinationPrefixes(new String[]{"/app"});
}
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(new String[]{"/api/chat"}).setAllowedOriginPatterns("*");
registry.addEndpoint(new String[]{"/api/signal"})
.setAllowedOriginPatterns("*");
}
}
Java Stomp를 사용할 때 3가지를 유의해야 합니다.
1. config.enableSimpleBroker(new String[]{"/topic","/busker","/audience"}); // sub
2. config.setApplicationDestinationPrefixes(new String[]{"/app"}); // pub
3. registry.addEndpoint(new String[]{"/api/signal"})
.setAllowedOriginPatterns("*");
1은 STOMP의 메세지를 구독하는 경우에 앞에 붙입니다.
2는 STOMP에 메세지를 보낼 때 사용합니다.
3은 STOMP WebSocket을 연결할 때 목적이나 클라이언트를 식별하기 위해 사용합니다.
const Streaming = ({ isStreaming }) => {
const pcRef = useRef(new RTCPeerConnection(PCConfig));
const clientRef = useRef(
new StompJS.Client({
brokerURL: `wss://이건 비밀이에요./api/signal`,
})
);
const pc = pcRef.current;
const client = clientRef.current; //useRef를 사용합니다.
brokerURL에서 /api/signal
을 붙였는데 EndPoint에 대응하는 Connection을 사용하시면 됩니다. 이 소켓은 Signaling을 위한 소켓입니다.
public void buskerSendIceCandidate(String userId, HashMap<String, Object> iceCandidate){
simpMessagingTemplate.convertAndSend(
"/busker/" + userId + "/iceCandidate", iceCandidate
);
}
client.subscribe(`/busker/${userId}/iceCandidate`, (res) => {
const iceResponse = JSON.parse(res.body);
if (iceResponse.id === "iceCandidate") {
console.log(koreaTime + " server send ice \n" + iceResponse.candidate.candidate)
const icecandidate = new RTCIceCandidate(iceResponse.candidate)
pc.addIceCandidate(icecandidate)
.then()
}
1의 prefix busker
로 구독 신청해놨습니다. 자바에서 보낼 때나 JS에서 받을 때 subcribe의 prefix를 유의해야합니다. prefix의 뒤로 특정 대상을 식별하지만 prefix가 없다면 애시당초 메세지를 읽을 수 없을겁니다.
@RestController
@RequiredArgsConstructor
public class SignalController {
private final Logger log = LoggerFactory.getLogger(SignalController.class);
private final SimpMessagingTemplate simpMessagingTemplate;
private final BuskingManagingService buskingManagingService;
@MessageMapping("/api/busker")
public void listenTestStomp(@Payload String message) {
HashMap<String, String> map = new HashMap<>();
map.put("test", "test");
simpMessagingTemplate.convertAndSend("/busker", map);
return;
}
client.publish({
destination: `/app/api/busker/${userId}/offer`,
body: JSON.stringify({
userId,
offer,
})
})
JAVA에서 Controller가 JS가 보낸 메세지의 destination을 통해 매핑합니다.
WebSocket 연결이 끝났다면 축하합니다. WebRTC의 10분 능선 중 1분 능선을 건너셨습니다. 나머지 9분 능선 중 8이 이 Peer Connection입니다. (1은 종료와 마무리)
내용이 너무 많아서 다음 포스팅으로 넘기겠습니다.