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의 이벤트 추상화를 적절히 활용하여 복잡한 상태 머신 없이도 신뢰성 있는 중계 기능을 구현한 점이 특징이다.