| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- STUN
- how to install cursor on ubuntu
- node
- jszip
- Flutter
- append row
- Excel
- Reverse tunneling
- ffmpeg
- segment
- race condition
- react
- turn
- multiple camera
- html2canvas
- Signaling server
- babel
- M3U8
- REST API
- code editor
- three.js
- Redux
- typescript
- localization
- Babel standalone
- webrtc
- HLS
- KakaoMap
- SDP
- mDNS
- Today
- Total
Never give up
Hybrid Signaling Topology: WebSockets Meets AMQP/MQTT 본문
지난 포스팅에서 로컬 기기 페어링 지옥을 다뤘는데
이번에는 패킷을 안전하게 주고받기 위한 시그널링 서버의 구성에 대해 짚고 넘어가보자 합니다
WebRTC를 구현할 때 브라우저와 기기 간에 SDP나 ICE Candidate 같은 메타데이터를 교환하는 시그널링은 필수죠
하지만 분산 환경에서 이를 안정적으로 설계하는 것은 완전히 다른 차원의 문제입니다
역방향 터널링(Reverse Tunneling): 공유기 설정 없이 방화벽 우회하기
프로토콜을 고민하기 전에 가장 먼저 마주한 제약은 바로 네트워크 접근성(Network Accessibility)이었습니다
전통적인 방식대로 원격 기기에 접속하려면 고정 IP를 쓰거나
방화벽 인바운드 규칙을 바꾸거나, 포트 포워딩을 설정해줘야 됩니다
하지만 일반 유저에게 이런 복잡한 설정을 요구하는 순간 프로덕트로서의 매력은 바닥이 되죠
(실제 많은 유저가 공유기 비밀번호조차 모르는 경우가 허다하니까요)
유저가 기기를 꽂자마자 바로 작동하는 진정한 zero config을 구현하기 위해
저는 역방향 터널링(Reverse Tunneling) 모델로 설계했습니다
로컬 서버가 클라우드 게이트웨이를 향해 먼저 아웃바운드 연결 하도록 해서
유저는 공유기 설정에 손 하나 대지 않고도 기기를 안전하게 구동할 수 있게 된 거죠
프로토콜의 미스매치: 브라우저의 제약 vs 기기의 네트워크 불안정성
보통 웹 애플리케이션에서 양방향 실시간 통신이라고 하면
브라우저가 기본 지원하는 WebSockets(Socket.IO)가 사실상 표준이죠. 가볍고 반응성도 좋으니까요
하지만 이 "가벼운" 웹소켓 클라이언트를 화면도 없는 headless 기기에 그대로 올리면
프로덕션 환경에서는 심각한 운영 취약점이 발생합니다
- Connection Drops: 로컬 서버가 Wi-Fi 음영 지역 등으로 아주 잠깐이라도 연결을 놓치면, 그 찰나의 다운타임 동안 클라우드가 보낸 중요한 시그널링 패킷은 영원히 증발합니다
- Tight Coupling: 웹 클라이언트와 기기가 정확히 동일한 소켓 세션에 강하게 묶여 버립니다. 이렇게 되면 나중에 클라우드 게이트웨이를 서버 여러 대로 수평 확장할 때 세션 공유 구조가 지저분해질 수 있죠
이 문제를 해결하기 위해 클라이언트와 로컬 서버 사이에 비동기 버퍼 역할을 해줄 메시지 브로커를 쓰기로 했습니다
MQTT를 쓸지 AMQP를 쓸지 표로 간단히 보고 다음으로 넘어가도록 하죠

