[PonsLink] 두 달 동안 방 하나에 매달리며 배운 제품의 첫 얼굴
WebRTC를 처음 제품에 넣을 때는 “브라우저끼리 직접 붙는다”는 말이 거의 마법처럼 들렸다. 서버가 미디어를 전부 중계하지 않아도 되고, 작은 팀도 실시간 방을 만들 수 있을 것 같았다.
실제로 WebRTC는 강력했다. 다만 두 달 동안 방 하나에 매달리며 배운 것은 반대편이었다. WebRTC는 연결의 문을 열어줄 뿐, 제품의 질서는 만들어주지 않는다.

입장 순서가 먼저 무너졌다
가장 먼저 깨진 것은 코덱이나 화질이 아니라 입장 순서였다.
- A가 먼저 들어오고 B가 나중에 들어오는 경우
- B만 새로고침하는 경우
- 모바일에서 권한 화면이 길게 멈추는 경우
- host가 방을 닫았다가 다시 여는 경우
- 한쪽만 ICE failed 후 재시도하는 경우
겉으로는 모두 “아직 안 들어왔음”으로 보이지만, 안에서는 전혀 다른 상태다. offer를 누가 만들지, answer를 언제 setRemoteDescription할지, ICE candidate가 remote description보다 먼저 도착하면 어디에 쌓을지가 달라진다.
데모 happy path만 보면 이 차이가 안 보인다. 사용자가 늘면 가장 먼저 터진다. 특히 모바일 권한 지연은 “느린 네트워크”로 오진하기 쉽다. 실제로는 signaling이 끝난 뒤 media track이 아직 없어서, 상대 peer는 빈 타일만 본다.
그래서 다시 만든다면 기능 패널보다 입장 시나리오 표를 먼저 쓰겠다. 누가 먼저 들어왔는지, 권한은 어디서 막혔는지, 재입장인지 신규인지가 상태 머신의 1순위 축이다. 이 표 없이 버튼부터 배치하면, 모든 예외가 “연결 중…”이라는 같은 얼굴로 뭉개진다.

방이 원하는 경험은 통화 이상이었다
내가 풀고 싶었던 질문은 “영상 통화가 되는가”가 아니었다. 작은 방에서 안전하게 들어오고, 서로를 보고, 채팅하고, 파일을 주고받고, 필요하면 화면 공유까지 이어지는 경험이었다.
문제는 이 기능들이 같은 성공 경험처럼 보여도 실패 조건이 다르다는 점이다.
| 기능 | 버틸 수 있는 실패 | 버티기 어려운 실패 |
|---|---|---|
| 영상/음성 | 잠깐의 끊김, 화질 저하 | 영구 mute, 일방 미수신 |
| 채팅 | 짧은 지연 | 순서 역전, 유실 |
| 파일 | 재시도 | 조각 유실, 저장 미완료 |
| presence | 수 초 지연 | 유령 peer, 중복 입장 |
| 화면 공유 | 해상도 하락 | 공유 중 권한 철회 미반영 |
“WebRTC로 다 했다”는 문장은 이 표를 가린다. 같은 data channel 위에 채팅과 파일을 태우면 구현은 짧아지지만, 한쪽의 백프레셔가 다른 쪽 체감 품질을 망가뜨린다. 기능 통합의 비용은 코드 줄 수가 아니라 실패 설명의 결합도다.
사용자는 기능 목록을 보고 제품을 평가하지 않는다. 한 번의 세션에서 “말할 수 있었는가”, “자료를 넘길 수 있었는가”, “다시 들어와도 같은 방인가”를 몸으로 평가한다. 그 세 질문이 서로 다른 실패 모드를 가지는데 한 상태 배지로 응답하면, 제품은 금방 믿을 수 없어 보인다.

