[P2P 딥다이브] 클라이언트끼리 직접 연결한다는 말의 정확한 의미
직접 연결이라는 말은 보기보다 위험하게 단순하다. 처음에는 두 브라우저가 서로의 IP를 알고 곧장 소켓을 여는 장면을 떠올렸다. 같은 공유기 안에 있으면 맞는 그림처럼 보이기도 한다. 하지만 WebRTC를 따라가다 보면 직접 연결은 “주소를 안다”가 아니라 “여러 후보 경로를 모으고 시험한 뒤 실제 패킷이 오갈 수 있는 길을 고른다”에 가깝다. 같은 입력인데 어떤 환경에서만 P2P가 도드라졌다.

"직접 연결"이 가린 실제 문제
직접 연결은 같은 공유기 안의 짧은 데모보다 넓은 말이다. 후보를 모으고, 시험하고, 실패하면 다른 길을 찾고, 사용자가 이해할 수 있는 상태로 바꾸는 전체 흐름이다. 이 흐름을 알고 나면 “클라이언트끼리 직접 연결한다”는 문장은 더 이상 낭만적인 한 줄이 아니다. 어느 서버가 약속을 잡고, 어느 경로가 바이트를 나르며, 어느 순간부터 우회가 필요한지 확인하는 작업이 된다.
*겉으로는 같은 지연이어도 후보 수집, 경로 선택, 전송 압력은 서로 다른 문제다. 문구는 기술을 숨기는 것이 아니라 번역하는 일이다. “ICE failed”를 그대로 보여 주면 정확하지도 친절하지도 않다. “직접 경로가 막혀 우회 경로를 찾고 있어요”는 사용자가 기다릴 이유를 준다. “상대 네트워크에서 직접 연결을 허용하지 않아 서버 경유로 전환했어요”는 비용과 속도 변화를 설명한다.
이런 문장이 있으려면 내부 상태가 충분히 작게 나뉘어 있어야 한다. 직접 연결을 검증하는 작은 시나리오도 정해 둘 수 있다. 같은 와이파이에서 host 후보가 선택되는지 본다. 다른 네트워크에서 srflx가 선택되는지 본다. UDP가 막힌 환경에서 relay로 넘어가는지 본다. 전송 중 네트워크를 바꿨을 때 재시도나 안내가 나오는지 본다. 수신 속도를 일부러 늦췄을 때 backpressure가 동작하는지 본다.
이 다섯 가지를 통과하면 데모보다 훨씬 현실에 가까워진다.

처음에는 케이블처럼 붙는 줄 알았다
문서에는 TURN을 부끄러운 fallback처럼 쓰지 않는 편이 좋다. TURN은 비용이 들고 지연이 늘 수 있지만 사용자가 작업을 끝내게 해 주는 안전망이다. 직접 경로 성공률을 높이는 노력과 TURN 경로를 안정적으로 운영하는 노력은 같이 필요하다. 순수한 P2P만 고집하면 네트워크가 까다로운 사용자를 제품 밖으로 밀어내게 된다. 짧게 보태면, 직접 연결은 상태를 잘게 나눌수록 사용자에게 더 친절해진다.
내가 남겨 둘 점검 항목은 단순하다. selected candidate pair를 기록하는가. relay 사용률을 비용 지표로 보는가. 직접 경로 실패 시 화면이 멈추지 않는가. 데이터 경로와 제어 경로를 로그에서 구분하는가. 여기에 candidate 조기 도착 처리와 재접속 시 이전 peer 정리까지 붙이면 제품에서 겪는 대부분의 혼란을 줄일 수 있다. 직접 연결을 제품에 넣는다면 상태 머신을 먼저 적는 편이 낫다.
방 입장, 권한 확인, offer 생성, answer 수신, candidate 교환, ICE checking, connected, relay fallback, disconnected, failed를 화면 문구와 로그 이름에 연결한다. 개발자용 로그에는 candidate type과 pair, RTT, bytesSent 같은 값이 필요하고, 사용자용 문구에는 기다릴지 다시 시도할지 판단할 말이 필요하다. 둘은 표현만 다를 뿐 같은 사건을 가리켜야 한다. 보안 측면에서는 candidate 정보가 사용자의 네트워크 힌트를 담을 수 있다는 점도 기억해야 한다. 로컬 주소 노출은 브라우저 정책으로 많이 줄었지만, 연결 후보와 relay 사용 여부는 여전히 환경에 관한 정보를 준다.
로그를 남길 때도 원인 분석에 필요한 값과 굳이 오래 보관할 필요가 없는 값을 나눠야 한다. 디버깅 편의 때문에 모든 원시 정보를 오래 들고 있는 것은 좋은 습관이 아니다.

