WebRTC 프로토콜 실시간 통신 | 시그널링·ICE·STUN/TURN·DTLS·SRTP 실전

이 글의 핵심

로컬에서는 잘 되던 화상 통화가 회사 방화벽이나 모바일망에서 연결되지 않는 문제는 대부분 NAT 통과 단계에서 생깁니다. P2P라도 시그널링 서버가 필요한 이유, TURN 릴레이가 필요한 상황과 비용, 지연·처리량·오버헤드 특성을 짚어 화상 회의나 파일 공유 서비스를 설계할 때의 판단 기준을 제공합니다.

들어가며

WebRTC(Web Real-Time Communication)는 브라우저와 네이티브 앱에서 플러그인 없이 음성·영상·데이터 채널을 P2P(또는 서버를 거치는 준-P2P)로 주고받기 위한 W3C·IETF 표준 묶음입니다. NAT 통과, 코덱 협상, 미디어 암호화가 한 스택에 들어 있어 화상 회의, 저지연 라이브 스트리밍, 게임 음성 채팅, P2P 파일 전송 등에 널리 쓰입니다.

다만 브라우저 API 몇 줄 뒤에는 시그널링 서버, ICE 후보, TURN 비용, 방화벽 정책이 숨어 있습니다. 이 글은 표준 문서와 실무 트러블슈팅을 잇는 전체 구조를 정리하는 것을 목표로 합니다.

왜 WebRTC인가요?

WebRTC는 플러그인 없이 브라우저끼리 저지연 미디어를 주고받는 것을 목표로 하며, NAT 통과·암호화·코덱 협상을 한 스택으로 제공합니다. 수많은 시청자에게 같은 영상을 뿌리는 대규모 방송(HLS 등)과는 역할이 다르고, 양방향 상호작용이 필요한 곳에 강합니다.

프로덕션에서 주의할 점

  • TURN은 미디어를 서버가 그대로 중계하므로 대역과 비용이 큽니다. 지역 배치와 사용량 모니터링 없이 켜 두면 청구액이 예상보다 크게 나올 수 있습니다.
  • 시그널링은 보통 HTTPS/WebSocket이므로, 인증과 세션 하이재킹 방지를 서버에서 따로 챙겨야 합니다.
  • 기업망은 UDP를 차단하는 경우가 흔합니다. TCP/TLS TURN으로 폴백하면 연결은 되지만 지연과 비용이 늘어나므로 정책과 함께 설계해야 합니다.

프로토콜 개요

역사 및 개발 배경

WebRTC는 Google이 GIPS를 인수해 얻은 음성·영상 엔진을 2011년 오픈소스로 공개한 것을 바탕으로, IETF(RTCWEB WG)와 W3C에서 표준화되었습니다. 새로운 전송 프로토콜을 만든 것이 아니라 VP8/VP9/H.264, Opus, RTP/RTCP, ICE, STUN, TURN, DTLS-SRTP, SCTP 같은 기존 인터넷 프로토콜을 조합해 브라우저에서 쓸 수 있는 실시간 스택을 만든 것입니다. 현재 주요 브라우저가 모두 핵심 API를 지원하며, iOS Safari도 iOS 11부터 지원합니다.

OSI 7계층에서의 위치

WebRTC는 한 계층으로 딱 잘리지 않습니다. 전송(UDP/TCP), 세션·표현(미디어·암호화 협상), 애플리케이션(시그널링)이 함께 묶여 있습니다. 실무에서는 “UDP 위의 RTP + 암호화(DTLS-SRTP) + 경로 탐색(ICE)“으로 이해하면 운영과 디버깅이 쉬워집니다.

핵심 특징

특징설명
P2P 우선가능하면 두 단말 사이의 직접 UDP 경로로 미디어를 보냅니다.
NAT traversalICE가 여러 후보 경로를 동시에 검사해 쓸 수 있는 경로를 찾습니다.
보안암호화가 필수입니다. 미디어는 DTLS-SRTP, 데이터 채널은 DTLS로 보호됩니다.
저지연TCP 재전송에 묶이지 않도록 UDP+RTP를 기본으로 합니다.
시그널링 분리SDP를 어떤 방식으로 교환할지는 애플리케이션이 정합니다(WebSocket, HTTP, Firebase 등).

동작 원리

시그널링(Signaling)

시그널링은 SDP(Session Description Protocol)로 코덱, 주소 후보, DTLS 인증서 fingerprint 등을 교환하는 제어 평면입니다. WebRTC 표준은 시그널링 전송 방식을 정하지 않으며, 대부분 HTTPS나 Secure WebSocket을 씁니다. 미디어 자체는 시그널링 경로를 타지 않습니다.

ICE(Interactive Connectivity Establishment)

