웹 미팅 서비스 개발 회고 #Project 2404

프로젝트를 시작하면서

이번 프로젝트에서는 브라우저에서 사용할 수 있는 웹 미팅 서비스를 개발했다.

처음에는 화상회의 기능을 만드는 정도로 생각했다. 카메라와 마이크를 연결하고 회의실에 들어가서 서로 영상을 볼 수 있으면 기본적인 기능은 끝날 거라고 생각했다.

실제로 개발을 시작하니 그렇게 단순하지 않았다.

회의실 생성부터 사용자 초대, 회의실 접근 권한, OTP 접속, 호스트와 일반 참가자 구분, Lobby, 참석 기록, 녹화, 음성 인증까지 같이 처리해야 했다.

특히 이번 서비스는 많은 사용자가 접속하는 환경을 고려해야 했다. 일반적인 소규모 화상회의 서비스처럼 참가자 수가 몇 명 정도라고 가정하고 구조를 잡을 수 없었다.

그래서 미디어 처리 방식도 처음부터 고민해야 했다.

P2P 방식으로 구성하면 참가자가 늘어날수록 각 클라이언트가 유지해야 하는 연결이 많아진다. SFU도 P2P보다 효율적이지만 참가자가 많아지면 결국 클라이언트에서 여러 미디어 스트림을 처리해야 한다.

이번 프로젝트에서는 대규모 접속을 고려해 MCU 방식으로 미디어를 서버에서 처리하는 방향으로 구성했다.

이 선택으로 클라이언트가 처리해야 하는 미디어 부담은 줄였지만 반대로 서버에서 처리해야 할 작업은 많아졌다.

개발하면서 WebRTC 연결 자체보다 오히려 그 주변에서 문제가 더 많이 발생했다.

회의실 접근 정책, 동시 입장, 참가자 상태 관리, 인증, 녹화 프로세스, 미디어 서버 자원 같은 것들이 서로 연결되어 있었다.


MCU를 선택한 이유

WebRTC 미디어 처리 방식은 P2P, SFU, MCU를 놓고 검토했다.

P2P는 참가자가 적을 때는 구조가 단순하다.

하지만 참가자가 늘어나면 각 클라이언트가 여러 참가자와 직접 연결해야 한다. 연결 수가 증가하고 송수신해야 하는 미디어도 많아진다.

대규모 접속을 전제로 하는 이번 서비스에는 맞지 않았다.

SFU는 이 문제를 어느 정도 해결할 수 있다. 클라이언트가 직접 서로 연결하는 대신 서버가 미디어를 중계하기 때문이다.

그렇지만 결국 클라이언트는 여러 참가자의 미디어를 받아서 처리해야 한다.

특히 저사양 장비나 다양한 브라우저 환경까지 고려하면 참가자 수가 많아졌을 때 클라이언트의 디코딩과 렌더링 부담을 무시하기 어려웠다.

그래서 MCU 방식을 선택했다.

참가자들의 미디어를 서버에서 받아 처리하고 필요한 형태로 합성한 뒤 클라이언트로 전달하는 구조다.

클라이언트에서는 여러 참가자의 미디어를 각각 처리하는 부담이 줄어든다.

대신 서버가 그만큼 일을 한다.

영상 디코딩과 합성, 인코딩에 필요한 CPU 자원이 발생하고 회의실이 늘어나면 미디어 처리량도 같이 증가한다.

결국 클라이언트의 부담을 서버로 옮기는 선택이었다.

이번 서비스에서는 많은 사용자가 접속하는 상황을 고려해야 했기 때문에 이쪽이 더 적합하다고 판단했다.


서버를 역할별로 나눈 이유

전체 시스템은 여러 서버와 서비스로 나눠서 구성했다.

클라이언트는 React와 TypeScript로 개발했다.

회의실과 시그널링은 XMPP/Prosody 기반으로 처리했고 Jicofo를 통해 회의 세션을 관리했다.

