WebRTC(Web Real-Time Communication) 구현하기

Woody·2024년 8월 26일
0

react

목록 보기
4/6

WebRTC(Web Real-Time Communication)는 웹 브라우저 간에 플러그인 없이 실시간으로 오디오, 비디오, 데이터를 교환할 수 있도록 설계된 오픈 프레임워크입니다.

WebRTC는 피어 투 피어(P2P) 방식으로 동작하며, 직접적인 브라우저 간 통신을 가능하게 합니다.

장단점

장점

  1. 플러그인 없이 실시간 통신 가능: WebRTC는 웹 표준 기술로, 별도의 플러그인 설치 없이 브라우저에서 직접 실시간 통신을 구현할 수 있습니다.
  2. 낮은 지연율: P2P 방식으로 동작하므로 중간 서버를 거치지 않아 지연율이 낮습니다.
  3. 다양한 응용 분야: 화상 회의, 원격 교육, 온라인 게임 등 다양한 분야에서 활용 가능합니다.

단점

  1. 브라우저 호환성: 일부 오래된 브라우저에서는 WebRTC를 지원하지 않을 수 있습니다. (IE 의 경우 지원하지 않음)
  2. 복잡한 시그널링 과정: WebRTC는 P2P 연결을 위한 시그널링 과정이 필요한데, 이를 직접 구현해야 합니다.
  3. 보안 이슈: WebRTC는 사용자의 IP 주소를 노출할 수 있어 보안에 취약할 수 있습니다. -> 이를 위해 STUN/TURN 서버를 사용

webRTC NAT, ICE, STUN, TURN 이란?

WebRTC는 브라우저 간 실시간 통신을 가능하게 하는 기술입니다.

하지만 대부분의 디바이스는 NAT 환경에 있어 직접 통신이 어렵습니다.

이를 해결하기 위해 WebRTC는 ICE, STUN, TURN 등의 프로토콜을 사용합니다.

NAT(Network Address Translation)

우리가 통상적인 네트워크에서 데이터를 받기 위해서 필요한건 공인 IP 입니다.

NAT는 사설(Private) IP를 공인(Public) IP로 바꿔주는 기술입니다.

NAT 를 통해 사설 IP 를 공인 IP 로 변경하는데 항상 IP 가 변경됩니다.

현대 네트워크에서는 NAT 와 방화벽이 필수인데 이는 WebRTC의 연결에 문제가 됩니다.

그렇다면 우리는 어떻게 WebRTC 를 사용할까요?

이를 위해서 사용되는것이 STUN , TURN 서버입니다.

ICE(Interactive Connectivity Establishment)

ICE는 NAT 환경에서 가장 효과적인 방법으로 통신할 수 있게 해주는 프레임워크입니다.

즉, 두 단말이 서로 통신 할 수 있는 최적의 경로를 찾게 도와줍니다.

ICE 를 사용하지 않으면 단말들의 각자의 환경이 다르기 때문에 A - B 로 연결되는 과정에서 문제가 되는 경우가 많습니다.

이에 ICE 프로세스를 통해서 두개가 연결될 수 있도록 도와주는 것입니다.

ICE 는 혼자 작동하지 않으며, STURN 과 TRUN 서버를 사용해야 합니다.

STUN(Session Traversal Utilities for NAT)

STUN 서버는 디바이스의 공인 IP와 포트를 알려줍니다.

이를 통해 NAT 뒤에 있는 디바이스의 위치를 파악할 수 있습니다.

하지만 STUN은 제한된 NAT(Symmetric NAT) 환경에서는 동작하지 않습니다.

-> 두 단말이 같은 NAT 환경에 있거나, 보안정책이 엄격한 상황 대해서 문제가 됩니다.

TURN(Traversal Using Relays around NAT)

TURN 서버는 STUN으로 직접 연결이 불가능한 경우, 데이터를 중계해줍니다.

모든 트래픽이 TURN 서버를 경유하기에 대역폭 소모가 크다는 단점이 있습니다.

-> 이에 ICE Candidate 과정에서 최후의 수단으로 사용해야 한다

ICE Candidate