ICE는 호스트 후보(로컬 주소), 서버 반사 후보(STUN으로 알아낸 공인 주소), 릴레이 후보(TURN 서버 주소)를 모은 뒤, 양쪽 후보를 짝지은 후보 쌍들에 대해 STUN 바인딩 요청으로 연결 검사를 수행합니다. 검사는 우선순위에 따라 여러 쌍을 병렬로 진행하며, 성공한 쌍 중 하나를 지명(nomination)해 실제 미디어 경로로 씁니다. 네트워크가 바뀌면 ICE restart로 이 과정을 다시 할 수 있습니다.

STUN / TURN

  • STUN: “NAT 바깥에서 내 주소와 포트가 어떻게 보이는가”를 알려 줍니다. 이 정보로 직접 연결에 성공하면 서버 비용은 거의 들지 않습니다.
  • TURN: 대칭 NAT나 방화벽 때문에 직접 연결이 불가능할 때 서버가 미디어를 중계합니다. 모든 트래픽이 서버를 지나므로 대역과 비용이 크고, 이를 최소화하는 것이 운영의 핵심입니다.

DTLS

DTLS(Datagram TLS)는 UDP 위에서 TLS와 같은 핸드셰이크를 수행합니다. WebRTC에서는 두 가지 역할을 합니다. 데이터 채널은 DTLS 위에 SCTP를 올려 전송하고, 미디어용 SRTP 키는 이 DTLS 핸드셰이크에서 뽑아냅니다(DTLS-SRTP). 각 피어는 자체 서명 인증서를 쓰며, 상대 인증서가 시그널링으로 받은 SDP의 a=fingerprint와 일치하는지 확인해 중간자 공격을 막습니다. 그래서 시그널링 채널이 신뢰할 수 없으면 암호화도 의미가 약해집니다.

SRTP

SRTP(Secure RTP)는 RTP 페이로드를 암호화하고 헤더와 페이로드 전체의 무결성을 보호합니다. RTP 헤더 자체는 라우팅과 재정렬에 필요해서 암호화되지 않습니다. 키는 위에서 설명한 DTLS 핸드셰이크 결과에서 파생되므로, SDP에는 키 자체가 아니라 인증서 fingerprint만 실립니다.

sequenceDiagram
  participant A as Peer A
  participant Sig as Signaling (HTTPS/WS)
  participant B as Peer B
  participant S as STUN/TURN
  A->>Sig: SDP offer
  Sig->>B: forward offer
  B->>Sig: SDP answer
  Sig->>A: forward answer
  A->>S: ICE binding / allocate
  B->>S: ICE binding / allocate
  A-->>B: ICE connectivity checks (UDP)
  Note over A,B: DTLS handshake, SRTP media

실전 프로그래밍

JavaScript (브라우저, 수동 SDP 교환 데모)

같은 기기의 한 페이지 안에서 시그널링을 복사·붙여넣기로 대신하는 학습용 최소 예제입니다. 실서비스에서는 이 부분을 시그널링 서버로 바꿉니다. 아래 HTML 골격과 스크립트를 같은 디렉터리에 저장하면 됩니다.

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>WebRTC minimal</title></head>
<body>
  <h3>Peer A</h3>
  <button id="aGo">A: create offer</button>
  <pre id="aOffer"></pre>
  <textarea id="aPasteAnswer" rows="6" cols="80" placeholder="Paste answer SDP here"></textarea>
  <button id="aApply">A: set answer</button>
  <h3>Peer B</h3>
  <textarea id="bPasteOffer" rows="6" cols="80" placeholder="Paste offer SDP here"></textarea>
  <button id="bApplyOffer">B: set offer &amp; create answer</button>
  <pre id="bAnswer"></pre>
  <script src="webrtc-demo.js"></script>
