음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

무료 사용자 3회 제한을 Supabase Edge Function으로 구현하다

JustBlurt 다시 짓다 — 클라우드 STT 전환기 · 전체 5편 중 4편

  1. 1편 내가 만든 앱을 내가 안 쓴다 — 온디바이스 STT의 한계발행
  2. 2편 ElevenLabs Scribe v2 Realtime으로 갈아탄 이유발행
  3. 3편 맥미니를 AI 정제 서버로 만들다발행
  4. 4편 무료 사용자 3회 제한을 Supabase Edge Function으로 구현하다현재 편
  5. 5편 앱 출시 블로커가 사업자등록이라는 것발행

클라우드 STT를 붙이고 나서 곧바로 현실적인 문제가 생겼습니다. ElevenLabs Scribe v2는 정확도가 높은 대신 API 호출마다 비용이 발생합니다. 무제한 무료로 제공하면 사용자가 늘수록 적자 구조가 됩니다. 무료 사용자에게 일정 횟수를 허용하되, 초과 시 온디바이스 STT로 자동 전환하는 구조가 필요했습니다.

이번 편에서는 Supabase Edge Function scribe-token이 그 게이트 역할을 어떻게 수행하는지, 그리고 클라이언트에서 업셀 힌트를 어떻게 표시하는지 정리합니다.


1. 무제한 무료는 지속 불가능하다

온디바이스 STT(Apple Speech Framework)는 추가 비용이 없습니다. 기기 CPU를 쓰는 만큼 배터리 소모가 있지만 서버 비용은 없습니다. 반면 클라우드 STT는 호출당 요금이 발생합니다. 사용자가 하루에 10번, 100번 음성을 입력하면 그 비용은 곧바로 서비스 운영 비용이 됩니다.

처음에는 "무료로 제공하고 나중에 유료 전환"을 생각했습니다. 그런데 무제한 무료 기간이 길어질수록 유료 전환 저항이 높아지고, 비용만 먼저 쌓입니다. 구조 자체를 처음부터 제한적으로 설계하는 것이 낫습니다.

결정한 정책은 단순합니다.

구분일일 클라우드 STT 횟수한도 초과 시
Free 사용자3회 (환경 변수로 조정 가능)온디바이스 STT 자동 전환
Pro 사용자무제한해당 없음

3이라는 숫자는 임의의 값입니다. 앱의 주요 사용 패턴(짧은 메모 위주)을 감안하면, 하루 3회면 가벼운 사용자에게는 충분하고, 자주 쓰는 사용자에게는 업그레이드 동기가 됩니다.


2. scribe-token Edge Function 설계

Supabase Edge Function scribe-token은 두 가지 일을 합니다. 첫째, 사용자 인증과 일일 사용량 확인. 둘째, 통과한 사용자에게 ElevenLabs 단일 사용 토큰 발급.

토큰을 단일 사용으로 설계한 이유가 있습니다. 클라이언트에 ElevenLabs API 키를 직접 넣으면 노출 위험이 있습니다. 대신 서버에서 일회용 토큰을 발급하고, 그 토큰으로만 한 번의 STT 요청을 허용합니다. 토큰이 한 번 쓰이면 즉시 무효화됩니다.

// scribe-token/index.ts 개념
const dailyCount = await getDailyCount(userId)
const FREE_LIMIT = parseInt(Deno.env.get('FREE_DAILY_CLOUD_STT') ?? '3')
if (tier === 'free' && dailyCount >= FREE_LIMIT) {
  return new Response(
    JSON.stringify({ error: 'daily_limit_exceeded', hint: 'upgrade' }),
    { status: 403 }
  )
}

// 한도 통과 — 카운터 증가 후 단일 사용 토큰 발급
await incrementDailyCount(userId)
const token = await issueElevenLabsToken()
return new Response(JSON.stringify({ token }), { status: 200 })

일일 카운터는 Supabase DB 테이블의 usage_date와 cloud_stt_count 컬럼으로 관리합니다. 날짜가 바뀌면 카운터가 초기화됩니다. 카운터 증가는 토큰 발급 직전에 수행해, 발급 실패 시 카운터가 잘못 소비되지 않도록 트랜잭션으로 묶습니다.

환경 변수 FREE_DAILY_CLOUD_STT를 두면 코드 변경 없이 Supabase 대시보드에서 한도를 조정할 수 있습니다. 초기 런칭 시에는 5로 설정해두고 비용 추이를 보면서 낮추는 방식을 고려할 수 있습니다.


3. subscription_tier 기반 분기

무료/유료 구분은 profiles 테이블의 subscription_tier 컬럼으로 합니다. 기본값은 'free'이고, Pro 결제가 완료되면 'pro'로 업데이트됩니다.

결제 연동은 RevenueCat을 씁니다. RevenueCat은 App Store 구독 이벤트를 webhook으로 전달합니다. 해당 webhook을 받는 별도 Edge Function이 subscription_tier를 업데이트합니다.

// RevenueCat webhook handler 개념
const event = await req.json()
if (event.type === 'INITIAL_PURCHASE' || event.type === 'RENEWAL') {
  await supabase
    .from('profiles')
    .update({ subscription_tier: 'pro' })
    .eq('id', event.app_user_id)
}
if (event.type === 'EXPIRATION' || event.type === 'CANCELLATION') {
  await supabase
    .from('profiles')
    .update({ subscription_tier: 'free' })
    .eq('id', event.app_user_id)
}

