[Spring] WebRTC 와 Spring websocket 을 이용하여 구글 밋 처럼 카메라 스트리밍 하기
WebRTC 와 Spring websocket 을 이용하여 구글 밋 처럼 카메라 스트리밍 하기
구글 밋 처럼 웹 브라우저에서 웹 API를 이용하여 카메라 영상을 받아와 실시간으로 스트리밍 하는 서비스를 만들어보고자 하였다.
구현한 코드를 공유해본다.
WebRTC
WebRTC에 대해서 간단하게 설명을 해보자면 웹 브라우저 간에 플러그인의 도움 없이 서로 통신할 수 있도록 설계된 API이다.
WebRTC 코드 정리 개인적으로 이 블로그에 그려진 그림 자료가 FLOW 를 깔끔하게 그려둬서 쉽게 이해할 수 있었다.
이 사이에서 spring은, 두 서버를 직접 연결할 수 있도록 돕는 매개체 역할을 한다고 이해하면 된다.
프론트엔드 : react (next.js)
프론트엔드는 next.js 를 이용하여 구현하였다.
streamer.tsx
"use client";
import React, { useEffect, useRef, useState } from "react";
const Streamer = () => {
const [muted, setMuted] = useState(true);
const localVideoRef = useRef<HTMLVideoElement>(null);
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const socket = useRef<WebSocket | null>(null);
useEffect(() => {
const initWebSocket = () => {
const ws = new WebSocket("ws://localhost:8080/ws");
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleSignal(data);
};
ws.onerror = (error) => {
console.error("WebSocket Error:", error);
};
ws.onclose = () => {
console.log("WebSocket closed");
};
socket.current = ws;
};
const initWebRTC = async () => {
try {
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
peerConnectionRef.current = pc;
pc.onicecandidate = (event) => {
if (event.candidate && socket.current) {
socket.current.send(
JSON.stringify({ type: "candidate", candidate: event.candidate })
);
}
};
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
if (socket.current) {
socket.current.send(
JSON.stringify({ type: "offer", sdp: offer.sdp })
);
}
} catch (error) {
console.error("WebRTC initialization failed:", error);
}
};
initWebSocket();
initWebRTC();
return () => {
// Clean up WebRTC
if (peerConnectionRef.current) {
peerConnectionRef.current.getSenders().forEach((sender) => {
sender.track?.stop();
});
peerConnectionRef.current.close();
}
// Clean up WebSocket
if (socket.current) {
socket.current.close();
}
};
}, []);
const handleSignal = async (data: any) => {
if (data.type === "answer") {
if (peerConnectionRef.current) {
await peerConnectionRef.current.setRemoteDescription({
type: "answer",
sdp: data.sdp,
});
}
} else if (data.type === "candidate") {
if (peerConnectionRef.current && data.candidate) {
await peerConnectionRef.current.addIceCandidate(data.candidate);
}
}
};
return (
<div>
<div>
<button onClick={() => setMuted(!muted)}>
toggle muted(current: {muted ? "muted" : "unmuted"})
</button>
</div>
<video ref={localVideoRef} autoPlay muted={muted} playsInline />;
</div>
);
};
export default Streamer;
viewer.tsx
"use client";
import React, { useRef, useEffect, useState } from "react";
const Viewer = () => {
const [muted, setMuted] = useState(true);
const pc = useRef<RTCPeerConnection | null>(null);
const socket = useRef<WebSocket | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
// WebSocket 초기화
socket.current = new WebSocket("ws://localhost:8080/ws");
socket.current.onmessage = async (event) => {
const data = JSON.parse(event.data);
if (data.type === "offer") {
pc.current = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
pc.current.onicecandidate = (event) => {
if (event.candidate && socket.current) {
socket.current.send(
JSON.stringify({ type: "candidate", candidate: event.candidate })
);
}
};
pc.current.ontrack = (event) => {
if (remoteVideoRef.current && event.streams[0]) {
remoteVideoRef.current.srcObject = event.streams[0];
}
};
try {
await pc.current.setRemoteDescription(
new RTCSessionDescription(data)
);
const answer = await pc.current.createAnswer();
await pc.current.setLocalDescription(answer);
if (socket.current) {
socket.current.send(JSON.stringify(answer));
}
} catch (error) {
console.error("Error handling offer:", error);
}
} else if (data.type === "candidate") {
try {
await pc.current?.addIceCandidate(
new RTCIceCandidate(data.candidate)
);
} catch (error) {
console.error("Error adding ICE candidate:", error);
}
}
};
return () => {
socket.current?.close();
pc.current?.close();
};
}, []);
return (
<div>
<div>
<button onClick={() => setMuted(!muted)}>
toggle muted(current: {muted ? "muted" : "unmuted"})
</button>
</div>
<video ref={remoteVideoRef} autoPlay muted={muted} playsInline></video>
</div>
);
};
export default Viewer;
Spring
Spring WebSocket 서버는 스트리머와 뷰어 간의 시그널링 메시지(offer, answer, ICE candidates)를 전달하는 중개 역할을 수행한다.
WebSocketConfig.java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new SignalHandler(), "/ws").setAllowedOrigins("*");
}
}
SignalHandler.java
@Slf4j
public class SignalHandler extends TextWebSocketHandler {
private final CopyOnWriteArrayList<WebSocketSession> sessions = new CopyOnWriteArrayList<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
log.debug("session added: {}", sessions);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.debug("message: {}", message);
// Broadcast the received message to all connected clients
for (WebSocketSession s : sessions) {
if (s.isOpen() && !s.getId().equals(session.getId())) {
log.debug("sendMessage to {}", s.getId());
s.sendMessage(message);
}
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
sessions.remove(session);
}
}
결과물
/
로 접근시 스트리머(카메라 영상 정보를 제공하는 주체)로, /viewer
로 접근시 뷰어로 동작한다.
왼쪽은 /
로 접근하였기 때문에 스트리머이다. 따라서 브라우저 주소창 옆에 동영상 아이콘이 나오고 있는걸 볼 수 있다.
음성도 전달된다.
이슈
onicecandidate 가 트리거 되지 않는 이슈
setLocalDescription 전에 addTrack을 먼저 진행해야 한다. 그렇지 않으면 onicecandidate 가 트리거 되지 않는다.
https://stackoverflow.com/a/58430313 를 참고하여 해결하였다.
동영상이 재생되지 않는 이슈
알 수 없는 이유로 동영상 재생이 계속 되다 안되다 하는 이슈가 있었다.
web rtc의 문제는 아니였고 브라우저에서 동영상 자동재생을 차단해서 발생된 일이였다. 콘솔에 에러도 남지 않아서 이 문제로 한참 헤맸다.
그래서 최초 실행시에는 동영상을 muted 로 처리하도록 하였다.
더 공부할 것
WebRTC에 대해서 많이 들어보았지만 직접 구현해보면서 더 알아볼 수 있었던 시간이였다. 이 구조는 1:1 으로만 가능하고 1:N 통신을 하고 싶다면 SFU 라는 키워드를 찾아봐야 한다고 한다. SFU에 대해서 좀 더 공부해봐야할 것 같다.