본문 바로가기
튜토리얼/AI

[Transformers.js] 브라우저에서 AI 추론 — 이미지 설명 생성기 (API 키 없음, ~200MB)

by 루까(Luka) 2026. 3. 5.
반응형

왜 만들었나

AI 추론은 항상 서버가 필요하다는 전제로 작업해왔다. OpenAI API 호출, 서버 배포, API 키 관리. 간단한 데모 하나를 만들어도 백엔드가 필수였다.

그러다 Hugging Face의 Transformers.js를 발견했다. 브라우저에서 직접 AI 모델을 실행한다는 개념이었다. 서버도, API 키도 없이. 첫 방문 때 모델을 다운로드(~200MB)하고 나면 이후부터는 브라우저 캐시에서 즉시 로드된다. 오프라인 동작도 된다.

확인하고 싶었던 건 하나였다. 실제로 쓸 만한 수준인가.


기술 상세

Transformers.js란

Hugging Face가 만든 JavaScript 라이브러리다. Python Transformers 라이브러리의 브라우저 포팅 버전으로, Hugging Face Hub의 모델을 ONNX(Open Neural Network Exchange) 포맷으로 변환해 브라우저에서 실행한다.

핵심은 ONNX Runtime Web이다. 모델 연산을 WebAssembly와 WebGL로 처리하기 때문에 별도 런타임 없이 브라우저만으로 추론이 가능하다.

ViT-GPT2 모델 구조

이번에 사용한 모델은 Xenova/vit-gpt2-image-captioning이다.

단계 모델 역할
인코더 ViT (Vision Transformer) 이미지 → 패치 임베딩 → 컨텍스트 벡터
디코더 GPT-2 컨텍스트 벡터 → 영어 토큰 → 문장 생성

이미지를 16×16 픽셀 패치로 잘라 각 패치를 토큰으로 처리한 뒤, GPT-2가 그 정보를 바탕으로 설명 문장을 자동 회귀(auto-regressive) 방식으로 생성한다.

Web Worker가 필요한 이유

pipeline() 호출과 모델 추론은 CPU 집약 작업이다. 메인 스레드에서 실행하면 추론 도중 UI가 완전히 멈춘다. 진행률 바도 업데이트가 안 된다.

Web Worker로 격리하면 메인 스레드는 계속 반응하고, Worker가 백그라운드에서 추론을 처리한다.

postMessage 통신 구조

메인 스레드와 Worker 사이의 메시지 흐름은 다음과 같다.

메인 → Worker  : { type: 'load' }
Worker → 메인  : { type: 'progress', status, file?, progress? }  ← N회 반복
Worker → 메인  : { type: 'ready' }

메인 → Worker  : { type: 'caption', dataUrl: string }
Worker → 메인  : { type: 'result', text: string }

모델이 준비되기 전까지 드롭존은 비활성화(pointer-events: none)된다. ready 메시지를 받은 시점에 드롭존이 활성화된다.

브라우저 캐시 동작

Transformers.js는 모델 파일을 Cache APIIndexedDB에 저장한다. 첫 방문 때 CDN에서 ~200MB를 다운로드하지만, 이후 방문부터는 캐시에서 즉시 로드된다. 네트워크 연결 없이도 동작한다.


실제 소스 코드

worker.js — 모델 로드 및 추론

import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';

// 로컬 모델 비활성화 (CDN에서만 로드)
env.allowLocalModels = false;

let captioner = null;

self.onmessage = async ({ data }) => {

  // ── 모델 로드 ───────────────────────────────────────
  if (data.type === 'load') {
    try {
      captioner = await pipeline(
        'image-to-text',
        'Xenova/vit-gpt2-image-captioning',
        {
          progress_callback: (p) => {
            self.postMessage({ type: 'progress', ...p });
          },
        }
      );
      self.postMessage({ type: 'ready' });
    } catch (err) {
      self.postMessage({ type: 'error', message: `모델 로드 실패: ${err.message}` });
    }
  }

  // ── 이미지 캡셔닝 ────────────────────────────────────
  if (data.type === 'caption') {
    if (!captioner) {
      self.postMessage({ type: 'error', message: '모델이 아직 준비되지 않았습니다.' });
      return;
    }
    try {
      const output = await captioner(data.dataUrl);
      const text   = output?.[0]?.generated_text ?? '(결과 없음)';
      self.postMessage({ type: 'result', text });
    } catch (err) {
      self.postMessage({ type: 'error', message: `추론 실패: ${err.message}` });
    }
  }

};

pipeline() 한 줄로 모델 로드와 추론 파이프라인이 완성된다. 태스크명('image-to-text')과 모델 ID만 넘기면 된다.

app.js — Worker 초기화 및 메시지 핸들러

// ── Web Worker 초기화 ─────────────────────────────────
const worker = new Worker('./worker.js', { type: 'module' });
worker.postMessage({ type: 'load' });

// ── Worker 메시지 핸들러 ──────────────────────────────
worker.onmessage = ({ data }) => {
  switch (data.type) {

    case 'progress':
      onProgress(data);
      break;

    case 'ready':
      progressWrap.hidden = true;
      setStatus('ready', '● READY');
      dropZone.classList.remove('disabled');
      break;

    case 'result':
      currentCaption = data.text;
      showCaption(data.text);
      setStatus('ready', '● READY');
      break;

    case 'error':
      showError(data.message);
      setStatus('ready', '● READY');
      break;
  }
};

