안녕하세요, 천상나타입니다.

    운영하는 도구 사이트에 브라우저 내장 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() → 세션 준비 완료 메시지. 이론상 문제없어 보이죠.

    워커 안의 create()가 영구 pending되는 구조 — 제스처가 전달되지 않는다

    원인은 사용자 제스처(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로 잡을 수가 없습니다. 그냥 그 줄에서 코드가 멈춰요.

    그래서 모든 대기 지점에 데드라인을 달았습니다.

    모든 비동기 대기에 건 데드라인 레이어 — probe 8초, init 15초, gen 30초

    패턴은 전부 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로 한국어로 옮겨요.

    ko·zh는 영어 생성 → Translator 번역 파이프라인, ja·es는 직접 생성

    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: 기다리는 대신 미리 만들기

    생성 자체도 문제당 몇 초가 걸려서, 하나 더 손을 댔어요. 답을 고르는 동안 다음 문제를 미리 생성하는 겁니다.

    문제 N을 보여주는 순간 N+1 생성을 백그라운드에서 시작

    문제를 화면에 띄우는 순간 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 모드로 들어오는 사람이 얼마나 되는지 궁금하네요.

    728x90
    Posted by 천상나타.