[PonsLink] 링크는 단순했지만, 뒤에서는 신호가 계속 엉켰다
PonsLink에서 가장 오래 남은 대비는 겉과 속의 차이였다. 사용자는 링크 하나를 클릭한다. 화면은 로비를 보여주고, 장치 권한을 묻고, 입장 버튼을 보여준다. 그러나 뒤에서는 SDP offer, SDP answer, ICE candidate, signaling room, peer identity, retry timer, TURN fallback이 계속 움직인다. 링크는 단순했지만, 그 단순함을 유지하려면 신호가 엉켰을 때도 제품이 침착해야 했다. 그래서 용어 사전 대신, 그 날 무엇을 보고 무엇을 바꿨는지부터 적는다.

링크 뒤 신호가 엉킨 지점
[의 세부 기록이다. WebRTC 개념 자체는 [에서 더 넓게 다뤘다. 링크가 쉬워 보이는 제품일수록, 뒤쪽 신호는 더 엄격하게 다뤄야 한다. 두 번째는 동시 offer였다. 두 peer가 모두 자신이 먼저라고 생각하면 negotiation glare가 생긴다. 이 문제는 문서에서는 예외처럼 보이지만 실제 제품에서는 생각보다 자주 만난다. 새로고침, reconnect, host 이동, 모바일 sleep/wake가 겹치면 충분히 발생한다. 그래서 누가 polite peer인지, 어떤 쪽이 offer를 rollback할지, 어떤 상태에서는 새 negotiation을 막을지 결정해야 했다.
PonsLink에서 이 문제는 새로고침 때 자주 보였다. 화면은 같은 방처럼 보이지만 내부 연결은 새로 시작된다. 이전 offer가 늦게 도착하거나 예전 ICE candidate가 다시 적용되면 사용자는 아무것도 하지 않았는데 방이 이상해진다. 그래서 링크의 단순함은 URL을 짧게 만드는 일이 아니라, 뒤쪽 식별자를 충분히 촘촘하게 잡는 일에 가깝다. 문제는 기술 용어가 많다는 것이 아니었다.
기술 용어가 사용자 경험으로 새어 나온다는 것이 문제였다. “remote description이 아직 없습니다”, “ICE candidate가 먼저 도착했습니다”, “signaling socket이 끊겼습니다” 같은 내부 상태가 그대로 노출되면 사용자는 제품을 믿기 어렵다. 반대로 모든 실패를 “연결 실패”로 뭉개면 운영자도 원인을 찾을 수 없다. 제품은 내부 상태를 충분히 구분하되, 사용자에게는 행동 가능한 말로 바꿔야 했다.

처음에는 링크만 만들면 될 줄 알았다
*live chat 자산은 signaling과 사용자 메시지가 모두 실시간 흐름이지만 서로 다른 신뢰 경계를 가진다는 점을 떠올리게 한다. 그런데 실제 방은 직선이 아니었다. 사용자는 새로고침한다. 모바일은 권한 허용에서 멈춘다. 네트워크는 잠깐 끊긴다. 두 브라우저가 거의 동시에 offer를 만들 수도 있다. ICE candidate는 remote description보다 먼저 도착할 수 있다. 같은 peer가 나갔다가 다시 들어오면 이전 연결의 잔여 이벤트가 늦게 도착할 수 있다.
signaling은 전달 레이어가 아니라 시간과 순서를 다루는 제품 레이어였다. MDN의 WebRTC protocol 문서는 ICE, STUN, TURN, SDP를 분리해서 설명한다. WebRTC.org의 peer connection guide도 signaling은 표준이 아니라 애플리케이션이 직접 정한다고 말한다. 이 말은 PonsLink 입장에서 부담이었다. 브라우저 API는 제공되지만, 어떤 메시지를 언제 보내고 어떻게 재시도하며 어떤 UI 상태로 보여줄지는 내가 정해야 했다. 또한 signaling message에는 roomId뿐 아니라 peer identity와 세대 개념이 필요했다.
같은 사람이 다시 들어오더라도 이전 연결의 이벤트가 새 연결에 섞이면 안 된다. feature grants와 session token도 같은 이유로 중요하다. URL이 같아도 세션 권한은 새로 확인해야 하고, 방이 닫혔다면 signaling이 살아 있어도 기능을 열면 안 된다. 링크의 단순함은 내부 식별자가 충분히 정확할 때만 유지된다. 이 queue를 느슨하게 다루면 실패가 늦게 드러난다. candidate를 버렸는지, 너무 오래된 candidate를 적용했는지, peer가 이미 바뀌었는지를 모른 채 “연결 실패”만 남는다.
반대로 queue의 수명과 소유자를 명확히 두면 사용자에게도 더 정확하게 말할 수 있다. “상대 연결 정보를 다시 맞추는 중”이라는 문장은 이런 내부 구분이 있어야 나온다.

신호 복잡도가 선명해진 순간
ICE candidate queue는 처음에는 아주 내부적인 구현 세부사항처럼 보였다. 하지만 remote description이 준비되기 전에 candidate가 도착하면 사용자는 결국 ‘상대가 안 보인다’는 문제로 경험한다. 그래서 queue는 개발자만 보는 배열이 아니라, 연결 준비 중인지 복구 중인지 설명하는 제품 문구의 근거가 된다. 다음에는 코드보다 표를 먼저 만들겠다. joining, negotiating, connected, recovering, closed를 왼쪽에 쓰고, 각 행에 받을 수 있는 message, 허용할 transition, 사용자 문구, 운영 로그를 같이 넣는다.
signaling은 메시지를 전달하는 통로가 아니라 시간 순서를 다루는 제품 계약이기 때문이다. 처음에는 signaling을 단순한 전달 레이어로 봤다. A가 offer를 만들면 signaling server가 B에게 보내고, B가 answer를 만들면 다시 A에게 보내고, ICE candidate가 생기면 서로 전달한다. 이 흐름은 문서로 보면 직선이다. 그래서 구현도 처음에는 직선처럼 시작했다. roomId와 peerId를 붙인 이벤트를 보내고, 받은 메시지에 따라 setRemoteDescription, addIceCandidate, createAnswer를 호출했다.
이 판단은 PonsWarp의 파일 전송에도 이어졌다. 빠르게 보내는 것보다 어떤 조각이 어디서 멈췄는지 설명하는 것이 더 중요했다. WebRTC signaling에서 배운 것은 곧 P2P data transfer의 상태 관리로 이어졌다. 연결이 직접적일수록 상태 설명은 더 정교해야 한다. 세 번째는 signaling socket과 WebRTC connection의 차이다. signaling socket이 잠깐 끊겨도 기존 media connection은 살아 있을 수 있다. 반대로 signaling은 붙어 있는데 ICE connection은 failed일 수 있다. 이 둘을 같은 연결 표시로 보여주면 사용자는 잘못된 판단을 한다.
PonsLink room은 “서버와 연결됨”보다 “상대와 세션이 유지됨”을 더 중요하게 보여줘야 했다. 다시 설계한 방식은 signaling을 상태 머신으로 보는 것이었다. idle에서 joining으로 가고, joining에서 negotiating으로 가며, remote description과 local description이 정리되면 connected로 간다. 문제가 생기면 failed로 바로 떨어뜨리는 대신 recovering을 둔다. recovering에서는 signaling 재연결, ICE restart, peer cleanup, UI 안내가 서로 다른 조건으로 실행된다. 사용자에게는 “연결을 다시 확인하고 있어요”처럼 보이지만 내부적으로는 꽤 엄격한 분기다.
신호 경계 나누기
*idle, joining, negotiating, connected, recovering 같은 명시적 상태가 있어야 링크 뒤의 불확실성을 설명할 수 있다. 첫 번째로 깨진 것은 이벤트 순서였다. ICE candidate를 받았지만 아직 remote description이 없으면 바로 addIceCandidate를 호출할 수 없다. 이때 candidate를 버리면 연결이 실패하고, 무작정 쌓으면 오래된 peer의 candidate가 섞일 수 있다. 그래서 candidate queue와 peer generation 같은 개념이 필요해진다. 작은 방이라도 상태를 대충 다루면 사용자는 “상대가 안 보인다”는 하나의 실패로 경험한다. 지금 돌아보면 signaling을 너무 늦게 제품 언어로 번역했다. 처음에는 기술 이벤트로 다뤘고, 나중에야 사용자가 보는 상태로 바꿨다. 다음에 다시 만든다면 signaling state와 사용자 메시지를 같은 표에서 설계할 것이다. 예를 들어 joining은 “방 정보를 확인하는 중”, negotiating은 “상대와 연결을 맞추는 중”, recovering은 “연결을 복구하는 중”처럼 매핑한다. 그리고 각 상태에서 사용자가 할 수 있는 행동과 운영자가 볼 수 있는 로그를 함께 정한다. *하나의 초대 링크 뒤에는 SDP 교환, signaling, ICE check, media/data channel이 순서대로 숨어 있다.
구현·운영에 남긴 조건
이 표를 먼저 만들면 예외 처리가 부끄럽지 않다. glare, reconnect, tab sleep, duplicate message, stale peer 같은 경우를 문서의 끝에 숨기지 않고 처음부터 제품 상태로 인정하게 된다. 사용자는 여전히 링크 하나만 보지만, 제품은 그 링크 뒤에서 어떤 신호를 버리고 어떤 신호를 기다릴지 더 분명하게 판단할 수 있다. 사용자는 같은 링크를 다시 눌렀다고 생각하지만 제품 안에서는 이전 peer와 새 peer를 구분해야 한다.
roomId만으로 부족하고 peer identity, session token, generation 같은 값이 필요하다. 이 식별자가 느슨하면 오래된 이벤트가 새 연결에 섞인다. 링크가 단순해 보일수록 내부 식별자는 더 엄격해야 했다. [의 세부 기록이다. WebRTC 개념 자체는 [에서 더 넓게 다뤘다. 링크가 쉬워 보이는 제품일수록, 뒤쪽 신호는 더 엄격하게 다뤄야 한다. 두 번째는 동시 offer였다. 두 peer가 모두 자신이 먼저라고 생각하면 negotiation glare가 생긴다.
이 문제는 문서에서는 예외처럼 보이지만 실제 제품에서는 생각보다 자주 만난다. 새로고침, reconnect, host 이동, 모바일 sleep/wake가 겹치면 충분히 발생한다. 그래서 누가 polite peer인지, 어떤 쪽이 offer를 rollback할지, 어떤 상태에서는 새 negotiation을 막을지 결정해야 했다. PonsLink에서 이 문제는 새로고침 때 자주 보였다. 화면은 같은 방처럼 보이지만 내부 연결은 새로 시작된다. 이전 offer가 늦게 도착하거나 예전 ICE candidate가 다시 적용되면 사용자는 아무것도 하지 않았는데 방이 이상해진다.
그래서 링크의 단순함은 URL을 짧게 만드는 일이 아니라, 뒤쪽 식별자를 충분히 촘촘하게 잡는 일에 가깝다. '[PonsLink 링크는 단순했지만, 뒤에서는 신호가 계속 엉켰다'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다.
마치며
문제는 기술 용어가 많다는 것이 아니었다. 기술 용어가 사용자 경험으로 새어 나온다는 것이 문제였다. “remote description이 아직 없습니다”, “ICE candidate가 먼저 도착했습니다”, “signaling socket이 끊겼습니다” 같은 내부 상태가 그대로 노출되면 사용자는 제품을 믿기 어렵다. 반대로 모든 실패를 “연결 실패”로 뭉개면 운영자도 원인을 찾을 수 없다. 제품은 내부 상태를 충분히 구분하되, 사용자에게는 행동 가능한 말로 바꿔야 했다.
*live chat 자산은 signaling과 사용자 메시지가 모두 실시간 흐름이지만 서로 다른 신뢰 경계를 가진다는 점을 떠올리게 한다. 그런데 실제 방은 직선이 아니었다. 사용자는 새로고침한다. 모바일은 권한 허용에서 멈춘다. 네트워크는 잠깐 끊긴다. 두 브라우저가 거의 동시에 offer를 만들 수도 있다. ICE candidate는 remote description보다 먼저 도착할 수 있다. 같은 peer가 나갔다가 다시 들어오면 이전 연결의 잔여 이벤트가 늦게 도착할 수 있다.
signaling은 전달 레이어가 아니라 시간과 순서를 다루는 제품 레이어였다. MDN의 WebRTC protocol 문서는 ICE, STUN, TURN, SDP를 분리해서 설명한다. WebRTC.org의 peer connection guide도 signaling은 표준이 아니라 애플리케이션이 직접 정한다고 말한다. 이 말은 PonsLink 입장에서 부담이었다. 브라우저 API는 제공되지만, 어떤 메시지를 언제 보내고 어떻게 재시도하며 어떤 UI 상태로 보여줄지는 내가 정해야 했다. 또한 signaling message에는 roomId뿐 아니라 peer identity와 세대 개념이 필요했다.
같은 사람이 다시 들어오더라도 이전 연결의 이벤트가 새 연결에 섞이면 안 된다. feature grants와 session token도 같은 이유로 중요하다. URL이 같아도 세션 권한은 새로 확인해야 하고, 방이 닫혔다면 signaling이 살아 있어도 기능을 열면 안 된다. 링크의 단순함은 내부 식별자가 충분히 정확할 때만 유지된다. 이 queue를 느슨하게 다루면 실패가 늦게 드러난다. candidate를 버렸는지, 너무 오래된 candidate를 적용했는지, peer가 이미 바뀌었는지를 모른 채 “연결 실패”만 남는다.
반대로 queue의 수명과 소유자를 명확히 두면 사용자에게도 더 정확하게 말할 수 있다. “상대 연결 정보를 다시 맞추는 중”이라는 문장은 이런 내부 구분이 있어야 나온다.