다양한 후보군(local, reflexive, relay)을 수집하여 가장 적합한 경로를 선택하는 과정을 Cnadidate Gathering 이라고 부릅니다.

동작원리

동작 원리

  1. 각 피어는 STUN/TURN 서버를 통해 후보군(local, reflexive, relay)을 수집합니다.
  2. 수집된 후보군을 상대 피어에 전달합니다. (Signaling)
  3. ICE는 연결 가능한 모든 조합을 시도하여 최적의 경로를 찾습니다.
  4. 직접 연결(P2P)이 불가능하면 TURN을 통한 중계 연결을 시도합니다.

이를 통해 우리는 복잡한 네트워크 환경을 신경쓰지 않고 구현이 가능하다.

구현

WebRTC 의 주요 컴포넌트:

  1. getUserMedia: 사용자의 카메라와 마이크에 접근하여 미디어 스트림을 가져옵니다.
  2. RTCPeerConnection: 피어 간의 연결을 설정하고 오디오, 비디오, 데이터 채널을 관리합니다.
  3. RTCDataChannel: 피어 간에 임의의 데이터를 교환할 수 있는 채널을 제공합니다.

1. 미디어 장치에 접근하여 트랙 가져오기

const getMediaTracks = async (
  media: 'user' | 'display',
  constraints: MediaStreamConstraints
): Promise<MediaStreamTrack[]> => {
  try {
    const stream =
      media === 'user'
        ? await navigator.mediaDevices.getUserMedia(constraints)
        : await navigator.mediaDevices.getDisplayMedia({
            ...constraints,
          });
    return stream.getTracks();
  } catch (e) {
    console.warn('Error accessing media devices:', e);
    return [];
  }
};

getMediaTracks 함수는 사용자의 미디어 장치(카메라, 마이크 또는 화면 공유)에 접근하여 미디어 트랙을 가져옵니다. getUserMedia와 getDisplayMedia API를 사용하여 사용자 미디어와 화면 공유 스트림을 가져올 수 있습니다.

2. PeerConnection에 트랜시버 설정하기

const setupTransceivers = async (
  pc: RTCPeerConnection,
  media: string
): Promise<MediaStreamTrack[]> => {
  const localTracks: MediaStreamTrack[] = [];

  // 소스 미디어 설정 (카메라 또는 화면 공유)
  const sourceMedia = media.includes('camera') ? 'user' : 'display';
  const constraints = {
    video: sourceMedia === 'user',
    audio: sourceMedia === 'user' && media.includes('microphone'),
  };

  // 미디어 트랙 가져오기
  const sourceTracks = await getMediaTracks(sourceMedia, constraints);

  // 송신 전용 트랜시버 추가
  sourceTracks.forEach((track) => {
    const transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
    // ... (코덱 및 대역폭 설정)
    if (track.kind === 'video') localTracks.push(track);
  });

  // 수신 전용 트랜시버 추가
  ['video', 'audio'].forEach((kind) => {
    if (media.includes(kind)) {
      pc.addTransceiver(kind, { direction: 'recvonly' });
    }
  });

  return localTracks;
};

setupTransceivers 함수는 RTCPeerConnection에 송신 및 수신 전용 트랜시버를 설정합니다. 송신 전용 트랜시버에는 사용자의 미디어 트랙을 추가하고, 코덱 및 대역폭을 설정합니다. 수신 전용 트랜시버는 상대방의 미디어를 수신하기 위해 추가됩니다.

3. PeerConnection 생성 및 트랜시버 설정

const createPeerConnection = async (
  media: string
): Promise<RTCPeerConnection> => {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      // ... (STUN 서버 목록)
    ],
  });
  await setupTransceivers(pc, media);
  return pc;
};

createPeerConnection 함수는 새로운 RTCPeerConnection을 생성하고, STUN 서버를 설정합니다. 그런 다음 setupTransceivers 함수를 호출하여 트랜시버를 설정합니다.

4. WebRTC 연결 설정

