WebRTC: NAT를 넘어 미디어와 데이터를 실시간으로 교환하는 통신 기술
웹 실시간 통신(Web Real-Time Communication, WebRTC)은 브라우저와 애플리케이션 사이에서 음성, 영상, 데이터를 낮은 지연으로 교환하는 기술 모음이다. 시그널링(Signaling)으로 연결 정보를 교환하고, ICE·STUN·TURN으로 통신 경로를 찾은 뒤 암호화된 미디어 또는 데이터 채널을 만든다. 연결이 성립하면 애플리케이션
By 송민성
1. 개념
웹 실시간 통신(Web Real-Time Communication, WebRTC)은 브라우저나 네이티브 애플리케이션이 음성, 영상, 임의 데이터를 실시간으로 교환하도록 지원하는 API와 통신 규격의 집합이다.
브라우저에서는 주로 다음 API를 사용한다.
MediaDevices.getUserMedia(): 카메라와 마이크 입력 획득RTCPeerConnection: 연결 협상, 경로 탐색, 미디어 송수신RTCDataChannel: 문자열이나 바이너리 데이터 송수신MediaStream: 오디오·비디오 트랙 묶음
WebRTC가 모든 통신 과정을 제공하는 것은 아니다. 상대 피어를 찾고 세션 기술 프로토콜(Session Description Protocol, SDP)과 ICE 후보를 전달하는 시그널링(Signaling)은 애플리케이션이 직접 구현해야 한다.
시그널링 서버
↑ ↑
│ SDP │ ICE 후보
↓ ↓
피어 A ←────── 실시간 연결 ──────→ 피어 B시그널링에는 웹소켓(WebSocket), HTTP, 서버 전송 이벤트(Server-Sent Events, SSE) 등을 사용할 수 있다. 시그널링 방식은 WebRTC 규격에 포함되지 않는다.
2. 왜 사용하는가
일반적인 HTTP 통신은 요청과 응답에 적합하지만, 음성이나 영상처럼 데이터가 계속 발생하는 통신에는 비효율적이다. 서버가 모든 미디어를 받아 다시 전달하면 서버 대역폭과 처리 비용도 커진다.
WebRTC는 다음 기능을 제공한다.
- 낮은 지연의 음성·영상 전송
- 네트워크 상태에 따른 비트레이트와 화질 조절
- NAT와 방화벽을 고려한 경로 탐색
- 패킷 손실과 지터(Jitter)에 대응하는 미디어 처리
- 연결 구간의 필수 암호화
- 신뢰성이나 순서 보장 여부를 조절할 수 있는 데이터 채널
WebRTC 연결이 항상 직접 연결되는 것은 아니다. NAT나 방화벽 때문에 직접 경로를 만들 수 없으면 TURN 서버가 데이터를 중계한다.
3. 동작 원리
3.1 미디어 획득
브라우저는 사용자 허가를 받은 뒤 카메라와 마이크 트랙을 가져온다.
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
const video = document.querySelector<HTMLVideoElement>("#local-video");
if (video) {
video.srcObject = stream;
}getUserMedia()는 보안 컨텍스트에서만 사용할 수 있다. 배포 환경에서는 HTTPS가 필요하며, localhost는 개발 목적으로 허용된다.
3.2 SDP 제안과 응답
세션 기술 프로토콜(Session Description Protocol, SDP)은 피어가 지원하는 코덱, 미디어 종류, 네트워크 정보 등을 표현한다.
연결을 시작하는 피어는 제안(Offer)을 만든다.
피어 A: createOffer()
피어 A: setLocalDescription()
피어 A ── SDP Offer ──> 피어 B상대 피어는 제안을 적용하고 응답(Answer)을 만든다.
피어 B: setRemoteDescription(Offer)
피어 B: createAnswer()
피어 B: setLocalDescription()
피어 B ── SDP Answer ──> 피어 A
피어 A: setRemoteDescription(Answer)SDP는 시그널링 서버를 통해 전달한다. 시그널링 서버가 미디어 데이터를 처리할 필요는 없다.
3.3 ICE 경로 탐색
상호 연결 설정(Interactive Connectivity Establishment, ICE)은 두 피어가 통신 가능한 네트워크 경로를 찾는 절차다.
각 피어는 여러 ICE 후보(Candidate)를 수집한다.
- 호스트 후보(Host candidate): 로컬 인터페이스 주소
- 서버 반사 후보(Server reflexive candidate): STUN 서버가 확인한 공인 주소
- 릴레이 후보(Relayed candidate): TURN 서버가 할당한 중계 주소
세션 탐색 유틸리티(Session Traversal Utilities for NAT, STUN)는 피어가 NAT 외부에서 보이는 IP 주소와 포트를 확인하도록 돕는다. STUN 서버는 미디어를 중계하지 않는다.
릴레이를 사용하는 NAT 순회(Traversal Using Relays around NAT, TURN)는 직접 연결이 실패했을 때 데이터를 중계한다. TURN을 사용하면 연결 성공률은 높아지지만 서버 대역폭 비용이 발생한다.
트리클 ICE(Trickle ICE)를 사용하면 모든 후보가 수집될 때까지 기다리지 않고 발견한 후보부터 상대에게 전달한다.
피어 A ── host 후보 ─────> 피어 B
피어 A ── srflx 후보 ────> 피어 B
피어 A ── relay 후보 ────> 피어 BICE는 후보 쌍에 연결성 검사(Connectivity check)를 수행하고, 성공한 경로 중 사용할 후보 쌍을 선택한다.
3.4 암호화와 데이터 전송
WebRTC 미디어는 보안 실시간 전송 프로토콜(Secure Real-time Transport Protocol, SRTP)로 암호화한다. 키는 데이터그램 전송 계층 보안(Datagram Transport Layer Security, DTLS) 핸드셰이크를 통해 합의한다.
데이터 채널(Data channel)은 일반적으로 다음 계층을 사용한다.
RTCDataChannel
↓
SCTP
↓
DTLS
↓
ICE가 선택한 전송 경로스트림 제어 전송 프로토콜(Stream Control Transmission Protocol, SCTP)은 여러 독립 스트림, 메시지 경계, 순서 설정, 부분 신뢰성을 지원한다.
4. 코드 예제
다음 예제는 웹소켓 시그널링 서버를 통해 두 브라우저 탭을 연결한다.
프로젝트 설치
npm init -y
npm install express ws
npm install --save-dev typescript tsx esbuild @types/express @types/ws
mkdir -p publicserver.ts
import express from "express";
import { createServer } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
const app = express();
app.use(express.static("public"));
const server = createServer(app);
const webSocketServer = new WebSocketServer({
server,
maxPayload: 64 * 1024,
});
const rooms = new Map<string, Set<WebSocket>>();
const allowedMessageTypes = new Set(["offer", "answer", "candidate"]);
webSocketServer.on("connection", (socket, request) => {
const url = new URL(request.url ?? "/", "http://localhost");
const roomId = url.searchParams.get("room") ?? "demo";
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(roomId)) {
socket.close(1008, "잘못된 방 이름");
return;
}
const room = rooms.get(roomId) ?? new Set<WebSocket>();
if (room.size >= 2) {
socket.send(JSON.stringify({
type: "error",
message: "이 예제는 방마다 두 명만 연결할 수 있습니다.",
}));
socket.close(1008, "방 인원 초과");
return;
}
room.add(socket);
rooms.set(roomId, room);
if (room.size === 2) {
const [first, second] = [...room];
first.send(JSON.stringify({
type: "ready",
initiator: true,
}));
second.send(JSON.stringify({
type: "ready",
initiator: false,
}));
}
socket.on("message", (rawMessage) => {
let message: { type?: string };
try {
message = JSON.parse(rawMessage.toString());
} catch {
return;
}
if (!message.type || !allowedMessageTypes.has(message.type)) {
return;
}
for (const peer of room) {
if (peer !== socket && peer.readyState === WebSocket.OPEN) {
peer.send(JSON.stringify(message));
}
}
});
socket.on("close", () => {
room.delete(socket);
for (const peer of room) {
if (peer.readyState === WebSocket.OPEN) {
peer.send(JSON.stringify({ type: "peer-left" }));
}
}
if (room.size === 0) {
rooms.delete(roomId);
}
});
});
server.listen(3000, () => {
console.log("http://localhost:3000/?room=demo");
});client.ts
type SignalMessage =
| { type: "ready"; initiator: boolean }
| { type: "offer"; sdp: RTCSessionDescriptionInit }
| { type: "answer"; sdp: RTCSessionDescriptionInit }
| { type: "candidate"; candidate: RTCIceCandidateInit }
| { type: "peer-left" }
| { type: "error"; message: string };
const localVideo =
document.querySelector<HTMLVideoElement>("#local-video");
const remoteVideo =
document.querySelector<HTMLVideoElement>("#remote-video");
const statusElement =
document.querySelector<HTMLElement>("#status");
if (!localVideo || !remoteVideo || !statusElement) {
throw new Error("필요한 화면 요소가 없습니다.");
}
const roomId =
new URLSearchParams(location.search).get("room") ?? "demo";
const webSocketProtocol =
location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(
`${webSocketProtocol}//${location.host}/?room=${encodeURIComponent(roomId)}`,
);
const localStreamPromise = navigator.mediaDevices
.getUserMedia({ audio: true, video: true })
.then((stream) => {
localVideo.srcObject = stream;
return stream;
});
let peerConnection: RTCPeerConnection | undefined;
let pendingCandidates: RTCIceCandidateInit[] = [];
function send(message: SignalMessage): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
}
async function createPeerConnection(): Promise<RTCPeerConnection> {
if (peerConnection) {
return peerConnection;
}
const stream = await localStreamPromise;
peerConnection = new RTCPeerConnection({
iceServers: [
{
urls: "stun:stun.l.google.com:19302",
},
],
});
for (const track of stream.getTracks()) {
peerConnection.addTrack(track, stream);
}
peerConnection.onicecandidate = (event) => {
if (event.candidate) {
send({
type: "candidate",
candidate: event.candidate.toJSON(),
});
}
};
peerConnection.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
peerConnection.onconnectionstatechange = () => {
statusElement.textContent =
`연결 상태: ${peerConnection?.connectionState}`;
};
return peerConnection;
}
async function addPendingCandidates(
connection: RTCPeerConnection,
): Promise<void> {
for (const candidate of pendingCandidates) {
await connection.addIceCandidate(candidate);
}
pendingCandidates = [];
}
socket.onmessage = async (event) => {
const message = JSON.parse(event.data) as SignalMessage;
if (message.type === "ready") {
const connection = await createPeerConnection();
if (message.initiator) {
const offer = await connection.createOffer();
await connection.setLocalDescription(offer);
send({
type: "offer",
sdp: connection.localDescription!,
});
}
return;
}
if (message.type === "offer") {
const connection = await createPeerConnection();
await connection.setRemoteDescription(message.sdp);
await addPendingCandidates(connection);
const answer = await connection.createAnswer();
await connection.setLocalDescription(answer);
send({
type: "answer",
sdp: connection.localDescription!,
});
return;
}
if (message.type === "answer") {
const connection = await createPeerConnection();
await connection.setRemoteDescription(message.sdp);
await addPendingCandidates(connection);
return;
}
if (message.type === "candidate") {
const connection = await createPeerConnection();
if (connection.remoteDescription) {
await connection.addIceCandidate(message.candidate);
} else {
pendingCandidates.push(message.candidate);
}
return;
}
if (message.type === "peer-left") {
peerConnection?.close();
peerConnection = undefined;
pendingCandidates = [];
remoteVideo.srcObject = null;
statusElement.textContent = "상대 피어 연결 종료";
return;
}
if (message.type === "error") {
statusElement.textContent = message.message;
}
};public/index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<title>WebRTC 예제</title>
<style>
video {
width: min(480px, 100%);
background: #111;
}
</style>
</head>
<body>
<h1>WebRTC 영상 통화</h1>
<p id="status">상대 피어 대기 중</p>
<h2>내 화면</h2>
<video id="local-video" autoplay muted playsinline></video>
<h2>상대 화면</h2>
<video id="remote-video" autoplay playsinline></video>
<script src="/client.js"></script>
</body>
</html>실행
npx esbuild client.ts --bundle --outfile=public/client.js
npx tsx server.ts브라우저 탭 두 개에서 같은 주소를 연다.
http://localhost:3000/?room=demo예제의 STUN 설정만으로 모든 네트워크에서 연결되지는 않는다. 운영 환경에서는 인증된 TURN 서버를 함께 설정해야 한다.
5. 시간 복잡도 또는 성능 특성
WebRTC 성능은 알고리즘의 시간복잡도보다 네트워크 경로, 참가자 수, 코덱, 해상도, 패킷 손실률의 영향을 크게 받는다.
ICE 후보 검사
로컬 ICE 후보가 L개이고 원격 후보가 R개라면 후보 쌍의 이론적 상한은 L × R개다. 실제 ICE 구현은 후보 우선순위, 중복 제거, 체크리스트 상태를 이용해 모든 조합을 동일하게 검사하지 않는다.
피어 투 피어 메시 구조
참가자 n명이 서로 직접 연결하는 메시(Mesh) 구조의 연결 수는 다음과 같다.
n(n - 1) / 2각 참가자는 최대 n - 1개의 미디어 스트림을 전송한다. 참가자가 늘어날수록 업로드 대역폭과 인코딩 부하가 빠르게 증가하므로 소규모 통화에 적합하다.
SFU 구조
선택적 전달 장치(Selective Forwarding Unit, SFU)를 사용하면 각 참가자는 일반적으로 서버로 하나의 업스트림을 전송한다. SFU는 해당 스트림을 다른 참가자에게 전달한다.
모든 참가자가 모든 영상을 받는다면 서버의 전체 송신량은 참가자 수에 대해 대략 O(n²)로 증가한다. 실제 서비스는 활성 화자 선택, 화질 계층, 구독 제한으로 전송량을 줄인다.
지연과 신뢰성
음성·영상은 지연을 줄이기 위해 손실된 패킷을 무조건 재전송하지 않는다. 수신 측은 지터 버퍼(Jitter buffer)를 사용해 패킷 도착 시간의 흔들림을 흡수하지만, 버퍼가 커지면 재생 지연도 늘어난다.
데이터 채널은 용도에 따라 신뢰성과 순서를 설정할 수 있다.
const reliableChannel =
peerConnection.createDataChannel("chat");
const realtimeChannel =
peerConnection.createDataChannel("position", {
ordered: false,
maxRetransmits: 0,
});채팅이나 파일 전송은 신뢰성 있는 채널이 적합하다. 게임 위치나 마우스 좌표처럼 최신 값이 더 중요한 데이터는 순서 없는 비신뢰 채널로 지연을 줄일 수 있다.
6. 실무 사용 사례
화상 회의
소규모 통화는 피어 투 피어 메시 구조로 구현할 수 있다. 참가자가 많아지면 SFU를 사용해 업로드 대역폭과 클라이언트 인코딩 부하를 줄인다.
화면 공유
getDisplayMedia()로 화면 트랙을 얻어 기존 연결에 추가하거나 카메라 트랙과 교체한다.
const displayStream =
await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
const screenTrack = displayStream.getVideoTracks()[0];
const sender = peerConnection
.getSenders()
.find((item) => item.track?.kind === "video");
if (!sender) {
throw new Error("영상 송신 트랙이 없습니다.");
}
await sender.replaceTrack(screenTrack);
screenTrack.onended = () => {
console.log("화면 공유가 종료되었습니다.");
};실시간 협업
데이터 채널로 커서 위치, 드로잉 이벤트, 게임 상태 등을 전송할 수 있다. 영구 저장, 권한 검사, 충돌 해결은 별도 서버에서 처리해야 한다.
원격 제어와 파일 전송
데이터 채널은 바이너리 데이터도 지원한다. 큰 파일은 작은 청크(Chunk)로 나누고 bufferedAmount를 확인하며 전송해야 메모리 사용량이 급증하지 않는다.
7. 주의할 점
시그널링 서버 인증
SDP와 ICE 후보에는 네트워크 관련 정보가 포함될 수 있다. 방 식별자만으로 사용자를 연결하지 말고 인증, 참가 권한, 메시지 크기 제한, 요청 속도 제한을 적용한다.
TURN 서버 준비
STUN만 사용하는 서비스는 일부 회사망, 이동통신망, 대칭형 NAT 환경에서 연결에 실패할 수 있다. 운영 환경에서는 UDP뿐 아니라 TCP와 TLS 경로를 지원하는 TURN 구성을 준비한다.
연결 상태 복구
네트워크가 Wi-Fi에서 이동통신망으로 바뀌면 기존 후보가 유효하지 않을 수 있다. connectionState와 iceConnectionState를 감시하고 필요하면 ICE 재시작(ICE restart)을 수행한다.
SDP 직접 수정
SDP 문자열을 정규 표현식으로 수정하면 브라우저와 코덱 조합에 따라 오류가 발생하기 쉽다. RTCRtpSender.setParameters(), 트랜시버(Transceiver), 표준 API로 해결할 수 있는지 먼저 확인한다.
다자간 연결 구조
참가자가 늘어날 가능성이 있다면 메시 구조를 그대로 확장하지 않는다. SFU 사용 여부, 동시 영상 수, 화면 공유, 녹화, 화질 계층을 기준으로 구조를 결정한다.
8. 핵심 정리
- WebRTC는 음성, 영상, 데이터를 낮은 지연으로 교환하는 통신 기술 모음이다.
- 시그널링은 WebRTC 규격 밖에 있으며 애플리케이션이 직접 구현한다.
- SDP는 통신 조건을 협상하고 ICE는 실제 통신 경로를 찾는다.
- STUN은 외부 주소를 확인하고 TURN은 직접 연결 실패 시 데이터를 중계한다.
- 미디어는 DTLS-SRTP로 보호되며 데이터 채널은 SCTP와 DTLS를 사용한다.
- 소규모 연결은 피어 투 피어 메시 구조로 처리할 수 있지만, 다자간 통화는 주로 SFU를 사용한다.