영상통화는 어떻게 NAT를 뚫는가

NAT 뒤에 있는 기기는 외부에서 걸어오는 연결을 받을 수 없다. 그게 NAT의 정의 자체다. 그런데도 매일 수십억 건의 영상통화가 그런 기기 둘을 직접 연결한다. 비결은 홀 펀칭이다. 양쪽이 같은 순간 패킷을 쏴서 서로가 지나갈 구멍을 뚫는다. 그 메커니즘과, 이걸 무력화하는 한 가지 NAT 유형과, 실패했을 때 임대료를 내는 릴레이 이야기.

NAT 뒤에 있는 기기는 외부에서 걸어오는 연결을 받을 수 없다. 라우터 설정에서 손보면 되는 오류가 아니라, NAT라는 물건의 정의가 원래 그렇다. NAT는 내 기기들이 밖으로 시작한 연결을 테이블로 기억해 두고, 그 테이블을 보고 응답을 올바른 연결로 되돌려 보낸다. 아무 예고 없이 도착한, 테이블 어느 행과도 안 맞는 패킷은 갈 곳이 없다. 그냥 버려진다. 지금 이 순간 당신 노트북이 포트 스캔에 난도질당하지 않는 이유가 이것이고, 동시에 이건 서로 다른 NAT 뒤에 있는 노트북 두 대가 직접 대화할 수 없어야 한다는 뜻이기도 하다 — 서류상으로는.

그런데 지금 이 순간에도 수백만 건의 영상통화가, NAT 뒤에 있는 그런 기기 둘 사이에서 음성과 영상을 직접 주고받으며 돌아가고 있다. 중간에서 바이트 하나 전달하는 서버 없이. 위의 규칙대로라면 양쪽 다 도달 불가능한 상대다. 그런데도 대화가 된다. 이걸 해내는 방식은 현대 인터넷의 위대한 실전 해킹 중 하나이고, 한 번 이해하고 나면 앞으로 거는 모든 통화에서 이게 보이기 시작한다.

핵심 수법: 패킷 두 개, 같은 순간

수법은 이렇다. 무례할 만큼 단순하다. 두 peer를 A와 B라고 하자. 둘 다 예고 없는 패킷을 받을 수 없다. 하지만 둘 다 보낼 수는 있다.

A가 B의 공인 주소로 UDP 패킷을 하나 쏘면, A의 NAT는 늘 하던 대로 테이블에 항목을 하나 만든다 — “내가 B한테 뭘 보냈으니, B가 보내는 답장은 받아주겠다” — 그리고 패킷을 내보낸다. 패킷은 B의 NAT에 닿지만 매칭되는 항목이 없어 버려진다. 헛수고다. 그런데 완전한 헛수고는 아니다. A 쪽에 구멍 하나가 열렸기 때문이다. A의 NAT는 이제 잠깐 동안 B로부터 오는 패킷을 들여보낼 준비가 됐다.

이제 B에서도 똑같이, 동시에 한다. B가 A를 향해 패킷을 쏜다. B의 NAT도 자기 구멍을 연다. 그리고 이번엔 B의 패킷이 A의 NAT에 도착했을 때 매칭되는 항목이 있다 — A가 아까 쏜 패킷이 만든 그 항목이다. 통과한다. A의 답장도 마찬가지로 B가 방금 연 구멍을 찾아 들어간다. 한두 왕복이면 양쪽 NAT 모두 서로를 가리키는 항목을 갖게 되고, 각자로는 절대 연결될 수 없는 기기 둘 사이에 양방향 UDP 경로가 생긴다.

이게 UDP 홀 펀칭이다. 두 끝점은 상대를 위해 구멍을 열어주는 게 아니다. 밖으로 손을 뻗음으로써 구멍을 열고, 상대가 동시에 뻗은 손이 그 구멍을 딱 제때 쓴다. 2005년 USENIX 논문에서 Bryan Ford, Pyda Srisuresh, Dan Kegel이 이걸 문서화하고 실측했다 — “Peer-to-Peer Communication Across Network Address Translators.” 실제로 깔려 있는 가정용 라우터 한 무더기를 상대로 시험해서 UDP 기준 약 82%가 이걸 지원한다는 걸 찾아냈다. 실험실 진기명기가 아니라 WebRTC, 대부분의 VoIP, 상당수 온라인 게임을 떠받치는 메커니즘이다.

그런데 위 설명을 다시 읽으면 슬쩍 넘어간 문제가 보인다. A가 첫 패킷을 B의 공인 주소로 쏴야 한다. A는 B의 공인 주소를 어떻게 아나? B는 NAT 뒤에 있다. B는 자기 공인 주소를 모른다. A도 모른다.

왜 내 주소를 남이 알려줘야 하나

처음 듣는 사람은 이 대목을 정말 이상하게 느낀다. NAT 뒤의 기기는 외부 세계가 자기를 어떤 주소로 보는지 모른다. 노트북은 자기 사설 주소 192.168.x.x는 알지만, 그건 집 밖 누구에게도 쓸모없다. 공인 IP와 — 결정적으로 — 이 특정 흐름에 NAT가 배정한 그 포트는 NAT가 즉석에서 정하고, 안쪽으로는 절대 알려주지 않는다.