const setupWebRTCConnection = (pc: RTCPeerConnection, ws: WebSocket) => {
  // ICE 후보가 발견되면 WebSocket을 통해 상대에게 전송
  pc.addEventListener('icecandidate', (ev) => {
    if (ev.candidate) {
      ws.send(
        JSON.stringify({
          type: 'webrtc/candidate',
          value: ev.candidate.candidate,
        })
      );
    }
  });

  // 로컬 SDP 오퍼 생성 및 WebSocket을 통해 전송
  pc.createOffer()
    .then((offer) => pc.setLocalDescription(offer))
    .then(() => {
      ws.send(
        JSON.stringify({
          type: 'webrtc/offer',
          value: pc.localDescription!.sdp,
        })
      );
    });
};

setupWebRTCConnection 함수는 WebRTC 연결을 설정합니다. ICE 후보가 발견되면 WebSocket을 통해 상대방에게 전송하고, 로컬 SDP 오퍼를 생성하여 상대방에게 전송합니다.

5. WebSocket 메시지 처리

const handleWebSocketMessage = (ev: MessageEvent, pc: RTCPeerConnection) => {
  const msg = JSON.parse(ev.data as string);
  if (msg.type === 'webrtc/candidate') {
    // 수신된 ICE 후보 추가
    pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
  } else if (msg.type === 'webrtc/answer') {
    // 수신된 SDP 답변 설정
    pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
  }
};

handleWebSocketMessage 함수는 WebSocket을 통해 수신된 메시지를 처리합니다. 상대방으로부터 ICE 후보나 SDP 답변을 받으면 RTCPeerConnection에 추가합니다.

6. WebRTC 및 WebSocket 연결 설정

const connect = async (
  media: string,
  videoRef: RefObject<HTMLVideoElement>,
  wsURL: string
) => {
  const pc = await createPeerConnection(media);
  const ws = new WebSocket(wsURL);

  // WebSocket 이벤트 리스너 설정
  ws.addEventListener('open', () => setupWebRTCConnection(pc, ws));
  ws.addEventListener('message', (ev) => handleWebSocketMessage(ev, pc));
  // ... (에러 처리)

  // 수신된 트랙을 비디오 요소에 연결
  pc.ontrack = (event: RTCTrackEvent) => {
    const [stream] = event.streams;
    if (videoRef.current && stream) {
      const videoElement = videoRef.current;
      videoElement.srcObject = stream;
    }
  };

  return { pc, ws };
};

connect 함수는 WebRTC와 WebSocket 연결을 설정하는 역할을 합니다. RTCPeerConnection을 생성하고, WebSocket을 연결한 뒤, 각각의 이벤트 리스너를 설정합니다. 상대방으로부터 수신된 미디어 트랙은 비디오 요소에 연결됩니다.

7. WebRTCVideo 컴포넌트

const WebRTCVideo = ({ wsURL }: { wsURL: string }) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const pcRef = useRef<RTCPeerConnection | null>(null);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    // camera, microphone, speaker, display, video, audio
    const media = 'video+audio';
    if (videoRef.current) {
      connect(media, videoRef, wsURL).then(({ pc, ws }) => {
        pcRef.current = pc;
        wsRef.current = ws;
      });
    }

    // 컴포넌트가 언마운트될 때 WebSocket 및 PeerConnection 닫기
    return () => {
      if (wsRef.current) wsRef.current.close();
      if (pcRef.current) pcRef.current.close();
    };
  }, [wsURL]);

  return (
    <video
      style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      ref={videoRef}
      autoPlay
      controls
      muted
    />
  );
};

WebRTCVideo 컴포넌트는 wsURL을 prop으로 받아 WebRTC 연결을 설정하고, 수신된 미디어 스트림을 비디오 요소에 연결합니다.

useEffect 훅을 사용하여 컴포넌트가 마운트될 때 connect 함수를 호출하고, 언마운트될 때 WebSocket과 RTCPeerConnection을 정리합니다.

전체코드

'use client';

import { RefObject, useEffect, useRef } from 'react';

// 사용자의 미디어 장치(카메라, 마이크 또는 화면 공유)에 접근하여 트랙을 가져오는 함수
const getMediaTracks = async (
  media: 'user' | 'display',
  constraints: MediaStreamConstraints
): Promise<MediaStreamTrack[]> => {
  try {
    const stream =
      media === 'user'
        ? await navigator.mediaDevices.getUserMedia(constraints)
        : await navigator.mediaDevices.getDisplayMedia({
            ...constraints,
          });
    return stream.getTracks();
  } catch (e) {
    console.warn('Error accessing media devices:', e); // eslint-disable-line no-console
    return [];
  }
};

