본문 바로가기

[WebRTC 박살내기 #3] PeerConnection API와 이벤트 흐름

WebRTC 박살내기 시리즈의 세 번째 글입니다. 핵심인 RTCPeerConnection API를 다룹니다. 연결 생성과 iceServers 설정부터 이벤트 흐름, Offer와 Answer 교환, 패킷 손실률 관측과 비트레이트 조절, 연결 복구까지 실전 예제와 함께 알아봅니다.

읽는 시간 22
[WebRTC 박살내기 #3] PeerConnection API와 이벤트 흐름

TL;DR

지난 편에서 MediaStream과 MediaStreamTrack을 다뤘습니다.
카메라와 마이크를 켜고 트랙을 제어하는 데까지 갔어요.

거기서 답하지 못한 질문이 하나 남습니다.

"이 미디어를 실제로 어떻게 상대방에게 보낼까?"

그 중심에 있는 것이 WebRTC의 핵심 API인 RTCPeerConnection (새 창에서 열림)입니다.
시그널링과 SDP 교환, ICE candidate 처리, 트랙 송수신, 연결 상태 관리를 실제로 실행하는 주체예요.

이 글이 다루는 것은 이렇습니다.

  • RTCPeerConnection 생성과 iceServers 설정
  • icecandidatetrack, connectionstatechange, negotiationneeded 이벤트 흐름
  • Offer와 Answer 교환
  • 패킷 손실률 관측과 비트레이트 조절
  • 연결이 끊겼을 때의 복구, 그리고 리소스 정리

코드에 나오는 signalingChannel부터 정하고 갑니다

이 글의 예제에 signalingChannel이 계속 나오는데, 이 글의 주제는 아니에요. RTCPeerConnection을 다루려면 상대와 메시지를 주고받을 통로가 있어야 해서 모양만 정해 두는 것입니다. 1편에서 봤듯이 시그널링은 WebRTC 표준에 없어서 실제 구현은 서비스마다 달라요. 여기서는 아래 모양의 객체가 이미 있다고 보고 진행합니다.

interface SignalingChannel {
	send(message: object): void;
	on(type: string, handler: (message: object) => void): void;
}

send()는 상대에게 메시지를 보내고, on()은 특정 타입의 메시지가 왔을 때 부를 함수를 등록해요. 브라우저 WebSocketonmessage 하나로 모든 메시지를 받으니 그대로 써도 됩니다. 다만 그러면 예제마다 type을 가르는 switch가 들어가서, 타입별로 핸들러를 따로 걸 수 있게 on()을 하나 얹었어요.

function createSignalingChannel(url) {
	const ws = new WebSocket(url);
	const handlers = new Map();

	ws.addEventListener("message", (event) => {
		const message = JSON.parse(event.data);
		handlers.get(message.type)?.(message);
	});

	return {
		send(message) {
			ws.send(JSON.stringify(message));
		},
		on(type, handler) {
			handlers.set(type, handler);
		}
	};
}

메시지를 JSON으로 싸서 보내고, 받은 쪽은 type 필드로 갈라 등록된 함수를 부르는 것이 전부예요. 방을 나누거나 누구와 붙일지 정하는 일은 서버가 맡아요. 그 부분은 서비스마다 달라서 이 글에서는 다루지 않아요.


RTCPeerConnection은 네 가지 일을 합니다

RTCPeerConnection은 두 피어 사이의 P2P 연결을 관리하는 객체예요.

이 객체가 담당하는 일은 이렇게 나뉘어요.

  • 미디어 협상: 어떤 코덱, 해상도로 통신할지 합의
  • 네트워크 경로 탐색: 연결 가능한 모든 경로 찾기
  • 데이터 전송: 실제 오디오와 비디오 송수신
  • 상태 관리: 연결 성공과 실패, 끊김 감지

쉽게 말해 이 네 가지를 객체 하나가 다 처리해요.


PeerConnection을 만듭니다

첫 설정은 STUN 서버 주소입니다

RTCPeerConnection을 만들 때 가장 중요한 설정이 iceServers입니다.

STUN 서버는 공유기(NAT) 뒤에 있는 브라우저에게 "너의 공인 IP는 1.2.3.4야"라고 알려주는 서버예요. NAT 뒤에 있으면 실제 IP 주소를 상대방이 알 수 없습니다.

ICE와 STUN, TURN의 자세한 설명은 이전 편 WebRTC 개념과 연결 구조의 ICE 절에 있습니다.

STUN 서버 하나만 넣은 최소 형태부터 봐요.

const pc = new RTCPeerConnection({
	iceServers: [
		// STUN 서버 추가
		{ urls: "stun:stun.l.google.com:19302" }
	]
});

iceServers에 STUN 서버 주소 하나를 등록한 것이 전부예요.

STUN만으로 안 되면 TURN을 더합니다

STUN만으로는 안 되는 경우가 있어요.
iceServers 배열에 TURN 서버를 함께 적으면 브라우저가 중계 경로까지 candidate에 넣어요.

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은 STUN과 달리 인증이 필요해서 usernamecredential을 함께 넘겨요.

양쪽이 다 공유기(NAT) 뒤에 있으면 직접 연결이 안 되는 경우가 있어요. 회사나 학교처럼 방화벽까지 두는 네트워크라면 더 그렇습니다. 밖에서 들어오는 연결을 막아 두거든요.

TURN 서버는 이런 상황에서 중계 역할을 해요.
두 피어가 직접 연결하지 못하면, 각자 TURN 서버에 연결하고 TURN 서버가 그 사이에서 데이터를 전달해줘요.

단점은 모든 데이터가 TURN 서버를 거쳐야 한다는 점이에요.
그만큼 지연 시간이 늘고, 대역폭을 소비하니 비용도 발생해요.
그래서 WebRTC는 직접 연결을 먼저 시도하고 실패할 때만 TURN 서버를 씁니다.


이벤트 리스너를 연결보다 먼저 등록합니다

연결을 시작하기 전에 먼저 이벤트 리스너를 등록해야 합니다.
그렇지 않으면 초기 단계에서 발생하는 중요한 이벤트를 놓칠 수 있어요.

icecandidate는 경로를 찾을 때마다 발생합니다

브라우저가 연결 가능한 네트워크 경로(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.candidate가 있으면 시그널링 채널로 그대로 보내고, null이면 더 찾을 경로가 없다는 뜻이에요.

연결 경로를 빨리 공유할수록 연결이 빨라져요.
모든 경로를 다 찾을 때까지 기다리지 않고 찾는 즉시 전송하는 게 효율적이거든요.

track은 상대 미디어가 도착할 때 발생합니다

상대 피어가 미디어 트랙(영상이나 오디오)을 전송할 때 발생해요.
상대방의 카메라나 마이크 스트림이 도착할 때 실행돼요.
이 이벤트에서 전달되는 event.streams[0]<video> 또는 <audio> 요소에 연결하면 상대방의 화면이나 음성이 표시돼요.

pc.addEventListener("track", (event) => {
	const remoteVideo = document.querySelector("#remoteVideo");
	remoteVideo.srcObject = event.streams[0];
});

받은 스트림을 <video> 요소의 srcObject에 꽂는 것이 전부예요.

이 이벤트는 addTrack()이나 addTransceiver()로 상대 피어가 새로운 트랙을 추가할 때 트리거돼요.
브라우저가 알아서 MediaStream을 만들어 주니 따로 수신 설정을 하지 않아도 돼요.

connectionstatechange는 연결 상태가 바뀔 때 발생합니다

RTCPeerConnection의 전체 연결 상태가 바뀔 때 발생해요.
이벤트 핸들러 내부에서는 pc.connectionState로 현재 상태를 확인할 수 있어요.

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;
	}
});

