Google TTS 결과를 R2에 캐시하고 브라우저에 전달하는 서버 파이프라인을 다룹니다. 음성 출력에 영향을 주는 모든 설정을 canonical JSON으로 묶어 캐시 키를 만들고, 동시 cache miss와 stale metadata를 처리하며, R2 저장 후 DB를 갱신하는 순서를 설명합니다. 인증·rate limit·내구성 있는 후속 작업까지 포함한 운영 기준도 함께 정리했습니다.
uploadTTSToR2(???, buffer)
// R2에 뭐라고 저장해? 파일 이름이 없음!
// "안녕하세요.mp3" 로 저장?
// → 한글, 공백, 특수문자 → URL 인코딩 문제
// → "Hello, World!.mp3" → 쉼표, 느낌표 문제
hash가 있으면?
uploadTTSToR2("014e31fa...", buffer)
// R2에 "tts/014e31fa....mp3" 로 저장
// → 항상 안전한 16진수(0-9, a-f) 문자만 사용
// → URL에 그대로 쓸 수 있음
중복 방지 효과
같은 canonical 입력 → 항상 같은 hash → 같은 파일 경로
"안녕하세요" + ko-KR + 1.0 → "014e31fa..."
"안녕하세요" + ko-KR + 1.0 → "014e31fa..." ← 동일!
완료된 cache row가 있으면 Google TTS 호출 없이 같은 object를 반환
구분자 문자열만 이어 붙이면 입력 값에 구분자가 포함될 때 충돌할 수 있고, pitch·encoding처럼 결과를 바꾸는 필드를 빼면 서로 다른 오디오가 같은 key를 공유합니다. 합성 결과에 영향을 주는 모든 값과 pipeline version을 canonical payload에 포함해야 합니다.
hash만으로 동시 cache miss를 막을 수는 없습니다. DB의 hash에 unique constraint를 두고 한 요청만 pending row를 소유하도록 하거나, 같은 key를 single-flight/queue로 합쳐야 합니다.
공식 client library는 Application Default Credentials(ADC)를 사용합니다. API key를 query string에 넣으면 URL이 proxy나 access log에 남을 수 있으므로 server-to-server 인증에는 service account/ADC를 사용합니다. 직접 REST를 호출한다면 Bearer access token, Content-Type: application/json, timeout, response.ok와 응답 schema 검증을 추가해야 합니다.
REST 표현:
"안녕하세요" → Google TTS → "//NExAARi..." (base64 JSON)
Node.js client library:
protobuf `bytes` 응답 → Buffer/Uint8Array
Google은 왜 base64로 주나?
→ 응답이 JSON이므로 바이너리를 직접 넣을 수 없음
→ base64로 인코딩하면 안전한 문자열로 JSON에 담을 수 있음
3단계: R2에 있는지 확인 — DB 캐시 전략
방법 비교
방법 A: R2에 직접 물어보기 (HeadObject)
─────────────────────────────────────
서버 → R2: "tts/014e31fa...mp3 있어?"
R2 → 서버: "있어 (200)" or "없어 (404)"
소요시간: storage endpoint까지의 네트워크 왕복
방법 B: DB에서 조회하기 (실제 사용 방식)
─────────────────────────────────────
서버 → Supabase: "tts_cache에 hash 있어?"
DB → 서버: "있어" or "없어"
소요시간: DB 위치와 부하에 따라 달라짐
실제 코드
// R2에 직접 물어보지 않음!const { data: cached } = await supabase
.from('tts_cache')
.select('hash')
.eq('hash', hash) // "014e31fa..." 있어?
.maybeSingle()
const isCacheHit = cached !== nullif (isCacheHit) {
// 접근 정책에 맞는 custom-domain URL 또는 짧은 signed URL// POST를 object storage의 GET으로 바꾸기 위해 303을 명시한다.returnNextResponse.redirect(awaitgetAuthorizedTTSUrl(hash), 303)
}
DB index를 사용할 때의 조건
R2 업로드할 때 DB에도 동시에 기록:
buffer → R2 저장 (PutObject)
hash → DB 저장 (INSERT tts_cache)
다음 요청이 오면:
DB 조회 → ready면 허가된 R2 URL 사용
DB 조회 → 없으면 Google TTS 작업 소유권 획득
DB 조회가 더 빠른지는 배포 위치와 부하를 측정해야 함
DB row가 있다는 사실만으로 R2 object가 존재한다고 보장되지는 않습니다. R2 upload가 성공한 뒤 row를 ready로 바꾸고, redirect 실패나 주기적 reconciliation으로 orphan row/object를 복구합니다. Cloudflare는 r2.dev URL을 non-production 용도로 설명하므로 production 공개 파일은 custom domain을 사용합니다. 사용자별 접근 통제가 필요하면 public URL 대신 짧은 signed URL이나 application proxy를 사용합니다.
4단계: 응답 수명과 분리된 작업 실행
// 응답 후 반드시 끝나야 하는 작업은 durable queue에 먼저 기록한다.await ttsJobs.enqueue({ hash, cacheInput })
returnNextResponse.json({ status: 'pending', hash }, { status: 202 })
응답을 반환한 뒤 기다리지 않은 Promise는 serverless runtime이 process를 멈추면 완료되지 않을 수 있습니다. cache upload와 사용량 기록처럼 정확성이 필요한 작업에는 durable queue를 사용합니다. Next.js after는 응답 이후 코드를 실행할 수 있지만 route의 최대 실행 시간 안에서 동작하므로, 긴 작업과 재시도 보장은 queue가 맡아야 합니다.
이번 요청 vs 다음 요청
첫 번째 요청 ("안녕하세요"):
→ Google TTS 호출 (유료)
→ 동기 방식이면 R2 upload와 DB ready 후 MP3 반환
→ 비동기 방식이면 durable job ID와 202 반환
두 번째 요청 ("안녕하세요"):
→ DB 캐시 hit!
→ 허가된 R2 URL로 redirect
→ Google TTS 호출 없음
서버에서 나가는 데이터:
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 media type 확인
2. MP3 frame header와 bitstream parse
3. Audio decoder가 PCM sample 생성
4. audio output으로 재생
남용 방지 — 2단계 Rate Limit
TTS는 유료 API이므로 남용을 막아야 합니다.
Layer 1: 분당 제한 (burst 방지)
───────────────────────────
예: 요금제별로 서로 다른 정책값 적용
→ cache hit든 miss든 모든 요청에 적용
Layer 2: 일일 제한 (비용 방지)
───────────────────────────
cache miss(실제 Google 호출)에 별도 적용
→ cache hit도 인프라 남용 방지를 위한 전체 요청 제한에는 포함
왜 2단계인가?
Layer 1만 있으면:
분당 한도만으로는 누적 유료 호출량을 제한할 수 없음
Layer 2만 있으면:
짧은 시간의 요청 집중 → 서버 과부하
cache hit라도 DB 조회 부하가 생김
둘 다 있어야 burst와 누적 비용 모두 방어
로컬 저장 vs 직접 업로드 — 재시도 전략
방법 A: 바로 R2 업로드 (route.ts 방식)
─────────────────────────────────────
Google TTS → buffer → R2 업로드
↓ 실패하면?
buffer가 메모리에만 있다가 사라짐
→ Google TTS 재호출 필요 (유료!)
방법 B: 로컬 저장 후 R2 업로드
─────────────────────────────────────
Google TTS → buffer → 로컬 MP3 저장 → R2 업로드
↓ ↓ 실패하면?
파일이 남아있음 로컬 파일만 다시 업로드
TTS 재호출 없이 재시도
serverless instance의 로컬 파일은 다음 호출까지 유지된다고 보장할 수 없어 durable retry 저장소로 사용할 수 없습니다. 재시도가 필요하면 합성 결과를 durable object storage에 먼저 쓰거나, queue job이 TTS 호출부터 idempotent하게 재실행하도록 설계합니다. 긴 batch worker에서는 작업 volume을 사용할 수 있지만 수명과 정리 정책을 명시해야 합니다.
정리 — 전체 파이프라인
클라이언트 서버 (route.ts) 외부 서비스
───────── ────────────── ──────────
POST /api/tts
──────────────→
1. 인증 확인
2. hash 생성 (SHA-256)
3. 전체 요청 rate limit
4. DB 캐시 조회 ─────→ Supabase
├── hit → redirect ──→ R2 URL
└── miss ↓
5. miss 소유권/일일 한도 확인
6. Google TTS 호출 ────→ Google API
← base64 문자열 ─────
7. Buffer.from(base64)
8. R2 업로드 ─────────→ R2
9. DB ready commit ───→ Supabase
←─────────────
MP3 바이너리 직접 수신
Audio decoder → 재생
다음 동일 요청:
──────────────→
DB 캐시 hit!
←─────────────
허가된 R2 URL로 redirect