mimizae 님의 블로그
webRTC란? 본문
우리 졸업 프로젝트, Re:PiT은 사용자의 운동 자세를 분석해 음성 및 텍스트 피드백을 즉각적으로 주고, 추후 web을 통해 운동 분석 히스토리를 조회해 그간의 분석 결과를 다시 확인할 수 있도록 한 스마트 피트니스 서비스를 구현하고 있습니다.
졸업 프로젝트에서의 jetson orin nano와 web의 직접적 통신을 구현하기 위해 web RTC에 대해 동작 원리에 대해 공부해 보았습니다.
1️⃣ Web RTC란?
WebRTC는 별도의 장치 없이 브라우저만으로 실시간 영상·음성·데이터를 P2P로 주고받을 수 있게 해주는 기술!!!!!
위의 여러 참고 자료를 읽고 이해한대로 한 줄 요약해 봤습니다.
web RTC 기술은 전형적인 P2P 통신입니다!
즉, 별다른 서버 없이 데이터를 주고 받기 가능.
(그러나 P2P로 연결되기 위한!! 과정에서는 서버가 필요함. 그 이후의 데이터 전송에는 서버가 필요 없다는 뜻.) → 엥 P2P인데 서버가 왜 이렇게 많이 나오지;; 했었어서 혹시 그럴까 봐 남깁니다.
이 web RTC를 사용하기 전에 알아야 할 것들이 산더미입니다 🥲🥲🥲 개념이 진짜 어려웠다 아직도 어렵다!!
Web RTC 통신에 관한 주요 개념
- 시그널링(Signaling)
- SDP(Session Description Protocol)
- ICE(Interactive Connectivity Establishment)
- STUN/TURN 서버
Web RTC 실시간 데이터 교환에 사용되는 API 3가지
- MediaStream — 카메라와 마이크 등의 데이터 스트림 접근
- RTCPeerConnection — 암호화 및 대역폭 관리 및 오디오, 비디오의 연결
- RTCDataChannel — 일반적인 데이터의 P2P 통신 (optional)
이 3가지의 API를 통해서 데이터 교환이 이루어지며 RTCPeerConnection 들이 적절하게 데이터를 교환할 수 있게 처리해 주는 과정을 시그널링(Signaling)이라고 한다.
즉, 이 과정을 위한 시그널링 서버가 필요하다.
(이것들을 설명하는 이유는 이것 바탕으로 웹캠 web rtc 테스트 로직을 구성했기 때문입니다)
2️⃣ Web RTC 통신에 관한 주요 개념
1) 시그널링(Signaling)이란?
WebRTC에서 P2P 연결을 시작하기 전에 두 브라우저(Peer)끼리 서로를 찾고, 연결 정보를 교환하며 연결을 시작하는 과정입니다.
교환하는 정보들:
- SDP (Session Description Protocol)
- Offer, Answer (코덱 정보, 미디어 종류 등)
- ICE Candidate (네트워크 후보 정보)
- 내 PC가 외부에서 접근 가능한 IP, 포트 등
왜 시그널링이 필요한가?
WebRTC 자체는 미디어 전송을 처리하는 기술이지, 상대방에게 연결 정보를 전달하는 방법은 제공하지 않음.
즉, 연결 정보를 어떻게 상대에게 전달할지는 직접 정해야 함.
⇒ 이 시그널링은 서버를 통해 이루어지고 보통은 web socket으로 구현한다고 합니다. (양방향 통신)
2) SDP란?
→ SDP는 "무엇으로 소통할지, 어떻게 소통할지"에 대한 정보가 담겨있는 양식이다.
즉, "나는 이런 오디오, 비디오를 지원해"라고 알려주는 자기소개서와 같다고 보면 됩니다.
더 자세하게는 세션 정보, 네트워크 정보, 미디어의 종류, 코덱, 스트림의 속성 등 많은 내용이 담겨있습니다.
그래서 연결하는 시작하는 쪽에서는 Offer SDP를 생성하고, 받는 쪽에서는 Answer SDP로 응답합니다.
(우리는 먼저 연결을 시작하는 쪽은 web, 받는 쪽은 jetson이 되겠죠?)
v=0
o=- 20518 0 IN IP4 203.0.113.1
s=-
c=IN IP4 203.0.113.1
t=0 0
m=audio 54400 RTP/AVP 0
a=rtpmap:0 PCMU/8000
m=video 55400 RTP/AVP 31
a=rtpmap:31 H261/90000
구조는 이렇구나~ 하고 알고 있으면 될 것 같습니다. (이를 가지고 peer끼리 알아서 최적의 환경을 정하는 것.)
위 내용을 간략히 설명하자면,
- 내 IP는 203.0.113.1이고
- 오디오를 54400 포트로 받을 거고, 코덱은 PCMU
- 비디오는 55400 포트로 받을 건데, 코덱은 H261
💭 SDP를 peer끼리 주고 받는 흐름
- Peer A (Caller) → createOffer()
- → SDP Offer 생성 (나는 이런 설정 쓸게)
- Peer B (Callee) → setRemoteDescription(offer)→ createAnswer() → SDP Answer 생성
- → 상대의 설정을 보고 가능 여부 판단
- 서로 setLocalDescription() / setRemoteDescription()으로 서로의 SDP 교환
3) ICE와 그 과정에서 사용되는 STUN/TURN 서버의 역할