여섯 가지 상태마다 다른 로그를 남기는 코드예요.
각 상태가 뜻하는 것은 이렇습니다.

상태설명
new아직 연결이 시작되지 않은 초기 상태
connectingICE 전송이 연결을 설정 중
connected연결이 성공적으로 완료됨
disconnected일시적인 네트워크 단절 발생 (자동 재시도 가능)
failed연결 실패, 수동 재연결 필요
closedPeerConnection이 완전히 종료됨

connectionstatechange는 ICE 레벨의 연결 상태(iceconnectionstatechange)보다 한 단계 위예요. ICE 전송과 DTLS 전송의 상태를 함께 모아 하나로 알려줍니다. 다만 데이터 채널의 상태는 여기에 들어가지 않아요. 그것은 채널마다 readyState로 따로 봐야 합니다.

negotiationneeded는 다시 협상해야 할 때 발생합니다

negotiationneeded는 처음 보면 낯선 단어예요. 앞의 nego가 협상을 뜻하는 negotiation에서 왔습니다. PeerConnection의 구성이 바뀌어 조건을 다시 맞춰야 할 때 브라우저가 알아서 띄우는 이벤트예요.

pc.addEventListener("negotiationneeded", async () => {
	const offer = await pc.createOffer();
	await pc.setLocalDescription(offer);

	signalingChannel.send({ type: "offer", sdp: offer });
});

