WebRTC 박살내기 세번째 시리즈 입니다. WebRTC의 핵심 RTCPeerConnection을 완벽하게 이해하고, 연결 생성부터 이벤트 처리, 품질 관리, 연결 복구까지 실전 예제와 함께 알아봅니다.
WebRTC 박살내기 첫번째 시리즈 입니다. WebRTC 기본 개념부터 시그널링, Offer/Answer(SDP), Trickle ICE, STUN/TURN, NAT, 그리고 Mesh·SFU·MCU 아키텍처까지 한 번에 정리합니다.
WebRTC 박살내기 마지막 시리즈 입니다. WebRTC의 RTCDataChannel을 이해하고, 채팅·파일 전송·게임 동기화까지 실시간 데이터 전송의 모든 것을 알아봅니다.
WebRTC 박살내기 두번째 시리즈 입니다. WebRTC의 MediaStream과 MediaStreamTrack을 깊이 이해하고, getUserMedia부터 트랙 제어, 품질 관리까지 실전 예제와 함께 알아봅니다.
안녕하세요! 지난 글에서는 MediaStream과 MediaStreamTrack을 살펴보았습니다.
카메라와 마이크를 켜고, 트랙을 제어하는 방법까지 알아보았는데요.
이번에는 "이 미디어를 실제로 어떻게 상대방에게 보낼까?"를 다룹니다.
그 중심에 있는 것이 바로 RTCPeerConnection입니다.
RTCPeerConnection은 WebRTC의 핵심 객체로, 시그널링, SDP 교환, ICE 후보 처리를 실제로 실행하는 주체입니다.
이 글에서는 RTCPeerConnection이 무엇이고, 실제로 어떻게 사용하는지 알아보겠습니다.
RTCPeerConnection은 두 피어 간의 P2P 연결을 관리하는 객체입니다.
이 객체가 담당하는 일은 아래와 같은데요
쉽게 말해, WebRTC 연결의 모든 것을 이 객체 하나가 처리합니다.
const pc = new RTCPeerConnection({
iceServers: [
// STUN 서버 추가
{ urls: "stun:stun.l.google.com:19302" }
]
});
RTCPeerConnection을 만들 때 가장 중요한 설정이 iceServers입니다.
🧐 왜 필요할까?대부분의 사용자는 공유기(NAT) 뒤에 있어서 실제 IP 주소를 상대방이 알 수 없습니다.
STUN 서버는 "너의 공인 IP는 1.2.3.4야"라고 알려주는 역할을 합니다.ICE와 STUN/TURN에 대한 자세한 설명은 이전 글을 확인해주세요.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
// TURN 서버 추가
{
urls: "turn:your-turn-server.com:3478",
username: "user",
credential: "password"
}
]
});
회사나 학교 같은 네트워크는 보안을 위해 방화벽을 설치하는 경우가 많습니다.
이 방화벽은 외부에서 들어오는 연결을 차단하기 때문에, 양쪽 모두 방화벽 뒤에 있으면 직접 연결이 불가능합니다.
TURN 서버는 이런 상황에서 중계 역할을 합니다.
두 피어가 직접 연결하지 못하면, 각자 TURN 서버에 연결하고 TURN 서버가 그 사이에서 데이터를 전달해줍니다.

단점은 모든 데이터가 TURN 서버를 거쳐야 해서 지연 시간이 증가하고, 대역폭을 소비해 비용이 발생할 수밖에 없다는 점입니다.
그래서 WebRTC는 직접 연결을 먼저 시도하고, 실패할 때만 TURN 서버를 사용하게 됩니다.
연결을 시작하기 전에 먼저 이벤트 리스너를 등록해야 합니다.
그렇지 않으면 초기 단계에서 발생하는 중요한 이벤트를 놓칠 수 있습니다.