위에서 자기소개(SDP)를 했으니 이제 어떻게 (어떤 경로로) 연락을 할지 정해야 합니다!
ICE는 "나한테 연락하려면 이 주소로 연락해! 이게 제일 빨라~"라고 알려주는 과정입니다.
즉, 두 피어 간의 최적의 통신 경로를 찾기 위해 사용되는 프레임워크입니다.
NAT(Network Address Traslation)와 방화벽 등의 네트워크 장벽을 극복하고 직접적인 연결을 가능하게 하는 중요한 역할을 합니다.
(여기에서 좀 더 까다로운 방화벽이 있을 때 사용하는 게 TURN 서버! 우회하여 피어끼리 직접 연결되도록 도와주는 역할)
왜 피어 간 최적의 통신 경로를 찾는 전략이 필요할까?
인터넷 환경은 매우 다양합니다.
- 집 와이파이 (내부 IP)
- 누군가는 NAT 뒤에
- 그리고 방화벽도… 통과해야 함.
→ 이 상황에서 상대 브라우저가 나를 어떻게 찾게 하는가?
모든 가능한 경로를 모아서(수집) 상대랑 직접 연결할 수 있는 경로를 찾아야 합니다!
(이 경로를 찾을 때 STUN/TURN 서버를 이용합니다.)
⬇️ ICE로 경로 찾는 과정 (요약) ⬇️
- 내 IP 후보들을 수집함 (ice gathering)
- host candidate: 내 로컬 IP (ex: 192.168.x.x)
- srflx candidate: STUN 서버 통해 얻은 공인 IP
- relay candidate: TURN 서버 통해 얻은 중계 주소
- 이 후보들을 상대에게 전달함 (시그널링 통해)
- 서로의 후보를 바탕으로 테스트 → 제일 잘 통하는 경로로 연결 시도