그래서 물어본다. 공인 인터넷에 있는, 유일한 임무가 “네 패킷이 어디서 온 것처럼 보이는지 읽어서 그 주소를 되돌려주는 것”인 서버로 패킷을 보낸다. 그게 STUN 서버다 — Session Traversal Utilities for NAT, 현재 RFC 8489, 원래는 2003년 RFC 3489. STUN Binding 요청을 보내면 “나는 너를 203.0.113.7:54321로 본다”고 답한다. NAT가 우연히 배정한 그 공인 IP와 포트, 즉 반사된 주소가 당신의 server-reflexive candidate이고, 이게 바로 상대 peer에게 넘겨주는 “여기로 조준하라”는 값이다.

STUN은 일부러 아주 작다. 통화를 릴레이하지 않고, 미디어를 건드리지 않고, 그냥 거울을 들어줄 뿐이다. STUN 교환은 패킷 몇 개면 끝나고 이후엔 완전히 빠진다. 그래서 공개 STUN 서버가 거의 공짜로 어마어마한 인구를 감당한다. 양쪽 peer가 각자 이걸 하고, 통화를 붙여준 시그널링 채널(웹서버, 앱 백엔드 — 이미 연결된 어떤 경로)로 서로의 반사 주소를 교환하면, 이제 각자 어디로 구멍을 뚫을지 안다. 앞 절의 홀 펀칭이 시작될 수 있다.

거울이 거짓말만 안 했다면 말이다. 그런데 NAT의 한 부류에 대해서는, 거울이 거짓말을 한다.

뚫리기를 거부하는 NAT

“STUN한테 내 공인 주소를 물어본다”에 깔린 달콤한 가정은, 당신에게 공인 주소가 하나 있다는 것이다 — 외부 세계가 당신을 보는, 안정된 IP:포트 하나. 대부분의 가정용 라우터라면 대충 맞다. 하지만 무시 못 할 소수에 대해서는, 모든 걸 망가뜨리는 방식으로 틀렸다.

초창기 STUN 명세는 NAT를 네 종류로 분류하려 했다 — full cone, restricted cone, port-restricted cone, symmetric. 이 용어는 수년간 혼란을 낳았고 결국 폐기됐다. RFC 4787이 이걸 동작(behavior) 중심 언어로 갈아치웠다. 실제 NAT들이 그 깔끔한 네 상자에 안 들어맞았기 때문이다. 하지만 그 옛 분류에서 한 가지 구분만은 살아남았다. 통화가 연결되느냐 마느냐를 실제로 가르는 게 바로 그거라서다: NAT가 상대가 누구든 같은 외부 포트를 배정하나, 아니면 목적지마다 다른 포트를 배정하나?

좋은 경우가 새 용어로 endpoint-independent mapping이다. STUN에 보내면 포트 54321을 받는다. peer에게 보내도 여전히 54321로 나간다. 그러니 STUN이 반사해준 주소가 peer가 보게 될 주소와 같다. 거울이 진실을 말했다.

악몽 같은 경우가 옛 명세로 symmetric NAT, 지금 용어로 address-and-port-dependent mapping이다. 여기서 NAT는 목적지가 다를 때마다 새 외부 포트를 배정한다. STUN과 통신할 때 54321을 받았다. peer와 통신하면 61000으로 나간다. STUN이 건네준 반사 주소 54321은 이제 거짓말이다 — 그건 STUN 서버와 대화할 때만 유효했다. peer가 54321을 조준하면 NAT엔 그 항목이 없고, 펀치는 벽을 친다. 다 제대로 했는데 안 된다. 당신이 광고한 주소가 딱 한 대화에만 참이었고, 지금 당신은 다른 대화를 하고 있기 때문이다.

symmetric NAT가 NAT 통과가 결코 “해결”될 수 없는 이유다. symmetric NAT 둘이 마주 보면 일반적으로 홀 펀칭이 아예 불가능하다 — 예측할 포트가 없다. 중요한 그 포트는 그게 속한 흐름이 만들어지기 전엔 존재하지 않는데, 그 흐름을 만드는 것이야말로 지금 하려는 일이기 때문이다. 이동통신사가 돌리는 캐리어급 NAT(CGNAT)는 종종 symmetric이다. 상당수 기업 장비도 그렇다. 통화가 “릴레이로 폴백됐다”고 할 때, 거의 항상 이게 이유다.

펀칭이 실패하면, 임대료를 낸다

끝점끼리 서로 못 닿으면 정직한 선택지는 딱 하나 남는다: 양쪽 다 닿을 수 있는 기계를 중간에 두고 — 양쪽 다 밖으로 나가는 연결은 되니까 — 모든 걸 전달하게 하는 것. 그 기계가 TURN을 돌린다 — Traversal Using Relays around NAT, RFC 8656.