미디어 처리는 MCU 구조로 구성했다.

서비스에서 사용하는 비즈니스 데이터는 Symfony 백엔드에서 관리했다.

회의실 생성, 예약, 사용자 초대, 권한, 참석 기록 같은 데이터는 미디어 처리 영역과 분리했다.

Node.js Gateway도 별도로 구성했다.

Socket.IO와 Redis를 이용한 실시간 상태 처리와 STT 기반 인증, 모바일 신분증 관련 기능을 이쪽에서 담당했다.

처음부터 모든 역할을 완벽하게 분리한 것은 아니었다.

개발하면서 기능이 계속 추가되다 보니 특정 서버에 역할이 너무 많이 들어가는 부분이 생겼다.

처음에는 빠르게 만들기 위해 한곳에서 처리했던 기능도 나중에는 수정 범위가 너무 넓어졌다. 그때부터 역할을 조금씩 분리했다.

특히 비즈니스 로직과 미디어 처리를 분리한 것은 개발하면서 도움이 됐다.

영상 문제와 DB 문제, 인증 문제를 서로 다른 영역에서 확인할 수 있었기 때문이다.


회의실 접속 과정과 OTP

이번 서비스에서 회의실에 들어가는 과정은 단순한 로그인과는 조금 달랐다.

사용자가 회의실에 접근하기 위해서는 해당 회의에 참여할 수 있는지 먼저 확인해야 했다.

여기에 OTP 접속 시스템을 넣었다.

회의실에 접속할 때 발급된 OTP를 입력하고 서버에서 해당 OTP가 유효한지 확인한 뒤 회의실 접속 절차를 진행하도록 했다.

OTP는 단순히 프론트에서 비교하지 않았다.

사용자가 입력한 값을 서버로 전달하고 서버에서 회의실과의 관계, 유효 시간, 사용 가능 여부 등을 확인하는 방식으로 처리했다.

특히 OTP는 한번 인증했다고 계속 사용할 수 있는 값으로 두지 않는 것이 중요했다.

유효 시간이 지나거나 이미 사용된 OTP는 다시 사용할 수 없도록 처리했다.

이렇게 하면 회의실 URL을 알고 있더라도 OTP가 없는 사용자가 바로 접근하는 것을 막을 수 있다.

실제 회의 서비스에서는 URL 자체를 비밀값처럼 취급하는 방식보다 별도의 접속 인증 단계를 두는 편이 관리하기 편했다.

회의실마다 접근 정책을 다르게 가져갈 수 있기 때문이다.


OTP와 JWT를 분리해서 처리했다

처음 보면 OTP와 JWT가 둘 다 인증처럼 보인다.

실제로는 역할이 달랐다.

OTP는 회의실에 접근할 수 있는지를 확인하기 위한 접속 인증에 가까웠고, OTP 검증이 끝난 이후에는 회의 세션에서 사용할 인증 정보를 발급하는 구조로 처리했다.

OTP 검증 -> 회의실 접근 확인 -> JWT 발급 -> 회의실 세션 접근

JWT에는 회의실 UID와 사용자 정보, 호스트 여부 등의 정보를 넣었다.

moderator 권한과 기능별 권한도 같이 관리했다.

예를 들어 녹화 기능은 호스트에게만 허용했다.

프론트에서 녹화 버튼을 숨기는 것으로 끝내지 않고 실제 권한 검사는 서버에서 했다.

클라이언트가 요청을 직접 만들어 보내더라도 서버에서 권한이 없으면 처리하지 않도록 했다.

JWT에는 만료 시간도 설정했다.

이번 구현에서는 회의 세션 기준으로 2시간 동안 사용할 수 있도록 구성했다.

이렇게 OTP와 JWT의 역할을 나눠두면 회의실에 들어오는 과정과 회의에 참여하고 있는 동안의 권한 관리를 별도로 볼 수 있었다.


회의실 상태와 참석 기록

회의실 상태는 계속 변한다.