</body>
</html>
// webrtc-demo.js — 위 HTML과 같은 디렉터리에 저장
// 변수 선언 및 초기화
const cfg = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
const pcA = new RTCPeerConnection(cfg);
const pcB = new RTCPeerConnection(cfg);
// ICE 후보 수집이 끝나면(candidate === null) 후보가 모두 포함된 SDP를 화면에 표시
pcA.onicecandidate = (e) => {
  if (!e.candidate) document.getElementById("aOffer").textContent = pcA.localDescription.sdp;
};
pcB.onicecandidate = (e) => {
  if (!e.candidate) document.getElementById("bAnswer").textContent = pcB.localDescription.sdp;
};
pcA.onconnectionstatechange = () => console.log("A state:", pcA.connectionState);
pcB.onconnectionstatechange = () => console.log("B state:", pcB.connectionState);
pcA.ontrack = (e) => console.log("A got track", e.streams[0]);
pcB.ontrack = (e) => console.log("B got track", e.streams[0]);
document.getElementById("aGo").onclick = async () => {
  pcA.addTransceiver("video", { direction: "recvonly" });
  const offer = await pcA.createOffer();
  await pcA.setLocalDescription(offer);
};
document.getElementById("bApplyOffer").onclick = async () => {
  const sdp = document.getElementById("bPasteOffer").value;
  await pcB.setRemoteDescription({ type: "offer", sdp });
  const answer = await pcB.createAnswer();
  await pcB.setLocalDescription(answer);
};
document.getElementById("aApply").onclick = async () => {
  const sdp = document.getElementById("aPasteAnswer").value;
  await pcA.setRemoteDescription({ type: "answer", sdp });
};
  • 카메라 없이 recvonly 트랜시버만 만들어 협상 흐름과 연결 상태 변화를 관찰합니다. 실제 영상을 보내려면 getUserMedia로 얻은 트랙을 addTrack으로 추가합니다.
  • createOffer() 직후의 SDP에는 아직 ICE 후보가 들어 있지 않습니다. 이 예제처럼 복사·붙여넣기로 한 번만 교환한다면, 후보 수집이 끝났을 때(onicecandidate에 null이 올 때) localDescription.sdp를 보내야 합니다. 실서비스에서는 기다리지 않고 후보가 생길 때마다 시그널링으로 따로 보내는 trickle ICE가 일반적이며, 수신 측은 addIceCandidate()로 추가합니다.

Python (aiortc, 서버·자동화 테스트)

