브라우저 내장 AI가 워커에서 멈춘 이유 — Gemini Nano 실전 연동기
안녕하세요, 천상나타입니다.
운영하는 도구 사이트에 브라우저 내장 AI로 퀴즈를 만드는 기능을 붙여봤어요. Chrome에 들어있는 Gemini Nano(Prompt API)가 문제를 만들고, Translator API가 번역하는 구조인데요. 서버가 필요 없어서 비용 0원이라는 게 매력적이었습니다.
문제는 만들고 나서였어요. "AI 준비 중…" 화면에서 영원히 안 넘어가는 버그가 있었는데, 원인을 찾는 과정이 꽤 재미있어서 정리해봅니다.
배경: 브라우저 내장 AI가 뭔데?
Chrome은 몇 버전 전부터 브라우저에 AI가 내장돼 있습니다. LanguageModel(Prompt API)로 Gemini Nano를 돌리고, Translator로 번역도 해요. 전부 온디바이스라 입력이 서버로 안 가고, API 키도 필요 없습니다.
const session = await LanguageModel.create({
expectedOutputs: [{ type: 'text', languages: ['en'] }],
});
const result = await session.prompt('Write a trivia question...');
다만 제약이 큽니다.
- 데스크톱 Chrome 전용 — 모바일·Safari·Firefox는 전부 미지원
- 첫 사용 시 수GB 모델 다운로드 — 디스크 여유 20GB+ 필요
- 출력 언어 제한 — en·es·ja·de·fr만 됨. 한국어·중국어 출력 불가
그래서 설계를 이렇게 잡았어요. AI 추론은 무거우니 Web Worker에서 돌리고, ko/zh는 영어로 생성 후 번역, AI가 안 되는 브라우저는 미리 번역해 둔 정적 퀴즈 뱅크로 플레이. 여기까지는 깔끔했는데…
삽질 1: 워커에서 create()가 영원히 끝나지 않는다
만들자마자 바로 걸렸어요. "퀴즈 시작"을 누르면 "AI 준비 중…"만 뜨고 아무 일도 없습니다. 콘솔엔 에러 하나 없었고요.
구조는 이랬습니다. 버튼 클릭 → 워커에 init 메시지 → 워커 안에서 LanguageModel.create() → 세션 준비 완료 메시지. 이론상 문제없어 보이죠.

원인은 사용자 제스처(user activation) 였습니다. 모델 다운로드를 시작하는 create()는 "방금 사용자가 뭔가 클릭했다"는 제스처가 필요한데, 워커 안에서는 그 제스처가 전달되지 않아요. 메인 스레드의 클릭은 살아있지만 postMessage를 넘어가면 사라집니다.
더 헷갈린 건, 모델이 이미 받아져 있으면 create()가 제스처 없이도 바로 끝난다는 거예요. 그래서 어떤 환경에선 멀쩡하고 어떤 환경에선 영구 정지 — 재현이 들쭉날쭉이었습니다.
해결은 경로를 나누는 것이었어요.
// 클릭 핸들러 안 — 제스처가 살아있는 동안 메인 스레드가 먼저 움직인다
if (needsDownload) {
await mainEngine.ensure(); // 다운로드 트리거
await mainEngine.ensureTranslator(locale); // 언어팩도 같이
}
// 모델이 받아지면 워커는 캐시된 팩으로 자체 세션을 연다
const r = await callWorker({ type: 'init', locale });
실제 GB급 다운로드는 브라우저 내부 IO라 메인 스레드를 막지 않고, 무거운 추론·번역은 그대로 워커가 담당합니다. 다운로드를 "시작"하는 순간만 제스처가 필요할 뿐이라 이 구조면 됐어요.

