[PonsLink] WebRTC offer 충돌을 피하는 상태 머신 설계
"브라우저끼리 붙이면 되겠지"라고 생각했다가 연결 순서, 신호, 상태가 꼬인 장면WebRTC offer 충돌을 피하는 상태 머신 설계라는 제목은, 기능명보다 그때 붙잡은 질문이 더 오래 남았다.
WebRTC는 연결의 일부만 열어줄 뿐, 제품이 책임질 상태·순서·실패 복구는 남긴다. 그 남은 책임을 어떻게 상태 머신으로 잡느냐가 이 글의 핵심이다.

offer 충돌이 커진 문제
두 브라우저가 거의 동시에 입장하면, 양쪽 다 offer를 만들고 서로 answer를 기다리는 glare 상태가 된다. 어느 쪽도 상대 answer를 못 받으면 연결은 멈춘다. 사용자에게는 그냥 "연결 안 됨"으로 보인다.
offer 충돌은 내부의 상태 문제였지만 사용자에게는 연결이 멈춘 화면으로 보였다.

현재 구성의 가정
초기 가정은 단순했다. host가 먼저 offer를 만들고 guest가 answer한다. 방 하나에 두 명이면 이 가정이 잘 동작한다.
그런데 재입장, 권한 지연, 모바일 백그라운드 복귀가 섞이면 "누가 offer인가"가 고정되지 않는다. 그때부터 현재 구성의 가정이 흔들린다.

glare가 선명해진 순간
부하는 미디어가 아니라 signaling 제어부에 쌓였다. candidate가 remote description보다 먼저 도착하면 어디에 쌓을지, answer를 언제 setRemoteDescription할지, ICE restart 타이밍은 언제인지가 꼬였다.
특히 모바일 권한 지연은 "느린 네트워크"로 오진하기 쉽다. 실제로는 signaling은 끝났는데 media track이 없어 상대는 빈 타일만 본다.
role 고정 선택
glare를 피하는 핵심은 역할을 결정론적으로 정하는 것이다.
| 방식 | 동작 | 선택 이유 |
|---|---|---|
| 역할 고정 | host=offerer 고정 | 작은 방에서 단순 |
| 정량 우선 | ID 큰 쪽이 offerer | 충돌 없이 결정 |
| signaling 중재 | broker가 순서 강제 | 복잡하지만 안전 |
PonsLink는 작은 방 기준으로 정량 우선(polite/impolite peer) 방식을 택했다. 양쪽 다 같은 규칙을 가지되, 충돌 시 한쪽만 rollback하도록 해서 무한 glare를 막았다.
다시 짜 본 구조: 상태 머신
상태를 명시적으로 잡았다.
- idle
- waiting-local-description
- exchanging
- stable
- restarting
- failed
각 전이에 타임아웃과 다음 행동을 둔다. 권한 대기는 설정 안내, ICE 장시간은 네트워크 전환 제안, failed는 재시도/재입장 안내. 스피너만 돌리는 UI는 신뢰 부채이므로 상태마다 문장을 정했다.
실패 모드
- candidate 큐 유실 → reconnect 루프
- answer 지연 → 빈 타일
- 권한 철회 → 연결됐는데 미디어 없음
- TURN 전환 타이밍 → 일방 미수신
이들을 한 "연결 실패"로 합치면 디버깅이 불가능하다. 상태 머신 로그와 사용자 문장을 같은 축으로 맞춰야 한다.
마치며
WebRTC offer 충돌을 피하는 건 라이브러리 설정이 아니라 제품 상태 머신 설계다. 브라우저가 문을 열어 줄 뿐, 문 안에서 누가 먼저 말할지는 제품이 정해야 한다.
지금은 glare를 "운 좋으면 안 생기는 버그"가 아니라 "역할 규칙으로 막는 상태"로 본다. 다음에 signaling을 다시 만지면 상태 머신부터 그리고, happy path는 나중에 그리겠다.