🌍 네트워크

WebRTC

Web Real-Time Communication

브라우저에서 플러그인 없이 P2P 실시간 통신을 가능하게 하는 기술. ICE/STUN/TURN으로 NAT 우회, 시그널링으로 SDP 교환. Zoom, Google Meet의 화상회의와 스크린 공유에 사용.

📖 상세 설명

WebRTC(Web Real-Time Communication)는 브라우저와 모바일 앱에서 플러그인 없이 실시간 P2P(Peer-to-Peer) 통신을 가능하게 하는 오픈소스 기술입니다. 2011년 Google이 개발을 시작했으며, 현재 W3C와 IETF에서 표준화되어 Chrome, Firefox, Safari, Edge 등 모든 주요 브라우저에서 지원됩니다.

WebRTC의 핵심 구성요소는 세 가지입니다. MediaStream은 카메라, 마이크에서 오디오/비디오 스트림을 캡처합니다. RTCPeerConnection은 피어 간 미디어 전송을 처리하며 암호화, 코덱, NAT 우회를 담당합니다. RTCDataChannel은 텍스트, 파일 등 임의의 데이터를 양방향으로 전송합니다.

P2P 연결 수립을 위해 시그널링(Signaling) 과정이 필요합니다. 두 피어가 연결 정보를 교환하는 것으로, WebRTC 자체는 시그널링 방법을 정의하지 않습니다. WebSocket, HTTP 폴링, Firebase 등 어떤 방식으로든 구현 가능합니다. 시그널링에서는 SDP(Session Description Protocol)를 주고받아 코덱, 해상도, 포트 등 미디어 정보를 협상합니다.

NAT(Network Address Translation) 환경에서 P2P 연결을 위해 ICE(Interactive Connectivity Establishment) 프레임워크를 사용합니다. STUN(Session Traversal Utilities for NAT) 서버는 피어의 공인 IP와 포트를 알려주고, 직접 연결이 불가능할 때 TURN(Traversal Using Relays around NAT) 서버가 릴레이 역할을 합니다. 일반적으로 80%의 연결은 STUN으로 해결되고, 나머지 20%가 TURN을 사용합니다.

WebRTC는 Zoom, Google Meet, Discord, Facebook Messenger의 영상통화, 스크린 공유, 실시간 협업 툴에서 널리 사용됩니다. 모든 미디어는 SRTP(Secure Real-time Transport Protocol)로 암호화되어 보안이 보장됩니다.

🏗️ WebRTC 연결 아키텍처

┌─────────────────────────────────────────────────────────────────────────────┐
│                         WebRTC P2P 연결 흐름                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   Peer A (Browser)                         Peer B (Browser)                │
│   ┌─────────────┐                           ┌─────────────┐                │
│   │ getUserMedia│                           │ getUserMedia│                │
│   │ (카메라/마이크)│                           │ (카메라/마이크)│                │
│   └──────┬──────┘                           └──────┬──────┘                │
│          │                                         │                        │
│          ▼                                         ▼                        │
│   ┌─────────────┐                           ┌─────────────┐                │
│   │RTCPeerConn- │                           │RTCPeerConn- │                │
│   │ection       │                           │ection       │                │
│   └──────┬──────┘                           └──────┬──────┘                │
│          │                                         │                        │
│          │         ┌───────────────────┐          │                        │
│          │         │  Signaling Server  │          │                        │
│          │         │   (WebSocket 등)    │          │                        │
│          │         └─────────┬─────────┘          │                        │
│          │                   │                     │                        │
│          │◄─────Offer SDP────┼────Offer SDP─────►│                        │
│          │◄─────Answer SDP───┼────Answer SDP────►│                        │
│          │◄────ICE Candidates─┼───ICE Candidates──►│                        │
│          │                   │                     │                        │
│          │         ┌─────────┴─────────┐          │                        │
│          │         │   STUN Server      │          │                        │
│          │         │ (공인 IP 확인)        │          │                        │
│          │         └─────────┬─────────┘          │                        │
│          │                   │                     │                        │
│          │◄───────Public IP/Port───────►│                        │
│          │                                         │                        │
│          │    ┌─────────────────────────┐         │                        │
│          │    │  TURN Server (Fallback) │         │                        │
│          │    │  (P2P 실패 시 릴레이)      │         │                        │
│          │    └───────────┬─────────────┘         │                        │
│          │                │                        │                        │
│          └────────────────┼────────────────────────┘                        │
│                           │                                                 │
│             ════════════════════════════════                            │
│                P2P Media Stream (암호화됨)                              │
│                - Video/Audio (SRTP)                                    │
│                - DataChannel (SCTP/DTLS)                               │
│             ════════════════════════════════                            │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

───► 시그널링 (연결 정보 교환)    ═══► P2P 미디어 스트림

