실시간 스트리밍 STT

스트리밍 · 2026년 8월 31일 기준

  • 테스트
  • 실측
  • WebSocket
  • Silero VAD
  • ThreadPool
  • 큐 분리
  • Redis Pub/Sub
목차 7
  1. 개요
  2. 왜 이 설계가 필요했는가 (Problem)
  3. 처리 흐름
  4. 핵심 설계 결정
  5. 설계가 만든 결과 (Result)
  6. 배포 환경 실측 (2026-08-30)
  7. 검증 현황

개요

WebSocket으로 들어오는 오디오 청크를 버퍼링 없이 실시간 전사하는 파이프라인. 수신(WS) → 포맷 변환 → Silero VAD 발화 분할 → STT 워커 큐 → 결과 큐 → Redis 브로드캐스트 / DB 저장 의 비동기 흐름으로 동작하며, 무거운 STT 추론이 async 이벤트 루프를 막지 않도록 ThreadPoolExecutor로 오프로딩했다.

WebSocket 양방향 STT 워커 3 Silero VAD 2단 큐 종료 플러시
청크 수신 + 결과 송신 ThreadPoolExecutor 16kHz·32ms·임계 0.5 입력 / 결과 분리 잔여 버퍼 패딩

왜 이 설계가 필요했는가 (Problem)


처리 흐름

처리 흐름 다이어그램처리 흐름 다이어그램

핵심 설계 결정

1. 입력/결과 큐 분리 + ThreadPoolExecutor 오프로딩

# STT 워커 — 입력 큐에서 꺼내 스레드풀에서 추론
세그먼트 = await 입력 큐.get()
텍스트 = await 이벤트 루프.run_in_executor(
    스레드풀,  # ThreadPoolExecutor · 워커 3개
    세그먼트 전사,  # Whisper 추론 (블로킹)
    세그먼트, 직전 문맥 프롬프트,
)
await 결과 큐.put({텍스트, 세그먼트 번호, 시각})

2. Silero VAD 상태머신 발화 분할

# 프레임마다 음성 확률 판정 → 발화가 끝나면 세그먼트 반환
음성 확률 = VAD 모델(프레임, 16kHz)
if 음성 확률 >= 임계값:  # 0.5
    발화 버퍼에 프레임 추가; 음성 프레임 수 += 1
elif 발화 중 and 침묵 프레임 수 >= 최대 침묵:  # 20프레임
    return 발화 버퍼 전체  # 발화 종료 → 세그먼트 반환

3. 포맷별 디코딩 전략

# 디코딩 전략 선택
if 입력 포맷 in [PCM, raw]: return NumPy 직처리
if 입력 포맷 in [Opus, WebM] and 스트리밍 포맷: return PyAV 파서
return Pydub  # MP3 등 폴백

4. 연결 종료 시 버퍼 플러시

# 연결 종료 처리
남은 오디오 = 변환기.flush()
if 남은 오디오 and 길이 < 프레임 크기:
    남은 오디오 += 0 바이트 × (프레임 크기 - 길이)  # 패딩
파이프라인에 남은 오디오 투입  # 강제 전사
최종 결과 = await STT 워커 완료 대기

설계가 만든 결과 (Result)

검증 결과

발화 분할 상태머신 테스트 · 17/17 통과 (pytest + hypothesis)

검증 내용 방식
무음만 들어오면 세그먼트가 한 번도 나오지 않는다 hypothesis 300 케이스 (임의 길이)
최소 발화길이(5프레임) 미달은 침묵이 와도 버려진다 hypothesis 200 케이스
연속 음성 5프레임에 도달해야 발화로 인정된다 경계
침묵 20프레임 도달 시 세그먼트 반환 · 19프레임까지는 반환 안 함 경계 양방향
세그먼트 반환 후 상태 완전 초기화 (모델 내부 상태 포함) —
프레임 크기가 다르면 무시하고 상태를 바꾸지 않는다 5가지 크기
종료 플러시는 최소길이 조건을 무시하고 남은 버퍼를 반환한다 종료 유실 방지
민감도 설정 3 이상 → 임계값 0.6 상향 설정 변환

모델은 스텁, 상태머신은 실물로 테스트했다

테스트를 짜다 드러난 구조 문제

부수적으로 의존성 목록에 Gemini SDK 패키지가 누락된 것도 확인했다 (코드는 그 SDK 를 임포트한다). 도커 이미지에서만 우연히 해결되고 있었다.

이 검증이 말하지 않는 것


배포 환경 실측 (2026-08-30)

자막 지연 — 단일 세션

발화 구간 길이 자막 지연
7.39 → 12.83s 5.44s ~5.2s
13.02 → 18.94s 5.92s ~5.1s
19.33 → 25.31s 5.98s ~5.7s
25.60 → 29.98s 4.38s ~6.0s

지연은 추론에서 오지 않는다

구간 실측/추정 비중
STT 추론 발화길이 × RTF 0.15 ≈ 0.7~0.9s ~15%
VAD 침묵 확정 세그먼트 간 공백 실측 0.19~0.39s ~5%
🔴 미규명 ~4s ~75%

추론은 실시간의 6.7배 빠른데(RTF 0.15) 자막은 5.5초 늦는다. §1 이 주장한 오프로딩은 이벤트 루프를 막지 않는 데는 성공했지만, 지연의 대부분은 그 바깥에 있다.

동시 4세션 — 실시간 자막이 죽는다

지표 단일 세션 동시 4세션
스트리밍 중 수신 자막 2건 🔴 0건
세션당 세그먼트 4개 2개 (VAD 분할이 바뀜)
세션 소요 37.5초 55.5초
마지막 전사 기록 스트림 내 🔴 클라이언트 종료 후 17~20초

원인 가설을 세우고 — 측정으로 기각했다

지표 threads=6 threads=2
단일 세션 자막 지연 5.2s 🔴 9.2 → 11.1s (세그먼트마다 누적)
단일 세션 소요 37.5s 47.6s
4세션 스트리밍중 자막 0건 0건 (변화 없음)
4세션 소요 55.5s 55.6s

검증 현황