WebRTC 통합
브라우저에서 ClawOps 번호로 click-to-call 을 구현하는 가이드. 동작하는 최소 구현부터 프로덕션 보안까지 단계별로 다룹니다.
WebRTC 통합
브라우저에서 마이크 권한만 받으면 ClawOps 번호로 발신할 수 있는 click-to-call 위젯을 구현합니다.
ClawOps 의 WebRTC 는 표준 SIP-over-WebSocket (RFC 7118) 위에서 동작하므로, JsSIP 등 검증된 SIP UA 라이브러리를 그대로 사용할 수 있습니다.
이 페이지는 두 부분으로 나뉩니다:
부가서비스 활성화 필요. WebRTC 통화는 SIP 트렁크 연결 부가서비스 입니다. 대시보드 → 부가서비스 에서 활성화 후 진행하세요.
아키텍처
구현
여기까지만 따라하면 로컬에서 발신이 동작합니다. 운영 환경 보안은 다음 보안 섹션에서 추가합니다.
1단계: SipCredential 발급
대시보드에서 WebRTC 용 SipCredential 을 1회 만듭니다.
platform.claw-ops.com 의 SIP Credentials 메뉴로 이동합니다.
| 필드 | 값 |
|---|---|
allowed_numbers | 발신자 번호로 허용할 070 번호 목록 |
realm | 자동 생성 (<random>.sip.claw-ops.com) |
생성된 credential_id (예: SC_xxxxx) 와 계정 ID 를 메모합니다. 다음 단계에서 사용합니다.
2단계: 서버측 토큰 발급 API
브라우저가 ClawOps API Key 를 직접 호출하면 안 됩니다. 자체 서버에 엔드포인트 1개를 두고 API Key 를 서버 환경변수로 가립니다.
서버가 하는 일은 두 가지뿐입니다:
- ClawOps 에 단기 SIP 토큰을 요청 (
POST /sip-credentials/:id/tokens) - ICE/TURN 서버 정보를 요청 (
GET /ice-servers)
// app/api/webrtc-call/token/route.ts (Next.js)
const API_BASE = process.env.CLAWOPS_API_BASE!; // https://api.claw-ops.com
const ACCOUNT_ID = process.env.CLAWOPS_ACCOUNT_ID!;
const CREDENTIAL_ID = process.env.CLAWOPS_WEBRTC_CREDENTIAL_ID!;
const API_KEY = process.env.CLAWOPS_API_KEY!;
export async function POST() {
const headers = { Authorization: `Bearer ${API_KEY}` };
// 1. SIP 단기 토큰 발급 (TTL 7분)
const tokenRes = await fetch(
`${API_BASE}/v1/accounts/${ACCOUNT_ID}/sip-credentials/${CREDENTIAL_ID}/tokens`,
{
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ ttl_seconds: 420 }),
},
);
const { token } = await tokenRes.json();
// 2. ICE/TURN 서버 정보
const iceRes = await fetch(`${API_BASE}/v1/accounts/${ACCOUNT_ID}/ice-servers`, { headers });
const { ice_servers } = await iceRes.json();
// 3. 브라우저로 그대로 전달
return Response.json({ token, iceServers: ice_servers });
}왜 토큰을 그대로 넘겨주나요?
토큰은 JWT 입니다. 안에 sip_username / sip_password / ws_url / realm 등 SIP 인증에 필요한 값이 들어 있습니다. 어차피 브라우저가 SIP 등록할 때 다 쓰는 값이라 숨길 의미가 없습니다. 서버가 가려야 할 건 "API Key" 하나뿐 이고, 토큰 자체는 만료 7분짜리 1회용이라 노출돼도 무해합니다 (Twilio Voice JS / Vonage Client SDK 와 동일 패턴).
3단계: 브라우저 — JsSIP UA
npm install jssip브라우저는 다음 흐름을 따릅니다:
- 위에서 만든
/api/webrtc-call/token호출 - 받은 JWT 의 payload 를 디코드해서 SIP 자격(
sip_username,sip_password,ws_url,realm) 추출 - JsSIP UA 만들고 발신
import { UA, WebSocketInterface } from 'jssip';
// JWT payload 디코드 — 서명 검증은 ClawOps 게이트웨이가 함
function decodeJwtPayload(jwt: string) {
const base64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
}
export async function call(destination: string) {
// 1. 서버에서 토큰 + ICE 받기
const res = await fetch('/api/webrtc-call/token', { method: 'POST' });
const { token, iceServers } = await res.json();
// 2. JWT payload 에서 SIP 자격 추출
const claims = decodeJwtPayload(token);
const wsUri = claims.ws_url; // wss://sip.claw-ops.com:5063
const realm = claims.realm; // <random>.sip.claw-ops.com
const sipUsername = claims.sip_username; // ephem_xxxxx (1회용)
const sipPassword = claims.sip_password;
const callerId = claims.allowed_caller_ids[0]; // 발신자로 표시될 070 번호
// 3. JsSIP UA 만들기
const socket = new WebSocketInterface(wsUri);
const ua = new UA({
sockets: [socket],
uri: `sip:${sipUsername}@${realm}`,
password: sipPassword,
register: false,
});
ua.start();
// 4. 발신
const session = ua.call(`sip:${destination}@${realm}`, {
mediaConstraints: { audio: true, video: false },
pcConfig: { iceServers },
// From URI 발신번호 (자세한 이유는 아래 박스)
fromUserName: callerId,
});
// 5. 통화 연결 후 상대 음성 재생
session.on('confirmed', () => {
const audioEl = document.querySelector<HTMLAudioElement>('#remote-audio')!;
const tracks = session.connection.getReceivers().map(r => r.track).filter(Boolean);
audioEl.srcObject = new MediaStream(tracks);
});
return {
hangup: () => { session.terminate(); ua.stop(); },
};
}페이지 어딘가에 오디오 엘리먼트를 두고:
<audio id="remote-audio" autoplay />
<button onclick="call('07012345678')">전화하기</button>왜 fromUserName 이 필요한가
JsSIP 는 기본적으로 From URI user 부분에 UA 의 uri 값(ephem_xxx) 을 그대로 씁니다.
하지만 ClawOps 게이트웨이는 발신번호 형식을 ^\+?[0-9]{1,20}$ 로 강제합니다 (위변조 방지).
fromUserName: callerId 옵션으로 From URI user 를 전화번호 형식으로 덮어씁니다.
인증 자체는 SIP digest username 에 의존하므로 영향 없음.
여기까지가 동작하는 최소 구성입니다. 그러나 운영 환경에 그대로 올리면 안 됩니다. 다음 보안 섹션을 적용하세요.
보안
운영 환경 click-to-call 은 비용 폭주 / 발신번호 위조 / 토큰 도용 위험에 노출됩니다. 아래 5가지를 모두 적용하세요.
S1. API Key 노출 금지
- API Key 는 서버 환경변수에만 둡니다 (
process.env.CLAWOPS_API_KEY). NEXT_PUBLIC_*/VITE_*등 클라이언트 번들에 들어가는 변수에 절대 넣지 마세요.- 클라이언트는 본인의 서버 에만 요청하고, 본인 서버가 ClawOps API 를 호출합니다 (위 2단계 참고).
S2. 도착 번호(destination) 는 서버가 결정
- // ❌ 클라이언트가 보낸 destination 을 그대로 사용
- const { destination } = await req.json();
+ // ✅ 서버가 세션 컨텍스트로부터 결정
+ const destination = await resolveDestinationForUser(req); // 예: 사용자 ID → 담당자 번호클라이언트가 destination 을 결정하면 토큰 탈취 시 임의 번호로 발신 가능 → 요금 폭탄. 사용자별 1:1 콜센터라면 세션 사용자 ID → 담당자 번호로 매핑. 범용 데모(우리 콜센터로만 발신) 라면 환경변수 1개로 박아두기.
S3. 봇 방어 (Cloudflare Turnstile)
토큰 발급 엔드포인트가 인증 없이 열려있다면 봇이 무한 발신 가능. Turnstile invisible challenge 를 페이지 로드 시점에 미리 실행해서 클릭 시점에 토큰 준비 완료 상태로 둡니다.
// lib/turnstile-context.tsx — 페이지 로드 시 토큰 사전 발급
'use client';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
const SITEKEY = process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY!;
const Ctx = createContext<{ token: string | null; consume: () => void }>({
token: null, consume: () => {},
});
export const useTurnstile = () => useContext(Ctx);
export function TurnstileProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const widgetIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!document.getElementById('cf-turnstile-script')) {
const s = document.createElement('script');
s.id = 'cf-turnstile-script';
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
s.async = true;
document.head.appendChild(s);
}
const t = setInterval(() => {
if (!window.turnstile || !containerRef.current || widgetIdRef.current) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: SITEKEY,
callback: setToken,
});
clearInterval(t);
}, 200);
return () => clearInterval(t);
}, []);
const consume = () => {
setToken(null);
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
};
return (
<Ctx.Provider value={{ token, consume }}>
{children}
<div ref={containerRef} style={{ position: 'fixed', left: -9999, top: -9999 }} />
</Ctx.Provider>
);
}서버에서 토큰 발급 전에 검증:
const { turnstileToken } = await req.json();
const ok = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET!, response: turnstileToken }),
}).then(r => r.json()).then(r => r.success);
if (!ok) return NextResponse.json({ error: 'turnstile_failed' }, { status: 403 });토큰 사용 후 consume() 으로 reset 하면 다음 통화용 새 Turnstile 토큰이 자동 발급됩니다.
S4. Rate-limit (PSTN 발신 시 필수)
- on-net 발신 (자기 계정 070 번호로 발신) → PSTN 비용 0. 길이 cap 만 있으면 OK.
- PSTN 발신 → 토큰 발급 단위로 IP/계정당 시간/일 한도 필수.
권장 기본값: IP 당 분당 1회 / 일 5회. 프로덕트 특성에 맞게 조정.
S5. 통화 길이 안전망
브라우저 setTimeout 은 사용자가 콘솔로 우회 가능합니다. 서버측 hard cap 을 별도로 둡니다.
| 시나리오 | 권장 |
|---|---|
| 자체 PBX/IVR 이 있다 | 거기서 발신자번호 기준 max-duration 설정 |
| ClawOps 만 사용 | 토큰 TTL 자체를 짧게 (5~7분) → 만료 시점에 통화도 정리됨. 추가로 Calls webhook 으로 길이 모니터링하다 임계 초과 시 hangup |
PSTN 발신은 비용이 발생하므로 반드시 길이 상한이 있어야 합니다. 클라이언트만 믿지 마세요.
참고
API Reference (Scalar UI) — 전체 스키마/예제/Try-it-out:
- 📘 WebRTC 전체 그룹
- POST /sip-credentials/:id/tokens — 단기 토큰 발급
- DELETE /sip-credentials/:id/tokens/:jti — 토큰 폐기
- GET /ice-servers — STUN/TURN 자격
토큰과 JWT 안의 값들
토큰 응답에 들어있는 token 은 HS256 JWT 입니다. 디코드하면 SIP 인증에 필요한 모든 값(sip_username, sip_password, ws_url, realm, allowed_caller_ids 등)이 payload 에 들어 있습니다 — 위 3단계 참고.
서명 검증은 ClawOps 게이트웨이가 SIP digest 단계에서 수행하므로 클라이언트는 디코드만 하면 됩니다.
ws_url / realm 은 환경별로 달라질 수 있으므로 하드코딩하지 말고 토큰 payload 에서 읽어 사용하세요. 전체 응답/payload 필드 목록은 API Reference 참고.
ICE / TURN
GET /ice-servers 가 반환하는 ice_servers 배열을 그대로 RTCPeerConnection 의 iceServers 옵션에 넘기면 됩니다. TURN 자격은 시간제한 HMAC 으로 발급되며 expires_at 이후 무효 — 새로 발급받으세요.
전체 응답 스키마는 API Reference 참고.
트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
| WSS 연결 실패 (status 0) | wsUri 하드코딩, 환경 불일치 | JWT payload 의 ws_url 사용 |
407 Proxy Authentication Required | digest 응답 누락 | JsSIP password 옵션 확인 |
403 Invalid Caller ID format | From URI user 가 phone format 위반 | fromUserName: callerId 옵션 추가 |
| INVITE 후 즉시 BYE (Q.850 cause=16) | self-call (자기 계정 번호 → 자기 계정 번호) | ClawOps 가 자동 on-net 라우팅. 별도 작업 불필요 |
| 마이크 권한 거부 | 사용자가 거부 | 안내 UI + 재요청 버튼 |
| 통화는 되는데 음성이 안 들림 | ICE/TURN 실패 (사용자 NAT 환경) | TURN(turns:5349) 자격 만료 여부 확인 |
운영 체크리스트
-
CLAWOPS_API_KEY는 서버 환경변수에만, 클라이언트 번들에 노출 X -
destination은 서버에서 결정 (클라이언트 입력 신뢰 X) - Cloudflare Turnstile 또는 동등한 봇 방어
- 토큰 TTL 5~7분 (통화 1건 길이)
- PSTN 발신은 토큰 발급 Rate-limit (IP/계정당 일/시간 한도)
- 통화 길이 안전망 (PBX max-duration 또는 webhook 모니터링)
- HTTPS 필수 —
getUserMedia는 secure context 에서만 동작
다음
- SIP 단말 (Linphone) — 데스크톱/모바일 SIP 소프트폰 등록
- Voice Agent — 발신측이 아니라 수신 AI 에이전트 만들기