#!/usr/bin/env python3
# pip install aiortc aiohttp
import asyncio
import json
from aiortc import RTCPeerConnection, RTCSessionDescription
from aiohttp import web
async def offer(request):
    params = await request.json()
    offer_desc = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
    pc = RTCPeerConnection()
    # 실무: 트랙 추가, datachannel 등
    await pc.setRemoteDescription(offer_desc)
    answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    return web.json_response(
        {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
    )
app = web.Application()
app.router.add_post("/offer", offer)
web.run_app(app, port=8080)
  • aiortc는 trickle ICE를 지원하지 않고 setLocalDescription() 안에서 후보 수집을 끝낸 뒤 반환하므로, 위처럼 localDescription.sdp를 바로 돌려주면 됩니다. 실제 서비스에서는 생성한 pc를 어딘가에 보관하고 연결 종료 시 close()해야 합니다.
  • aiortc는 헤드리스 테스트, 간단한 서버 측 처리, 녹화 등에 쓰입니다. 프로덕션 SFU로는 Janus, mediasoup, LiveKit 등을 검토합니다.

C++ (시그널링·미디어 분리 관점)

브라우저 밖의 C++ 네이티브 구현은 libwebrtc(규모가 큼), libdatachannel, GStreamer webrtcbin 등을 씁니다. 가장 간단한 구성은 시그널링 서버만 C++로 만들고 미디어는 브라우저나 모바일 SDK에 맡기는 것입니다. 예를 들어 WebSocket++나 Boost.Beast로 SDP 문자열을 중계하는 서버를 만들고, HTTPS/TLS는 리버스 프록시에서 처리합니다.

// 개념: 시그널링 서버가 offer/answer JSON을 중계 (의사코드)
// 실제로는 JSON 파싱·룸 ID·인증·세션 수명 관리 필요
void relay_sdp(const std::string& from_peer, const std::string& sdp_json) {
  // Redis pub/sub, WebSocket broadcast, DB 저장 등
}

에러 처리·타임아웃

  • iceConnectionState / connectionState 변화를 구독하고, failed가 되면 ICE restart나 TURN 강제 사용(iceTransportPolicy: "relay")으로 재시도합니다. disconnected는 일시적일 수 있으므로 바로 끊지 말고 잠시 기다립니다.
  • 시그널링은 HTTP 5xx나 WebSocket 끊김에 대비해 지수 백오프 재연결을 둡니다.

성능 특성

지연 시간(Latency)

직접 P2P 경로가 잡히면 SFU를 한 번 거치는 경로보다 종단 간 지연이 짧은 경우가 많습니다. TURN 릴레이는 지리적으로 먼 서버를 쓰면 RTT가 크게 늘어납니다.

처리량(Throughput)

대역폭 적응은 혼잡 제어 알고리즘(브라우저에서는 GCC 계열)이 RTCP 피드백(transport-wide congestion control)으로 지연 변화와 손실을 보고 송신 비트레이트를 조절하는 방식입니다. simulcast는 송신자가 여러 해상도를 동시에 보내고, SFU가 수신자 네트워크에 맞는 층을 골라 전달하게 합니다.

오버헤드

UDP·RTP 헤더, SRTP 인증 태그, RTCP, ICE keepalive가 더해집니다. TURN은 서버 쪽 CPU와 대역 부담이 큽니다.

벤치마크 참고

같은 회선에서도 코덱, 해상도, 패킷 손실에 따라 체감 품질이 크게 달라집니다. 실측은 Chrome의 chrome://webrtc-internals나 getStats()로 얻은 RTT, jitter, packetsLost 값으로 확인합니다.


실무 활용 사례

화상 회의

대형 화상 회의 서비스는 자체 인프라와 SFU를 쓰는 경우가 많지만, 브라우저에서 동작하는 부분은 WebRTC 표준 API 위에 만들어집니다.

P2P 파일 공유

데이터 채널(SCTP over DTLS)로 파일을 청크 단위로 보냅니다. 대용량이라면 메시지 크기 제한, bufferedAmount를 이용한 백프레셔, 전송 재개를 애플리케이션에서 설계해야 합니다.

라이브 스트리밍

양방향 상호작용이 필요한 저지연 방송에는 WebRTC가 적합하고, 대규모 일방향 방송은 HLS/DASH에 맡기는 하이브리드 구성이 흔합니다.


최적화 팁

TURN 최소화

  • TURN 서버를 사용자와 가까운 지역에 배치합니다.
  • UDP가 막혀 TCP/TLS TURN으로 폴백하면 지연과 비용이 늘어나므로, 대상 네트워크 정책을 함께 파악해 둡니다.

대역폭 적응

  • 혼잡 제어는 브라우저 구현에 의존하지만, 애플리케이션에서 송신 최대 비트레이트(RTCRtpSender.setParameters의 maxBitrate)와 프레임레이트를 제한하면 안정성이 좋아집니다.

Simulcast

  • 성능이 제각각인 단말이 한 방에 모이면 simulcast와 SFU를 조합해 불필요한 대역 낭비를 줄일 수 있습니다.

흔한 문제와 해결

흔한 실수와 해결

실수결과해결
시그널링만 HTTPS면 미디어도 보호된다고 가정시그널링과 미디어 경로는 분리되어 있음DTLS-SRTP 동작과 TURN 자격 증명을 따로 검증
STUN만 켜고 TURN을 두지 않음대칭 NAT 등에서 연결 실패비용까지 포함해 TURN을 설계
ICE 실패 시 원인 없이 재시도만사용자에게는 무한 로딩iceConnectionState와 getStats()로 원인 분류
개발망에서만 테스트실제 사용자는 방화벽 뒤에 있음기업망·모바일 시나리오를 테스트에 포함

NAT traversal 실패

  • 대칭 NAT나 엔터프라이즈 방화벽 뒤에서는 직접 연결이 불가능하므로 TURN이 필수입니다.
  • 브라우저는 개인정보 보호를 위해 로컬 IP 대신 xxxx.local 형태의 mDNS 호스트 후보를 내보냅니다. 이 후보는 같은 LAN 안에서만 해석되므로, 후보 목록에 mDNS 후보만 있고 STUN으로 얻은 서버 반사 후보가 없다면 STUN 서버 접근이 막혀 있거나 설정이 빠진 것은 아닌지 확인합니다.

방화벽 이슈

  • UDP가 차단되어 있으면 TURN over TLS(443) 같은 허용된 포트로 우회합니다. 지연과 비용은 늘어납니다.
  • 기업 프록시는 WebSocket 시그널링도 막을 수 있으므로, 443 포트만으로 동작하는 구성을 검토합니다.

보안·준수

  • TURN 자격 증명은 오래 쓰는 고정 비밀번호 대신, 서버가 만료 시간이 있는 단기 자격 증명을 발급하는 패턴이 일반적입니다.
  • 녹화나 감사 요구가 있다면 SFU에서 녹화하는 식으로 미디어 경로를 별도로 설계해야 합니다.

전송 계층 기초는 TCP 가이드와 UDP 가이드를 함께 보면 전체 그림이 더 선명해집니다.


자주 묻는 질문 (FAQ)

Q. STUN만 설정했는데 일부 사용자만 연결이 안 되는 이유는 무엇인가요?

A. STUN은 공인 주소를 알아내 직접 연결을 시도하게 할 뿐이라, 대칭 NAT나 기업 방화벽 뒤에서는 직접 연결 자체가 불가능합니다. 이런 환경에서는 미디어를 중계하는 TURN 서버가 필수이며, UDP가 막힌 곳을 위해 TURN over TLS 폴백도 준비해야 합니다. 개발망에서만 테스트하면 이 문제가 드러나지 않으므로 기업망과 모바일 환경을 테스트 시나리오에 포함하는 것이 좋습니다.


같이 보면 좋은 글