Introduction & Requirements
회의 시작 시간이 되었습니다. 서울의 발표자는 노트북에서 화면을 공유하고, 샌프란시스코의 동료는 사무실 네트워크로 접속합니다. 이동 중인 참가자는 휴대전화의 Wi-Fi 연결이 끊겨 이동통신망으로 전환됩니다. 누군가는 카메라를 켜고, 호스트는 대기실에 있는 사람을 입장시킵니다.
사용자에게는 하나의 회의 화면이지만 서버가 처리하는 일은 서로 다릅니다. 입장 자격을 확인하고, 참가자 목록을 갱신하고, 미디어를 전달할 서버를 배정해야 합니다. 그동안 음성과 영상 패킷은 계속 도착합니다. 호스트가 참가자를 퇴장시키면 목록에서 이름을 지우는 것뿐 아니라 그 참가자의 송신과 수신도 차단해야 합니다.
두 사람이 대화하는 기능은 서로의 주소를 교환하고 직접 미디어를 보내는 방식으로 시작할 수 있습니다. 여러 사람이 참여하면 각 기기가 같은 영상을 여러 상대에게 보내야 합니다. 참가자가 늘수록 업로드 대역폭과 연결 관리 부담이 커지고, 모바일 기기는 다운로드 속도보다 디코딩 성능이나 발열 때문에 먼저 품질을 낮춰야 할 수도 있습니다.
서버 하나가 모든 영상을 받아 합성한 뒤 전달하는 방법도 있습니다. 수신 기기는 합성 영상 하나만 재생하면 되지만, 서버가 디코딩과 합성, 재인코딩을 수행해야 합니다. 사용자마다 다른 화면 배치를 원하면 처리해야 할 출력도 늘어납니다.
이 글에서는 권한과 회의 상태를 처리하는 경로, 시간 안에 도착해야 하는 미디어 경로, 녹화와 통계를 처리하는 경로를 분리하겠습니다. 가장 어려운 상태는 참가자가 목록에는 남아 있지만 미디어 연결은 끊겨 있거나, 퇴장 처리는 저장되었지만 일부 미디어 서버에는 아직 적용되지 않은 상태입니다.
공개된 제품 동작과 설계 가정
2026년 9월 25일에 확인한 Zoom의 대기실 안내는 호스트가 참가자의 입장을 승인하는 동작을 설명합니다. 참가자 관리 안내에는 음소거, 화면 공유 권한, 퇴장 같은 제어 기능이 나옵니다. 음소거 해제는 참가자에게 요청할 수 있으며, 사전 동의를 받은 경우의 동작도 별도로 설명합니다.
Zoom Technical Library의 Zoom Node 배포 안내에는 Hybrid Zone Controller Proxy와 Hybrid Multimedia Router가 등장합니다. 이는 공개된 Hybrid 배포의 구성요소입니다. 이 설명만으로 Zoom의 전체 클라우드 내부 구조를 동일하게 단정할 수는 없습니다.
Zoom의 E2EE 안내는 참가자 기기에서 회의 키를 생성하고, Zoom 서버는 해당 키로 미디어 내용을 복호화할 수 없다고 설명합니다. 같은 안내에서 클라우드 녹화, 실시간 전사 등 일부 기능이 제한된다고 명시합니다.
아래의 PostgreSQL schema, SFU 배치, Queue, 권한 lease, API, 장애 복구 절차는 공개된 제품 동작을 만족시키기 위한 시스템 디자인 가정입니다. 실제 내부 구현 전체는 공개되어 있지 않습니다. WebRTC와 RTP 관련 표준을 설계의 공통 기반으로 사용하지만, Zoom의 모든 클라이언트가 여기의 프로토콜과 API를 그대로 사용한다고 가정하지 않습니다.
Functional Requirements
사용자는 회의를 생성하고, 초대받은 회의에 데스크톱이나 모바일 클라이언트로 참여할 수 있습니다.
호스트는 대기실 입장, 음소거 정책, 화면 공유 권한, 참가자 퇴장, 회의 종료를 제어할 수 있습니다.
참가자는 마이크와 카메라를 직접 켜고 끌 수 있으며, 허용된 경우 화면을 공유할 수 있습니다.
참가자는 발표자 중심 화면이나 여러 사람을 보여주는 화면을 선택할 수 있습니다.
시스템은 참가자의 네트워크와 기기 상태에 맞춰 영상 품질과 수신할 스트림 수를 조정합니다.
시스템은 일시적인 연결 단절 이후 같은 참가자 세션을 복구하고, 이미 취소된 권한을 다시 부여하지 않습니다.
허용된 회의에서는 호스트가 클라우드 녹화를 요청하고, 처리 상태와 완성된 결과를 확인할 수 있습니다.
E2EE 회의에서는 지원 가능한 기능만 허용하며, 참가자 동의 없이 암호화 수준을 낮추지 않습니다.
Non-Functional Requirements
아래 수치는 모두 설계를 위한 가정이며 Zoom의 공개 SLA가 아닙니다.
입장 승인이 끝난 시점부터 수신 가능한 음성 트랙을 재생할 준비가 될 때까지 p95 3초를 목표로 합니다. 대기실에서 호스트를 기다리는 시간은 별도로 측정합니다.
같은 권역의 정상적인 네트워크 환경에서 음성의 송신 기기 입력부터 수신 기기 출력까지 p95 200ms 이내를 목표로 합니다. 대륙 간 경로와 제한된 네트워크는 구분해서 평가합니다.
권한이 있는 입장 시도의 월간 성공률을 99.95%로 둡니다. 정책에 따른 정상 거절은 구분하되, 서버 장애로 인한 실패는 포함합니다.
참가자 퇴장과 강제 음소거 정책은 저장과 실제 미디어 차단을 구분하며, 정상 상태에서 차단 적용 p99 1초를 목표로 합니다.
회의 생성, 권한 변경, 녹화 요청은 재시도해도 동일한 업무 결과가 중복 생성되지 않아야 합니다.
참가자 목록과 통계는 짧은 지연을 허용하지만, 입장 승인과 새 미디어 연결의 권한 검사는 현재 상태를 기준으로 수행합니다.
미디어 서버 하나의 장애가 해당 서버에 연결된 회의 범위를 넘어 전파되지 않도록 자원과 복구 작업을 격리합니다.
녹화 데이터는 접근 권한, 보존 기간, 삭제 정책을 적용하며, 진단 로그에 회의 키와 접속 토큰을 저장하지 않습니다.
비용은 서버 CPU뿐 아니라 미디어 egress, 권역 간 전송, TURN 중계, 녹화 저장과 변환까지 포함합니다.
Out of Scope
대규모 시청자에게 일방향으로 배포하는 Webinar와 라이브 방송
전화망, SIP, H.323 장비와의 연동
캘린더 연동과 반복 회의 일정 관리
영구 채팅, 파일 공유, Whiteboard
자막과 회의 요약 모델의 학습 및 추론
고객사 내부에 배포하는 Hybrid 제품 자체의 구현
여기서 meeting_id는 실제로 진행되는 회의 한 번을 가리킵니다. 반복 일정과 실제 회의 실행을 분리하는 데이터 모델은 이후 확장으로 남깁니다.
Traffic & Capacity Assumptions
이 절의 사용자 수, 회의 시간, 사용 비율, bitrate는 모두 계산을 위한 가정입니다. 실제 Zoom 이용량을 추정한 수치가 아닙니다.
하루 이용자를 1,000만 명, 한 명의 하루 평균 회의 참여를 두 번으로 가정하겠습니다. 하루 2,000만 건의 논리적인 입장 세션이 만들어집니다. 정상 입장의 평균은 초당 약 231건이며, 피크를 평균의 여섯 배로 두면 초당 약 1,389건입니다.
이 수치에는 재접속과 실패 재시도가 포함되지 않습니다. 입장 API만 보고 미디어 서버의 용량을 결정하면 안 됩니다. 회의는 입장 후에도 오랫동안 연결을 유지하기 때문입니다.
한 번 참여하면 평균 30분 동안 머문다고 하겠습니다. 하루 누적 참여 시간은 6억 분이며, 하루 전체에 나누면 평균 동시 접속은 약 41만 7,000명입니다. 동일한 여섯 배 피크 가정에서는 약 250만 명이 동시에 접속합니다.
평균 회의 인원을 여섯 명으로 두면 피크 시점의 활성 회의는 약 41만 7,000개입니다. 연결 수와 활성 회의 수는 서로 다른 자원을 사용합니다. Signaling 서버는 연결 수에, Room Controller는 회의별 상태와 명령량에 영향을 받습니다.
하나의 참여 세션에서 입장, 퇴장, 권한 변경 등 내구성이 필요한 이벤트가 평균 20개 발생한다고 가정하겠습니다. 하루 약 4억 이벤트, 평균 초당 약 4,630건이며, 여섯 배 피크는 초당 약 2만 8,000건입니다.
이는 원본 상태를 저장하고 Outbox로 발행할 경로의 규모입니다. 반면 모든 연결이 10초마다 상태 신호를 보내면 피크에 초당 25만 건이 추가됩니다. 이런 heartbeat를 전부 RDBMS UPDATE로 처리하지 않고 연결 서버의 메모리와 짧은 TTL 상태로 관리합니다.
피크 참가자 한 명의 평균 업로드를 1.2Mbps, 다운로드를 2Mbps로 가정하겠습니다. 오디오와 화면 공유를 포함한 전체 참가자 평균값이며, 모든 사람이 같은 화질을 사용한다는 뜻은 아닙니다. 피크 미디어 ingress는 약 3Tbps, egress는 약 5Tbps입니다.
패킷 헤더와 재전송 등을 위한 25% 여유를 양쪽 합계에 적용하면 약 10Tbps입니다. 이 값에 TURN과 권역 간 중계가 만드는 내부 전송은 별도로 더해야 합니다. SFU 수는 CPU 사용률뿐 아니라 실제 NIC 처리량과 packets_per_second를 측정해 결정해야 합니다.
수신자 한 명이 평균 아홉 개의 영상 트랙을 구독한다고 가정하면 피크에 약 2,250만 개의 영상 구독 관계를 관리합니다. 평균 동시 접속자에게 다운로드 2Mbps를 하루 동안 제공하면 외부 전송량은 약 9PB입니다.
이 9PB는 저장량이 아니라 네트워크를 지나간 양입니다. 작은 영상 타일까지 고화질로 보내면 전송 비용과 기기 디코딩 부담이 함께 증가합니다. 화면 배치에 맞춰 구독과 화질을 제한하는 기능이 용량 설계에 직접 영향을 줍니다.
참여 세션 2,000만 건을 평균 여섯 명으로 나누면 하루 회의는 약 333만 건입니다. 그중 2%가 30분 동안 녹화되고, 합성 녹화 출력이 평균 1.5Mbps라고 가정하면 하루 약 22.5TB가 생성됩니다. 30일 보관하면 약 675TB입니다.
이는 합성 결과 한 벌의 논리적 크기입니다. 개별 트랙 원본, 임시 변환 파일, 복제본은 별도로 계산해야 합니다. 녹화 비율은 작더라도 저장과 변환 작업은 회의가 끝난 뒤 계속 남습니다.
Zoom의 대역폭 계산 안내도 화면 배치, 해상도, 화면 공유 방식에 따라 사용량이 달라진다고 설명합니다. 따라서 실제 운영에서는 단일 bitrate를 모든 회의에 적용하지 않고 기기와 사용 유형별 분포로 위 가정을 교체합니다.
Core Entities & Data Modeling
먼저 없어지면 안 되는 상태와 곧 버려도 되는 상태를 나누겠습니다.
회의가 종료되었다는 사실, 참가자가 입장 승인을 받았다는 사실, 특정 참가자의 발언 권한이 취소되었다는 사실은 장애 후에도 복구되어야 합니다. 이런 상태는 RDBMS transaction으로 변경합니다. 마이크에서 방금 생성된 패킷, 지난 순간의 네트워크 추정값, 현재 화면의 타일 크기는 같은 방식으로 저장하지 않습니다.
주요 이름은 다음 의미로 사용합니다.
meeting_id , 실제로 진행되는 회의 한 번
participant_id , 한 참가자의 회의 참여 세션
principal_id , 인증된 사용자 또는 서버가 발급한 게스트 주체
client_instance_id , 회의에 접속하는 앱 인스턴스
transport_generation , 같은 참여 세션에서 교체된 미디어 연결의 세대
track_id , 마이크, 카메라, 화면 공유 등 하나의 논리적 미디어 소스
controller_epoch , 회의를 제어할 권한을 가진 Controller의 세대
version , 특정 원본 상태의 변경 버전
event_id , 중복 전달을 식별하는 이벤트 번호
idempotency_key , 같은 업무 요청을 재시도할 때 유지하는 값
RDBMS에는 회의 설정, 참가자 입장 상태, 역할, 재입장 제한, 녹화 작업 상태, Outbox를 둡니다. 조건부 변경과 고유 제약이 필요하기 때문입니다. 계정 정보 전체를 회의 shard에 복사하지 않고, 회의의 권한 판단에 필요한 식별자와 정책만 보관합니다.
Event Stream에는 이미 확정된 회의 이벤트와 집계용 품질 이벤트를 보냅니다. 녹화 파일은 Object Storage에 두고, RDBMS에는 소유권과 처리 상태, 객체 위치를 기록합니다. 녹화 바이너리 자체를 DB row에 넣으면 백업과 복제 비용도 함께 커집니다.
Cache에는 조회용 회의 요약과 참가자 목록을 둡니다. 실제 미디어 연결과 스트림 구독은 SFU 메모리에 둡니다. SFU가 종료되면 이 연결 상태는 새로 구성하며, packet sequence를 DB에서 복원하려 하지 않습니다.
Analytics 저장소에는 익명화하거나 접근을 제한한 품질 집계를 보관합니다. 현재 범위에는 Search Index, Vector Index, Graph Index, Online Feature Store가 필요하지 않습니다. 실시간 영상 전달이라는 이유만으로 ML serving 계층을 추가하지 않습니다.
회의와 참가자 원본 상태
아래 PostgreSQL 예시는 중요한 제약을 보여주는 최소 schema입니다. 인증 서비스, 권한 검사 코드, 녹화 작업과 재입장 제한 테이블은 별도로 둡니다.
CREATE TABLE meetings (
meeting_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
organizer_id uuid NOT NULL,
idempotency_key text NOT NULL,
creation_request jsonb NOT NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'live', 'ending', 'ended')),
encryption_mode text NOT NULL
CHECK (encryption_mode IN ('transport', 'e2ee')),
waiting_room_enabled boolean NOT NULL,
controller_epoch bigint NOT NULL DEFAULT 0
CHECK (controller_epoch >= 0),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, organizer_id, idempotency_key)
);
CREATE TABLE meeting_participants (
meeting_id uuid NOT NULL REFERENCES meetings(meeting_id),
participant_id uuid NOT NULL,
principal_id uuid NOT NULL,
client_instance_id uuid NOT NULL,
idempotency_key text NOT NULL,
role text NOT NULL
CHECK (role IN ('host', 'cohost', 'attendee')),
admission_state text NOT NULL
CHECK (admission_state IN ('waiting', 'admitted', 'left', 'removed')),
audio_publish_allowed boolean NOT NULL DEFAULT false,
screen_publish_allowed boolean NOT NULL DEFAULT false,
transport_generation bigint NOT NULL DEFAULT 0
CHECK (transport_generation >= 0),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (meeting_id, participant_id),
UNIQUE (meeting_id, principal_id, idempotency_key)
);
CREATE UNIQUE INDEX one_active_device_session
ON meeting_participants (meeting_id, principal_id, client_instance_id)
WHERE admission_state IN ('waiting', 'admitted');
CREATE UNIQUE INDEX one_admitted_host
ON meeting_participants (meeting_id)
WHERE role = 'host' AND admission_state = 'admitted';
CREATE TABLE outbox_events (
event_id uuid PRIMARY KEY,
meeting_id uuid NOT NULL REFERENCES meetings(meeting_id),
aggregate_version bigint NOT NULL CHECK (aggregate_version > 0),
event_type text NOT NULL,
idempotency_key text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
UNIQUE (meeting_id, event_type, idempotency_key)
);
CREATE INDEX outbox_unpublished
ON outbox_events (created_at, event_id)
WHERE published_at IS NULL;status를 boolean으로 만들지 않은 이유는 종료 요청과 실제 종료 완료를 구분해야 하기 때문입니다. ending 동안에는 새 입장을 거절하지만, 기존 연결 차단과 녹화 종료 작업은 아직 진행 중일 수 있습니다.
created_at은 원본 객체가 생긴 시간이고 updated_at은 마지막 변경 시간입니다. 순서를 판단할 때는 기기의 시계가 아니라 version을 사용합니다. Controller가 교체되는 문제는 데이터 수정과 별개이므로 controller_epoch도 따로 둡니다.
audio_publish_allowed는 서버의 허가입니다. 사용자가 마이크를 켰다는 뜻이 아닙니다. 실제 송신은 서버의 허가, 사용자의 현재 선택, 유효한 연결이 모두 있을 때만 가능합니다. 호스트가 권한을 다시 허용해도 앱이 사용자의 마이크를 자동으로 켜지 않도록 합니다.
admission_state 역시 네트워크 연결 상태가 아닙니다. admitted 참가자가 잠시 연결을 잃으면 입장 승인은 유지하면서 연결 상태를 reconnecting으로 보여줄 수 있습니다. 허용된 복구 시간이 지나거나 사용자가 나가면 left로 확정합니다.
퇴장 후 재입장을 막을 때는 participant_id만 차단하면 부족합니다. 새 참여 세션을 만들 수 있으므로 principal_id를 기준으로 별도 제한을 검사합니다. 익명 게스트가 새 신원으로 들어오는 것까지 강하게 막아야 한다면 로그인이나 검증된 초대 자격이 필요합니다.
RDBMS에서 제외할 데이터
음성과 영상 패킷은 재생 시한이 지나면 가치가 급격히 줄어듭니다. 매 패킷을 OLTP DB에 기록하면 저장 완료를 기다리는 시간이 미디어 경로에 들어갑니다. 실시간 전달은 SFU의 메모리 Queue로 처리하고, 녹화가 허용된 경우에만 별도 Worker가 파일로 저장합니다.
RTP sequence, jitter buffer, 재전송용 짧은 packet cache는 연결에 종속된 상태입니다. 이 값을 DB에 보존해도 끊어진 네트워크 연결을 그대로 되살릴 수 없습니다. SFU와 클라이언트 메모리에 두고 연결 교체 시 초기화합니다.
heartbeat와 연결 품질 표본도 개별 RDBMS UPDATE로 만들지 않습니다. 연결 서버가 현재 상태를 유지하고, 시간 구간별 통계와 의미 있는 상태 변화만 내보냅니다.
영상 구독 목록, 타일 크기, 현재 선택한 화질은 자주 바뀝니다. SFU와 클라이언트의 세션 상태로 처리하며, 복구 시 클라이언트가 최신 화면 배치를 다시 보냅니다. 과거 타일 변경을 전부 재생할 필요는 없습니다.
패킷 손실과 프레임 정지 같은 원시 진단 데이터는 수집량을 제한해 분석 저장소로 보냅니다. 모든 참가자의 매 패킷 trace를 상시 수집하면 미디어 서비스와 별도로 큰 수집 시스템이 필요합니다.
API Signatures
아래는 이 글에서 설계하는 서비스의 API입니다. Zoom의 실제 API 명세가 아닙니다. tenant와 principal은 검증된 인증 정보에서 얻으며, 요청 본문의 주장만으로 권한을 부여하지 않습니다.
회의 생성
POST /v1/meetings HTTP/1.1
Content-Type: application/json
Idempotency-Key: create-meeting-7f23
{
"title": "Architecture Review",
"waiting_room_enabled": true,
"encryption_mode": "transport"
}{
"meeting_id": "f3d14245-8010-4a06-8a7b-8c742066a752",
"status": "scheduled",
"version": 1
}원본 회의와 Outbox가 같은 transaction으로 저장되면 응답합니다. 미디어 서버의 실제 연결은 입장 시점에 구성합니다. 같은 주체가 같은 키와 같은 생성 내용으로 재시도하면 기존 meeting_id를 반환하고, 내용이 다르면 409 Conflict로 거절합니다.
입장 신청
POST /v1/meetings/{meeting_id}/participants HTTP/1.1
Content-Type: application/json
Idempotency-Key: join-9c11
{
"client_instance_id": "3b48d820-d385-46ed-bb82-0ea9c649ad71",
"display_name": "Richard",
"capabilities": {
"video_codecs": ["VP8", "H264"],
"simulcast": true,
"e2ee": true
}
}{
"participant_id": "d3f88423-45eb-4de3-8dc7-1456575c5313",
"admission_state": "waiting",
"participant_version": 1,
"media_access": "not_granted"
}회의 종료 여부, 재입장 제한, 인증 조건을 확인하고 입장 신청을 저장합니다. 대기실 참가자에게는 회의의 미디어 접근 권한을 주지 않습니다. 응답이 유실되어도 같은 키로 재시도하므로 참가자 목록에 같은 신청이 반복해서 생기지 않습니다.
승인 후 미디어 연결 준비
POST /v1/meetings/{meeting_id}/participants/{participant_id}/transports HTTP/1.1
Content-Type: application/json
Idempotency-Key: transport-attempt-31
{
"expected_transport_generation": 0,
"device_key_id": "device-key-82"
}{
"transport_generation": 1,
"controller_epoch": 12,
"region": "us-west",
"signaling_endpoint": "wss://signal.example.com/v1/connect",
"connect_ticket": "short-lived-device-bound-ticket",
"ticket_expires_in_seconds": 30
}이 API는 현재 승인 상태를 다시 확인하고 연결 시도를 예약합니다. 응답의 ticket은 연결 협상을 시작할 자격이며, 계속 미디어를 보낼 수 있는 무기한 권한이 아닙니다. SFU 선택과 협상 정보는 제공하지만, 상대 음성이 재생될 때까지 HTTP 응답을 붙잡지는 않습니다.
동일한 연결 시도는 같은 generation으로 복구합니다. 이전 시도의 결과가 만료되었다면 현재 generation을 확인한 뒤 새 키로 다음 시도를 시작합니다. 재시도마다 generation을 무조건 늘리면 늦게 도착한 정상 연결까지 반복해서 끊을 수 있습니다.
권한 변경
POST /v1/meetings/{meeting_id}/commands HTTP/1.1
Content-Type: application/json
Idempotency-Key: revoke-audio-19
{
"type": "revoke_audio_publish",
"participant_id": "d3f88423-45eb-4de3-8dc7-1456575c5313",
"expected_participant_version": 4
}{
"command_id": "cmd-6a20",
"state": "committed",
"participant_version": 5,
"media_enforcement": "pending"
}Controller는 호출자의 현재 역할을 확인하고 version이 일치할 때만 권한을 바꿉니다. 응답은 저장이 완료되었다는 뜻입니다. 실제 미디어 서버 적용은 별도 상태로 전달하며, 클라이언트도 완료 전에는 적용 중으로 표시합니다.
remove_participant, transfer_host, end_meeting, start_recording도 명령으로 처리하되 필요한 상태 조건과 권한 검사를 따로 둡니다. 단순한 toggle 대신 원하는 상태를 명시해야 중복 요청 때문에 음소거가 다시 해제되는 문제가 생기지 않습니다.
이벤트 복구와 현재 상태
GET /v1/meetings/{meeting_id}/snapshot HTTP/1.1
Authorization: Bearer session-token{
"meeting_status": "live",
"controller_epoch": 12,
"control_cursor": "12.1842",
"participant_count": 18,
"self": {
"admission_state": "admitted",
"participant_version": 5,
"audio_publish_allowed": false
}
}Signaling 연결을 다시 열 때는 마지막으로 적용한 제어 이벤트 위치를 보냅니다.
{
"type": "resume",
"connect_ticket": "short-lived-device-bound-ticket",
"resume_after": "12.1842"
}서버가 이후 이벤트를 보관하고 있으면 이어서 보내고, 보관 범위를 벗어났거나 Controller 세대가 바뀌면 snapshot을 요구합니다. snapshot과 cursor는 같은 제어 상태 기준점에서 생성합니다. 목록만 오래된 Cache에서 읽고 최신 cursor를 붙이면 중간 이벤트를 영구적으로 놓칠 수 있습니다.
이 cursor는 제어 이벤트 복구용입니다. RTP 패킷의 sequence나 녹화 파일 위치를 대신하지 않습니다. 권한 검사와 snapshot의 개인별 필터링을 적용해 다른 참가자의 비공개 정보를 노출하지 않습니다.
High-Level Design
회의 제어 경로
Meeting Service는 회의 생성과 조회를 처리합니다. 진행 중인 회의의 권한 명령은 해당 회의의 Room Controller로 전달합니다. Controller는 한 회의의 변경 순서를 정하고 DB에 저장하며, SFU에 적용할 권한 버전을 전달합니다.
Signaling Gateway는 오래 유지되는 클라이언트 연결과 협상 메시지를 처리합니다. 이 서버가 재시작되었다고 마이크에서 생성된 미디어까지 반드시 멈출 필요는 없습니다. 미디어 연결은 별도로 유지하되, 뒤에서 설명할 권한 유효 기간을 넘어서 계속 송신하도록 허용하지는 않습니다.
Authority Directory는 어떤 Controller가 회의를 담당하며 어느 세대의 제어 권한을 가졌는지 알려줍니다. 단순한 조회 Cache와 달리 담당자 변경은 강한 정합성이 필요합니다. 오래된 담당자가 새 담당자와 동시에 권한을 발급하면 퇴장한 참가자가 다시 연결될 수 있기 때문입니다.
Outbox와 Event Stream은 조회용 상태와 후속 작업을 복구 가능하게 만듭니다. 다만 즉각적인 퇴장 적용을 일반 이벤트 소비 속도에만 맡기지 않습니다. Controller가 SFU로 직접 적용을 요청하고, 이벤트 경로는 누락과 장애를 복구하는 역할도 담당합니다.
실시간 미디어 경로
미디어는 일반 HTTP API와 RDBMS를 통과하지 않습니다. 클라이언트가 SFU로 보내고, SFU는 수신자별 구독에 맞는 패킷을 전달합니다. 네트워크 제약 때문에 SFU와 직접 통신할 수 없으면 TURN Relay를 경유합니다.
여기서 직접 연결은 다른 참가자와의 P2P 연결이 아니라 클라이언트와 SFU 사이의 연결입니다. TURN은 연결 가능한 경로를 제공하고, SFU는 누가 어떤 트랙을 받을지 결정합니다. 중계한다는 공통점이 있어도 두 컴포넌트의 책임은 다릅니다.
권역이 다른 SFU 사이에는 수신자가 실제로 필요로 하는 트랙과 품질 계층만 전달합니다. 다이어그램의 양방향 화살표는 참가자 모두의 모든 영상을 모든 권역에 복제한다는 뜻이 아닙니다.
녹화 경로
Recorder는 권한을 가진 미디어 구독자로 동작합니다. 파일 변환이 오래 걸리거나 저장소 응답이 늦어져도 SFU의 실시간 전송 Queue에 녹화 작업을 밀어 넣지 않습니다. 녹화는 processing이나 partial 상태로 남길 수 있지만, 그 때문에 모든 참가자의 음성이 늦게 재생되어서는 안 됩니다.
미디어 전달 방식을 선택하는 과정
P2P Mesh로 가능한 범위
두 사람이 같은 지역에서 통화하고 녹화나 서버 제어가 단순하다면 P2P는 서버 전송 비용을 줄일 수 있습니다. 그러나 N명이 서로에게 같은 영상을 보내는 Mesh에서는 각 기기가 최대 N-1개의 상대에게 송신해야 합니다.
영상 하나가 1Mbps이고 참가자가 20명이라면 한 기기의 영상 업로드만 최대 19Mbps입니다. 오디오와 패킷 부가 비용은 별도입니다. 같은 인코딩 결과를 재사용할 수 있어도 네트워크로 보내는 복사본은 줄어들지 않습니다.
따라서 소수 참가자와 안정적인 네트워크에서는 가능하지만, 참가자 수가 커지고 모바일 참여가 많아지면 중앙 미디어 서버를 두는 편이 합리적입니다. 이 선택은 단말 업로드 부담을 줄이는 대신 서비스 제공자의 egress와 미디어 서버 운영 비용을 늘립니다.
서버 합성과 선택적 전달
여러 참가자의 영상을 서버에서 합성해 하나의 영상으로 보내는 MCU 방식은 수신 기기의 부담을 낮춥니다. 저성능 단말 지원이나 녹화 결과 생성에서는 유용합니다. 대신 서버가 프레임을 복원하고 다시 인코딩해야 하므로 계산량과 지연이 추가됩니다.
이 설계의 기본 회의 경로는 필요한 스트림을 골라 전달하는 SFU입니다. 수신자마다 다른 트랙과 품질을 선택하며, 기본 영상 전달 경로에서는 디코딩과 재인코딩을 하지 않습니다. IETF의 RTP Topologies 문서는 이런 선택적 전달 구조를 별도로 설명합니다.
SFU를 사용한다고 전체 전송량이 자동으로 선형이 되는 것은 아닙니다. 모든 사람이 나머지 모든 영상을 받으면 여전히 N×(N-1)개의 전달 관계가 생깁니다. 수신자별 영상 구독 수를 K개로 제한할 때 전달 관계를 대략 N×K로 제한할 수 있습니다.
초기에는 회의 하나를 SFU 하나에 배치합니다. 여러 SFU를 사용하면 회의의 미디어 용량을 늘릴 수 있지만, 권역 간 구독 관리와 중복 전달 방지, 장애 복구가 추가됩니다. 회의가 실제로 한 서버의 대역폭이나 패킷 처리 한계에 접근할 때 분산을 검토합니다.
연결을 만드는 과정과 NAT 통과
클라이언트는 입장 승인 이후 지원하는 codec과 암호화 기능, 연결 후보를 교환합니다. 브라우저 기반 설계에서는 SDP로 미디어 협상 내용을 전달하고 ICE로 통신 가능한 경로를 검사합니다.
STUN은 외부에서 관측되는 주소를 알아내는 데 사용합니다. STUN 서버가 회의 영상을 계속 전달하는 것은 아닙니다. 직접적인 UDP 경로가 성립하지 않으면 TURN 중계를 시도하며, UDP가 차단된 환경에서는 TURN 서버까지 TCP나 TLS로 연결하는 경로도 준비합니다. 이 구분은 IETF의 Transports for WebRTC에서 다룹니다.
UDP 경로에서는 일부 패킷이 없어도 다음 패킷을 받을 수 있습니다. TCP 구간에서는 손실된 데이터를 복구하는 동안 뒤의 데이터 전달도 늦어질 수 있습니다. 따라서 TCP 중계는 연결 가능성을 높이는 대안이지만, 같은 네트워크에서 UDP와 동일한 미디어 품질을 보장하지 않습니다.
TURN 서버는 인증된 짧은 수명의 자격을 요구하고 목적지와 자원 사용을 제한합니다. 공개 중계기로 방치하면 회의 서비스와 무관한 트래픽에도 대역폭이 사용될 수 있습니다.
공통 codec이 없거나 요청한 E2EE 기능을 지원하지 않는 기기는 협상 단계에서 명확한 오류를 받습니다. 실패했다고 조용히 암호화를 낮추거나 모든 회의에 실시간 transcoding을 추가하지 않습니다. 호환성용 변환 경로가 필요하면 자원과 기능을 따로 설계합니다.
화면 배치와 네트워크에 맞춘 품질 조정
Simulcast와 SVC
발표자의 얼굴을 크게 보는 참가자와 작은 타일로 보는 참가자에게 같은 해상도를 보낼 필요는 없습니다. 송신자가 같은 카메라를 여러 품질로 인코딩해 보내면 SFU가 수신자마다 적절한 버전을 선택할 수 있습니다. 이를 simulcast라고 부르며, IETF RFC 8853이 RTP와 SDP에서의 처리 방법을 설명합니다.
설계 예시로 낮은 품질 150kbps, 중간 품질 400kbps, 높은 품질 1.2Mbps를 동시에 보내면 영상 업로드 합계는 1.75Mbps입니다. 높은 품질 하나만 보내는 것보다 업로드와 인코딩 부담이 늘어납니다. 대신 SFU가 매 수신자용 영상을 새로 인코딩하지 않아도 됩니다.
SVC는 기본 계층에 추가 계층을 더해 시간적 또는 공간적 품질을 높이는 방식입니다. 수신자가 필요한 계층까지만 받아 사용할 수 있지만, codec 지원과 계층 간 의존성을 확인해야 합니다. 이 글에서 simulcast와 SVC는 구현 선택지이며, Zoom의 현재 모든 영상이 특정 방식으로 처리된다는 주장은 아닙니다.
모바일 기기가 발열로 인코딩 속도를 유지하지 못하면 네트워크가 좋아도 품질을 낮춥니다. 모든 기기에서 항상 세 개의 인코딩을 생성하지 않고 실제 구독 수요와 하드웨어 지원을 반영합니다. 사용되지 않는 높은 계층은 중지해 배터리와 업로드를 줄일 수 있습니다.
클라이언트의 요청과 서버의 배정
클라이언트는 보고 싶은 track_id, 타일 크기, 화면 표시 여부, 고정한 발표자를 전달합니다. 이 요청은 원하는 화면을 설명할 뿐, 그 품질을 받을 권한이나 용량을 보장하지 않습니다.
SFU는 수신자의 추정 대역폭에서 오디오와 제어에 필요한 여유를 남긴 뒤 영상 예산을 배정합니다. 카메라 타일 수와 해상도를 줄이고, 문서를 읽는 회의에서는 화면 공유의 글자 선명도를 유지하도록 조정합니다. 움직임이 많은 동영상을 공유하는 경우에는 프레임 수의 가치가 더 커집니다.
Zoom의 대역폭 안내도 일반 화면 공유와 움직임 중심 공유의 프레임 및 대역폭 특성을 구분합니다. 여기의 구체적인 예산 배정 정책은 별도의 설계 가정입니다.
송신자에서 SFU까지의 업로드 혼잡과 SFU에서 특정 수신자까지의 다운로드 혼잡을 따로 제어합니다. 한 수신자의 네트워크가 느리다는 이유로 다른 모든 사람의 영상을 동일하게 낮출 필요는 없습니다. 반대로 송신자의 업로드 자체가 부족하면 SFU에서 수신 품질만 바꿔서는 문제를 해결할 수 없습니다.
화질 변경에는 여유 구간을 둡니다. 잠깐의 개선마다 높은 계층으로 올리면 곧 다시 낮추는 동작이 반복됩니다. 손실률, 대기 중인 패킷의 시간, 수신 피드백이 일정 기간 회복되었을 때 단계적으로 올립니다.
구독 변경과 keyframe
새로운 영상이나 다른 인코딩 계층으로 전환할 때는 그 지점부터 복원 가능한 프레임이 필요합니다. 높은 계층의 임의 패킷부터 전달하면 수신자가 필요한 참조 프레임을 갖고 있지 않을 수 있습니다.
SFU는 codec과 계층 구조에 맞는 전환 지점을 기다리고, 필요한 경우 송신자에게 새 복원 기준 프레임을 요청합니다. 참가자 수백 명이 같은 발표자를 선택하더라도 요청을 합쳐서 보내야 합니다. 참가자마다 요청을 그대로 전달하면 큰 keyframe 생성이 반복될 수 있습니다.
track_id는 재접속 전후의 논리적 카메라를 나타내고, RTP의 SSRC와 sequence는 실제 전송 세션에서 해석합니다. SFU가 수신자별 헤더를 조정할 수 있으므로 SSRC를 서비스 전체의 영구 참가자 ID처럼 사용하지 않습니다. IETF의 RTP Topologies도 선택적 전달 시 RTP 식별자와 sequence 관리 문제를 설명합니다.
늦은 패킷을 언제 포기할 것인가
지연 예산을 나누기
음성 지연은 SFU 처리 시간만으로 결정되지 않습니다. 설계 예시로 입력 프레임을 모으는 데 20ms, 인코딩에 10ms, 양쪽 네트워크 구간에 합계 60ms, SFU 처리에 5ms, 수신 버퍼에 40ms, 디코딩과 출력에 15ms를 배정하면 총 150ms입니다.
이 숫자는 각 단계의 예산입니다. 단계별 p95를 더했다고 전체 p95가 되는 것은 아닙니다. 실제 송수신 측정으로 전체 분포를 확인하고, 대륙 간 통신에는 더 큰 네트워크 지연이 필요하다는 점을 반영해야 합니다.
수신자는 도착 간격이 일정하지 않은 패킷을 잠시 모아 재생 간격을 맞춥니다. 이것이 jitter buffer입니다. 버퍼를 크게 만들면 순간적인 지연 변화는 흡수할 수 있지만 대화의 응답 시간도 늘어납니다. 따라서 대기 시간과 재생 누락 사이에서 조절합니다.
재전송과 복구 데이터
수신자는 잃어버린 패킷을 알려 재전송을 요청할 수 있습니다. NACK은 손실 패킷을 알리는 피드백이며, PLI는 영상 복원을 위한 새 기준 프레임이 필요함을 알리는 피드백입니다. 두 신호를 같은 의미로 취급하지 않습니다. IETF의 RTP 피드백 문서와 WebRTC 미디어 전송 문서가 이러한 메커니즘을 설명합니다.
재생까지 30ms가 남았는데 재전송 왕복에 100ms가 필요하다면 해당 패킷을 기다려도 제시간에 재생하기 어렵습니다. 이때는 손실을 보완하는 재생 처리나 다음 복원 가능한 영상으로 진행합니다. 오래된 음성을 뒤늦게 모두 재생하면 현재 대화에서 더 멀어집니다.
미리 복구용 데이터를 함께 보내는 FEC는 왕복을 기다리지 않고 일부 손실을 복원할 수 있지만 평상시 대역폭도 사용합니다. 모든 손실에 재전송과 FEC를 최대한 적용하지 않고, 남은 시간과 혼잡 상태에 맞춰 사용합니다.
SFU의 송신 Queue에는 byte 한도뿐 아니라 시간 한도도 둡니다. 소비 속도보다 패킷이 들어오는 속도가 빠르면 Queue에 쌓인 미디어가 점점 오래됩니다. 이런 backpressure 상황에서 무한히 보관하는 대신 영상 계층을 낮추고 재생 시한을 넘긴 데이터를 버립니다.
버릴 때도 영상 의존성을 고려합니다. 참조 프레임을 제거하고 종속 프레임만 보내면 수신자가 영상을 복원하지 못합니다. codec에 맞춰 프레임이나 계층 단위로 중지하고, 필요하면 새 keyframe에서 재개합니다.
음성과 영상의 시간 맞추기
음성과 영상은 도착 순서가 달라도 같은 시점을 재생해야 합니다. RTP timestamp와 송신 측 시간 관계를 이용해 재생 기준을 맞추고, 수신 측에서 두 트랙의 시차를 관측합니다.
영상이 늦어진다고 음성을 끝없이 기다리게 하지 않습니다. 이 설계에서는 대화가 이어지는 것을 우선하고, 영상 프레임을 건너뛰거나 잠시 정지한 화면을 보여줄 수 있습니다. 사용자가 보는 화질과 듣는 대화 품질을 각각 평가해야 하는 이유입니다.
회의 권한과 미디어 연결의 정합성
한 회의의 명령 순서
호스트가 참가자를 퇴장시키는 동시에 해당 참가자가 재접속한다고 하겠습니다. 두 요청이 서로 다른 서버에서 오래된 권한을 읽으면 퇴장 후 새로운 미디어 연결이 만들어질 수 있습니다.
한 회의에는 유효한 Controller를 하나만 두고, 입장 승인과 퇴장, 호스트 이전 같은 명령을 순서대로 처리합니다. DB 변경은 현재 controller_epoch와 대상 version을 조건으로 실행합니다. 영향을 받은 row가 없으면 성공으로 응답하지 않고 현재 상태를 다시 확인합니다.
Controller의 실행 권한은 강한 정합성을 제공하는 조정 계층의 lease로 제한합니다. lease는 일정 시간 동안만 유효한 담당 권한입니다. 새 Controller는 이전 담당자를 차단하고 새 epoch를 확보한 뒤 DB의 원본 상태를 읽습니다. 이전 세대의 DB 변경과 SFU 제어 요청은 거절됩니다.
Controller의 lease 갱신은 여러 회의를 담당하는 프로세스 단위로 묶을 수 있습니다. 참가자 heartbeat나 미디어 패킷마다 DB transaction을 실행하지 않습니다. 다만 모든 권한 변경은 현재 Controller를 통과해야 하며, lease나 DB 변경 결과를 확신할 수 없으면 권한 확대와 관련 권한 갱신을 중단합니다.
호스트 이전은 회의 row를 잠근 transaction 안에서 기존 역할 해제와 새 역할 부여를 함께 수행합니다. 두 호스트가 동시에 존재하지 않도록 unique constraint도 사용합니다. 원래 회의를 만든 organizer_id와 현재 진행을 제어하는 host 역할을 구분하면 호스트 이전 때문에 소유권 정보가 바뀌지 않습니다.
퇴장은 DB UPDATE만으로 완료되지 않는다
DB에 removed를 저장한 뒤에도 SFU가 이미 수락한 연결은 남아 있을 수 있습니다. 따라서 Controller는 관련 ingress와 egress SFU에 변경된 참가자 version을 전달하고 적용 확인을 받습니다.
송신 차단은 해당 참가자가 새 패킷을 보내지 못하게 하는 일입니다. 수신 차단은 그 참가자에게 다른 사람의 패킷을 더 보내지 않는 일입니다. 여러 SFU를 거친 트랙과 재전송 cache, 아직 송신되지 않은 Queue에도 해당 제어가 적용되어야 합니다.
적용 확인이 누락된 서버에는 유효 기간이 짧은 미디어 권한을 사용합니다. 예를 들어 최대 3초의 권한 유효 기간을 설계할 수 있습니다. SFU는 갱신을 받지 못하면 전달을 중단하고, 새 epoch의 제어를 수락하면 이전 세대 권한을 폐기합니다.
이 기간이 실제 차단 상한으로 작동하려면 시계 오차를 보수적으로 반영하고, 권한 만료가 Controller lease보다 늦지 않도록 해야 합니다. 이전 Controller가 분리된 상태에서 권한을 계속 연장할 수 있으면 짧은 TTL도 차단을 보장하지 못합니다.
원본 변경은 committed, 모든 관련 경로의 확인이나 유효 기간 종료를 확인한 상태는 applied로 구분합니다. 이미 참가자 기기에 도착한 패킷이나 로컬에 저장한 내용을 회수할 수는 없습니다. 완료 상태는 이후의 전달을 차단했다는 의미로 정의합니다.
이 선택은 권한을 확인할 수 없는 장애에서 일부 통화를 멈추게 할 수 있습니다. 대신 오래된 권한으로 계속 참여하는 상황을 제한합니다. 음소거 버튼의 표시만 바꾸는 구현과 서비스가 송신 권한을 강제하는 구현은 이 지점에서 달라집니다.
Transactional Outbox와 재시도
회의 생성 row는 저장했지만 이벤트 발행 전에 서버가 종료될 수 있습니다. 반대로 이벤트를 먼저 발행하면 DB에 없는 회의를 다른 서비스가 관측할 수 있습니다. 원본 변경과 발행할 이벤트를 같은 DB transaction에 기록하는 Outbox를 사용해 이 간격을 처리하겠습니다.
다음 함수는 앞의 schema에서 회의 생성과 이벤트 기록을 수행합니다. 같은 키가 이미 존재하면 실제로 저장된 meeting_id를 조회해서 사용합니다. 입력으로 받은 새 UUID를 그대로 이벤트에 넣지 않습니다.
CREATE OR REPLACE FUNCTION create_meeting_with_outbox(
p_proposed_id uuid,
p_tenant_id uuid,
p_organizer_id uuid,
p_idempotency_key text,
p_title text,
p_encryption_mode text,
p_waiting_room_enabled boolean
) RETURNS uuid
LANGUAGE plpgsql
AS $$
DECLARE
v_request jsonb;
v_meeting meetings%ROWTYPE;
BEGIN
v_request := jsonb_build_object(
'title', p_title,
'encryption_mode', p_encryption_mode,
'waiting_room_enabled', p_waiting_room_enabled
);
INSERT INTO meetings (
meeting_id, tenant_id, organizer_id, idempotency_key,
creation_request, title, encryption_mode, waiting_room_enabled
) VALUES (
p_proposed_id, p_tenant_id, p_organizer_id, p_idempotency_key,
v_request, p_title, p_encryption_mode, p_waiting_room_enabled
)
ON CONFLICT (tenant_id, organizer_id, idempotency_key)
DO NOTHING;
SELECT m.* INTO STRICT v_meeting
FROM meetings AS m
WHERE m.tenant_id = p_tenant_id
AND m.organizer_id = p_organizer_id
AND m.idempotency_key = p_idempotency_key
FOR UPDATE;
IF v_meeting.creation_request IS DISTINCT FROM v_request THEN
RAISE EXCEPTION 'idempotency key reused with different request';
END IF;
INSERT INTO outbox_events (
event_id, meeting_id, aggregate_version,
event_type, idempotency_key, payload
) VALUES (
gen_random_uuid(), v_meeting.meeting_id, 1,
'meeting.created', p_idempotency_key,
jsonb_build_object(
'meeting_id', v_meeting.meeting_id,
'created_at', v_meeting.created_at,
'request', v_meeting.creation_request
)
)
ON CONFLICT (meeting_id, event_type, idempotency_key)
DO NOTHING;
RETURN v_meeting.meeting_id;
END;
$$;
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT create_meeting_with_outbox(
'f3d14245-8010-4a06-8a7b-8c742066a752',
'6e96d682-30cd-420a-b89a-a853a6fd84be',
'03552713-71c7-4390-a86c-f3b2fa45e0c1',
'create-meeting-7f23',
'Architecture Review',
'transport',
true
);
COMMIT;입력 tenant와 organizer는 호출 전에 검증합니다. 같은 키의 내용이 다르면 함수가 transaction을 실패시키고, API 계층은 이를 충돌 응답으로 변환합니다. 유효한 생성 요청의 재시도는 기존 UUID와 원래 생성 내용을 유지합니다.
READ COMMITTED에서 ON CONFLICT DO NOTHING이 다른 transaction과 충돌하면, INSERT 시점의 snapshot에서는 충돌 row를 읽지 못할 수 있습니다. 따라서 같은 SQL 문장의 CTE 결과만으로 기존 row를 찾는 방식에 의존하지 않고 다음 statement에서 조회합니다. PostgreSQL의 Transaction Isolation 문서가 이 동작을 설명합니다.
이벤트의 aggregate_version은 해당 이벤트가 변경한 원본 객체의 버전입니다. 참가자 이벤트라면 payload에 participant_id도 포함하며, 회의 전체 이벤트의 순서는 별도 control cursor로 관리합니다. 서로 다른 참가자의 version 5를 같은 객체의 버전으로 비교하지 않습니다.
Relay가 이벤트를 발행한 직후 published_at을 기록하기 전에 종료되면 같은 이벤트가 다시 발행될 수 있습니다. 따라서 전달 방식은 at-least-once이고, consumer는 event_id 중복을 처리해야 합니다. DB에 결과를 쓰는 consumer라면 소비 기록과 업무 변경을 같은 transaction으로 묶습니다.
외부 작업에서는 event_id만으로 충분하지 않을 수 있습니다. 녹화 시작처럼 지속되는 작업은 recording_job_id와 작업 세대를 기준으로 중복 실행을 막습니다. 원본 및 Outbox의 멱등성 기록은 API가 허용하는 재시도 기간보다 먼저 삭제하지 않습니다.
Eventual Consistency를 허용하는 범위
참가자 목록은 원본 회의 상태에서 만들어지는 조회 결과입니다. 원본보다 조금 늦어도 괜찮지만, 어떤 시간 차이를 허용하는지 구체적으로 정해야 합니다.
다음은 퇴장 명령을 처리하는 정상 경로의 예시 시간이며 설계 가정입니다.
10:00:00.000
참가자의 admission_state를 removed로 저장
10:00:00.040
Controller가 관련 SFU에 권한 철회를 전달
10:00:00.120
관련 SFU가 송신과 수신 차단을 확인
10:00:00.200
조회용 참가자 목록에서 퇴장 상태를 반영
10:00:00.400
다른 참가자 화면의 목록 갱신다른 사람의 화면에 이름이 잠시 남아 있는 것은 허용할 수 있습니다. 그러나 그 목록을 근거로 새 transport를 발급하면 안 됩니다. 새 입장과 재접속은 현재 Controller의 원본 권한을 확인하고, SFU는 자신에게 적용된 권한 버전과 유효 기간을 검사합니다.
차단 대상 SFU의 확인이 늦으면 시간 예시처럼 바로 applied가 되지 않습니다. 유효 기간 종료를 포함한 차단 조건을 확인할 때까지 적용 중으로 남습니다. 데이터가 언젠가 맞아질 것이라는 설명만으로 권한 철회를 완료 처리하지 않습니다.
Horizontal Scaling
회의 생성과 일반 조회를 처리하는 API 서버는 요청 사이에 로컬 상태를 유지하지 않도록 만들어 늘릴 수 있습니다. QPS, 처리 중인 요청 수, DB connection 대기, request_latency_p99를 함께 보면서 확장합니다. DB가 이미 한계인 상황에서 API 서버만 늘리면 대기하는 연결이 더 많아질 수 있습니다.
Signaling Gateway는 연결을 유지하므로 일반적인 stateless API와 다릅니다. 서버를 늘리면 새 연결은 분산할 수 있지만 기존 연결은 그대로 남습니다. active_connections, 메시지 처리 지연, event loop 지연, 메모리를 보고 새 연결 배정을 조절하며, 종료할 서버는 새 접속을 중지한 뒤 재접속을 유도합니다.
SFU는 egress_bps, ingress_bps, packets_per_second, packet_queue_age, 활성 구독 수, 패킷 처리 CPU를 함께 봅니다. 영상은 bitrate가 높고 음성은 작은 패킷이 자주 도착하므로 같은 대역폭이어도 처리 부담이 같지 않습니다.
SFU를 추가해도 진행 중인 미디어 연결이 자동으로 옮겨지지 않습니다. 새 회의를 배정하거나 명시적인 연결 교체를 수행해야 합니다. 회의 시작 직전 입장이 집중되는 패턴에 대비해 여유 서버를 먼저 준비하고, 배포 시에는 진행 중인 회의를 고려해 drain합니다.
Room Controller는 여러 회의를 프로세스들에 분산합니다. 한 회의의 권한 명령은 계속 한 순서로 처리하며, 현재 발화자 계산이나 매 프레임 품질 조정까지 이 명령 경로에 넣지 않습니다. 그렇게 해야 미디어의 빈번한 변화가 호스트 명령 처리를 지연시키지 않습니다.
Vertical Scaling
초기에는 더 큰 DB 인스턴스와 충분한 메모리를 가진 SFU가 합리적입니다. DB의 working set이 메모리에 들어가고 write IOPS가 충분하면 복잡한 shard 이동을 만들지 않고도 규모를 늘릴 수 있습니다. SFU도 NIC와 CPU 자원이 함께 개선되면 더 많은 연결을 수용할 수 있습니다.
다만 CPU만 늘렸는데 NIC 송신 한계에 도달해 있다면 미디어 처리량은 크게 개선되지 않습니다. DB도 빠른 CPU보다 저장 장치 지연이나 잠금 대기가 더 큰 문제일 수 있습니다. CPU saturation, memory pressure, IOPS, replication_lag, network bandwidth, p95 지연을 원인별로 확인해야 합니다.
부하 시험에서 확인한 지속 가능한 처리량에 접근하고 장애 복구용 여유까지 부족해지면 수평 확장이나 sharding을 진행합니다. 모든 서버에 같은 CPU 비율을 적용하는 대신, 회의 크기와 실제 트래픽 분포를 재현한 결과로 배정 한도를 정합니다.
큰 서버는 한 번의 장애에 영향을 받는 참가자 수도 늘립니다. 서버 비용만 비교하지 않고 재접속 규모와 교체 시간도 수직 확장의 비용으로 계산합니다.
Database Sharding
tenant_id로 나누면 고객사 단위 관리와 격리가 쉽지만 큰 고객사에 부하가 집중될 수 있습니다. organizer_id로 나누면 주최자의 회의 목록은 편하지만, 회의의 모든 참가자를 함께 변경하는 경로와는 맞지 않습니다.
이 설계에서는 meeting_id를 hash한 논리 bucket을 기준으로 회의 원본을 배치합니다. 같은 회의의 참가자, 권한 명령, Outbox, 녹화 작업 metadata가 같은 shard에 있도록 합니다. 입장 승인과 호스트 이전 같은 transaction을 shard 안에서 완료할 수 있기 때문입니다.
회의 단위 조회는 shard-local query입니다. 반면 사용자가 참여한 모든 회의, 고객사 전체의 이용 현황은 여러 shard에 걸칩니다. 매번 모든 shard를 조회하지 않고 이벤트로 사용자별 목록과 고객사별 집계를 따로 만듭니다.
한 회의의 명령량이 집중되는 hot shard 문제는 회의 수가 많아서 생기는 문제와 다릅니다. 큰 회의를 전용 bucket이나 shard로 이동하면 다른 회의를 보호할 수 있지만, 한 회의의 직렬 명령 처리 한계까지 없어지지는 않습니다. heartbeat와 타일 변경을 DB 경로에서 제외하고, 필요하면 대규모 시청을 별도 제품 경로로 분리합니다.
큰 tenant의 서로 다른 회의는 여러 shard에 분산할 수 있습니다. 고객사별 격리나 별도 보존 정책이 필요하면 dedicated shard 집합을 배정합니다. 한 고객사를 반드시 물리 DB 하나에 넣어야 한다는 뜻은 아닙니다.
Rebalance는 물리 shard 수로 단순 나머지 연산을 다시 계산하는 방식으로 하지 않습니다. 논리 bucket과 실제 shard의 대응을 바꾸고, snapshot 복사와 변경분 반영 후 짧게 쓰기를 차단해 소유권을 전환합니다. 이전 writer를 차단하지 않은 채 양쪽 DB에 쓰면 멱등성 제약도 서로 다른 DB에서 따로 통과할 수 있습니다.
Database Partitioning
Sharding이 여러 DB cluster에 데이터를 나누는 일이라면, partitioning은 한 cluster 안에서 큰 테이블을 분할하는 일입니다. 둘은 해결하는 문제가 다릅니다.
종료된 회의의 감사 기록, 녹화 segment metadata, 장기간의 사용 내역은 시간 기준 partition이 적합할 수 있습니다. 날짜 범위 조회와 보존 기간 종료 후 삭제가 쉬워집니다. region 기준 분리는 보관 위치 제약이 있을 때 고려하고, 그 안의 큰 테이블은 시간이나 hash 기준으로 더 나눌 수 있습니다.
아직 발행되지 않은 Outbox, 실행 중인 녹화, 재시도에 필요한 멱등성 기록이 남아 있다면 날짜가 지났다는 이유만으로 partition을 제거하지 않습니다. 미완료 작업과 보존 예외가 없는지 확인한 뒤 archive하거나 drop합니다.
앞의 schema는 partition되지 않은 테이블을 기준으로 작성했습니다. 시간 partition을 추가하면서 고유 키에 날짜를 붙이면, 날짜가 다른 중복 요청이 통과할 수 있습니다. 멱등성 제약을 유지할 별도 테이블을 두거나 partition 방식과 키의 범위를 다시 설계해야 합니다.
Cache Scaling
Cache에는 회의 제목과 표시용 설정, 참가자 목록, 현재 연결 상태를 합친 요약을 둡니다. 예를 들어 참가자 목록 TTL을 5초로 설정하고, 정상 상태에서는 이벤트로 그보다 빨리 갱신합니다. TTL은 이벤트가 누락되었을 때 재조회하는 보완 수단입니다.
Cache miss에는 한 요청이 원본을 읽고 같은 조회를 기다리던 요청들이 그 결과를 공유하게 합니다. 동시에 수천 개의 요청이 같은 DB 조회를 반복하지 않도록 하기 위한 방식입니다. fallback에도 동시 실행 한도를 두고, 표시용 목록에는 짧게 오래된 값을 제공할 수 있습니다.
권한 발급에는 이런 stale fallback을 사용하지 않습니다. 현재 승인 상태를 확인할 수 없으면 연결 준비 중이나 권한 확인 실패를 반환합니다. 목록을 빠르게 보여주는 일과 미디어 접근을 허용하는 일은 서로 다른 요구사항입니다.
Invalidation 이벤트보다 먼저 시작한 DB 조회가 나중에 끝나 Cache를 오래된 값으로 덮을 수도 있습니다. Cache 갱신에 version을 포함하고, 이미 확인한 최소 버전보다 낮은 응답은 저장하지 않습니다. 단순 삭제만으로는 이 경쟁 상태가 해결되지 않습니다.
큰 회의의 동일한 목록을 많이 조회하면 읽기 복제본이나 페이지별 Cache를 사용할 수 있습니다. 화면에서 필요한 일부 참가자만 구독하면 목록 전체를 매 변경마다 보내는 비용도 줄어듭니다. 복제본이 늘면 각 복제본의 갱신 지연과 버전을 관측해야 합니다.
Stream / Queue Partitioning
회의 제어 이벤트는 meeting_id를 partition key로 둡니다. 같은 회의의 이벤트를 함께 처리하고 다른 회의들은 병렬로 처리하기 위해서입니다. 녹화 작업 Queue는 recording_job_id를 기준으로 분산할 수 있습니다.
같은 partition을 사용한다고 DB에서 확정된 순서가 자동으로 복원되지는 않습니다. 여러 Relay가 동시에 발행하면 발행 순서가 달라질 수 있습니다. 순서가 필요한 경로에서는 회의별 발행을 직렬화하거나, 객체 버전과 현재 상태를 포함한 이벤트를 사용해 오래된 적용을 거절하고 누락 시 snapshot으로 복구합니다.
전체 상태를 대체하는 이벤트와 일부 변경만 담는 이벤트도 다릅니다. 전체 상태 version 8을 이미 적용했다면 뒤늦은 version 7은 버릴 수 있습니다. 그러나 증가량이나 일부 필드 변경만 담긴 이벤트를 번호가 낮다는 이유로 버리면 결과가 달라질 수 있으므로 연속 적용이나 재조회가 필요합니다.
consumer_lag와 가장 오래된 이벤트의 queue_age를 보고 소비자를 늘립니다. partition 수보다 소비자를 많이 배치해도 한 consumer group에서 유효한 병렬성이 계속 늘어나지는 않습니다. 특정 회의 하나가 hot partition을 만들면 일반 회의와 분리하거나 이벤트 종류별로 경로를 나눕니다.
품질 통계는 참가자와 시간 구간을 기준으로 더 넓게 분산할 수 있습니다. 반면 같은 회의의 권한 명령을 임의로 여러 partition에 흩뜨려 처리량만 높이면 명령 순서 문제가 다시 생깁니다. 원시 미디어 패킷은 이 durable Stream에 넣지 않습니다.
Read Model Scaling
화면에 보여주기 편하도록 원본 데이터를 합쳐 만든 조회용 데이터를 Read Model이라고 부르겠습니다. 참가자 목록은 입장 상태와 역할, 표시 이름, 현재 연결 상태를 합친 결과입니다.
역할과 입장 상태는 DB의 확정 이벤트에서 가져오고, 연결 상태는 Signaling Gateway와 SFU의 관측에서 가져옵니다. 두 상태를 같은 version으로 취급하지 않습니다. 권한 version과 transport_generation, 연결 관측 시점을 따로 관리해야 이전 연결의 늦은 heartbeat가 현재 상태를 덮지 않습니다.
작은 회의에서는 전체 목록을 전달할 수 있습니다. 큰 회의에서는 현재 페이지, 발표자, 검색 결과에 필요한 부분만 전달하고 나머지는 cursor로 조회합니다. 참가자 수는 집계값으로 제공하되, 잠시 표시된 인원 차이 때문에 미디어 입장 한도를 잘못 판단하지 않도록 입장 허가는 원본 제어 경로에서 결정합니다.
Read Model이 유실되면 원본 snapshot을 기준으로 다시 만듭니다. 재구성 중에는 목록 일부가 늦게 표시될 수 있지만 기존 미디어를 조회용 목록의 완성 여부에 종속시키지 않습니다. 퇴장, 호스트 이전, 재접속 권한처럼 현재 상태가 필요한 요청은 이 경로를 사용하지 않습니다.
Hot Key
이 서비스의 구체적인 hot key 후보는 큰 회의의 참가자 목록, 많은 사람이 보고 있는 발표자의 화면 공유 track_id, 대형 고객사의 동시 접속 한도입니다. 각각 부하가 생기는 이유가 다릅니다.
참가자 목록은 canonical meeting_id로 관리하다 읽기가 집중되면 페이지 단위 분리나 읽기 복제본을 추가합니다. 통계 counter는 여러 virtual shard에 나누어 누적한 뒤 합칠 수 있지만, 합산 전에 정확한 인원 제한을 판단하는 용도로는 사용하지 않습니다.
발표자의 트랙은 단순한 Redis hot key가 아닙니다. 같은 미디어를 많은 수신자에게 복사하는 egress와 구독 갱신 비용이 집중된 상태입니다. 상위 SFU가 선택된 계층을 하위 SFU로 전달하고, 하위 SFU가 수신자별로 전달하도록 분산할 수 있습니다.
고객사의 정확한 접속 한도는 counter를 읽고 각 서버가 독립적으로 증가시키면 초과할 수 있습니다. 엄격한 한도가 필요하면 lease가 있는 예약 수량을 서버들에 배정하고, 해제와 만료를 정산합니다. 작은 규모에서는 단일 원자적 counter가 더 단순하며, 분산 예약은 잔여 용량이 일부 서버에 남는 비용을 추가합니다.
모든 key에 virtual shard를 적용하지 않습니다. 호스트 역할이나 회의 종료 상태처럼 하나의 판단 순서가 필요한 권한을 복제해서 독립적으로 갱신하면 처리량 대신 정합성 문제가 생깁니다.
여러 지역에 걸친 회의
가까운 SFU에 접속하면 참가자의 마지막 네트워크 구간을 줄일 수 있습니다. 그러나 두 SFU 사이의 경로가 길거나 혼잡하면 전체 지연이 줄지 않을 수도 있습니다. 배정은 지리적 거리만 보지 않고 실제 RTT, 패킷 손실, 권역 간 사용량, 허용된 데이터 처리 위치를 함께 봅니다.
같은 원격 트랙을 한 SFU의 참가자 100명이 구독한다면, SFU 간에는 필요한 계층을 공통으로 받아 로컬에서 분배할 수 있습니다. 참가자마다 동일한 트랙을 별도로 권역 간 전송하는 비용을 줄이는 방식입니다. 반대로 서로 다른 계층을 요구하면 실제로 보내야 하는 계층 수는 늘어납니다.
중계 구조에서는 루프와 중복 전달을 막아야 합니다. Controller가 트랙별 배포 경로를 관리하고, SFU는 원본 track_id와 송신 세대를 유지합니다. 경로가 바뀌어도 같은 오디오가 두 경로에서 동시에 재생되지 않도록 활성 경로를 전환합니다.
기본 배치는 한 회의의 제어 원본을 home region에 두고, 미디어 SFU를 참가자 가까이에 두는 방식입니다. 여러 지역에서 동일한 회의 권한을 동시에 변경하는 구조보다 단순하지만, 멀리 있는 참가자의 입장과 호스트 명령에는 home region까지의 지연이 추가됩니다.
home region 장애에서는 DB replica가 있다는 사실만으로 복구 완료를 선언하지 않습니다. 비동기 복제본에는 최근 퇴장이나 종료 명령이 없을 수 있습니다. 최신 권한 상태를 확인할 수 없으면 새 미디어 권한을 발급하지 않고 복구 대기 상태를 보여줍니다.
지역 전체 장애에서도 확정된 권한 변경을 잃지 않아야 한다면 관련 상태에 지역 간 동기 복제나 합의가 필요합니다. 대신 정상 상태의 쓰기 지연과 비용이 증가합니다. 지역 간 자동 복구 속도와 최근 권한 변경 보존을 동시에 공짜로 얻을 수는 없습니다.
녹화의 내구성과 E2EE
녹화 시작과 결과 완성
start_recording 명령은 녹화 권한과 암호화 모드를 확인하고 recording_job을 생성합니다. 작업 상태는 requested, starting, recording, finalizing, ready, partial, failed로 구분합니다. 요청을 저장한 것과 Recorder가 실제로 미디어를 받기 시작한 것은 다르기 때문입니다.
Recorder는 recording_job_id에 대한 실행 lease와 작업 세대를 얻습니다. 예를 들어 10초 단위로 파일을 나누어 업로드하면 Worker가 종료되어도 이미 저장한 구간은 남습니다. segment의 시간 범위와 checksum을 manifest에 기록해 누락 구간을 확인합니다.
업로드 key에는 작업 ID, 실행 세대, segment 번호를 포함합니다. 교체 전 Worker가 늦게 업로드하더라도 새 Worker의 파일을 덮지 않도록 하고, 현재 세대만 최종 manifest를 갱신할 수 있게 합니다. 사용되지 않은 객체는 이후 정리합니다.
Finalizer는 manifest의 구간과 실제 객체를 대조해 결과를 만듭니다. 장애 동안 받지 못한 실시간 미디어는 Queue를 다시 읽는다고 복원되지 않습니다. 그런 결과는 partial로 표시하고 누락 구간을 알려야 합니다.
녹화를 빠짐없이 보존해야 한다면 중복 Recorder나 더 강한 임시 저장 경로를 고려해야 합니다. 그만큼 수신 대역폭, 저장량, 접근 권한 관리가 늘어납니다. 일반 회의의 통화 지속성과 녹화의 무손실 보장은 별도의 목표입니다.
서버가 미디어 키를 가지는가
이 글의 transport 모드는 클라이언트와 SFU 사이에 DTLS-SRTP를 적용하는 WebRTC 설계입니다. 이때 SFU 측에서 미디어 내용에 접근할 수 있는 구조와, 참가자만 내용 키를 가지는 E2EE를 구분해야 합니다. 이 설명을 Zoom의 일반 모드가 모든 SFU에서 항상 복호화된다는 주장으로 읽어서는 안 됩니다.
E2EE에서는 서버가 프레임 내용을 읽지 못해도 허용된 track_id와 계층 정보에 따라 전달할 수 있습니다. IETF의 SFrame은 실시간 미디어 프레임 암호화를 다루는 표준입니다. 이를 채택하더라도 참가자 신원 확인과 그룹 키 배포 전체가 자동으로 해결되는 것은 아닙니다.
이 설계에서는 참가자 추가와 제거에 맞춰 키 세대를 갱신하고, 제거된 참가자에게 새 키를 전달하지 않습니다. 늦게 입장한 사람에게 과거 키를 제공할지도 별도 정책으로 정합니다. 키 배포가 끝나지 않은 참가자에게는 보안 연결 준비 상태를 보여주며 서버 키로 조용히 대체하지 않습니다.
Zoom의 E2EE 안내가 설명하는 클라우드 녹화 제한도 이 구분과 연결됩니다. 서버가 내용 키를 가지지 않는 회의에서 일반적인 서버 Recorder가 평문 영상을 합성할 수는 없습니다. 녹화용 참가자에게 키를 제공하는 다른 제품 정책을 만들 수는 있지만, 이는 접근 가능한 주체가 추가되는 별도 선택입니다.
E2EE에서도 접속 시간, 패킷 크기, 라우팅에 필요한 일부 metadata는 서버가 관측할 수 있습니다. 또한 정당하게 입장한 참가자의 로컬 녹화나 다른 기기를 이용한 촬영까지 암호화로 막을 수는 없습니다. 미디어 암호화와 참가자 접근 제어를 함께 설계해야 합니다.
Weak Network / Offline
휴대전화가 Wi-Fi에서 이동통신망으로 바뀌면 기존 네트워크 경로가 더 이상 유효하지 않을 수 있습니다. 우선 연결 후보를 다시 검사하는 ICE restart를 시도하고, 기존 SFU나 transport를 사용할 수 없으면 새로운 연결 세대로 교체합니다.
복구 중에는 참가자를 목록에서 즉시 삭제하지 않고 reconnecting으로 표시합니다. 예를 들어 30초의 복구 유예를 설계할 수 있습니다. 다만 이 유예는 입장 세션을 유지하는 시간이며, 이전 미디어 권한을 30초 동안 무조건 허용하는 뜻은 아닙니다.
클라이언트는 마지막 제어 cursor와 카메라 선택 같은 복원에 필요한 상태를 저장합니다. 재접속하면 snapshot 또는 이후 이벤트를 적용하고 최신 구독 목록을 다시 보냅니다. 만료된 이벤트 기록과 화면 thumbnail에는 크기 한도를 두고 오래 사용하지 않은 항목부터 제거합니다.
모든 조작을 Pending Mutation Queue에 넣지는 않습니다. 카메라 끄기와 마이크 끄기는 로컬에서 즉시 반영할 수 있습니다. 반대로 연결이 끊긴 동안 누른 마이크 켜기나 녹화 시작을 몇 분 뒤 자동으로 실행하면 사용자가 예상하지 못한 송신이 생길 수 있습니다.
재전송할 제어 요청에는 같은 idempotency_key와 기대 version을 유지하고, 실행 유효 시간도 둡니다. 오래된 요청이 현재 권한과 충돌하면 거절하고 화면을 갱신합니다. 영구 채팅을 범위에 포함하지 않았으므로 일반적인 메시지 초안 저장소는 필요하지 않습니다.
오프라인 동안의 음성을 계속 녹음해 연결 복구 후 모두 보내는 기능도 기본 회의 동작으로 두지 않습니다. 복구 후에는 현재 시점의 미디어부터 이어가며, 화면에 연결이 끊긴 상태를 알립니다. 로컬 상태가 서버의 입장 권한을 영구적으로 대체하지는 않습니다.
Failure Handling
응답을 받지 못한 생성과 제어 요청
클라이언트가 timeout을 봤더라도 DB에는 회의 생성이나 권한 변경이 확정되어 있을 수 있습니다. 같은 idempotency_key로 요청 결과를 조회하거나 재시도합니다. 새 키로 같은 업무를 다시 만들지 않습니다. 사용자에게는 실패 확정 대신 처리 상태 확인 중을 표시하고 원본 결과가 확인되면 갱신합니다.
SFU 종료와 미디어 경로 교체
SFU가 종료되면 그 서버의 연결과 짧은 packet cache가 사라집니다. Controller는 다른 SFU를 배정하고 현재 권한을 확인한 뒤 새 transport_generation으로 연결을 준비합니다. 새로운 보안 연결을 만들며 이전 연결의 암호화 상태와 packet counter를 임의로 재사용하지 않습니다.
기존 미디어가 남아 있는 계획된 교체에서는 새 연결을 준비한 뒤 활성 경로를 전환합니다. 이전 경로와 새 경로가 동시에 같은 음성을 재생하지 않게 합니다. 장애 교체에서는 음성을 먼저 복구하고 영상은 새로운 keyframe부터 시작하며, 그동안 재연결 표시와 정지된 영상 또는 avatar를 보여줍니다.
Controller 장애와 DB failover
Controller가 종료되면 새 담당자가 실행 권한과 epoch를 확보하고 원본 상태를 읽습니다. DB commit 직후 응답이 유실된 명령도 원본과 멱등성 기록으로 결과를 확인합니다. 불확실한 transaction을 성공으로 가정하지 않습니다.
DB failover 중에는 권한을 확대하지 않습니다. 기존 미디어는 확인 가능한 권한 유효 기간 안에서만 유지하며, 갱신할 수 없으면 중단합니다. 화면에는 일시적인 연결 복구 또는 제어 불가 상태를 표시합니다. 이 정책의 통화 중단 비용은 권한 확인 없이 계속 전달하는 위험과 함께 선택한 결과입니다.
Queue 지연과 중복 이벤트
이벤트 소비가 늦으면 목록이나 통계가 이전 상태로 보일 수 있습니다. 권한 철회는 직접 적용 경로와 유효 기간으로 처리하고, 조회 모델은 consumer를 확장하거나 원본 snapshot으로 복구합니다. event_id로 중복을 식별하고 객체 version으로 역순 갱신을 막습니다.
오래된 목록 때문에 새로운 미디어 권한을 발급하지 않으므로 조회 지연이 권한 확대가 되지는 않습니다. UI에는 필요한 경우 목록 갱신 중 상태를 보여주며, 실제 미디어 연결 상태와 목록의 표시 지연을 별도로 진단합니다.
Cache와 여러 SFU의 부분 적용
일부 SFU만 새 권한을 적용한 상태에서는 명령을 applied로 완료 처리하지 않습니다. 관련 서버를 다시 확인하고, 응답하지 않는 경로는 권한 만료와 연결 차단으로 종료합니다. 이미 적용한 서버는 같은 version의 재요청을 받더라도 상태를 다시 반전시키지 않습니다.
Cache가 유실되면 제한된 동시성으로 다시 채웁니다. 원본 DB 조회가 밀린다는 이유로 오래된 참가자 목록을 접근 권한 증거로 사용하지 않습니다. 사용자에게는 표시용 정보의 지연과 입장 권한 확인 실패를 다르게 안내합니다.
Recorder 종료와 Object Storage timeout
Worker가 종료되면 새 실행 세대가 이미 저장된 segment를 확인하고 현재 시점부터 수신을 이어갑니다. 업로드 timeout은 객체가 없다는 뜻이 아닙니다. 동일한 객체 key와 checksum으로 저장 여부를 조회하고, 확인된 구간은 재사용합니다.
저장 결과가 불명확한 상태에서 새 이름으로 무조건 업로드하면 중복 파일과 manifest 불일치가 생길 수 있습니다. 최종화에서는 작업 상태와 객체 목록을 대조하고, 복원할 수 없는 누락은 partial로 노출합니다. 실시간 회의는 녹화 실패와 분리해서 유지합니다.
지역 장애와 재접속 집중
한 지역이 실패하면 다른 지역에 미디어와 제어 요청이 동시에 몰립니다. 모든 클라이언트가 같은 간격으로 재시도하지 않도록 무작위 지연을 포함한 backoff를 적용하고, 서버는 재시도 간격과 입장 처리량을 제한합니다.
대체 지역에 여유 용량이 없으면 우선 음성 중심으로 복구하고 영상 구독을 줄일 수 있습니다. 그러나 최신 회의 권한을 확인할 수 없는 경우에는 품질만 낮춰 연결을 허용하지 않습니다. 권한 복구 대기와 용량 대기를 구분해 알리고, 종료된 회의를 새 회의처럼 자동 생성하지 않습니다.
Observability
API 성공률만으로 통화 품질을 판단할 수 없습니다. 입장 API가 200을 반환해도 ICE 연결이 실패하거나 오디오가 재생되지 않을 수 있습니다. 요청 처리와 실제 미디어 경험을 연결해서 측정해야 합니다.
request_latency_p95와 request_latency_p99는 생성, 입장, 권한 변경별로 나눕니다. join_to_media_ready_ms는 승인 이후 미디어 연결 준비 시간을 측정합니다. 실제 첫 음성 재생 시간은 송신 중인 유효한 음성 트랙이 있는 세션에서 측정해, 아무도 말하지 않는 시간을 연결 실패로 계산하지 않습니다.
requests_per_second, control_events_per_second, active_connections, active_meetings, active_subscriptions는 각각 API, 제어, 연결, 회의 상태, 미디어 구독 규모를 보여줍니다. SFU에는 ingress_bps, egress_bps, packets_per_second, packet_queue_age_ms를 추가합니다.
consumer_lag와 queue_age는 후속 작업이 얼마나 뒤처졌는지 보여줍니다. outbox_oldest_unpublished_age, roster_update_age, replication_lag를 함께 보면 원본 저장, 이벤트 발행, 조회 갱신 중 어디서 지연이 시작됐는지 구분할 수 있습니다.
미디어 품질에는 packet_loss_rate, jitter, round_trip_time, audio_concealment_ratio, video_freeze_duration, frames_dropped, reconnect_duration을 사용합니다. W3C의 WebRTC Statistics 명세는 손실, jitter, 재생 및 프레임 관련 통계를 정의합니다. 제공되는 counter와 timestamp로 구간별 지표를 계산하되 클라이언트별 지원 차이를 확인합니다.
음성의 실제 단방향 지연을 RTT의 절반으로 대체하지 않습니다. 경로가 비대칭일 수 있고 인코딩과 재생 버퍼 시간도 들어갑니다. 동기화된 시험 환경에서 입력부터 출력까지 측정하고, 실제 사용자 환경에서는 측정 오차와 추정 구간을 구분합니다.
정확성은 duplicate_event_rate, invalid_state_transition, stale_generation_rejected, authorization_enforcement_lag, recording_manifest_difference로 관측합니다. 특히 DB에는 removed인데 아직 전달 중인 경로가 있는지 대조하는 작업이 필요합니다. 평균값만 보면 일부 회의에서 오래 남는 권한 적용 지연을 놓칠 수 있습니다.
제품 지표는 승인된 입장 성공률, 음성을 사용할 수 있었던 참여 시간의 비율, 참가자 시간당 재접속 횟수, 화면 공유 준비 시간, 녹화 완성률을 봅니다. 영상 bitrate를 낮춰 서버 부하가 줄었다고 해서 제품 품질도 개선되었다고 결론내리지 않습니다.
SLO는 지역, 기기, 네트워크 유형별로 구분하면서 전체 실패도 함께 집계합니다. 오류 예산 소모가 빠르면 신규 고화질 기능 배포나 서버 감축을 멈추고 원인을 확인합니다. meeting_id를 모든 metric label에 붙이면 시계열 수가 지나치게 늘어나므로, 개별 회의 분석은 접근이 통제된 로그와 표본 trace에서 수행합니다.
부하 시험에서는 작은 회의 수를 늘리는 시험과 회의 하나의 인원을 늘리는 시험을 분리합니다. 패킷 손실, UDP 차단, 네트워크 전환, SFU 종료, Controller 교체, 녹화 업로드 timeout을 주입하고 사용자가 보는 복구 상태까지 확인합니다. 서버 QPS만 높이는 시험으로는 이 시스템의 한계를 충분히 알 수 없습니다.
시니어 아키텍트 면접 Q&A
그냥 모든 참가자를 SFU 하나에 붙이면 되지 않나요?
작은 회의에서는 그 방식이 합리적입니다. 연결과 구독 상태가 한곳에 있어 운영과 디버깅도 단순합니다. 회의 하나의 egress, packets_per_second, 구독 변경 지연이 서버 한계에 접근하면 여러 SFU로 분산합니다.
분산 이후에는 트랙별 중계 경로, 권역 간 비용, 중복 재생 방지, 권한 철회 적용 범위를 관리해야 합니다. 참가자가 적은 회의까지 처음부터 여러 SFU에 나누면 이런 비용을 먼저 부담하게 됩니다.
미디어도 Kafka에 넣으면 장애 때 다시 재생할 수 있지 않나요?
녹화나 지연을 허용하는 분석이라면 저장 후 재처리하는 구조를 사용할 수 있습니다. 그러나 실시간 대화에서는 이미 재생 시한이 지난 음성보다 지금 생성되는 음성이 필요합니다. 대량의 미디어를 durable broker에 기록하고 복제하는 비용도 큽니다.
따라서 회의 상태와 작업 이벤트는 내구성이 있는 Stream으로 보내고, 실시간 미디어는 짧은 Queue로 전달합니다. 과거 내용 보존이 필요하면 별도 녹화 경로를 추가합니다. 그 결과 녹화하지 않은 장애 구간은 나중에 복원할 수 없습니다.
권한을 Cache에 두고 TTL만 짧게 하면 되지 않나요?
표시용 목록이나 짧은 지연을 허용하는 조회에는 가능합니다. 그러나 진행 중인 연결이 Cache를 다시 읽지 않으면 TTL이 만료되어도 계속 미디어를 전달할 수 있습니다. 권한 변경 이벤트가 유실되거나 이전 Controller가 권한을 갱신하는 상황도 고려해야 합니다.
미디어 경로 자체에서 권한 버전과 만료를 검사하고, 새 제어 세대가 이전 세대를 차단하게 해야 합니다. 추가되는 비용은 권한 갱신과 적용 확인이며, 제어 장애가 길어지면 기존 통화도 멈출 수 있습니다.
DB transaction 하나로 입장과 미디어 연결을 모두 처리하면 안 되나요?
입장 상태와 Outbox 같은 DB 내부 변경은 한 transaction으로 묶는 것이 적절합니다. 그러나 클라이언트의 네트워크 협상과 SFU 연결이 언제 완료될지는 DB가 제어할 수 없습니다. 그동안 transaction을 열어 두면 잠금과 connection을 오래 점유합니다.
승인 상태를 먼저 확정하고, 제한된 자격으로 미디어 연결을 구성하며, 실패한 예약은 만료시키는 방식이 필요합니다. 상태가 더 늘지만 네트워크 실패 때문에 DB transaction이 장시간 유지되는 문제를 줄입니다.
E2EE를 유지하면서 클라우드 녹화도 제공하면 안 되나요?
서버가 내용 키를 가지지 않는다는 조건에서는 일반적인 서버 합성 녹화를 수행할 수 없습니다. 참가자 기기에서 녹화하거나, 명시적으로 녹화용 참가자를 추가하는 다른 정책을 설계할 수는 있습니다.
녹화용 주체에 키를 주면 누가 내용에 접근할 수 있는지가 달라집니다. 동의 표시, 키 배포, 보존과 삭제, 접근 감사도 다시 설계해야 합니다. 기능을 지원하기 위해 사용자에게 알리지 않고 암호화 모드를 바꾸면 안 됩니다.
모든 지역에서 회의 상태를 동시에 쓰면 장애 복구가 더 쉽지 않나요?
읽기 위주의 정보나 충돌을 허용할 수 있는 일부 데이터에는 여러 지역의 독립적인 쓰기가 가능할 수 있습니다. 그러나 한 지역의 호스트 이전과 다른 지역의 퇴장이 충돌하면 현재 누가 권한을 가졌는지 결정해야 합니다. 단순한 마지막 timestamp 선택은 적절하지 않습니다.
회의별 담당 권한과 순서를 유지하거나, 지역 간 합의를 사용해야 합니다. 전자는 담당 지역 복구가 필요하고, 후자는 정상 상태의 지연과 운영 비용을 늘립니다. 복구 대상이 조회 데이터인지 접근 권한인지에 따라 선택해야 합니다.
규모가 10배가 되면 무엇부터 바꿀 것인가
이 글의 가정에서 이용량이 같은 형태로 10배 늘면 피크 동시 접속은 2,500만 명, 외부 미디어 egress는 약 50Tbps가 됩니다. 입장 API의 피크는 약 초당 1만 4,000건입니다. 두 수치를 함께 보면 API 서버 증설만으로 대응할 수 없다는 점이 드러납니다.
우선 SFU와 TURN의 지역별 전송 용량, 구독 정책, 클라이언트가 실제 표시하는 화질을 확인합니다. 화면 밖의 트랙이나 필요 이상으로 높은 계층을 줄이면 서버 수를 늘리는 것과 별개로 전송량을 줄일 수 있습니다.
그다음 Signaling 연결 수와 재접속 처리 여유, Controller가 담당하는 회의 수를 확장합니다. DB의 IOPS와 잠금 대기, 복제 지연이 한계에 접근하면 회의별 shard를 추가합니다. 녹화 비율과 보존 기간이 바뀌지 않아도 녹화 저장과 변환량은 함께 커지므로 별도 용량 계획이 필요합니다.
회의 수만 10배가 되는 경우와 회의 하나의 참가자가 10배가 되는 경우는 다릅니다. 전자는 회의를 여러 서버로 분산하는 방식이 효과적입니다. 후자는 특정 트랙의 fan-out, 참가자 목록, 한 회의의 제어 순서가 먼저 제한될 수 있습니다.
화상회의에서는 현재 대화를 이어가는 일과 과거 데이터를 보존하는 일, 참가자의 권한을 적용하는 일이 서로 다른 시간 조건을 가집니다. 미디어는 재생 시한 안에 전달하고, 권한은 원본 상태와 실제 전송 경로에서 함께 강제하며, 녹화와 조회는 각자의 복구 경로를 갖게 해야 합니다. 규모를 늘릴 때도 이 구분을 유지해야 통화 품질과 권한의 정확성을 함께 관리할 수 있습니다.