회의실이 만들어지고 참가자가 들어온다. 누군가는 중간에 나가고 다시 들어올 수도 있다.

마지막 참가자가 나가면 회의실 상태도 변경된다.

Symfony에서 일정 시간마다 회의실을 조회하는 방식도 생각할 수 있었지만 이벤트 기반으로 처리하는 편이 더 적합했다.

Prosody에서 발생하는 MUC 이벤트를 백엔드로 전달받았다.

회의실 생성과 삭제, 참가자의 입장과 퇴장 이벤트를 Webhook으로 받았다.

참가자가 들어오면 JID와 이름, 입장 시간, 모더레이터 여부 등을 저장했다.

퇴장하면 퇴장 시간을 기록하고 입장 시간과 비교해서 실제 참석 시간을 계산했다.

이렇게 하면 회의가 끝난 뒤 참가자별 참석 시간을 확인할 수 있다.

그런데 여기서 문제가 하나 나왔다.

이벤트가 항상 예상한 순서대로 들어오는 것이 아니었다.


joined와 left 이벤트 순서 문제

처음에는 occupant-joined가 먼저 처리되고 occupant-left가 그 다음에 처리될 거라고 생각했다.

대부분의 경우는 실제로 그렇게 동작했다.

문제는 참가자가 들어오자마자 나가는 경우였다.

joined 이벤트를 받아 DB에 참가자 정보를 저장하는 작업이 끝나기 전에 left 이벤트가 들어올 수 있었다.

그러면 left 이벤트에서는 해당 참가자를 찾을 수 없다.

처음에는 DB 조회 부분을 수정하려고 했다.

조금 늦게 조회하거나 조회 실패 시 다시 조회하면 될 거라고 생각했다.

그런데 로그를 계속 확인해보니 DB 문제가 아니었다.

이벤트 순서를 애플리케이션에서 보장할 수 없는 게 문제였다.

그래서 Webhook 처리 구조를 변경했다.

참가자 JID를 기준으로 Redis 락을 잡고 이벤트를 처리했다.

joined가 아직 처리되지 않은 상태에서 left가 들어오면 바로 실패시키지 않고 pending 상태로 보관했다.

joined 처리가 끝나면 pending된 left 이벤트를 다시 처리했다.

대기 시간은 길게 잡지 않았다.

실제 상황에서는 이벤트가 거의 동시에 들어오는 경우였기 때문에 1~2초 정도의 재시도만으로 대부분 처리할 수 있었다.

이 부분은 개발하면서 구조를 수정하게 된 대표적인 사례였다.

실시간 이벤트를 처리할 때는 정상적인 이벤트 순서를 전제로 만들면 문제가 생길 수 있다는 것을 직접 확인했다.


Lobby를 추가하면서

회의실에 들어오는 과정에는 OTP만 있는 것이 아니었다.

회의실에 접근할 수 있다고 해서 바로 본 회의에 넣는 것도 문제가 있었다.

그래서 Lobby를 추가했다.

참가자가 입장을 요청하면 대기 상태로 두고 호스트가 승인해야 실제 회의에 들어갈 수 있도록 했다.

호스트에게는 WebSocket으로 대기 중인 참가자가 있다는 것을 전달했다.

호스트 화면에서 참가자를 확인하고 승인하거나 거절할 수 있게 했다.

회의 시작 전 공지 메시지를 보내는 기능도 같이 처리했다.

최대 참가자 수 제한도 회의실마다 가지고 있었다.

max_users를 저장하고 현재 활성 참가자 수와 비교해서 입장을 제한했다.

여기서도 클라이언트에서만 체크하지 않았다.

현재 99명이고 최대 100명인 상태에서 여러 명이 거의 동시에 입장하면 각각 같은 99명을 읽을 수 있기 때문이다.

그렇게 되면 제한 인원을 넘어갈 수 있다.

그래서 실제 입장 가능 여부는 서버에서 판단했다.


STT 기반 음성 인증