핸들러가 createOffer()setLocalDescription()을 차례로 불러요.
두 API는 뒤의 Offer/Answer 교환 절에서 다뤄요.

PeerConnection의 구성이나 전송 방향이 바뀌면 브라우저가 알아서 이 이벤트를 띄워요. 네 가지 경우가 있습니다.

  • 트랙이 추가되거나 제거될 때
  • 트랙 전송 방향이 바뀔 때 (sendonly, recvonly 등)
  • DataChannel이 새로 추가될 때
  • 트랜시버 설정이 바뀔 때

트랜시버(Transceiver)는 보내는 쪽(RTCRtpSender)과 받는 쪽(RTCRtpReceiver)을 한 쌍으로 묶어 둔 객체예요. addTrack()을 부르면 브라우저가 내부에서 이것을 만듭니다. 방향을 직접 정하거나 협상 전에 자리를 미리 잡아 두고 싶을 때만 addTransceiver()로 직접 다뤄요.

이 이벤트가 뜨면 새 Offer를 만들어 시그널링 채널로 상대에게 보냅니다. 개발자가 직접 부르는 이벤트가 아니에요.
브라우저가 "이제 SDP(세션 기술 정보)를 다시 맞춰야 한다"고 판단해 먼저 알려주는 신호입니다.


카메라와 마이크를 얹습니다

카메라와 마이크를 켜서 PeerConnection에 얹는 단계예요.

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

stream.getTracks().forEach((track) => {
	pc.addTrack(track, stream);
});

getUserMedia()로 받은 스트림에서 트랙을 하나씩 꺼내 pc.addTrack()으로 등록해요.

addTrack()을 부르면 네 가지가 순서대로 일어나요.

  1. 트랙이 PeerConnection에 등록됨
  2. negotiationneeded 이벤트가 자동으로 발생
  3. 이벤트 핸들러에서 Offer를 만들어 전송
  4. 상대방이 Answer로 응답하면 연결 시작

Offer와 Answer를 주고받습니다

발신자는 Offer를 만듭니다

발신자 쪽은 세 단계면 끝나요.

// 1. Offer 생성
const offer = await pc.createOffer();

// 2. 내 PeerConnection에 등록
await pc.setLocalDescription(offer);

// 3. 상대방에게 전송
signalingChannel.send({ type: "offer", sdp: offer });

createOffer()는 SDP라는 형식으로 다음 정보를 담아요.

  • 내가 지원하는 코덱 (VP8, H.264, Opus 등)
  • 보낼 미디어 종류 (비디오, 오디오)
  • 네트워크 프로토콜

setLocalDescription()은 이 설정을 내 브라우저에 알려줘요.
"앞으로 이 설정으로 통신한다"고 브라우저에 정해 두는 것입니다.

수신자는 Answer를 만듭니다

수신자 쪽은 받은 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 });

setRemoteDescription()은 상대방의 설정을 받아들여요.

createAnswer()는 상대방의 Offer를 보고 응답을 만들어요.
양쪽이 모두 지원하는 코덱과 설정을 자동으로 선택해요.

candidate는 오는 대로 등록합니다

받은 쪽에서 candidate를 등록하는 코드는 한 줄이에요.

// 상대방의 Candidate 받기
signalingChannel.on("candidate", async (candidate) => {
	await pc.addIceCandidate(candidate);
});

시그널링 채널로 온 candidate를 pc.addIceCandidate()에 넘기면 끝이에요.

Offer와 Answer를 주고받는 동안 ICE Candidate도 함께 오갑니다.
브라우저가 찾은 연결 경로를 서로 공유하는 과정이에요.


지금까지를 하나의 코드로 잇습니다

지금까지 다룬 것을 하나의 코드로 이으면 이렇게 됩니다.

