| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |
- M3U8
- REST API
- Excel
- how to install cursor on ubuntu
- typescript
- Reverse tunneling
- segment
- STUN
- three.js
- ffmpeg
- HLS
- babel
- Redux
- html2canvas
- KakaoMap
- mDNS
- webrtc
- append row
- race condition
- Flutter
- react
- node
- Signaling server
- jszip
- multiple camera
- turn
- SDP
- code editor
- Babel standalone
- localization
- Today
- Total
Never give up
WebRTC Race Conditions & Strict SDP Alignment 본문
일반적인 웹 브라우저 간(Web-to-Web)의 WebRTC 애플리케이션이라면
브라우저의 네이티브 런타임에서는 관대하게 작동합니다
패킷 순서가 조금 뒤바뀌어 오더라도 자체적으로 버퍼링을 하거나
암호화 핸드셰이크 과정에서 일어나는 일시적인 상태 미스매치를 알아서 복구해 주죠
하지만 이 피어 연결을 브라우저 밖으로 꺼내서 에이전트에 올리고 비동기 메시지 브로커와 동기화하는 순간
그 자비로움(?)은 눈 씻고 찾아볼 수 없습니다
(Node.js 미디어 런타임은 자비가 없습니다)
ICE Candidate나 SDP Offer가 단 1밀리초라도 순서가 뒤바뀌어 도착하면
엔진은 기다려주지 않고 즉시 invalid state 예외를 뿜으며 미디어 세션을 통째로 날려버립니다
이 무자비한 비동기 시그널링의 포화 속에서 살아남기 위해 제가 어떻게 상태 정렬 라이프사이클을 설계했는지 풀어보겠습니다.
근본적인 원인: 비동기 브로커 vs 동기식 핸드셰이크
제가 설계한 하이브리드 아키텍처는 클라이언트에 웹소켓을, 기기에 AMQP/MQTT를 사용합니다
인프라 간의 연결은 완벽하지만, 이 구조는 네트워크 Jitter와 비동기성이라는 위험한 부작용이 있죠
기본적으로 WebRTC 핸드셰이크는 다음과 같은 완벽한 순서가 보장되어야 하죠
- 로컬 미디어 드라이버(FFmpeg, UDP 소켓) 초기화
- 원격(Remote) SDP Offer 등록
- 로컬(Local) SDP Answer 생성
- 검증된 ICE Candidate의 지속적인 Trickle 교환
하지만 클라우드 환경에서는 대참사가 일어날 수 있습니다
클라이언트가 SDP Offer를 보내자마자 10개의 ICE Candidate를 연달아 쏜다고 가정해보죠
만약 클라우드에서 무거운 SDP 페이로드를 보내는 동안
RabbitMQ가 가벼운 Candidate 패킷들을 로컬 서버에 먼저 배달해 버린다면
로컬 서버는는 로컬 피어 연결이 아직 stable 상태에서 벗어나기도 전에 ICE Candidate를 받게 되고 실패합니다
(sdp 협상 전에 icecandidate를 받은 상태)
메시지 브로커 튜닝: prefetch(1)의 함정 탈출하기
이 실시간 패킷 교환은 메시지 브로커 세팅에도 큰 영향을 주었습니다
RabbitMQ의 Consumer Prefetch Count를 1로 설정하는 것이 불문율입니다
하지만 이 시그널링 제어 평면에서 prefetch(1) 제약은 피어 연결 단계의 치명적인 병목 구간이 됩니다
핵심 SDP 협상이 완전히 마무리되고 나면 연결 경로를 미친 듯이 주고받는 상황으로 진입하게 됩니다
브라우저가 클라우드 게이트웨이를 거쳐 RabbitMQ 큐로 고빈도의 가벼운 ICE Candidate 스트림을 쏟아내는데
이 Candidate 패킷을 딱 하나씩만 가져와서 처리하고 확인(ack)하도록 강제하면
엄청난 직렬화 지연(Serialization Lag)이 발생하기 때문에 조절이 필요합니다
그렇다고 Look-ahead 파이프라인을 무작정 늘리면 메시지가 꼬일 위험이 있습니다
결국 저는 단일 전송 파이프라인의 구조적 제약을 지키기 위해
RabbitMQ의 원칙인 prefetch(1)을 그대로 유지하기로 하고
로컬 서버 런타임에서 잘 처리할수 있도록 할 것인가에 대한 고민을 했습니다
// 순차적 흡수 제어를 보장하기 위해 엄격한 단일 메시지 전송(prefetch 1)을 강제합니다
await this.channel.prefetch(1);
클라이언트 사이드 파이프라인
원격 에이전트가 완벽히 준비되었음을 보장하기 위해, 클라이언트 사이드는 통제된 흐름을 구현합니다

