문제 상황
우리 플랫폼에는 단순한 회의 녹음 기능이 있었습니다. 브라우저에서 녹음 버튼을 누르고, 끝나면 정지하고, 파일을 업로드한 뒤, 버튼을 눌러 전사(transcribe)하는 방식이었죠. 단순하지만 — 심각한 결함이 있었습니다.
세 가지 고질적인 문제가 계속 드러났습니다.
브라우저 메모리 폭증. MediaRecorder API는 오디오 청크를 Blob/ArrayBuffer 형태로 메모리에 계속 쌓아둡니다. WebM Opus로 녹음한 1시간짜리 회의라면 대략 60100MB에 달하는 원본 청크가 됩니다. 녹음을 정지하면 200MB까지 치솟습니다. 여기에 업로드를 위한 new Blob(chunks)가 모든 청크를 하나로 병합하는데 — 이때 잠시 원본 청크와 병합된 blob이 동시에 메모리에 존재하면서 120FormData 변환까지 더하면 탭 하나의 JS 힙에서 200300MB를 차지하게 됩니다. Chrome은 탭당 대략 14GB의 메모리 상한을 강제하는데, 탭을 10개 이상 열어두면 GC가 심하게 스래싱을 일으키고 UI가 버벅이며 탭이 아예 크래시할 수 있습니다. 최악인 부분은, 크래시가 곧 완전한 데이터 손실을 의미했다는 점입니다 — 서버로 아무것도 전송된 게 없었으니까요.
낮은 한국어 STT 품질. 우리는 OpenAI Whisper를 사용하고 있었는데, 한국어 음성 인식 정확도가 85% 정도에 그쳤습니다 — 화자 분리(speaker diarization)도 없었고, 25MB라는 파일 크기 상한이 있어 긴 회의는 처리할 수 없었습니다.
수동 워크플로우의 번거로움. 사용자가 녹음 후 “전사” 버튼을 직접 눌러야 했습니다. 누르는 걸 잊으면? 전사본은 없습니다. 결과는 5초마다 폴링으로 확인했는데 — 일반적인 10~25분짜리 전사 구간 동안 300건이 넘는 불필요한 API 호출이 발생했습니다.
새로운 아키텍처: 6단계 자동화 파이프라인
우리는 “정지” 버튼을 누른 이후 사용자의 개입이 전혀 필요 없는 여섯 단계로 전체 흐름을 재설계했습니다.
- 녹음 캡처(Recording Capture) — 5분 단위 세이브포인트와 자동 클라우드 백업
- 클라우드 업로드(Cloud Upload) — presigned URL을 통한 브라우저-to-R2 직접 업로드
- 자동 트리거(Auto-Trigger) — 파이널라이즈 시 서버 측 전사 자동 시작
- 음성 인식(Speech Recognition) — 화자 분리를 지원하는 VITO AI(sommers 모델)
- AI 요약(AI Summary) — Claude API가 구조화된 회의록 생성
- 실시간 전달(Real-Time Delivery) — Centrifugo WebSocket으로 상태 업데이트를 실시간 푸시
각 단계를 하나씩 살펴보겠습니다.
1단계: 5분 세이브포인트 — 브라우저 메모리 길들이기
핵심 아이디어
녹음 전체를 메모리에 버퍼링하는 대신, 5분마다 오디오를 클라우드로 플러시합니다. 5분짜리 WebM Opus 세그먼트는 겨우 510MB에 불과하므로, 브라우저는 어느 시점에도 그 이상을 보유하지 않습니다. 1시간짜리 녹음이라도? 여전히 메모리에는 10MB뿐입니다.
동작 방식
- 녹음 시작 → 클라이언트가 서버에 init 요청을 보내고, 녹음 ID와 첫 번째 파트를 위한 presigned 업로드 URL을 받습니다.
- 5분마다 → 누적된 오디오 청크를 하나의 blob으로 묶어 Cloudflare R2에 직접 업로드합니다. 성공하면 클라이언트가 서버에 알리고(“파트 N 저장됨”), 다음 presigned URL을 받은 뒤 메모리에서 청크를 비웁니다.
- 녹음 정지 → 마지막 부분 세그먼트를 업로드하고,
finalize요청으로 서버에 모든 파트를 병합하라고 지시합니다.
크래시 복구 — 이중 메커니즘
가장 큰 성과는 회복력(resilience)입니다. 녹음 도중 브라우저가 크래시하더라도, 그 순간까지의 모든 세이브포인트는 이미 R2에 안전하게 저장되어 있습니다. 최대 데이터 손실은 5분입니다.
우리는 서로를 보완하는 두 가지 복구 메커니즘을 구현했습니다.
서버 측 타임아웃. 각 세이브포인트는 BullMQ를 통해 “10분 후 자동으로 처리”하는 지연 작업(delayed job)을 예약합니다. 다음 세이브포인트가 도착하면 이전 타이머는 취소됩니다. 10분 동안 세이브포인트가 나타나지 않으면 서버는 크래시가 발생했다고 판단하고, 보유한 파트들을 병합하여 전사를 진행합니다.
클라이언트 측 복구 UI. 사용자가 재접속하면, 앱은 중단된 세션을 감지하고 다음과 같이 표시합니다. “이전 녹음이 중단되었습니다. 저장된 20분을 복구할까요?”
왜 둘 다 필요할까요? 단일 타임아웃만으로는 “안전한 대기 시간”과 “빠른 복구” 사이에서 트레이드오프가 발생합니다. 타임아웃이 짧으면 일시적인 네트워크 장애로 인한 오탐(false positive) 위험이 있고, 길면 빠르게 재접속하는 사용자의 복구가 지연됩니다. 이중 접근 방식을 쓰면 타임아웃은 보수적으로 길게 유지하면서도 즉각적인 수동 복구를 제공할 수 있습니다.
WebM 헤더 문제
흥미로운 함정이 하나 있었습니다. 5분마다 MediaRecorder 청크를 모아 새로운 Blob을 생성했더니, 두 번째 세그먼트부터는 WebM EBML 헤더가 누락되었습니다. ffmpeg가 파싱을 거부했죠.
근본 원인은 MediaRecorder가 컨테이너 헤더를 녹음 시작 시점에만 방출한다는 점입니다. 우리의 해결책은 각 세이브포인트 경계마다 동일한 오디오 스트림에서 MediaRecorder를 정지 후 재시작하는 것이었습니다. 이렇게 하면 각 세그먼트가 독립적이고 완전히 유효한 WebM 파일이 됩니다. 이때 발생하는 오디오 간격은 5~10ms로 — 회의 맥락에서는 전혀 감지되지 않습니다. 이는 RecordRTC 같은 라이브러리에서도 쓰이는 잘 알려진 패턴입니다.
2단계: Presigned URL을 통한 R2 직접 업로드
기존 시스템에서는 오디오 파일이 스토리지로 가는 도중 애플리케이션 서버를 거쳤습니다. 새 시스템은 데이터 경로에서 서버를 완전히 제거했습니다.
Presigned URL은 시간 제한이 있고 사전 인증된 URL로, 브라우저가 Cloudflare R2에 직접 PUT할 수 있게 해줍니다. 서버는 URL을 발급하는 일만 합니다(데이터 전송이 아니라 서명이라는 가벼운 작업). 실제 파일 바이트는 서버를 거치지 않습니다.
그 결과, 동시에 얼마나 많은 사용자가 녹음하든 상관없이 업로드에 대한 서버 대역폭 소비가 0이 됩니다. 사용자가 10만 명이어도, 녹음 중 서버가 하는 일은 URL을 발급하는 것뿐입니다 — 무시해도 될 만한 비용이죠.
다운로드도 같은 방식으로 동작합니다. 서버는 인증 검사를 하고, 접근을 로깅한 뒤, presigned 다운로드 URL로 리다이렉트합니다. 파일은 R2가 직접 제공합니다. 파일 I/O를 위한 서버 메모리 사용량은 0입니다.
또한 코드 리뷰 과정에서, R2 연동 레이어(provider 클래스, presigned URL 생성, 스토리지 키 관리)가 이전 기능에서 이미 우리 코드베이스에 존재한다는 것을 발견했습니다. 우리는 세이브포인트 전용 API 라우트만 새로 만들면 됐습니다.
3단계: 자동 전사 트리거
이전에는 사용자가 “전사” 버튼을 누르는 것을 기억해야 했습니다. 잊으면 전사는 — 영영 — 일어나지 않았습니다.
이제는 서버가 ffmpeg로 파트 병합을 완료하면(finalize 단계) 자동으로 BullMQ에 전사 작업을 큐잉합니다. 사용자의 조작은 필요 없습니다. 기존 “전사” 버튼은 실패 케이스에 대한 수동 재시도 옵션으로만 남겨두었습니다.
4단계: STT 엔진 — OpenAI Whisper에서 VITO로
6개 STT 엔진 평가
2026년 3월 기준으로 상용 API 세 가지(VITO, ElevenLabs, Google Chirp 3)와 오픈소스 세 가지(Whisper, Qwen3-ASR, Voxtral)를 벤치마크했습니다.
한국어 정확도(문자 오류율, Character Error Rate):
| 엔진 | CER |
|---|---|
| VITO sommers | < 8% |
| ElevenLabs Scribe v2 | 8.5% |
| Whisper large-v3 | 10.8~11.4% |
| Google Chirp 3 | 11.3% |
화자 분리(speaker diarization): VITO(2~5명, 자동 감지), ElevenLabs(최대 32명), Google 모두 기본 지원합니다. Whisper는 WhisperX 같은 외부 도구가 필요합니다.
VITO의 차별화된 기능:
- ITN(역텍스트 정규화, Inverse Text Normalization): 말로 표현된 숫자를 표기 형태로 변환합니다 — “삼천원” → “3,000원.”
- 비유창성(disfluency) 필터링: “음,” “어” 같은 군더더기 단어를 자동으로 제거합니다.
- 키워드 부스팅: 최대 500개의 도메인 특화 용어를 등록해 인식 정확도를 높일 수 있습니다.
다른 어떤 엔진도 이 기능들을 기본으로 제공하지 않습니다.
파일 크기 제한: Whisper의 25MB 상한은 긴 회의에는 결정적인 걸림돌이었습니다. VITO Batch STT에는 제한이 없습니다.
사용량 구간별 비용 분석
비용 역학은 사용량에 따라 크게 달라집니다($1 = ₩1,430 기준):
| 월간 시간 | 최저가 옵션 | 비고 |
|---|---|---|
| 2.5~10h | VITO / ElevenLabs (무료 티어) | VITO는 가입 시 10시간 무료 제공 |
| ~20h | VITO (~₩1만) | Google/ElevenLabs보다 2.7~3.1배 저렴 |
| ~50h | ElevenLabs Creator ($22) | 이 구간부터 VITO보다 저렴해지기 시작 |
| ~100h | VITO (~₩9만) | 대안 대비 1.5~1.6배 저렴 |
| 200~500h | ElevenLabs 구독 플랜 | VITO보다 1.3~2.1배 저렴 |
| 500h+ | Google Dynamic Batch | 대규모에서 시간당 요율 최저 |
VITO를 선택한 이유
우리 제품에서 한국어 회의 전사 품질은 핵심 가치입니다. 우리의 결정은 세 가지 요인으로 좁혀졌습니다.
- CER < 8%로 최고의 한국어 정확도를 보입니다. 회의 전사에서 낮은 정확도는 단순히 오류가 많다는 의미를 넘어 — 더 높은 후처리 비용과 사용자 불만을 뜻합니다.
- 올인원 패키지. 화자 분리 + ITN + 비유창성 필터링이 기본 내장되어 있습니다. ElevenLabs는 일부 구간에서 단가가 더 낮지만 ITN과 비유창성 필터링을 위해 별도 구현이 필요합니다. Google은 화자 분리에서 안정성 문제가 보고된 바 있습니다.
- BullMQ 아키텍처와의 자연스러운 통합.
CalendarEvent의 참석자 수를spk_count로 전달해 화자 분리 정확도를 자동으로 높일 수 있습니다.
우리는 Streaming STT보다 Batch STT를 선택했는데, 배치 모드는 전체 오디오 파일을 처리하기 때문에 화자 분리 품질이 훨씬 좋기 때문입니다. 스트리밍 모드는 전체 맥락 없이 실시간으로 화자를 할당해야 하므로 레이블이 뒤바뀌거나 한 화자가 여러 명으로 쪼개지는 오류가 발생합니다 — 여러 참석자가 있는 회의록에는 용납할 수 없는 수준이죠.
스트리밍 파일 전송: R2 → VITO
R2에서 VITO API로 오디오를 전달할 때, 우리는 파일을 서버 메모리에 버퍼링하는 대신 **스트림 파이핑(stream piping)**을 사용합니다.
| 동시 사용자 | 버퍼 방식 | 스트리밍 방식 |
|---|---|---|
| 10 (각 60MB) | 600MB | ~KB |
| 50 | 3GB | ~KB |
| 100 | 6GB → OOM 위험 | ~KB |
구현에는 https.request() + form.pipe()를 사용해 R2의 응답에서 VITO의 업로드 엔드포인트로 직접 스트리밍합니다. 이를 위해 Node.js의 네이티브 fetch는 사용할 수 없었는데, Readable 스트림 본문을 지원하지 않기 때문입니다 — https 모듈까지 내려가야 했던, 주목할 만한 제약이었습니다.
이 설계 덕분에 가용 서버 RAM과 무관하게 BullMQ 동시성을 공격적으로 설정할 수 있습니다.
워커를 차단하지 않는 비동기 폴링
VITO의 Batch API는 비동기입니다. 오디오를 제출하면 작업 ID를 받고, 완료 여부를 폴링해야 합니다. VITO는 웹훅을 지원하지 않으므로 폴링이 유일한 선택지입니다.
가장 순진한 방식인 while (!done) { await sleep(30s); check(); }는 전사가 진행되는 10~25분 내내 BullMQ 워커 슬롯 하나를 잠가버립니다. 심각한 병목이죠.
우리의 해결책은 상태 머신으로서의 BullMQ 지연 작업입니다.
submit-transcription작업 → VITO로 파일 전송 → 즉시 반환(슬롯 해제).- 30초 지연을 둔
check-transcription작업을 예약합니다. check-transcription실행 → VITO에 한 번 조회 → 아직 안 끝났으면 또 다른 체크를 예약 → 즉시 반환.- 완료되면 → 세그먼트를 파싱 → AI 요약 작업을 큐잉합니다.
각 단계는 워커를 단 몇 초만 점유합니다. 동시에 100건을 전사한다고요? 문제없습니다 — 워커가 절대 차단되지 않으니까요.
정규화된 세그먼트 저장
VITO의 결과는 두 가지 형태로 저장됩니다.
segments컬럼 (JSON): 화자 ID, 밀리초 단위 시작/종료 타임스탬프, 텍스트를 담은 구조화된 데이터입니다. UI에서의 화자 필터링, 타임스탬프 기반 재생, AI 요약 입력에 사용됩니다.transcript컬럼 (일반 텍스트):[화자 1] 00:00:05 - 오늘 스프린트 리뷰는...같은 사람이 읽기 쉬운 형식입니다. 전문 검색, 미리보기, Claude API 입력에 사용됩니다.
향후 STT 엔진을 교체하더라도 다운스트림의 데이터 계약이 깨지지 않도록, VITO의 필드명을 우리 자체 스키마로 정규화합니다.
5단계: Claude를 이용한 AI 요약
요약 파이프라인
VITO가 전사본을 생성하면, Claude(claude-sonnet-4-20250514)가 구조화된 회의록을 생성합니다. 우리는 segments JSON이 아니라 transcript 컬럼(일반 텍스트)을 입력으로 넣습니다 — 요약에는 밀리초 단위의 정밀한 타임스탬프가 아니라 화자 레이블과 발화 내용만 필요하기 때문입니다. 일반 텍스트는 프롬프트도 더 간결하게 만들고 토큰 사용량도 낮춰줍니다.
Claude는 회의 제목, 소요 시간, 참석자, 전체 요약(3~10문장), 안건별 논의 사항과 결정 사항, 액션 아이템(담당자 및 기한 포함), 핵심 인사이트를 담은 JSON 객체를 반환합니다.
적응형 티어 시스템
초기에 우리는 고정 길이 프롬프트가 이상하리만치 획일적인 요약을 만들어낸다는 것을 알아챘습니다. 10분짜리 스탠드업과 2시간짜리 전략 회의가 똑같은 깊이의 커버리지를 받았죠. 긴 회의의 경우 안건 논의 사항이 잘려나가고 있었습니다.
우리는 전사본 길이에 기반한 3단계 티어 시스템을 도입했습니다.
| 전사본 길이 | 요약 | 안건 항목 |
|---|---|---|
| < 2,000자 | 2~3문장 | 1~2개 |
| 2,000~8,000자 | 맥락을 담은 4~6문장 | 3~5개 |
| > 8,000자 | 배경, 논거, 결론을 담은 6~10문장 | 5~10개 |
6단계: Centrifugo WebSocket을 통한 실시간 상태 전달
폴링 문제
기존 시스템은 React Query로 5초마다 서버에 폴링했습니다. “아직 안 끝났어?” 10~25분에 걸친 전사 동안, 이는 약 300건의 낭비된 API 호출입니다.
하이브리드 접근: 푸시 + 안전망 폴링
이제 주된 업데이트 채널은 Centrifugo WebSocket입니다. 각 파이프라인 단계(병합 → 전사 → 요약 → 완료)가 사용자에게 상태 업데이트를 실시간으로 푸시합니다.
우리는 여전히 안전망 폴링을 유지하지만 — 5초가 아니라 30초 간격입니다. WebSocket 연결이 끊기면, 클라이언트는 현재 파이프라인 상태에 따라 5~15초 간격의 공격적인 폴링으로 폴백합니다.
결과: 전사 사이클당 API 호출이 약 300건에서 약 50건으로 줄었습니다.
알림 라우팅
- 녹음 상세 페이지를 보고 있는 사용자는 Centrifugo 채널 구독을 통해 실시간 UI 업데이트를 받습니다.
- 녹음을 시작한 사람은 앱 내 어디에 있든 — 심지어 데스크톱 메신저 클라이언트에서도 — 개인 알림(토스트 + 알림 벨)을 받습니다. 알림은 데이터베이스에 영속 저장됩니다.
- 다른 회의 참석자들은 실시간으로 알림을 받지 않습니다. 이들은 회의 뷰어를 통해 완성된 전사본과 요약에 접근할 수 있습니다.
공통 설계 철학
가장 큰 영향을 준 세 가지 아키텍처 결정 — R2 스트리밍 파이프, presigned URL을 활용한 5분 세이브포인트, BullMQ 지연 작업 폴링 — 을 관통하는 하나의 공통 원칙이 드러납니다.
동시성이 확장되어도 리소스 소비는 거의 일정하게 유지된다.
| 패턴 | 전통적 접근 | 우리의 접근 |
|---|---|---|
| R2 → VITO 파일 전송 | 동시 사용자 수에 비례해 메모리 증가 | 스트림 파이핑으로 일정한 ~KB |
| 브라우저 녹음 + 업로드 | 녹음 길이에 따라 메모리 증가, 사용자 수에 따라 서버 대역폭 증가 | 브라우저에서 고정 ~10MB, 서버 대역폭 0 |
| STT 완료 폴링 | 작업당 워커 슬롯이 수 분간 잠김 | 워커는 수 초만 점유, 슬롯은 즉시 해제 |
이것은 성급한 최적화가 아닙니다 — 동시 사용자 50명에서 무너지는 시스템과, 아키텍처 변경 없이 100명 이상을 감당하는 시스템의 차이입니다.
기술 스택
| 레이어 | 기술 |
|---|---|
| 프론트엔드 | MediaRecorder API, React Query |
| 스토리지 | Cloudflare R2, Presigned URLs |
| 작업 큐 | BullMQ, Redis |
| 오디오 처리 | ffmpeg |
| 음성-텍스트 변환(STT) | VITO sommers (Batch STT) |
| AI 요약 | Claude Sonnet |
| 실시간 | Centrifugo WebSocket |
| 컨테이너 | web + worker 분리 배포 |
결과
여섯 개 파이프라인 단계는 2026년 3월 25일 기준으로 모두 완료되어 배포되었습니다.
메모리 안정성: 녹음 길이와 무관하게 브라우저 메모리는 ~10MB 아래로 유지됩니다. 탭이 크래시해도 이제 최대 5분 분량의 오디오만 손실됩니다.
STT 품질: 한국어 인식 정확도가 ~85%에서 92% 이상으로 개선되었습니다. 화자 분리 덕분에 “누가 무엇을 말했는지” 알 수 있게 되었습니다 — 이전에는 불가능했던 기능이죠. 파일 크기 제한도 없습니다.
사용자 경험: 녹음이 정지되면 전사와 요약이 자동으로 실행됩니다. 실시간 상태 업데이트가 300건의 폴링 호출을 약 50건으로 대체했습니다. 수동 단계는 전혀 없습니다.
서버 효율성: 파일 데이터는 애플리케이션 서버를 절대 거치지 않습니다. 스트리밍 전송 덕분에 동시 전사가 100건이어도 서버 메모리는 ~KB 수준을 유지합니다. 이 아키텍처는 10만 명 사용자까지 확장되도록 설계되었습니다.
이 글은 우리 플랫폼의 녹음 시스템 개편 과정을 기록한 것입니다. 모든 벤치마크, 비용 수치, 아키텍처 결정은 2026년 3월 기준의 상황을 반영합니다.