인증 기능 중에는 STT를 이용한 음성 인증도 있었다.

사용자가 지정된 문장을 읽으면 브라우저에서 음성을 녹음하고 서버로 전송한다.

브라우저에서 녹음된 파일은 환경에 따라 WebM/Opus 형태로 들어왔다.

문제는 STT 서버가 요구하는 입력 형식과 맞지 않는 경우가 있다는 것이었다.

그래서 Node.js Gateway에서 파일을 받은 뒤 FFmpeg를 이용해서 변환했다.

브라우저 녹음 -> Node.js 업로드 -> FFmpeg 변환 -> STT API -> 텍스트 비교

FFmpeg에서는 16kHz, mono 형태로 변환했다.

STT 결과를 받은 뒤에는 공백이나 일부 문장 부호를 정리하고 미리 지정한 문장과 비교했다.

인증 과정에서 사용한 음성 파일을 계속 저장할 필요는 없었다.

STT 처리가 끝나면 원본 파일과 변환 파일을 바로 삭제했다.

이쪽은 생각보다 환경적인 문제가 많았다.

브라우저가 실제로 어떤 코덱으로 녹음하는지 확인해야 했고 FFmpeg에서 정상적으로 변환되는지도 봐야 했다.

변환된 음성이 STT에서 제대로 인식되는지도 따로 확인했다.

각 단계가 따로 놀면 전체 인증 과정이 실패한다.


대규모 접속에서 MCU를 운영하면서

MCU 구조에서는 클라이언트가 편해지는 대신 서버가 바빠진다.

참가자의 미디어를 서버에서 받아서 처리하고 다시 클라이언트로 전달하기 때문에 서버의 CPU와 네트워크 사용량이 중요했다.

특히 회의실 하나의 참가자가 많아지면 단순 HTTP 요청 수와는 다른 형태로 부하가 증가한다.

영상의 해상도나 프레임레이트, 인코딩 상태 등에 따라서 필요한 자원이 달라진다.

그래서 MCU 서버의 상태를 단순히 CPU 사용률 하나로 판단하기는 어려웠다.

회의실 수가 늘어나면 동시에 처리해야 하는 미디어 작업도 늘어난다.

어떤 회의실은 참가자가 많고 어떤 회의실은 거의 사용되지 않는 식으로 부하가 균등하지도 않다.

결국 미디어 서버별 부하를 따로 확인하고 회의실을 적절하게 분산하는 것이 중요했다.

대규모 접속을 고려하면서 가장 신경 쓴 부분 중 하나도 이 부분이었다.


Jibri 녹화에서 발생했던 문제

녹화는 Jibri를 사용했다.

여기도 단순히 녹화 버튼을 만들고 끝나는 기능은 아니었다.

간헐적으로 영상과 오디오의 싱크가 어긋나는 문제가 있었고 녹화가 끝난 뒤 Chromium 프로세스가 정상적으로 종료되지 않는 경우도 있었다.

처음에는 Jibri만 확인했다.

그런데 원인을 따라가다 보니 Chromium과 FFmpeg, X11, PulseAudio, Docker까지 같이 봐야 했다.

Jibri는 X11 Dummy Display와 가상 오디오 환경을 사용한다.

PulseAudio에서는 module-null-sink 관련 버퍼 설정을 조정했다.

Chromium이 사용하는 Docker shared memory도 확인했다.

/dev/shm 크기가 부족하면 Chromium이 가상 렌더링을 처리하는 과정에서 문제가 발생할 수 있었다.

그래서 컨테이너의 shared memory를 2GB 이상으로 늘렸다.

FFmpeg에서도 입력 버퍼 부족과 관련된 문제가 있어서 thread_queue_size를 조정했다.

설정을 하나씩 바꿔가면서 녹화를 반복했다.

녹화가 끝났을 때 프로세스가 남아 있는지 확인하고 영상과 오디오 싱크가 다시 틀어지는지도 확인했다.