브라우저가 연결 가능한 네트워크 경로(ICE Candidate) 를 찾을 때마다 발생합니다.
WebRTC는 NAT, 방화벽, 네트워크 종류(Wi-Fi, LTE 등)에 따라 서로 다른 경로를 탐색합니다.
이때 찾아낸 후보 경로를 상대방에게 즉시 전달해야 연결이 완성됩니다.
pc.addEventListener("icecandidate", (event) => {
if (event.candidate) {
// 찾은 경로를 상대방에게 즉시 전송
signalingChannel.send({
type: "candidate",
candidate: event.candidate
});
} else {
// null이면 모든 경로 수집 완료
console.log("ICE 수집 완료");
}
});
💡 왜 즉시 보내나요?
연결 경로를 빨리 공유할수록 연결이 빨라집니다.
모든 경로를 다 찾을 때까지 기다리지 않고, 찾는 즉시 전송하는 것이 효율적입니다.
상대 피어가 미디어 트랙(영상, 오디오 등) 을 전송할 때 발생합니다.
즉, 원격 사용자의 카메라나 마이크 스트림이 수신될 때 실행됩니다.
이 이벤트에서 전달되는 event.streams[0]을 <video> 또는 <audio> 요소에 연결하면 상대방의 화면이나 음성이 표시됩니다.
💡 참고
이 이벤트는
addTrack()이나addTransceiver()를 통해 상대 피어가 새로운 트랙을 추가할 때 트리거됩니다.
브라우저가 자동으로 MediaStream을 구성하므로 별도의 수신 설정이 필요 없습니다.
RTCPeerConnection의 전체 연결 상태가 바뀔 때 발생합니다.
이벤트 핸들러 내부에서는 pc.connectionState를 통해 현재 상태를 확인할 수 있습니다.
| 상태 | 설명 |
|---|---|
new | 아직 연결이 시작되지 않은 초기 상태 |
connecting | ICE 전송이 연결을 설정 중 |
connected | 연결이 성공적으로 완료됨 |
disconnected | 일시적인 네트워크 단절 발생 (자동 재시도 가능) |
failed | 연결 실패, 수동 재연결 필요 |
closed | PeerConnection이 완전히 종료됨 |
💡 참고
이 이벤트는 ICE 레벨의 연결 상태(
iceconnectionstatechange)와 달리, 전체 PeerConnection의 "최종 상태"를 반영합니다.
즉, ICE뿐만 아니라 미디어, 데이터 채널 등 모든 연결 단계를 포함합니다.
negotiationneeded라는 단어가 다소 생소하게 느껴질 수 있는데요,
앞 글자 "nego"를 보면 중고거래에서 가격을 흥정할 때 쓰는 "네고하다"의 '네고'와 같은 의미입니다.
즉, PeerConnection 간 재협상이 필요할 때 브라우저가 자동으로 발생시키는 이벤트입니다.
이 이벤트는 PeerConnection의 구성이나 전송 방향이 변경되었을 때 자동으로 트리거됩니다.
sendonly, recvonly 등)보통 이 이벤트가 발생하면, 새로운 Offer를 생성해 시그널링 서버를 통해 상대 피어에게 전송합니다.
💡 참고
negotiationneeded는 브라우저가 "이제 SDP(세션 기술 정보)를 다시 맞춰야 한다"고 판단할 때 호출됩니다.
수동으로 호출하는 이벤트가 아니라, 브라우저가 "우리 지금 다시 협상해야 해"라고 알려주는 신호입니다.
카메라와 마이크를 켜서 PeerConnection에 추가합니다.
addTrack()을 호출하면 무슨 일이 일어날까요?negotiationneeded 이벤트가 자동으로 발생createOffer()는 SDP라는 형식으로 다음 정보를 담습니다.
setLocalDescription()은 이 설정을 내 브라우저에 알려줍니다.
"앞으로 이 설정으로 통신할 거야"라고 선언하는 거죠.
setRemoteDescription()은 상대방의 설정을 받아들이는 것입니다.
createAnswer()는 상대방의 Offer를 보고 응답을 만듭니다.
양쪽이 모두 지원하는 코덱과 설정을 자동으로 선택합니다.
Offer/Answer 교환과 동시에 ICE Candidate도 주고받아야 합니다.
브라우저가 찾은 연결 경로를 서로 공유하는 과정입니다.