// ── 진행률 처리 ───────────────────────────────────────
function onProgress(data) {
  // status: 'downloading' | 'progress' | 'loading' | 'done' 등
  if (data.status !== 'downloading' && data.status !== 'progress') return;

  const pct  = Math.round(data.progress ?? 0);
  const file = data.file ? data.file.split('/').pop() : '';

  progressFill.style.width = `${pct}%`;
  progressText.textContent = `${pct}% — ${file || '모델'} 로딩 중...`;
}

app.js — processFile() 함수

function processFile(file) {
  if (!file || !file.type.startsWith('image/')) {
    showError('이미지 파일만 지원합니다 (JPG, PNG, WebP, GIF).');
    return;
  }

  const reader = new FileReader();
  reader.onload = ({ target }) => {
    const dataUrl = target.result;

    // 미리보기
    previewImg.src     = dataUrl;
    resultLayout.hidden = false;

    // 추론 시작
    inferSpinner.hidden = false;
    captionBody.innerHTML = '';
    captionBody.appendChild(inferSpinner);
    copyBtn.hidden = true;
    currentCaption = '';
    setStatus('inferring', '🔄 추론 중...');

    worker.postMessage({ type: 'caption', dataUrl });
  };
  reader.readAsDataURL(file);
}

FileReader로 이미지를 Data URL로 변환한 뒤 Worker에 전달한다. Worker는 Data URL을 직접 captioner() 에 넘기면 된다. Transformers.js가 내부적으로 이미지 디코딩을 처리한다.


비교 테이블

항목 Transformers.js 서버 API (OpenAI 등) Python 로컬
API 키 불필요 필요 불필요
서버 불필요 필요 불필요
첫 로드 ~200MB 다운로드 즉시 모델 설치 필요
재방문 캐시 → 빠름 API 호출 즉시
오프라인 캐시 후 가능 불가 가능
품질 준수 (ViT-GPT2) 최고 (GPT-4V) 모델 의존
비용 무료 사용량 과금 무료

품질은 GPT-4V 수준에 미치지 못하지만, API 키나 서버 없이 브라우저에서 바로 돌아간다는 점에서 용도가 다르다.


삽질 기록

1. file:// 프로토콜에서 Worker 미동작

index.html을 파인더에서 더블클릭해서 열면 Worker가 아예 생성되지 않는다. file:// 프로토콜은 Web Worker의 모듈 스크립트 로드를 차단한다.

# 반드시 로컬 서버로 실행
python3 -m http.server 8080
# → http://localhost:8080 에서 접근

2. <script type="module"> 누락

app.js가 Worker를 { type: 'module' } 옵션으로 생성하는데, index.html에서 app.js를 일반 <script>로 로드하면 import 구문에서 오류가 난다.

<!-- 틀림 -->
<script src="app.js"></script>

<!-- 맞음 -->
<script type="module" src="app.js"></script>

3. progress_callback status 값

progress_callback에서 넘어오는 status 값이 항상 'downloading'이 아니다. 모델 파일 종류나 로드 단계에 따라 'progress'로 오는 경우도 있어서 두 경우를 모두 처리해야 진행률 바가 제대로 동작한다.

// 이것만 처리하면 진행률이 중간에 멈추는 것처럼 보임
if (data.status !== 'downloading') return;

// 두 경우 모두 처리
if (data.status !== 'downloading' && data.status !== 'progress') return;

4. 같은 파일 재선택 시 이벤트 미발화

<input type="file">은 같은 파일을 다시 선택해도 change 이벤트가 발화하지 않는다. 처리 후 value를 초기화해줘야 한다.

fileInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (file) {
    processFile(file);
    fileInput.value = ''; // 동일 파일 재선택 허용
  }
});

5. dragleave 이벤트가 자식 요소 위에서도 발화

드롭존 위에서 마우스를 움직이다 보면 dragleave가 예상치 못하게 발화해 drag-over 스타일이 깜빡인다. 드롭존의 자식 요소 경계를 넘을 때도 dragleave가 발화하기 때문이다. relatedTarget을 확인해 드롭존 내부 이동인지 실제 이탈인지 구분해야 한다.

dropZone.addEventListener('dragleave', (e) => {
  if (!dropZone.contains(e.relatedTarget)) {
    dropZone.classList.remove('drag-over');
  }
});

마무리

이번 작업에서 핵심은 세 가지였다.

첫째, Web Worker로 UI를 분리한다. 메인 스레드에서 무거운 추론을 돌리면 UI가 얼어붙는다. Worker 격리는 선택이 아닌 필수다.

둘째, pipeline() 한 줄로 추론이 완성된다. 모델 로드, 전처리, 추론, 후처리가 전부 내부에서 처리된다. 복잡한 모델 핸들링 코드가 필요 없다.

셋째, 브라우저 캐시로 재방문이 빠르다. 첫 로드만 기다리면 그다음부터는 네트워크 없이도 동작한다. 오프라인 지원이 자연스럽게 따라온다.

서버 API 품질이 필요하면 OpenAI를 쓰면 된다. 하지만 서버 없이 빠르게 AI 기능을 붙이고 싶다면 Transformers.js는 충분히 실용적인 선택지다.


기술 스택

항목 내용
런타임 브라우저 (No Node.js)
AI 라이브러리 Transformers.js v2.17.2
모델 Xenova/vit-gpt2-image-captioning
모델 포맷 ONNX
추론 엔진 ONNX Runtime Web (WebAssembly)
스레드 Web Worker (module type)
캐시 Cache API / IndexedDB (자동)

소스 코드: GitHub

데모: 로컬 서버 실행 필요. 소스를 클론 후 python3 -m http.server 로 실행하거나, 튜토리얼 목록에서 확인.

반응형