๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ

์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ

๐ŸŽฅ WebRTC๋ฅผ ์ด์šฉํ•œ ํ™”์ƒ ์ฑ„ํŒ… ๊ตฌํ˜„ํ•˜๊ธฐ(SpringBoot + React)

๐Ÿ“ ๋ชฉ์ฐจ

  1. ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ
  2. TURN/STUN ์„œ๋ฒ„ ๊ตฌ์ถ•
  3. Spring Boot ์‹œ๊ทธ๋„๋ง ์„œ๋ฒ„ ๊ตฌํ˜„
  4. React ํด๋ผ์ด์–ธํŠธ ๊ตฌํ˜„
  5. ํ…Œ์ŠคํŠธ ๋ฐ ๋ฌธ์ œ ํ•ด๊ฒฐ

๐ŸŽฏ ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ

WebRTC(Web Real-Time Communication)๋ฅผ ์ด์šฉํ•˜์—ฌ ๋ธŒ๋ผ์šฐ์ € ๊ฐ„ ์‹ค์‹œ๊ฐ„ ํ™”์ƒ ์ฑ„ํŒ…์„ ๊ตฌํ˜„ํ•˜๋Š” ํ”„๋กœ์ ํŠธ์ž…๋‹ˆ๋‹ค.

๐Ÿ› ๏ธ ์‚ฌ์šฉ ๊ธฐ์ˆ 

  • 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๋ฅผ ์ด์šฉํ•œ ํ™”์ƒ ์ฑ„ํŒ… ๊ตฌํ˜„์— ๋„์›€์ด ๋˜๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค! ๐Ÿ‘‹