1. MQTT가 엄격한 시그널링 파이프라인에서 실패하는 이유
MQTT는 가볍고 오버헤드가 적어 고빈도 계측 데이터를 브로드캐스팅하는 데는 타의 추종을 불허합니다
하지만 MQTT는 본질적으로 엄격한 메시지 큐가 아닙니다
로컬 서버가 잠깐 터널을 재연결하는 동안 클라이언트가 SDP Offer를 토픽에 발행해 버리면
그 메시지는 증발하고 연결이 안되는 상황이 생기죠
2. AMQP가 승리한 이유
결국 저는 RabbitMQ(AMQP)를 채택하여 우편함을 활용했습니다
- 확실한 배달 보장(ACK): 클라우드 게이트웨이가 시그널링 이벤트를 쏘면, RabbitMQ는 이를 물리 큐에 확실히 쥐고 있습니다. 네트워크가 흔들리더라도 기기가 다시 붙는 순간 자신의 개인 우편함에서 밀린 시그널링 페이로드를 한 번에 가져가므로, 패킷 유실로 시스템 상태가 꼬이는 일이 없습니다.
- 구독 편의성(Zero-Knowledge Subscriptions): 로컬 서버가 상위 라우팅 구조나 복잡한 토픽을 찾아 다이나믹하게 구독할 필요가 없습니다. 아키텍처적으로 기기마다 전용 큐를 딱 하나씩 배정해 두고, 로컬서버는 오직 자기 우편함만 바라보게 만들었습니다.
비동기 백프레셔 전략: 1분 QoS 드롭 정책 (Message TTL)
비동기 우편함 구조가 일시적인 네트워크 순단은 완벽하게 해결해 주지만
새로운 아키텍처적 취약점을 만들어냅니다. 바로 큐 부하 문제입니다
만약 로컬 서버가 몇 시간 동안 오프라인 상태가 되었는데
클라이언트가 웹소켓을 통해 고빈도의 WebRTC 시그널링 이벤트를 계속 밀어 넣는다고 생각해보죠
(어우.. 살려줘요..)
RabbitMQ 큐는 엄청나게 부풀어 오르고 시스템 메모리를 갉아먹겠죠
나중에 기기가 겨우 연결되었을 때 패킷들이 뒤섞여 쏟아지며 대참사가 일어나겠죠
이를 제어하기 위해 RabbitMQ의 Per-Queue Message TTL(Time-To-Live) 기능을 이용해
엄격한 1분 QoS 드롭 정책을 걸어두었습니다.
핵심 설계 원칙: > "실시간 네트워킹 기기가 액티브 세션 중에 1분 이상 시그널링이 끊겼다면, 그 패킷은 이미 죽은 패킷이다."
네트워크가 끊긴 기기가 60초 이내에 복구되어 우편함을 비우지 못하면
큐에 쌓여있던 오래된 패킷들을 폐기시켜 재연결 시 과부하로 죽는 현상을 막고
유효한 패킷들만 처리하게 하는거죠
인바운드 공격 표면을 원천 차단하는 아키텍처
로컬 공유기의 인바운드 포트를 절대 열지 않기 위해 완성된 아웃바운드 전용 릴레이 모델의 전체 파이프라인은 다음과 같습니다

