WebRTC 시그널링 서버: Node.js 예제를 포함한 완벽 가이드
WebRTC 신호화 서버: Node.js 예제를 포함한 완벽한 가이드
WebRTC를 통해 브라우저들은 영상, 음성, 데이터를 서로 직접 전송할 수 있습니다. 플러그인, Flash, 미디어 스트림에 접근하는 중개 서버 없이 말입니다. 하지만 처음 시도하는 모든 개발자가 막히는 부분이 있습니다. 두 브라우저가 인터넷에서 마법처럼 서로를 찾을 수는 없다는 것입니다. 그들에게 필요한 것은 중개인이고, 그것이 신호화 서버입니다.
저는 화상 상담 플랫폼, 멀티플레이 브라우저 게임, 협업 편집 도구를 위한 신호화 서버를 구축했습니다. 매번 신호화 계층이 혼란의 원천이었는데, 어렵기 때문이 아니라 WebRTC가 의도적으로 이를 명시하지 않았기 때문입니다. 명세는 어떤 정보를 교환해야 하는지는 말하지만 어떻게 교환할지에 대해서는 아무것도 말하지 않습니다. 이는 축복이자 저주입니다.
이 가이드는 모든 것을 다룹니다. 신호화가 실제로 어떻게 작동하는지, offer/answer 프로토콜과 SDP, ICE 후보 교환과 NAT 순회, 그리고 두 가지 프로덕션 레벨의 구현입니다. 하나는 Node.js + Socket.io이고 다른 하나는 Supabase Realtime을 사용합니다. 우리는 의사 코드가 아닌 실제 코드를 살펴볼 것입니다.
목차
- 신호화 서버가 실제로 하는 역할은?
- Offer/Answer 프로토콜과 SDP
- ICE 후보 및 NAT 순회
- 신호화 전송 옵션 비교
- 구현 1: Node.js + Socket.io
- 구현 2: Supabase Realtime
- 프로덕션 강화 팁
- TURN 서버: 직접 연결 실패 시
- FAQ

