ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 마이크 떼자마자 STT를 깨우는 법 — Warmup POST 패턴
    IT 2026. 5. 13. 21:00
    마이크 떼자마자 STT를 깨우는 법 — Warmup POST 패턴

    음성 챗봇의 사용자 흐름은 "마이크 누름 → 말함 → 마이크 뗌 → 응답 들음" 네 단계입니다. 이 중 사용자가 가장 답답해하는 구간이 "마이크 뗀 직후 → 첫 응답 시작"입니다. 여기서 침묵이 길어지면 챗봇 자체가 잘못된 것 같은 인상을 줍니다.

    이 침묵을 줄이는 작은 트릭이 Warmup POST입니다. 마이크 권한을 받는 순간(또는 마이크를 켜는 순간)에 STT 서버에 빈 요청을 한 번 보냅니다. 사용자가 말하는 동안 STT 데몬이 백그라운드에서 깨어 모델을 GPU에 로드합니다. 사용자가 마이크를 떼고 실제 transcribe 요청이 도착할 때, 데몬은 이미 준비된 상태입니다. 콜드 스타트 5초가 사용자 인지 시간 바깥으로 밀려나는 패턴입니다.

    STT 데몬도 on-demand라서 콜드 스타트가 있다

    가정용 서버에서 GB급 모델은 항상 띄워두기 부담스럽습니다. STT(Whisper) 데몬도 별도 글에서 다룬 on-demand 패턴으로 운영합니다 — 첫 요청에 깨어나고 1시간 idle하면 자기 종료. 이게 자원 효율은 좋은데, 깨어날 때마다 모델 로드(GPU에 1.6GB 옮기기)가 5초 정도 걸립니다.

    아이가 가끔 챗봇과 대화할 때 — 학교 다녀와서, 자기 전 — 매번 첫 발화에서 5초 대기가 발생합니다. 그 다음 발화부터는 데몬이 깨어 있어서 빠르지만, "처음 한 번"이 인상을 결정합니다. 매 세션 첫 응답이 느리면 챗봇 자체가 느린 거라고 느낍니다.

    병렬화의 발상

    이 5초를 어디로 옮길 수 있을까. 두 가지 옵션이 떠오릅니다.

    데몬을 항상 띄워둔다. 콜드 스타트는 0이지만 자원 점유가 영원합니다. STT 데몬만 GPU 2GB 정도를 24시간 잡고 있게 됩니다. 가정용 서버에서 이건 부담스럽습니다.

    마이크를 켜는 순간에 데몬을 미리 깨운다. 사용자가 말하는 동안(보통 2~5초) 백그라운드에서 모델 로드가 진행됩니다. 발화가 끝나는 시점엔 데몬이 이미 준비됨.

    옵션 ②가 자연스럽습니다. 사용자 발화 시간과 모델 로드 시간을 시간축에서 겹쳐 진행하는 거죠. 사용자가 말하는 동안 서버는 일하고 있고, 사용자가 멈추는 순간 응답이 거의 즉시 시작됩니다.

    Warmup 미적용 vs 적용 타임라인

    그림 설명 — A 방식(warmup 없음)에서는 사용자 발화가 끝난 다음에야 STT 데몬이 깨어나기 시작합니다. 콜드 스타트 5초 + 추론 1초가 일렬로 쌓여 첫 응답까지 8초가 걸립니다. B 방식(warmup 적용)에서는 마이크를 누르는 순간 백그라운드에서 데몬이 깨어나기 시작하고, 사용자 발화 3초 동안 모델 로드가 거의 완료됩니다. 발화 종료 시점엔 데몬이 준비돼 있어 STT 추론만 1초 들이면 응답이 시작됩니다. 사용자가 인지하는 첫 응답 지연이 절반으로 줄어듭니다.

    구현 — 두 줄짜리 비동기 fetch

    프론트엔드 측 구현은 정말 단순합니다. 마이크 시작 함수에 fetch 한 줄만 추가하면 끝납니다.

    async function startMic() {
      if (appState !== STATE.IDLE) return;
      try {
        // ★ 마이크 시작 직후 STT 데몬을 비동기로 깨움
        // 응답을 기다리지 않고 fire-and-forget. 실패해도 무시.
        fetch('/api/transcribe-warmup', { method: 'POST' }).catch(() => {});
    
        micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
        mediaRecorder = new MediaRecorder(micStream);
        mediaRecorder.ondataavailable = e => recordedChunks.push(e.data);
        mediaRecorder.onstop = onRecordingStopped;
        mediaRecorder.start();
        setState(STATE.LISTENING);
      } catch (e) {
        console.error('mic start failed', e);
      }
    }
    

    코드 설명 — 핵심은 fetch(...).catch(() => {}); 한 줄입니다. await를 쓰지 않고 promise를 던져만 두는 fire-and-forget 패턴입니다. 응답을 기다리지 않으니 마이크 권한 요청·녹음 시작이 그대로 진행됩니다. .catch로 비어 있는 핸들러를 단 이유는, fetch가 실패하더라도(데몬이 안 떠서, 네트워크 일시적 오류) 마이크 흐름에 영향을 주지 않기 위함입니다. warmup이 실패해도 사용자 흐름은 정상 진행되고, 발화 끝나면 본 transcribe 요청이 STT 데몬을 깨웁니다(다만 이때는 콜드 스타트가 그대로 발생).

    서버 측에서도 단순합니다. warmup 엔드포인트는 데몬 health check를 던지고 즉시 응답을 돌려줍니다.

    async def handle_transcribe_warmup(request):
        """마이크 시작 직후 호출되는 비동기 깨움 엔드포인트.
    
        데몬이 안 떠 있으면 systemd가 socket activation으로 띄우면서
        모델 로드를 시작한다. 응답은 즉시 돌아간다(데몬이 준비될 때까지
        기다리지 않음).
        """
        # 게이트웨이의 warmup 엔드포인트 URL — 챗봇은 이 주소로 그대로 패스스루한다.
        upstream_url = f"{config.GEMMA_BASE_URL}/api/transcribe-warmup"
    
        # 비동기 HTTP 세션. with 블록을 빠져나가면 커넥션 풀이 자동 정리된다.
        async with aiohttp.ClientSession() as sess:
            try:
                # 게이트웨이로 POST 한 방. 데몬이 안 떠 있으면 systemd socket이
                # 이 연결을 받으면서 데몬을 띄우고 모델 로드를 시작한다(socket activation).
                async with sess.post(upstream_url) as upstream:
                    if upstream.status == 200:
                        # 데몬이 이미 떠 있거나 정상적으로 깨어난 케이스 — 응답 그대로 전달.
                        return web.json_response(await upstream.json())
                    # 비정상 응답이어도 클라이언트에는 200을 돌려준다.
                    # warmup이 실패해도 사용자 흐름(마이크 → 발화 → transcribe)은 막지 않는다.
                    return web.json_response({"warming_up": False}, status=200)
            except aiohttp.ClientError:
                # 게이트웨이 자체에 못 닿는 경우(네트워크 오류 등)도 동일하게 fail-soft.
                # 본 transcribe 요청이 어차피 한 번 더 데몬을 깨우니, 이 호출의 실패는 치명적이지 않다.
                return web.json_response({"warming_up": False}, status=200)
    

    코드 설명 — 챗봇의 라우터는 그냥 게이트웨이의 같은 이름 엔드포인트로 패스스루합니다. 게이트웨이 측은 STT 데몬이 떠 있으면 health check만 하고 200을 돌려주고, 안 떠 있으면 systemd socket이 첫 연결을 받아 데몬을 띄우면서 모델 로드를 시작합니다. 응답은 데몬이 준비될 때까지 기다리지 않고 즉시 돌아갑니다(라우터 입장에선 200이든 5xx든 신경 안 씀, 그저 트리거 역할). 사용자 발화 동안 데몬은 백그라운드에서 묵묵히 모델을 로드합니다.

    비용 — 안 쓰면 낭비 아닌가?

    이 패턴의 잠재적 단점은 사용자가 마이크를 누르고 곧 말 안 할 수도 있다는 점입니다. 그러면 데몬은 깨어났는데 transcribe 요청이 도착하지 않아 자원만 점유하게 됩니다.

    실제로 일어나면 어떤 일이 생기는지 따져보겠습니다. 데몬이 깨어 모델을 로드하는 데 5초·2GB. 사용자가 5초 안에 발화를 시작하지 않으면 데몬은 그대로 idle 상태로 남고, 1시간 후 자기 종료합니다. 그 한 시간 동안 GPU 2GB가 점유되는 게 비용입니다.

    이 비용은 받아들일 만합니다. 이유는 두 가지입니다.

    1. 사용자가 마이크를 누른다는 건 거의 항상 말할 의도입니다. 실수로 누르고 가만히 있는 케이스는 매우 드뭅니다. 99% 이상의 경우 발화가 따라옵니다.
    2. warmup으로 깨어난 데몬은 어차피 그 세션 내내 활용됩니다. 한 번 누르면 보통 여러 발화로 이어지니, 첫 깨움이 그 세션 전체에 분산됩니다.

    완전한 낭비가 발생하는 시나리오 — 마이크 한 번 누르고 곧 잠그고 1시간 안 돌아오는 — 는 가족용 챗봇에선 거의 일어나지 않습니다. 만약 이런 패턴이 잦은 환경(예: 공용 키오스크)이라면 warmup을 마이크 권한 요청 직후가 아니라 발화 종료 1초 전쯤에 트리거하는 변형이 필요할 수 있습니다.

    일반화 — "사용자가 다음에 할 일을 추측해서 미리 시작한다"

    Warmup POST는 더 일반적인 UX 패턴의 한 사례입니다. 사용자가 다음에 할 일이 통계적으로 거의 확실하다면, 그 일을 위한 백그라운드 작업을 미리 시작한다는 거죠.

    웹 페이지에서 Hover 시 다음 페이지를 prefetch하기, 모바일 앱에서 화면을 열 때 다음 단계의 데이터를 미리 fetch하기, 검색창에 글자를 입력하면 추천 결과를 미리 가져오기 — 다 같은 발상입니다. "확실치 않은 미래의 일"을 미리 시작하는 건 약간의 자원 낭비를 감수하고 사용자 인지 지연을 줄이는 거래입니다.

    중요한 건 실패가 사용자에게 안 보이도록 처리하는 것입니다. fire-and-forget으로 던져두고, 결과를 기다리지 않고, 실패해도 메인 흐름은 진행되도록. 이 두 줄짜리 fetch가 그 패턴을 정확히 구현합니다. 코드는 짧지만 사용자 인지 시간이 절반으로 줄어든다 — 음성 챗봇처럼 첫 응답이 신뢰를 가르는 환경에서 이건 작지 않은 차이입니다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.