// PeerConnection에 트랜시버(transceiver)를 설정하는 함수
const setupTransceivers = async (
  pc: RTCPeerConnection,
  media: string
): Promise<MediaStreamTrack[]> => {
  const localTracks: MediaStreamTrack[] = [];

  // 소스 미디어 설정 (카메라 또는 화면 공유)
  const sourceMedia = media.includes('camera') ? 'user' : 'display';
  const constraints = {
    video: sourceMedia === 'user',
    audio: sourceMedia === 'user' && media.includes('microphone'),
  };

  // 미디어 트랙 가져오기
  const sourceTracks = await getMediaTracks(sourceMedia, constraints);

  // 송신 전용 트랜시버 추가
  sourceTracks.forEach((track) => {
    const transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
    // 대역폭 제어
    if (track.kind === 'video') {
      const vp8Codec = {
        mimeType: 'video/VP8',
        clockRate: 90000,
      } as RTCRtpCodecCapability;
      const h264Codec = {
        mimeType: 'video/H264',
        clockRate: 90000,
      } as RTCRtpCodecCapability;
      transceiver.setCodecPreferences([vp8Codec, h264Codec]);

      const parameters = transceiver.sender.getParameters();
      parameters.encodings = [
        {
          rid: 'high',
          maxBitrate: 2500000, // 2.5 Mbps
        },
        {
          rid: 'medium',
          maxBitrate: 1200000, // 1.2 Mbps
        },
        {
          rid: 'low',
          maxBitrate: 500000, // 500 kbps
        },
      ];
      transceiver.sender.setParameters(parameters);
    }
    if (track.kind === 'video') localTracks.push(track);
  });

  // 수신 전용 트랜시버 추가
  ['video', 'audio'].forEach((kind) => {
    if (media.includes(kind)) {
      pc.addTransceiver(kind, { direction: 'recvonly' });
    }
  });

  return localTracks;
};

// PeerConnection을 생성하고 트랜시버를 설정하는 함수
const createPeerConnection = async (
  media: string
): Promise<RTCPeerConnection> => {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      { urls: 'stun:stun1.l.google.com:19302' },
      { urls: 'stun:stun2.l.google.com:19302' },
      { urls: 'stun:stun.stunprotocol.org:3478' },
      { urls: 'stun:stun.sipnet.net:3478' },
      { urls: 'stun:stun.ideasip.com:3478' },
    ],
  });
  await setupTransceivers(pc, media);
  return pc;
};

// WebRTC 연결 설정 함수
const setupWebRTCConnection = (pc: RTCPeerConnection, ws: WebSocket) => {
  // ICE 후보가 발견되면 WebSocket을 통해 상대에게 전송
  pc.addEventListener('icecandidate', (ev) => {
    if (ev.candidate) {
      ws.send(
        JSON.stringify({
          type: 'webrtc/candidate',
          value: ev.candidate.candidate,
        })
      );
    }
  });

  // 로컬 SDP 오퍼 생성 및 WebSocket을 통해 전송
  pc.createOffer()
    .then((offer) => pc.setLocalDescription(offer))
    .then(() => {
      ws.send(
        JSON.stringify({
          type: 'webrtc/offer',
          value: pc.localDescription!.sdp,
        })
      );
    });
};

// WebSocket 메시지 처리 함수
const handleWebSocketMessage = (ev: MessageEvent, pc: RTCPeerConnection) => {
  const msg = JSON.parse(ev.data as string);
  if (msg.type === 'webrtc/candidate') {
    // 수신된 ICE 후보 추가
    pc.addIceCandidate({ candidate: msg.value, sdpMid: '0' });
  } else if (msg.type === 'webrtc/answer') {
    // 수신된 SDP 답변 설정
    pc.setRemoteDescription({ type: 'answer', sdp: msg.value });
  }
};

