블로그 목록
Backend10분 읽기

TTS 서버 파이프라인 해부 — Google TTS → R2 캐시 → 브라우저 재생

Google TTS API 호출, SHA-256 hash 기반 중복 방지, DB vs R2 캐시 조회 전략, fire-and-forget 패턴, 2단계 Rate Limit까지. 서버 사이드 오디오 파이프라인을 코드 레벨에서 해부합니다.

TTSR2캐시Base64

Google TTS API를 호출하고, 결과를 R2에 캐싱하고, 브라우저에서 재생하는 서버 사이드 오디오 파이프라인을 코드 레벨에서 해부합니다. hash 기반 중복 방지, DB vs R2 캐시 조회 전략, fire-and-forget 패턴까지.


전체 흐름 — 한눈에 보기

클라이언트: "안녕하세요" TTS 요청
    ↓
┌─────────────────────────────────────────┐
│            GET /api/tts                  │
│                                          │
│  1. 인증 체크                            │
│  2. text+lang+voice+speed → SHA-256 hash │
│  3. DB에서 hash 조회 (캐시 확인)          │
│     ├─ hit  → R2 URL로 redirect (0원)    │
│     └─ miss → Google TTS 호출 (유료)      │
│  4. buffer → R2 업로드 (백그라운드)       │
│  5. MP3 바이너리 직접 반환                │
└─────────────────────────────────────────┘

1단계: Hash 생성 — 왜 필요한가?

function computeTTSHash(
  text: string, langCode: string, voice: string, speed: number
): string {
  return createHash('sha256')
    .update(`${text}|${langCode}|${voice}|${speed}`)
    .digest('hex')
}

// 예시:
// "안녕하세요|ko-KR|ko-KR-Neural2-A|1.0"
// → "014e31fa3c8b2d9a7f..." (64자리 16진수)

hash가 없으면?

uploadTTSToR2(???, buffer)
// R2에 뭐라고 저장해? 파일 이름이 없음!

// "안녕하세요.mp3" 로 저장?
// → 한글, 공백, 특수문자 → URL 인코딩 문제
// → "Hello, World!.mp3" → 쉼표, 느낌표 문제

hash가 있으면?

uploadTTSToR2("014e31fa...", buffer)
// R2에 "tts/014e31fa....mp3" 로 저장
// → 항상 안전한 16진수(0-9, a-f) 문자만 사용
// → URL에 그대로 쓸 수 있음

중복 방지 효과

같은 입력 → 항상 같은 hash → 같은 파일 경로

"안녕하세요" + ko-KR + 1.0 → "014e31fa..."
"안녕하세요" + ko-KR + 1.0 → "014e31fa..."  ← 동일!

100번 요청해도 R2에는 파일 1개만 존재
2번째부터는 Google TTS 호출 없이 캐시에서 반환

2단계: Google TTS 호출

async function callGoogleTTS(
  text: string, langCode: string, voice: string, speed: number
): Promise<Buffer | null> {

  const res = await fetch(
    `https://texttospeech.googleapis.com/v1/text:synthesize?key=${apiKey}`,
    {
      method: 'POST',
      body: JSON.stringify({
        input: { text },                    // "안녕하세요"
        voice: { languageCode: langCode, name: voice },
        audioConfig: {
          audioEncoding: 'MP3',             // MP3 포맷 요청
          speakingRate: speed,              // 재생 속도
          sampleRateHertz: 44100,           // 샘플레이트
        },
      }),
    },
  )

  // Google이 응답하는 JSON:
  // { "audioContent": "//NExAARi3ACMAAA..." }
  const { audioContent } = await res.json()

  // base64 문자열 → 실제 MP3 바이너리
  return Buffer.from(audioContent, 'base64')
}
흐름:
"안녕하세요" → Google TTS → "//NExAARi..." (base64) → Buffer (바이너리)

Google은 왜 base64로 주나?
→ 응답이 JSON이므로 바이너리를 직접 넣을 수 없음
→ base64로 인코딩하면 안전한 문자열로 JSON에 담을 수 있음

3단계: R2에 있는지 확인 — DB 캐시 전략

방법 비교

방법 A: R2에 직접 물어보기 (HeadObject)
─────────────────────────────────────
서버 → R2: "tts/014e31fa...mp3 있어?"
R2 → 서버: "있어 (200)" or "없어 (404)"
소요시간: ~50ms (네트워크 왕복)

방법 B: DB에서 조회하기 (실제 사용 방식)
─────────────────────────────────────
서버 → Supabase: "tts_cache에 hash 있어?"
DB → 서버: "있어" or "없어"
소요시간: ~1ms

실제 코드

// R2에 직접 물어보지 않음!
const { data: cached } = await supabase
  .from('tts_cache')
  .select('hash')
  .eq('hash', hash)        // "014e31fa..." 있어?
  .maybeSingle()

const isCacheHit = cached !== null

if (isCacheHit) {
  // R2 public URL로 즉시 redirect
  // Google TTS 호출 없음 → 비용 0원
  return NextResponse.redirect(getTTSPublicUrl(hash))
  // → https://pub-xxx.r2.dev/tts/014e31fa....mp3
}

왜 DB가 더 나은가?

R2 업로드할 때 DB에도 동시에 기록:
  buffer → R2 저장 (PutObject)
  hash   → DB 저장 (INSERT tts_cache)

다음 요청이 오면:
  DB 조회 (1ms) → 있으면 R2 URL redirect
  DB 조회 (1ms) → 없으면 Google TTS 새로 호출