각 peer가 TURN 서버로 밖으로 나가는 연결을 열고(밖으로 나가는 건 늘 되고, NAT가 절대 안 막는 유일한 방향이다), 릴레이 주소를 하나 할당해 달라고 한다. 이제 TURN 서버는 각 peer를 대신하는 공인 주소를 갖는다. A가 릴레이로 미디어를 보내면 릴레이가 B로 전달하고, B가 릴레이로 보내면 릴레이가 A로 전달한다. 두 끝점은 서로 절대 안 닿는다. 모든 편지를 읽는 우체국을 통해 편지를 주고받는 펜팔이다.

TURN은 사실상 항상 된다. 그래서 실서비스 WebRTC 배포는 이걸 있으면 좋은 게 아니라 필수 인프라로 취급한다. 동시에 아무도 쓰고 싶어 하지 않는 선택지이기도 하다. 이유는 청구서에 찍힌다. 직접 뚫은 통화는 운영자에게 비용이 0이다 — 바이트가 peer에서 peer로 간다. 릴레이된 통화는 영상의 모든 프레임을 양방향으로 당신의 서버, 당신의 대역폭으로 흘려보낸다. 규모가 커지면 TURN으로 폴백되는 통화 비율은 비용 항목이 되고, “그냥 WebRTC 써, P2P잖아”라는 말을 인프라 엔지니어가 웃어넘기게 만드는 바로 그 항목이다. 안 될 때까지는 P2P다. 그리고 얼마나 자주 안 되는지는 당신이 통제할 수도, 볼 수도 없는 NAT 유형에 달렸다.

ICE: 다 시도하고, 아무것도 믿지 마라

마지막 조각이 이 난장판을 하나로 묶는다. 실제로는 어느 경로가 될지 미리 알 수 없기 때문이다. 로컬 네트워크의 직접 경로일 수도, 공인 인터넷을 가로지른 홀 펀칭 경로일 수도, 릴레이가 필요한 symmetric NAT 두 개의 절망적 조합일 수도 있다 — 그리고 들여다본다고 알 수 없다. 그래서 영리하게 굴려 하지 않는다. 모든 선택지를 모아서 경주를 붙인다.

그게 ICE다 — Interactive Connectivity Establishment, RFC 8445. 각 peer가 candidate를 모은다: 로컬 주소(host), STUN이 반사해준 공인 주소(server-reflexive), TURN 릴레이 주소(relay), 각각 여러 개일 수도 있다. 이 목록 전체를 상대에게 보낸다. 그다음 양쪽 peer가 연결 검사를 체계적으로 쏜다 — 이번엔 서로에게 보내는 STUN 패킷이다 — “내 candidate” × “네 candidate”의 모든 조합에 대해, 어느 쌍이 실제로 응답을 받는지 본다. 직접 경로가 있으면 이기고, 아무것도 없으면 릴레이가 이긴다. ICE가 되는 것 중 가장 좋은 쌍을 골라 통화를 태우고, 우승자가 죽을 경우를 대비해 나머지도 덥혀 둔다.

무식한 완력이다. 그리고 이게 옳은 설계다. 네트워크가 자기 자신에 대해 진실을 말해주지 않기 때문이다. NAT에게 “너 무슨 종류냐” 물어서 그 답을 믿을 수는 없다. 오직 패킷을 보내보고 뭐가 돌아오는지 볼 수 있을 뿐이다. ICE는 그 겸손을 제도화한 것이다. 당신이 거는 모든 영상통화가 조용히 이 퍼레이드를 돌린다 — 작은 탐침 수십 개가 어둠 속으로 발사되는 — “연결 중…”과 상대의 얼굴 사이의 그 1초 동안.

이 이야기가 진짜 말하는 것

한 발 물러서면 이 전체 장치는 어딘가 우스꽝스럽다. STUN은 기계가 자기 주소를 알기 위해 존재한다. 홀 펀칭은 기계 둘이 각자의 문지기를 속여 협조시키기 위해 존재한다. TURN은 그 속임수가 실패한 경우의 비용을 대기 위해 존재한다. ICE는 이 모두를 경마처럼 돌리기 위해 존재한다. 어느 하나도 혼자 믿을 만큼 안정적이지 않아서다. 네트워크가 해야 할 가장 기본적인 일 — 컴퓨터 둘이 서로 패킷을 보내게 하는 것 — 하나를 해내려고 이렇게 많은 프로토콜이 필요하다.

이 모두를 지은 이유는, NAT가 다른 모든 게 딛고 서 있던 가정을 조용히 삭제했기 때문이다 — 어떤 호스트든 어떤 호스트에 닿을 수 있다는 가정. 그 삭제가 NAT의 진짜 대가였다. 라우터에서가 아니라 바로 여기서, 모든 실시간 앱이 짊어져야 하는 통과 기계장치라는 영구 세금으로 치러진다. 이 중 어느 것도 WebRTC 데모엔 안 나온다. 데모는 상자 둘과 화살표 하나를 보여준다. 그 화살표는 거짓말이거나, 적어도 희망이다 — 그 뒤엔 STUN, TURN, ICE, 그리고 대개는, 협조하도록 설계된 적 없는 NAT들을 상대로 두 패킷이 같은 순간에 도착하고 구멍이 버텨준다는 작은 매일의 기적이 있다.

토론 참여

← 블로그로 돌아가기