mesh를 고른 이유와 클라이언트 책임
초기 선택은 단순한 browser mesh였다. 참가자가 적다면 SFU 없이도 동작하고, 비용도 낮다. 작은 상담·협업 방이라는 PonsLink 목표와는 맞았다.
대신 클라이언트 책임이 커진다.
- peer connection 수 증가
- ICE restart / reconnect 설명
- TURN fallback 비용과 시점
- 장치 권한과 media track 생명주기
- 탭 종료와 네트워크 전환 처리
mesh는 “서버가 적게 일한다”가 아니라 **“서버가 할 일과 브라우저가 할 일을 정확히 나눈다”**는 뜻에 가까웠다. 서버를 줄인 만큼, 브라우저 상태 머신과 사용자 문장이 제품의 중심이 된다.
TURN을 “최후 수단”으로만 두면 안 된다. 실제 환경에서는 회사망·카페 Wi-Fi·모바일 전환 때문에 relay가 생각보다 자주 필요하다. 문제는 relay 사용 사실 자체가 아니라, 사용자가 왜 연결이 느린지 모른 채 기다리게 만드는 것이다.
클라이언트 책임이 커질수록 관측도 클라이언트 중심으로 다시 짜야 한다. 서버 access log만으로는 offer/answer 경쟁 상태나 candidate queue 유실을 설명하기 어렵다. 브라우저에서 남긴 이벤트 타임라인이 mesh를 고른 팀은 서버 대시보드보다 클라이언트 관측 설계에 먼저 투자해야 한다.
control plane과 media plane을 나누다
다시 설계한 기준은 평면 분리였다.
- BFF / control plane: room·session authority, feature grant, TURN credential, upload URL
- Signaling: offer / answer / ICE candidate 전달과 재시도 정책
- Media plane: 가능한 한 browser mesh
- 파일 경로: 크기·정책에 따라 data channel이 아닌 별도 업로드를 허용
이렇게 나눠야 “영상은 사는데 파일만 죽는” 상황을 다른 문장으로 설명할 수 있다. 모든 것을 data channel 하나에 태우면 데모는 단순해지고 제품은 쉽게 통째로 무너진다.
권한도 같은 원리다. “방에 들어왔다”와 “화면 공유가 허용된다”, “파일 전송이 허용된다”를 한 토큰으로 묶으면 운영이 편해 보이지만, 정책 변경 때 전부 같이 흔들린다. feature grant를 세션 권한의 하위 클레임으로 두면, 제품 문장도 “입장 가능 / 기능 가능”으로 나눌 수 있다.
평면 분리는 아키텍처 다이어그램용 장식이 아니다. 장애 대응 단위다. signaling 장애와 media 장애와 권한 장애를 같은 온콜 문장으로 쓰면, 고치는 손도 같은 곳을 헤매게 된다. 경계가 코드에만 있고 운영 분류에 없으면 분리는 미완성이다.
연결 중 UI가 설명해야 할 상태
내부 상태와 사용자 문장을 분리하지 않으면 제품은 불안해 보인다.
| 내부 | 사용자가 이해해야 하는 말 |
|---|---|
| permission pending | 카메라/마이크 권한을 기다리는 중 |
| signaling | 방에 연결 정보를 교환하는 중 |
| ice connecting | 네트워크 경로를 찾는 중 |
| connected / no media | 연결은 됐지만 화면이 아직 없음 |
| reconnecting | 잠시 끊겨 다시 붙이는 중 |
| failed | 다시 시도하거나 다른 네트워크가 필요함 |
connecting 하나로 뭉개면 사용자는 콘솔을 볼 수 없으니 “멈춤”으로 해석한다. WebRTC 제품의 얼굴은 연결 성공 순간이 아니라, 흔들리는 순간에 뭐라고 말하는지에서 정해진다.
실무적으로는 상태마다 타임아웃과 다음 행동이 있어야 한다. 권한 대기는 설정 안내, ICE 장시간은 네트워크 전환 제안, failed는 재시도/방 재입장/지원 연락 중 무엇을 우선할지. 스피너만 돌리는 UI는 기술 부채가 아니라 신뢰 부채다.
나는 이 표를 디자인 시스템이 아니라 제품 계약으로 취급했다. 문장이 바뀌면 지원 응대 스크립트도 바뀌고, 로그 필드 이름도 같이 맞춰야 한다. UI 카피만 고치고 관측 축을 안 고치면, 다음 장애 때 또 “사용자는 멈췄다고 하는데 서버는 정상이네요”가 반복된다.
다시 붙잡는 체크 포인트
두 달을 한 줄로 압축하면 이렇다. WebRTC 연동 자체보다, 입장 시나리오와 실패 문장을 제품 계약으로 고정하는 일이 더 어려웠다.
다음 작업 전에 내가 다시 확인하는 목록은 짧다.
- 신규 입장과 재입장이 같은 상태 머신을 쓰는가
- candidate queue가 remote description 전에 유실되지 않는가
- 권한 지연을 네트워크 지연과 다른 문장으로 말하는가
- TURN relay 구간을 사용자에게 숨긴 채 무한 대기로 두지 않는가
- 파일/채팅/미디어 실패가 한 배지로 뭉개지지 않는가
이 목록을 통과하지 못한 채 기능을 더하면, 방은 더 화려해지고 신뢰는 더 얇아진다. WebRTC는 여전히 좋은 문이다. 다만 문만 멋지면 사람들은 안에서 길을 잃는다. 실무에서 가장 비싼 버그는 재현이 안 되는 버그다. WebRTC 방에서는 그 비율이 유독 높다. 같은 코드인데 회사망에서만 실패하고, 같은 휴대폰인데 권한 팝업 순서만 달라지고, 같은 상대인데 재입장 때만 candidate가 꼬인다. 그래서 나는 재현 조건을 기능 명세의 일부로 취급하기 시작했다.
최소한 남겨야 하는 재현 축은 이렇다.
- 브라우저/OS와 권한 상태
- 선입장/후입장/재입장 여부
- 네트워크 전환 여부(Wi-Fi↔LTE)
- TURN 사용 여부
- media track 존재 여부
이 축이 로그와 QA 시나리오에 없으면, 팀은 “내 환경에서는 되는데” 루프에 빠진다. 연결 제품의 품질은 평균 성공률만이 아니라, 실패를 같은 언어로 재현할 수 있는가로 결정된다.
또한 장치 전환은 별도 장르다. 이어폰 연결, 전면/후면 카메라 전환, 화면 공유 시작/종료는 track replace 타이밍을 흔든다. 통화 중 장치 변경을 “설정 부가 기능”으로 미루면, 실제 상담 현장에서 가장 자주 터지는 불안을 방치하는 셈이다. 방 제품의 얼굴은 첫 연결과 장치 전환에서 동시에 만들어진다.
마지막으로, 작은 팀일수록 SFU 도입 시점을 과신하기 쉽다. mesh의 고통을 SFU가 전부 지워 주지는 않는다. 권한, 입장 순서, 상태 문장, 재연결 설명은 토폴로지가 바뀌어도 남는다. 미디어 경로를 바꾸기 전에 제품 계약을 먼저 고정해야 한다. 회고를 쓰며 가장 부끄러웠던 순간은, 연결 성공 스크린샷을 제품 완성으로 착각했던 때다. 초록 인디케이터는 시작점이지 도착점이 아니다. 사용자가 말할 수 있고, 다시 들어와도 같은 방에 있고, 실패해도 다음 행동을 알 때 비로소 방 제품이 된다.
이 기준을 받아들이면 로드맵 문장이 바뀐다. “화면 공유 추가”보다 “화면 공유 실패 시 문장과 복구”가 먼저 보인다. “이모지 반응”보다 “유령 peer 제거”가 먼저 보인다. 화려함보다 질서를 고르는 쪽이, 작은 팀이 실시간 제품에서 살아남는 방법이었다.
실시간 스택을 고를 때 이제는 라이브러리 데모 영상보다 실패 매트릭스를 먼저 본다. 누가 먼저 들어왔는지, 권한이 어디 막혔는지, relay가 언제 필요한지. 그 매트릭스를 채울 수 없는 추상화는 아무리 현대적으로 보여도 제품 표면에 구멍을 남긴다. 구멍은 언젠가 사용자 문장으로 돌아온다. 같은 결론을 다음 주 우선순위 회의에서도 다시 꺼내 보려 한다.
실시간 스택을 고를 때 이제는 라이브러리 데모 영상보다 실패 매트릭스를 먼저 본다. 누가 먼저 들어왔는지, 권한이 어디 막혔는지, relay가 언제 필요한지. 그 매트릭스를 채울 수 없는 추상화는 아무리 현대적으로 보여도 제품 표면에 구멍을 남긴다. 구멍은 언젠가 사용자 문장으로 돌아온다.
마치며
다시 만든다면 기능 패널보다 실패 표를 먼저 쓰겠다. offer/answer, ICE queue, device permission, TURN relay, reconnect, room authority를 한 줄씩 놓고, 사용자 문장과 운영 로그를 같이 적는다.
WebRTC는 문을 연다. 그 문 안에서 사람들이 길을 잃지 않게 만드는 일은 제품의 몫이다. 두 달의 결론은 단순하다. 연결 라이브러리를 붙이는 속도와, 연결 중 상태를 설명하는 속도는 전혀 다른 경쟁력이다.
방이 제품의 얼굴이 되려면, 성공 화면보다 흔들림 화면을 먼저 설계해야 한다.