본문 바로가기
Front-End

WebRTC, 화상통화 구현이 어려운 당신을 위한 (3)

by tccelestyn6248 2026. 3. 10.

저번 글에선 WebRTC의 WebRTC의 동작 원리와 한계점, 그리고 해결책 등을 정리했습니다.

이번 글에선 예제 코드와 함께 WebRTC 사용법(코드)을 알아봅시다.

 

이번글을 읽기 전에 이전 글을 읽고 오신다면 이해하기 더욱 편하기 때문에 읽고 오시는 것을 추천드립니다.

2026.01.12 - [Front-End] - WebRTC, 화상통화 구현이 어려운 당신을 위한 (2)
 

WebRTC, 화상통화 구현이 어려운 당신을 위한 (2)

저번 글에선 WebRTC의 간단한 정의, 동작조건, 통신 원리, 주요 개념과 용어 등을 정리했습니다.이번 글에선 WebRTC의 동작 원리와 한계점, 그리고 해결책을 알아봅시다. 이번글을 읽기 전에 이전 글

tccelestyn6248.tistory.com

 

그럼 바로 글 시작하겠습니다.

 

예제 코드

import React, { useRef, useState } from 'react';

function WebRTCApp() {
  const localVideoRef = useRef(null);
  const remoteVideoRef = useRef(null);
  const pc1 = useRef(new RTCPeerConnection());
  const pc2 = useRef(new RTCPeerConnection());
  const [connected, setConnected] = useState(false);

  const startCall = async () => {
    // 카메라/마이크 스트림 가져오기
    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    localVideoRef.current.srcObject = stream;

    // 스트림을 연결 객체(pc1)에 추가
    stream.getTracks().forEach(track => pc1.current.addTrack(track, stream));

    // ICE Candidate 설정 (네트워크 경로 찾기)
    pc1.current.onicecandidate = e => e.candidate && pc2.current.addIceCandidate(e.candidate);
    pc2.current.onicecandidate = e => e.candidate && pc1.current.addIceCandidate(e.candidate);

    // 상대방(pc2)이 트랙을 받으면 비디오 태그에 연결
    pc2.current.ontrack = e => {
      remoteVideoRef.current.srcObject = e.streams[0];
    };

    // Offer/Answer 협상 (SDP 교환)
    const offer = await pc1.current.createOffer();
    await pc1.current.setLocalDescription(offer);
    await pc2.current.setRemoteDescription(offer);

    const answer = await pc2.current.createAnswer();
    await pc2.current.setLocalDescription(answer);
    await pc1.current.setRemoteDescription(answer);

    setConnected(true);
  };

  return (
    <div style={{ textAlign: 'center', padding: '20px' }}>
      <h2>React WebRTC 1:1 시뮬레이션</h2>
      <div style={{ display: 'flex', justifyContent: 'center', gap: '20px' }}>
        <div>
          <p>내 화면 (Local)</p>
          <video ref={localVideoRef} autoPlay playsInline style={{ width: '300px', borderRadius: '10px', background: '#222' }} />
        </div>
        <div>
          <p>상대방 화면 (Remote)</p>
          <video ref={remoteVideoRef} autoPlay playsInline style={{ width: '300px', borderRadius: '10px', background: '#222' }} />
        </div>
      </div>
      <br />
      <button onClick={startCall} disabled={connected} style={{ padding: '10px 20px', fontSize: '16px', cursor: 'pointer' }}>
        {connected ? '연결 완료' : '화상 채팅 시작'}
      </button>
    </div>
  );
}

export default WebRTCApp;

 

위 코드는 간단한 WebRTC 예제 코드입니다.

이제 이 코드를 하나하나 천천히 뜯어가며 분석해 갑시다.

 

1. 카메라/마이크 스트림 가져오기

const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

이 코드는 브라우저에게 사용자의 카메라와 마이크 권한을 사용할 수 있도록 요청하는 코드입니다.

  • navigator.mediaDevices : 브라우저가 제공하는 도구함으로, 카메라/마이크/화면 공유 같은 하드웨어 장치에 접근할 수 있도록 도와줍니다.
  • . getUserMedia({ video: true, audio: true }) : 
    • video/audio : 무엇을 가져올지 결정하는 설정입니다. 둘 다 true면 비디오와 오디오를 모두 사용할 수 있습니다.
    • 권한 팝업 : 이 함수가 실행되면 브라우저 상단에 권한 허용 승인 버튼이 뜹니다. 이때 허용하면 비디오와 오디오를 사용할 수 있습니다. 
