음성 AI는 대화가 말하는 속도에 맞춰 오갈 때만 자연스럽게 느껴집니다. 네트워크가 이를 방해하면 사람들은 어색한 침묵, 중간에 잘리는 끼어들기, 지연된 발화 개시로 즉시 이를 체감합니다. 이는 ChatGPT 음성 대화, Realtime API로 개발하는 개발자, 대화형 워크플로에서 작동하는 에이전트, 그리고 사용자가 아직 말하는 동안 오디오를 처리해야 하는 모델 모두에 중요합니다.
OpenAI 규모에서는 이것이 다음의 세 가지 구체적 요구 사항으로 이어집니다.
- 주간 활성 사용자 9억 명 이상을 위한 글로벌 도달 범위
- 세션이 시작되자마자 사용자가 바로 말할 수 있도록 빠른 연결 설정
- 턴 전환이 또렷하게 느껴지도록 낮고 안정적인 미디어 왕복 시간, 낮은 지터, 낮은 패킷 손실
실시간 AI 상호작용을 담당하는 OpenAI 팀은 최근, 규모가 커지며 서로 충돌하기 시작한 세 가지 제약을 해결하기 위해 WebRTC 스택을 재설계했습니다. 세션당 하나의 포트로 미디어를 종료하는 방식은 OpenAI 인프라에 잘 맞지 않았고, 상태를 가지는 ICE(Interactive Connectivity Establishment) 및 DTLS(Datagram Transport Layer Security) 세션은 안정적인 소유권이 필요했으며, 글로벌 라우팅은 첫 홉 지연 시간을 낮게 유지해야 했습니다. 이 글에서는 클라이언트에게는 표준 WebRTC 동작을 유지하면서 OpenAI 인프라 내부에서 패킷이 라우팅되는 방식을 바꾸기 위해 우리가 구축한 분리형 relay plus transceiver 아키텍처를 소개합니다.
WebRTC는 브라우저, 모바일 앱, 서버 사이에서 저지연 오디오, 비디오, 데이터를 전송하기 위한 오픈 표준입니다. 흔히 피어 투 피어 통화와 연관되지만, 대화형 미디어의 어려운 부분을 표준화해 주기 때문에 클라이언트-서버 실시간 시스템의 실용적인 기반이기도 합니다. 예를 들어 연결 설정과 NAT(Network Address Translation) 통과를 위한 ICE, 암호화된 전송을 위한 DTLS와 SRTP(Secure Real-time Transport Protocol), 오디오 압축 및 디코딩을 위한 코덱 협상, 품질 제어를 위한 RTCP(Real-time Transport Control Protocol), 그리고 에코 제거와 지터 버퍼링 같은 클라이언트 측 기능이 여기에 포함됩니다.
이 표준화는 AI 제품에 중요합니다. WebRTC가 없다면 각 클라이언트는 NAT를 넘는 연결 설정, 미디어 암호화, 코덱 협상(전송 및 압축 해제에 선택되는 코더-디코더), 변화하는 네트워크 조건에 대한 적응 방식을 모두 각자 다르게 해결해야 합니다. WebRTC를 사용하면 이미 브라우저와 모바일 플랫폼 전반에 구현된 프로토콜 스택 위에 구축할 수 있어, 실시간 미디어를 모델과 연결하는 인프라에 우리 작업을 집중할 수 있습니다.
우리는 또한 성숙한 오픈소스 구현체와 브라우저, 모바일 앱, 서버 간 상호운용성을 유지하는 표준 작업을 포함한 WebRTC 생태계 자체에 기반합니다. Justin Uberti(WebRTC의 초기 설계자 중 한 명)와 Sean DuBois(Pion의 창시자이자 유지관리자)의 기초적 작업 덕분에, 우리 같은 팀은 저수준 전송, 암호화, 혼잡 제어 동작을 처음부터 다시 만들지 않고도 검증된 미디어 인프라 위에서 개발할 수 있었습니다. 다행히 Justin과 Sean 모두 현재 OpenAI의 동료로, WebRTC와 실시간 AI를 더욱 긴밀히 결합하는 방향을 함께 이끌고 있습니다.
AI에서 가장 중요한 속성은 오디오가 연속적인 스트림으로 도착한다는 점입니다. 음성 에이전트는 전체 업로드가 끝나기를 기다리는 대신, 사용자가 아직 말하는 동안 전사, 추론, 도구 호출, 음성 생성을 시작할 수 있습니다. 이것이 시스템을 대화형처럼 느끼게 하는 요소이며, 무전기처럼 느껴지게 하는 시스템과의 차이입니다.
WebRTC를 선택한 다음 질문은 그것을 어디에서 종료할지(예를 들어 엣지에서 WebRTC 연결을 수락하고 소유할지)와, 그 세션들을 추론 백엔드에 어떻게 연결할지였습니다. 종료 지점은 실시간 세션 상태, 미디어 전송, 라우팅, 지연 시간, 장애 격리를 어떻게 처리할지 결정하기 때문에 중요합니다.
SFU(selective forwarding unit)는 각 참여자로부터 하나의 WebRTC 스트림을 받아 다른 참여자들에게 선택적으로 전달하는 미디어 서버입니다. 이 모델에서 SFU는 각 참여자에 대해 별도의 WebRTC 연결을 종료하며, AI는 세션의 또 다른 참여자로 참여합니다. 이는 그룹 통화, 수업, 협업 회의처럼 본질적으로 다자간인 제품에 잘 맞을 수 있습니다. 오디오 코덱, RTCP 메시지, 데이터 채널, 녹화, 스트림별 정책을 한곳에 모아둘 수 있기 때문입니다.1
클라이언트 대 AI 제품에서도 SFU는 흔히 기본 출발점입니다. 시그널링, 미디어 라우팅, 녹화, 관측성, 그리고 사람에게 넘기기나 참여자 추가 같은 향후 확장을 위해 이미 검증된 하나의 시스템을 재사용할 수 있기 때문입니다.
하지만 우리의 워크로드는 다릅니다. 대부분의 세션은 1:1, 즉 한 사용자가 하나의 모델과 대화하거나 하나의 애플리케이션이 하나의 실시간 에이전트와 대화하는 형태이며, 매 턴마다 지연 시간에 민감합니다. 이런 트래픽 형태에 맞춰 우리는 transceiver 모델을 선택했습니다. WebRTC 엣지 서비스가 클라이언트 연결을 종료한 뒤, 미디어와 이벤트를 모델 추론, 전사, 음성 생성, 도구 사용, 오케스트레이션을 위한 더 단순한 내부 프로토콜로 변환합니다.
이 설계에서 트랜시버는 ICE 연결 확인, DTLS 핸드셰이크, SRTP 암호화 키, 세션 수명을 포함한 WebRTC 세션 상태를 유일하게 소유하는 서비스입니다. 여기서 “종료”란 트랜시버가 이러한 핸드셰이크를 완료하고 미디어를 암호화 또는 복호화하는 엔드포인트라는 뜻입니다. 이 상태를 한곳에 유지하면 세션 소유권을 더 쉽게 추론할 수 있었고, 백엔드 서비스가 스스로 WebRTC 피어처럼 동작하지 않고도 일반 서비스처럼 확장할 수 있었습니다.
트랜시버 모델을 선택한 뒤, 우리의 첫 구현은 Pion 위에 구축한 단일 Go 서비스로, 시그널링과 미디어 종료를 모두 처리했습니다. 이 서비스는 ChatGPT 음성 대화, Realtime API의 WebRTC 엔드포인트, 그리고 여러 연구 프로젝트를 지원합니다.
운영 측면에서 트랜시버 서비스는 두 가지 일을 합니다.
- 시그널링: SDP 협상, 코덱 선택, ICE 자격 증명, 세션 설정
- 미디어: 다운스트림 WebRTC 연결 종료와, 추론 및 오케스트레이션을 위한 백엔드 서비스와의 업스트림 연결 유지
우리는 이 서비스가 다른 인프라와 마찬가지로 Kubernetes에서 실행되길 원했습니다. Kubernetes에서는 워크로드가 수요에 따라 확장·축소되고 호스트 간 이동할 수 있습니다. 하지만 기존의 세션당 하나의 포트를 쓰는 WebRTC 모델은 이 환경에 잘 맞지 않습니다. 노드가 추가, 제거, 재스케줄되는 동안 노출·보안·유지를 해야 하는 대규모 공인 UDP 포트 범위에 의존하기 때문입니다.2
첫 번째 문제는 바로 이 세션당 하나의 포트 모델 자체였습니다. 높은 동시성에서는 매우 큰 UDP 포트 범위를 노출하고 관리해야 한다는 뜻입니다.
- 클라우드 로드 밸런서와 Kubernetes 서비스는 서비스당 수만 개의 공인 UDP 포트를 전제로 설계되지 않았습니다. 범위가 하나 늘어날 때마다 로드 밸런서 설정, 헬스 체크, 방화벽 정책, 롤아웃 안정성 측면의 운영 복잡성이 커집니다.3
- 대규모 UDP 포트 범위는 외부에서 도달 가능한 표면적을 넓히고 네트워크 정책 감사를 어렵게 만들어 보안적으로도 까다롭습니다.
- 또한 오토스케일링과도 잘 맞지 않습니다. Kubernetes에서는 파드가 계속 추가, 제거, 재스케줄됩니다. 각 파드가 크고 안정적인 포트 범위를 예약하고 광고해야 한다면 이러한 탄력성은 쉽게 깨집니다.4
이 때문에 많은 WebRTC 시스템은 서버당 하나의 UDP 포트와, 그 뒤에서 동작하는 애플리케이션 수준의 역다중화를 향해 이동합니다.5
서버당 단일 포트 설계는 포트 수 문제를 해결하지만, 플릿 전체에서 각 세션의 소유권을 유지해야 한다는 두 번째 문제를 만듭니다.
ICE와 DTLS는 상태를 가지는 프로토콜입니다. 세션을 만든 프로세스는 해당 세션의 패킷을 계속 받아야 연결 확인을 검증하고, DTLS 핸드셰이크를 완료하고, SRTP를 복호화하고, ICE 재시작 같은 이후 세션 변경도 처리할 수 있습니다. 같은 세션의 패킷이 다른 프로세스에 도착하면 설정이 실패하거나 미디어가 끊길 수 있습니다.
이로써 우리의 목표는 명확해졌습니다. 공용 인터넷에는 작고 고정된 UDP 표면만 노출하면서도, 모든 패킷을 해당 WebRTC 세션을 소유한 트랜시버로 계속 라우팅하는 것이었습니다.
우리는 TURN(Traversal Using Relays around NAT)을 포함해 여러 방식을 평가했습니다. TURN에서는 엣지 릴레이가 클라이언트 할당을 종료하고 그 대신 트래픽을 전달합니다.2
접근 방식 | 장점 | 단점 |
세션별 고유 IP:port(네이티브 직접 UDP라고도 함) | 클라이언트와 서버 간 직접 미디어 경로 데이터 경로에 포워딩 계층이 없음 | 세션마다 공인 UDP 포트 하나가 필요함 큰 포트 범위는 노출하고 보호하기 어려움 Kubernetes 및 클라우드 로드 밸런서와 잘 맞지 않음 |
서버별 고유 IP:port | 세션별 노출 방식보다 훨씬 작은 공인 UDP 노출면 서버당 하나의 공유 소켓으로 많은 세션을 역다중화할 수 있음 | 단일 호스트에서는 깔끔하게 작동하지만, 공유 로드 밸런싱 플릿 전체에서는 그것만으로 충분하지 않음 단일 호스트에서의 세션 역다중화는 패킷이 그 호스트에 도달한 뒤에만 도움이 됨. 로드 밸런싱된 플릿 전체에서는 첫 패킷이 여전히 잘못된 인스턴스에 도착할 수 있으므로, 각 세션을 해당 세션을 소유한 프로세스로 결정적으로 유도할 방법이 여전히 필요함 |
TURN 릴레이(프로토콜 종료형) | 클라이언트는 TURN 릴레이 주소와 포트에만 도달하면 됨 엣지에서 정책을 중앙집중화할 수 있음 | TURN 할당으로 설정 왕복 횟수가 추가됨 TURN 서버 간 할당 이동이나 복구도 여전히 어려움 |
상태 없는 포워더 + 상태 있는 종료기(OpenAI의 relay + transceiver) | 작은 공인 UDP 노출면 트랜시버가 여전히 전체 WebRTC 세션을 소유함 | 미디어가 세션 소유 트랜시버에 도달하기 전에 포워딩 홉이 하나 추가됨 릴레이와 트랜시버 간의 커스텀 조정이 필요함 |
우리가 배포한 아키텍처는 패킷 라우팅과 프로토콜 종료를 분리합니다. 시그널링은 여전히 세션 설정을 위해 트랜시버에 도달하지만, 미디어는 먼저 릴레이를 통해 들어옵니다. 릴레이는 작은 공용 노출면을 가진 경량 UDP 포워딩 계층이고, 트랜시버는 그 뒤에 있는 상태 기반 WebRTC 엔드포인트입니다.
릴레이는 미디어를 복호화하지도 않고, ICE 상태 머신을 실행하지도 않으며, 코덱 협상에 참여하지도 않습니다. 목적지를 선택할 만큼의 패킷 메타데이터만 읽은 뒤, 세션을 소유한 트랜시버로 패킷을 전달합니다. 트랜시버는 여전히 일반적인 WebRTC 흐름을 보고, 모든 프로토콜 상태도 계속 소유합니다. 클라이언트 관점에서는 WebRTC 세션에서 바뀌는 것이 없습니다.
이 구성에서 첫 패킷 라우팅이 핵심 단계입니다. 릴레이는 외부 조회 서비스를 기다리며 멈추는 대신, 패킷 경로 자체에서 세션이 아직 존재하지 않는 상태에서도 클라이언트의 첫 패킷을 라우팅해야 합니다.
모든 WebRTC 세션에는 이미 프로토콜 고유의 라우팅 훅이 있습니다. 바로 ICE 사용자 이름 조각인 ufrag로, 세션 설정 중 교환되고 STUN 연결 확인에 다시 실려 오는 짧은 식별자입니다. 우리는 서버 측 ufrag를 생성할 때 릴레이가 목적지 클러스터와 세션 소유 트랜시버를 추론할 수 있을 정도의 라우팅 메타데이터만 담도록 했습니다.
시그널링 중에 트랜시버는 세션 상태를 할당하고 SDP 응답에서 공유 릴레이 VIP와 UDP 포트를 반환합니다. VIP는 릴레이 플릿 앞단의 가상 IP 주소로, 포트와 함께 클라이언트에 단일하고 안정적인 목적지(예: 203.0.113.10:3478)를 제공합니다. 실제로는 그 뒤에 많은 릴레이 인스턴스가 있습니다. 클라이언트의 첫 미디어 경로 패킷은 보통 STUN(Session Traversal Utilities for NAT) 바인딩 요청이며, ICE는 이를 사용해 패킷이 광고된 주소에 도달할 수 있는지 확인합니다.
릴레이는 이 첫 STUN 패킷을 최소한으로만 파싱해 서버 ufrag를 읽고, 라우팅 힌트를 디코딩하고, 패킷을 세션 소유 트랜시버로 전달합니다. 각 트랜시버는 공유 UDP 소켓, 즉 세션당 하나의 소켓이 아니라 내부 IP:포트에 바인딩된 하나의 운영체제 엔드포인트에서 수신 대기합니다. 릴레이가 클라이언트의 소스 IP:포트에서 해당 트랜시버 목적지까지 세션을 만들고 나면, 이후의 DTLS, RTP, RTCP 패킷은 ufrag를 다시 디코딩하지 않고도 그 세션 안에서 흐릅니다.
릴레이의 세션은 의도적으로 최소화되어 있으며, 패킷 포워딩에 필요한 메모리 내 세션과 모니터링용 카운터, 세션 만료 및 정리를 위한 타이머만으로 구성됩니다. 이 설계 선택은 패킷 경로에서 직접 라우팅을 유지합니다. 릴레이가 재시작되어 세션을 잃더라도 다음 STUN 패킷이 ufrag 라우팅 힌트로부터 세션을 다시 만듭니다. 이를 더 안정적으로 만들기 위해 Redis 캐시를 사용해 경로가 설정되면 <클라이언트 IP + Port, transceiver IP + Port> 매핑을 보관하여, 다음 STUN 패킷이 도착하기 전 더 이른 시점에 복구할 수 있도록 했습니다.
공용 UDP 표면을 소수의 안정적인 주소와 포트로 줄인 뒤, 우리는 같은 릴레이 패턴을 전 세계에 배포할 수 있었습니다. Global Relay는 동일한 패킷 포워딩 동작을 구현하는 지리적으로 분산된 릴레이 인그레스 지점들의 플릿입니다.
넓은 지리적 인그레스 분포는 첫 번째 클라이언트-OpenAI 홉을 단축합니다. 패킷이 먼저 먼 지역까지 공용 인터넷을 가로질러 가는 대신, 지리적으로도 네트워크 토폴로지 측면에서도 사용자와 가까운 릴레이에서 우리 네트워크에 진입할 수 있기 때문입니다. 실질적으로 이는 트래픽이 백본에 도달하기 전 지연 시간 감소, 지터 감소, 피할 수 있는 손실 버스트 감소를 의미합니다.6
우리는 초기 HTTP 또는 WebSocket 요청이 가까운 트랜시버 클러스터에 도달하도록 시그널링에 Cloudflare geo 및 proximity steering을 사용합니다. 요청 컨텍스트는 세션 위치와 클라이언트에 광고할 Global Relay 인그레스 지점을 결정합니다. SDP 응답은 Global Relay 주소를 제공하고, ufrag에는 Global Relay가 미디어를 지정된 클러스터로 라우팅하고 릴레이가 목적지 트랜시버로 라우팅하는 데 충분한 정보가 담깁니다.
지리 기반 시그널링 유도와 Global Relay를 함께 사용하면 설정과 미디어 모두 가까운 진입 경로를 타면서도 세션은 하나의 트랜시버에 고정됩니다. 이는 시그널링 왕복 시간과 첫 ICE 연결 확인의 왕복 시간을 줄여, 사용자가 말을 시작하기 전 기다려야 하는 시간을 직접적으로 단축합니다.
우리는 릴레이 서비스를 Go로 작성했고, 의도적으로 구현 범위를 좁게 유지했습니다. Linux에서는 커널의 네트워킹 스택이 머신의 네트워크 인터페이스에서 UDP 패킷을 받아 소켓으로 전달합니다. 소켓은 프로세스가 IP:Port에 바인딩한 뒤 읽는 운영체제 엔드포인트입니다. Relay는 사용자 공간에서 실행되므로, 일반적인 Go 프로세스가 해당 소켓에서 패킷 헤더를 읽고, 소량의 흐름 상태를 갱신한 뒤, WebRTC를 종료하지 않고 패킷을 전달합니다. 더 높은 패킷 처리율을 위해 사용자 공간 프로세스가 네트워크 큐를 직접 폴링할 수 있게 해 주는 커널 우회 프레임워크는 운영 복잡성을 높이므로 필요하지 않았습니다.
핵심 설계 선택:
- 프로토콜 종료 없음: Relay는 STUN 헤더와 ufrag만 파싱하고, 이후 DTLS, RTP, RTCP에는 캐시된 상태를 사용하여 패킷을 불투명하게 유지합니다.
- 일시적 상태: 흐름 상태와 관측성을 위해 클라이언트 주소에서 트랜시버 목적지로의 작고 짧은 타임아웃의 메모리 내 맵을 유지합니다.
- 수평 확장성: 여러 릴레이 인스턴스가 로드 밸런서 뒤에서 병렬로 실행됩니다. 상태는 강한 WebRTC 상태가 아니므로, 재시작 시 트래픽 손실은 최소이고 흐름 복구도 빠릅니다.
효율성 확보 조치:
SO_REUSEPORT는 같은 머신의 여러 릴레이 워커가 동일한 UDP 포트에 바인딩할 수 있게 하는 Linux 소켓 옵션입니다. 그러면 커널이 들어오는 패킷을 워커들에 분산하므로 단일 읽기 루프 병목을 피할 수 있습니다.runtime.LockOSThread는 각 UDP 읽기 고루틴을 특정 OS 스레드에 고정합니다.SO_REUSEPORT와 결합하면 같은 흐름(소스 및 목적지 IP:Port와 프로토콜)의 패킷이 같은 CPU 코어에 머무는 경향이 생겨 캐시 지역성이 개선되고 컨텍스트 스위칭이 줄어듭니다.- 사전 할당된 버퍼와 최소한의 복사는 파싱 및 할당 오버헤드를 낮춰 Go에서 가비지 컬렉션을 피하는 데 도움이 됩니다.
이 구현은 비교적 작은 릴레이 풋프린트로도 우리의 글로벌 실시간 미디어 트래픽을 처리했기 때문에, 커널 우회 경로를 택하는 대신 더 단순한 설계를 유지했습니다.
이 아키텍처 덕분에 우리는 수천 개의 UDP 포트를 노출하지 않고도 Kubernetes에서 WebRTC 미디어를 운영할 수 있게 되었습니다. 더 작고 고정된 UDP 표면은 보안과 로드 밸런싱이 더 쉬우며, 인프라가 확장될 때도 대규모 공인 포트 범위를 예약할 필요가 없기 때문에 중요합니다. Kubernetes의 더 나은 인프라 지원과 더 작은 노출면에서 오는 보안 이점과 함께, 이 설계는 클라이언트에 대해 표준 WebRTC 동작도 유지하며 SFU 없는 설계가 우리 워크로드의 올바른 기본값이었음을 확인해 주었습니다. 대부분의 세션은 점대점이고 지연 시간에 민감하며, 추론 서비스가 WebRTC 피어처럼 동작할 필요가 없을 때 더 쉽게 확장됩니다.
더 넓은 교훈은 복잡성을 추가하기에 가장 좋은 곳이 모든 백엔드 서비스나 커스텀 클라이언트 동작이 아니라 얇은 라우팅 계층이라는 점입니다. 라우팅 메타데이터를 프로토콜 고유 필드에 인코딩함으로써 우리는 결정적인 첫 패킷 라우팅, 작은 공용 UDP 노출면, 그리고 전 세계 사용자 가까이에 인그레스를 배치할 수 있는 충분한 유연성을 얻었습니다.
특히 중요했던 몇 가지 선택은 다음과 같습니다.
- 엣지에서 프로토콜 의미를 보존할 것. 클라이언트는 여전히 표준 WebRTC를 사용하므로 브라우저와 모바일 상호운용성이 유지됩니다.
- 어려운 세션 상태는 한곳에 둘 것. 트랜시버가 ICE, DTLS, SRTP, 세션 수명을 소유하고 릴레이는 패킷만 전달합니다.
- 설정 과정에 이미 मौजूद한 정보로 라우팅할 것. ICE ufrag는 핫패스 조회 의존성을 추가하지 않고도 첫 패킷 라우팅 훅을 제공했습니다.
- 커널 우회를 고려하기 전에 먼저 일반적인 경우를 최적화할 것.
SO_REUSEPORT, 스레드 고정, 저할당 파싱을 신중히 활용한 좁은 범위의 Go 구현만으로도 우리 워크로드에는 충분했습니다.
실시간 음성 AI는 인프라가 지연 시간을 보이지 않게 만들 때만 제대로 작동합니다. 우리에게 그것은 클라이언트가 WebRTC 자체에 기대하는 바를 바꾸지 않으면서 WebRTC 배포의 형태를 바꾸는 것을 의미했습니다.


