[Troubleshoot] Next.js TTS 모바일 발음 이슈 및 Gemini API 서버 사이드 캐싱 해결기
Next.js 환경에서 학습 서비스를 개발하며 겪은 두 가지 핵심 이슈인 '모바일 TTS 발음 파편화'와 'API 호출 비용 최적화를 위한 서버 캐싱'에 대한 트러블슈팅 과정을 공유합니다.
1. TTS 발음 이슈: 왜 내 폰에서는 영어가 한국어처럼 들릴까?
📌 문제 상황
PC 웹 브라우저에서는 유창한 영어로 들리던 TTS(Text-to-Speech)가 모바일(Android/iOS) 기기에서 접속하면 한국어 억지로 영어를 읽는 듯한 '한국어식 발음'으로 출력되는 현상이 발생했습니다.
🔍 원인 분석
브라우저 내장 window.speechSynthesis는 OS 및 브라우저 엔진에 따라 제공하는 음성 데이터(Voices)가 다릅니다. 단순히 lang = 'en-US'만 설정할 경우, 모바일 기기에서는 기본 설정된 한국어 엔진이 영어를 읽어버리는 경우가 발생하기 때문입니다.
✅ 해결 방법: 음성 엔진 우선순위 할당 로직 도입
모바일 환경을 감지하여, 품질이 검증된 엔진(Google, Apple 등)을 강제로 매칭하고 모바일 특유의 먹먹함을 해결하기 위해 pitch와 rate를 보정했습니다.
이전로직
async playTTS(text: string, options?: AudioServiceOptions): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.isSupported()) {
reject(new Error('Browser does not support speech synthesis'));
return;
}
const opts = { ...this.defaultOptions, ...options };
// 이전 재생 중지
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = opts.lang || 'en-US';
utterance.rate = opts.rate || 0.9;
utterance.pitch = opts.pitch || 1;
// 최고 품질 음성 선택
const voice = this.selectBestVoice(utterance.lang);
if (voice) {
utterance.voice = voice;
}
utterance.onend = () => resolve();
utterance.onerror = (error) => {
console.error('Speech synthesis error:', error);
reject(error);
};
window.speechSynthesis.speak(utterance);
});
}
이후 로직
async playTTS(text: string, options?: AudioServiceOptions): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window === 'undefined' || !window.speechSynthesis) {
return reject(new Error('지원하지 않는 브라우저입니다.'));
}
window.speechSynthesis.cancel();
const opts = { ...this.defaultOptions, ...options };
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = opts.lang || 'en-US';
const isMobile = this.checkIsMobile();
if(isMobile){
utterance.rate = options?.rate || 0.85;
utterance.pitch = 1.2; 보
} else {
utterance.rate = options?.rate || 0.9;
utterance.pitch = options?.pitch || 1.0;
}
// 핵심 로직: 영어 발음 강제를 위한 Voice Selection
const voices = window.speechSynthesis.getVoices();
const enVoices = voices.filter(v => v.lang.startsWith('en'));
// 모바일 품질이 좋은 엔진 순서대로 매칭
const selectedVoice =
enVoices.find(v => v.name.includes('Google') && v.lang === 'en-US') ||
enVoices.find(v => v.name.includes('Apple') && v.lang === 'en-US') ||
enVoices.find(v => v.lang === 'en-US') ||
enVoices[0];
if (selectedVoice) {
utterance.voice = selectedVoice;
}
utterance.onend = () => resolve();
utterance.onerror = (e) => reject(e);
window.speechSynthesis.speak(utterance);
});
}
2. Gemini API 재요청 이슈: LocalStorage의 한계와 서버 캐싱
📌 문제 상황
Gemini API는 호출당 비용이나 할당량이 제한되어 있습니다. 처음에는 사용자의 새로고침을 막기 위해 LocalStorage와 Client-side 메모리 캐시를 사용했으나, 이는 '개별 사용자'에게만 유효했습니다. 서비스에 1,000명의 새로운 사용자가 들어오면 여전히 1,000번의 API 호출이 발생하는 구조였습니다.
🔍 원인 분석
- LocalStorage/SWR: 클라이언트 브라우저에만 데이터가 존재하므로 기기 간 공유가 불가능합니다.
- 서버 메모리 변수: 서버가 재시작되면 초기화되며, 인스턴스가 여러 개일 경우 동기화되지 않습니다.
✅ 해결 방법: Next.js Route Segment Config를 통한 정적 캐싱
모든 사용자가 동일한 학습 데이터를 본다면, 매번 생성할 필요 없이 서버 레벨에서 정적 결과물로 빌드하거나 긴 주기(24시간 등)로 캐싱하는 것이 가장 효율적입니다.
TypeScript
// ✅ Next.js App Router 서버 캐시 전략 적용
export const revalidate = 86400; // 24시간 동안 캐시 유지
export const dynamic = 'force-static'; // 해당 라우트를 정적 페이지로 강제
export async function GET(request: NextRequest) {
try {
// API 호출 및 데이터 처리 로직...
return NextResponse.json(grammarData);
} catch (error) {
return NextResponse.json({ error: 'Failed' }, { status: 500 });
}
}
💡 마치며: 무엇을 배웠는가?
- TTS는 엔진 선택이 핵심이다: 단순
lang설정만으로는 부족하며, 디바이스별 보이스 리스트를 직접 필터링해야 일관된 UX를 제공할 수 있습니다. - 캐싱의 주체를 명확히 하자: * 개인화된 데이터 👉 LocalStorage / SWR
- 공통된 리소스 (콘텐츠) 👉 Server-side Caching (Next.js Cache)
불필요한 API 호출을 줄임으로써 비용 절감은 물론, 서버 응답 속도를 획기적으로 개선할 수 있었습니다.
'나의 주니어 개발 일기 > 트러블슈팅' 카테고리의 다른 글
| RabbitMQ 채널이 과도하게 생성되는 이슈 – Publisher만 사용했는데 Channel 51개 발생 (2) | 2025.07.22 |
|---|---|
| Spring Boot 쓰레드 풀 설정, 정말 안전할까? – 실무에서 놓치기 쉬운 포인트 (2) | 2025.05.30 |
| Spring Integration Udp 통신중 버퍼 크기로 인한 트러블 슈팅 (1) | 2024.07.05 |
| 코틀린 JPA 순환참조 문제 발생 (1) | 2024.01.30 |
| JAVA에서 C의 네트워크 패킷을 해석할때의 트러블슈팅 (0) | 2024.01.23 |