- React 클라이언트가 NestJS 백엔드가 관리하는 보안 웹소켓 룸을 통해 시그널링 이벤트를 트리거
- NestJS 클라우드 게이트웨이가 이 페이로드를 가로채 비즈니스 로직을 수행 후, 소켓 이벤트를 AMQP 라우팅 키(devices.{machineId}.inbound)로 변환합니다.
- RabbitMQ가 메시지를 해당 기기의 격리된 전용 큐로 전달합니다.
- 로컬 서버(Node.js)는 오직 아웃바운드 연결만을 통해 이 메시지를 Consume하고, SDP를 처리한 뒤 응답을 다시 아웃바운드 파이프라인으로 밀어 넣습니다.
이중 레이어 보안 (WebSockets & AMQPS)
오픈소스 레포지토리나 로컬 서버의 런타임 환경에 고정된 자격 증명을 하드코딩하는 것은 그야말로 보안 안티 패턴입니다
완벽한 Zero-Trust를 달성하기 위해, 웹소켓과 AMQP 각각에 인증 라이프사이클을 적용했습니다
1. 클라이언트 사이드 보호: 웹소켓 룸을 위한 상태 없는 JWT
1. 유저가 로그인하고 React 대시보드를 열면, 클라이언트는 NestJS 인증 플레인으로부터 수명이 짧은 JWT를 발급받습니다.
2. Socket.IO 핸드셰이크 과정에서 게이트웨이가 이 토큰을 검증하고, 확인이 끝나면 해당 소켓 세션을 유저의 ID와 특정 machineId로 묶인 격리된 룸에 동적으로 진입시킵니다.
3. 악성 클라이언트가 다른 기기의 채널을 훔쳐보거나 패킷을 주입하는 행위를 원천 차단합니다.
2. 기기 사이드 보호: RabbitMQ ACL을 통한 계정 생성
기기마다 고정된 브로커 계정을 공유하는 대신, RabbitMQ Management HTTP API를 활용해 온디맨드 자격 증명 라이프사이클을 만들었습니다.
로컬 서버가 페어링을 시작하는 순간, NestJS서버는 crypto를 이용해 안전한 ID/PW 패이로드를 생성합니다. 그리고 브로커에 전달하기 전에 엄격한 정규식 기반의 ACL 권한을 주입합니다.
const permissionRegex = `^(amq\\.default|q_device_${machineId})$`;
이 규칙 덕분에 에지 노드는 가상 호스트의 다른 어떤 설정도 건드릴 수 없고
오직 자신에게 할당된 명시적 큐와 기본 익스체인지에만 접근(Read/Write)할 수 있습니다
설령 물리적인 탈취로 인해 기기 하나가 해킹당하더라도 공격자는 철저히 격리된 샌드박스에 갇히게 되고
클러스터 내의 다른 기기 트래픽을 감지하거나 손댈 수 없습니다
기기가 연결 해제되거나 해제 요청이 들어오면 Nest서버에서 즉시 브로커 API에 DELETE 콜을 날려 계정을 흔적도 없이 지워버립니다
3. 전송 계층 보안(TLS): Nginx 리버스 프록시 & AMQPS (5671)
네트워크 패킷이 평문으로 노출된다면 어플리케이션 레이어의 토큰들은 아무런 의미가 없겠죠
게다가 WebRTC는 보안 컨텍스트를 엄격히 요구하기 때문에 unencrypted HTTP 환경에서는
브라우저 카메라 권한(getUserMedia)을 싹 차단해 버립니다
그래서 토폴로지 전반에 걸쳐 TLS 암호화를 강제했습니다.
- Nginx를 통한 TLS 설정: NestJS 클라우드가 직접 TLS 터미네이션을 처리하지 않도록 하고, 앞단에 자동 갱신되는 Let's Encrypt 인증서 기반의 Nginx 리버스 프록시를 두었습니다. 덕분에 민감한 SDP와 ICE 정보가 담긴 일반 REST API(https://) 및 고빈도 Socket.IO 패킷(wss://)은 공용 인터넷을 지나기 전에 완전히 암호화됩니다.
- AMQPS(포트 5671)로 브로커: 로컬 에이전트와 RabbitMQ 간의 아웃바운드 동기화 터널은 평문 AMQP(포트 5672)를 아예 거부합니다. 브로커가 오직 AMQPS(포트 5671)로만 통신하도록 매핑했고, 로컬 에이전트는 브로커의 인증 기관 체인에 대해 엄격한 TLS 핸드셰이크를 수행합니다. 로컬 공유기 단에서 일어날 수 있는 중간자 공격이나 패킷 인젝션을 예방했습니다
마치며
연결성과 보안이 강화된 동적 아키텍처를 결합함으로써 유저 경험과 인프라 보안을 모두 챙긴 시그널링을 완성할 수 있었습니다
하지만 안전하고 비동기적인 브로커 레이어를 구축했다고 해서 끝난 것은 아닙니다
분산 메시징 큐가 도입되는 순간, 우리는 또 다른 혼돈의 변수인 비동기성을 마주하게 되기 때문이죠
RabbitMQ 익스체인지를 타고 SDP Offer, Answer, 그리고 수많은 Trickle ICE Candidate들이
무서운 속도로 몰아치기 시작하면 필연적으로 레이스 컨디션(경쟁 상태)이 발생합니다
히든레이어에서 핸들링하는 웹 브라우저와 달리 엄격한 Node.js 미디어 런타임은 핸드셰이크 패킷이
단 1밀리초라도 순서가 뒤바뀌어 도착하면 가차 없이 크래시를 내며 뻗어버리니까요
다음 부분에서는 이 비동기 시그널링의 포화 속에서 살아남기 위해 어떻게 결정론적 상태 정렬 라이프사이클을 엔지니어링하고
브로커의 Prefetch 제약을 튜닝했는지를 다루겠습니다
'Side project' 카테고리의 다른 글
| High-Performance Camera Handling: FFmpeg Pipeline & Codec Hell (0) | 2026.06.09 |
|---|---|
| WebRTC Race Conditions & Strict SDP Alignment (0) | 2026.06.09 |
| Local Device Pairing: From Hell to Heaven (mDNS vs. QR) (0) | 2026.06.09 |