localVideoRef.current.srcObject = stream;

가져온 영상 데이터를 화면에 실제로 보여주는 작업입니다.

  • localVideoRef.current :
    • 리액트에서 <video ref={localVideoRef} />라고 선언한 실제 HTML 비디오 태그를 가리킵니다.
    • 리액트는 직접적으로 DOM을 건드리는 것을 권하지 않지만, 비디오 스트림 연결은 직접 데이터를 넣어줘야 하므로 useRef를 사용해 실제 엘리먼트에 접근합니다
  • . srcObject : 
    • 비디오 태그에 데이터를 넣는 통로입니다.

결과적으로 이 코드가 정상적으로 실행되는 순간 localVideoRef가 가리키는 화면에 카메라 화면이 실시간으로 나오게 됩니다.

2. 스트림을 연결 객체(pc1)에 추가

stream.getTracks().forEach(track => pc1.current.addTrack(track, stream))

이 코드는 내가 확보한 영상과 음성 데이터(stream)를 잘게 쪼개서 상대방에게 보내는 통로(RTCPeerConnection)에 하나씩 실어 보내는 아주 중요한 단계입니다.

  • stream.getTracks()
    • getTracks()는 이 스트림 안에 있는 모든 트랙을 꺼내서 배열(Array) 형태로 만들어줍니다.
    • 즉, [비디오 트랙, 오디오 트랙] 이런 리스트를 만드는 것입니다
  • . forEach(track =>...)
    • 이는 JS의 반목분입니다. 배열 안에 있는 트랙들을 꺼내어 명령을 수행합니다.
    • 첫 번째 - 비디오 트랙
    • 두 번째 - 오디오 트랙
  • pc1.current. addTrack(track, stream)
    • pc1.current
      • 리액트에서 생성한 연결 통로(RTCPeerConnection) 객체입니다.
    • .addTrack(track, stream)
      • 첫 번째 인자(track): 전송할 개별 데이터(영상 혹은 소리)입니다.
      • 두 번째 인자(stream): 이 트랙이 어떤 스트림에 속해 있는지 알려주는 '부모' 정보입니다.

전체적인 의미

코드 비유
stream 영상과 소리가 들어있는 종합 선물 세트
getTracks() 상자를 열어서 안에 든 '사과(영상)' '배(소리)'를 꺼낸다
forEach 꺼낸 과일들을 하나씩 손에 든다
addTrack 전송용 컨베이어 벨트(pc1) 위에 과일을 하나씩 올린다.
이때 "아까 그 선물 세트"라고 이름표(stream)를 붙여서 보냅니다.

 3. ICE Candidate 설정 (네트워크 경로 찾기)

pc1.current.onicecandidate = e => e.candidate && pc2.current.addIceCandidate(e.candidate);
pc2.current.onicecandidate = e => e.candidate && pc1.current.addIceCandidate(e.candidate);

이 코드는 WebRTC 연결에서 가장 중요한 "상대방에게 가는 길 찾기(ICE)" 과정을 자동화한 코드입니다.

  • pc1.current.onicecandidate = e => e.candidate (이벤트 발생)
    • 내 주소를 찾았을 때 알려주는 알림 창
    • pc1.current가 네트워크 환경에서 IP주소와 포트번호를 찾아내면 자동으로 실행됩니다.
    • 이때 찾아낸 주소 정보가 바로 e.candidate입니다.
  • e.candidate &&... (조건문)
    • "주소를 제대로 찾았을 때만 실행해!"라는 뜻입니다.
    • ICE Candidate를 찾는 과정은 여러 번 일어납니다. 마지막에는 "더 이상 찾을 주소가 없어"라는 의미로 빈 값(null)이 들어오는데, 이때 에러가 나지 않도록 방지하는 코드입니다.
  • pc2.current.addIceCandidate(e.candidate) (전달)
    • "찾은 내 주소를 상대방의 주소록에 등록해!"라는 동작입니다.
    • pc1이 찾은 주소(e.candidate)를 pc2에게 건네주며, "나랑 연결하고 싶으면 이 길로 와!"라고 알려주는 것입니다.