신호화 서버가 실제로 하는 역할은?
전문 용어를 벗겨내봅시다. 신호화 서버는 메시지 중계기입니다. 그게 전부입니다. 두 피어가 직접 연결되기 전에 몇 킬로바이트의 텍스트를 교환해야 합니다. 신호화 서버가 이 메시지들을 왕복으로 전달합니다.
구체적으로, 신호화 서버는 다음을 처리합니다:
- 피어 발견 -- 사용자 A가 사용자 B에게 "나는 당신과 연결하고 싶다"고 말해야 합니다. 누군가 이 메시지를 라우팅해야 합니다.
- SDP 교환 -- 두 피어는 미디어 능력(코덱, 암호화 등)을 설명하는 세션 설명 프로토콜 블롭을 생성합니다. 이들은 A에서 B로, 다시 B에서 A로 가야 합니다.
- ICE 후보 중계 -- 각 피어는 잠재적 네트워크 경로(후보)를 발견하고 신호화 서버를 통해 다른 피어로 보냅니다.
- 세션 라이프사이클 -- 연결 시작, 재협상, 종료.
신호화 서버가 하지 않는 것: 절대 오디오나 비디오 데이터에 접촉하지 않습니다. 두 피어가 직접 연결을 설정하면 신호화 서버의 역할은 본질적으로 끝납니다. 미디어는 피어 간에 흐릅니다.
파티에서 두 사람을 소개하는 것처럼 생각해보세요. 당신이 다가가서 "이봐, Sarah, 이 사람은 Mike야. 너희 둘 다 등산을 좋아해"라고 말합니다. 그러면 당신은 떠납니다. Sarah와 Mike가 그것을 처리합니다. 당신이 그 시나리오에서 신호화 서버입니다.
WebRTC가 신호화를 표준화하지 않는 이유
이것은 간과한 것이 아니라 의도적인 설계 선택입니다. WebRTC 명세 작성자들은 대부분의 애플리케이션이 이미 사용자 간의 통신 채널을 가지고 있다는 것을 인식했습니다. 채팅 시스템, 매칭 서비스, 협업 플랫폼 말입니다. 특정 신호화 프로토콜을 강제하면 모든 앱이 통신 계층 두 개를 구현해야 하는 대신 이미 존재하는 것을 활용할 수 없게 됩니다.
데이팅 앱은 이미 메시징 인프라를 가지고 있습니다. 원격 의료 플랫폼은 이미 예약 시스템을 가지고 있습니다. 게임 플랫폼은 이미 로비 서버를 가지고 있습니다. WebRTC는 당신이 이미 가진 것을 사용하여 SDP와 ICE 메시지를 전달할 수 있게 합니다.
당신은 문자 그대로 USB 스틱을 운반하는 비둘기를 사용할 수도 있습니다(지연이 중요하지 않다면). (지연은 중요하지만, 요점은 유지됩니다.)
Offer/Answer 프로토콜과 SDP
핵심 신호화 교환은 offer/answer 모델이라는 패턴을 따릅니다. 이것은 SIP(세션 시작 프로토콜)에서 차용했습니다. 다음이 흐름입니다:
- 피어 A가
RTCPeerConnection을 생성하고createOffer()를 호출합니다 - 피어 A는
setLocalDescription()을 통해 offer를 로컬 설명으로 설정합니다 - 피어 A는 신호화 서버를 통해 offer(SDP 블롭)를 피어 B로 전송합니다
- 피어 B는 offer를 수신하고
setRemoteDescription()을 통해 원격 설명으로 설정합니다 - 피어 B는
createAnswer()를 호출합니다 - 피어 B는 answer를 로컬 설명으로 설정합니다
- 피어 B는 신호화 서버를 통해 answer를 다시 전송합니다
- 피어 A는 answer를 수신하고 원격 설명으로 설정합니다
이 교환 후, 두 피어는 서로의 미디어 능력을 알게 됩니다. 하지만 아직 연결할 수 없습니다. 네트워크 경로 정보가 필요하며, 이것은 ICE에서 옵니다.
SDP 블롭 내부는 뭔가요?
SDP는 1996년에 설계된 것처럼 보이는 텍스트 형식입니다. 왜냐하면 실제로 그렇기 때문입니다. 여기 간략한 예시가 있습니다:
v=0
o=- 4625943584070133116 2 IN IP4 127.0.0.1
s=-
t=0 0
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:aR7B
a=ice-pwd:hN3Y2mDoS68sGLprHvBGNbKp
a=fingerprint:sha-256 D1:3C:A0:...
a=setup:actpass
a=mid:0
a=rtpmap:96 VP8/90000
a=rtpmap:97 H264/90000
중요한 부분: 지원되는 코덱(VP8, H264), 암호화 지문, ICE 자격증명, 미디어 타입이 나열되어 있습니다. 당신의 신호화 서버는 이것 중 어떤 것도 파싱할 필요가 없습니다. 블롭을 불명확한 문자열로 통과시키기만 하면 됩니다.
코드의 SDP Offer/Answer
다음은 offer를 하는 피어의 클라이언트 측 JavaScript입니다:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your-turn-server.com:3478', username: 'user', credential: 'pass' }
]
});
// 로컬 미디어 트랙 추가
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Offer 생성 및 전송
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 신호화 서버로 전송
signalingServer.send({
type: 'offer',
sdp: pc.localDescription,
targetPeerId: 'peer-b-id'
});
그리고 answer 하는 피어:
signalingServer.on('offer', async (data) => {
await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingServer.send({
type: 'answer',
sdp: pc.localDescription,
targetPeerId: data.fromPeerId
});
});
ICE 후보 및 NAT 순회
SDP 협상은 어떤 미디어를 보낼지 처리합니다. ICE는 네트워크에서 다른 피어에 도달하는 방법을 처리합니다. 이것은 흥미로운 부분이며 실제 세계에서 대부분의 WebRTC 연결이 실제로 실패하는 곳입니다.
NAT 문제
대부분의 기기는 NAT(네트워크 주소 변환)를 실행하는 라우터 뒤에 있습니다. 당신의 노트북은 192.168.1.42의 로컬 IP를 가질 수 있지만, 외부 세계는 당신의 라우터의 공개 IP를 봅니다. 두 피어가 모두 NAT 뒤에 있으면, 어느 쪽도 다른 쪽에 도달하는 방법을 모릅니다.
ICE(대화형 연결 설정)는 여러 후보 경로를 수집하고 모두를 시도함으로써 이 문제를 해결합니다:
| 후보 타입 | 출처 | 성공률 | 지연 |
|---|---|---|---|
| Host | 로컬 네트워크 인터페이스 | 같은 LAN에서만 작동 | 가장 낮음 |
| Server-reflexive (srflx) | STUN 서버를 통해 발견 | ~80-85%의 연결 | 낮음 |
| Relay | TURN 서버를 통해 할당 | ~99%+ (폴백) | 더 높음 (중계됨) |
ICE 후보 Trickling
여기 중요한 최적화가 있습니다: trickle ICE. 모든 후보가 수집될 때까지 기다리는 대신, 발견되는 즉시 각 후보를 원격 피어로 보냅니다. 이것은 연결 설정에서 초 단위로 시간을 절약할 수 있습니다.
// Caller 측: 도착하는 즉시 후보 전송
pc.onicecandidate = (event) => {
if (event.candidate) {
signalingServer.send({
type: 'ice-candidate',
candidate: event.candidate,
targetPeerId: remotePeerId
});
}
};
// Receiver 측: 도착하는 즉시 후보 추가
signalingServer.on('ice-candidate', async (data) => {
try {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} catch (err) {
// 원격 설명이 설정되기 전에 후보가 도착할 수 있습니다
// 그들을 버퍼링하고 나중에 추가합니다
console.error('ICE 후보 추가 실패:', err);
}
});
그 catch 블록은 실제 신경 쓰이는 부분을 암시합니다: ICE 후보는 setRemoteDescription()을 호출하기 전에 도착할 수 있습니다. 당신은 그들을 버퍼링해야 합니다. 아래의 프로덕션 구현에서 이것을 제대로 처리할 것입니다.
ICE 후보 버퍼링
거의 모든 튜토리얼이 이것을 건너뛰며, 프로덕션에서 버그를 유발합니다. 여기 패턴이 있습니다:
let pendingCandidates = [];
let remoteDescriptionSet = false;
signalingServer.on('ice-candidate', async (data) => {
if (remoteDescriptionSet) {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} else {
pendingCandidates.push(data.candidate);
}
});
// 원격 설명 설정 후:
await pc.setRemoteDescription(remoteDesc);
remoteDescriptionSet = true;
for (const candidate of pendingCandidates) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
pendingCandidates = [];