💻 코드 예제

// WebRTC P2P 화상통화 기본 구현
const config = {
    iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },  // Google STUN 서버
        { urls: 'stun:stun1.l.google.com:19302' },
        // TURN 서버 (필요 시)
        {
            urls: 'turn:your-turn-server.com:3478',
            username: 'user',
            credential: 'password'
        }
    ]
};

// RTCPeerConnection 생성
const peerConnection = new RTCPeerConnection(config);

// 1. 로컬 미디어 스트림 획득
async function startLocalStream() {
    const stream = await navigator.mediaDevices.getUserMedia({
        video: { width: 1280, height: 720 },
        audio: true
    });

    // 로컬 비디오에 표시
    document.getElementById('localVideo').srcObject = stream;

    // PeerConnection에 트랙 추가
    stream.getTracks().forEach(track => {
        peerConnection.addTrack(track, stream);
    });

    return stream;
}

// 2. ICE Candidate 이벤트 처리
peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
        // 시그널링 서버를 통해 상대방에게 전달
        signalingChannel.send({
            type: 'ice-candidate',
            candidate: event.candidate
        });
    }
};

// 3. 원격 스트림 수신
peerConnection.ontrack = (event) => {
    document.getElementById('remoteVideo').srcObject = event.streams[0];
};

// 4. Offer 생성 (호출자)
async function createOffer() {
    const offer = await peerConnection.createOffer();
    await peerConnection.setLocalDescription(offer);

    // 시그널링 서버로 Offer 전송
    signalingChannel.send({
        type: 'offer',
        sdp: offer.sdp
    });
}

// 5. Answer 생성 (수신자)
async function handleOffer(offer) {
    await peerConnection.setRemoteDescription(
        new RTCSessionDescription({ type: 'offer', sdp: offer.sdp })
    );

    const answer = await peerConnection.createAnswer();
    await peerConnection.setLocalDescription(answer);

    signalingChannel.send({
        type: 'answer',
        sdp: answer.sdp
    });
}

// 6. Answer 처리 (호출자)
async function handleAnswer(answer) {
    await peerConnection.setRemoteDescription(
        new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })
    );
}

// 7. ICE Candidate 처리
async function handleIceCandidate(candidate) {
    await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}

// 연결 상태 모니터링
peerConnection.onconnectionstatechange = () => {
    console.log('Connection state:', peerConnection.connectionState);
    // 'connected' | 'disconnected' | 'failed' | 'closed'
};
// Node.js 시그널링 서버 (Socket.IO)
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
    cors: { origin: '*' }
});

const rooms = new Map();

io.on('connection', (socket) => {
    console.log('User connected:', socket.id);

    // 방 참가
    socket.on('join-room', (roomId) => {
        socket.join(roomId);

        if (!rooms.has(roomId)) {
            rooms.set(roomId, new Set());
        }
        rooms.get(roomId).add(socket.id);

        // 기존 참가자들에게 알림
        socket.to(roomId).emit('user-joined', socket.id);

        // 기존 참가자 목록 전송
        const existingUsers = [...rooms.get(roomId)].filter(id => id !== socket.id);
        socket.emit('existing-users', existingUsers);
    });

    // Offer 전달
    socket.on('offer', ({ targetId, sdp }) => {
        io.to(targetId).emit('offer', {
            senderId: socket.id,
            sdp
        });
    });

    // Answer 전달
    socket.on('answer', ({ targetId, sdp }) => {
        io.to(targetId).emit('answer', {
            senderId: socket.id,
            sdp
        });
    });

    // ICE Candidate 전달
    socket.on('ice-candidate', ({ targetId, candidate }) => {
        io.to(targetId).emit('ice-candidate', {
            senderId: socket.id,
            candidate
        });
    });

    // 연결 해제
    socket.on('disconnect', () => {
        rooms.forEach((users, roomId) => {
            if (users.has(socket.id)) {
                users.delete(socket.id);
                socket.to(roomId).emit('user-left', socket.id);
            }
        });
    });
});

server.listen(3000, () => {
    console.log('Signaling server running on port 3000');
});
// 화면 공유 구현
async function startScreenShare() {
    try {
        // 화면 캡처 스트림 획득
        const screenStream = await navigator.mediaDevices.getDisplayMedia({
            video: {
                cursor: 'always',  // 마우스 커서 표시
                displaySurface: 'monitor'  // 전체 화면 | 'window' | 'tab'
            },
            audio: true  // 시스템 오디오 (브라우저 지원에 따라)
        });

        // 기존 비디오 트랙을 화면 공유로 교체
        const videoTrack = screenStream.getVideoTracks()[0];
        const sender = peerConnection.getSenders().find(
            s => s.track?.kind === 'video'
        );

        if (sender) {
            await sender.replaceTrack(videoTrack);
        }

        // 화면 공유 중단 감지
        videoTrack.onended = () => {
            console.log('Screen sharing stopped');
            stopScreenShare();
        };

        return screenStream;
    } catch (error) {
        if (error.name === 'NotAllowedError') {
            console.log('User denied screen sharing');
        }
        throw error;
    }
}