삽질 2: 다운로드 진행률이 영원히 0%
워커 문제를 풀고도 또 걸렸어요. 모델 다운로드가 분명히 돌고 있는데 진행률 바가 0%에서 안 움직이는 겁니다.
monitor: (m) => m.addEventListener('downloadprogress', (e) => {
onProgress(e.loaded / e.total); // ← 내가 처음 쓴 방식
}),
문제는 downloadprogress 이벤트의 loaded가 Chrome 버전에 따라 다른 값이라는 거예요. 어떤 버전은 바이트(예: 153,000,000 / 2,400,000,000), 어떤 버전은 0~1 분수를 줍니다. 분수 버전에서 total은 undefined라, 나눗셈이 NaN이 되거나 0에 고정돼요. 다운로드는 정상인데 UI만 멈춘 것처럼 보였던 겁니다.
둘 다 처리하게 고쳤어요.
const pct = total && total > 1
? Math.min(100, Math.round((loaded / total) * 100)) // 바이트 방식
: Math.min(100, Math.round(loaded <= 1 ? loaded * 100 : loaded)); // 분수 방식
스펙이 표준화 진행 중이라 API가 흔들리는데, 이런 건 문서보다 둘 다 받는 방어 코드가 답이었습니다.
삽질 3: hang은 에러로 오지 않는다
여기가 제일 중요한 교훈이었어요. await가 영구 pending되면 reject도 안 날아와서 try/catch로 잡을 수가 없습니다. 그냥 그 줄에서 코드가 멈춰요.
그래서 모든 대기 지점에 데드라인을 달았습니다.

패턴은 전부 Promise.race입니다.
const useAi = await Promise.race([
prepareQuizAi(locale, onProgress), // 실제 작업
noProgress(30_000), // 진행 이벤트가 30초 없으면 false
]);
포인트가 두 개 있어요.
진행 중과 hang을 구분해야 합니다. 모델 다운로드는 몇 분씩 걸리는 게 정상이거든요. 그래서 단순 타임아웃이 아니라 "마지막 downloadprogress 이벤트 이후 30초"로 잡았어요. 다운로드가 건강하면 이벤트가 계속 들어오니 안 끊기고, 멈췄으면 끊깁니다.
죽은 워커를 재시도하면 안 됩니다. 워커가 죽은 상태로 두면 이후 문제마다 30초 타임아웃을 기다리게 돼요. 그래서 한 번 실패하면 dead 플래그를 세워서 이후엔 즉시 폴백으로 넘어가게 했어요.
const killWorker = () => {
workerDead = true;
worker?.terminate();
// pending 작업들도 전부 reject로 정리
for (const job of pending.values()) job.reject(new Error('worker dead'));
pending.clear();
};
그리고 사용자한테도 탈출구를 하나 뒀어요 — "AI 준비 중" 상태에서 버튼이 "준비된 문제로 바로 시작"으로 바뀝니다. 기다리기 싫으면 눌러서 바로 플레이하는 거죠.
덤: 한국어를 못 쓰는 AI에게 한국어 퀴즈를 시키기
언어 문제도 한 번 우회가 필요했어요. Nano는 한국어·중국어 출력이 안 되는데, 한국어 퀴즈를 내야 했으니까요.
결론은 "영어로 만들고 번역" 이었습니다. 프롬프트에 청중을 넣어두면("players in South Korea — include Korea-related general knowledge") 영어로 만들지만 한국 관련 문제가 나오고, 그걸 Translator로 한국어로 옮겨요.

responseConstraint에 JSON 스키마를 넣으면 출력 형식도 강제됩니다.
const schema = {
type: 'object',
properties: {
question: { type: 'string' },
choices: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 },
answerIndex: { type: 'integer', minimum: 0, maximum: 3 },
explanation: { type: 'string' },
},
required: ['question', 'choices', 'answerIndex', 'explanation'],
};
await session.prompt(prompt, { responseConstraint: schema });
자유 텍스트를 파싱하는 것보다 이게 훨씬 안정적이었어요. 그래도 가끔 깨진 출력이 나와서 JSON 파싱 실패 시 한 번 재시도하는 안전망은 남겨뒀습니다.
덤 2: 기다리는 대신 미리 만들기
생성 자체도 문제당 몇 초가 걸려서, 하나 더 손을 댔어요. 답을 고르는 동안 다음 문제를 미리 생성하는 겁니다.

