kimbab.se WebRTC 연동 가이드
채팅 웹이나 앱이 kimbab.se의 시그널링 서버와 STUN/TURN 서버를 통해 서로 연결되도록 설정하는 방법입니다. 이 문서의 규칙대로만 붙이면 같은 방에 들어온 사용자끼리 P2P로 메시지를 주고받을 수 있습니다.
1구조 한눈에 보기
연결은 두 단계로 나뉩니다. 먼저 시그널링 서버가 두 사용자를 소개해 주고(연결 정보 교환), 그다음 두 사용자가 직접 연결해서 채팅 메시지를 주고받습니다. 직접 연결이 막힌 네트워크(회사망, 일부 LTE)에서는 TURN 서버가 중간에서 데이터를 대신 전달합니다.
sequenceDiagram
participant A as 사용자 A (먼저 입장)
participant S as 시그널링 서버
wss://kimbab.se/signaling
participant B as 사용자 B (나중 입장)
A->>S: 접속 ?room=abc
S-->>A: welcome (id, peers=[], iceServers)
B->>S: 접속 ?room=abc
S-->>B: welcome (id, peers=[A], iceServers)
S-->>A: peer-joined (B)
B->>S: offer (to: A)
S-->>A: offer (from: B)
A->>S: answer (to: B)
S-->>B: answer (from: A)
A-)B: candidate (서버 경유, 여러 번)
B-)A: candidate (서버 경유, 여러 번)
A--)B: RTCDataChannel 연결 (직접 또는 TURN 중계)
Note over A,B: 이후 채팅 메시지는 시그널링 서버를 거치지 않음
| 구성 요소 | 역할 | 클라이언트가 할 일 |
|---|---|---|
| 시그널링 서버 | 같은 방 사용자끼리 SDP offer/answer와 ICE candidate를 전달 | WebSocket으로 접속해서 JSON 메시지 송수신 |
| STUN | 내 공인 IP/포트 알려주기 (srflx 후보) | iceServers에 넣기만 하면 됨 |
| TURN | 직접 연결 실패 시 데이터 중계 (relay 후보) | welcome으로 받은 임시 계정을 iceServers에 넣기 |
2접속 정보
| 용도 | 주소 | 비고 |
|---|---|---|
| 시그널링 | wss://kimbab.se/signaling?room=<방이름> | 방 이름: 영문·숫자·- _ ., 최대 64자. 생략하면 lobby |
| 상태 확인 | https://kimbab.se/signaling/health | ok connections=N |
| STUN | stun:kimbab.se:3478 | 인증 없음 |
| TURN (UDP) | turn:kimbab.se:3478?transport=udp | 임시 계정 필요 (12시간 유효). welcome 메시지에 자동으로 들어옴 |
| TURN (TCP) | turn:kimbab.se:3478?transport=tcp | |
| TURN (TLS) | turns:kimbab.se:5349?transport=tcp |
welcome 메시지의 iceServers 배열이 STUN과 TURN 주소, 그리고 유효한 TURN 계정까지 담고 있습니다. 이 배열을 RTCPeerConnection 설정에 그대로 넘기면 됩니다. TURN 비밀번호는 서버가 매번 새로 만들고 12시간 후 만료되므로 앱에 고정값으로 넣으면 안 됩니다.
welcome으로 받는 iceServers는 이런 모양입니다.
[
{ "urls": ["stun:kimbab.se:3478"] },
{
"urls": [
"turn:kimbab.se:3478?transport=udp",
"turn:kimbab.se:3478?transport=tcp",
"turns:kimbab.se:5349?transport=tcp"
],
"username": "1790487411:4",
"credential": "wbOZBZA0Y54xMoeMvYI6Mg+1n2c="
}
]3시그널링 메시지
모든 메시지는 WebSocket 텍스트 프레임의 JSON 객체입니다. 서버는 내용을 해석하지 않고 전달만 합니다. 서버가 붙이거나 읽는 필드는 to와 from 두 개뿐입니다.
서버가 보내는 메시지 서버 → 클라이언트
| type | 언제 | 필드 |
|---|---|---|
welcome | 접속 직후 1회 | id 내 ID, room, peers 이미 방에 있는 사람 ID 배열, iceServers |
peer-joined | 누군가 방에 들어옴 | id |
peer-left | 누군가 나감 (연결 끊김 포함) | id |
error | 방이 가득 참 (직후 연결 종료) | message: "room full" |
클라이언트가 보내는 메시지 클라이언트 → 서버 → 상대
"to": "<상대 id>"가 있으면 그 사람에게만 전달됩니다.to가 없으면 같은 방의 나머지 전원에게 전달됩니다.- 서버가
"from": "<보낸 사람 id>"를 붙여서 보냅니다. 클라이언트가from을 적어 보내도 서버 값으로 덮어써지므로 위조할 수 없습니다. - JSON 객체가 아닌 텍스트는 조용히 무시됩니다.
아래 세 가지 type을 표준으로 씁니다. 모든 클라이언트(웹, Android, iOS)가 같은 이름을 써야 서로 통합니다.
{ "type": "offer", "to": "5", "sdp": { "type": "offer", "sdp": "v=0..." } }
{ "type": "answer", "to": "4", "sdp": { "type": "answer", "sdp": "v=0..." } }
{ "type": "candidate", "to": "4", "candidate": { "candidate": "candidate:...", "sdpMid": "0", "sdpMLineIndex": 0 } }앱에서 새 메시지 종류를 만들 때 welcome, peer-joined, peer-left, error는 type으로 쓰지 마세요. 서버 메시지와 구분할 수 없게 됩니다.
4연결 규칙 7가지
서로 연락이 되느냐는 대부분 이 규칙에서 갈립니다. 모든 플랫폼의 클라이언트가 똑같이 지켜야 합니다.
① 같은 방 이름으로 접속
같은 room 값으로 접속한 사람끼리만 서로 보입니다. 1:1 채팅이면 두 사람이 공유하는 대화방 ID를 방 이름으로 쓰세요.
② offer는 나중에 들어온 사람이 보낸다
welcome의 peers에 있는 각 사람에게 새로 들어온 쪽이 offer를 보냅니다. 먼저 있던 쪽은 peer-joined를 받아도 아무것도 보내지 않고 offer를 기다립니다. 양쪽이 동시에 offer를 보내면 연결이 꼬이므로(glare) 이 규칙을 반드시 지켜 주세요.
③ iceServers는 welcome에서 받은 값을 그대로
각 RTCPeerConnection은 welcome의 iceServers로 만듭니다. iceTransportPolicy는 기본값("all")으로 두세요.
이 TURN 서버(turn-rs)는 이 서버에 접속한 클라이언트끼리만 중계합니다. 한쪽만 kimbab.se TURN을 쓰고 다른 쪽이 다른 TURN 서비스(Twilio 등)를 쓰거나 TURN 없이 붙으면, 직접 연결이 막힌 네트워크에서 연결되지 않습니다. 모든 앱이 welcome의 iceServers를 쓰면 자동으로 해결됩니다.
⑤ 상대마다 RTCPeerConnection 하나씩
3명 이상 그룹 채팅은 모든 쌍이 각각 연결하는 메시(mesh) 방식입니다. 상대 ID별로 RTCPeerConnection과 RTCDataChannel을 따로 관리하고, peer-left를 받으면 해당 연결을 닫으세요. 방 하나에 최대 16명입니다.
⑥ 채팅 메시지는 DataChannel로
연결이 되면 채팅 내용은 RTCDataChannel로 보냅니다. 이 경로는 암호화(DTLS)되고 시그널링 서버를 거치지 않습니다. 시그널링 WebSocket으로도 메시지를 보낼 수는 있지만 서버를 거치고 64KB 제한이 있으니 연결 전 상태 알림 같은 용도로만 쓰세요.
⑦ 끊기면 다시 접속하고, 12시간 넘는 세션은 계정을 갱신
- WebSocket이 끊기면 1초, 2초, 4초… 간격으로 재접속합니다. 재접속하면 ID가 새로 바뀌므로 규칙 ②에 따라 다시 연결합니다.
- TURN 계정은 12시간 후 만료됩니다. 한 연결을 12시간 넘게 유지하는 앱은 그 전에 시그널링에 다시 접속해서 새
iceServers를 받고 연결을 새로 맺으세요.
시그널링 서버에는 로그인 기능이 없어서 방 이름을 아는 사람은 누구나 들어올 수 있습니다. 방 이름은 UUID처럼 추측하기 어려운 값으로 만들고, 상대가 누구인지는 앱 쪽에서 확인하세요. 예를 들어 연결 직후 DataChannel로 로그인 토큰을 주고받아 검증하는 방식이 있습니다.
5웹 예제: 최소 채팅
HTML 파일 하나로 동작하는 그룹 채팅입니다. 브라우저 탭 두 개(또는 서로 다른 PC)에서 열고 같은 방 이름을 쓰면 서로 메시지가 오갑니다. 규칙 ②·③·⑤·⑥이 모두 들어 있습니다.
<!doctype html>
<meta charset="utf-8">
<div id="log" style="height:300px;overflow:auto;border:1px solid #ccc;padding:8px"></div>
<form id="f"><input id="msg" autocomplete="off" placeholder="메시지"><button>보내기</button></form>
<script>
const ROOM = new URLSearchParams(location.search).get('room') || 'demo-room-7f3a';
const SIGNALING = 'wss://kimbab.se/signaling?room=' + encodeURIComponent(ROOM);
let ws, myId, iceServers;
const peers = new Map(); // 상대 id -> { pc, dc }
const log = (t) => { const d = document.createElement('div'); d.textContent = t; logEl.append(d); logEl.scrollTop = 1e9; };
const logEl = document.getElementById('log');
const signal = (obj) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); };
function createPeer(id, isCaller) {
const pc = new RTCPeerConnection({ iceServers }); // 규칙 ③
const peer = { pc, dc: null };
peers.set(id, peer);
pc.onicecandidate = (e) => { if (e.candidate) signal({ type: 'candidate', to: id, candidate: e.candidate }); };
pc.onconnectionstatechange = () => {
log(`[${id}] ${pc.connectionState}`);
if (pc.connectionState === 'failed' && isCaller && peers.get(id)?.pc === pc) { // 실패하면 offer 보낸 쪽이 새로 연결
closePeer(id);
createPeer(id, true);
}
};
const setup = (dc) => {
peer.dc = dc;
dc.onopen = () => log(`[${id}] 채팅 연결됨`);
dc.onmessage = (e) => log(`${id}: ${e.data}`);
};
if (isCaller) { // 규칙 ②: 나중에 온 쪽이 offer
setup(pc.createDataChannel('chat'));
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => signal({ type: 'offer', to: id, sdp: pc.localDescription }));
} else {
pc.ondatachannel = (e) => setup(e.channel);
}
return peer;
}
function closePeer(id) {
peers.get(id)?.pc.close();
peers.delete(id);
}
function connect(retry = 0) {
ws = new WebSocket(SIGNALING);
ws.onmessage = async ({ data }) => {
const m = JSON.parse(data);
try {
switch (m.type) {
case 'welcome':
retry = 0;
myId = m.id;
iceServers = m.iceServers;
log(`방 ${m.room} 입장, 내 id ${myId}`);
m.peers.forEach((id) => createPeer(id, true));
break;
case 'peer-joined':
log(`[${m.id}] 입장`); // offer가 올 때까지 기다림
break;
case 'offer': {
closePeer(m.from); // 재연결 offer면 이전 연결 정리
const { pc } = createPeer(m.from, false);
await pc.setRemoteDescription(m.sdp);
await pc.setLocalDescription(await pc.createAnswer());
signal({ type: 'answer', to: m.from, sdp: pc.localDescription });
break;
}
case 'answer':
await peers.get(m.from)?.pc.setRemoteDescription(m.sdp);
break;
case 'candidate':
await peers.get(m.from)?.pc.addIceCandidate(m.candidate);
break;
case 'peer-left':
log(`[${m.id}] 퇴장`);
closePeer(m.id);
break;
case 'error':
log('서버 오류: ' + m.message);
break;
}
} catch (err) {
log('처리 오류: ' + err.message);
}
};
ws.onclose = () => { // 규칙 ⑦: 재접속
[...peers.keys()].forEach(closePeer);
const wait = Math.min(1000 * 2 ** retry, 30000);
log(`시그널링 끊김, ${wait / 1000}초 후 재접속`);
setTimeout(() => connect(retry + 1), wait);
};
}
document.getElementById('f').onsubmit = (e) => { // 규칙 ⑥: DataChannel로 전송
e.preventDefault();
const input = document.getElementById('msg');
if (!input.value) return;
for (const { dc } of peers.values()) if (dc?.readyState === 'open') dc.send(input.value);
log(`나: ${input.value}`);
input.value = '';
};
connect();
</script>테스트: 이 예제는 kimbab.se/webrtc/chat.html에 올려 두었습니다. 두 창(또는 두 기기)에서 같은 주소를 엽니다. 두 창에 채팅 연결됨이 뜨면 성공입니다.
이 예제는 코드를 짧게 하려고 시그널링 연결이 끊기면 채팅 연결도 모두 닫고, 재접속 후 새로 연결합니다(재접속하면 ID가 바뀌기 때문). 실제 앱에서 끊김 없이 이어 가려면 앱 쪽 사용자 ID로 상대를 구분해 기존 연결을 재사용하도록 만드세요.
브라우저는 http 페이지에서 일부 WebRTC 기능을 제한합니다. 개발 중에는 http://localhost까지는 괜찮습니다.
6모바일 앱 설정
메시지 형식과 연결 순서는 웹과 똑같습니다. 플랫폼마다 달라지는 부분은 welcome의 iceServers를 각 WebRTC 라이브러리 형식으로 바꾸는 코드뿐입니다.
React Native (react-native-webrtc)
import { RTCPeerConnection } from 'react-native-webrtc';
// WebSocket은 RN 내장 객체 사용. 웹 예제 코드를 거의 그대로 쓸 수 있음
const pc = new RTCPeerConnection({ iceServers: welcome.iceServers });Flutter (flutter_webrtc)
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
final ws = WebSocketChannel.connect(Uri.parse('wss://kimbab.se/signaling?room=$roomId'));
// welcome 수신 후
final pc = await createPeerConnection({
'iceServers': welcome['iceServers'], // 같은 JSON 구조를 그대로 받음
});Android (Kotlin, org.webrtc)
// WebSocket: OkHttp client.newWebSocket(Request.Builder().url("wss://kimbab.se/signaling?room=$roomId").build(), listener)
fun iceServersFrom(welcome: JSONObject): List<PeerConnection.IceServer> {
val arr = welcome.getJSONArray("iceServers")
return (0 until arr.length()).map { i ->
val s = arr.getJSONObject(i)
val urls = s.getJSONArray("urls").let { u -> (0 until u.length()).map { u.getString(it) } }
val b = PeerConnection.IceServer.builder(urls)
if (s.has("username")) b.setUsername(s.getString("username")).setPassword(s.getString("credential"))
b.createIceServer()
}
}
val config = PeerConnection.RTCConfiguration(iceServersFrom(welcome)).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
}
val pc = factory.createPeerConnection(config, observer)Android에서 candidate를 보낼 때는 웹과 같은 필드 이름으로 JSON을 만드세요: candidate, sdpMid, sdpMLineIndex. SDP는 {"type":"offer","sdp":"..."} 형태로 보냅니다. type은 소문자여야 웹 클라이언트가 읽을 수 있습니다.
iOS (Swift, WebRTC.framework)
// WebSocket: URLSessionWebSocketTask(url: URL(string: "wss://kimbab.se/signaling?room=\(roomId)")!)
func iceServers(from welcome: [String: Any]) -> [RTCIceServer] {
let list = welcome["iceServers"] as? [[String: Any]] ?? []
return list.map { s in
RTCIceServer(urlStrings: s["urls"] as? [String] ?? [],
username: s["username"] as? String,
credential: s["credential"] as? String)
}
}
let config = RTCConfiguration()
config.iceServers = iceServers(from: welcome)
config.sdpSemantics = .unifiedPlan
let pc = factory.peerConnection(with: config, constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), delegate: self)서버는 25초마다 ping을 보내고, 75초 동안 아무 프레임도 오지 않으면 연결을 끊습니다. 브라우저와 OkHttp, URLSession, web_socket_channel은 ping에 자동으로 응답하므로 따로 할 일이 없습니다. 자동 응답이 없는 라이브러리를 쓴다면 30초마다 ping이라는 텍스트를 보내세요. JSON이 아니므로 서버는 연결 유지로만 처리하고 다른 사람에게 전달하지 않습니다.
모바일 앱은 백그라운드로 가면 OS가 WebSocket을 끊는 경우가 많습니다. 앱이 다시 앞으로 올 때 재접속하도록 만드세요(규칙 ⑦).
7제한 값
| 항목 | 값 | 넘으면 |
|---|---|---|
| 메시지 1개 크기 (시그널링) | 64 KB | 연결 종료 |
| 방 하나의 인원 | 16명 | error: room full 후 연결 종료 |
| 서버 전체 동시 접속 | 2,000 | HTTP 503 |
| 무응답 연결 종료 | 75초 | 연결 종료, 상대에게 peer-left |
| TURN 계정 유효 시간 | 12시간 | TURN 인증 실패 (401) |
| 방 이름 | [A-Za-z0-9._-]{1,64} | HTTP 400 |
이 값들은 서버의 /etc/webrtc/signaling.env에서 바꿀 수 있습니다. 나중에 ALLOWED_ORIGINS로 접속 가능한 웹 도메인을 제한하면 Origin 헤더를 보내지 않는 모바일 앱이 403으로 막힐 수 있으니, 그때는 앱도 함께 확인해야 합니다.
8문제 해결
| 증상 | 확인할 것 |
|---|---|
| WebSocket 접속 자체가 안 됨 | https://kimbab.se/signaling/health가 ok인지 확인. 주소가 wss://인지, 방 이름에 허용되지 않은 문자(한글, 공백)가 없는지 확인 |
| 상대가 안 보임 | 양쪽 room 값이 글자 하나까지 같은지 확인 |
offer/answer는 오가는데 연결이 failed | 양쪽이 모두 welcome의 iceServers를 썼는지 확인 (규칙 ③·④). candidate를 to로 보냈는지 확인 |
| 같은 와이파이에서는 되는데 LTE/회사망에서 안 됨 | TURN 설정 누락. relay 후보가 생기는지 확인 (아래) |
| 연결이 12시간쯤 뒤 끊김 | TURN 계정 만료. 규칙 ⑦대로 갱신 |
| 둘이 동시에 offer를 보내 꼬임 | 규칙 ② 위반. peer-joined에서는 offer를 보내지 않음 |
어떤 경로로 연결됐는지 보기
- Chrome에서
chrome://webrtc-internals를 열면 후보 목록과 최종 선택된 경로(host,srflx,relay)가 보입니다. - TURN 동작만 따로 확인하려면 Trickle ICE 테스트 페이지에
turn:kimbab.se:3478과 welcome에서 받은 계정을 넣고 Gather candidates를 누릅니다.relay줄이 나오면 정상입니다.
9출시 전 체크리스트
- 시그널링 주소는
wss://kimbab.se/signaling?room=...이고, 방 이름은 추측하기 어려운 값이다 iceServers를 앱에 고정값으로 넣지 않고 welcome에서 받아 쓴다- 나중에 들어온 쪽만 offer를 보낸다
- offer, answer, candidate 메시지의
type이름과 필드가 모든 플랫폼에서 같다 - 상대마다 연결을 따로 만들고,
peer-left를 받으면 닫는다 - 채팅 내용은 DataChannel로 보낸다
- WebSocket이 끊기면 간격을 늘려 가며 재접속한다
- LTE 한쪽과 와이파이 한쪽처럼 서로 다른 네트워크에서 연결을 시험해 봤다