[소소한 개발 일지] 프린트시 컨텐츠가 페이지간 짤리지 않게하기
WebRTC에 대해 알아보고 간단한 화상 통화 샘플 프로젝트를 만들어보자
2024-11-24
Explanation
오늘은 WebRTC에 대해 알아보고, 두 사용자가 서로의 카메라를 통해서 영상을 공유하는 간단한 샘플 프로젝트를 만들어보려고 합니다.
여담으로 저는 예전에 face-api.js를 사용해서 카메라 영상 속에서 얼굴 부분을 찾아서 특정 이미지를 얼굴에 따라 위치시키는 프로젝트를 한 적이 있는데, 이것도 다음에 시간이 되면 간단하게 포스팅 해보겠습니다!
이 글에 사용되는 샘플 프로젝트는 아래의 리포지토리에서 확인 하실 수 있습니다.
링크: https://github.com/falsy/blog-post-example/tree/main/webRTC
지식이 부족해서 네트워크와 관련하여 잘 모르는 용어나 기능들이 나오면 하나하나 짧게라도 정리하면서 글을 적어보려 합니다. 물론 너무 깊게 접근하면 어려우니까 간단하게 이해할 수 있는 정도까지만 알아볼거에요.
우선 WebRTC는 Web Real-Time Coummunication의 약자로 웹 애플리케이션이나 모바일 애플리케이션의 브라우저 간에 실시간으로 비디오, 오디오, 데이터 전송을 가능하게 해주는 Google에서 시작해서 W3C, IETF를 통해 표준화되고 여러 브라우저에서 지원하는 오픈소스 기술을 이야기합니다.
이름처럼 실시간 통신이 가장 큰 특징인 것 같아요. 그리고 그 안에서 P2P(Peer-to-Peer)* 연결을 통해 서버를 거치지 않고 클라이언트 간의 직접 연결하여 대역폭과 비용을 절감하고, 낮은 지연으로 오디오, 비디오, 데이터 전송을 지원합니다.
– P2P(Peer-to-Peer)*
우선 여기서 Peer는 인터넷에 연결된 컴퓨터 장치? 단말기?를 이야기하는데요, 그래서 P2P는 인터넷을 통해서 두 장치의 연결을 말합니다. 조금 더 정확하게 말하면 중앙 서버 없이 네트워크에 연결된 참여자가 직접 데이터를 주고 받는 분산형 네트워크 아키텍처입니다. 예를 들어 비트토렌트를 생각하면 조금 이해가 편한데요.(문득, 제가 처음 사용한 P2P 서비스는 소리바다 였던거 같아요.) 각 참여자(노드)는 고유한 주소를 가지고 있고 마그넷 주소를 통해서 참여자 간에 네트워크 연결이 이루어지고 데이터는 네트워크 전체에 분산되어 저장되고 각 노드는 필요한 데이터를 다른 노드에게서 직접 다운로드하거나 공유하게 됩니다. 이렇게 모든 노드가 클라이언트이면서 동시에 서버의 역할도 수행하는 아키텍처입니다.
WebRTC는 크게 세 가지로 구성되어 있습니다.
1. MediaStream API
사용자 장치(카메라, 마이크)의 데이터를 캡처합니다.
2. RTCPeerConnection API
P2P 연결을 설정하고 미디어 데이터를 교환합니다.
3. RTCDataChannel API
데이터 전송을 위한 양방향 통신 채널을 제공합니다.
그리고 대략적으로 아래와 같이 동작합니다.
1. MediaStream API를 통해서 사용자의 카메라 또는 마이크에 접근합니다.
2. SDP(Session Description Protocol)을 통해 신호를 교환합니다.
(WebSocket과 같은 별도의 서버를 통해서 두 클라이언트 간 연결 정보를 교환)
3. ICE(Interactive Connectivity Establishment) 후보 교환합니다.
(P2P 연결을 설정)
4. 미디어 및 데이터 전송합니다.
(RTCPeerConnection 및 RTCDataChannel을 통해 데이터 전송)
위 처음 듣는 단어들이 많아서 좀 어려운데요. 아주 간단하게 화상 통화를 예로 들면, 영상을 보내는 쪽의 컴퓨터나 모바일 장치의 카메라를 통한 카메라 영상을 캡쳐해서 인코딩한 후에 P2P로 전송하고, 영상을 받는 쪽에서는 받은 영상 데이터를 디코딩 한 후 화면에 출력합니다.
WebRTC는 P2P를 기반으로 동작하기 때문에 WebRTC를 이해하려면 P2P를 먼저 알아봐야 하는데요. 만약에 두 개의 장치의 화상통화를 설계한다고 했을 때, 가장 일반적인? 설계는 중간에 중계 서버를 두고 중계 서버를 통해서 카메라 영상 데이터를 주고 받는 시스템을 생각할 수 있는데요. 이 방식은 개발하기에는 익숙하지만 중계서버에 성능도 좋아야하고 많은 트래픽을 견뎌야하고 또 대역폭도 좋아야하기 때문에 사용자가 늘어날수록 네트워크 비용이 많이 들어가게 됩니다.
그리하여, P2P 방식을 사용해서 두 개의 장치가 서로 직접 접속해서 통신하는 방법을 사용하는데요. 하지만 두 장치가 통신을 하려면 서로의 주소(공인 IP와 포트 정보)를 알아야 하는데요. 이를 위해서 서로의 주소를 주고 받을 수 있도록 하는 서버가 필요합니다.
이 서버를 시그널링 서버(Signaling Server)라고 합니다. 앞에 이야기했던 중계 서버처럼 데이터를 직접 주고 받는 서버가 아니고 두 장치가 연결될 수 있도록 서로의 주소를 보내줘서 두 장치가 연결될 수 있도록 하는 역할을 하는 서버입니다.
저는 이따가 WebSocket 서버를 사용해서 시그널링 서버를 만들어 줄 거에요.
그런데 여기에서 만약 두개의 장치가 공인 IP(Public IP)를 가진 장치라면 간단하게 시그널링 서버를 통해서 바로 서로의 공인 IP 주소를 가지고 연결될 수 있는데요. 하지만 wifi 무선 공유기와 같이 NAT(Network Address Translation)*을 사용하거나 방화벽을 사용하는 경우에는 두 장치가 서로의 주소를 알기가 쉽지 않습니다.
– NAT(Network Address Translation)*
무선 공유기나 사무실에서의 별도의 사설 네트워크와 같이 내부에서 사용하는 사설 IP와 외부의 공인 IP를 연결해주는 역할을 합니다. 하나의 사설 IP와 특정 공인 IP를 1:1로 매핑하는 정적 NAT(Static NAT)와 공인 IP 주소 풀 안에서 사용 가능한 IP를 동적으로 할당해서 사용하는 동적 NAT(Dynamic NAT)와 하나의 공인 IP 주소를 가지고 포트 번호를 사용해서 여러 장치를 공유하는 PAT(Port Address Translation) 등.. 여러가지 유형이 있습니다.
정확하게는 모든 NAT이 P2P 연결에 문제가 발생하는 것은 아닙니다. 공인 IP를 직접 사용하거나 사설 IP와 정적 NAT으로 1:1로 매핑되는 경우에는 문제가 발생하지 않습니다. 하지만 만약에 장치가 사설 IP로 사용되는 경우라면 외부에서 장치에 접근할 수 있는 주소를 알 수 없기 때문에 문제가 발생합니다.
그리고 위처럼 NAT 뒤에 있는 장치와 P2P 연결을 할 수 있도록 도와주는 프로토콜로 STUN(Session Traversal Utilities for NAT)가 있습니다.
STUN은 간단하게 이야기하면, 클라이언트가 STUN 서버를 통해서 클라이언트가 외부 네트워크에서 도달할 수 있는 주소(공인 IP와 포트 정보)를 얻고 이를 사용해서 P2P 연결을 설정합니다.
[STUN 동작]
1. 클라이언트는 사설 IP와 포트 정보를 가지고 STUN 서버에 공인 IP 주소를 가진 서버에 요청을 보냅니다.
2. STUN 서버는 클라이언트가 보낸 요청의 클라이언트에 도달할 수 있는 공인 IP 주소와 포트 정보를 확인해서 응답합니다.
3. 클라이언트는 획득한 공인 IP와 포트 정보를 P2P 상대방에게 공유하여 직접 연결을 시도합니다.
하지만 이마저도 대칭 NAT(Symmetric NAT)* 환경이나 방화벽이 외부에서의 연결을 차단하면 STUN 서버를 사용해서도 P2P 연결이 불가능한 경우가 있습니다. 이럴때는 TURN(Traversal Using Relays around NAT) 서버를 사용해서 P2P 연결을 중계합니다.
– 대칭 NAT(Symmetric NAT)*
동일한 장치라도 서로 다른 외부 서버에서 요청을 받을 때마다 NAT 라우터가 각 요청에 대해 새로운 공인 IP와 포트쌍을 할당하는 방식입니다. 보안을 강화하기 위해 설계되었지만, P2P 연결이나 NAT 우회에 어려움이 있습니다.
TURN 서버는 NAT 환경에서 클라이언트 간 P2P 연결이 불가능할 경우 중계서버를 사용해서 데이터를 주고받오록 도와주는 프로토콜입니다.
[TURN 동작]
1. 클라이언트는 TURN 서버에 연결 요청을 보내면 TURN 서버는 클라이언트에게 공인 IP 주소와 포트 정보를 클라이언트에 제공합니다.
2. TURN 서버는 클라이언트 간의 데이터의 송신자와 수신자를 중계합니다.
3. “클라이언트 A → TURN 서버 → 클라이언트 B”, “클라이언트 B → TURN 서버 → 클라이언트 A” 와 같이 데이터가 전달됩니다.
이렇게 TURN 서버를 사용하면 다양한 STUN 만으로 연결이 불가능한 경우와 같은 다양한 NAT 환경에도 작동이 가능하지만 TURN 서버를 거치는 중계 방식은 네트워크 지연이나 서버에 부하가 발생할 수 있으며 대역폭 사용량도 늘어날 수 있습니다.
위와 같이 TURN은 STUN의 보완 기술로 대부분의 실시간 통신 애플리케이션에서는 STUN과 TURN을 함께 사용합니다. (STUN 우선 시도)
앞서 시그널링 서버가 “두 장치가 연결될 수 있도록 서로의 주소를 보내줘서 두 장치가 연결될 수 있도록 하는 역할을 하는 서버”라고 간단하게 이야기했는데요, 조금만 더 자세히 알아볼게요.
시그널링 서버의 주요 역할은 크게 “SDP 정보 교환”, “ICE 후보 교환”, “연결 상태 관리”, “실시간 이벤트 전달”이 있습니다.
하나씩 간단하게 예를 들어 이야기를 해보면,
하나의 화상 미팅룸이 있습니다. 클라이언트 A가 미팅룸(시그널링 서버)에 접속을 하면 시그널링 서버에 자신의 접속을 알립니다.(Offer) 다음으로 클라이언트 B가 클라이언트 A와 화상 미팅을 하기 위해 미팅룸에 접속하고(Offer) 클라이언트 A 에게 접속 요청을 합니다.(Offer) 클라이언트 A가 요청을 수락하면 답변을 회신합니다.(Answer)
이렇게 시그널링 서버를 통해서 클라이언트 A와 클라이언트 B가 연결에 필요한 미디어 정보나 네트워크 정보가 포함된 메시지를 공유합니다.(SDP 정보 교환)
클라이언트 A와 클라이언트 B가 P2P 연결을 설정하기 위한 서로의 IP 주소와 포트 정보가 필요한데요. 이때 ICE(Interactive Connectivity Establishment) 프레임워크를 사용합니다. ICE는 두 클라이언트의 최적의 경로를 찾는 역할을 하며 조금 전에 이야기한 STUN과 TURN이 이 ICE 프로토콜의 일부로 동작합니다.
이제 ICE를 수행하며 STUN 또는 TURN 서버를 통해서 클라이언트가 자신의 공인 IP와 포트 정보를 확인해서 ICE 후보(ICE Candidate)*를 생성합니다. 그리고 시그널링 서버를 통해서 이 ICE 후보를 교환하여 두 클라이언트가 P2P로 직접 연결되고 이제 서로 데이터를 주고 받을 수 있게 됩니다.
– ICE 후보(ICE Candidate)*
ICE 후보에는 IP 주소, 포트, 프로토콜 등의 정보가 포함되어 있습니다.
그리고 시그널링 서버는 그밖의 클라이언트의 접속과 종료 같은 연결 상태를 관리하고 실시간으로 이벤트를 전달하는 역할을 합니다. 앞서 이야기한 SDP 정보 교환(Offer, Anser)과 ICE 후보 교환(ICE Candidate)도 실시간 이벤트를 통해서 이루어집니다.
마지막으로 한 번 더 요약해 보면
– WebRTC는 P2P 기반으로 동작하며,
– P2P 연결은 시그널링 서버를 통해 장치 간의 연결 정보를 교환함으로써 관리됩니다.
– 그리고 이 과정에서 ICE 프레임워크를 사용하여 장치 간 접속을 설정합니다.
– ICE는 SDP(Session Description Protocol)를 통해 정보를 교환하며,
– SDP에는 연결에 필요한 미디어 정보(코덱, 대역폭 등)와 네트워크 정보가 포함됩니다.
– 그리고 여기서 네트워크 정보가 STUN/TURN 서버를 통해 생성된 ICE 후보를 이야기합니다.
이제 알아본 내용을 바탕으로 간단하게 화상 통화 샘플 프로젝트를 만들어 볼게요.
저는 Node.js와 WebSocket을 사용해서 시그널링 서버를 만들어 줄거에요. 그런데 왠지 실제로 사용한다면 덩그러니 WebSocket 서버를 사용하기보단 CORS 같은 약간의 미들웨어 설정을 함께 사용할 것 같아서 저는 간단하게 Koa 프레임워크를 함께 사용했어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
import Koa from "koa" import cors from "@koa/cors" import { WebSocketServer, WebSocket } from "ws" const app = new Koa() // CORS 설정을 통해서 "http://localhost:2000"를 통해서만 접근이 가능하도록 설정 const allowedOrigins = ["http://localhost:2000"] app.use( cors({ origin: (ctx) => { const origin = ctx.request.header.origin if (origin && allowedOrigins.includes(origin)) { return origin } return "" } }) ) const wss = new WebSocketServer({ noServer: true }) // 클라이언트의 연결 상태를 Map으로 관리 const clients: Map<string, WebSocket> = new Map() wss.on("connection", (socket) => { // 클라이언트가 접속하면 임의의 ID를 부여 const clientId = Math.random().toString(36).substring(2, 9) clients.set(clientId, socket) console.log(`클라이언트 ${clientId} 연결됨`) socket.on("message", (message) => { // 메시지는 JSON 형태의 문자열로 받아서 이를 파싱하여 사용 const data = JSON.parse(message.toString()) const { to } = data // 데이터에 to 값을 확인해서, 대상 클라이언트에게 메시지를 전달 if (clients.has(to)) { const targetSocket = clients.get(to) if (targetSocket && targetSocket.readyState === WebSocket.OPEN) { targetSocket.send(JSON.stringify({ ...data, from: clientId })) } } }) socket.on("close", () => { console.log(`클라이언트 연결 종료: ${clientId}`) clients.delete(clientId) }) // 접속한 클라이언트에게 자신의 ID 정보를 전달 socket.send(JSON.stringify({ type: "join", id: clientId })) }) // 7777번 포트로 서버 실행 const server = app.listen(7777, () => { console.log("Koa, WebSocket 서버가 http://localhost:7777 에서 실행 중입니다.") }) server.on("upgrade", (request, socket, head) => { const origin = request.headers.origin if (!origin || !allowedOrigins.includes(origin)) { // 오리진 값이 없거나 허용된 오리진 값이 아니라면 권한 없음을 응답 socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") socket.destroy() return } wss.handleUpgrade(request, socket, head, (ws) => { wss.emit("connection", ws, request) }) }) |
제가 Koa와 CORS 설정을 추가해서 코드가 조금 더 길어졌지만, 아주 간단하죠?
더 간단하게 클라이언트를 구성할 수 있지만, 저는 아무 생각없이 Webpack과 React를 사용해서 만들었네요..
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
import { useEffect, useRef, useState } from "react" export default function Meeting() { const ws = useRef<WebSocket | null>(null) const pc = useRef<RTCPeerConnection | null>(null) const localVideo = useRef<HTMLVideoElement>(null) const remoteVideo = useRef<HTMLVideoElement>(null) const inputEl = useRef<HTMLInputElement>(null) const [clinetId, setClientId] = useState<string>("") const [remoteClientId, setRemoteClientId] = useState<string>("") useEffect(() => { // 컴포넌트가 마운트 되면 WebRTC를 설정합니다. setWebRTC() return () => { if (ws.current) { ws.current.close() } } }, []) const setWebRTC = async () => { // navigator.mediaDevices.getUserMedia() 메서드를 활용해서 사용자의 비디오 스트림 데이터를 가져옵니다. // (MediaStream API) const localStream = await getUserMedia() if (!localStream) return // RTCPeerConnection API를 활용해서 P2P 연결을 설정합니다. setPeerConnection(localStream) // 로컬의 미디어 스트림 정보를 video 엘리먼트를 통해서 화면에 그립니다. localVideo.current.srcObject = localStream // 앞서 만든 시그널링 서버에 접속합니다. ws.current = new WebSocket("ws://localhost:7777") ws.current.onmessage = async (message) => { const data = JSON.parse(message.data) switch (data.type) { case "join": // 시그널링 서버를 통해 클라이언트 ID를 받아 useState에 담아 화면에 출력 setClientId(data.id) break case "offer": // 연결 제안이 온 경우 상대의 ID를 useStated에 담아 화면에 출력 setRemoteClientId(data.from) // 상대방이 보낸 offer 정보를 원격 설명으로 설정하고 이를 통해 상대방의 연결 설정 정보를 받습니다. await pc.current.setRemoteDescription(data.offer) // 로컬 클라이언트에서 상대방의 offer에 응답하기 위한 anser를 생성합니다. const answer = await pc.current.createAnswer() // 생성한 anser를 로컬 설명으로 설정합니다. await pc.current.setLocalDescription(answer) // 생성한 answer를 WebSocket을 통해 상대방에게 전송합니다. ws.current.send( JSON.stringify({ type: "answer", to: data.from, answer }) ) break case "answer": // 상대방이 보낸 answer 정보를 원격 설명으로 설정하여 이를 통해 P2P 연결이 설정됩니다. await pc.current.setRemoteDescription(data.answer) break case "candidate": // 상대방이 ICE 후보를 전송한 경우 ICE 후보를 추가하여 연결 설정을 완료합니다. await pc.current.addIceCandidate(data.candidate) break // 여기에서 offer/answer 교환과 candidate 교환은 독립적으로 이루어지며 // 모든 교환이 완료되면 P2P 연결이 설정됩니다. } } // WebSocket 연결이 완료되면 ws.current.onopen = async () => { // WebRTC 연결에 필요한 offer를 생성 const offer = await pc.current.createOffer() // offer를 사용자의 로컬 설명으로 설정합니다. await pc.current.setLocalDescription(offer) // offer를 시그널링 서버를 통해 상대방에게 전송합니다. ws.current.send(JSON.stringify({ type: "offer", offer })) } } const getUserMedia = async () => { try { // 비디오 권한을 요청합니다. const localStream = await window.navigator.mediaDevices.getUserMedia({ video: true }) return localStream } catch (error) { console.error(error) return false } } const setPeerConnection = (localStream: MediaStream) => { // RTCPeerConnection 객체는 P2P 연결을 생성하고 관리하기 위한 기본 객체입니다. // isServers 옵션을 통해서 STUN, TURN 서버의 설정을 지정할 수 있습니다. // "stun:stun.l.google.com:19302"는 Google에서 제공하는 공개 STUN 서버입니다. const RTCPC = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }) // 상대방에게 전송할 사용자의 로컬 미디어 트랙(비디오 및 오디오)를 RTCPeerConnection 객체에 추가합니다. // 이렇게 추가한 트랙은 WebRTC 연결이 설정되면 상대방에게 전송됩니다. localStream .getTracks() .forEach((track) => RTCPC.addTrack(track, localStream)) // WebRTC 연결에서 상대방이 전송한 미디어 스트림을 수신하여 화면에 출력합니다. RTCPC.ontrack = (event) => { remoteVideo.current.srcObject = event.streams[0] } // WebRTC의 ICE 후보가 생성될 때마다 호출되며 생성된 ICE 후보를 상대방에게 전달하여 P2P 연결을 설정합니다. RTCPC.onicecandidate = (event) => { if (event.candidate && ws.current) { ws.current.send( JSON.stringify({ type: "candidate", candidate: event.candidate }) ) } } // RTCPeerConnection 객체를 useRef에 담아서 재사용합니다. pc.current = RTCPC } // 상대 클라이언트 ID를 가지고 WebRTC를 연결 const startMeeting = async () => { if (!inputEl.current.value) { window.alert("Enter Client ID") return } if (ws.current) { // WebRTC 연결에 필요한 offer를 생성 const offer = await pc.current.createOffer() // offer를 사용자의 로컬 설명으로 설정합니다. await pc.current.setLocalDescription(offer) // 사용자에게 입력받은 클라이언트 ID의 클라이언트에게 WebRTC 연결 요청 ws.current.send( JSON.stringify({ type: "offer", to: inputEl.current.value, offer }) ) // 상대 ClientID를 useState에 담에 화면에 출력 setRemoteClientId(inputEl.current.value) } } return ( <div> <h1>WebRTC Meeting</h1> <input ref={inputEl} type="text" placeholder="Enter Client ID" /> <button onClick={startMeeting}>Start Meeting</button> <div> <div> <p>Client ID: {clinetId}</p> <video ref={localVideo} autoPlay muted playsInline /> </div> <div> <p>Remote Client ID: {remoteClientId}</p> <video ref={remoteVideo} autoPlay muted playsInline /> </div> </div> </div> ) } |
이제 샘플 프로젝트의 서버와 클라이언트를 실행하고, 브라우저에서 두개의 탭을 열어서 하나의 클라이언트 ID 값을 가지고 다른 하나의 브라우저에 입력한 후 “Start Meeting” 버튼을 클릭하면 로컬의 비디오와 원격 비디오가 각각 출력되는 것을 확인할 수 있습니다.
[참고]
– https://webrtc.org/getting-started/overview?hl=ko
– https://blog.naver.com/kojang74/222485013205