- 소켓 안정화: 클라이언트는 먼저 Socket.IO 게이트웨이에 연결하고 격리된 룸에 조인
- 시그널 훅: 조건 없이 Offer를 눈감고 쏘는 게 아니라, 클라우드 서버가 "기기 온라인" 컨펌할 때까지 대기
- Receive-Only Offer 전략: 클라이언트는 SDP Offer를 생성할 때 속성을 명시적으로 recvonly(수신 전용)로 플래그를 지정해 전송해서, 브라우저가 순수한 소비자임을 기기에 알려줌으로써 네트워크 리소스 할당을 시작부터 최적화
- Answer 및 Trickle ICE 시작: 들어오는 SDP Answer를 성공적으로 캡처하고 디코딩한 이후에만, 클라이언트는 P2P 연결을 바인딩하기 위해 본격적인 ICE Candidate 공유를 시작
서버 사이드 파이프라인: 엄격한 미디어 정렬
로컬 서버 단에서는 로우 레벨 미디어 파이프라인(FFmpeg, UDP)과 WebRTC을 조율하기 위해 훨씬 더 최적화가 필요합니다

- 인프라 동기화: 에이전트는 AMQP/MQTT 브로커에 훅을 걸고 초기화 페이로드 대기
- 하드웨어 Pre-flight 초기화: 시그널링 메시지가 안착하는 순간, 에이전트는 하부 비디오 스트림 원시 객체들을 동시에 초기화 진행, OS 레벨의 FFmpeg 프로세스를 올리고, 로컬 UDP 소켓을 바인딩하며, WebRTC 피어를 인스턴스화하고, 비디오 코덱세팅
- 엄격한 상태 전환: SDP Offer(send-only)가 시그널링 레이어에 안전하게 주입될 때까지 에이전트는 모든 외부 입력을 차단
- 액티브 파이프라인 전송: 매칭되는 SDP Answer를 생성하고, UDP-to-FFmpeg 스트림 파이프라인을 부팅한 뒤, Answer를 아웃바운드 라우팅 키로 전송
- Answer 이후 ICE 소비: Answer가 완전히 네트워크 선로 위에 올라간 후에야 에이전트는 ICE Candidate를 송수신하고, 조기 네트워크 파싱 에러를 예방
코드 전략: Node.js에서 상태 드리프트 막기
엄격한 선형적 흐름을 설계함으로써, 무겁고 복잡한 상태 잠금 메커니즘을 굳이 도입할 필요가 없어졌습니다
클라이언트가 SDP Answer를 명확히 기다렸다가 ICE Candidate를 쏟아내기 때문에
에지 에이전트는 언제나 기분 좋은 상태에서 패킷을 맞이할 수 있습니다
혹시 모를 극한의 네트워크 지터 때문에 패킷이 교차하더라도 깔끔한 조건부 파이프라인 체크로 해결합니다
1. Ice candidate queuing
private _onIceCandidate = async () => {
this.peerConnection!.onicecandidate = (e) => {
if (!e.candidate) {
return;
}
const { candidate, sdpMid, sdpMLineIndex } = e.candidate;
console.log("[iceCandidate]", { candidate, sdpMid, sdpMLineIndex });
if (sdpMid === undefined || sdpMLineIndex === undefined) {
return;
}
// 즉시 브로드캐스팅하는 대신, 로컬 큐에 candidate를 캡처하고 고립시킴
this.iceCandidateQueue.push({ candidate, sdpMid, sdpMLineIndex });
};
};
2. debouncing
인바운드 Candidate가 도착하면 에이전트는 즉시 브로커에 ack를 전송하여 prefetch(1) 루프를 풀고
다음 메시지를 받을 수 있도록 길을 열어줍니다
동시에 페이로드를 로컬 WebRTC 런타임에 비동기적으로 주입합니다
이때, 중복으로 동일 ice candidate를 전송하는건 절대 해서는 안되는 안티패턴이니
디바운스를 이용해서 딱 한번 정확하게 처리해주도록 합니다
case MQ_MSG.CANDIDATE:
// 1. ack first
this.channel.ack(msg);
// 2. set remote ice candidate
await webRTCController.receiveIceCandidate(payload.data);
// 3. debounce clear
this._clearCandidateDebounce();
// 4. sending ice candidates
this.candidateDebounce = setTimeout(() => {
webRTCController.iceCandidateQueue.forEach((model) => {
if (!webRTCController.isConnected) {
this._publish({
type: MQ_MSG.CANDIDATE,
data: model,
});
}
});
// Reset local tracking queue after successful dispatch
webRTCController.iceCandidateQueue = [];
}, 100);
break;
전용 TURN 서버와 동적 TTL 자격 증명을 통한 Symmetric NAT 극복
이 아키텍처의 핵심 하이라이트 중 하나는 시그널링과 미디어 스트림이
상용 이동통신사 모바일 네트워크(LTE/5G) 위에서 온전히 테스트되었다는 점입니다
모바일 네트워크는 악명 높은 NAT와 촘촘한 방화벽을 사용하는 것으로 잘 알려져 있습니다
(Symmetric NAT / Carrier-Grade NAT)
일반적인 STUN 기반의 P2P 홀펀칭은 이 엄격한 이중 NAT 포트 매핑 장벽에 막혀
통계적으로 100%에 가깝게 실패하는 척박한 환경이죠
어떤 환경에서도 실패 없는 연결을 보장하기 위해, 전용 TURN 서버를 미디어 Fallback 파이프라인에 완전히 통합했습니다
하지만 고정된 TURN 서버 설정을 클라이언트 사이드 React 소스 코드에 그대로 노출하는 것은 심각한 보안 리스크입니다
악의적인 해커가 자격 증명을 긁어가서 제 미디어 릴레이 인프라를 악용할 수 있으니까요
강력한 Zero-Trust 네트워크 아키텍처를 구현하기 위해, 자격 증명 생성을 철저히 서버 사이드로 추상화하는 연결 시퀀스를 재설계했습니다.
- 클라이언트는 WebRTC 핸드셰이크를 시작하기전, NestJS 클라우드 게이트웨이에 임시 접근 권한을 명시적으로 요청
- 클라우드 서버는 암호화 모듈을 사용하여 엄격한 TTL 메커니즘이 바인딩된, 암호학적으로 안전한 시한부 TURN 자격 증명을 실시간으로 생성
- 클라이언트는 미디어 세션이 유지되는 동안 이 임시 토큰을 소비. 핸드셰이크가 마무리되거나 지정된 TTL이 만료되면 토큰은 자동으로 휘발되어 사라지며, 자격 증명 유출 취약점을 완벽히 지워버리는 동시에 강력한 NAT 통과율을 보장

마치며
비동기적인 시스템을 타이트하게 정렬된 동기식 핸드셰이크 흐름으로 강제해 시그널링을 완벽히 수행하고
클라우드에서 엄격한 보안 문지기 역할을 수행합니다
다음에는 히든레이어가 없는 엄격한 환경에서 코덱 세팅은 어떻게 진행하는지 다룹니다
'Side project' 카테고리의 다른 글
| High-Performance Camera Handling: FFmpeg Pipeline & Codec Hell (0) | 2026.06.09 |
|---|---|
| Hybrid Signaling Topology: WebSockets Meets AMQP/MQTT (0) | 2026.06.09 |
| Local Device Pairing: From Hell to Heaven (feat. mDNS) (0) | 2026.06.09 |