문제를 화면에 띄우는 순간 generateQuiz를 promise로 발사해두고, 다음 문제로 넘어갈 때 그 promise를 꺼냅니다. 사용자가 답을 고르고 해설을 읽는 몇 초가 곧 생성 시간이라, 2번째 문제부터는 체감 대기가 거의 없어요.
주의할 건 하나뿐인데, 재시작이나 종료 후에 늦게 도착한 결과가 진행 중인 게임을 덮어쓰면 안 됩니다. 실행할 때마다 번호(runId)를 올려서, 돌아온 promise의 번호가 다르면 그냥 버려요.
const pref = prefetchRef.current;
prefetchRef.current = null;
const q = pref && pref.id === runId ? await pref.p : await generateQuiz(...);
로딩 화면에는 "서버에 안 보낸다" 같은 기술 설명 대신, 3.5초마다 바뀌는 잡학 상식("알고 계셨나요? 문어는 심장이 3개입니다")을 넣었어요. 기다리는 시간에 읽을 거리를 주는 쪽이 훨씬 낫더라고요.
한계도 분명하다
솔직히 적으면, 브라우저 내장 AI는 아직 이른 단계입니다.
- 쓸 수 있는 사람이 적어요. 데스크톱 Chrome + 하드웨어 요건(VRAM 4GB+ 또는 RAM 16GB+) 충족이 필요합니다. 모바일 사용자는 전부 제외예요.
- 첫 다운로드가 무겁습니다. GB 단위라 첫 클릭 한 번으로 시작해야 하고, 그동안 기다려줄 사용자는 별로 없죠.
- 정답이 틀릴 때가 있어요. 소형 모델이라 "국가 3요소" 같은 지식 퀴즈도 간혹 엉뚱한 정답을 냅니다. 그래서 이상한 문제는 넘기라고 안내하고, 준비된 문제 뱅크를 반드시 병행하는 게 맞아요.
- 문서가 애매한 부분이 있습니다. 워커에서의 제스처 전파 같은 건 버전마다 다를 수 있어서, "이 환경에선 된다"에 의존하면 안 됩니다.
정리
브라우저 내장 AI는 "설치 없이 공짜로 쓰는 AI API"라는 점에서 분명 매력적이에요. 다만 이번에 배운 건, API 호출이 쉬운 거지 제품으로 만드는 게 쉬운 건 아니었다는 겁니다.
제스처가 워커에 안 넘어가고, 진행률 값이 버전마다 다르고, hang은 에러조차 안 던지고 — 결국 본체는 LanguageModel 호출 한 줄이 아니라 그 주변의 방어 레이어였어요. 어느 경로로 실패해도 게임이 멈추지 않게 하는 설계가 진짜 일이었습니다.
다음엔 이 퀴즈를 실제로 붙여서 지표를 봐야겠어요. AI 모드로 들어오는 사람이 얼마나 되는지 궁금하네요.
'IT > javaScript' 카테고리의 다른 글
| 브라우저 내장 AI 지도 — Gemini Nano, 지금 뭘 쓸 수 있나 (0) | 2026.11.15 |
|---|---|
| aria-label과 role 속성 - 웹 접근성을 위한 필수 개념 (0) | 2025.04.05 |
| JavaScript Async Await Promise 사용법의 이해 (0) | 2023.01.24 |
| JavaScript 비동기 Promise에 대한 설명 (0) | 2023.01.24 |
| 클로저 자바스크립트(Closure javascript) 그리고 캡슐화 - 내부함수와 외부함수 이해 (0) | 2019.05.11 |