signalmaster 내부 동작 분석: socket.io 기반 실시간 메시징 구조

signalmaster는 WebRTC 신호 교환을 위한 경량 서버로, socket.io를 기반으로 클라이언트 간 실시간 연결을 중개한다. 이 글에서는 해당 프로젝트의 내부 메커니즘을 단계별로 살펴보고, 실제 운영 환경에서의 핵심 패턴을 정리한다.

서버 초기화 및 IO 인스턴스 구성

실시간 통신의 진입점은 sockets.js 모듈이다. HTTP 서버를 socket.io에 바인딩하는 방식은 다음과 같다.

const { Server } = require('socket.io');

module.exports = (httpServer, cfg) => {
    const io = new Server(httpServer, {
        cors: { origin: cfg.allowedOrigins }
    });
    // ...
};

메인 애플리케이션에서는 http 모듈로 서버를 생성한 뒤, 동일한 포트에서 socket.io가 연결을 가로채도록 설정한다.

const http = require('http');
const configureSockets = require('./sockets');

const app = http.createServer(requestHandler);
configureSockets(app, appConfig);
app.listen(8080);

이 구조의 장점은 하나의 포트에서 REST API와 WebSocket 연결을 동시에 처리할 수 있다는 점이다. 별도의 포트 개방 없이 실시간 기능을 추가할 수 있다.

네임스페이스와 동적 방 할당

signalmaster는 다중 사용자 환경에서 개별 그룹을 분리하기 위해 socket.io의 room 기능을 적극 활용한다. 연결 수립 시 클라이언트는 특정 식별자를 통해 방에 진입한다.

peer.on('enter', (roomId, ack) => {
    exitCurrent(peer);
    
    peer.join(roomId);
    peer.activeRoom = roomId;
    
    const occupants = inspectRoom(io, roomId);
    ack?.(occupants);
});

각 방의 참가자 목록과 리소스 상태는 어댑터를 통해 직접 조회한다. 운영 환경에서는 Redis 어댑터로 교체하여 다중 서버 간 상태 공유가 가능하다.

function inspectRoom(ioInstance, roomId) {
    const namespace = ioInstance.of('/');
    const roomSockets = namespace.adapter.rooms.get(roomId);
    
    if (!roomSockets) return {};
    
    const snapshot = {};
    for (const socketId of roomSockets.keys()) {
        const member = namespace.sockets.get(socketId);
        snapshot[socketId] = member?.capabilities ?? {};
    }
    return snapshot;
}

메시지 중계 로직

클라이언트 간 직접 통신이 불가능한 WebRTC 특성상, 서버는 SDP와 ICE candidate를 중계하는 역할을 수행한다. signalmaster의 핵심은 단순한 포워딩 로직에 있다.

peer.on('relay', (envelope) => {
    if (!envelope?.target) return;
    
    const forwarded = {
        sender: peer.id,
        payload: envelope.payload,
        timestamp: Date.now()
    };
    
    io.to(envelope.target).emit('relay', forwarded);
});

수신자를 지정하지 않은 브로드캐스트의 경우, 현재 방 전체로 메시지를 확산한다.

peer.on('broadcast', (packet) => {
    if (!peer.activeRoom) return;
    
    peer.to(peer.activeRoom).emit('broadcast', {
        origin: peer.id,
        data: packet
    });
});

미디어 리소스 추적

각 피어는 연결 시점에 자신이 사용 가능한 미디어 스트림 종류를 등록한다. 이 정보는 추후 방 내 다른 참가자들이 수신 가능한 트랙을 파악하는 데 활용된다.

peer.capabilities = {
    webcam: true,
    display: false,
    microphone: true
};

연결 해제 또는 방 퇴장 시, 해당 피어의 정보를 다른 참가자들에게 통지하여 UI 상태를 동기화한다.

peer.on('leave', () => {
    notifyDeparture(peer, 'intentional');
});

peer.on('disconnect', () => {
    notifyDeparture(peer, 'abrupt');
});

function notifyDeparture(member, reason) {
    if (!member.activeRoom) return;
    
    member.to(member.activeRoom).emit('peer-gone', {
        peerId: member.id,
        cause: reason
    });
    member.leave(member.activeRoom);
}

TURN 인증 정보 동적 발급

대칭형 NAT 환경에서의 연결성 확보를 위해, 서버는 HMAC 기반의 임시 자격 증명을 생성하여 클라이언트에 전달한다.

const { createHmac } = require('crypto');

function generateTurnCredentials(servers) {
    return servers.map((srv) => {
        const epoch = Math.floor(Date.now() / 1000);
        const expiry = epoch + (srv.ttl || 86400);
        const username = String(expiry);
        
        const signature = createHmac('sha1', srv.secret)
            .update(username)
            .digest('base64');
        
        return {
            username,
            credential: signature,
            urls: srv.endpoints
        };
    });
}

// 연결 수립 직후 클라이언트에 전송
peer.emit('ice-servers', generateTurnCredentials(config.turn));

이 방식은 TURN 서버의 비밀 키를 클라이언트에 노출하지 않으면서도, 짧은 유효기간을 가진 인증 정보를 제공할 수 있다.

운영 환경 고려사항

실제 서비스에 적용할 때는 다음 사항을 추가로 검토해야 한다.

  • 어댑터 교체: 단일 프로세스 운영 시 메모리 어댑터로 충분하지만, 로드 밸런싱 환경에서는 Redis 또는 MongoDB 어댑터로 전환 필요
  • 연결 제한: perMessageDeflate 설정과 동시 연결 수에 따른 메모리 사용량 모니터링
  • Heartbeat 주기: 기본 ping/pong 간격보다 짧은 타임아웃으로僵死 연결을 빠르게 정리

signalmaster의 구조는 단순하지만, WebRTC 신호 교환의 본질을 잘 반영하고 있다. socket.io의 이벤트 추상화를 적절히 활용하여 복잡한 상태 머신 없이도 신뢰성 있는 중계 기능을 구현한 점이 특징이다.

태그: Socket.io WebRTC signalmaster 실시간통신 시그널링서버

6월 17일 01:49에 게시됨