Row Level Security는 scribe-token이 service role key로 실행되기 때문에 RLS 정책을 우회할 수 있습니다. 그러나 다른 사용자의 subscription_tier를 조회하거나 카운터를 임의로 건드리는 일은 함수 로직 안에서 명시적으로 자기 자신의 레코드만 접근하도록 제한합니다.


4. 클라이언트 업셀 힌트 표시

scribe-token이 403과 함께 { error: 'daily_limit_exceeded', hint: 'upgrade' }를 반환하면, 클라이언트는 두 가지를 동시에 처리합니다. 첫째, 온디바이스 STT로 폴백해서 현재 음성 입력은 계속 처리합니다. 둘째, 앱 상단에 업그레이드 배너를 표시합니다.

// iOS 클라이언트 개념 (Swift)
func handleScribeTokenFailure(error: ScribeTokenError) {
    switch error {
    case .dailyLimitExceeded:
        // 온디바이스 STT로 폴백 — 사용자 경험 끊기지 않음
        fallbackToOnDeviceSTT()
        // 업셀 배너 표시
        showUpgradeBanner(
            message: "오늘의 클라우드 변환 횟수를 모두 사용했습니다. Pro로 업그레이드하면 무제한으로 사용할 수 있습니다.",
            action: .upgrade
        )
    default:
        // 기타 에러도 온디바이스로 폴백
        fallbackToOnDeviceSTT()
    }
}

배너를 표시하는 타이밍이 중요합니다. 클라우드 STT 시도가 막힌 그 순간, 사용자는 "왜 품질이 달라졌지?"라고 느낄 수 있습니다. 그 직후에 이유를 설명하고 업그레이드 경로를 제시하는 것이 자연스러운 업셀 흐름입니다. 사용자가 불편을 인식하는 순간에 해결책을 보여주는 방식입니다.

배너는 앱 내 구매 화면으로 연결되며, 횟수는 자정에 자동 초기화된다는 안내도 함께 표시합니다. 한도 초과를 당장 해결하지 않을 사용자에게는 "내일 다시 3회 이용 가능"이라는 정보가 이탈 방지 역할을 합니다.


5. use-smart-transcriber 폴백 패턴

클라이언트의 전사 로직은 use-smart-transcriber라는 훅 하나로 추상화했습니다. 클라우드를 먼저 시도하고, 실패하면 온디바이스로 자동 전환하는 패턴입니다.

// use-smart-transcriber 개념 (React Native / TypeScript)
async function transcribe(audioBlob: Blob): Promise<TranscribeResult> {
  try {
    // 1. scribe-token 요청
    const { token } = await fetchScribeToken()
    // 2. ElevenLabs Scribe v2로 전사
    const text = await elevenLabsTranscribe(audioBlob, token)
    return { text, source: 'cloud' }
  } catch (err) {
    if (isLimitExceeded(err)) {
      // 업셀 힌트 트리거 (비동기, UI 레이어에 위임)
      notifyLimitExceeded()
    }
    // 3. 온디바이스 STT 폴백
    const text = await onDeviceTranscribe(audioBlob)
    return { text, source: 'ondevice' }
  }
}

이 패턴의 핵심은 폴백이 투명하다는 점입니다. 호출하는 쪽에서는 클라우드인지 온디바이스인지 신경 쓰지 않아도 됩니다. 반환값의 source 필드를 보면 어디서 처리됐는지 알 수 있고, UI 레이어는 이 값을 참고해 품질 표시 아이콘을 달거나 달지 않을 수 있습니다.

네트워크 오류나 ElevenLabs 서버 장애처럼 한도 초과가 아닌 다른 이유로 클라우드 STT가 실패한 경우에도 동일하게 온디바이스로 폴백합니다. 이 경우에는 업셀 힌트를 띄우지 않습니다. isLimitExceeded가 HTTP 403과 daily_limit_exceeded 에러 코드를 조합해서 판단합니다.

[참고] 온디바이스 STT 폴백은 품질이 다소 낮을 수 있습니다. 짧은 메모 위주라면 체감 차이가 작지만, 긴 문장이나 전문 용어가 많을 때는 클라우드 대비 정확도가 떨어집니다. 이 사실을 사용자에게 솔직하게 안내하는 것이 장기적으로 신뢰도를 높입니다.

보안 측면도 정리합니다. Supabase Row Level Security는 profiles와 daily_usage 테이블에 모두 적용됩니다. 사용자는 자신의 레코드만 읽고 쓸 수 있습니다. scribe-token Edge Function은 service role key를 사용하지만, 함수 내부에서 JWT에서 추출한 userId만 조회 대상으로 고정합니다. 토큰은 발급 후 단일 사용이므로, 탈취되더라도 재사용이 불가능합니다. 이 게이트 설계까지가 혼자 운영하는 사이드 프로젝트 JustBlurt 안에서 비용과 사용자 경험을 맞춰가며 부딪힌 문제였고, 다음 편에서는 이 앱을 App Store에 제출하며 마주한 현실을 다룹니다.


댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기