이미지 로딩 중...
AI Generated
2025. 11. 20. · 2 Views
Socket.io로 배우는 WebRTC 시그널링 서버 구축
WebRTC 연결을 위한 필수 요소인 시그널링 서버를 Socket.io로 구축하는 방법을 배웁니다. Room 관리, Offer/Answer 교환, ICE Candidate 전달 등 실시간 통신의 핵심 개념을 실무 예제와 함께 친절하게 설명합니다.
목차
1. 시그널링이란?
시작하며
여러분이 화상 회의 앱을 만들려고 할 때, "사용자들이 어떻게 서로를 찾아서 연결될까?"라는 의문을 가져본 적 있나요? WebRTC는 브라우저끼리 직접 연결되는 기술이지만, 처음 만날 때는 누군가의 도움이 필요합니다.
마치 두 친구가 카페에서 만나기로 했을 때, 서로의 위치를 문자로 주고받듯이, WebRTC도 연결 전에 정보를 교환해야 합니다. 그런데 아직 연결되지 않은 상태에서 어떻게 정보를 주고받을까요?
바로 이럴 때 필요한 것이 시그널링입니다. 시그널링은 WebRTC 연결을 맺기 전에 두 브라우저가 서로의 정보를 교환할 수 있도록 도와주는 중개자 역할을 합니다.
개요
간단히 말해서, 시그널링은 WebRTC 연결을 시작하기 전에 필요한 정보를 주고받는 과정입니다. 실제 프로젝트에서 화상 채팅이나 파일 공유 기능을 만들 때, WebRTC로 P2P 연결을 하려면 먼저 상대방의 네트워크 정보, 미디어 설정 등을 알아야 합니다.
예를 들어, 온라인 수업 플랫폼에서 학생과 선생님이 화상으로 연결될 때 이 과정이 반드시 필요합니다. 기존에는 중앙 서버를 통해 모든 데이터를 주고받았다면, WebRTC를 사용하면 처음 연결만 서버가 도와주고 이후에는 직접 통신할 수 있습니다.
시그널링의 핵심 특징은 첫째, WebRTC 표준에 포함되지 않아 개발자가 자유롭게 구현할 수 있다는 점, 둘째, WebSocket이나 Socket.io 같은 실시간 통신 기술을 사용한다는 점, 셋째, Offer, Answer, ICE Candidate 같은 메시지를 교환한다는 점입니다. 이러한 특징들이 있어야 브라우저 간 안정적인 P2P 연결이 가능합니다.
코드 예제
// 시그널링 서버의 기본 구조
const io = require('socket.io')(3000);
io.on('connection', (socket) => {
// 클라이언트가 연결되면 실행됩니다
console.log('새로운 사용자 연결:', socket.id);
// 시그널링 메시지를 받아서 다른 사용자에게 전달
socket.on('signal', (data) => {
// data에는 Offer, Answer, ICE Candidate 정보가 담깁니다
socket.to(data.room).emit('signal', {
type: data.type,
signal: data.signal,
from: socket.id
});
});
socket.on('disconnect', () => {
console.log('사용자 연결 종료:', socket.id);
});
});
설명
이것이 하는 일: 시그널링 서버는 WebRTC 연결을 원하는 두 클라이언트 사이에서 메신저 역할을 합니다. 직접 연결되기 전까지 필요한 모든 정보를 안전하게 전달해주는 우체부 같은 존재입니다.
첫 번째로, Socket.io 서버를 생성하고 3000번 포트에서 대기합니다. 클라이언트가 접속하면 고유한 socket.id가 부여되고, 이것이 그 사용자를 구분하는 식별자가 됩니다.
이렇게 하면 수백 명이 동시에 접속해도 각자를 정확히 구분할 수 있습니다. 그 다음으로, 'signal' 이벤트를 받으면 해당 메시지를 같은 방(room)에 있는 다른 사용자에게 전달합니다.
예를 들어 A 사용자가 "나는 이런 비디오 코덱을 지원해"라는 정보를 보내면, 서버는 이를 B 사용자에게 그대로 전달합니다. 서버는 내용을 이해하거나 수정하지 않고 단순히 배달만 합니다.
마지막으로, 사용자가 연결을 끊으면 disconnect 이벤트가 발생하여 로그를 남기고 정리 작업을 할 수 있습니다. 이를 통해 다른 사용자들에게 "이 사람이 나갔어요"라고 알려줄 수도 있습니다.
여러분이 이 코드를 사용하면 복잡한 WebRTC 연결 과정을 체계적으로 관리할 수 있고, 화상 채팅, 화면 공유, 파일 전송 등 다양한 P2P 기능을 구현할 수 있습니다. 또한 서버 부하를 최소화하면서도 안정적인 실시간 통신 환경을 제공할 수 있습니다.
실전 팁
💡 시그널링 서버는 연결을 맺어주기만 하므로 WebRTC 연결 후에는 서버 부담이 거의 없습니다. 따라서 하나의 시그널링 서버로 수천 개의 P2P 연결을 관리할 수 있어 비용 효율적입니다.
💡 시그널링 메시지에는 민감한 정보가 포함될 수 있으므로 반드시 HTTPS/WSS를 사용하세요. 특히 프로덕션 환경에서는 SSL 인증서 설정이 필수입니다.
💡 시그널링은 WebRTC 표준이 아니므로 Socket.io 외에도 WebSocket, SIP, XMPP 등 다양한 방법으로 구현할 수 있습니다. 프로젝트 특성에 맞게 선택하세요.
💡 연결 실패 시 재시도 로직을 반드시 구현하세요. 네트워크 문제로 시그널링이 실패하면 사용자가 아무것도 할 수 없게 되므로, 자동 재연결 기능이 중요합니다.
2. Socket.io 서버 설정
시작하며
여러분이 실시간 채팅 앱을 만들다가 "WebSocket 코드가 너무 복잡한데, 더 쉬운 방법은 없을까?"라고 생각해본 적 있나요? 순수 WebSocket API는 연결 관리, 재연결, 브라우저 호환성 처리 등 신경 쓸 게 많습니다.
이런 문제는 많은 개발자들이 겪는 공통적인 어려움입니다. 특히 실시간 기능이 처음인 분들은 연결이 끊겼을 때 어떻게 처리해야 할지, 오래된 브라우저에서는 어떻게 대응할지 막막합니다.
바로 이럴 때 필요한 것이 Socket.io입니다. Socket.io는 WebSocket을 기반으로 하지만 자동 재연결, 하위 호환성, Room 기능 등을 기본 제공하여 개발을 훨씬 쉽게 만들어줍니다.
개요
간단히 말해서, Socket.io는 실시간 양방향 통신을 위한 라이브러리로 WebSocket의 복잡함을 감추고 편리한 API를 제공합니다. WebRTC 시그널링 서버를 만들 때 순수 WebSocket으로 구현하면 연결 상태 관리, 재연결 로직, 브라우저 호환성 등을 직접 처리해야 합니다.
예를 들어, 온라인 게임에서 네트워크가 잠깐 끊겼다가 다시 연결될 때 게임 상태를 유지하려면 복잡한 코드가 필요합니다. 기존에는 WebSocket, Long Polling, Server-Sent Events 등을 상황에 맞게 골라 써야 했다면, Socket.io를 사용하면 라이브러리가 자동으로 최적의 방법을 선택해줍니다.
Socket.io의 핵심 특징은 첫째, 자동 재연결 기능으로 네트워크 끊김에 강하다는 점, 둘째, Room과 Namespace로 사용자 그룹을 쉽게 관리할 수 있다는 점, 셋째, 이벤트 기반 통신으로 코드가 직관적이라는 점입니다. 이러한 특징들이 있어 복잡한 실시간 애플리케이션도 빠르게 개발할 수 있습니다.
코드 예제
// Socket.io 서버 설정 (Express와 함께 사용)
const express = require('express');
const app = express();
const server = require('http').createServer(app);
const io = require('socket.io')(server, {
// CORS 설정: 다른 도메인에서의 접속 허용
cors: {
origin: "https://yourdomain.com",
methods: ["GET", "POST"]
},
// 연결 타임아웃 설정
pingTimeout: 60000,
// 재연결 시도 간격
pingInterval: 25000
});
// 정적 파일 제공 (클라이언트 HTML/JS)
app.use(express.static('public'));
// Socket.io 연결 처리
io.on('connection', (socket) => {
console.log(`사용자 연결: ${socket.id}`);
});
// 서버 시작
server.listen(3000, () => {
console.log('시그널링 서버가 3000번 포트에서 실행 중입니다');
});
설명
이것이 하는 일: 이 코드는 Express 웹 서버와 Socket.io를 결합하여 HTTP와 WebSocket을 동시에 처리하는 서버를 만듭니다. 일반 웹 페이지도 제공하면서 실시간 통신도 가능한 풀스택 서버가 되는 것입니다.
첫 번째로, Express 앱을 만들고 HTTP 서버로 감싼 뒤 Socket.io를 연결합니다. 이렇게 하면 같은 포트에서 일반 HTTP 요청과 WebSocket 연결을 모두 받을 수 있습니다.
CORS 설정으로 특정 도메인에서만 접속을 허용하여 보안을 강화합니다. 프로덕션에서는 반드시 origin을 실제 도메인으로 설정해야 합니다.
그 다음으로, pingTimeout과 pingInterval을 설정하여 연결 상태를 주기적으로 확인합니다. pingInterval마다 클라이언트에게 "살아있니?" 메시지를 보내고, pingTimeout 안에 응답이 없으면 연결이 끊긴 것으로 판단합니다.
기본값으로도 충분하지만, 모바일 환경에서는 값을 늘려야 할 수 있습니다. 마지막으로, express.static으로 public 폴더의 파일들을 제공하고, 3000번 포트에서 서버를 시작합니다.
이제 브라우저에서 http://localhost:3000으로 접속하면 HTML 페이지가 보이고, 동시에 Socket.io 연결도 자동으로 이루어집니다. 한 번의 설정으로 웹 서버와 WebSocket 서버가 모두 준비됩니다.
여러분이 이 코드를 사용하면 복잡한 서버 설정 없이 몇 줄로 실시간 통신 환경을 구축할 수 있습니다. Express와의 통합으로 REST API와 WebSocket을 함께 사용할 수 있어, 인증, 파일 업로드 등 다양한 기능을 추가하기도 쉽습니다.
또한 Socket.io의 자동 재연결 기능 덕분에 불안정한 모바일 네트워크에서도 안정적으로 작동합니다.
실전 팁
💡 프로덕션 환경에서는 반드시 Redis Adapter를 사용하여 여러 서버 인스턴스 간 메시지를 동기화하세요. 로드 밸런서 뒤에서 여러 서버가 돌아갈 때 필수입니다.
💡 CORS origin을 '*'로 설정하면 보안에 취약합니다. 반드시 실제 클라이언트 도메인만 허용하고, credentials도 필요할 경우에만 true로 설정하세요.
💡 Socket.io v4부터는 기본적으로 WebSocket만 사용하지만, transports 옵션으로 Long Polling을 추가할 수 있습니다. 오래된 브라우저를 지원해야 한다면 ['polling', 'websocket']으로 설정하세요.
💡 연결 수가 많아지면 maxHttpBufferSize를 조정해야 할 수 있습니다. 기본값은 1MB인데, 큰 파일이나 이미지를 주고받는다면 늘려야 합니다.
💡 개발 중에는 io.engine.on('connection_error')로 연결 오류를 모니터링하세요. CORS 문제, 인증 실패 등을 빠르게 발견할 수 있습니다.
3. Room 개념과 구현
시작하며
여러분이 화상 회의 앱을 만들 때, "100명이 동시에 접속했는데 어떻게 5명씩 각자의 회의방으로 나눌까?"라는 문제에 부딪힌 적 있나요? 모든 사용자에게 모든 메시지를 보내면 혼란스럽고 비효율적입니다.
이런 문제는 규모가 커질수록 심각해집니다. 한 사용자의 화면 공유가 전혀 관련 없는 다른 회의실 사람들에게도 전송된다면, 불필요한 트래픽과 개인정보 노출 위험이 생깁니다.
바로 이럴 때 필요한 것이 Room입니다. Socket.io의 Room 기능을 사용하면 사용자들을 논리적인 그룹으로 나누어 각 그룹 내에서만 메시지를 주고받게 할 수 있습니다.
개요
간단히 말해서, Room은 Socket 연결들을 그룹으로 묶어서 특정 그룹에만 메시지를 보낼 수 있게 하는 기능입니다. 실제 프로젝트에서 화상 회의, 채팅방, 게임 매칭 등을 구현할 때 사용자들을 격리된 공간으로 나누는 것이 필수입니다.
예를 들어, 온라인 교육 플랫폼에서 반별로 화상 수업을 진행할 때 각 반을 별도의 Room으로 관리하면 다른 반의 수업 내용이 섞이지 않습니다. 기존에는 서버에서 직접 사용자 목록을 관리하고 메시지를 필터링해야 했다면, Socket.io Room을 사용하면 join/leave 메서드만으로 간단히 그룹을 관리할 수 있습니다.
Room의 핵심 특징은 첫째, 한 소켓이 여러 Room에 동시에 속할 수 있다는 점, 둘째, Room은 자동으로 생성되고 마지막 사용자가 나가면 자동으로 삭제된다는 점, 셋째, Room 내 모든 사용자에게 한 번에 메시지를 보낼 수 있다는 점입니다. 이러한 특징들이 있어 복잡한 그룹 통신 로직을 간단하게 구현할 수 있습니다.
코드 예제
// Room 생성 및 참가 처리
io.on('connection', (socket) => {
// 사용자가 Room에 참가할 때
socket.on('join-room', (roomId, userId) => {
// Room에 참가
socket.join(roomId);
// Room에 저장할 사용자 정보
socket.userId = userId;
socket.roomId = roomId;
console.log(`${userId}님이 ${roomId} 방에 입장했습니다`);
// 같은 Room의 다른 사용자들에게 알림 (자신 제외)
socket.to(roomId).emit('user-joined', {
userId: userId,
socketId: socket.id
});
// Room의 현재 인원 수 확인
const roomSize = io.sockets.adapter.rooms.get(roomId)?.size || 0;
socket.emit('room-info', { participants: roomSize });
});
// Room 나가기
socket.on('leave-room', () => {
const roomId = socket.roomId;
socket.to(roomId).emit('user-left', { userId: socket.userId });
socket.leave(roomId);
});
});
설명
이것이 하는 일: 이 코드는 사용자가 특정 회의실(Room)에 입장하고 퇴장하는 과정을 관리하며, 같은 방에 있는 사람들끼리만 통신할 수 있도록 합니다. 마치 아파트 각 호에 초인종을 누르는 것처럼, 특정 Room에만 메시지를 보낼 수 있습니다.
첫 번째로, 클라이언트가 'join-room' 이벤트를 보내면 socket.join(roomId)로 해당 Room에 참가시킵니다. roomId는 문자열이면 뭐든 가능하며, 없으면 자동으로 생성됩니다.
socket 객체에 userId와 roomId를 저장하여 나중에 누가 어느 방에 있는지 추적할 수 있게 합니다. 이는 연결이 끊겼을 때 정리 작업에도 유용합니다.
그 다음으로, socket.to(roomId).emit()을 사용하여 같은 Room의 다른 사용자들에게만 'user-joined' 이벤트를 보냅니다. 중요한 점은 to()를 사용하면 메시지를 보낸 본인은 제외된다는 것입니다.
이렇게 하면 "A님이 입장했습니다"라는 메시지가 A 본인에게는 가지 않고 다른 참가자들에게만 전달됩니다. 마지막으로, io.sockets.adapter.rooms로 Room 정보를 조회하여 현재 몇 명이 있는지 확인합니다.
size 속성이 참가자 수를 나타내며, Room이 없으면 undefined가 반환되므로 ?. 연산자로 안전하게 처리합니다.
사용자가 나갈 때는 socket.leave()로 Room에서 제거하고 다른 참가자들에게 알립니다. 여러분이 이 코드를 사용하면 화상 회의, 게임 매칭, 채팅방 등 다양한 그룹 기반 기능을 쉽게 구현할 수 있습니다.
Room별로 메시지가 격리되므로 보안도 좋고, 불필요한 트래픽도 줄일 수 있습니다. 또한 Room에 속한 모든 사용자 목록을 가져오거나, 특정 사용자를 강제로 퇴장시키는 등 고급 기능도 구현할 수 있습니다.
실전 팁
💡 Room 이름은 UUID나 랜덤 문자열을 사용하여 추측하기 어렵게 만드세요. 'room1', 'room2' 같은 간단한 이름은 다른 사용자가 쉽게 침입할 수 있습니다.
💡 사용자가 disconnect될 때 자동으로 모든 Room에서 제거되지만, 명시적으로 leave 이벤트를 보내는 게 좋습니다. 깔끔한 정리와 다른 사용자에게 알림을 보낼 수 있기 때문입니다.
💡 Room 최대 인원을 제한하려면 join 전에 size를 확인하세요. 예를 들어 화상 회의를 4명으로 제한하려면 size >= 4일 때 참가를 거부하면 됩니다.
💡 하나의 소켓이 여러 Room에 속할 수 있다는 점을 활용하세요. 예를 들어 모든 사용자가 'global' Room에 속하면서 동시에 개별 회의 Room에도 속할 수 있습니다.
💡 Redis Adapter를 사용하면 여러 서버에 분산된 사용자들도 같은 Room에서 통신할 수 있습니다. 프로덕션에서 스케일아웃할 때 필수입니다.
4. Offer/Answer 교환
시작하며
여러분이 WebRTC로 화상 통화를 구현할 때, "두 브라우저가 서로 어떤 코덱을 지원하는지 어떻게 알까?"라는 궁금증을 가져본 적 있나요? 연결하기 전에 서로의 능력과 조건을 미리 협상해야 합니다.
이런 문제는 WebRTC의 핵심 과정입니다. 한쪽은 VP8 비디오 코덱만 지원하고 다른 쪽은 H.264만 지원한다면, 이를 미리 알고 협상하지 않으면 연결은 되더라도 화면이 보이지 않습니다.
바로 이럴 때 필요한 것이 Offer/Answer 교환입니다. SDP(Session Description Protocol) 형식으로 각자의 미디어 설정, 코덱, 네트워크 정보를 주고받아 양쪽이 모두 지원하는 방식으로 연결을 맺습니다.
개요
간단히 말해서, Offer/Answer는 WebRTC 연결을 맺기 위해 두 피어가 서로의 미디어 설정과 네트워크 정보를 교환하는 협상 과정입니다. 실제 프로젝트에서 화상 통화, 화면 공유, 파일 전송 등을 구현할 때 반드시 거쳐야 하는 단계입니다.
예를 들어, 온라인 면접 플랫폼에서 면접관과 지원자가 연결될 때 한쪽이 Offer를 만들어 보내면 다른 쪽이 Answer로 응답하여 연결이 성립됩니다. 기존에는 미디어 서버가 모든 스트림을 받아서 다시 배포했다면, WebRTC는 Offer/Answer 교환만으로 피어 간 직접 연결을 만들어 서버 부담을 크게 줄입니다.
Offer/Answer의 핵심 특징은 첫째, SDP 형식으로 미디어 설정을 텍스트로 표현한다는 점, 둘째, 한쪽이 Offer를 만들면 다른 쪽은 반드시 Answer로 응답해야 한다는 점, 셋째, 이 과정이 끝나야 ICE Candidate 교환이 의미가 있다는 점입니다. 이러한 특징들이 있어 서로 다른 환경의 브라우저들도 최적의 방식으로 연결될 수 있습니다.
코드 예제
// Offer/Answer 교환 처리
io.on('connection', (socket) => {
// 발신자가 Offer를 보낼 때
socket.on('offer', (data) => {
console.log(`${socket.userId}가 ${data.target}에게 Offer 전송`);
// 대상 사용자에게만 Offer 전달
socket.to(data.target).emit('offer', {
offer: data.offer, // SDP 정보
from: socket.id,
userId: socket.userId
});
});
// 수신자가 Answer를 보낼 때
socket.on('answer', (data) => {
console.log(`${socket.userId}가 ${data.target}에게 Answer 전송`);
// 원래 Offer를 보낸 사람에게 Answer 전달
socket.to(data.target).emit('answer', {
answer: data.answer, // SDP 정보
from: socket.id,
userId: socket.userId
});
});
// Offer/Answer 실패 처리
socket.on('negotiation-error', (data) => {
socket.to(data.target).emit('connection-failed', {
reason: data.reason
});
});
});
설명
이것이 하는 일: 이 코드는 WebRTC 연결을 원하는 두 피어 사이에서 Offer와 Answer를 안전하게 전달하는 우체부 역할을 합니다. SDP라는 긴 텍스트 데이터를 받아 정확한 상대방에게 전달하는 것이 핵심입니다.
첫 번째로, 'offer' 이벤트를 받으면 data.target에 있는 소켓 ID로 메시지를 전달합니다. data.offer에는 비디오/오디오 코덱, 해상도, 비트레이트 등 수백 줄의 SDP 정보가 담겨 있지만, 서버는 내용을 해석하지 않고 그대로 전달만 합니다.
socket.to()를 사용하면 특정 소켓 하나에게만 보낼 수 있어, Room 전체가 아닌 1:1 연결에 적합합니다. 그 다음으로, 'answer' 이벤트를 받으면 같은 방식으로 원래 Offer를 보낸 사람에게 전달합니다.
Answer의 SDP도 Offer만큼 복잡하며, 두 개의 SDP를 비교하여 양쪽이 모두 지원하는 최적의 설정을 찾아냅니다. 이 과정은 브라우저가 자동으로 처리하므로 서버는 단순히 전달만 하면 됩니다.
마지막으로, 협상 중 오류가 발생하면 'negotiation-error' 이벤트로 상대방에게 알립니다. 예를 들어 한쪽이 비디오를 지원하지 않는 환경이거나, 네트워크 문제로 SDP 생성에 실패했을 때 상대방이 무한정 기다리지 않도록 알려주는 것입니다.
이를 통해 사용자에게 "연결에 실패했습니다" 같은 명확한 메시지를 보여줄 수 있습니다. 여러분이 이 코드를 사용하면 복잡한 WebRTC 협상 과정을 서버에서 안전하게 중계할 수 있습니다.
1:1 화상 통화, 화면 공유, 파일 전송 등 모든 WebRTC 기능의 기초가 되는 코드입니다. 또한 로그를 남겨서 연결 실패 시 디버깅할 수 있고, 필요하다면 SDP 내용을 검증하거나 수정할 수도 있습니다.
실전 팁
💡 SDP에는 IP 주소가 포함될 수 있으므로 보안이 중요합니다. 반드시 HTTPS/WSS를 사용하고, 필요하다면 TURN 서버를 통해 IP를 숨기세요.
💡 Offer/Answer 교환에는 순서가 있습니다. Offer를 받기 전에 Answer를 보내면 에러가 나므로, 클라이언트 측에서 상태 관리를 철저히 하세요.
💡 재협상(renegotiation)이 필요할 때도 있습니다. 예를 들어 화면 공유를 추가로 시작하면 새 Offer/Answer 교환이 필요하므로 여러 번 호출될 수 있습니다.
💡 Offer 생성에는 시간이 걸릴 수 있습니다. 특히 복잡한 미디어 설정이나 느린 기기에서는 수 초가 걸리므로, 타임아웃을 설정하여 무한 대기를 방지하세요.
💡 SDP를 수정하여 특정 코덱을 강제하거나 비트레이트를 제한할 수 있습니다. 프로덕션에서 품질 관리가 필요하다면 SDP mungling을 고려하세요.
5. ICE Candidate 전달
시작하며
여러분이 WebRTC 연결을 시도할 때, "Offer/Answer는 주고받았는데 왜 연결이 안 될까?"라는 상황을 겪어본 적 있나요? 미디어 설정 협상은 끝났지만, 실제로 데이터를 주고받을 네트워크 경로를 찾지 못한 경우입니다.
이런 문제는 NAT, 방화벽, 공유기 등 네트워크 환경이 복잡할수록 자주 발생합니다. 브라우저의 실제 IP 주소가 여러 겹으로 숨겨져 있어서, 상대방이 어디로 패킷을 보내야 할지 모르는 것입니다.
바로 이럴 때 필요한 것이 ICE Candidate 전달입니다. ICE(Interactive Connectivity Establishment)는 가능한 모든 네트워크 경로를 찾아서 상대방에게 알려주고, 그 중 최적의 경로로 연결되게 합니다.
개요
간단히 말해서, ICE Candidate는 자신에게 도달할 수 있는 네트워크 경로 정보이며, 이를 상대방에게 전달하여 실제 데이터 통신이 가능하게 만듭니다. 실제 프로젝트에서 WebRTC 연결의 성공률을 높이는 핵심 요소입니다.
예를 들어, 회사 내부망에 있는 사용자와 카페 WiFi를 쓰는 사용자가 연결될 때 여러 Candidate를 시도하면서 통하는 경로를 찾습니다. 기존에는 고정 IP나 포트 포워딩이 필요했다면, ICE는 STUN/TURN 서버를 활용하여 복잡한 네트워크 환경에서도 자동으로 최적 경로를 찾아줍니다.
ICE Candidate의 핵심 특징은 첫째, 하나의 연결에 여러 개의 Candidate가 생성된다는 점, 둘째, host(로컬), srflx(STUN), relay(TURN) 같은 다양한 타입이 있다는 점, 셋째, 실시간으로 생성되므로 즉시 전달해야 한다는 점입니다. 이러한 특징들이 있어 99% 이상의 네트워크 환경에서 연결이 가능합니다.
코드 예제
// ICE Candidate 전달 처리
io.on('connection', (socket) => {
// ICE Candidate를 받아서 상대방에게 전달
socket.on('ice-candidate', (data) => {
// Candidate 정보 로깅 (디버깅용)
if (data.candidate) {
const type = data.candidate.type || 'unknown';
console.log(`${socket.userId} -> ${data.target}: ${type} candidate`);
}
// 대상 소켓에게 전달
socket.to(data.target).emit('ice-candidate', {
candidate: data.candidate, // ICE candidate 객체
from: socket.id,
userId: socket.userId
});
});
// ICE 연결 상태 변경 알림
socket.on('ice-state-change', (data) => {
console.log(`${socket.userId}의 ICE 상태: ${data.state}`);
// 연결 실패 시 Room의 다른 사용자들에게 알림
if (data.state === 'failed' || data.state === 'disconnected') {
socket.to(socket.roomId).emit('peer-connection-lost', {
userId: socket.userId,
reason: data.state
});
}
});
});
설명
이것이 하는 일: 이 코드는 브라우저에서 발견한 네트워크 경로들을 상대방에게 실시간으로 전달하는 택배 서비스입니다. Candidate는 연결 과정에서 계속 생성되므로 빠르게 전달하는 것이 중요합니다.
첫 번째로, 'ice-candidate' 이벤트를 받으면 data.candidate를 확인하고 타입을 로깅합니다. host 타입은 로컬 네트워크 주소, srflx는 STUN 서버를 통해 발견한 공인 IP, relay는 TURN 서버를 통한 중계 경로를 의미합니다.
일반적으로 host → srflx → relay 순서로 시도하며, 빠른 것이 우선 사용됩니다. 그 다음으로, socket.to(data.target)로 특정 상대방에게만 Candidate를 전달합니다.
한 연결에서 보통 3-10개의 Candidate가 생성되므로, 이 이벤트는 짧은 시간에 여러 번 발생합니다. 서버는 각각을 빠르게 전달하기만 하면 되고, 브라우저가 알아서 연결 가능한 것을 찾아 사용합니다.
마지막으로, ICE 상태 변화를 추적하여 연결 실패나 끊김을 감지합니다. ICE 상태는 checking → connected → completed 순서로 진행되며, failed나 disconnected가 되면 연결에 문제가 있다는 뜻입니다.
이때 다른 참가자들에게 알려서 UI를 업데이트하거나 재연결을 시도할 수 있게 합니다. 여러분이 이 코드를 사용하면 NAT, 방화벽, 복잡한 네트워크 환경에서도 WebRTC 연결 성공률을 크게 높일 수 있습니다.
STUN 서버만 있어도 대부분의 상황에서 연결되며, 정말 까다로운 기업망 같은 곳에서는 TURN 서버를 추가하면 됩니다. 또한 ICE 상태를 모니터링하여 네트워크 품질 문제를 빠르게 파악하고 대응할 수 있습니다.
실전 팁
💡 ICE Candidate는 생성되는 즉시 전달해야 합니다. 모아서 한 번에 보내면 연결 시간이 크게 늘어나므로, onicecandidate 이벤트마다 바로 전송하세요.
💡 null candidate를 받으면 더 이상 Candidate가 없다는 뜻입니다. 이를 상대방에게도 알려서 연결 시도를 마무리할 수 있게 하세요.
💡 TURN 서버는 대역폭을 많이 사용하므로 비용이 듭니다. 무료 STUN 서버(google STUN)로 시작하고, 연결 실패율이 높을 때만 TURN을 추가하세요.
💡 ICE 수집 정책을 'relay'로 설정하면 TURN만 사용하여 IP 주소를 완전히 숨길 수 있습니다. 프라이버시가 중요한 서비스에서 유용합니다.
💡 Trickle ICE를 사용하면 Candidate를 하나씩 전달하지만, 비활성화하면 모든 Candidate를 모아서 한 번에 보냅니다. 대부분은 Trickle ICE가 더 빠르므로 권장됩니다.
6. 연결 상태 관리
시작하며
여러분이 화상 회의를 하던 중 네트워크가 불안정해질 때, "연결이 끊어졌는지, 일시적인 지연인지 어떻게 알까?"라는 상황을 겪어본 적 있나요? 사용자에게 정확한 상태를 보여주지 않으면 혼란스럽고 답답합니다.
이런 문제는 실시간 통신 애플리케이션의 사용자 경험을 크게 좌우합니다. 연결이 끊어졌는데도 계속 말하고 있거나, 일시적인 지연인데 "연결 끊김" 에러를 보여주면 사용자가 불안해합니다.
바로 이럴 때 필요한 것이 연결 상태 관리입니다. Socket.io 연결, WebRTC 피어 연결, ICE 연결 등 여러 레이어의 상태를 추적하고, 적절한 재연결 로직을 구현하여 안정적인 서비스를 제공합니다.
개요
간단히 말해서, 연결 상태 관리는 시그널링 연결과 P2P 연결의 상태를 실시간으로 모니터링하고, 문제 발생 시 자동 복구하는 시스템입니다. 실제 프로젝트에서 네트워크 불안정, 서버 재시작, 사용자 이동 등 다양한 상황에 대응하는 핵심 기능입니다.
예를 들어, 모바일 앱에서 WiFi와 LTE를 전환할 때 연결을 유지하거나 빠르게 재연결하는 것이 중요합니다. 기존에는 연결이 끊어지면 전체 페이지를 새로고침해야 했다면, 적절한 상태 관리로 사용자가 눈치채지 못하게 자동 재연결할 수 있습니다.
연결 상태 관리의 핵심 특징은 첫째, 여러 레이어의 상태를 종합적으로 판단해야 한다는 점, 둘째, 재연결 시 기존 Room과 컨텍스트를 복원해야 한다는 점, 셋째, 사용자에게 적절한 피드백을 제공해야 한다는 점입니다. 이러한 특징들이 있어 불안정한 환경에서도 끊김 없는 경험을 제공할 수 있습니다.
코드 예제
// 연결 상태 관리
io.on('connection', (socket) => {
console.log(`연결됨: ${socket.id}`);
// 사용자 연결 상태 저장
const userState = {
socketId: socket.id,
connectedAt: Date.now(),
roomId: null,
isActive: true
};
// 연결 끊김 처리
socket.on('disconnect', (reason) => {
console.log(`연결 끊김: ${socket.id}, 이유: ${reason}`);
userState.isActive = false;
// Room에 있었다면 다른 사용자들에게 알림
if (socket.roomId) {
socket.to(socket.roomId).emit('user-disconnected', {
userId: socket.userId,
reason: reason,
timestamp: Date.now()
});
}
});
// 재연결 처리
socket.on('reconnect-request', (data) => {
console.log(`재연결 요청: ${data.userId}`);
// 이전 Room 정보로 자동 재참가
if (data.previousRoomId) {
socket.join(data.previousRoomId);
socket.roomId = data.previousRoomId;
socket.userId = data.userId;
// 다른 사용자들에게 재연결 알림
socket.to(data.previousRoomId).emit('user-reconnected', {
userId: data.userId,
socketId: socket.id
});
}
});
// 하트비트 (연결 상태 확인)
socket.on('heartbeat', () => {
socket.emit('heartbeat-ack', { timestamp: Date.now() });
});
// 에러 처리
socket.on('error', (error) => {
console.error(`소켓 에러 ${socket.id}:`, error);
socket.emit('error-notification', { message: '연결 오류가 발생했습니다' });
});
});
설명
이것이 하는 일: 이 코드는 사용자의 연결 상태를 지속적으로 추적하고, 끊김이나 재연결을 감지하여 적절히 대응하는 모니터링 시스템입니다. 마치 병원의 심박수 모니터처럼 연결의 건강 상태를 계속 확인합니다.
첫 번째로, 연결이 수립되면 userState 객체에 연결 시간, Room 정보 등을 저장합니다. 이 정보는 나중에 재연결할 때나 통계를 낼 때 유용합니다.
disconnect 이벤트에서 reason을 확인하면 'transport close'(네트워크 문제), 'client namespace disconnect'(사용자가 직접 종료), 'server namespace disconnect'(서버가 강제 종료) 등을 구분할 수 있습니다. 그 다음으로, 재연결 요청을 받으면 이전 Room에 자동으로 재참가시킵니다.
Socket.io는 재연결 시 새로운 socket.id를 부여하므로, 클라이언트가 이전 userId와 roomId를 보내야 컨텍스트를 복원할 수 있습니다. 다른 참가자들에게 'user-reconnected' 이벤트를 보내면, 클라이언트에서 WebRTC 재협상을 시작할 수 있습니다.
마지막으로, heartbeat 메커니즘으로 연결이 살아있는지 주기적으로 확인합니다. 클라이언트가 10초마다 heartbeat를 보내고 응답을 받으면 정상, 3번 연속 실패하면 연결 끊김으로 판단하는 식입니다.
Socket.io 자체 ping/pong도 있지만, 애플리케이션 레벨에서 한 번 더 확인하면 더 정확합니다. 여러분이 이 코드를 사용하면 모바일 네트워크처럼 불안정한 환경에서도 사용자 경험을 크게 개선할 수 있습니다.
자동 재연결으로 사용자가 수동으로 새로고침할 필요가 없고, 상태를 정확히 표시하여 혼란을 줄입니다. 또한 연결 품질 데이터를 수집하여 네트워크 문제를 분석하고 개선할 수 있습니다.
실전 팁
💡 Socket.io의 자동 재연결 기능을 활용하되, 재연결 후 애플리케이션 상태(Room, 사용자 정보 등)는 직접 복원해야 합니다. reconnect 이벤트에서 이전 상태를 서버에 보내세요.
💡 exponential backoff로 재연결 간격을 조정하세요. 1초, 2초, 4초, 8초... 식으로 늘리면 서버 부담을 줄이면서 결국 재연결됩니다. Socket.io의 reconnectionDelayMax 옵션을 사용하세요.
💡 사용자에게 연결 상태를 시각적으로 표시하세요. "연결 중", "연결됨", "재연결 중", "연결 끊김" 같은 상태 표시가 있으면 사용자가 상황을 이해하고 기다릴 수 있습니다.
💡 disconnect 이벤트에서 바로 정리하지 말고 5-10초 정도 유예 시간을 두세요. 일시적인 네트워크 끊김인 경우 빠르게 재연결되므로, 너무 빨리 정리하면 재연결이 복잡해집니다.
💡 WebRTC 연결 상태(iceConnectionState)도 함께 모니터링하세요. Socket.io는 연결되었지만 P2P가 끊어진 경우도 있으므로, 두 상태를 모두 확인해야 완전한 그림을 볼 수 있습니다.