| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- segment
- typescript
- HLS
- jszip
- three.js
- Redux
- node
- mDNS
- KakaoMap
- Flutter
- Excel
- REST API
- Signaling server
- react
- Babel standalone
- ffmpeg
- babel
- M3U8
- STUN
- race condition
- localization
- append row
- Reverse tunneling
- SDP
- how to install cursor on ubuntu
- webrtc
- code editor
- multiple camera
- turn
- html2canvas
- Today
- Total
Never give up
WebRTC Race Conditions & Strict SDP Alignment 본문
일반적인 웹 브라우저 간(Web-to-Web)의 WebRTC 애플리케이션이라면
브라우저의 네이티브 런타임에서 관대하게 작동합니다
패킷 순서가 조금 뒤바뀌어 오더라도 자체적으로 버퍼링을 하거나
암호화 핸드셰이크 과정에서 일어나는 일시적인 상태 미스매치를 알아서 복구해 주죠
하지만 이 피어 연결을 브라우저 밖으로 꺼내서 Headless 에이전트에 올리고 비동기 메시지 브로커와 동기화하는 순간
그 자비로움(?)은 눈 씻고 찾아볼 수 없습니다
(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를 받게 되고 실패합니다
(Offer를 받기도 전에 icecandidate를 받은 상태)
메시지 브로커 튜닝: prefetch(1)의 함정 탈출하기
이 실시간 패킷 교환은 메시지 브로커 세팅에도 큰 영향을 주었습니다
일반적인 백엔드 마이크로서비스 아키텍처에서는 작업의 공평한 분배를 위해
RabbitMQ의 소비자 프리페치 카운트(Consumer Prefetch Count)를 1로 설정하는 것이 불문율입니다
하지만 이 시그널링 제어 평면에서 prefetch(1) 제약은 피어 연결 단계의 치명적인 병목 구간이 됩니다
핵심 SDP 교환(Offer/Answer 협상)이 완전히 마무리되고 나면 연결 경로를 미친 듯이 주고받는 상황으로 진입하게 됩니다
브라우저가 클라우드 게이트웨이를 거쳐 RabbitMQ 큐로 고빈도의 가벼운 ICE Candidate 스트림을 쏟아내는데
prefetch(1) 제한이 걸려있으면 연결 경로를 하나밖에 못받는 상황이 되죠
안정적인 P2P 다리를 놓기 위해 여러 연결 경로를 동시에 집어삼키고 평가해야 하는 인프라 환경에서
에이전트에게 딱 한 번에 한 패킷씩만 가져와서 확인하고 커밋하라고 강제하는 것은 엄청난 Serialization Lag을 만듭니다
한 번에 패킷 하나라는 좁은 창구로는 겹겹이 밀려오는 Candidate의 속도를 감당할 수가 없었던 거죠
이 흡입 병목을 제거하고 에지 에이전트가 들어오는 네트워크 Candidate 무리들을 동시 다발적으로 버퍼링하고 처리할 수 있도록
브로커 인터페이스의 Look-ahead 파이프라인을 확장했습니다.
// 고빈도 ICE candidate 스트림을 동시에 효율적으로 흡수하기 위해 병렬 파이프라인 버퍼링 강제
await this.channel.prefetch(10);
클라이언트 사이드 파이프라인
원격 에이전트가 완벽히 준비되었음을 보장하기 위해, 클라이언트 사이드는 통제된 흐름을 구현합니다

- 소켓 안정화: 클라이언트는 먼저 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. 네이티브 이벤트 발생 시 로컬 큐잉 전략
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. 파이프라인 안정화 후 통제된 큐 플러싱(Flushing)
// 들어오는 시그널링 패킷들을 매끄럽게 처리한 후 실행을 강제함
const receivedCandidate = await webRTCController.receiveIceCandidate(payload.data);
// 파이프라인 전환이 완전히 안정화되면 안전하게 큐를 비움(Flush)
webRTCController.iceCandidateQueue.forEach((model) => {
if (!webRTCController.isConnected) {
this._publish({
type: MQ_MSG.CANDIDATE,
data: model,
});
}
});
구조적 정렬로 극심한 네트워크 변동성 아래에서도 예기치 못한 세션 드롭 없이
미디어 프레임을 결정론적으로 견고하게 처리가능해집니다
전용 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 (mDNS vs. QR) (0) | 2026.06.09 |