ICE에 STUN/TURN 서버를 이용한다고 했는데,
쉽게 말하자면 STUN 서버는 ‘나 지금 어디에 있는 거니? 하고 물으면’ "너의 공인 IP는 이거야" 라고 알려주는 서버입니다.
만약 NAT 환경이라면 private ip를 별도로 가지고 있기 때문에 직접적인 P2P 통신이 어렵거나 불가능할 수 있습니다.
따라서 클라이언트는 자신의 pubilc ip를 확인해야 하기에 STUN 서버에 요청을 보내게 됩니다.
하지만 여기에서 공개 ip 주소를 발견한다고 해도 모두 연결할 수 있는 것은 아닙니다.
어떠한 라우터들은 네트워크 연결에 제한을 두기 때문입니다.
몇몇 라우터들은 symmetric NAT 라는 걸 사용하는데, 이는 이전에 연결한 적이 있는 연결들만 허용한다는 의미입니다.
여기에서! 이 제한을 막기 위해, 우회하기 위해 TURN 서버를 이용합니다.
TURN 서버는 중계 서버 역할을 해줘서, 두 클라이언트가 직접 연결 못 할 때 가운데서 미디어를 대신 전달해줍니다.
잠깐!!!! NAT가 뭔가요... 왜 불가능하다는 것...?
이렇게 시그널링을 이용해서 Web RTC 통신을 위한 준비 단계를 거쳤다면,
(어떻게 최적의 경로를 찾고 네트워크 환경에 따라 공인 IP만 있으면 되는지, 우회 서버를 써야 하는지 등등)
실제로 데이터를 교환하는 단계로 나아가야 합니다!!
(제가 작성한 코드는 jetson 브라우저와 웹 브라우저를 web RTC P2P 통신을 위한 연습으로 가상의 peer을 두고 내 웹캠, 브라우저만을 사용해서 테스트 했기에 실질적으로는 통신을 위한 준비 단계는 거칠 필요도 없이 바로 데이터 교환만 이루고 있습니다. 참고!!)
이는 3가지의 api를 사용하면 되는데,
- MediaStream API→ getUserMedia() 호출
→ 브라우저에서 카메라, 마이크에 접근해서 미디어 스트림을 얻는 API - RTCPeerConnection API
→ 연결, 암호화, NAT traversal, 대역폭 제어 등 모든 핵심 기능 담당
→ 피어 간의 P2P 연결을 생성하고, MediaStream을 전송 - RTCDataChannel API (option)
→ 음성/영상 말고도 채팅 메시지, 게임 데이터 같은 일반적인 데이터도 P2P로 교환 가능
예를 들어 흐름을 설명하면…
- 사용자 A가 브라우저에서 getUserMedia()로 카메라 켜기 → MediaStream 생성
- RTCPeerConnection을 통해 연결 객체 만들기
- Signaling을 통해 A가 B에게 SDP를 전송 (offer)
- B가 받아서 응답 SDP (answer) 전송
- 서로의 ICE 후보 정보 교환 (STUN 서버 사용)
- 연결 성립 → RTCPeerConnection에 MediaStream을 attach → 영상, 음성 스트림 전송 시작
이제 코드 예제를 봐 봅시다
1. MediaStream
→ 카메라, 마이크 등 사용자 미디어에 접근
const stream = await navigator.mediaDevices.getUserMedia({ ... });
localStreamRef.current = stream;
localVideoRef.current.srcObject = stream;
2. RTCPeerConnection
→ P2P 연결을 설정하고 미디어 스트림 송수신 처리
pc1Ref.current = new RTCPeerConnection(rtcConfig); // Caller
pc2Ref.current = new RTCPeerConnection(rtcConfig); // Callee
3. SDP (Session Description Protocol)
→ 미디어 세션의 형식·코덱·네고 정보 교환 (Offer/Answer)
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(offer);
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(answer);
4. ICE (Interactive Connectivity Establishment)
→ P2P 통신을 위한 최적의 네트워크 경로 찾기 (IP, 포트 등)
pc1.onicecandidate = (event) => {
if (event.candidate) {
pc2.addIceCandidate(event.candidate);
}
};
pc2.onicecandidate = (event) => {
if (event.candidate) {
pc1.addIceCandidate(event.candidate);
}
};
5. STUN (Session Traversal Utilities for NAT)
→ 자신의 공인 IP와 포트를 확인해 ICE 후보 생성에 도움
const rtcConfig = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
};
6. TURN
→ P2P가 불가능한 환경에서 중계 서버를 통해 우회 연결, 로컬 테스트엔 필요 없음
→ Jetson과 통신할 때 방화벽/NAT 문제가 생기면 TURN이 필요!
7. 시그널링
→ 시그널링 절차만 구현되어있음. 당연히 브라우저 하나 내에서 두 peer로 테스트 했기 때문임.
// 시그널링을 WebSocket 없이 내부적으로 직접 연결하고 있음
pc1.setLocalDescription → pc2.setRemoteDescription
pc2.setLocalDescription → pc1.setRemoteDescription
아래는 UI 로직을 제외한 코드 전문입니다!
"use client";
import { useState, useRef, useEffect } from "react";
// web RTC 테스트를 위한 임시 home
export default function Home() {
const [isStreaming, setIsStreaming] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [connectionState, setConnectionState] = useState<string>("new");
const [error, setError] = useState<string>("");
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const localStreamRef = useRef<MediaStream | null>(null);
// 두 개의 PeerConnection (자기 자신과 연결)
const pc1Ref = useRef<RTCPeerConnection | null>(null); // Caller
const pc2Ref = useRef<RTCPeerConnection | null>(null); // Callee
// WebRTC 설정 -> 무료 스턴 서버!!
const rtcConfig = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
};
// 카메라 시작
const handleStartCamera = async () => {
try {
setError("");
console.log("카메라 준비");
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
},
audio: true,
});
localStreamRef.current = stream;
if (localVideoRef.current) {
localVideoRef.current.srcObject = stream;
}
setIsStreaming(true);
console.log("카메라 시작");
} catch (err) {
console.error("카메라 접근 실패:", err);
setError("카메라에 접근할 수 없습니다. 권한을 확인해주세요.");
}
};
// 카메라 중지
const handleStopCamera = () => {
console.log("카메라 중지 준비");
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((track) => {
track.stop();
});
localStreamRef.current = null;
}
if (localVideoRef.current) {
localVideoRef.current.srcObject = null;
}
if (remoteVideoRef.current) {
remoteVideoRef.current.srcObject = null;
}
// PeerConnection들 정리
if (pc1Ref.current) {
pc1Ref.current.close();
pc1Ref.current = null;
}
if (pc2Ref.current) {
pc2Ref.current.close();
pc2Ref.current = null;
}
setIsStreaming(false);
setIsConnected(false);
setConnectionState("new");
console.log("카메라 중지");
};
// 진짜 WebRTC P2P 연결 시작!!!!!!
const startWebRTCConnection = async () => {
try {
if (!localStreamRef.current) {
setError("먼저 카메라를 시작해주세요.");
return;
}
console.log("진짜 WebRTC P2P 연결 시작!");
setConnectionState("connecting");
// 1. 두 개의 PeerConnection 생성
pc1Ref.current = new RTCPeerConnection(rtcConfig); // Caller
pc2Ref.current = new RTCPeerConnection(rtcConfig); // Callee
const pc1 = pc1Ref.current;
const pc2 = pc2Ref.current;
// 2. ICE Candidate 교환 설정 (서로 연결)
pc1.onicecandidate = (event) => {
if (event.candidate) {
console.log("PC1 → PC2 ICE Candidate");
pc2.addIceCandidate(event.candidate);
}
};
pc2.onicecandidate = (event) => {
if (event.candidate) {
console.log("PC2 → PC1 ICE Candidate");
pc1.addIceCandidate(event.candidate);
}
};
// 3. PC2에서 원격 스트림 받기 (중복 방지)
let remoteStreamSet = false;
pc2.ontrack = (event) => {
console.log(
"Track 수신:",
event.track.kind,
"- Stream ID:",
event.streams[0].id
);
// 첫 번째 track에서만 스트림 설정
if (!remoteStreamSet && remoteVideoRef.current) {
remoteStreamSet = true;
const video = remoteVideoRef.current;
const stream = event.streams[0];
console.log("스트림 설정 시작 - Tracks:", stream.getTracks().length);
video.srcObject = stream;
// loadedmetadata 이벤트를 기다린 후 재생
video.onloadedmetadata = () => {
console.log("메타데이터 로드됨, 재생 시도");
video
.play()
.then(() => {
console.log("비디오 재생 성공");
})
.catch((error) => {
console.error("비디오 재생 실패:", error);
});
};
// 추가 디버깅
setTimeout(() => {
console.log("최종 비디오 상태:", {
videoWidth: video.videoWidth,
videoHeight: video.videoHeight,
readyState: video.readyState,
paused: video.paused,
currentTime: video.currentTime,
srcObject: !!video.srcObject,
});
}, 2000);
}
};
// 4. 연결 상태 모니터링
pc1.onconnectionstatechange = () => {
console.log("PC1 연결 상태:", pc1.connectionState);
setConnectionState(pc1.connectionState);
setIsConnected(pc1.connectionState === "connected");
};
pc2.onconnectionstatechange = () => {
console.log("PC2 연결 상태:", pc2.connectionState);
};
// 5. PC1에 로컬 스트림 추가 (송신자)
console.log(
"로컬 스트림 트랙들:",
localStreamRef.current.getTracks().map((t) => t.kind)
);
localStreamRef.current.getTracks().forEach((track) => {
if (localStreamRef.current) {
console.log(
"Track 추가:",
track.kind,
"enabled:",
track.enabled,
"readyState:",
track.readyState
);
const sender = pc1.addTrack(track, localStreamRef.current);
console.log("Sender 추가됨:", sender);
}
});
// 6. Offer-Answer 교환 시작!
console.log("Offer 생성 중");
const offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
console.log("PC2에 Offer 전달");
await pc2.setRemoteDescription(offer);
console.log("Answer 생성 중");
const answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
console.log("PC1에 Answer 전달");
await pc1.setRemoteDescription(answer);
console.log("WebRTC 핸드셰이크 완료! 연결 대기 중");
} catch (err) {
console.error("WebRTC 연결 실패:", err);
setError("WebRTC 연결에 실패했습니다: " + (err as Error).message);
setConnectionState("failed");
}
};
// ... 추가 로직
return ( ... );
}
참고 자료들!!
https://gilssang97.tistory.com/79
https://medium.com/@hyun.sang/webrtc-webrtc란-43df68cbe511