[WebTranslator 개발기 #10] Gemini 429 쿼터 초과와 통합 프롬프트 빌더
지난 9편에서는 단어 사전 팝업이 열려 있는 상태에서 단축키(Alt+A)를 눌렀을 때 발생하던 팝업 잔존과 텍스트 이중 수집 충돌을 비동기 수집 전 동기적으로 팝업을 소탕하는 선행 소탕(Cleanup-First) 패턴으로 말끔히 해결했습니다.
기초 인터랙션과 다중 엔진 어댑터가 안착되자, 실제 사용량이 많은 대용량 웹문서(MDN 개발 문서, 방대한 깃허브 위키 등)와 무한 스크롤 웹페이지를 대상으로 본격적인 실전 번역을 가동했습니다. 그러나 100개 이상의 문단이 포함된 긴 페이지를 번역하던 중, Google Gemini API의 무료 티어 호출 한도에 걸려 429 Resource Exhausted 에러가 터지며 페이지 절반이 번역되지 않고 멈춰버리는 장애가 발생했습니다.
설상가상으로 프로젝트 초기에 "스크롤이나 AJAX로 새 글이 들어오면 대충 감지해서 번역하자"며 임시로 걸어두었던 조잡한 동적 로딩 감지기가 스크롤을 내릴 때마다 미세한 텍스트 덩어리를 쪼개어 API로 무차별 난사하면서 쿼터 고갈을 극도로 가속화하고 있었습니다.
이번 Part 4 (API 안정성 & 최적화)의 첫 글에서는 429 쿼터 제한을 극복하는 지수 백오프(Exponential Backoff), 임시 감지기를 걷어내고 정식 완성한 MutationObserver 디바운스 배치 큐, 그리고 모델별 프롬프트 파편화를 일원화한 중앙 통합 프롬프트 빌더 구축 과정을 정리해 보고자 합니다.
1. 대용량 번역의 복병: 15 RPM 쿼터와 임시 동적 감지기의 폭주
Google Gemini 1.5 Flash는 초고속 응답과 무료 API 티어를 제공하여 개인용 번역 확장 프로그램에 가장 매력적인 모델입니다. 하지만 무료 티어에는 엄격한 분당 요청 수 제한(15 RPM, Requests Per Minute)이 걸려 있습니다.
문제가 터진 원인은 두 가지의 악순환이었습니다:
- 지나치게 잘게 쪼갠 배치: 빠른 렌더링을 위해 텍스트 노드를 5~10개씩 작게 쪼개어 연속적으로
Promise.all병렬 호출을 날렸습니다. - 임시 동적 감지기의 무차별 난사: 개발 초기에 임시로 넣어두었던 DOM 감지기가 SPA나 무한 스크롤 웹페이지에서 새로운 노드가 주입될 때마다 디바운스 없이 즉시 개별 API 요청을 쏟아부었습니다.
{
"error": {
"code": 429,
"message": "Resource has been exhausted (e.g. check quota).",
"status": "RESOURCE_EXHAUSTED"
}
}
2. 프롬프트 파편화(Prompt Fragmentation)의 폐해
동시에 프로젝트 내부 구조에서도 큰 유지보수 결함이 드러났습니다. AI 어시스턴트가 모델별 어댑터를 작성하면서 각 파일마다 프롬프트 문자열을 제각각 구현해 두었던 것입니다.
// gemini_adapter.js: 모델마다 따로따로 작성된 프롬프트 (오류 코드)
function getGeminiPrompt(texts) {
return "Translate JSON array to Korean: " + JSON.stringify(texts);
}
// openai_adapter.js
function getOpenAIPrompt(texts) {
return "You are a professional translator. Output JSON array: " + JSON.stringify(texts);
}
발생한 문제점
- 품질 불일치: OpenAI(GPT)와 Gemini 간에도 프롬프트 지침이 미세하게 달라 존댓말/반말이 섞이거나 마크다운 백틱 처리 여부가 갈리는 등 엔진마다 번역 톤앤매너와 JSON 출력 형식이 통일되지 못했습니다.
- 유지보수 비용 폭증: "HTML 태그나 변수명은 번역하지 말 것"이라는 공통 번역 규칙을 하나 추가하려면 각 어댑터 파일을 전부 열어서 중복 수정해야 했습니다.
3. 해결책 1: 지수 백오프와 MutationObserver 디바운스 배치 큐
쿼터 폭주를 근본적으로 차단하기 위해, 임시 동적 감지기를 전면 폐기하고 디바운스 배치 큐 기반의 정식 MutationObserver와 지수 백오프(Exponential Backoff) 파이프라인을 구축했습니다.
| 방어 메커니즘 | 처리 로직 | 효과 |
|---|---|---|
| 배치 크기 압축 | 텍스트 노드를 20~25개 대형 청크로 통합 | 기본 API 호출 횟수 70% 이상 절감 |
| MutationObserver 큐 | 300ms 디바운스 + 배치 큐(`dynamicQueue`) 누적 | 무한 스크롤 노드 무차별 호출 차단 및 일괄 처리 |
| 지수 백오프 재시도 | 429 감지 시 1초 $\rightarrow$ 2초 $\rightarrow$ 4초 자동 대기 | 요청 취소 없는 100% 완주 보장 |
정식 MutationObserver 동적 번역 큐 구현
// src/content/dynamic_observer.js: 디바운스 배치 큐 기반 정식 동적 감지기
let dynamicQueue = [];
let debounceTimer = null;
export function initDynamicObserver() {
const observer = new MutationObserver((mutations) => {
// 번역 진행 중이 아닐 때는 무시
if (document.body.dataset.wtStatus !== "translated") return;
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
// 이미 번역된 래퍼(.wt-trans-wrapper)는 수집 제외
if (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains("wt-trans-wrapper")) {
const texts = collectTextNodes(node);
dynamicQueue.push(...texts);
}
});
}
// 300ms 디바운스로 요청을 묶어서 25개 단위 배치로 발송
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (dynamicQueue.length > 0) {
const batchToTranslate = dynamicQueue.splice(0, dynamicQueue.length);
dispatchDynamicBatchTranslation(batchToTranslate);
}
}, 300);
});
observer.observe(document.body, { childList: true, subtree: true });
}
4. 해결책 2: 중앙 통합 프롬프트 빌더 (`prompt_builder.js`)
모든 AI 모델이 공유하는 번역 규칙을 단일 모듈에서 일원화 관리하도록 중앙 통합 프롬프트 빌더를 구축했습니다.
모델마다 따로따로 프롬프트를 짜지 마라. 공통 4대 번역 규칙(1:1 매칭, 원문 언어 무관 번역, 코드/태그 원형 보존, 순수 JSON 강제)을 단일 베이스로 정의하고, 사용자 커스텀 지시문만 결합하여 반환하는 buildTranslationPrompt로 완전히 일원화해라.
| 공통 번역 4대 철칙 | 세부 지침 | 효과 |
|---|---|---|
| 1. 엄격한 1:1 매칭 | 입력 배열의 요소 개수와 인덱스 순서 100% 일치 보장 | 원문 노드와 번역문의 인덱스 결합 실패 방지 |
| 2. 비대상 언어 완전 번역 | 원문 언어(영어, 일본어, 중국어 등)에 무관하게 한국어로 번역 | 다국어가 혼용된 웹페이지 완벽 대응 |
| 3. 기술 명칭 원형 보존 | 코드 변수명, HTML 태그, 브랜드 고유명사 보존 | 개발 문서 레이아웃 및 코드 깨짐 방지 |
| 4. 순수 JSON 배열 강제 | 마크다운 백틱 및 서술형 인사말 출력 금지 | JSON 파싱 성공률 99.9% 보장 |
통합 프롬프트 빌더 구현
// src/background/prompt_builder.js: 중앙 통합 번역 프롬프트 빌더
export function buildTranslationPrompt(targetLang = "ko", customPrompt = "") {
const baseRules = [
`You are an expert professional translator. Translate the given JSON array of strings into ${targetLang}.`,
"",
"Strict Rules:",
"1. Maintain exact 1-to-1 array element correspondence and order.",
"2. Translate 100% of non-target text regardless of original language.",
"3. Keep code names, variables, HTML tags, and technical terms intact.",
"4. Return ONLY a valid JSON array with NO markdown backticks or commentary."
];
// 사용자가 옵션에서 등록한 커스텀 지시문 동적 결합
if (customPrompt && customPrompt.trim().length > 0) {
baseRules.push("", `User Custom Instructions: ${customPrompt.trim()}`);
}
return baseRules.join("\n");
}
5. 실전 검증 결과와 초기 3회 재시도 전략의 한계
MutationObserver 디바운스 큐와 통합 프롬프트 빌더를 얹은 후 200문단 이상의 방대한 기술 문서에서 실전 검증을 진행했습니다. 초기 아키텍처에서는 단순 재시도 카운터를 두어 다음과 같이 설계했습니다:
- 1차 429 발생: 5초 대기 후 즉시 재요청.
- 2차 429 반려: 10초 대기 후 2차 재요청.
- 3차 429 반려: 다시 10초 대기 후 3차 재요청.
- 4차 최종 실패: 3회 재시도 초과로 간주하고
Failed에러를 던지며 해당 배치 영구 포기.
발생한 심각한 결함: 빈번한 Failed 폭사
이론상으로는 25초 이상의 여유가 생기므로 쿼터가 회복될 것이라 예상했으나, 실전에서는 앞서 발송된 수많은 비동기 배치들이 여전히 15 RPM 쿼터를 연속으로 소모하고 있었습니다.
결국 고작 3번의 재시도로는 쿼터 병목을 뚫지 못해 페이지 곳곳에서 Failed 상태가 속출했고, 본문 군데군데가 번역되지 않고 흉하게 비어버리는 불완전 번역 결함이 발생했습니다.
6. 추가 개선: 큐 후방 재진입(Re-enqueuing)과 10초 지속 루프
단 1개의 문단도 유실 없이 100% 완주 번역을 보장하기 위해, 실패 시 포기하지 않고 배치를 큐 맨 뒤로 다시 밀어넣는 큐 후방 재진입(Re-enqueuing)과 10초 지속 루프로 아키텍처를 전면 개선했습니다.
// src/background/translator.js: 429 반려 시 큐 후방 재진입 및 지속 재시도 파이프라인
export async function processBatchWithPersistentRetry(batchQueue, engineConfig) {
while (batchQueue.length > 0) {
const currentBatch = batchQueue.shift();
try {
const translated = await callEngineAPI(currentBatch, engineConfig);
applyTranslationToDOM(currentBatch, translated);
} catch (err) {
if (err.status === 429 || err.message.includes("429")) {
console.warn("[WebTranslator] 429 쿼터 초과 감지. 10초 대기 후 큐 후방으로 재진입합니다.");
// 1. 이미 진행 중인 선행 요청들이 쿼터를 소모하고 비워질 때까지 10초 대기
await sleep(10000);
// 2. 실패 배치를 큐 맨 뒤로 다시 넣어 순서 재조정 (영구 포기 방지)
batchQueue.push(currentBatch);
} else {
console.error("[WebTranslator] 치명적 에러 발생:", err);
}
}
}
}
| 비교 항목 | 초기 3회 고정 재시도 | 개선된 큐 후방 재진입 지속 루프 |
|---|---|---|
| 실패 처리 방식 | 3회 실패 시 즉시 Failed로 버림 |
10초 대기 후 큐 맨 뒤로 다시 밀어넣어 무한 복구 |
| 선행 요청 대기 | 선행 배치들이 쿼터를 다 쓸 때까지 못 기다림 | 10초 여유를 확보하여 선행 요청 완결 후 쿼터 확보 |
| 최종 완주율 | 긴 문서에서 빈번한 일부 누락 발생 | Failed 발생률 0%, 100% 전수 번역 완주 |
마무리하며
큐 후방 재진입과 10초 지속 루프를 도입함으로써, Gemini 무료 티어의 15 RPM 한계 속에서도 수백 문단의 긴 웹페이지를 단 한 줄의 누락이나 Failed 에러 없이 100% 완주하는 강력한 복원력을 완성했습니다.
그러나 프롬프트를 일원화하고 쿼터 문제를 완전히 정복하여 쾌적하게 사용하던 중, 구글이 사전 예고 없이 모델을 Deprecated(지원 중단) 처리하면서 모든 Gemini 요청이 404 This model models/gemini-2.5-flash is no longer available 에러를 뿜으며 전면 중단되는 새로운 사태가 터졌습니다.
다음 글에서는 소스 코드 내 하드코딩된 모델명을 제거하고, 구글 API에서 현재 사용 가능한 최신 모델 목록을 실시간으로 가져오는 동적 모델 탐색(Dynamic Model Fetch) 시스템 구축 과정을 다루어 보겠습니다.
대화 참여하기