블로그
frontend
6遺??뚯슂

"서버 없이 STT를 돌린다": 4B급 음성 인식을 브라우저(WebGPU)로 밀어넣는 설계

클라이언트 측 STT(음성→텍스트)는 "모델을 띄우는 것"보다 "메모리·로딩·스트리밍 UI"를 설계하는 순간부터 현실이 된다. WebGPU + WASM으로 브라우저에서 4B급 음성 인식을 돌리는 실전 설계.

한 문장 결론: 클라이언트 측 STT(음성→텍스트)는 "모델을 띄우는 것"보다 "메모리·로딩·스트리밍 UI"를 설계하는 순간부터 현실이 된다.

브라우저에서 수 GB 모델을 돌리는 시도는 단순한 데모가 아니라, 개인정보(음성) 경로를 로컬로 고정하고 지연(latency)을 UX로 흡수하는 제품 패턴으로 이어질 수 있습니다.

이번 글은 "Rust + WASM + WebGPU로 4B급 스트리밍 음성 인식을 브라우저 탭에서 실행"하는 구현 사례를 바탕으로, Next.js 프런트엔드에서 재현 가능한 관점으로 핵심을 정리합니다.

배경/문제

브라우저 STT는 매력적이지만, 시작하자마자 벽을 만납니다.

  • 대용량 가중치 다운로드: 첫 실행 비용이 UX를 깨기 쉽다.
  • 메모리/주소 공간 제약: 단일 파일 로딩, ArrayBuffer 크기, 주소 공간 등 제약을 맞닥뜨린다.
  • GPU 읽기/동기화 제약: GPU 결과를 동기적으로 읽어오는 패턴이 막히기 쉽다.
  • 진짜 "실시간" UX: "Stop and transcribe"는 쉬운데, 말하는 동안 자막이 따라오는 UX는 어렵다.

그래서 브라우저 STT의 본질은 모델보다 데이터 파이프라인(오디오) + 로딩 전략(가중치) + 렌더링/스트리밍 UX입니다.


핵심 개념

다이어그램 불러오는 중...

→ 기대 결과/무엇이 달라졌는지: "한 번에 전사"가 아니라, 조각 단위로 축적·추론·렌더링하는 구조로 UX 목표가 명확해집니다.

원문 사례에서 눈에 띄는 해법은 다음 계열입니다.

  • 샤딩(Sharding): 모델 파일을 512MB 같은 덩어리로 쪼개 로딩 제약을 회피
  • 2단계 로딩: 파싱 단계와 최종 텐서 준비 단계를 분리해 메모리 피크를 낮춤
  • 비동기 텐서 처리: GPU 결과를 동기 readback 대신 비동기 API로 받는 흐름
  • 임베딩 테이블 분리/우회: 초대형 테이블은 GPU/CPU 역할을 나눠 병목을 피함
💡
핵심은 "브라우저는 작은 네이티브가 아니다"라는 사실입니다. 불러오는 순간과 유지하는 순간의 비용을 따로 봐야 합니다.

해결 접근

대안/비교(최소 2개):

접근 방식장점단점
서버 STT초기 로딩이 없고 안정적음성이 서버로 이동, 비용/스케일 문제
클라이언트 STT(WebGPU)프라이버시/오프라인/지연(네트워크) 이점초기 로딩과 기기 편차를 설계로 해결 필요

클라이언트 STT를 선택했다면, "처음 방문에 2~3GB 다운로드"를 그대로 노출하지 않고 아래처럼 우회합니다.

  • 명시적 동의 UI: "모델 다운로드/저장"을 사용자가 인지하게 만들기
  • Cache Storage 또는 IndexedDB에 저장하고 재방문 시 재사용
  • 샤드 단위 병렬 다운로드 + 점진 로딩으로 TTI(Time to Interactive) 개선

Cache Storage는 "요청 단위 캐시"에 유리합니다. 대용량 바이너리는 IndexedDB도 선택지입니다.

"진짜 실시간 자막"은 보통 다음 조합이 필요합니다.

  • 마이크 입력을 고정 길이 청크(예: 수백 ms~수 초)로 잘라 링 버퍼에 저장
  • 일정 간격으로 추론을 돌리고, 결과를 증분 렌더링(partial)으로 붙임
  • 사용자 발화 경계(침묵/에너지)로 청크 병합/확정을 수행

브라우저에서는 Web Audio API가 기본 선택입니다. 고정 지연·안정적 처리가 필요하면 AudioWorklet으로 입력 처리를 분리하는 편이 안전합니다.

브라우저 추론은 메인 스레드를 쉽게 잠가버립니다. 그래서 Next.js에서는 아래처럼 역할을 나누는 편이 좋습니다.

  • Client Component: 마이크 권한/녹음 UI/자막 렌더링
  • Web Worker: WASM 로드, 모델 샤드 로딩, WebGPU 실행 트리거
  • (선택) AudioWorklet: 입력 청크 생성과 링 버퍼 관리
다이어그램 불러오는 중...

→ 기대 결과/무엇이 달라졌는지: UI는 끊김 없이 반응하고, 추론은 백그라운드에서 돌아가 "말하는 동안 자막이 따라오는" UX에 가까워집니다.


구현(코드)

아래 코드는 "Next.js에서 마이크 입력을 받고, Worker에 청크를 넘겨 partial text를 UI에 업데이트"하는 최소 골격입니다.

typescript
// app/(demo)/page.tsx
"use client";

import { useEffect, useRef, useState } from "react";