// WebRTC 및 WebSocket 연결 설정 함수
const connect = async (
  media: string,
  videoRef: RefObject<HTMLVideoElement>,
  wsURL: string
) => {
  const pc = await createPeerConnection(media);
  const ws = new WebSocket(wsURL);

  // WebSocket 이벤트 리스너 설정
  ws.addEventListener('open', () => setupWebRTCConnection(pc, ws));
  ws.addEventListener('message', (ev) => handleWebSocketMessage(ev, pc));
  ws.addEventListener(
    'error',
    (event) => console.error('WebSocket connection error:', event) // eslint-disable-line no-console
  );
  ws.addEventListener(
    'close',
    (event) => console.warn('WebSocket connection closed:', event) // eslint-disable-line no-console
  );

  // 수신된 트랙을 비디오 요소에 연결
  pc.ontrack = (event: RTCTrackEvent) => {
    const [stream] = event.streams;
    if (videoRef.current && stream) {
      const videoElement = videoRef.current;
      videoElement.srcObject = stream;
    }
  };

  return { pc, ws };
};

const WebRTCVideo = ({ wsURL }: { wsURL: string }) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const pcRef = useRef<RTCPeerConnection | null>(null);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    // camera, microphone, speaker, display, video, audio
    const media = 'video+audio';
    if (videoRef.current) {
      connect(media, videoRef, wsURL).then(({ pc, ws }) => {
        pcRef.current = pc;
        wsRef.current = ws;
      });
    }

    // 컴포넌트가 언마운트될 때 WebSocket 및 PeerConnection 닫기
    return () => {
      if (wsRef.current) wsRef.current.close();
      if (pcRef.current) pcRef.current.close();
    };
  }, [wsURL]);

  return (
    <video
      style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      ref={videoRef}
      autoPlay
      controls
      muted
    />
  );
};

export default WebRTCVideo;

추가사항

폐쇄망을 위한 리눅스에서 STUN 서버 구축하기

폐쇄망 환경에서 WebRTC를 사용할 때, 외부 STUN 서버에 접근할 수 없는 경우가 있습니다.

이때는 직접 STUN 서버를 구축하는 것이 해결책이 될 수 있습니다.

오픈소스 STUN 서버 구현체로는 coturn, restund 등이 있습니다. 여기서는 coturn을 사용하겠습니다. coturn은 STUN과 TURN 기능을 모두 제공하는 통합 솔루션입니다.

서버 준비하기

우선 STUN 서버로 사용할 리눅스 서버를 준비합니다. 공인 IP를 가진 서버여야 하며, STUN에서 사용할 포트(기본 3478)를 개방해야 합니다.

coturn 설치하기

서버에 SSH로 접속한 뒤, 다음 명령어로 coturn을 설치합니다. (Ubuntu 기준)

sudo apt-get update
sudo apt-get install coturn

설정 파일 수정하기

/etc/default/coturn 파일을 열어 다음 옵션을 수정합니다.

TURNSERVER_ENABLED=1

/etc/turnserver.conf 파일을 열어 다음 옵션들을 추가합니다.

listening-ip=<서버 IP>
listening-port=3478
realm=your.domain.com
min-port=10000
max-port=20000
fingerprint
lt-cred-mech
user=<사용자명>:<비밀번호>

서버 재시작하기

설정을 마쳤으면 coturn 서비스를 재시작합니다.

sudo systemctl restart coturn

STUN 서버 테스트하기

서버가 정상적으로 동작하는지 테스트합니다. STUN 클라이언트 도구인 stunclient를 사용할 수 있습니다.

정상적으로 동작한다면 NAT type과 공인 IP, 포트 정보를 반환합니다.

이제 WebRTC 애플리케이션에서 방금 만든 STUN 서버를 사용할 수 있습니다. iceServers 설정에 STUN 서버의 URL을 추가하면 됩니다.

iceServers: [
  {
    urls: "stun:your.domain.com:3478",
    username: "<사용자명>",
    credential: "<비밀번호>",
  },
]

폐쇄망에서도 이렇게 자체 STUN 서버를 구축함으로써 WebRTC의 NAT 트래버설 기능을 문제없이 사용할 수 있습니다.

profile
프론트엔드 개발자로 살아가기

0개의 댓글