사건 조사 보고서
Channel Talk
안녕하세요. 채널톡의 미트팀에서 실시간 음성/화상 서비스를 개발하고 있는 루이, 렌토입니다.
오늘은 최근 겪었던 미스터리한 문제 해결 과정을 공유하고자 합니다.
채널톡 미트는 WebRTC 기술을 기반으로 한 실시간 커뮤니케이션 서비스입니다. 특히 WebRTC ↔ VoIP 통합 기술을 통해 많은 기업들의 전화 상담 업무를 지원하고 있습니다. 현재 2천여개의 고객사가 사용 중이며 하루 평균 3만건의 미트가 이뤄지고 있습니다.
본격적인 문제 해결 과정을 설명하기 전에 WebRTC의 구조와 ICE에 대한 기본 개념을 간략히 설명드리겠습니다.
WebRTC는 별도의 플러그인이나 소프트웨어 설치 없이 웹 브라우저 간에 오디오, 비디오, 데이터를 실시간으로 주고받을 수 있게 해주는 기술입니다.
WebRTC는 크게 Mesh, MCU, SFU 세 가지 아키텍처로 구현될 수 있는데, 저희는 SFU(Selective Forwarding Unit) 방식을 채택하고 있습니다. SFU는 참여자들이 자신의 미디어 스트림을 서버에 한 번만 전송하면, 서버가 이를 다른 참여자들에게 선택적으로 전달해주기 때문에 확장성이 좋고 대역폭을 효율적으로 사용할 수 있어 다자간 통화에 적합합니다.
ICE 는 서로 다른 네트워크에 있는 기기들이 방화벽이나 NAT를 통과하여 가장 효율적인 경로로 연결하는 방법을 찾는 프로토콜입니다. WebRTC에서는 먼저 SDP(Session Description Protocol)를 통한 Offer/Answer 교환으로 미디어 정보를 공유한 후, ICE 과정에서 여러 후보 경로(Candidate)를 수집하고 테스트하여 최적의 통신 경로를 찾습니다.
앞서 설명드린 것과 같이 채널톡에서는 WebRTC ↔ VoIP 통합 기술을 통해 전화 상담 업무를 지원하고 있습니다.
이때 고객과 상담원은 WebRTC 서버에 각각 참여하게 되며, 서버는 이들의 음성을 적절히 처리하여 서로가 음성을 교환할 수 있게 동작합니다.
본문을 읽기 위해 참고할 내용은 서버 구성의 특성상 고객은 Private IP 를 통해 연결되고, 상담원은 Public IP 를 통해 연결된다는 점입니다.
위 그래프는 하루 동안의 미트 음성 처리 서버의 CPU 와 네트워크 자원 사용량을 보여줍니다.
특히 주목할 만한 점은 9시, 10시, 그리고 13시에 발생하는 급격한 트래픽 증가입니다. 이는 기업들의 업무 시작 시간과 점심시간 이후 업무 재개 시점과 맞물려 있습니다. 이 시간대에는 CPU 사용률이 60~70% 정도 급격히 상승하는 현상이 발생합니다.
WebRTC 기반의 실시간 음성 처리는 오디오 스트림의 인코딩/디코딩, 패킷 처리, 지터 버퍼 관리 등 CPU 집약적인 작업들을 실시간으로 처리해야 하며, 각각의 음성 스트림은 독립적인 처리 스레드를 필요로 합니다.
CPU 사용률이 급증하면 운영체제의 스케줄러가 각 스레드에 할당할 수 있는 CPU 시간이 줄어들게 됩니다. 실시간 음성 처리는 밀리초 단위의 정확한 타이밍이 중요한데, 스레드가 적시에 CPU 자원을 할당받지 못하면 오디오 프레임 처리가 지연되거나 누락될 수 있습니다. 이는 곧 음성 끊김, 지연, 왜곡 등 품질 저하로 이어질 수 있어 실시간 음성 서비스에서 CPU 자원 관리는 매우 중요합니다.
이러한 CPU 자원 관리의 중요성을 고려해 트래픽이 급증하는 시간대(09시, 10시, 13시)를 대비하여 08시부터 14시까지 평소보다 2배 많은 서버를 미리 확보하는 방안을 결정했습니다. 개발 환경에서 충분한 테스트를 거친 후 배포했지만, 다음 날 아침 예상치 못한 장애를 맞이하게 됩니다.
다음 날 아침 고객사들로부터 전화 연결 시 소리가 들리지 않는다는 제보가 잇따라 접수되기 시작했고, 분석 결과 약 25%의 통화에서 묵음 현상이 발생하고 있었습니다.
이전과 달라진 점은 트래픽 급증을 고려해 미리 서버를 증설해 둔 것이 유일했습니다. 기존에도 서버는 자연스럽게 스케일링되고 있었기 때문에, 단순히 서버를 증설한 것이 원인이라고 쉽게 단정할 수 없었습니다. 또한 여러 지표들 역시 이전과 동일한 양상을 띄고 있었습니다.
이렇게 직관적으로는 이해하기 어려운 현상이 바로 문제의 시작이었습니다.
문제를 처음 마주하고서 가장 먼저 든 의문은 "단순히 서버 수만 늘렸을 뿐인데, 왜 문제가 발생했을까?" 였습니다. 이전에도 전화 사용량이 증가하면 자동으로 서버가 스케일링 되었기 때문에, 서버를 미리 늘려 둔 것이 문제가 된다는 점을 직관적으로 이해하기 어려웠습니다.
원인을 찾기 위해 다양한 가능성을 검토하던 중, ICE 관련 로그에서 중요한 단서를 발견했습니다.
ICE 로그를 분석해 보니, 문제가 발생한 모든 통화에서 공통적인 패턴이 있었습니다. 고객은 publisher/subscriber 모두 'connected' 상태(연결 완료)였지만, 상담원은 'connecting' 상태(연결 시도 중)에서 멈춰 있던 것입니다.
또한 녹음된 데이터를 확인해 보니, 고객의 음성은 정상적으로 녹음되었지만, 상담원의 음성은 전혀 녹음되지 않았습니다.
이러한 단서를 바탕으로 첫 번째 가설을 세웠습니다.
“Private IP를 사용하는 고객은 정상적으로 연결되었지만, Public IP를 사용하는 상담원의 연결에서 문제가 발생했을 것이다”
첫 번째 가설을 바탕으로 Public IP를 이용한 ICE 로그들을 모두 확인했습니다. 여기서 발견한 점은 돌아가고 있는 서버 중에서 유일하게 하나의 서버에서만 지속적으로 ICE 에 실패한다는 점이였습니다.
그리고 그 서버는 트래픽에 대비하여 추가적으로 띄워진 서버였습니다. 다만, 이 로그만으로는 새롭게 뜬 서버가 문제라고 판단하기에는 근거가 부족했습니다. 마침 여러 서버중 새로 뜬 서버에만 문제가 발생했을 수도 있으니까요.
문제가 발생한 서버의 Public IP가 어떻게 할당되고 관리되는지 그리고 다른 서버들과 어떤 차이가 있는지 확인하기 위해, 저희는 해당 서버의 Public IP 생명 주기를 따라가 보기로 했습니다.
먼저, 인프라 레벨에서의 Public IP 생애주기는 다음과 같습니다.
Init Container 에서 우리가 관리하는 EIP 중 하나를 가져와 Network Interface 에 할당합니다.
WebRTC 서버는 해당 EIP 를 사용해 RTP 통신을 진행하다가, 종료 시그널을 받으면 남아 있는 통화를 모두 마친 후 내려가는 방식으로 동작합니다.
서버가 종료될 때에는 인스턴스가 함께 내려가기 때문에, 별도로 EIP 를 릴리즈하는 과정은 없습니다.
즉, WebRTC 서버가 Running 상태인 동안에는 EIP가 유지되며, EIP는 서버가 처음 올라올 때만 변경되는 구조입니다.
이러한 동작을 전제로, 문제가 발생했던 시점의 로그와 메트릭을 확인해 보았습니다. 먼저, WebRTC 서버가 기동될 때 출력한 로그에서 서버가 인식한 Public IP를 확인했고, 쿠버네티스에서 해당 노드의 ExternalIP
값과 비교해 보았습니다.
두 값은 서로 일치하고 있었고, 서버가 시작될 당시에는 EIP가 예상대로 노드에 정상적으로 할당된 것으로 보였습니다. 실제로 RTP 통신이 정상적으로 이루어졌는지까지는 확인하지 않았지만, WebRTC 서버가 인식한 Public IP와 쿠버네티스가 관리하는 External IP가 일치하고 있었다는 점을 근거로, 인프라 구성 자체보다는 이후 애플리케이션에서 해당 IP를 어떻게 활용했는지에 문제가 있을 가능성이 더 높다고 판단했습니다.
인프라 단에서 문제가 없음을 확인한 후 애플리케이션을 확인해보기 시작했습니다.
WebRTC 서버에서는 Redis 를 통해 아래과 같이 Public IP 를 관리합니다.
서버가 시작되는 순간 시스템 호출으로 노드에 할당된 Public IP 를 가져와 Redis 에 등록합니다.
서버가 종료되는 순간 Redis 에서 자신의 정보를 삭제합니다.
WebRTC 서버가 ICE 요청을 받으면, Redis 에서 해당 통화의 RTP 세션을 담당하는 서버의 Public IP 를 찾아 이를 기반으로 ICE Gathering 을 수행합니다.
WebRTC 서버의 Public IP 관리 과정을 자세히 살펴보니, 서버 종료 과정에서 문제가 발생했을 가능성이 높아 보였습니다.
노드의 EIP 는 직접 해제되지 않지만, WebRTC 서버는 종료될 때 Redis 에서 해당 EIP 정보를 삭제하는 로직이 있습니다. 만약 서버가 비정상적으로 종료되었다면 Redis에서 EIP 정보가 정상적으로 삭제되지 못하고, 새로운 ICE 연결 과정에서 이미 종료된 서버의 EIP를 그대로 참조하면서 통신이 실패했을 가능성이 있었습니다.
이러한 단서를 바탕으로 두 번째 가설을 세웠습니다.
“종료된 WebRTC 서버의 EIP 정보가 Redis 에 남아 있었고, 새로운 ICE 연결이 잘못된 EIP 를 참조하면서 연결에 실패한 것이 아닐까?”
이 가설이 맞다면 Redis 에 종료된 서버의 EIP 가 그대로 남아, ICE 과정에서 잘못된 EIP 를 참조하게 되었을 것입니다. 즉, ICE Gathering 에서 종료된 서버의 EIP 를 사용했고, 이를 기반으로 클라이언트가 종료된 서버로 연결을 시도하면서 묵음이 발생했을 가능성이 있습니다.
이를 검증하기 위해 개발 환경에서 Redis 에 저장된 EIP 정보가 제대로 갱신되는지 추적해 보았습니다. 테스트 결과 WebRTC 서버는 모든 상황에서 Redis 정보를 정상적으로 정리하고 있었습니다.
문제가 발생했던 당시의 Redis 데이터를 직접 확인해 보았을 때에도, 이미 종료된 서버들의 EIP 정보는 모두 정상적으로 삭제된 상태였습니다.
다시 원점으로 돌아가, 우리는 새로운 가능성을 찾아야 했습니다.
미트 서비스에서는 하나의 WebRTC 서버가 하나의 Pod로 구성되며, 각각의 Pod는 하나의 노드 위에서 동작하도록 설계되어 있습니다. 즉, Node : WebRTC 서버가 1:1의 관계를 이루며, 이는 서버가 사용하는 리소스가 통화 품질에 직접적인 영향을 미치기 때문에 서버 간 간섭을 막기 위한 구조입니다.
일반적인 스케일 아웃 상황에서는 트래픽 증가에 따라 새로운 노드가 함께 생성되고, WebRTC 서버는 해당 노드에서 처음부터 실행됩니다. 이때 먼저 InitContainer가 실행되어 Public IP(EIP)를 해당 노드의 Network Interface에 할당합니다. InitContainer가 종료된 뒤에야 Main Container가 실행되는데, 이 시점부터 컨테이너 이미지(Pod의 Main Container)가 풀(Pull)되고 실행되기 시작합니다.
중요한 점은, 이미지가 풀되고 WebRTC 서버가 완전히 기동되기까지 시간이 제법 소요된다는 것입니다. 반면 EIP는 InitContainer에서 할당되기 때문에, 일반적인 상황에서는 항상 EIP가 먼저 할당되고 난 뒤에 WebRTC 서버가 실행되며, 결과적으로 서버는 올바른 Public IP를 인식하게 됩니다.
이러한 흐름을 기반으로 우리는 “WebRTC 서버는 항상 새로운 노드에서 실행되며, 항상 새로운 EIP가 먼저 할당된다”는 가정을 세우고 시스템을 운영하고 있었습니다.
하지만 이 가정에는 "외부의 개입이 없는 경우"라는 중요한 전제 조건이 있었습니다. 예를 들어 운영자가 정책 변경, 긴급 대응, 리소스 회수 등의 이유로 서버 전체를 재시작하거나 특정 서버를 수동으로 조작하는 경우에는 상황이 달라집니다. 기존 노드에 WebRTC 서버가 다시 배포되는 경우, 노드에는 이미 필요한 이미지가 존재하기 때문에 Main Container(WebRTC 서버)가 매우 빠르게 실행됩니다.
문제는 이때 EIP 할당과 IP 변경이 WebRTC 서버보다 늦게 적용될 수 있다는 점입니다. 다시 말해, 서버가 어플리케이션을 실행하며 시스템 콜로 외부 IP를 조회하고 이를 Redis에 등록하는 시점은 EIP가 실제 노드에 적용되기 직전일 수도 있는 것입니다. 이 경우 WebRTC 서버는 이전 상태(Old EIP)를 Redis에 등록하게 되고, 이후에는 실제 노드의 Public IP가 새로운 값(New EIP)으로 바뀌게 됩니다.
이러한 불일치는 ICE 과정에서 잘못된 Public IP를 클라이언트에 전달하게 만들고, 결국 연결 실패나 오디오 미수신 등의 문제로 이어질 수 있습니다.
마침 문제 발생 당시, WebRTC 서버 업그레이드와 함께 Public IP Pool을 확장하고, 이에 대응하여 전체 서버를 재시작하는 작업이 순차적으로 이루어지던 중이었습니다. 고객들은 아직 본격적인 업무를 시작하기 전이었기 때문에 일부 서버는 종료 시그널을 받자마자 바로 내려갔고, 해당 노드는 리소스를 확보한 뒤 다시 Pod가 재배포되었습니다. 이 과정에서 위와 같은 비정상적인 순서가 발생했고, 결과적으로 서버가 잘못된 Public IP를 Redis에 등록하게 되는 문제가 나타났습니다.
<쿠버네티스와 AWS EIP의 동기화 문제>
위 현상을 IP 정보가 관리되는 계층별로 나누어 도식을 그려보면 다음과 같습니다.
<그림>
인프라(AWS EIP) - WebRTC Pod가 올라올 때 Init Container에서 AssociateAddress API를 이용하여 해당 노드의 Network Interface에 EIP를 할당하며, 외부와 통신할 때 사용되는 Public IP입니다.
클러스터(ExternalIP of Node) - 쿠버네티스의 node.status.addresses
에는 External IP가 있고, 이는 kube_node_status_addresses
메트릭을 통해 모니터링됩니다. 이 정보는 node-controller에 의해 5분 주기로 갱신됩니다.
어플리케이션(PublicIP in Redis) - WebRTC 서버는 서로 원활한 통신을 위해 올라올 때 자신의 Public IP를 Redis에 저장하며, 필요한 경우 이를 조회하여 ICE 과정에서 활용합니다.
한 가지 흥미로운 점은, AWS CloudTrail 에서 새로운 EIP 할당을 위한 AssociateAddress API 호출이 성공적으로 완료된 것으로 기록되어 있었지만, API 호출 직후 WebRTC 서버가 System Call을 통해 확인한 Public IP는 다른 값이었습니다.
이 IP를 추적해 보니, 동일한 노드에 올라가 있던 Pod(Old Pod)의 Init Container에서 할당받았던 IP와 일치했습니다. AssociateAddress API 호출 로그와 WebRTC 서버의 IP 확인 로그 사이에는 약 1초의 시간차만 존재했죠. 이를 통해 우리는 "AWS의 EIP 할당은 API 응답과 실제 네트워크 적용 사이에 시간차가 있을 수 있다"는 새로운 가설을 세웠습니다.
이 가설을 검증하기 위해 간단한 스크립트를 작성하여 테스트한 결과, 실제로 1초 전후의 지연이 발생하는 것을 확인하였습니다. 이러한 시간차로 인해 WebRTC 서버는 시작 시점에 잘못된 IP 정보를 Redis에 등록하게 되었고, 결국 ICE Gathering 과정에서 잘못된 Public IP가 참조되면서 연결 실패로 이어졌던 것입니다.
서버는 항상 새로운 노드에서 시작된다는 가정 하에 기대했던 타임라인은 <그림1>과 같습니다.
<그림1>
하지만, WebRTC 서버가 삭제된 후 동일한 노드에 올라오는 경우의 타임라인은 <그림2>와 같았습니다.
<그림2>
위 그림이 다소 복잡해 보일 수 있지만, 각 이벤트가 발생했던 시점마다 Public IP가 서로 다른 것을 확인할 수 있습니다. 실제로 확인한 타임라인(<그림2>)을 기준으로 아래 사항을 살펴보시면 이해하는 데에 도움이 될 것입니다.
EIP 할당을 요청하는 시점(Associate New ElasticIP)부터 Real Public IP가 New EIP로 변경되기까지 시간 간격이 있습니다.
위 시간 간격 사이에 Main Container가 올라와서 IP를 가져오기(Get Public IP in WebRTC Server) 때문에, WebRTC Server는 Redis에 Old EIP를 자신의 Public IP로 등록하게 됩니다.
디버깅 과정에서 메트릭으로 확인한 Node.status.ExternalIP는 kubelet의 updateNodeStatus()가 호출될 때마다 업데이트되고, 최대 5분(interval) 가량 차이가 있을 수 있습니다
우리의 가설을 검증하기 위해 최근 1개월간의 스케일링 기록을 분석해보았습니다. 그 결과 이슈가 발생했던 케이스와 발생하지 않았던 케이스 모두 위 가설로 설명할 수 있었습니다. 저희는 EIP를 할당하기 전후로 검증하는 방어로직을 구현하여 테스트를 진행했고, 해당 이슈가 더 이상 발생하지 않는 것을 확인한 후 당일 바로 Production 환경에 배포를 진행했습니다.
AWS 측에 문의해본 결과, AssociateAddress API 요청 자체는 동기식으로 처리되어 결과를 반환하지만, 실제 네트워크 레벨에서 EIP가 적용되는 데에는 1~2분까지도 소요될 수 있다는 답변을 받았습니다. 물론 테스트 결과로는 대부분 1초 전후로 적용되는 것을 확인할 수 있었지만, 이러한 AWS의 답변은 시간차 현상이 실제로 존재하는 문제였음을 입증해주었습니다.
이번 글에서는 Kubernetes 환경의 Meet 서비스 스케일링 과정에서 발생한 WebRTC 묵음 현상을 어떻게 디버깅하고 실마리를 찾아갔는지 살펴보았습니다.
문제를 정의하고 원인을 분석하는 여정은 결코 쉽지 않았지만, 각 단계마다 가설을 세우고 하나씩 검증해 나가는 과정은 기술적인 성취는 물론 개인적으로도 큰 보람을 안겨주었습니다.
앞으로도 더욱 안정적이고 신뢰할 수 있는 Meet 서비스를 제공하기 위해 지속적으로 개선하고 노력하겠습니다.
We Make a Future Classic Product
채널팀과 함께 성장하고 싶은 분을 기다립니다