이 코드에는 전제가 하나 있어요. 양쪽이 똑같이 돌리면 안 됩니다. 둘 다 addTrack()을 부르니 둘 다 negotiationneeded가 뜨고 둘 다 offer를 보내는데, 내가 이미 보낸 offer가 있는 상태로 상대 offer를 받으면 setRemoteDescription()InvalidStateError를 던져요. 이 충돌을 glare라고 부릅니다.

가장 싸게 막는 방법은 먼저 거는 쪽을 정하는 것이에요. 방에 먼저 들어온 사람만 offer를 보내고, 나중에 들어온 사람은 negotiationneeded를 무시하면 됩니다. 아래 코드의 isCaller가 그 역할이에요.

// 1. PeerConnection 생성
const pc = new RTCPeerConnection({
	iceServers: [{ urls: "stun:stun.l.google.com:19302" }]
});

const isCaller = true; // 먼저 방에 들어온 쪽만 true
const pendingCandidates = [];

// 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 () => {
	if (!isCaller) return; // 받는 쪽은 offer를 만들지 않아요
	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);
	await drainCandidates();

	const answer = await pc.createAnswer();
	await pc.setLocalDescription(answer);
	signalingChannel.send({ type: "answer", sdp: answer });
});

signalingChannel.on("answer", async (answer) => {
	if (pc.signalingState !== "have-local-offer") return;
	await pc.setRemoteDescription(answer);
	await drainCandidates();
});

signalingChannel.on("candidate", async (candidate) => {
	// remote description이 아직 없으면 모아 뒀다가 나중에 넣습니다
	if (!pc.remoteDescription) {
		pendingCandidates.push(candidate);
		return;
	}
	await pc.addIceCandidate(candidate);
});

async function drainCandidates() {
	while (pendingCandidates.length > 0) {
		await pc.addIceCandidate(pendingCandidates.shift());
	}
}

pendingCandidates가 필요한 이유는 도착 순서 때문이에요. addIceCandidate()setRemoteDescription()을 지난 뒤에만 통하는데, 시그널링 메시지가 그 순서로 온다는 보장이 없습니다. candidate가 먼저 도착하면 배열에 모아 뒀다가 remote description을 설정한 뒤에 몰아서 넣어요.

양쪽이 동시에 트랙을 바꿀 수 있는 앱이라면 isCaller 하나로는 부족합니다. 충돌이 났을 때 한쪽이 자기 offer를 물러 주는 처리가 필요해요. 표준 패턴 이름이 perfect negotiation이니 필요할 때 찾아보세요.

여기서 놓치면 안 되는 것이 다섯이에요.

  1. 이벤트 리스너를 먼저 등록
  2. 미디어를 추가하면 negotiationneeded 자동 발생
  3. offer를 보내는 쪽을 한쪽으로 정하기
  4. ICE Candidate는 Offer/Answer와 병렬로 교환하되 순서를 방어
  5. 모든 메시지는 시그널링 채널로 전달

네트워크가 나빠지면 손실률부터 봅니다

getStats로 패킷 손실률을 잽니다

여기서 먼저 짚을 것이 하나 있어요. getStats()가 주는 packetsLostpacketsReceived는 지금 이 순간의 값이 아니라 통화가 시작된 뒤로 쌓인 누적값입니다. 그냥 나누면 통화 전체의 평균이 나와요. 30분을 깨끗하게 통화한 뒤 마지막 10초가 완전히 망가져도 누적 비율은 거의 움직이지 않습니다.

그래서 직전에 읽은 값을 들고 있다가 차이로 계산해요. 3초마다 최근 3초의 손실률을 재는 코드입니다.

let prev = { lost: 0, received: 0 };

async function checkQuality() {
	const stats = await pc.getStats();

	stats.forEach((report) => {
		if (report.type !== "inbound-rtp" || report.kind !== "video") return;

		const lost = report.packetsLost ?? 0;
		const received = report.packetsReceived ?? 0;

		const deltaLost = lost - prev.lost;
		const deltaReceived = received - prev.received;
		prev = { lost, received };

		const expected = deltaLost + deltaReceived;
		if (expected <= 0) return; // 이번 구간에 들어온 패킷이 없으면 판정하지 않아요

		const lossRate = Math.max(0, deltaLost) / expected;
		console.log(`최근 3초 손실률: ${(lossRate * 100).toFixed(2)}%`);

		if (lossRate > 0.05) {
			console.warn("네트워크 불안정");
		}
	});
}