그 이후 녹화 중간에 Chromium이 죽거나 종료 후 좀비 프로세스로 남는 문제는 크게 줄었다.

이 문제는 애플리케이션 코드만 보고 해결하기 어려웠다.

Jibri 하나만 보는 게 아니라 그 아래에서 같이 동작하는 구성 요소들을 전부 확인해야 했다.


운영하면서 아쉬웠던 부분

기능은 어느 정도 정리됐지만 운영 관점에서는 부족한 부분이 있었다.

가장 아쉬웠던 건 WebRTC 상태를 충분히 모니터링하지 못했다는 것이다.

사용자가 영상이 끊긴다고 했을 때 서버 CPU만 확인해서는 원인을 알기 어렵다.

RTT가 올라갔을 수도 있고 Jitter가 발생했을 수도 있다.

Packet Loss가 발생했을 수도 있고 MCU에서 미디어 처리가 밀렸을 수도 있다.

브라우저 쪽 문제일 수도 있다.

그래서 다시 만든다면 이 부분은 초기에 넣을 것 같다.

브라우저의 getStats()를 이용해 RTT, Packet Loss, Jitter, Bitrate, FPS, 인코딩 및 디코딩 상태 같은 데이터를 수집하고 서버에서 모아볼 수 있도록 만들고 싶다.

회의실 단위의 정보도 필요하다.

어떤 회의실에 사용자가 몰리는지, 어떤 미디어 서버에서 부하가 높아지는지, 특정 회의실에서 패킷 손실이나 미디어 처리 지연이 반복되는지 확인할 수 있어야 한다.

Prometheus와 Grafana 같은 모니터링 시스템을 붙여서 한 화면에서 확인할 수 있으면 장애 대응이 훨씬 편했을 것이다.


Kubernetes에서 미디어 서버를 운영하면서

일반적인 HTTP 서버를 Kubernetes에 올리는 것과 WebRTC 미디어 서버를 올리는 것은 차이가 있었다.

HTTP 서버는 80이나 443 포트를 중심으로 처리하면 되지만 WebRTC 미디어는 UDP 트래픽을 직접 처리해야 한다.

그래서 미디어 서버에서 사용하는 UDP 포트 범위와 네트워크 구성을 같이 봐야 했다.

특히 여러 미디어 서버 인스턴스를 운영하면 더 복잡해진다.

단순히 Deployment 개수를 늘리는 것으로 끝나지 않는다.

외부에서 들어오는 미디어 트래픽이 어느 인스턴스로 들어가는지, 해당 인스턴스가 필요한 포트를 제대로 받을 수 있는지까지 확인해야 한다.

HostPort를 사용하는 방법이나 별도의 네트워크 구성을 검토했다.

MCU에서는 서버가 미디어를 직접 처리하기 때문에 인스턴스별 자원 사용량도 중요했다.

HTTP 요청 수가 비슷하더라도 회의실 구성이나 참가자 수가 다르면 미디어 처리량은 크게 달라질 수 있다.

그래서 일반적인 웹 서버처럼 CPU 사용률만 보고 스케일링하는 방식으로는 부족한 부분이 있었다.


지금 다시 만든다면

지금 다시 시작한다면 WebRTC 연결부터 만들지는 않을 것 같다.

먼저 전체 서비스에서 어느 정도의 동시 접속을 처리해야 하는지 정하고, 그 기준으로 MCU 서버의 자원 요구량부터 측정할 것이다.

회의실 하나에 몇 명이 들어왔을 때 CPU가 얼마나 증가하는지, 해상도와 프레임레이트가 달라지면 미디어 처리량이 어떻게 변하는지 직접 측정해놓는 게 좋다.

그 데이터를 기준으로 미디어 서버를 배치해야 한다.

OTP 접속도 좀 더 체계적으로 관리할 것 같다.

OTP 발급과 검증, 만료, 재사용 방지, 잘못된 OTP 반복 입력에 대한 처리 등을 별도의 인증 흐름으로 명확하게 분리하고 싶다.