HeadObject(50ms) vs DB 조회(1ms) = 50배 차이
매 요청마다 50ms를 절약하면 체감 성능이 달라짐

4단계: Fire-and-Forget — await 없는 백그라운드 실행

// ❌ await 있으면 (느린 방식)
await uploadTTSToR2(hash, audioBuffer)      // 300ms 대기
await supabase.from('tts_cache').insert()   // 50ms 대기
await supabase.from('tts_usage').insert()   // 50ms 대기
return response                              // 총 400ms 후 응답

// ✅ await 없으면 (실제 코드 — 빠른 방식)
uploadTTSToR2(hash, audioBuffer).catch(...)  // 시작만 하고 넘어감
supabase.from('tts_cache').insert()          // 시작만 하고 넘어감
supabase.from('tts_usage').insert()          // 시작만 하고 넘어감
return response                              // 즉시 응답!
시간 흐름:

[await 방식]
요청 ─── TTS(200ms) ─── R2(300ms) ─── DB(50ms) ─── 응답
                                                     ↑ 550ms

[fire-and-forget 방식]
요청 ─── TTS(200ms) ─── 응답 반환!
                    └── R2 업로드 (백그라운드)
                    └── DB 기록 (백그라운드)
                         ↑ 200ms ← 350ms 절약!

이번 요청 vs 다음 요청

첫 번째 요청 ("안녕하세요"):
  → Google TTS 호출 (유료)
  → MP3 바이너리 직접 반환 ← 이번엔 직접 줌
  → 백그라운드: R2 업로드 + DB 기록

두 번째 요청 ("안녕하세요"):
  → DB 캐시 hit!
  → R2 URL로 redirect ← 0원, 1ms
  → Google TTS 호출 없음

5단계: 응답 반환 — Buffer에서 브라우저까지

return new NextResponse(new Uint8Array(audioBuffer), {
  headers: {
    'Content-Type': 'audio/mpeg',           // "이건 MP3야"
    'Content-Length': audioBuffer.length.toString(),
    'Cache-Control': 'private, max-age=86400', // 24시간 브라우저 캐시
  },
})
서버에서 나가는 데이터:
  HTTP/1.1 200 OK
  Content-Type: audio/mpeg      ← 브라우저가 MP3로 인식
  Content-Length: 48000

  ff f3 10 c4 00 1c a5 ...     ← 실제 MP3 바이너리

브라우저가 받으면:
  1. Content-Type 보고 "MP3다!" 인식
  2. ff f3 프레임 마커 확인
  3. Audio 디코더로 파형 복원
  4. 스피커로 재생 🔊

남용 방지 — 2단계 Rate Limit

TTS는 유료 API이므로 남용을 막아야 합니다.

Layer 1: 분당 제한 (burst 방지)
───────────────────────────
유료 회원: 10회/분
무료 회원: 3회/분
→ 캐시 hit든 miss든 모든 요청에 적용

Layer 2: 일일 제한 (비용 방지)
───────────────────────────
cache miss(실제 Google 호출)에만 적용
→ 캐시 hit는 Google 비용이 0원이므로 제한하지 않음
→ 같은 문장 100번 들어도 비용은 1회분
왜 2단계인가?

Layer 1만 있으면:
  10회/분 × 60분 × 24시간 = 14,400회/일
  전부 새로운 문장이면 Google 비용 폭발

Layer 2만 있으면:
  1초에 100번 요청 (DoS) → 서버 과부하
  cache hit라도 DB 조회 부하가 생김

둘 다 있어야 burst와 누적 비용 모두 방어

로컬 저장 vs 직접 업로드 — 재시도 전략

방법 A: 바로 R2 업로드 (route.ts 방식)
─────────────────────────────────────
Google TTS → buffer → R2 업로드
                        ↓ 실패하면?
                    buffer가 메모리에만 있다가 사라짐
                    → Google TTS 재호출 필요 (유료!)

방법 B: 로컬 저장 후 R2 업로드
─────────────────────────────────────
Google TTS → buffer → 로컬 MP3 저장 → R2 업로드
                         ↓               ↓ 실패하면?
                    파일이 남아있음   로컬 파일만 다시 업로드
                                     Google TTS 재호출 불필요!
                                     비용 0원으로 재시도!

현재 route.ts는 방법 A를 사용합니다. 실시간 API 응답이라 로컬 파일을 쓰기 어렵고, R2 업로드 실패율이 매우 낮기 때문입니다. 배치 처리(스크립트)에서는 방법 B가 더 안전합니다.


정리 — 전체 파이프라인

클라이언트                    서버 (route.ts)                  외부 서비스
─────────                    ──────────────                  ──────────
GET /api/tts?text=안녕
    ──────────────→
                              1. 인증 확인
                              2. hash 생성 (SHA-256)
                              3. DB 캐시 조회 ─────→ Supabase
                                 ├── hit → redirect ──→ R2 URL
                                 └── miss ↓
                              4. rate limit 확인
                              5. Google TTS 호출 ────→ Google API
                                 ← base64 문자열 ─────
                              6. Buffer.from(base64)
                              7. R2 업로드 (백그라운드) → R2
                              8. DB 기록 (백그라운드) → Supabase
    ←─────────────
    MP3 바이너리 직접 수신
    Audio 디코더 → 🔊 재생

다음 동일 요청:
    ──────────────→
                              DB 캐시 hit!
    ←─────────────
    R2 URL로 redirect (1ms, 0원)

© 2026 Frank Kim. All rights reserved.