지금까지 배운 내용을 실제 코드로 정리하면 다음과 같습니다.
이것이 RTCPeerConnection의 전체 연결 흐름입니다.
핵심 포인트negotiationneeded 자동 발생getStats()는 현재 연결의 모든 통계를 제공합니다.
패킷 손실률은 네트워크 품질의 핵심 지표입니다.
데이터가 네트워크를 통과하다가 일부가 사라지는 비율인데요,
5% 이상이면 영상이 끊기거나 버벅거립니다.
비트레이트(Bitrate)는 초당 전송하는 데이터 양입니다.
비트레이트가 높으면?
비트레이트가 낮으면?
네트워크 상태를 보고 자동으로 조절하면 최적의 경험을 제공할 수 있습니다.
iceRestart: true 옵션은 새로운 네트워크 경로를 찾습니다.
이 옵션이 필요한 경우는 다음과 같습니다.
즉, 네트워크망 정보가 변경되었을 때 사용됩니다.
WebRTC는 네트워크망이 변경되면 Negotiation(협상)과 Signaling(시그널링)을 다시 진행해야 합니다.
기존 연결 경로가 더 이상 유효하지 않을 때, 처음부터 다시 경로를 찾는 것입니다.
track.stop() 없이는 카메라 불빛이 계속 켜져있음pc.close() 없이는 연결이 계속 유지됨React에서는 다음과 같이 사용하면 되겠죠?
이번 글에서는 RTCPeerConnection API의 모든 것을 다뤘습니다.
icecandidate: 연결 경로 찾음track: 상대방 미디어 수신connectionstatechange: 연결 상태 변화negotiationneeded: 재협상 필요negotiationneeded는 자동 발생다음 글에서는 실시간 채팅 / 파일 전송 / 게임 데이터 동기화 등
영상/음성이 아닌 일반 데이터를 P2P로 주고받는 RTCDataChannel에 대해 알아보겠습니다.
끝까지 읽어주셔서 감사합니다!
pc.addEventListener("track", (event) => {
const remoteVideo = document.querySelector("#remoteVideo");
remoteVideo.srcObject = event.streams[0];
});
pc.addEventListener("connectionstatechange", () => {
switch (pc.connectionState) {
case "new":
console.log("연결 초기화됨");
break;
case "connecting":
console.log("연결 시도 중");
break;
case "connected":
console.log("연결 성공");
break;
case "disconnected":
console.log("일시적으로 끊김");
break;
case "failed":
console.log("연결 실패");
break;
case "closed":
console.log("연결 종료");
break;
}
});
pc.addEventListener("negotiationneeded", async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: "offer", sdp: offer });
});
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
// 1. Offer 생성
const offer = await pc.createOffer();
// 2. 내 PeerConnection에 등록
await pc.setLocalDescription(offer);
// 3. 상대방에게 전송
signalingChannel.send({ type: "offer", sdp: offer });
// 1. 받은 Offer를 등록
await pc.setRemoteDescription(receivedOffer);
// 2. Answer 생성
const answer = await pc.createAnswer();
// 3. 내 PeerConnection에 등록
await pc.setLocalDescription(answer);
// 4. 응답 전송
signalingChannel.send({ type: "answer", sdp: answer });
// 상대방의 Candidate 받기
signalingChannel.on("candidate", async (candidate) => {
await pc.addIceCandidate(candidate);
});
// 1. PeerConnection 생성
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});
// 2. 이벤트 리스너 등록
pc.addEventListener("icecandidate", (e) => {
if (e.candidate) {
signalingChannel.send({ type: "candidate", candidate: e.candidate });
}
});
pc.addEventListener("track", (e) => {
document.querySelector("#remoteVideo").srcObject = e.streams[0];
});
pc.addEventListener("connectionstatechange", () => {
console.log("상태:", pc.connectionState);
});
pc.addEventListener("negotiationneeded", async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: "offer", sdp: offer });
});
// 3. 미디어 추가 (negotiationneeded 자동 발생)
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
stream.getTracks().forEach((track) => {
pc.addTrack(track, stream);
});
// 4. 상대방 메시지 처리
signalingChannel.on("offer", async (offer) => {
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingChannel.send({ type: "answer", sdp: answer });
});
signalingChannel.on("answer", async (answer) => {
await pc.setRemoteDescription(answer);
});
signalingChannel.on("candidate", async (candidate) => {
await pc.addIceCandidate(candidate);
});
async function checkQuality() {
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === "inbound-rtp" && report.kind === "video") {
const packetsLost = report.packetsLost || 0;
const packetsReceived = report.packetsReceived || 0;
const lossRate = packetsLost / (packetsLost + packetsReceived);
console.log(`패킷 손실률: ${(lossRate * 100).toFixed(2)}%`);
if (lossRate > 0.05) {
console.warn("⚠️ 네트워크 불안정");
}
}
});
}
setInterval(checkQuality, 3000);
async function adjustBitrate(targetBitrate) {
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
if (!sender) return;
const params = sender.getParameters();
if (!params.encodings) params.encodings = [{}];
params.encodings[0].maxBitrate = targetBitrate;
await sender.setParameters(params);
console.log(`비트레이트 조정: ${targetBitrate / 1000}kbps`);
}
// 네트워크 상태에 따라 자동 조절
async function autoAdjust() {
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === "inbound-rtp" && report.kind === "video") {
const lossRate = report.packetsLost / report.packetsReceived;
if (lossRate > 0.05) {
adjustBitrate(500_000); // 500kbps
} else if (lossRate < 0.02) {
adjustBitrate(2_500_000); // 2.5Mbps
}
}
});
}
setInterval(autoAdjust, 5000);
let reconnectAttempts = 0;
const MAX_ATTEMPTS = 3;
pc.addEventListener("iceconnectionstatechange", async () => {
const state = pc.iceConnectionState;
console.log("ICE 상태:", state);
if (state === "disconnected") {
console.log("⚠️ 연결 끊김, 재연결 시도");
if (reconnectAttempts < MAX_ATTEMPTS) {
reconnectAttempts++;
// ICE 재시작
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
signalingChannel.send({ type: "offer", sdp: offer });
} else {
console.error("❌ 재연결 실패, 수동 재시작 필요");
}
}
if (state === "connected") {
reconnectAttempts = 0;
console.log("✅ 연결 복구 성공");
}
if (state === "failed") {
console.error("❌ 연결 완전 실패");
}
});
function cleanup() {
// 1. 미디어 트랙 정지
if (localStream) {
localStream.getTracks().forEach((track) => {
track.stop();
console.log(`${track.kind} 트랙 정지`);
});
}
// 2. PeerConnection 종료
if (pc) {
pc.close();
console.log("PeerConnection 종료");
}
// 3. UI 정리
const localVideo = document.querySelector("#localVideo");
const remoteVideo = document.querySelector("#remoteVideo");
if (localVideo) localVideo.srcObject = null;
if (remoteVideo) remoteVideo.srcObject = null;
console.log("✅ 리소스 정리 완료");
}
// 페이지 나갈 때 자동 실행
window.addEventListener("beforeunload", cleanup);
useEffect(() => {
// 연결 설정...
return () => {
cleanup(); // 컴포넌트 언마운트 시 자동 실행
};
}, []);