setInterval(checkQuality, 3000);

Math.max(0, ...)로 한 번 거른 이유가 있어요. packetsLost는 부호가 있는 값이라 음수가 될 수 있습니다. 재전송으로 같은 패킷이 두 번 도착하면 보낸 것보다 받은 것이 많아지거든요. 이 값을 그대로 쓰면 손실률이 음수가 되고, 뒤에 나올 자동 조절이 엉뚱하게 움직입니다.

getStats()는 현재 연결의 모든 통계를 줍니다. 그중 패킷 손실률은 네트워크 품질을 볼 때 가장 먼저 보는 값이에요. 데이터가 네트워크를 지나다가 일부가 사라지는 비율입니다. 위 코드의 5퍼센트는 예로 둔 값이라 서비스에 맞게 직접 재서 정해야 해요.

손실률이 높으면 비트레이트를 낮춥니다

손실률이 높으면 보내는 양을 줄이면 돼요. 송신 쪽 파라미터를 꺼내 고쳐 쓰는 코드입니다.

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`);
}

getParameters()로 현재 설정을 꺼내 maxBitrate만 바꾸고 setParameters()로 되돌려 넣어요.

여기서 갈라 볼 것이 있습니다. 앞에서 본 inbound-rtp의 손실률은 내가 받는 영상이 깨진 정도예요. 그 값이 나쁘다고 내가 보내는 비트레이트를 줄여도 내 화면은 그대로 나쁩니다. 상대 화질만 떨어져요.

내 송신을 줄일 근거는 상대가 내 영상을 얼마나 잃었는지이고, 그 숫자는 remote-inbound-rtp 리포트에 담겨 옵니다. 상대가 보내주는 수신 보고서를 브라우저가 정리해 둔 것이에요.

let lowLossStreak = 0;

async function autoAdjust() {
	const stats = await pc.getStats();

	stats.forEach((report) => {
		if (report.type !== "remote-inbound-rtp" || report.kind !== "video") return;

		// fractionLost는 0에서 1 사이의 비율로 옵니다
		const lossRate = report.fractionLost ?? 0;

		if (lossRate > 0.05) {
			lowLossStreak = 0;
			adjustBitrate(500_000); // 500kbps
			return;
		}

		if (lossRate < 0.02) {
			lowLossStreak += 1;
			// 세 번 연속 좋을 때만 올립니다
			if (lowLossStreak >= 3) adjustBitrate(2_500_000); // 2.5Mbps
		}
	});
}

setInterval(autoAdjust, 5000);

올리는 조건에 lowLossStreak를 둔 이유가 있어요. 임계 하나로 올리고 내리면 손실률이 두 값 사이를 오갈 때 5초마다 500kbps와 2.5Mbps를 왕복합니다. 화질이 계속 출렁여서 그냥 낮은 화질로 고정한 것보다 나쁘게 보여요. 내릴 때는 바로 내리고 올릴 때는 천천히 올리는 편이 낫습니다.

비트레이트(Bitrate)는 초당 전송하는 데이터 양이에요.

비트레이트가 높으면?

  • 화질이 좋아짐
  • 네트워크 대역폭을 많이 씀
  • 불안정한 네트워크에서는 끊김

비트레이트가 낮으면?

  • 화질이 떨어짐
  • 네트워크 대역폭을 적게 씀
  • 불안정한 네트워크에서도 안정적

네트워크 상태를 보고 자동으로 조절하면 화질과 안정성 사이에서 그때그때 나은 쪽을 고를 수 있어요.


끊긴 연결을 되살립니다

disconnected는 끊긴 것이 아니라 흔들린 것일 수 있어요. 엘리베이터에 들어갔다 나오는 정도로도 뜨고, 몇 초 뒤에 저절로 connected로 돌아오는 일이 흔합니다. 여기서 바로 재협상을 걸면 회복하는 중인 연결을 끊고 처음부터 다시 붙이게 돼요.

그래서 두 상태를 다르게 다룹니다. failed는 확실히 끝난 것이니 즉시 경로를 다시 찾고, disconnected는 몇 초 기다려 보고 그래도 안 돌아올 때만 움직여요.

let disconnectedTimer = null;

pc.addEventListener("iceconnectionstatechange", () => {
	const state = pc.iceConnectionState;
	console.log("ICE 상태:", state);

	if (state === "failed") {
		pc.restartIce();
		return;
	}

	if (state === "disconnected") {
		// 5초 기다려 보고 그래도 안 돌아오면 경로를 다시 찾습니다
		disconnectedTimer = setTimeout(() => pc.restartIce(), 5000);
		return;
	}

	if (state === "connected" || state === "completed") {
		clearTimeout(disconnectedTimer);
		disconnectedTimer = null;
	}
});

경로를 다시 찾을 때는 pc.restartIce()를 씁니다. 이걸 부르면 브라우저가 알아서 negotiationneeded를 띄워 주니, 앞에서 만들어 둔 offer 경로를 그대로 타면 돼요.

경로를 다시 찾아야 하는 상황은 이렇습니다.

  • Wi-Fi에서 LTE로 전환
  • VPN 연결과 해제
  • 네트워크 환경 변경

기존 경로가 더 이상 통하지 않게 됐으니 candidate 수집부터 다시 하는 것이에요.

이 핸들러도 양쪽이 똑같이 들고 있으면 둘 다 재시작을 걸어 충돌합니다. 앞에서 정한 "먼저 거는 쪽"만 restartIce()를 부르게 두세요.


다 쓴 자원을 정리합니다

통화가 끝나면 켜 둔 것을 되돌려 놓아야 합니다.
트랙과 PeerConnection, 화면을 차례로 정리하는 코드예요.

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);

beforeunload에 걸어두면 페이지를 떠날 때 이 함수가 알아서 돌아요.

리소스 정리가 중요한 이유
  1. 카메라/마이크 해제
    • track.stop() 없이는 카메라 불빛이 계속 켜져있음
    • 다른 앱에서도 사용할 수 없음
  2. 메모리 누수 방지
    • PeerConnection이 열려있으면 계속 메모리 사용
    • 장시간 방치하면 브라우저가 느려짐
  3. 네트워크 리소스 해제
    • pc.close() 없이는 연결이 계속 유지됨
    • 서버에서도 불필요한 리소스 소비

React에서는 이렇게 씁니다.

useEffect(() => {
	const pc = new RTCPeerConnection({ iceServers });
	pcRef.current = pc;

	// 이벤트 리스너 등록과 미디어 추가...

	return () => {
		pcRef.current = null;
		cleanup(pc);
	};
}, []);

언마운트 시점에 cleanup()이 돌아 카메라와 연결이 함께 닫혀요.

pc를 컴포넌트 밖의 변수가 아니라 effect 안에서 만든 이유가 있습니다. 개발 모드의 StrictMode는 effect를 두 번 실행해요. 만드는 곳과 닫는 곳이 갈라져 있으면 첫 번째 정리에서 pc.close()가 돌고, 두 번째 실행이 이미 닫힌 객체를 그대로 씁니다.


마무리

이번 글에서는 RTCPeerConnection API를 생성부터 정리까지 따라가 봤어요.

PeerConnection의 역할
  • 미디어 협상
  • 네트워크 경로 탐색
  • 데이터 전송
  • 상태 관리
연결 순서
  1. PeerConnection 생성
  2. 이벤트 리스너 등록 (먼저!)
  3. 미디어를 추가하면 negotiationneeded 자동 발생
  4. Offer를 보내는 쪽을 한쪽으로 정하기
  5. Offer와 Answer 교환
  6. ICE Candidate 교환 (병렬로 오되 순서는 방어)
핵심 이벤트
  • icecandidate: 연결 경로 찾음
  • track: 상대방 미디어 수신
  • connectionstatechange: 연결 상태 변화
  • negotiationneeded: 재협상 필요
품질과 복구에서 조심할 것
  • 손실률은 누적값이 아니라 직전 값과의 차이로 잰다
  • 내 송신을 줄일 근거는 remote-inbound-rtp에 있다
  • ICE 재시작은 failed에서 즉시, disconnected는 기다려 본 뒤에
  • 종료할 때 트랙과 PeerConnection을 함께 닫는다

다음 편에서는 실시간 채팅과 파일 전송, 게임 데이터 동기화처럼
영상과 음성이 아닌 일반 데이터를 P2P로 주고받는 RTCDataChannel을 다룹니다.

참고자료

댓글

스크롤하면 댓글이 로드됩니다.