Springboot
[WebRTC] 실시간 화상채팅 구현하기: SpringBoot + React
Tae4an
2024. 11. 8. 16:11
반응형
WebRTC란 무엇인가?
WebRTC(Web Real-Time Communication)는 웹 브라우저와 모바일 애플리케이션에서 별도의 플러그인 없이 실시간 오디오, 비디오, 데이터 통신을 가능하게 하는 오픈소스 기술입니다.
WebRTC의 핵심 특징
- P2P 통신: 서버를 거치지 않고 브라우저 간 직접 연결
- 플러그인 불필요: 브라우저 내장 API로 바로 사용 가능
- 실시간 통신: 낮은 지연시간으로 음성/영상 통화 구현
- 크로스 플랫폼: 데스크톱, 모바일 브라우저에서 동일하게 작동
WebRTC가 활용되는 서비스들
- 화상회의: Zoom, Google Meet, Microsoft Teams
- 실시간 스트리밍: Twitch, YouTube Live
- 게임: 실시간 멀티플레이어 게임의 음성채팅
- 고객지원: 웹사이트의 실시간 상담 기능
사용 기술
- TURN/STUN 서버: Coturn
- 백엔드: Spring Boot
- 프론트엔드: React + TypeScript
- 통신: WebSocket, WebRTC
TURN/STUN 서버 구축
1️⃣ Coturn 설치
# 시스템 업데이트
sudo apt update
sudo apt upgrade
# Coturn 설치
sudo apt install coturn
2️⃣ Coturn 설정
# 서비스 활성화
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
# 설정 파일 작성
sudo bash -c 'cat > /etc/turnserver.conf << EOF
external-ip=YOUR_PUBLIC_IP
realm=YOUR_PUBLIC_IP
lt-cred-mech
user=turnuser:Turn2024!@#
EOF'
3️⃣ 방화벽 설정
sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
sudo ufw allow 49152:65535/udp
4️⃣ 서비스 시작
sudo systemctl start coturn
sudo systemctl enable coturn
Spring Boot 시그널링 서버 구현
1️⃣ 의존성 설정
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
2️⃣ WebSocket 설정
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new WebRTCSignalingHandler(), "/signal")
.setAllowedOrigins("*");
}
}
3️⃣ 시그널링 메시지 DTO
@Data
public class SignalMessage {
private String type;
private String targetSessionId;
private Object data;
private String roomId;
}
4️⃣ WebRTC 시그널링 핸들러
WebRTCSignalingHandler.java
@Slf4j
public class WebRTCSignalingHandler extends TextWebSocketHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Map<String, String> roomSessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) {
log.info("New WebSocket connection established: {}", session.getId());
}
@Override
public void handleTextMessage(WebSocketSession session, TextMessage message) {
try {
log.info("Received message from client {}: {}", session.getId(), message.getPayload());
SignalMessage signalMessage = objectMapper.readValue(message.getPayload(), SignalMessage.class);
String roomId = signalMessage.getRoomId();
log.info("Processing {} message for room: {}", signalMessage.getType(), roomId);
switch (signalMessage.getType()) {
case "join":
handleJoinMessage(session, roomId);
break;
case "offer":
log.info("Received offer from: {}", session.getId());
broadcastToRoom(session, message, roomId);
break;
case "answer":
log.info("Received answer from: {}", session.getId());
broadcastToRoom(session, message, roomId);
break;
case "ice-candidate":
log.info("Received ICE candidate from: {}", session.getId());
broadcastToRoom(session, message, roomId);
break;
default:
log.warn("Unknown message type: {}", signalMessage.getType());
}
} catch (Exception e) {
log.error("Error handling message: ", e);
}
}
private void broadcastToRoom(WebSocketSession sender, TextMessage message, String roomId) {
sessions.forEach((sessionId, webSocketSession) -> {
if (!sender.getId().equals(sessionId) &&
roomId.equals(roomSessions.get(sessionId))) {
try {
log.info("Broadcasting message to session {} in room {}", sessionId, roomId);
webSocketSession.sendMessage(message);
} catch (IOException e) {
log.error("Error sending message to session {}: {}", sessionId, e.getMessage());
}
}
});
}
private void handleJoinMessage(WebSocketSession session, String roomId) {
sessions.put(session.getId(), session);
roomSessions.put(session.getId(), roomId);
log.info("Client {} joined room: {}", session.getId(), roomId);
// 같은 방의 참가자 수 로깅
long roomParticipants = roomSessions.values()
.stream()
.filter(room -> room.equals(roomId))
.count();
log.info("Room {} now has {} participants", roomId, roomParticipants);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
String roomId = roomSessions.get(session.getId());
sessions.remove(session.getId());
roomSessions.remove(session.getId());
log.info("Client {} disconnected from room {}", session.getId(), roomId);
// 남은 참가자 수 로깅
if (roomId != null) {
long remainingParticipants = roomSessions.values()
.stream()
.filter(room -> room.equals(roomId))
.count();
log.info("Room {} now has {} participants remaining", roomId, remainingParticipants);
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
log.error("Transport error for session {}: {}", session.getId(), exception.getMessage());
}
}
React 클라이언트 구현
1️⃣ 프로젝트 구조
src/
├── components/
│ ├── WebRTCComponent.tsx
│ └── WebRTC.css
├── utils/
│ └── webrtc.ts
└── App.tsx
2️⃣ WebRTC 유틸리티
// webrtc.ts
export const checkWebRTCSupport = () => {
const support = {
webRTC: false,
getUserMedia: false,
mediaDevices: false,
peerConnection: false,
websocket: false,
};
try {
support.webRTC = !!window.RTCPeerConnection;
support.getUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
support.mediaDevices = !!navigator.mediaDevices;
support.peerConnection = !!(window.RTCPeerConnection || window.RTCPeerConnection || window.RTCPeerConnection);
support.websocket = !!window.WebSocket;
} catch (e) {
console.error('Error checking WebRTC support:', e);
}
return support;
};
3️⃣ WebRTC 컴포넌트
WebRTCComponent.tsx
// WebRTCComponent.tsx
import React, { useEffect, useRef, useState } from 'react';
import { checkWebRTCSupport } from '../utils/webrtc.ts';
import './WebRTC.css';
interface WebRTCComponentProps {
roomId: string;
}
const WebRTCComponent: React.FC<WebRTCComponentProps> = ({ roomId }) => {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isCallStarted, setIsCallStarted] = useState(false);
const [initError, setInitError] = useState<string | null>(null);
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const peerConnection = useRef<RTCPeerConnection | null>(null);
const websocket = useRef<WebSocket | null>(null);
const configuration: RTCConfiguration = {
iceServers: [
{
urls: 'turn:[YOUR_PUBLIC_IP]:3478',
username: 'turnuser',
credential: 'Turn2024!@#'
}
]
};
useEffect(() => {
const init = async () => {
try {
console.log('Checking WebRTC support...');
const support = checkWebRTCSupport();
if (!support.webRTC || !support.getUserMedia) {
throw new Error('Your browser does not support required WebRTC features');
}
console.log('Initializing WebRTC...');
// WebSocket 연결
websocket.current = new WebSocket('ws://[YOUR_WEBSOCKET_SERVER_URL]/signal');
websocket.current.onopen = () => {
console.log('WebSocket connected');
sendSignalingMessage({
type: 'join',
roomId
});
};
// 미디어 스트림 가져오기
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: true
});
console.log('Local media stream obtained');
} catch (mediaError) {
console.error('Media access error:', mediaError);
throw new Error('Unable to access camera and microphone');
}
setLocalStream(stream);
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
// WebRTC Peer Connection 초기화
peerConnection.current = new RTCPeerConnection(configuration);
console.log('PeerConnection created with config:', configuration);
// 로컬 스트림 추가
stream.getTracks().forEach(track => {
if (peerConnection.current) {
console.log('Adding track to peer connection:', track.kind);
peerConnection.current.addTrack(track, stream);
}
});
// 원격 스트림 처리
peerConnection.current.ontrack = (event) => {
console.log('Received remote track:', event.track.kind);
if (remoteVideoRef.current && event.streams[0]) {
console.log('Setting remote stream');
remoteVideoRef.current.srcObject = event.streams[0];
setRemoteStream(event.streams[0]);
}
};
// ICE 후보 처리
peerConnection.current.onicecandidate = (event) => {
if (event.candidate) {
console.log('Sending ICE candidate');
sendSignalingMessage({
type: 'ice-candidate',
data: event.candidate,
roomId
});
}
};
// 연결 상태 모니터링
peerConnection.current.onconnectionstatechange = () => {
console.log('Connection state changed:', peerConnection.current?.connectionState);
setIsConnected(peerConnection.current?.connectionState === 'connected');
};
peerConnection.current.oniceconnectionstatechange = () => {
console.log('ICE connection state:', peerConnection.current?.iceConnectionState);
};
// WebSocket 메시지 처리
websocket.current.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
console.log('Received message:', message.type);
switch (message.type) {
case 'offer':
await handleOffer(message.data);
break;
case 'answer':
await handleAnswer(message.data);
break;
case 'ice-candidate':
await handleIceCandidate(message.data);
break;
default:
console.log('Unknown message type:', message.type);
}
} catch (error) {
console.error('Error handling WebSocket message:', error);
}
};
websocket.current.onclose = () => {
console.log('WebSocket disconnected');
setIsConnected(false);
};
websocket.current.onerror = (error) => {
console.error('WebSocket error:', error);
setInitError('WebSocket connection failed');
};
} catch (error) {
console.error('Error initializing WebRTC:', error);
setInitError(error.message);
}
};
init();
// Cleanup
return () => {
console.log('Cleaning up...');
localStream?.getTracks().forEach(track => {
console.log('Stopping track:', track.kind);
track.stop();
});
if (peerConnection.current) {
console.log('Closing peer connection');
peerConnection.current.close();
}
if (websocket.current) {
console.log('Closing WebSocket');
websocket.current.close();
}
};
}, [roomId]);
const sendSignalingMessage = (message: any) => {
if (websocket.current?.readyState === WebSocket.OPEN) {
console.log('Sending message:', message.type);
websocket.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket is not open');
}
};
const handleOffer = async (offer: RTCSessionDescriptionInit) => {
try {
if (peerConnection.current) {
console.log('Setting remote description (offer)');
await peerConnection.current.setRemoteDescription(new RTCSessionDescription(offer));
console.log('Creating answer');
const answer = await peerConnection.current.createAnswer();
console.log('Setting local description (answer)');
await peerConnection.current.setLocalDescription(answer);
sendSignalingMessage({
type: 'answer',
data: answer,
roomId
});
}
} catch (error) {
console.error('Error handling offer:', error);
}
};
const handleAnswer = async (answer: RTCSessionDescriptionInit) => {
try {
if (peerConnection.current) {
console.log('Setting remote description (answer)');
await peerConnection.current.setRemoteDescription(new RTCSessionDescription(answer));
}
} catch (error) {
console.error('Error handling answer:', error);
}
};
const handleIceCandidate = async (candidate: RTCIceCandidateInit) => {
try {
if (peerConnection.current) {
console.log('Adding ICE candidate');
await peerConnection.current.addIceCandidate(new RTCIceCandidate(candidate));
}
} catch (error) {
console.error('Error handling ICE candidate:', error);
}
};
const startCall = async () => {
try {
if (peerConnection.current) {
console.log('Creating offer');
const offer = await peerConnection.current.createOffer();
console.log('Setting local description (offer)');
await peerConnection.current.setLocalDescription(offer);
sendSignalingMessage({
type: 'offer',
data: offer,
roomId
});
setIsCallStarted(true);
}
} catch (error) {
console.error('Error starting call:', error);
}
};
const endCall = () => {
try {
localStream?.getTracks().forEach(track => track.stop());
peerConnection.current?.close();
setIsCallStarted(false);
setIsConnected(false);
setRemoteStream(null);
console.log('Call ended');
} catch (error) {
console.error('Error ending call:', error);
}
};
if (initError) {
return (
<div className="webrtc-error">
<h2>WebRTC Error</h2>
<p>{initError}</p>
<p>Please use a modern browser with camera and microphone support.</p>
<p>Supported browsers:</p>
<ul>
<li>Google Chrome (recommended)</li>
<li>Mozilla Firefox</li>
<li>Microsoft Edge</li>
<li>Safari 11+</li>
</ul>
</div>
);
}
return (
<div className="webrtc-container">
<div className="video-grid">
<div className="video-wrapper">
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="video-stream"
/>
<div className="video-label">Local Stream</div>
</div>
<div className="video-wrapper">
<video
ref={remoteVideoRef}
autoPlay
playsInline
className="video-stream"
/>
<div className="video-label">Remote Stream</div>
</div>
</div>
<div className="controls">
{!isCallStarted ? (
<button
onClick={startCall}
className="control-button start-call"
disabled={!localStream}
>
Start Call
</button>
) : (
<button
onClick={endCall}
className="control-button end-call"
>
End Call
</button>
)}
</div>
<div className="connection-status">
Status: {isConnected ? 'Connected' : 'Disconnected'}
</div>
</div>
);
};
export default WebRTCComponent;
4️⃣ 스타일링
WebRTC.css
/* WebRTC.css */
.webrtc-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
width: 100%;
}
.video-wrapper {
position: relative;
background: #000;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 16/9;
}
.video-stream {
width: 100%;
height: 100%;
object-fit: cover;
background: #2c3e50;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 14px;
}
.controls {
display: flex;
gap: 10px;
margin-top: 20px;
}
.control-button {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
}
.start-call {
background-color: #2ecc71;
color: white;
}
.start-call:hover {
background-color: #27ae60;
}
.start-call:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.end-call {
background-color: #e74c3c;
color: white;
}
.end-call:hover {
background-color: #c0392b;
}
.connection-status {
margin-top: 10px;
padding: 5px 10px;
border-radius: 4px;
background: #f0f0f0;
font-size: 14px;
}
.webrtc-error {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
background-color: #fff3f3;
border: 1px solid #ffcdd2;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.webrtc-error h2 {
color: #d32f2f;
margin-bottom: 1rem;
}
.webrtc-error ul {
list-style-type: none;
padding-left: 0;
}
.webrtc-error li {
padding: 0.5rem 0;
color: #666;
}
.webrtc-error p {
margin: 0.5rem 0;
color: #333;
}
테스트 및 문제 해결
기본 동작 테스트
- TURN 서버 상태 확인
sudo systemctl status coturn
- 시그널링 서버 로그 확인
tail -f /var/log/spring-boot.log
- 브라우저 콘솔에서 확인할 사항
- WebSocket 연결 상태
- ICE Candidate 수집 상태
- Peer Connection 상태
문제 해결
WebRTC 미지원 브라우저
if (!support.webRTC || !support.getUserMedia) {
throw new Error('Browser not supported');
}
TURN 서버 연결 실패
- 방화벽 설정 확인
- TURN 서버 로그 확인
- ICE 서버 설정 확인
- 🔒 WebRTC와 보안: HTTPS 필요성
⚠️ HTTP에서 WebRTC 사용 시 제한사항
현대 브라우저들은 보안을 위해 민감한 기능들을 HTTPS 환경에서만 사용할 수 있도록 제한하고 있습니다. 그 중 하나가 getUserMedia()
API입니다.
🚫 HTTP 환경에서 발생하는 오류
// HTTP 환경에서 실행 시
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
// 이 코드는 실행되지 않습니다
})
.catch(error => {
// NotAllowedError: Permission denied
// 또는
// SecurityError: The operation is insecure
});
해결 방법
1️⃣ 개발 환경에서의 임시 해결책
localhost 예외 처리
localhost
또는127.0.0.1
에서는 HTTP로도 카메라 접근이 가능합니다.// 개발 서버 실행 npm start // 기본적으로 http://localhost:3000 으로 실행
2️⃣ 프로덕션 환경 해결책
A. SSL 인증서 설치
# Let's Encrypt 설치
sudo apt-get install certbot
# SSL 인증서 발급
sudo certbot certonly --standalone -d your-domain.com
# Nginx 설정 예시
sudo nano /etc/nginx/sites-available/default
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
location /signal {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
}
B. Spring Boot SSL 설정
# application.yml
server:
ssl:
key-store: classpath:keystore.p12
key-store-password: your-password
key-store-type: PKCS12
key-alias: your-alias
port: 8443
보안 관련 체크리스트
필수 확인 사항
- HTTPS 프로토콜 사용 여부
- SSL 인증서 유효성
- WebSocket 보안 연결 (WSS)
- TURN 서버 인증 설정
환경별 구성 방법
1️⃣ 개발 환경
// development 환경 설정
const wsUrl = process.env.NODE_ENV === 'development'
? 'ws://localhost:8080/signal'
: 'wss://your-domain.com/signal';
2️⃣ 프로덕션 환경
// WebRTCComponent.tsx
const websocket = new WebSocket('wss://your-domain.com/signal');
문제 해결 가이드
일반적인 에러 메시지와 해결방법
NotAllowedError: Permission denied
원인: HTTP 환경에서 getUserMedia 호출 해결: HTTPS 프로토콜 사용
SecurityError: The operation is insecure
원인: 보안되지 않은 컨텍스트 해결: SSL 인증서 설치 및 HTTPS 활성화
개발 환경 디버깅
const initWebRTC = async () => {
try {
// 프로토콜 체크
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
throw new Error('HTTPS is required for camera access');
}
// getUserMedia 호출
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// 성공적으로 스트림 획득
console.log('Camera access granted');
} catch (error) {
if (error.name === 'NotAllowedError') {
console.error('Camera access denied by user or HTTP protocol');
} else if (error.name === 'SecurityError') {
console.error('Security error: HTTPS required');
}
// 에러 처리
handleError(error);
}
};
정리
핵심 포인트
- WebRTC의
getUserMedia()
는 보안상의 이유로 HTTPS가 필수 - 개발 시에는 localhost 예외 활용
- 프로덕션 배포 시에는 반드시 SSL/TLS 인증서 설치
권장 사항
- 개발 초기부터 HTTPS 환경 구성
- SSL 인증서는 신뢰할 수 있는 인증 기관에서 발급
- 모든 연결(WebSocket, TURN 서버 등)에 보안 프로토콜 적용
주의 사항
- 자체 서명 인증서는 개발 용도로만 사용
- 프로덕션 환경에서는 반드시 공인 인증서 사용
- 모든 외부 연결에 대해 보안 프로토콜 적용 필요
이러한 보안 요구사항들은 사용자의 개인정보를 보호하고, 안전한 WebRTC 서비스를 제공하기 위해 꼭 필요한 요소들입니다.
참고 자료
🔑 주요 포인트
- WebRTC는 P2P 연결이 기본이지만, NAT 통과를 위해 TURN 서버가 필요
- 시그널링 서버는 초기 연결 설정에만 사용
- 미디어 스트림은 최대한 P2P로 전송되도록 구현
- 브라우저 호환성 체크는 필수
이 글이 WebRTC를 이용한 화상 채팅 구현에 도움이 되길 바랍니다.
반응형