그리고 OTP 인증 이후 발급되는 JWT의 권한도 회의실과 사용자 상태에 맞춰 관리해야 한다.

모니터링도 처음부터 넣을 것이다.

RTT, Packet Loss, Jitter, Bitrate, FPS뿐 아니라 MCU 서버의 인코딩 지연과 CPU, 메모리, 네트워크 사용량도 같이 수집하고 싶다.

회의실별 참가자 수와 미디어 서버별 부하를 연결해서 볼 수 있어야 실제로 서버를 어디에 추가해야 하는지도 판단할 수 있다.

Kubernetes 환경에서 자동으로 확장하는 것도 이 데이터를 기준으로 설계하는 게 맞다고 생각한다.

단순히 CPU가 높다는 이유만으로 서버를 추가하는 것보다 실제 미디어 처리량을 기준으로 판단하는 편이 낫다.


마무리

이번 프로젝트에서 어려웠던 것은 WebRTC API 자체가 아니었다.

회의실에 접속하는 과정부터 생각보다 처리할 게 많았다.

OTP를 이용해서 회의실 접근을 확인하고, 인증이 끝난 뒤에는 JWT를 통해 세션 권한을 관리했다. 호스트와 일반 참가자의 권한도 나눴고 Lobby를 통해 실제 회의 입장 여부도 별도로 처리했다.

회의가 시작되면 참가자 상태를 관리해야 했다.

Prosody에서 발생하는 이벤트를 받아 참석 기록을 만들었는데 occupant-joinedoccupant-left의 순서를 당연하게 생각했다가 Race Condition을 직접 겪었다.

결국 Redis 락과 pending 처리를 추가했다.

미디어 쪽에서는 대규모 접속을 고려해서 MCU 방식을 사용했다.

클라이언트가 여러 참가자의 미디어를 직접 처리하도록 하는 대신 서버에서 미디어를 집약해서 처리하는 구조였다.

대신 서버의 부담이 커졌다.

회의실 수와 참가자 수에 따라 미디어 처리량이 크게 달라졌고 일반적인 HTTP 서버처럼 단순히 요청 수만 보고 서버 상태를 판단할 수 없었다.

녹화도 마찬가지였다.

Jibri를 사용하면서 Chromium, FFmpeg, X11, PulseAudio, Docker까지 같이 확인해야 했다.

한쪽의 문제처럼 보였던 것이 실제로는 다른 계층에서 발생하는 경우가 있었다.

STT 음성 인증도 브라우저 녹음 형식부터 FFmpeg 변환, STT API까지 연결해서 확인해야 했다.

결국 하나의 WebRTC 기능을 만드는 프로젝트라기보다 여러 시스템을 묶어서 실제 서비스로 만드는 작업에 가까웠다.

특히 대규모 접속을 고려하면서 느낀 건 단순히 “많은 사용자를 받을 수 있다”는 것과 실제로 안정적으로 운영할 수 있다는 것은 다르다는 점이다.

서버가 버티는지도 봐야 하고 미디어 처리가 밀리는지도 봐야 한다.

인증이 정상적으로 동작하는지도 봐야 하고 참가자 상태가 정확하게 기록되는지도 확인해야 한다.

그리고 문제가 발생했을 때 원인을 찾을 수 있어야 한다.

다시 만든다면 모니터링을 나중에 붙이지 않을 것이다.

처음부터 필요한 지표를 정의하고 데이터를 모아둘 것 같다.

기능은 나중에도 추가할 수 있다. 하지만 장애가 발생한 뒤 과거의 상태를 다시 만들어내는 것은 어렵다.

이번 프로젝트에서 가장 크게 남은 것도 그 부분이다.

실시간 서비스에서는 정상적으로 동작하는 것만큼이나 문제가 발생했을 때 그 문제를 추적할 수 있는 구조를 만드는 것이 중요했다.