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;
  }

테스트 및 문제 해결

기본 동작 테스트

  1. TURN 서버 상태 확인
  2. sudo systemctl status coturn
  3. 시그널링 서버 로그 확인
  4. tail -f /var/log/spring-boot.log
  5. 브라우저 콘솔에서 확인할 사항
  • WebSocket 연결 상태
  • ICE Candidate 수집 상태
  • Peer Connection 상태

문제 해결

WebRTC 미지원 브라우저

if (!support.webRTC || !support.getUserMedia) {
  throw new Error('Browser not supported');
}

TURN 서버 연결 실패

  1. 방화벽 설정 확인
  2. TURN 서버 로그 확인
  3. ICE 서버 설정 확인
  4. 🔒 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

보안 관련 체크리스트

필수 확인 사항

  1. HTTPS 프로토콜 사용 여부
  2. SSL 인증서 유효성
  3. WebSocket 보안 연결 (WSS)
  4. 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');

문제 해결 가이드

일반적인 에러 메시지와 해결방법

  1. NotAllowedError: Permission denied
  2. 원인: HTTP 환경에서 getUserMedia 호출 해결: HTTPS 프로토콜 사용
  3. SecurityError: The operation is insecure
  4. 원인: 보안되지 않은 컨텍스트 해결: 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);
  }
};

정리

핵심 포인트

  1. WebRTC의 getUserMedia()는 보안상의 이유로 HTTPS가 필수
  2. 개발 시에는 localhost 예외 활용
  3. 프로덕션 배포 시에는 반드시 SSL/TLS 인증서 설치

권장 사항

  • 개발 초기부터 HTTPS 환경 구성
  • SSL 인증서는 신뢰할 수 있는 인증 기관에서 발급
  • 모든 연결(WebSocket, TURN 서버 등)에 보안 프로토콜 적용

주의 사항

  • 자체 서명 인증서는 개발 용도로만 사용
  • 프로덕션 환경에서는 반드시 공인 인증서 사용
  • 모든 외부 연결에 대해 보안 프로토콜 적용 필요

이러한 보안 요구사항들은 사용자의 개인정보를 보호하고, 안전한 WebRTC 서비스를 제공하기 위해 꼭 필요한 요소들입니다.


참고 자료

🔑 주요 포인트

  • WebRTC는 P2P 연결이 기본이지만, NAT 통과를 위해 TURN 서버가 필요
  • 시그널링 서버는 초기 연결 설정에만 사용
  • 미디어 스트림은 최대한 P2P로 전송되도록 구현
  • 브라우저 호환성 체크는 필수

이 글이 WebRTC를 이용한 화상 채팅 구현에 도움이 되길 바랍니다.

반응형