신호화 전송 옵션 비교
기본적으로 신호화를 위해 어떤 양방향 통신 채널이든 사용할 수 있습니다. 일반적인 옵션들이 어떻게 비교되는지는 다음과 같습니다:
| 전송 | 장점 | 단점 | 최적 용도 |
|---|---|---|---|
| WebSocket (raw) | 낮은 지연, 전이중, 경량 | 수동 재연결 로직, 방 없음 | 간단한 1:1 앱 |
| Socket.io | 자동 재연결, 방, 폴링 폴백 | 더 큰 라이브러리 크기(~45KB), 표준 아님 | 대부분의 웹 앱 |
| Supabase Realtime | 관리형 인프라, 내장 인증, Postgres 통합 | 공급업체 종속, 메시지 크기 제한 | Supabase 기반 앱 |
| Firebase Realtime DB | 관리형, 좋은 문서, 오프라인 지원 | 공급업체 종속, 규모 시 가격 | Firebase 기반 앱 |
| HTTP polling | 모든 곳에서 작동, 구현 간단 | 높은 지연, 서버 부하 | 레거시 환경 |
| SIP over WebSocket | 전화 시스템과의 상호운용성 | 복잡함, 대부분의 웹 앱에 과도함 | VoIP 통합 |
대부분의 프로젝트에서는 Socket.io나 Supabase Realtime 같은 관리형 실시간 서비스가 올바른 선택입니다. 둘 다 구축해봅시다.
구현 1: Node.js + Socket.io
이것은 가장 일반적인 접근 방식이며 신호화 인프라에 대한 완전한 제어를 원하는 팀에게 추천하는 것입니다. 우리는 여러 피어가 있는 방을 지원하는 신호화 서버를 구축하고 있습니다.
서버 설정
import { createServer } from 'http';
import { Server } from 'socket.io';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
methods: ['GET', 'POST']
}
});
// 방과 그 참가자들을 추적합니다
const rooms = new Map();
io.on('connection', (socket) => {
console.log(`피어 연결됨: ${socket.id}`);
socket.on('join-room', (roomId) => {
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(socket.id);
// 방의 기존 피어들에게 알림
socket.to(roomId).emit('peer-joined', { peerId: socket.id });
// 새 피어에게 기존 피어 목록을 보냅니다
const existingPeers = [...rooms.get(roomId)].filter(id => id !== socket.id);
socket.emit('existing-peers', { peers: existingPeers });
});
socket.on('offer', ({ targetPeerId, sdp }) => {
io.to(targetPeerId).emit('offer', {
sdp,
fromPeerId: socket.id
});
});
socket.on('answer', ({ targetPeerId, sdp }) => {
io.to(targetPeerId).emit('answer', {
sdp,
fromPeerId: socket.id
});
});
socket.on('ice-candidate', ({ targetPeerId, candidate }) => {
io.to(targetPeerId).emit('ice-candidate', {
candidate,
fromPeerId: socket.id
});
});
socket.on('disconnect', () => {
// 방 정리
for (const [roomId, peers] of rooms.entries()) {
if (peers.has(socket.id)) {
peers.delete(socket.id);
socket.to(roomId).emit('peer-left', { peerId: socket.id });
if (peers.size === 0) {
rooms.delete(roomId);
}
}
}
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`신호화 서버가 포트 ${PORT}에서 실행 중입니다`);
});
클라이언트 측 통합
import { io } from 'socket.io-client';
const socket = io('wss://your-signaling-server.com');
const peerConnections = new Map();
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// 프로덕션을 위해 TURN 서버 추가
]
};
function createPeerConnection(remotePeerId) {
const pc = new RTCPeerConnection(ICE_SERVERS);
let pendingCandidates = [];
let isRemoteDescSet = false;
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', {
targetPeerId: remotePeerId,
candidate: event.candidate
});
}
};
pc.ontrack = (event) => {
// 원격 스트림을 비디오 요소에 첨부합니다
const remoteVideo = document.getElementById(`video-${remotePeerId}`);
if (remoteVideo) {
remoteVideo.srcObject = event.streams[0];
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
console.warn(`${remotePeerId}에 대한 연결 ${pc.connectionState}`);
// 재연결 로직을 여기에 구현하세요
}
};
// 연결과 그 상태를 저장합니다
peerConnections.set(remotePeerId, {
pc,
pendingCandidates,
isRemoteDescSet,
setRemoteDescDone() {
this.isRemoteDescSet = true;
this.pendingCandidates.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
this.pendingCandidates = [];
}
});
return pc;
}
// 방에 참가합니다
socket.emit('join-room', 'my-room-id');
// 새 피어가 참가하면, offer를 생성합니다
socket.on('peer-joined', async ({ peerId }) => {
const pc = createPeerConnection(peerId);
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { targetPeerId: peerId, sdp: pc.localDescription });
});
socket.on('offer', async ({ sdp, fromPeerId }) => {
const pc = createPeerConnection(fromPeerId);
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
peerConnections.get(fromPeerId).setRemoteDescDone();
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { targetPeerId: fromPeerId, sdp: pc.localDescription });
});
socket.on('answer', async ({ sdp, fromPeerId }) => {
const conn = peerConnections.get(fromPeerId);
await conn.pc.setRemoteDescription(new RTCSessionDescription(sdp));
conn.setRemoteDescDone();
});
socket.on('ice-candidate', ({ candidate, fromPeerId }) => {
const conn = peerConnections.get(fromPeerId);
if (conn.isRemoteDescSet) {
conn.pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
conn.pendingCandidates.push(candidate);
}
});
이것은 방에 있는 여러 피어를 처리하고, ICE 후보를 제대로 버퍼링하고, 연결 해제 시 정리합니다. 이것은 우리가 Next.js 애플리케이션에 실시간 기능을 구축할 때 사용하는 패턴입니다.
구현 2: Supabase Realtime
Supabase를 이미 사용 중이거나 자신의 WebSocket 서버를 실행하는 것을 피하고 싶다면, Supabase Realtime 채널은 신호화에 완벽하게 작동합니다. 이 접근 방식은 Supabase의 Broadcast 기능을 사용합니다. 데이터베이스 쓰기가 필요하지 않습니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
const roomId = 'video-call-room-123';
const myPeerId = crypto.randomUUID();
const channel = supabase.channel(roomId, {
config: { broadcast: { self: false } }
});
// 신호화 메시지 듣기
channel
.on('broadcast', { event: 'offer' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleOffer(payload);
}
})
.on('broadcast', { event: 'answer' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleAnswer(payload);
}
})
.on('broadcast', { event: 'ice-candidate' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleIceCandidate(payload);
}
})
.on('broadcast', { event: 'peer-joined' }, ({ payload }) => {
if (payload.peerId !== myPeerId) {
initiateConnection(payload.peerId);
}
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 존재 공표
await channel.send({
type: 'broadcast',
event: 'peer-joined',
payload: { peerId: myPeerId }
});
}
});
// Offer 전송
async function sendOffer(targetPeerId, sdp) {
await channel.send({
type: 'broadcast',
event: 'offer',
payload: { sdp, fromPeerId: myPeerId, targetPeerId }
});
}
// Answer 전송
async function sendAnswer(targetPeerId, sdp) {
await channel.send({
type: 'broadcast',
event: 'answer',
payload: { sdp, fromPeerId: myPeerId, targetPeerId }
});
}
// ICE 후보 전송
async function sendIceCandidate(targetPeerId, candidate) {
await channel.send({
type: 'broadcast',
event: 'ice-candidate',
payload: { candidate, fromPeerId: myPeerId, targetPeerId }
});
}
Supabase 접근 방식은 주목할 만한 장점이 있습니다: Supabase의 Presence 기능을 사용하여 무료로 존재 추적을 얻을 수 있습니다. 추가 코드 없이 방의 사용자를 볼 수 있습니다. 트레이드오프는 broadcast 메시지가 모든 채널 구독자에게 표시된다는 것입니다. 당신은 클라이언트 측에서 targetPeerId로 필터링하고 있으며, 이는 신호화에 문제없습니다(민감하지 않은 데이터이며, 실제 미디어는 암호화됨).
Supabase에서 구축하는 팀의 경우, 이것은 인프라에서 전체 서버를 제거합니다. 우리는 실시간 협업이 요구사항이었던 헤드리스 CMS 프로젝트에서 이 패턴을 사용했습니다.
프로덕션 강화 팁
개발에서 신호화를 작동시키는 것은 간단합니다. 프로덕션에서 신뢰할 수 있게 유지하는 것은 실제 작업이 시작되는 곳입니다.
1. 메시지 순서 지정 및 중복 제거
WebSocket 메시지는 재연결 중에 순서대로 도착할 수 있습니다. 메시지에 시퀀스 번호를 추가하고 클라이언트에서 재정렬을 처리합니다:
let messageSeq = 0;
function sendSignalingMessage(type, payload) {
socket.emit(type, { ...payload, seq: ++messageSeq, timestamp: Date.now() });
}
2. Heartbeat 및 재연결
Socket.io는 재연결을 자동으로 처리하지만, 재연결 후 방에 다시 참가해야 합니다:
socket.on('connect', () => {
if (currentRoom) {
socket.emit('join-room', currentRoom);
}
});
3. Rate Limiting
ICE 후보 trickling은 빠른 연속으로 수십 개의 메시지를 생성할 수 있습니다. 바쁜 서버에서는 이것이 누적됩니다. 소켓당 rate limiting을 구현합니다:
import { RateLimiterMemory } from 'rate-limiter-flexible';
const rateLimiter = new RateLimiterMemory({
points: 50, // 메시지
duration: 10, // 초당 10초
});
io.on('connection', (socket) => {
socket.use(async ([event, ...args], next) => {
try {
await rateLimiter.consume(socket.id);
next();
} catch {
next(new Error('Rate limit exceeded'));
}
});
});
4. 인증
프로덕션에서는 절대 인증 없이 신호화 서버를 실행하지 마세요. Socket.io에서는 미들웨어를 사용합니다:
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = await verifyJWT(token);
socket.data.userId = user.id;
next();
} catch {
next(new Error('Authentication failed'));
}
});
5. 수평 확장
단일 Node.js 프로세스는 수천 개의 신호화 연결을 처리할 수 있습니다(신호화는 경량입니다. 텍스트 메시지만). 한 서버 너머로 확장해야 할 때는 @socket.io/redis-adapter를 사용하세요:
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
이것은 socket.to(peerId).emit()이 여러 서버 인스턴스에서 작동하도록 보장합니다.
TURN 서버: 직접 연결 실패 시
STUN은 약 80-85%의 연결에서 작동합니다. 나머지 15-20%(대칭 NAT, 제한적인 방화벽, 기업 네트워크)의 경우, 미디어 트래픽을 중계하기 위해 TURN 서버가 필요합니다.
2026년에 TURN을 위한 당신의 옵션은 다음을 포함합니다:
| 공급자 | 가격 (대략) | 참고 |
|---|---|---|
| Cloudflare Calls TURN | 무료 계층 사용 가능, 사용량 기반 | 대부분의 프로젝트에 최고의 가치 |
| Twilio TURN | $0.40/GB | 신뢰할 수 있음, 잘 문서화됨 |
| Metered.ca | 무료 500GB/월, 그 다음 $0.40/GB | 인디 프로젝트에 인기 |
| Self-hosted coturn | 서버 비용만 | 완전한 제어, 운영 부담 |
| Xirsys | $24.99/월부터 | 글로벌 PoP |
항상 ICE 설정에 최소한 하나의 TURN 서버를 포함하세요. 사용자에게 "대부분의 사람들에게는 작동합니다"라고 말하는 것은 VP가 호텔 WiFi에서 당신의 제품을 데모하려고 할 때 수용 가능하지 않습니다.
신뢰할 수 있게 엔터프라이즈 네트워크에서 작동해야 하는 것을 구축하고 있다면, 우리에게 연락하세요. 우리는 팀이 까다로운 edge 케이스를 처리하는 WebRTC 솔루션을 설계하는 데 도움을 주었습니다.
FAQ
WebRTC 신호화 서버란 무엇인가요?
신호화 서버는 두 WebRTC 피어가 직접 연결을 설정하는 데 필요한 정보를 교환하는 것을 도와주는 메시지 중계기입니다. SDP offer와 answer(미디어 능력을 설명)와 ICE 후보(네트워크 경로를 설명)를 전달합니다. 신호화 서버는 오디오나 비디오 데이터에 절대 접촉하지 않습니다. 피어가 연결되면, 미디어는 그들 사이에 직접 흐릅니다.
WebRTC가 표준 신호화 프로토콜을 포함하지 않는 이유는 뭔가요?
WebRTC 명세는 의도적으로 신호화를 명시하지 않습니다. 대부분의 애플리케이션은 이미 사용자 간의 통신 채널을 가지고 있기 때문입니다. 채팅 앱, 매칭 서비스, 또는 협업 도구는 신호화를 위해 기존 인프라를 재사용할 수 있습니다. 이 유연성은 WebSocket, HTTP, SIP, XMPP 또는 텍스트 메시지를 전달할 수 있는 다른 전송을 사용할 수 있다는 의미입니다.
SDP offer와 SDP answer의 차이는 뭔가요?
SDP offer는 연결을 시작하는 피어에 의해 생성됩니다. 그 피어의 미디어 능력을 설명합니다. 지원되는 코덱, 암호화 방법, 미디어 타입을 말입니다. SDP answer는 수신 피어에 의해 생성되며, offer에서 지원하는 능력 중 어떤 것을 지원하는지 확인합니다. 함께, 그들은 양 피어가 미디어 세션에 사용할 공유 매개변수를 협상합니다.
ICE 후보란 무엇이며 왜 그들을 교환해야 하나요?
ICE 후보는 피어가 도달할 수 있는 잠재적 네트워크 경로입니다. 각 피어는 로컬 네트워크 인터페이스를 확인하고, STUN 서버에 공개 주소를 쿼리하고, TURN 서버에 중계 주소를 할당하여 후보를 발견합니다. 이 후보들은 신호화 서버를 통해 다른 피어로 보내져야 하므로 양쪽이 여러 경로를 시도할 수 있고 NAT와 방화벽을 통해 작동하는 경로를 찾을 수 있습니다.
Supabase Realtime을 WebRTC 신호화 서버로 사용할 수 있나요?
네. Supabase Realtime의 Broadcast 기능은 데이터베이스 쓰기가 필요하지 않기 때문에 신호화에 잘 작동합니다. 각 방을 위한 채널을 생성하고, offer/answer/ICE 메시지를 broadcast하고, 클라이언트 측에서 target peer ID로 필터링합니다. Supabase를 이미 사용 중인 프로젝트가 별도의 신호화 서버를 실행하지 않으려는 경우 훌륭한 선택입니다.
신호화 서버가 동시에 몇 개의 연결을 처리할 수 있나요?
단일 Node.js + Socket.io 프로세스는 일반적으로 10,000–50,000개의 동시 신호화 연결을 처리할 수 있으며, 메시지 빈도와 서버 리소스에 따라 다릅니다. 신호화 트래픽은 경량입니다. 더 큰 규모의 경우 Redis 어댑터를 Socket.io와 함께 사용하여 여러 서버 인스턴스에 연결을 분산시킵니다. WebRTC의 병목은 거의 신호화 서버가 아닙니다. 일반적으로 TURN 중계 대역폭입니다.
프로덕션에서 TURN 서버가 필요한가요?
네. STUN이 약 80-85%의 연결을 처리하지만, 약 15-20%의 사용자가 대칭 NAT나 제한적인 방화벽 뒤에 있어 직접 연결을 방지합니다. TURN 서버가 없으면 이 사용자들은 연결할 수 없습니다. 엔터프라이즈 환경에서 TURN 없이 실패율은 훨씬 더 높을 수 있습니다. Cloudflare Calls, Twilio, Metered.ca 같은 서비스는 무료 계층으로 TURN을 제공합니다.
신호화 서버가 활성 통화 중에 다운되면 어떻게 되나요?
WebRTC 연결이 설정되면, 신호화 서버에 더 이상 의존하지 않습니다. 기존 통화는 신호화 서버가 충돌해도 계속 작동합니다. 하지만 새 연결은 설정될 수 없으며, 기존 연결이 재협상되어야 하면(예를 들어, 화면 공유 추가), 그것은 실패합니다. 클러스터링이나 관리형 서비스를 사용하여 신호화 서버의 높은 가용성을 설계해야 합니다.