이미지 로딩 중...
AI Generated
2025. 11. 20. · 3 Views
WebRTC Mesh 방식 다자간 화상회의 완벽 가이드
다자간 화상회의를 구현하는 Mesh, SFU, MCU 방식의 차이부터 실제 구현까지. PeerConnection 관리, 참여자 추가/제거, 대역폭 최적화까지 실무에 바로 적용할 수 있는 완벽한 가이드입니다.
목차
1. Mesh vs SFU vs MCU
시작하며
여러분이 화상회의 서비스를 만들려고 할 때 이런 고민을 한 적 있나요? "4명이 통화하는데 왜 이렇게 느리지?" 혹은 "참가자가 늘어날수록 화질이 떨어지는데 어떻게 해야 하지?" 이런 문제는 화상회의 아키텍처 선택과 직접적으로 연결됩니다.
잘못된 방식을 선택하면 소규모 회의도 버벅거리고, 올바른 방식을 선택하면 대규모 회의도 원활하게 진행할 수 있습니다. 바로 이럴 때 필요한 것이 Mesh, SFU, MCU 방식의 차이점을 이해하는 것입니다.
각 방식의 장단점을 알면 여러분의 서비스에 가장 적합한 구조를 선택할 수 있습니다.
개요
간단히 말해서, 이 세 가지는 화상회의에서 영상 데이터를 전달하는 서로 다른 방법입니다. Mesh 방식은 모든 참가자가 서로에게 직접 영상을 보내는 방식입니다.
예를 들어, 3명이 통화하면 나는 2명에게 내 영상을 보내고, 2명의 영상을 각각 받습니다. 서버가 필요 없어 간단하지만, 참가자가 늘어나면 내 컴퓨터가 감당해야 할 연결이 기하급수적으로 증가합니다.
SFU(Selective Forwarding Unit) 방식은 중간에 서버가 있어서 각 참가자는 서버에만 영상을 보냅니다. 서버는 받은 영상을 다른 참가자들에게 그대로 전달만 합니다.
서버 부담이 적고 확장성이 좋아서 대규모 회의에 적합합니다. MCU(Multipoint Control Unit) 방식은 서버가 모든 영상을 하나로 합쳐서 참가자들에게 보냅니다.
각 참가자는 하나의 영상만 받으면 되니 대역폭이 절약되지만, 서버에서 영상을 합치는 작업이 매우 무거워 비용이 많이 듭니다. 핵심 차이는 확장성과 비용입니다.
Mesh는 2-4명의 소규모에 적합하고, SFU는 중대규모(5-50명)에 이상적이며, MCU는 대규모이지만 비용이 높습니다.
코드 예제
// Mesh 방식의 기본 구조 예시
class MeshVideoConference {
constructor() {
// 각 참가자와의 연결을 저장하는 맵
this.peerConnections = new Map();
this.localStream = null;
}
async addParticipant(participantId) {
// 새 참가자를 위한 PeerConnection 생성
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 내 영상을 상대방에게 전송
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
// 연결 저장
this.peerConnections.set(participantId, pc);
return pc;
}
}
설명
이것이 하는 일: 세 가지 아키텍처는 화상회의에서 영상 데이터가 어떻게 이동하는지를 결정합니다. Mesh 방식을 좀 더 자세히 살펴봅시다.
이것은 마치 여러 친구들과 동시에 전화하는 것과 비슷합니다. 3명이면 2개의 전화선, 4명이면 3개의 전화선이 필요합니다.
수식으로 표현하면 N명일 때 각자 N-1개의 연결이 필요합니다. 서버가 필요 없고 지연시간이 짧다는 장점이 있지만, 6명만 되어도 각자 5개의 영상을 업로드해야 해서 인터넷이 느려집니다.
SFU 방식은 우체국 같은 역할을 합니다. 여러분이 편지(영상)를 우체국(서버)에 보내면, 우체국은 그 편지를 다른 사람들에게 그대로 배달합니다.
편지 내용을 읽거나 바꾸지 않고 배달만 하기 때문에 서버 부담이 적습니다. 각 참가자는 1개의 영상만 업로드하면 되니 Mesh보다 훨씬 효율적입니다.
MCU 방식은 방송국 같습니다. 모든 참가자의 영상을 받아서 하나의 화면으로 편집한 후 방송합니다.
각 참가자는 하나의 영상만 다운로드하면 되니 인터넷이 느린 사람도 참여할 수 있습니다. 하지만 서버가 실시간으로 영상을 합치는 작업(인코딩)을 해야 해서 비싼 서버가 필요합니다.
여러분이 소규모 팀 회의(2-4명)를 만든다면 Mesh를 사용하세요. 서버 비용이 없고 구현이 간단합니다.
교육 플랫폼이나 웨비나(10-50명)를 만든다면 SFU가 최적입니다. 대규모 컨퍼런스(100명 이상)라면 MCU를 고려하되, 비용을 감당할 수 있는지 먼저 확인하세요.
실무에서는 하이브리드 방식도 많이 사용합니다. 예를 들어, 발표자 4명은 Mesh로 연결하고, 청중들은 SFU로 시청만 하는 방식입니다.
이렇게 하면 각 방식의 장점을 모두 활용할 수 있습니다.
실전 팁
💡 참가자가 4명 이하라면 무조건 Mesh를 선택하세요. 서버 비용이 들지 않고 지연시간이 가장 짧아 자연스러운 대화가 가능합니다.
💡 5-20명 규모라면 SFU를 사용하되, 각 참가자의 업로드 대역폭을 체크하세요. 최소 2Mbps 이상이어야 안정적입니다.
💡 MCU는 구현하지 말고 Jitsi, Janus 같은 오픈소스나 Agora, Twilio 같은 상용 서비스를 사용하세요. 직접 만들면 개발 비용이 너무 큽니다.
💡 Mesh를 선택했다면 참가자 수를 최대 6명으로 제한하세요. 그 이상은 대부분의 사용자 인터넷이 감당하지 못합니다.
💡 처음에는 Mesh로 시작하고, 사용자가 늘어나면 SFU로 마이그레이션하는 전략이 효과적입니다. 초기 개발 비용을 줄일 수 있습니다.
2. 다중 PeerConnection 관리
시작하며
여러분이 화상회의 앱을 만들다가 이런 상황을 겪어본 적 있나요? 참가자가 3명인데 누구 영상이 누구 연결인지 헷갈리고, 한 명이 나가면 어떤 연결을 닫아야 할지 모르겠는 상황 말이죠.
이런 문제는 PeerConnection들을 체계적으로 관리하지 않아서 발생합니다. Mesh 방식에서는 참가자마다 별도의 PeerConnection이 필요한데, 이것들을 제대로 관리하지 않으면 메모리 누수, 잘못된 연결, 영상 꼬임 같은 문제가 생깁니다.
바로 이럴 때 필요한 것이 체계적인 PeerConnection 관리 시스템입니다. 각 연결을 고유 ID로 식별하고, 상태를 추적하며, 생명주기를 관리하면 안정적인 다자간 통화를 구현할 수 있습니다.
개요
간단히 말해서, 다중 PeerConnection 관리는 여러 개의 WebRTC 연결을 효율적으로 생성, 추적, 종료하는 시스템입니다. 각 참가자와의 연결을 Map이나 Object로 저장하고, 참가자 ID를 키로 사용하면 언제든지 특정 참가자의 연결을 찾을 수 있습니다.
예를 들어, "user123"이 나가면 connections.get('user123')로 해당 연결을 찾아서 닫으면 됩니다. 기존에는 연결들을 배열에 넣고 하나씩 확인했다면, 이제는 Map을 사용해서 O(1) 시간에 원하는 연결을 찾을 수 있습니다.
이것은 참가자가 많아질수록 성능 차이가 커집니다. 핵심 특징은 세 가지입니다.
첫째, 각 연결에 고유 식별자를 부여합니다. 둘째, 연결 상태(connecting, connected, disconnected)를 추적합니다.
셋째, 연결이 끊어지면 자동으로 재연결하거나 정리합니다. 이러한 특징들이 안정적인 화상회의의 기반이 됩니다.
코드 예제
// 체계적인 PeerConnection 관리 클래스
class PeerConnectionManager {
constructor() {
this.connections = new Map();
this.localStream = null;
}
async createConnection(userId, isInitiator = false) {
// 이미 연결이 있으면 재사용
if (this.connections.has(userId)) {
return this.connections.get(userId);
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 로컬 스트림 추가
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
// 연결 상태 모니터링
pc.oniceconnectionstatechange = () => {
console.log(`${userId} 연결 상태:`, pc.iceConnectionState);
if (pc.iceConnectionState === 'failed') {
this.handleConnectionFailure(userId);
}
};
// 원격 스트림 수신
pc.ontrack = (event) => {
this.handleRemoteStream(userId, event.streams[0]);
};
// Map에 저장 (userId를 키로 사용)
this.connections.set(userId, pc);
return pc;
}
closeConnection(userId) {
const pc = this.connections.get(userId);
if (pc) {
pc.close();
this.connections.delete(userId);
console.log(`${userId}와의 연결 종료`);
}
}
closeAllConnections() {
this.connections.forEach((pc, userId) => {
this.closeConnection(userId);
});
}
}
설명
이것이 하는 일: 여러 참가자와의 WebRTC 연결을 체계적으로 생성하고, 추적하고, 종료하는 시스템을 만듭니다. 첫 번째로, createConnection 메서드는 새로운 참가자를 위한 PeerConnection을 생성합니다.
이미 해당 참가자와의 연결이 있는지 먼저 확인하는 것이 중요합니다. 만약 있다면 새로 만들지 않고 기존 연결을 재사용합니다.
이렇게 하면 실수로 같은 사람과 여러 번 연결하는 것을 방지할 수 있습니다. 그 다음으로, oniceconnectionstatechange 이벤트 핸들러가 연결 상태를 지속적으로 모니터링합니다.
WebRTC 연결은 'new' → 'checking' → 'connected' → 'completed' 순서로 상태가 변합니다. 만약 'failed' 상태가 되면 네트워크 문제나 방화벽 때문일 수 있으므로, 사용자에게 알리거나 재연결을 시도해야 합니다.
ontrack 이벤트는 상대방의 영상/음성 스트림이 도착했을 때 실행됩니다. 여기서 받은 스트림을 화면의 video 엘리먼트에 연결하면 상대방의 영상이 보입니다.
userId와 함께 저장해두면 나중에 "이 영상이 누구 것인지" 쉽게 알 수 있습니다. 마지막으로, closeConnection과 closeAllConnections 메서드가 정리 작업을 담당합니다.
참가자가 나가거나 회의가 종료될 때 반드시 PeerConnection을 닫아야 합니다. 닫지 않으면 계속 대역폭을 소모하고 메모리를 차지합니다.
Map의 delete 메서드로 참조도 함께 제거해야 가비지 컬렉션이 제대로 작동합니다. 여러분이 이 코드를 사용하면 참가자가 10명이든 20명이든 각 연결을 명확하게 구분할 수 있습니다.
버그가 생겨도 특정 연결만 디버깅할 수 있어 문제 해결이 빠릅니다. 또한 연결 상태를 UI에 표시해서 사용자에게 "연결 중...", "연결됨" 같은 피드백을 줄 수 있습니다.
실전 팁
💡 PeerConnection을 생성할 때 반드시 모든 이벤트 핸들러를 설정한 후에 offer/answer를 주고받으세요. 순서가 바뀌면 이벤트를 놓칠 수 있습니다.
💡 연결을 닫을 때는 pc.close() → Map.delete() → UI 업데이트 순서를 지키세요. 역순으로 하면 이미 닫힌 연결을 참조하는 에러가 발생할 수 있습니다.
💡 개발 중에는 connections.size를 주기적으로 로깅하세요. 참가자가 나간 후에도 size가 줄지 않는다면 메모리 누수가 있다는 신호입니다.
💡 각 PeerConnection에 타임스탬프를 함께 저장하면 "언제 연결되었는지", "얼마나 오래 통화 중인지" 같은 통계를 쉽게 만들 수 있습니다.
💡 production 환경에서는 연결 실패 시 최대 3번까지 자동 재연결을 시도하세요. 일시적인 네트워크 문제는 대부분 재연결로 해결됩니다.
3. 참여자 추가/제거
시작하며
여러분이 화상회의 중에 새로운 사람이 들어오거나 나갈 때마다 이런 일을 겪어본 적 있나요? 새 참가자가 들어왔는데 기존 사람들의 영상이 안 보이거나, 누군가 나갔는데 그 사람 화면이 계속 남아있는 상황 말이죠.
이런 문제는 참가자의 입장/퇴장을 제대로 처리하지 않아서 발생합니다. Mesh 방식에서는 한 명이 들어오면 모든 기존 참가자와 연결해야 하고, 한 명이 나가면 그 사람과 관련된 모든 연결을 정리해야 합니다.
이 과정에서 실수하면 연결이 꼬이거나 영상이 멈춥니다. 바로 이럴 때 필요한 것이 체계적인 참여자 추가/제거 로직입니다.
시그널링 서버와 통신해서 누가 들어오고 나가는지 알아내고, 그에 맞게 PeerConnection을 생성하거나 정리하면 안정적인 다자간 통화를 유지할 수 있습니다.
개요
간단히 말해서, 참여자 추가/제거는 화상회의 중에 동적으로 사람들이 입장하고 퇴장할 때 필요한 연결을 만들고 정리하는 프로세스입니다. 새 참가자가 들어오면 두 가지 일이 동시에 일어나야 합니다.
첫째, 새 참가자는 모든 기존 참가자와 연결해야 합니다. 둘째, 모든 기존 참가자는 새 참가자와 연결해야 합니다.
예를 들어, A, B, C가 통화 중인데 D가 들어오면 D→A, D→B, D→C, A→D, B→D, C→D 총 6개의 연결이 생성됩니다. 기존에는 서버에서 "누가 들어왔어"라는 메시지만 보냈다면, 이제는 "현재 참가자 목록"과 함께 보내서 새 참가자가 누구와 연결해야 하는지 명확히 알려줍니다.
이렇게 하면 누락되는 연결이 없습니다. 핵심 특징은 세 가지입니다.
첫째, 시그널링 서버가 입장/퇴장 이벤트를 모든 참가자에게 브로드캐스트합니다. 둘째, offer/answer SDP를 교환해서 미디어 연결을 수립합니다.
셋째, ICE candidate를 주고받아서 최적의 연결 경로를 찾습니다. 이러한 단계들을 올바른 순서로 실행해야 연결이 성공합니다.
코드 예제
// 참여자 추가 및 제거 처리
class VideoConferenceRoom {
constructor(signalingServer, myUserId) {
this.signaling = signalingServer;
this.myUserId = myUserId;
this.pcManager = new PeerConnectionManager();
this.participants = new Set();
this.setupSignalingHandlers();
}
setupSignalingHandlers() {
// 새 참가자 입장 시
this.signaling.on('user-joined', async ({ userId, participants }) => {
console.log(`${userId}님이 입장했습니다`);
// 기존 참가자들과 연결
for (const participantId of participants) {
if (participantId !== this.myUserId) {
await this.connectToUser(participantId);
}
}
this.participants.add(userId);
this.updateUI();
});
// 참가자 퇴장 시
this.signaling.on('user-left', ({ userId }) => {
console.log(`${userId}님이 퇴장했습니다`);
// 해당 참가자와의 연결 종료
this.pcManager.closeConnection(userId);
this.participants.delete(userId);
this.removeVideoElement(userId);
this.updateUI();
});
// Offer 수신 (상대방이 연결 요청)
this.signaling.on('offer', async ({ from, offer }) => {
const pc = await this.pcManager.createConnection(from, false);
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.signaling.send('answer', { to: from, answer });
});
// Answer 수신 (연결 요청 수락됨)
this.signaling.on('answer', async ({ from, answer }) => {
const pc = this.pcManager.connections.get(from);
await pc.setRemoteDescription(answer);
});
// ICE candidate 수신
this.signaling.on('ice-candidate', async ({ from, candidate }) => {
const pc = this.pcManager.connections.get(from);
await pc.addIceCandidate(candidate);
});
}
async connectToUser(userId) {
// Offer 생성 및 전송
const pc = await this.pcManager.createConnection(userId, true);
pc.onicecandidate = (event) => {
if (event.candidate) {
this.signaling.send('ice-candidate', {
to: userId,
candidate: event.candidate
});
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.signaling.send('offer', { to: userId, offer });
}
leaveRoom() {
// 모든 연결 종료 및 시그널링 서버에 알림
this.pcManager.closeAllConnections();
this.signaling.send('leave-room', { userId: this.myUserId });
}
}
설명
이것이 하는 일: 화상회의 중에 참가자가 동적으로 입장하고 퇴장할 때 필요한 모든 WebRTC 연결을 자동으로 관리합니다. 첫 번째로, user-joined 이벤트 핸들러가 새 참가자 입장을 처리합니다.
서버에서 받은 participants 배열에는 현재 방에 있는 모든 사람의 ID가 들어있습니다. 내 ID를 제외한 모든 사람과 연결해야 하므로 for 루프를 돌면서 connectToUser를 호출합니다.
이때 새로 들어온 사람뿐만 아니라 이미 있던 사람들과도 연결하는 것이 중요합니다. 그 다음으로, connectToUser 메서드가 실제 WebRTC 연결을 수립합니다.
먼저 offer(연결 제안)를 만들어서 상대방에게 보냅니다. Offer에는 "나는 이런 코덱을 지원하고, 이런 미디어를 보낼 수 있어"라는 정보가 들어있습니다.
상대방은 이것을 받아서 answer(응답)를 만들어 돌려보냅니다. 이 과정을 SDP 교환이라고 합니다.
SDP 교환이 끝나면 ICE candidate 교환이 시작됩니다. ICE candidate는 "내 컴퓨터에 연결하려면 이 IP 주소와 포트를 사용해"라는 정보입니다.
각자 여러 개의 candidate를 보내고, WebRTC는 그 중에서 가장 빠른 경로를 자동으로 선택합니다. onicecandidate 이벤트가 발생할 때마다 시그널링 서버를 통해 상대방에게 전달해야 합니다.
마지막으로, user-left 이벤트가 퇴장을 처리합니다. 해당 참가자와의 PeerConnection을 닫고, participants Set에서 제거하고, 화면에서 video 엘리먼트를 삭제합니다.
이 세 가지를 모두 해야 깨끗하게 정리됩니다. 하나라도 빠뜨리면 메모리 누수나 빈 화면이 남는 문제가 생깁니다.
여러분이 이 코드를 사용하면 참가자가 수시로 들락날락해도 안정적으로 연결이 유지됩니다. 각 단계마다 에러 처리를 추가하면 네트워크가 불안정해도 부분적으로 연결을 복구할 수 있습니다.
또한 연결 시도 중에 사용자가 나가는 경우도 처리해야 하므로, connectToUser 시작 전에 해당 참가자가 여전히 방에 있는지 확인하는 것이 좋습니다.
실전 팁
💡 새 참가자가 입장할 때 기존 참가자들에게 "연결 중..." UI를 표시하세요. 연결에 2-3초 걸릴 수 있어서 사용자가 기다리는 동안 답답함을 느낄 수 있습니다.
💡 offer/answer/candidate를 보낼 때 타임아웃을 설정하세요. 5초 안에 응답이 없으면 재시도하거나 에러를 표시해야 합니다.
💡 참가자가 나갈 때 화면에서 video 엘리먼트를 제거하기 전에 0.5초 정도 페이드아웃 애니메이션을 주면 더 자연스럽습니다.
💡 시그널링 메시지에 버전 번호를 넣으세요. 클라이언트 버전이 다르면 호환되지 않는 메시지가 올 수 있어서 디버깅이 어렵습니다.
💡 방에 입장할 때 기존 참가자 수를 먼저 확인하세요. 이미 최대 인원이면 입장을 막고 "방이 가득 찼습니다" 메시지를 표시해야 합니다.
4. 대역폭 관리
시작하며
여러분이 화상회의를 하다가 이런 경험을 한 적 있나요? 처음에는 영상이 선명했는데 사람이 몇 명 더 들어오니까 화질이 떨어지고 끊기기 시작하는 상황 말이죠.
심지어 어떤 참가자는 계속 버퍼링이 걸려서 대화가 불가능한 경우도 있습니다. 이런 문제는 대역폭을 제대로 관리하지 않아서 발생합니다.
Mesh 방식에서는 참가자가 늘어날수록 각 사람이 업로드해야 하는 데이터가 기하급수적으로 증가합니다. 4명이면 괜찮지만 6명만 되어도 일반 가정 인터넷으로는 감당하기 어렵습니다.
바로 이럴 때 필요한 것이 적응형 대역폭 관리입니다. 네트워크 상태를 실시간으로 모니터링하고, 자동으로 화질과 프레임레이트를 조절하면 불안정한 네트워크에서도 끊김 없는 통화를 유지할 수 있습니다.
개요
간단히 말해서, 대역폭 관리는 사용 가능한 네트워크 속도에 맞춰 영상 품질을 자동으로 조절하는 기술입니다. WebRTC는 기본적으로 가능한 최고 품질로 영상을 전송하려고 합니다.
예를 들어, 1080p 30fps로 보내려고 하는데 인터넷이 느리면 패킷 손실이 발생해서 영상이 끊깁니다. 대역폭 관리를 하면 네트워크가 느릴 때 자동으로 720p 15fps로 낮춰서 끊김을 방지합니다.
기존에는 고정된 화질을 사용했다면, 이제는 네트워크 통계를 보고 동적으로 조절합니다. WebRTC의 getStats() API로 현재 비트레이트, 패킷 손실률, 지터를 확인할 수 있습니다.
핵심 특징은 세 가지입니다. 첫째, 비트레이트 제한을 설정해서 과도한 대역폭 사용을 방지합니다.
둘째, 네트워크 통계를 주기적으로 모니터링해서 문제를 조기에 감지합니다. 셋째, 화질 저하보다 프레임레이트 유지를 우선해서 자연스러운 움직임을 보장합니다.
코드 예제
// 적응형 대역폭 관리 시스템
class BandwidthManager {
constructor() {
this.maxBitrate = 2500; // kbps
this.minBitrate = 300;
this.currentBitrate = 1500;
}
async applyBitrateLimit(sender, bitrate) {
// 비디오 sender의 인코딩 파라미터 가져오기
const parameters = sender.getParameters();
if (!parameters.encodings) {
parameters.encodings = [{}];
}
// 최대 비트레이트 설정
parameters.encodings[0].maxBitrate = bitrate * 1000; // bps로 변환
await sender.setParameters(parameters);
console.log(`비트레이트를 ${bitrate}kbps로 제한했습니다`);
}
async monitorConnection(peerConnection, userId) {
// 2초마다 통계 확인
setInterval(async () => {
const stats = await peerConnection.getStats();
let packetLoss = 0;
let currentBitrate = 0;
stats.forEach(report => {
if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
// 패킷 손실률 계산
if (report.packetsLost && report.packetsSent) {
packetLoss = (report.packetsLost / report.packetsSent) * 100;
}
// 현재 비트레이트 확인
if (report.bytesSent && report.timestamp) {
currentBitrate = (report.bytesSent * 8) / 1000; // kbps
}
}
});
// 패킷 손실이 5% 이상이면 비트레이트 낮추기
if (packetLoss > 5 && this.currentBitrate > this.minBitrate) {
this.currentBitrate = Math.max(
this.minBitrate,
this.currentBitrate * 0.8
);
const sender = this.getVideoSender(peerConnection);
await this.applyBitrateLimit(sender, this.currentBitrate);
console.log(`${userId}: 패킷 손실 ${packetLoss.toFixed(1)}% 감지, 비트레이트 낮춤`);
}
// 패킷 손실이 1% 미만이면 비트레이트 높이기
else if (packetLoss < 1 && this.currentBitrate < this.maxBitrate) {
this.currentBitrate = Math.min(
this.maxBitrate,
this.currentBitrate * 1.1
);
const sender = this.getVideoSender(peerConnection);
await this.applyBitrateLimit(sender, this.currentBitrate);
console.log(`${userId}: 네트워크 안정적, 비트레이트 높임`);
}
}, 2000);
}
getVideoSender(peerConnection) {
const senders = peerConnection.getSenders();
return senders.find(sender => sender.track?.kind === 'video');
}
async setResolution(stream, width, height, frameRate) {
// 비디오 트랙의 해상도와 프레임레이트 변경
const videoTrack = stream.getVideoTracks()[0];
await videoTrack.applyConstraints({
width: { ideal: width },
height: { ideal: height },
frameRate: { ideal: frameRate }
});
}
}
설명
이것이 하는 일: 네트워크 상황에 맞춰 영상 품질을 동적으로 조절해서 끊김 없는 화상회의를 제공합니다. 첫 번째로, applyBitrateLimit 메서드가 최대 전송 비트레이트를 제한합니다.
RTCRtpSender의 파라미터를 수정해서 "초당 최대 1500kbps만 보내"라고 지시합니다. 이렇게 하면 인터넷이 느린 사용자도 업로드 대역폭을 초과하지 않아 다른 앱(브라우저, 메신저)을 함께 사용할 수 있습니다.
그 다음으로, monitorConnection 메서드가 2초마다 연결 통계를 확인합니다. getStats() API는 엄청나게 많은 정보를 반환하는데, 그 중에서 'outbound-rtp' 타입의 비디오 리포트를 찾아야 합니다.
여기에 packetsLost(손실된 패킷), packetsSent(전송한 패킷), bytesSent(전송한 바이트) 같은 중요한 지표들이 들어있습니다. 패킷 손실률이 5%를 넘으면 네트워크가 혼잡하다는 신호입니다.
이때는 현재 비트레이트를 20% 낮춥니다(0.8 곱하기). 너무 급격히 낮추면 화질이 갑자기 나빠져서 사용자가 당황하므로, 점진적으로 조절하는 것이 좋습니다.
반대로 패킷 손실률이 1% 미만으로 안정적이면 10% 높여서 더 좋은 화질을 제공합니다. 마지막으로, setResolution 메서드가 비디오 트랙의 해상도와 프레임레이트를 직접 변경합니다.
예를 들어, 네트워크가 매우 느리면 640x480 15fps로 낮추고, 빠르면 1280x720 30fps로 높입니다. applyConstraints는 비동기 작업이므로 await를 사용해야 하며, 변경에 1-2초 걸릴 수 있습니다.
여러분이 이 코드를 사용하면 사용자의 인터넷 속도가 들쑥날쑥해도 자동으로 대응합니다. 카페에서 느린 와이파이를 쓰다가 5G로 바꾸면 화질이 자동으로 좋아지고, 반대로 지하철에 들어가면 화질이 낮아지지만 끊기지는 않습니다.
실무에서는 이 로직에 더해 사용자가 수동으로 화질을 선택할 수 있는 옵션도 제공하면 좋습니다.
실전 팁
💡 비트레이트를 조절한 후 최소 5초는 기다렸다가 다음 조절을 하세요. 너무 자주 바꾸면 영상이 계속 흔들려 보입니다.
💡 모바일 사용자는 기본 비트레이트를 낮게(1000kbps) 설정하세요. 모바일 데이터는 제한이 있고 업로드 속도가 느린 경우가 많습니다.
💡 화질보다 프레임레이트를 우선하세요. 720p 30fps가 1080p 15fps보다 자연스럽게 보입니다. 사람들은 해상도보다 부드러움을 더 중요하게 느낍니다.
💡 getStats()는 매우 무거운 작업이므로 1초에 한 번 이상 호출하지 마세요. 2-3초 간격이 적당합니다.
💡 네트워크 통계를 UI에 표시하는 개발자 모드를 만들어두세요. 실제 사용자가 문제를 겪을 때 스크린샷만 받아도 원인을 파악할 수 있습니다.
5. 최대 참여자 수 제한
시작하며
여러분이 화상회의 서비스를 운영하다가 이런 상황을 겪어본 적 있나요? 한 방에 10명이 들어오니까 모두의 화면이 얼고 아무것도 할 수 없게 되는 상황 말이죠.
심지어 브라우저가 멈추거나 컴퓨터 팬이 미친 듯이 돌아가는 경우도 있습니다. 이런 문제는 Mesh 방식의 근본적인 한계 때문에 발생합니다.
참가자가 N명이면 각자 N-1개의 영상을 업로드하고 N-1개의 영상을 다운로드해야 합니다. 10명이면 각자 9개의 연결을 유지해야 하는데, 일반 사용자의 컴퓨터와 인터넷으로는 감당하기 어렵습니다.
바로 이럴 때 필요한 것이 최대 참여자 수 제한입니다. 방에 들어올 수 있는 인원을 미리 제한하면 모든 참가자가 안정적인 경험을 할 수 있습니다.
서버와 클라이언트 양쪽에서 제한을 구현해야 완벽하게 보호됩니다.
개요
간단히 말해서, 최대 참여자 수 제한은 한 화상회의 방에 들어올 수 있는 사람의 수를 정해서 시스템 과부하를 방지하는 안전장치입니다. Mesh 방식에서는 참가자 수가 성능에 직접적인 영향을 미칩니다.
4명일 때는 각자 3개의 연결이지만, 8명이면 7개의 연결입니다. 각 연결마다 영상 인코딩, 네트워크 전송, 디코딩이 필요하므로 CPU와 대역폭 사용이 급증합니다.
예를 들어, 각 영상이 1.5Mbps라면 7명의 영상을 받으려면 10.5Mbps 다운로드가 필요합니다. 기존에는 제한 없이 계속 받다가 문제가 생겼다면, 이제는 미리 최대 인원을 설정합니다.
일반적으로 Mesh 방식은 4-6명이 적당하고, 최대 8명을 넘지 않는 것이 좋습니다. 핵심 특징은 세 가지입니다.
첫째, 서버에서 방의 현재 인원을 추적하고 최대치를 넘으면 입장을 거부합니다. 둘째, 클라이언트도 참가자 수를 확인해서 UI에 경고를 표시합니다.
셋째, 관리자나 호스트에게는 제한을 무시하고 들어올 수 있는 권한을 줄 수 있습니다.
코드 예제
// 서버 측 참여자 수 제한 (Node.js + Socket.IO 예시)
class RoomManager {
constructor() {
this.rooms = new Map(); // roomId -> Set of userIds
this.MAX_PARTICIPANTS = 6;
}
canJoinRoom(roomId) {
const room = this.rooms.get(roomId);
if (!room) return true; // 새 방은 입장 가능
return room.size < this.MAX_PARTICIPANTS;
}
joinRoom(roomId, userId, socket) {
// 최대 인원 확인
if (!this.canJoinRoom(roomId)) {
socket.emit('join-error', {
message: `방이 가득 찼습니다 (최대 ${this.MAX_PARTICIPANTS}명)`
});
return false;
}
// 방이 없으면 생성
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, new Set());
}
const room = this.rooms.get(roomId);
room.add(userId);
// 방의 모든 사람에게 새 참가자 알림
socket.to(roomId).emit('user-joined', {
userId,
participants: Array.from(room),
remainingSlots: this.MAX_PARTICIPANTS - room.size
});
// 입장한 사람에게 기존 참가자 목록 전달
socket.emit('room-joined', {
roomId,
participants: Array.from(room).filter(id => id !== userId),
totalParticipants: room.size,
maxParticipants: this.MAX_PARTICIPANTS
});
return true;
}
leaveRoom(roomId, userId) {
const room = this.rooms.get(roomId);
if (room) {
room.delete(userId);
// 방이 비었으면 삭제
if (room.size === 0) {
this.rooms.delete(roomId);
}
}
}
}
// 클라이언트 측 입장 처리
class RoomClient {
constructor(signalingServer) {
this.signaling = signalingServer;
this.MAX_SAFE_PARTICIPANTS = 6;
this.signaling.on('room-joined', (data) => {
if (data.totalParticipants >= this.MAX_SAFE_PARTICIPANTS) {
this.showWarning(
`현재 ${data.totalParticipants}명 참여 중입니다. ` +
`품질이 저하될 수 있습니다.`
);
}
});
this.signaling.on('join-error', (data) => {
this.showError(data.message);
});
}
async joinRoom(roomId) {
this.signaling.emit('join-room', { roomId });
}
showWarning(message) {
// UI에 경고 표시
console.warn(message);
const warningEl = document.getElementById('warning');
warningEl.textContent = message;
warningEl.style.display = 'block';
}
showError(message) {
alert(message);
// 입장 실패 시 로비로 돌아가기
window.location.href = '/lobby';
}
}
설명
이것이 하는 일: 화상회의 방에 너무 많은 사람이 들어와 시스템이 과부하되는 것을 방지합니다. 첫 번째로, 서버의 canJoinRoom 메서드가 입장 가능 여부를 확인합니다.
현재 방에 몇 명이 있는지 확인하고, MAX_PARTICIPANTS보다 적으면 true를 반환합니다. 이 검사는 매우 중요합니다.
서버에서 하지 않으면 악의적인 사용자가 클라이언트 코드를 수정해서 제한을 우회할 수 있습니다. 그 다음으로, joinRoom 메서드가 실제 입장을 처리합니다.
canJoinRoom이 false를 반환하면 'join-error' 이벤트를 보내서 사용자에게 "방이 가득 찼습니다"라고 알립니다. 입장이 허용되면 방의 Set에 userId를 추가하고, 기존 참가자들에게 'user-joined' 이벤트를 브로드캐스트합니다.
이때 remainingSlots(남은 자리 수)도 함께 보내면 UI에 "2/6명 참여 중" 같은 정보를 표시할 수 있습니다. 클라이언트 측에서는 'room-joined' 이벤트를 받았을 때 현재 인원을 확인합니다.
MAX_SAFE_PARTICIPANTS에 가까워지면 경고 메시지를 표시합니다. 예를 들어, "6명 중 6명 참여 중입니다.
화질이 저하될 수 있습니다"라고 알려주면 사용자가 예상치 못한 품질 저하에 당황하지 않습니다. 마지막으로, leaveRoom 메서드가 퇴장을 처리합니다.
Set에서 userId를 제거하고, 만약 방이 비었으면(size === 0) Map에서 방 자체를 삭제합니다. 이렇게 해야 메모리 누수가 발생하지 않고, 나중에 같은 roomId로 새 방을 만들 때 문제가 없습니다.
여러분이 이 코드를 사용하면 시스템이 감당할 수 없는 상황을 미리 방지할 수 있습니다. 사용자 경험도 좋아집니다.
"방이 가득 찼습니다"라는 명확한 메시지를 받는 것이, 입장했는데 화면이 멈추는 것보다 훨씬 낫습니다. 실무에서는 유료 사용자에게는 더 높은 제한을 주거나, 호스트가 최대 인원을 설정할 수 있게 하는 기능도 추가할 수 있습니다.
실전 팁
💡 MAX_PARTICIPANTS를 환경변수로 관리하세요. 서버 성능이 좋아지면 코드 수정 없이 제한을 늘릴 수 있습니다.
💡 방이 거의 찼을 때(예: 5/6명) 입장하는 사용자에게 "마지막 자리입니다" 같은 메시지를 보여주면 긴박감을 줄 수 있습니다.
💡 VIP나 관리자에게는 우선권을 주세요. 일반 사용자는 6명 제한이지만, 호스트는 언제든 들어올 수 있게 하면 유용합니다.
💡 대기열 시스템을 구현하세요. 방이 가득 찼을 때 대기 목록에 추가하고, 누군가 나가면 자동으로 입장시키면 사용자 경험이 좋아집니다.
💡 통계를 수집하세요. "몇 명일 때 가장 많이 방이 찼는지" 데이터를 보면 제한을 조정하거나 SFU로 마이그레이션할 타이밍을 알 수 있습니다.
6. 성능 최적화
시작하며
여러분이 화상회의 앱을 만들었는데 이런 피드백을 받은 적 있나요? "노트북 팬이 시끄럽게 돌아가요", "배터리가 30분만에 다 닳았어요", "브라우저 탭이 멈춰요" 같은 불만 말이죠.
이런 문제는 WebRTC가 기본적으로 최고 품질을 추구하면서 CPU와 메모리를 과도하게 사용하기 때문입니다. 특히 Mesh 방식은 여러 개의 영상을 동시에 인코딩하고 디코딩해야 해서 리소스 사용이 매우 높습니다.
최적화하지 않으면 사용자가 다른 작업을 할 수 없을 정도로 느려집니다. 바로 이럴 때 필요한 것이 체계적인 성능 최적화입니다.
하드웨어 가속 활용, 불필요한 영상 중지, 스트림 재사용, 레이아웃 최적화 등 다양한 기법을 적용하면 같은 품질을 훨씬 적은 리소스로 제공할 수 있습니다.
개요
간단히 말해서, 성능 최적화는 화상회의의 품질은 유지하면서 CPU, 메모리, 대역폭 사용을 최소화하는 기술들의 집합입니다. 가장 큰 성능 향상은 하드웨어 가속에서 나옵니다.
최신 컴퓨터의 GPU는 영상 인코딩/디코딩에 특화된 칩을 가지고 있어서 CPU보다 10배 이상 빠르고 전력 소모도 적습니다. 예를 들어, H.264 하드웨어 인코딩을 사용하면 CPU 사용률이 80%에서 20%로 떨어집니다.
기존에는 모든 참가자의 영상을 항상 받았다면, 이제는 화면에 보이는 영상만 받습니다. 10명이 참여해도 실제로 화면에는 4명만 보인다면, 나머지 6명의 영상은 낮은 품질로 받거나 아예 안 받을 수 있습니다.
핵심 특징은 네 가지입니다. 첫째, Simulcast로 한 영상을 여러 품질로 동시에 전송합니다.
둘째, 화면에 보이지 않는 영상은 자동으로 일시정지합니다. 셋째, 로컬 스트림을 재사용해서 여러 PeerConnection에서 같은 트랙을 공유합니다.
넷째, Virtual DOM 같은 기법으로 video 엘리먼트 생성/삭제를 최소화합니다.
코드 예제
// 성능 최적화가 적용된 화상회의 시스템
class OptimizedVideoConference {
constructor() {
this.localStream = null;
this.peerConnections = new Map();
this.videoElements = new Map();
this.visibleParticipants = new Set();
}
async initialize() {
// 하드웨어 가속 활성화된 로컬 스트림 획득
this.localStream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
// 하드웨어 가속 사용 (가능한 경우)
advanced: [{
googCpuOveruseDetection: true,
googNoiseReduction: true
}]
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
}
async addParticipant(userId) {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
// 성능 최적화 옵션
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// 로컬 스트림의 트랙을 재사용 (복사하지 않음)
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
// 원격 스트림 수신
pc.ontrack = (event) => {
this.handleRemoteStream(userId, event.streams[0]);
};
this.peerConnections.set(userId, pc);
return pc;
}
handleRemoteStream(userId, stream) {
// video 엘리먼트 재사용 (가능한 경우)
let videoEl = this.videoElements.get(userId);
if (!videoEl) {
videoEl = document.createElement('video');
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = userId === 'local'; // 자기 자신은 음소거
this.videoElements.set(userId, videoEl);
document.getElementById('videos').appendChild(videoEl);
}
videoEl.srcObject = stream;
}
// Intersection Observer로 화면에 보이는 영상만 활성화
setupVisibilityOptimization() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const userId = entry.target.dataset.userId;
const pc = this.peerConnections.get(userId);
if (!pc) return;
if (entry.isIntersecting) {
// 화면에 보이면 영상 재생
this.visibleParticipants.add(userId);
this.enableVideo(pc, true);
} else {
// 화면에 안 보이면 영상 일시정지
this.visibleParticipants.delete(userId);
this.enableVideo(pc, false);
}
});
}, {
threshold: 0.1 // 10%만 보여도 활성화
});
// 모든 video 엘리먼트 관찰
this.videoElements.forEach((videoEl, userId) => {
videoEl.dataset.userId = userId;
observer.observe(videoEl);
});
}
enableVideo(peerConnection, enable) {
// 수신받는 비디오 트랙 활성화/비활성화
const receivers = peerConnection.getReceivers();
receivers.forEach(receiver => {
if (receiver.track?.kind === 'video') {
receiver.track.enabled = enable;
}
});
}
// 메모리 누수 방지: 정리 작업
removeParticipant(userId) {
// PeerConnection 정리
const pc = this.peerConnections.get(userId);
if (pc) {
pc.close();
this.peerConnections.delete(userId);
}
// Video 엘리먼트 정리
const videoEl = this.videoElements.get(userId);
if (videoEl) {
videoEl.srcObject = null; // 스트림 참조 해제
videoEl.remove(); // DOM에서 제거
this.videoElements.delete(userId);
}
this.visibleParticipants.delete(userId);
}
// CPU 사용률 모니터링
async monitorPerformance() {
setInterval(async () => {
for (const [userId, pc] of this.peerConnections) {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
// 하드웨어 인코더 사용 여부 확인
if (report.encoderImplementation) {
console.log(`${userId}: ${report.encoderImplementation}`);
}
// CPU 제한 감지
if (report.qualityLimitationReason === 'cpu') {
console.warn(`${userId}: CPU 과부하로 품질 저하`);
this.reduceCpuLoad();
}
}
});
}
}, 5000);
}
reduceCpuLoad() {
// CPU 부하가 높을 때 프레임레이트 낮추기
this.localStream.getVideoTracks()[0].applyConstraints({
frameRate: { ideal: 15 }
});
}
}
설명
이것이 하는 일: 화상회의의 품질은 유지하면서 CPU, 메모리, 네트워크 리소스 사용을 극적으로 줄입니다. 첫 번째로, initialize 메서드에서 하드웨어 가속을 활성화합니다.
googCpuOveruseDetection: true를 설정하면 브라우저가 CPU 과부하를 감지했을 때 자동으로 인코딩을 간소화합니다. echoCancellation, noiseSuppression 같은 오디오 최적화도 중요합니다.
이것들을 끄면 CPU를 절약할 수 있지만, 음질이 나빠져서 회의 품질이 떨어지므로 켜두는 것이 좋습니다. 그 다음으로, addParticipant에서 bundlePolicy: 'max-bundle'을 설정합니다.
이것은 여러 미디어 트랙(오디오, 비디오)을 하나의 네트워크 연결로 묶어서 전송하는 옵션입니다. 연결을 줄이면 NAT traversal이 쉬워지고 대역폭도 절약됩니다.
또한 localStream.getTracks()로 트랙을 직접 추가하면 스트림을 복사하지 않아 메모리가 절약됩니다. setupVisibilityOptimization이 핵심 최적화 기법입니다.
Intersection Observer는 요소가 화면에 보이는지 감지하는 브라우저 API입니다. 예를 들어, 사용자가 채팅창을 열어서 일부 영상이 가려지면 그 영상들의 track.enabled를 false로 설정합니다.
이렇게 하면 디코딩을 멈춰서 CPU를 크게 절약할 수 있습니다. 영상은 받지만 화면에 그리지 않으므로, 다시 보이게 되면 즉시 재생됩니다.
마지막으로, monitorPerformance가 성능 문제를 실시간으로 감지합니다. qualityLimitationReason이 'cpu'로 나오면 컴퓨터가 영상 인코딩을 감당하지 못한다는 뜻입니다.
이때 reduceCpuLoad를 호출해서 프레임레이트를 30fps에서 15fps로 낮춥니다. 화질은 유지되지만 움직임이 약간 부자연스러워질 수 있습니다.
하지만 영상이 완전히 멈추는 것보다는 훨씬 낫습니다. 여러분이 이 코드를 사용하면 같은 화상회의를 절반의 리소스로 실행할 수 있습니다.
노트북 배터리가 2배 오래 가고, 다른 앱을 함께 사용해도 느려지지 않습니다. 특히 오래된 컴퓨터나 저사양 기기를 쓰는 사용자들에게 큰 차이를 만듭니다.
실무에서는 사용자 설정에 "저전력 모드" 옵션을 추가해서 배터리를 더 아끼고 싶은 사람이 선택할 수 있게 하면 좋습니다.
실전 팁
💡 Chrome의 chrome://webrtc-internals에서 실시간 성능 지표를 확인하세요. CPU 사용률, 비트레이트, 패킷 손실 등을 그래프로 볼 수 있어 최적화 효과를 측정할 수 있습니다.
💡 배터리 절약을 위해 화면이 inactive일 때(다른 탭으로 이동) 자동으로 영상을 끄세요. Page Visibility API(document.hidden)를 사용하면 쉽게 구현할 수 있습니다.
💡 Simulcast를 지원하는 브라우저에서는 한 영상을 세 가지 해상도(1080p, 720p, 360p)로 동시 전송하세요. 받는 사람이 필요한 품질만 선택해서 받을 수 있습니다.
💡 video 엘리먼트에 will-change: transform CSS를 추가하면 GPU 가속 렌더링이 활성화되어 화면 갱신이 부드러워집니다. 하지만 모든 video에 쓰면 역효과가 날 수 있으니 주의하세요.
💡 메모리 누수를 찾으려면 Chrome DevTools의 Memory Profiler를 사용하세요. 참가자가 나간 후에도 MediaStream이나 RTCPeerConnection 객체가 남아있다면 어디선가 참조를 유지하고 있다는 뜻입니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
WebRTC 화면 공유 완벽 가이드
WebRTC의 화면 공유 기능을 처음부터 끝까지 배워봅니다. getDisplayMedia API 사용법부터 화면과 카메라 동시 표시, 공유 중단 감지, 해상도 설정까지 실무에서 바로 활용할 수 있는 모든 것을 다룹니다.
1:1 화상 통화 구현 완벽 가이드
WebRTC를 활용한 1:1 화상 통화 시스템을 처음부터 끝까지 구현하는 방법을 배워봅니다. 통화 시작부터 종료까지, 음소거와 카메라 제어, 연결 품질 모니터링까지 실무에 바로 적용할 수 있는 모든 과정을 다룹니다.
ICE Candidate 처리 완벽 가이드
WebRTC 연결을 위한 ICE Candidate 처리 과정을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다. onicecandidate 이벤트부터 addIceCandidate 메서드, Trickle ICE 구현, NAT 트래버설까지 실무에서 바로 적용 가능한 예제와 함께 안내합니다.
WebRTC RTCPeerConnection 생성과 관리 완벽 가이드
WebRTC의 핵심인 RTCPeerConnection을 생성하고 관리하는 방법을 배웁니다. ICE 서버 설정부터 미디어 트랙 추가, 이벤트 관리, 연결 상태 모니터링까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.
Socket.io로 배우는 WebRTC 시그널링 서버 구축
WebRTC 연결을 위한 필수 요소인 시그널링 서버를 Socket.io로 구축하는 방법을 배웁니다. Room 관리, Offer/Answer 교환, ICE Candidate 전달 등 실시간 통신의 핵심 개념을 실무 예제와 함께 친절하게 설명합니다.