상대방(pc2)이 트랙을 받으면 비디오 태그에 연결

pc2.current.ontrack = e => {
	remoteVideoRef.current.srcObject = e.streams[0];
};

이 코드는 상대방이 보낸 영상과 소리 데이터(트랙)가 내 컴퓨터에 도착했을 때, 그걸 잡아서 화면에 띄우는 '수신 대기' 로직입니다.

아까 pc1.addTrack으로 데이터를 보냈다면, 여기서는 pc2.ontrack으로 그 데이터를 받는 것입니다.

  • pc2.current.ontrack (이벤트 발생)
    • "상대방이 보낸 데이터가 방금 도착했어!"라는 알림입니다.
    • 상대방(pc1)이 addTrack을 통해 영상이나 음성 데이터를 보내면, 내 쪽의 연결 통로(pc2)에서 ontrack이라는 이벤트가 자동으로 발생합니다.
  • e.streams [0] (데이터 꺼내기)
    • "보따리에서 영상 스트림을 꺼내자"는 뜻입니다
    • e 안에는 상대방이 보낸 트랙(Track) 정보들이 들어있는데, 보통 이 트랙들이 묶여 있는 스트림(Stream)의 배열 형태로 들어옵니다.
  • remoteVideoRef.current.srcObject =... (화면 연결)
    • "꺼낸 영상을 '상대방 화면' 칸에 꽂아!"라는 동작입니다.
    • 리액트에서 미리 만들어둔 상대방 비디오 태그(remoteVideoRef)에 방금 받은 실시간 스트림 데이터를 연결합니다.

Offer/Answer 협상 (SDP 교환)

const offer = await pc1.current.createOffer();
await pc1.current.setLocalDescription(offer);
await pc2.current.setRemoteDescription(offer);

const answer = await pc2.current.createAnswer();
await pc2.current.setLocalDescription(answer);
await pc1.current.setRemoteDescription(answer);

이 코드는 WebRTC의 가장 핵심적인 "계약 체결" 단계인 SDP 교환 과정입니다.

즉, 컴퓨터들이 "서로 어떤 화질로 주고받을까?"같은 설명서를 주고받는 과정이라고 봅니다.

 

pc1이 제안하기 (Offer)

const offer = await pc1.current.createOffer(); // 1. 제안서 작성
await pc1.current.setLocalDescription(offer);  // 2. 내 사양 확정
await pc2.current.setRemoteDescription(offer); // 3. 상대에게 제안서 전달

 

  • createOffer: pc1이 "나는 이런 해상도와 코덱을 지원해. 우리 이렇게 연결할래?"라는 내용이 담긴 제안서(Offer)를 만듭니다.
  • setLocalDescription: pc1 스스로 "그래, 나는 이 사양대로 나갈 준비가 됐어!"라고 자기 설정을 확정하는 것입니다.
  • setRemoteDescription: pc2에게 이 제안서를 건네줍니다. pc2는 "아, pc1은 이런 사양을 원하는구나"라고 이해하게 됩니다.

pc2가 응답하기 (Answer)

const answer = await pc2.current.createAnswer(); // 4. 응답서 작성
await pc2.current.setLocalDescription(answer);  // 5. 내 사양 확정
await pc1.current.setRemoteDescription(answer); // 6. 다시 나에게 응답 전달

 

 

  • createAnswer: 제안을 받은 pc2가 "오케이, 확인했어. 그럼 나는 이런 사양으로 응답할게!"라는 응답서(Answer)를 만듭니다.
  • setLocalDescription: pc2도 "나도 이 사양대로 데이터를 보낼 준비가 됐어!"라고 자기 설정을 확정합니다.
  • setRemoteDescription: 다시 pc1에게 이 응답서를 돌려줍니다. pc1이 이걸 받으면 비로소 양쪽의 계약이 성사됩니다.

이렇게 간단한 webRTC의 코드를 보고 해설을 해봤습니다.

이 코드는 정말 간단한 코드이기 때문에 이 코드에 +a로 다른 코드를 추가하면 더욱더 완벽한 webRTC를 사용 가능합니다.