export default function Page() {
  const [text, setText] = useState("");
  const workerRef = useRef<Worker | null>(null);
  const audioCtxRef = useRef<AudioContext | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(new URL("./stt.worker.ts", import.meta.url), { type: "module" });
    workerRef.current.onmessage = (e) => {
      if (e.data?.type === "partial") setText(e.data.text);
      if (e.data?.type === "error") console.error(e.data.error);
    };
    return () => workerRef.current?.terminate();
  }, []);

  async function start() {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const audioCtx = new AudioContext({ sampleRate: 16000 });
    audioCtxRef.current = audioCtx;

    const src = audioCtx.createMediaStreamSource(stream);
    const processor = audioCtx.createScriptProcessor(4096, 1, 1);

    // 데모용: ScriptProcessorNode는 레거시입니다.
    // 운영에서는 AudioWorklet을 권장합니다.
    processor.onaudioprocess = (ev) => {
      const input = ev.inputBuffer.getChannelData(0);
      // Float32Array 복사(메시지 전송 안전)
      workerRef.current?.postMessage({ type: "audio", samples: new Float32Array(input) }, [input.buffer]);
    };

    src.connect(processor);
    processor.connect(audioCtx.destination);

    workerRef.current?.postMessage({ type: "start" });
  }

  function stop() {
    workerRef.current?.postMessage({ type: "stop" });
    audioCtxRef.current?.close();
  }

  return (
	<main style= padding: 24 >
		<h1>Browser STT Demo</h1>
		<div style= display: "flex", gap: 8 >
			<button onClick={start}>Start</button>
			<button onClick={stop}>Stop</button>
		</div>
		<p style= marginTop: 16, whiteSpace: "pre-wrap" >{text}</p>
	</main>
  );
}

→ 기대 결과/무엇이 달라졌는지: 사용자는 "Stop and transcribe"를 기다리지 않고, partial 텍스트가 계속 갱신되는 형태로 체감 지연이 줄어듭니다.

⚠️
주의: ScriptProcessorNode는 데모용입니다. 안정적인 청크 처리에는 AudioWorklet을 고려하세요.
typescript
// app/(demo)/stt.worker.ts
let started = false;

// 실제 구현에서는 여기서 WASM 모듈 로드 + WebGPU 초기화 + 모델 샤드 로딩을 수행
async function init() {
  // TODO: load wasm, init webgpu, warmup
}

async function inferChunk(samples: Float32Array): Promise<string> {
  // TODO: feature extraction + model forward + decode(partial)
  return ""; // partial transcript
}

self.onmessage = async (e: MessageEvent) => {
  const msg = e.data;

  if (msg.type === "start") {
    if (!started) {
      started = true;
      await init();
    }
    return;
  }

  if (msg.type === "stop") {
    started = false;
    return;
  }

  if (msg.type === "audio" && started) {
    try {
      const partial = await inferChunk(msg.samples as Float32Array);
      (self as any).postMessage({ type: "partial", text: partial });
    } catch (err: any) {
      (self as any).postMessage({ type: "error", error: String(err?.message ?? err) });
    }
  }
};

→ 기대 결과/무엇이 달라졌는지: 무거운 추론 경로가 메인 스레드에서 분리되어, UI 멈춤(프리즈) 위험을 낮출 수 있습니다.


검증 방법(체크리스트)

첫 실행에서 "모델 다운로드/저장"이 사용자 동의 흐름에 포함된다
재방문 시 캐시에서 재사용되어 초기 로딩이 줄어든다
마이크 입력은 청크 단위로 전달되고, partial 텍스트가 끊김 없이 갱신된다
WebGPU 미지원 환경에서 기능이 안전하게 비활성화된다
Worker 분리 후에도 메모리 피크가 과도하지 않다(샤딩/2단계 로딩 적용 여부 확인)

흔한 실수/FAQ

Q1. "브라우저에서 2~3GB 다운로드"는 너무 과한가요?

과한지 여부는 UX 설계에 달렸습니다. 자동 다운로드가 아니라 명시적 선택 + 캐시 재사용 + 진행률 표시를 붙이면, 제품 맥락에 따라 감당 가능한 비용이 될 수 있습니다. 캐시 전략은 Cache API/IndexedDB 선택이 갈립니다.

Q2. "진짜 실시간"은 어디서 갈리나요?

대부분은 "모델 성능"보다 청크 길이, 링 버퍼 설계, partial 확정 규칙에서 갈립니다. 짧은 청크로 자주 업데이트하고, 침묵 구간에서 확정하는 방식이 UX를 크게 바꿉니다. (입력 처리는 AudioWorklet이 도움 됩니다.)

Q3. Next.js에서 서버 컴포넌트로 처리하면 더 쉽지 않나요?

브라우저 STT의 목표가 "클라이언트 측 추론"이라면, 오디오 수집과 WebGPU 실행은 클라이언트에서 이뤄져야 합니다. Next.js에서는 Client Component + Worker 조합으로 경계를 고정하는 편이 유지보수에 유리합니다.


요약(3~5줄)

  • 브라우저 STT의 핵심은 모델보다 로딩·메모리·스트리밍 UX입니다.
  • 샤딩/2단계 로딩/비동기 처리로 브라우저 제약을 우회할 수 있습니다.
  • Next.js에서는 Client Component(오디오/UX) + Worker(추론) 분리가 기본값입니다.
  • Cache API/IndexedDB로 재다운로드 비용을 줄이고, 명시적 동의 UI로 사용자 통제를 확보하세요.

결론

브라우저에서 4B급 STT를 돌리는 건 "가능/불가능"의 문제가 아니라, 어떤 제약을 어떤 UX로 상쇄하느냐의 문제입니다.

샤딩·캐시·Worker·링 버퍼까지 포함한 설계를 잡으면, "서버 없이도 음성을 처리하는 앱"이 데모가 아니라 제품 패턴으로 바뀝니다.


참고(공식 문서 링크)

관련 게시물