연결에 들어오는 구성요소
직접 연결을 디버깅할 때 브라우저 개발자 도구의 WebRTC internals 같은 화면을 자주 보게 된다. 여기서 중요한 값은 단순히 connected 여부가 아니다. local candidate와 remote candidate의 type, selected pair, bytesSent와 bytesReceived, roundTripTime, availableOutgoingBitrate 같은 값이 같이 움직인다. 숫자를 한 번만 찍어 보는 것보다 상태가 바뀌는 순간을 시간순으로 보는 편이 좋다. 사용자가 느낀 지연은 대개 어느 한 값이 아니라 여러 단계의 누적으로 생긴다. 구현할 때는 연결 전과 연결 후를 나눠서 테스트 이름을 붙이는 편이 좋다.
연결 전에는 offer/answer 순서, candidate 버퍼링, 권한 확인, peer ID 정리가 핵심이다. 연결 후에는 DataChannel open 이후의 흐름 제어, 파일 무결성, 중단 복구, 브라우저 탭 상태가 중요하다. 직접 연결이라는 한 단어 안에 이 두 단계가 섞여 있지만 실패 원인은 다르다. 테스트 이름을 나누면 로그 이름도 같이 선명해진다. 직접 연결을 말할 때 “서버를 거치지 않는다”는 표현은 반만 맞다.
애플리케이션 서버가 payload를 들고 있지 않을 수는 있지만, STUN이나 TURN 같은 인프라와 signaling 서버는 여전히 관여한다. 사용자는 이 차이를 알 필요가 없을 수 있지만 개발자는 반드시 알아야 한다. 어느 서버가 주소를 알려 주고, 어느 서버가 relay가 되고, 어느 서버가 권한을 확인하는지 나눠야 비용과 장애 원인을 제대로 볼 수 있다. 직접 연결은 성공했을 때보다 애매하게 느릴 때 더 어려웠다.
failed가 뜨면 fallback을 타면 된다. connected인데 bytes가 거의 움직이지 않거나 bufferedAmount가 계속 커지는 상황은 더 까다롭다. 이때는 연결 상태와 전송 상태를 따로 봐야 한다. 경로는 열렸지만 수신자가 따라오지 못할 수 있고, 브라우저가 백그라운드에서 느려졌을 수도 있다. 그래서 제품 상태도 “연결됨” 하나로 끝내지 말고 “전송 안정화 중”, “상대 수신 속도 대기” 같은 하위 상태를 둘 필요가 있다.
결론을 짧게 쓰면, 직접 연결은 서버를 지우는 기술이 아니라 데이터 경로를 고르는 절차다. 이 절차가 제품에 들어오려면 signaling, candidate 처리, ICE 상태, relay 비용, 전송 압력, 사용자 문구가 하나의 흐름으로 이어져야 한다. 용어보다 책임, 성공보다 실패, 구조보다 사용자가 보는 상태를 먼저 확인하는 습관이 남는다. P2P를 설명할 때 서버/클라이언트/사용자 중 누구 문장으로 말하는지 먼저 고르면 논쟁이 줄었다.
직접 연결이 성립하는 조건
직접 연결을 사용자에게 설명할 때는 ‘가까운 길을 먼저 찾는다’ 정도의 비유가 유용했다. 가까운 길이 막히면 돌아가는 길을 쓰고, 돌아가는 길은 조금 느리거나 비용이 들 수 있다. 다만 이 비유가 기술을 왜곡하면 안 된다. 같은 지역에 있어도 NAT 정책 때문에 직접 경로가 막힐 수 있고, 멀리 있어도 조건이 맞으면 잘 연결될 수 있다. 물리적 거리보다 네트워크 정책과 경로 검사가 중요하다. 직접 연결은 한 번에 열리는 선이 아니라 후보를 모으고 검증해 고른 결과다.
이 차이를 놓치면 signaling 서버가 있느냐 없느냐, TURN을 쓰면 P2P가 아니냐 같은 질문에서 계속 헛돈다. signaling은 약속을 교환하는 통로이고, ICE는 후보 경로를 시험하는 절차이며, selected candidate pair는 결국 선택된 길이다. 애플리케이션 서버가 파일 payload를 직접 나르지 않아도 signaling 서버는 필요할 수 있다. TURN이 선택되면 순수한 직접 경로는 아니지만 WebRTC 연결 모델 안의 현실적인 우회로다. host 후보는 로컬 네트워크 안에서 보이는 주소다. srflx는 STUN을 통해 바깥에서 보이는 주소를 알아낸 결과다. relay는 TURN 서버를 거치는 경로다. 이름만 보면 host나 srflx가 더 “진짜 직접 연결”처럼 느껴지지만 제품 입장에서는 사용자가 일을 끝낼 수 있는지도 중요하다. relay로 갔다고 무조건 실패라고 표시하면 사용자는 멀쩡히 진행 가능한 경로를 오류로 이해한다. 반대로 relay 비율을 비용 지표로 보지 않으면 운영비가 뒤늦게 튄다.
연결이 열리지 않는 경우
*host, srflx, relay는 성공과 실패의 라벨이 아니라 경로의 성격을 알려 주는 값이다. DataChannel 파일 전송에서는 연결이 된 뒤가 더 중요할 때도 많다. 채널이 open 상태가 되어도 받는 쪽이 파일을 저장하는 속도를 따라가지 못하면 bufferedAmount가 커진다. 보내는 쪽은 이 값을 보고 속도를 조절해야 한다. 조절하지 않으면 브라우저 메모리가 커지고, 결국 연결이 끊기거나 탭이 버벅인다.
직접 연결 공부가 ICE에서 끝나면 실제 파일 전송의 절반을 놓친다. 결국 직접 연결은 결과보다 과정의 이름이다. 후보를 수집하고, 서로 교환하고, 순서에 맞게 적용하고, 실제로 패킷이 오가는지 시험하고, 막히면 우회한다. 이 과정을 로그와 화면에 반영하면 사용자는 덜 답답하고 운영자는 덜 막힌다. 단어 하나를 정확히 이해하는 일처럼 보였지만, 실제로는 제품의 연결 상태 전체를 설계하는 일에 가까웠다.
이 정도로 나눠 놓으면 직접 연결을 과장하지도 깎아내리지도 않게 된다. 열리면 좋은 경로이고, 막히면 다른 경로가 필요하다. 중요한 것은 브라우저가 실제로 고른 길을 기록하고, 사용자가 그 길의 변화를 이해하게 하는 일이다. 직접 연결은 보이지 않는 네트워크 선택을 제품 언어로 바꾸는 작업까지 포함한다. 앞선 P2P 노트가 서버의 책임을 남겨 두는 법을 다뤘다면, 이 노트는 데이터 경로가 실제로 어떻게 골라지는지에 초점을 둔다.
다음에는 P2P가 강한 경우와 약한 경우를 따로 봐야 한다. 직접 연결이 가능하다는 사실만으로 좋은 제품 판단이 나오지는 않기 때문이다. '[P2P 딥다이브 클라이언트끼리 직접 연결한다는 말의 정확한 의미'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다.
직접 연결을 검토할 때는 “서버를 안 거친다”는 문장을 그대로 믿지 않는다. signaling은 이미 서버를 지나가고, TURN으로 바뀌는 순간 payload도 서버를 지난다. 그래서 사용자 안내에는 직접 연결 시도, 우회 경로 전환, 실패 종료를 다른 상태로 남겨야 한다. 같은 전송 실패라도 어디까지 직접이었는지 알아야 다음 대응이 가능하다. ICE restart도 미리 생각해 둘 만하다.
네트워크가 바뀌거나 모바일 사용자가 와이파이에서 LTE로 넘어가면 기존 경로가 더 이상 맞지 않을 수 있다. 이때 연결을 완전히 끊고 다시 초대하게 만들 것인지, 내부적으로 경로를 다시 찾을 것인지에 따라 사용자 경험이 달라진다. 직접 연결은 한 번 성공하면 끝나는 상태가 아니라 세션 동안 계속 관리해야 하는 경로다. 작게 실험할 때는 먼저 candidate 목록을 그대로 본다.
후보가 언제 모였는지, 어떤 후보끼리 pair가 되었는지, nominated된 pair가 무엇인지 기록한다. 그 다음 네트워크 조건을 바꾼다. 같은 와이파이, 다른 와이파이, 회사 VPN, 모바일 핫스팟, UDP가 막힌 환경을 나눠 보면 같은 코드도 다른 경로를 고른다. 이때 화면 문구가 모두 “연결 중”이면 공부한 내용이 제품에 반영되지 않은 것이다. 실패도 이름을 나눠야 한다. host 후보만 보고 성공으로 착각하는 경우가 있고, candidate가 remote description보다 먼저 도착해 버퍼링이 필요한 경우도 있다.
ICE checking에서 오래 머무르거나 failed로 떨어질 수 있고, connected 뒤에도 데이터 채널 bufferedAmount가 줄지 않아 사실상 전송이 멈춘 것처럼 보일 수 있다. 사용자는 이 모든 상황을 “느리다”로 말하지만 로그는 더 구체적이어야 한다. 연결 상태도 브라우저가 주는 단어를 그대로 제품 상태로 쓰면 부족하다. new, checking, connected, disconnected, failed, closed는 개발자에게 필요한 상태다. 사용자에게는 “상대와 연결 정보를 맞추는 중”, “직접 경로를 확인하는 중”, “우회 경로로 바꾸는 중”, “상대 연결이 끊겨 다시 기다리는 중”처럼 행동에 가까운 문장이 필요하다.
내부 상태와 외부 문구를 매핑해 두면 고객 지원도 같은 언어를 쓸 수 있다. 사용자에게 보여 줄 정보의 양도 조절해야 한다. candidate type을 그대로 보여 줄 필요는 없다. 그러나 직접 경로인지 우회 경로인지, 우회 경로라서 속도가 달라질 수 있는지 정도는 알려 주는 편이 낫다. 특히 대용량 파일에서는 사용자가 기다릴지 다른 방법을 쓸지 판단해야 한다.
연결 기술을 숨기는 것이 친절함은 아니다. 사용자가 행동을 정할 수 있을 만큼만 번역해서 보여 주는 것이 더 친절하다.
직접 연결에서 가장 자주 생기는 오해는 세 가지였다. 첫째, 공개 IP를 알면 바로 연결된다는 생각이다. NAT와 방화벽, UDP 차단은 그렇게 친절하지 않다. 둘째, signaling 서버가 있으면 직접 연결이 아니라는 생각이다. 약속 교환과 payload 전송은 다른 층이다. 셋째, connected가 되면 제품 문제가 끝난다는 생각이다. 연결 뒤에도 수신 속도, 버퍼 증가, 탭 상태, 파일 저장 속도가 전송 품질을 흔든다. 숫자로 볼 때도 평균보다 꼬리 구간이 중요하다. 1GB 파일을 세 번 보낼 때 직접 경로가 모두 열리면 서버 signaling 트래픽은 작고 데이터는 피어 업로드와 다운로드로 계산된다. 하지만 일부 사용자가 relay로 빠지면 비용 구조가 달라진다. 연결 시간 p95가 길면 성공률이 높아도 사용자는 답답해한다. 재시도 후 성공률과 중단 후 복구 가능성을 같이 봐야 “직접 연결을 지원한다”는 문장이 제품 약속이 된다. 직접 연결을 다시 볼 때는 선택된 경로와 사용자 문구가 함께 남아 있는지 확인한다.
PonsWarp의 Direct 모드를 떠올리면 이 구분이 더 분명해진다. 파일이 서버에 저장되지 않는 경험은 직접 경로가 열렸을 때 가장 잘 맞는다. 하지만 모든 사용자의 네트워크가 그 경로를 허락하지 않는다. 그래서 Cloud Drop 같은 보완 경로가 같이 있어야 했고, 사용자는 지금 직접 전송인지 우회 전송인지 이해할 수 있어야 했다. 직접 연결은 마케팅 문장이 아니라 경로 선택과 fallback 정책을 포함한 설계다.
마치며
이 글에서 확인한 경계는 다음 변경의 기준으로 남겼다.