// DataChannel로 파일 전송
function setupDataChannel() {
    const dataChannel = peerConnection.createDataChannel('fileTransfer', {
        ordered: true,  // 순서 보장
        maxRetransmits: 3  // 재전송 횟수
    });

    dataChannel.onopen = () => {
        console.log('DataChannel opened');
    };

    dataChannel.onmessage = (event) => {
        // 수신된 데이터 처리
        const data = JSON.parse(event.data);
        if (data.type === 'file-chunk') {
            handleFileChunk(data);
        }
    };

    // 파일을 청크로 나누어 전송
    async function sendFile(file) {
        const chunkSize = 16384;  // 16KB 청크
        const reader = new FileReader();
        let offset = 0;

        // 파일 메타데이터 전송
        dataChannel.send(JSON.stringify({
            type: 'file-start',
            name: file.name,
            size: file.size,
            mimeType: file.type
        }));

        // 청크 단위로 전송
        while (offset < file.size) {
            const chunk = file.slice(offset, offset + chunkSize);
            const buffer = await chunk.arrayBuffer();

            dataChannel.send(JSON.stringify({
                type: 'file-chunk',
                data: Array.from(new Uint8Array(buffer)),
                offset
            }));

            offset += chunkSize;
        }

        dataChannel.send(JSON.stringify({ type: 'file-end' }));
    }

    return { dataChannel, sendFile };
}

// 연결 품질 통계
async function getConnectionStats() {
    const stats = await peerConnection.getStats();

    stats.forEach(report => {
        if (report.type === 'inbound-rtp' && report.kind === 'video') {
            console.log('Video Stats:', {
                framesReceived: report.framesReceived,
                framesDropped: report.framesDropped,
                bytesReceived: report.bytesReceived,
                packetsLost: report.packetsLost
            });
        }
    });
}

🗣️ 실무에서 이렇게 말하세요

💬 화상회의 시스템 설계 회의에서
"WebRTC 직접 연결이 안 되는 경우가 20% 정도 있어서 TURN 서버가 필수입니다. Twilio나 Xirsys TURN 서비스 쓰거나, coturn으로 직접 구축할 수 있어요. 대역폭 비용이 꽤 나오니까 예산 계획에 포함시켜야 합니다."
💬 면접에서
"WebRTC 연결 흐름을 설명드리면, 먼저 시그널링 서버를 통해 SDP Offer/Answer를 교환합니다. 이때 미디어 코덱, 해상도 같은 정보가 협상됩니다. 동시에 ICE Candidate를 수집해서 STUN 서버로 공인 IP를 확인하고, 양측의 ICE Candidate를 교환해서 최적의 연결 경로를 찾습니다. Symmetric NAT 환경에서는 TURN 릴레이를 사용합니다."
💬 트러블슈팅 미팅에서
"ICE connection state가 계속 'checking'에서 멈춰있으면 STUN 서버 응답 문제이거나 방화벽에서 UDP 포트가 막혀있을 가능성이 높습니다. chrome://webrtc-internals에서 ICE candidate 수집 로그를 확인해보시고, TURN 서버 fallback이 제대로 되는지도 체크해보세요."

⚠️ 흔한 실수 & 주의사항

TURN 서버 없이 배포

Symmetric NAT, 기업 방화벽 환경에서는 STUN만으로 연결이 안 됩니다. 실제 운영환경에서 약 20%의 연결이 TURN이 필요합니다. 반드시 TURN 서버를 구축하거나 클라우드 서비스를 사용하세요.

시그널링 순서 무시

setRemoteDescription 전에 addIceCandidate를 호출하면 에러가 발생합니다. SDP 교환이 완료된 후 ICE Candidate를 추가해야 합니다. Candidate를 큐에 저장했다가 처리하는 패턴을 사용하세요.

HTTPS 환경 미적용

getUserMedia는 localhost를 제외하고 HTTPS에서만 작동합니다. 개발 환경에서도 ngrok이나 self-signed 인증서로 HTTPS를 적용하세요. HTTP에서는 미디어 권한 요청이 자동 거부됩니다.

권장 구현 방법

Twilio, Agora, Daily.co 같은 WebRTC PaaS를 사용하면 TURN 인프라와 시그널링이 포함됩니다. 직접 구축 시 coturn + Socket.IO 조합이 일반적입니다. 연결 상태 모니터링과 자동 재연결 로직을 필수로 구현하세요.

🔗 관련 용어

📚 더 배우기