브라우저에서 AI 모델 돌리기: Transformers.js, ONNX Runtime Web, WebLLM과 성능 최적화
이 글의 핵심
WebAssembly와 WebGPU로 서버 없이 브라우저에서 AI 모델을 실행하는 방법을 Transformers.js, ONNX Runtime Web, WebLLM으로 나눠 설명하고, 이미지 분류 예제와 성능·비용 비교를 정리합니다.
이 글의 핵심
WebAssembly와 WebGPU로 브라우저에서 AI 모델을 실행하는 방법을 다룹니다. Transformers.js, ONNX Runtime Web, WebLLM 세 가지 도구로 서버 없이 추론하는 코드를 보고, 각 도구가 어떤 상황에 맞는지, 실제로 배포할 때 어디서 막히는지를 함께 정리합니다.
브라우저 추론을 고려하게 되는 이유
추론 비용이 요청 수에 비례해요
서버나 외부 API로 추론하면 사용자가 늘수록 비용도 그대로 늘어납니다. 브라우저에서 실행하면 연산은 사용자 기기가 담당하므로 서버 쪽에는 모델 파일을 내려보내는 정적 호스팅 비용만 남습니다. 대신 그 비용이 사용자의 배터리, 메모리, 첫 다운로드 대기 시간으로 옮겨 간다는 점은 분명히 알고 시작해야 합니다.
네트워크 왕복이 체감돼요
타이핑할 때마다 감정을 분석하거나 카메라 프레임마다 분류하는 기능처럼 호출 빈도가 높으면 네트워크 왕복이 누적됩니다. 작은 모델을 로컬에서 돌리면 왕복이 사라지고 오프라인에서도 동작합니다. 다만 모델이 커질수록 기기 성능 차이가 그대로 응답 속도 차이가 되므로, 저사양 기기에서는 서버보다 느려질 수 있습니다.
개인정보 보호가 필요해요
민감한 텍스트나 이미지를 서버로 보낼 수 없는 경우 브라우저에서 처리하면 입력 데이터가 외부로 나가지 않습니다. 개인정보 처리 동의나 보관 정책 부담이 줄어드는 것이 실무에서는 비용보다 더 큰 이유가 되기도 합니다.
flowchart LR
subgraph Before[서버 AI]
A1[브라우저] --> A2[서버 API]
A2 --> A3[AI 모델]
A3 --> A2
A2 --> A1
A4[비용: 요청마다 과금]
A5[지연: 네트워크 왕복 포함]
end
subgraph After[브라우저 AI]
B1[브라우저]
B2[WASM/WebGPU 런타임]
B1 --> B2
B2 --> B1
B3[비용: 모델 파일 호스팅]
B4[지연: 기기 성능에 좌우]
end
브라우저에서 모델을 돌리는 WebAssembly AI
WebAssembly (WASM) 는 브라우저에서 네이티브에 가까운 속도로 실행되는 바이너리 포맷입니다. 여기서 오해하기 쉬운 점이 있는데, AI 모델 자체가 WASM으로 컴파일되는 것은 아닙니다. WASM으로 컴파일되는 것은 추론 런타임(행렬 곱셈, 합성곱 같은 연산을 수행하는 엔진)이고, 모델은 가중치와 연산 그래프를 담은 데이터 파일(ONNX 등)로 따로 내려받습니다. 런타임은 이 파일을 읽어 CPU에서는 WASM(SIMD, 멀티스레드)으로, GPU가 있으면 WebGPU로 연산을 실행합니다.
주요 기술 스택:
- Transformers.js: Hugging Face 모델을 브라우저에서 실행 (내부적으로 ONNX Runtime Web 사용)
- ONNX Runtime Web: ONNX 모델을 WASM/WebGPU로 실행하는 Microsoft 런타임
- WebLLM: Llama, Mistral, Qwen 같은 LLM을 WebGPU로 실행 (MLC 컴파일 모델 사용)
- WebGPU: 브라우저의 범용 GPU 연산 API. 대형 모델은 사실상 필수
flowchart TB
subgraph Stack[브라우저 AI 스택]
A["AI 모델\nPyTorch/TensorFlow"]
B[ONNX 또는 MLC 형식 변환 + 양자화]
C[모델 파일 다운로드]
D["런타임 실행\nWASM(CPU) / WebGPU"]
end
A --> B --> C --> D
subgraph Frameworks[프레임워크]
F1[Transformers.js]
F2[ONNX Runtime Web]
F3[WebLLM]
end
D --> F1
D --> F2
D --> F3
세 도구의 관계를 정리하면, Transformers.js는 ONNX Runtime Web 위에 토크나이저·전처리·후처리를 얹은 고수준 라이브러리이고, ONNX Runtime Web은 직접 변환한 모델을 돌리는 저수준 런타임이며, WebLLM은 LLM 전용으로 따로 최적화된 별도 계열입니다. 분류·임베딩·음성 인식 같은 작업은 Transformers.js로 시작하고, 사내 모델을 직접 변환했다면 ONNX Runtime Web을, 채팅형 LLM이 목적이면 WebLLM을 고르는 것이 일반적인 기준입니다.
Transformers.js로 감정 분석·이미지 분류·텍스트 생성
설치
npm install @xenova/transformers
@xenova/transformers는 Transformers.js v2 패키지 이름입니다. v3부터는 Hugging Face 공식 조직으로 옮겨 @huggingface/transformers로 배포되며, pipeline(task, model, { device: 'webgpu' })처럼 WebGPU를 옵션 하나로 켤 수 있게 되었습니다. 아래 예제는 v2 기준이지만 import 경로만 바꾸면 v3에서도 대부분 그대로 동작합니다. 새로 시작한다면 v3 패키지를 쓰는 편이 좋습니다.
예제 1: 감정 분석
// sentiment-analysis.js
import { pipeline } from '@xenova/transformers';
// 파이프라인 생성 (첫 실행 시 모델 다운로드)
const classifier = await pipeline('sentiment-analysis');
// 감정 분석
const result = await classifier('이 제품 정말 좋아요!');
console.log(result);
// [{ label: 'POSITIVE', score: 0.9998 }]
pipeline('sentiment-analysis')처럼 모델을 지정하지 않으면 작업별 기본 모델이 쓰이는데, 감정 분석의 기본값은 영어 영화 리뷰(SST-2)로 학습한 DistilBERT입니다. 한국어 문장을 넣어도 에러 없이 점수가 나오지만 그 점수는 신뢰할 수 없습니다. 제가 브라우저 추론을 처음 붙일 때 가장 먼저 겪은 함정이 이것이었는데, 영어 모델에 한국어를 넣으면 토크나이저가 대부분의 글자를 알 수 없는 토큰으로 처리하고도 그럴듯한 확률을 돌려주기 때문에 데모에서는 잘 되는 것처럼 보입니다. 한국어 서비스라면 Hugging Face Hub에서 다국어 모델 중 ONNX 가중치가 함께 올라온 모델(보통 onnx/ 폴더가 있는 저장소)을 골라 두 번째 인자로 명시해야 합니다.
첫 호출에서는 모델 파일을 Hugging Face Hub에서 내려받으므로, 기본 DistilBERT 기준으로도 수십 MB의 다운로드가 발생합니다. 두 번째 방문부터는 브라우저 캐시에서 읽어 오므로 훨씬 빨라집니다.
예제 2: 이미지 분류
// image-classification.js
import { pipeline } from '@xenova/transformers';
const classifier = await pipeline('image-classification');
// 이미지 URL 또는 File 객체
const result = await classifier('https://example.com/cat.jpg');
console.log(result);
// [
// { label: 'cat', score: 0.95 },
// { label: 'kitten', score: 0.03 },
// ]
예제 3: 텍스트 생성
// text-generation.js
import { pipeline } from '@xenova/transformers';
const generator = await pipeline('text-generation', 'Xenova/gpt2');
const result = await generator('인공지능의 미래는', {
max_new_tokens: 50,
temperature: 0.7,
});
console.log(result[0].generated_text);
GPT-2는 영어 위주로 학습된 2019년 모델이라 한국어 프롬프트에는 의미 있는 문장을 만들지 못합니다. 이 예제는 API 형태를 보여 주는 용도로 보고, 실제 텍스트 생성은 다국어를 지원하는 소형 LLM(ONNX로 변환된 Qwen·Phi 계열 등)이나 뒤에서 다룰 WebLLM을 쓰는 것이 현실적입니다. 또 temperature는 do_sample: true를 함께 줘야 적용되는 점도 알아 두면 좋습니다. 샘플링을 켜지 않으면 탐욕적(greedy) 디코딩으로 매번 같은 결과가 나옵니다.
React 통합
아래 컴포넌트는 마운트될 때 모델을 불러오고, 버튼을 누르면 분류를 실행합니다. 단순한 구조지만 실제로 붙여 보면 몇 가지 문제가 드러납니다.
// components/SentimentAnalyzer.tsx
'use client';
import { useState, useEffect } from 'react';
import { pipeline } from '@xenova/transformers';
export default function SentimentAnalyzer() {
const [classifier, setClassifier] = useState<any>(null);
const [text, setText] = useState('');
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 모델 로드
pipeline('sentiment-analysis').then(setClassifier);
}, []);
const analyze = async () => {
if (!classifier || !text) return;
setLoading(true);
const output = await classifier(text);
setResult(output[0]);
setLoading(false);
};
return (
<div className="p-4">
<h2 className="text-2xl font-bold mb-4">감정 분석</h2>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="텍스트를 입력하세요"
className="w-full p-2 border rounded mb-4"
rows={4}
/>
<button
onClick={analyze}
disabled={!classifier || loading}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
{loading ? '분석 중...' : '분석'}
</button>
{result && (
<div className="mt-4 p-4 bg-gray-100 rounded">
<p className="font-bold">{result.label}</p>
<p>신뢰도: {(result.score * 100).toFixed(2)}%</p>
</div>
)}
</div>
);
}
첫째, React 개발 모드의 StrictMode는 useEffect를 두 번 실행하므로 모델 로딩도 두 번 시작됩니다. 캐시 덕분에 다운로드가 두 번 일어나지는 않지만 모델 초기화 비용과 메모리는 두 배로 듭니다. 파이프라인을 컴포넌트 밖의 모듈 수준 싱글턴(let instance = null; async function getClassifier() {...})으로 관리하면 해결됩니다. 둘째, Next.js에서 'use client'를 붙여도 컴포넌트는 서버에서 한 번 렌더링되고 모듈도 서버 번들에 포함됩니다. Transformers.js v2는 Node 전용 모듈(sharp, onnxruntime-node)을 참조하기 때문에 빌드 에러가 나는 경우가 있어, next.config.js에서 해당 모듈을 제외하거나 dynamic(() => import(...), { ssr: false })로 불러와야 합니다. 셋째, 추론이 메인 스레드에서 돌아서 긴 텍스트나 큰 이미지에서는 버튼과 입력창이 잠깐 멈춥니다. 이 문제는 성능 최적화 절의 웹 워커로 해결합니다.
ONNX Runtime Web으로 PyTorch 모델 실행
ONNX란?
ONNX (Open Neural Network Exchange) 는 AI 모델의 표준 포맷입니다. PyTorch, TensorFlow 모델을 ONNX로 변환하면 다양한 플랫폼에서 실행할 수 있습니다.
설치
npm install onnxruntime-web
PyTorch 모델을 ONNX로 변환
# convert_to_onnx.py
import torch
import torch.nn as nn
# 간단한 모델
class SimpleModel(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Linear(10, 2)
def forward(self, x):
return self.fc(x)
model = SimpleModel()
model.eval()
# 더미 입력
dummy_input = torch.randn(1, 10)
# ONNX로 변환
torch.onnx.export(
model,
dummy_input,
"model.onnx",
input_names=['input'],
output_names=['output'],
dynamic_axes={
'input': {0: 'batch_size'},
'output': {0: 'batch_size'}
}
)
torch.onnx.export는 더미 입력을 모델에 한 번 흘려보내며 실행된 연산을 기록해 그래프를 만듭니다. 그래서 입력 모양이 고정으로 기록되는데, dynamic_axes로 0번 축을 batch_size로 지정해야 브라우저에서 배치 크기가 다른 입력을 넣을 수 있습니다. 이 지정을 빠뜨리면 [2, 10] 입력을 넣는 순간 Got invalid dimensions for input: input ... Expected: 1 같은 에러가 납니다. input_names와 output_names도 중요한데, 브라우저 쪽 코드는 이 이름을 키로 텐서를 주고받기 때문입니다. 또 기록 방식 특성상 forward 안의 파이썬 if문처럼 입력 값에 따라 달라지는 분기는 더미 입력이 지난 경로 하나만 그래프에 남으므로, 조건 분기가 있는 모델은 변환 후 결과를 원본과 반드시 비교해야 합니다. 변환된 모델은 onnxruntime(Python)으로 먼저 돌려 출력이 PyTorch와 일치하는지 확인하고, 필요하면 onnxruntime.quantization으로 INT8 양자화해 크기를 줄인 뒤 브라우저에 올립니다.
브라우저에서 실행
// inference.js
import * as ort from 'onnxruntime-web';
async function runInference() {
// 모델 로드
const session = await ort.InferenceSession.create('./model.onnx');
// 입력 데이터 준비
const input = new Float32Array(10).fill(1.0);
const tensor = new ort.Tensor('float32', input, [1, 10]);
// 추론 실행
const feeds = { input: tensor };
const results = await session.run(feeds);
// 결과 출력
const output = results.output.data;
console.log('Output:', output);
}
runInference();
feeds 객체의 키 input은 변환할 때 지정한 input_names와 같아야 하고, 결과도 results.output처럼 output_names로 꺼냅니다. data는 평평한 Float32Array라서 [1, 2] 모양의 출력이라도 길이 2짜리 배열로 나오며, 다차원 해석은 results.output.dims를 보고 직접 해야 합니다. 브라우저에서 가장 흔히 만나는 에러는 모델이 아니라 런타임 파일 로딩 문제입니다. ONNX Runtime Web은 ort-wasm-simd-threaded.wasm 같은 WASM 파일을 별도로 내려받는데, 번들러가 이 파일을 출력 폴더에 복사하지 않으면 no available backend found 또는 WASM 파일 404 에러가 납니다. ort.env.wasm.wasmPaths로 CDN 경로를 지정하거나 빌드 설정에서 파일을 복사해 줘야 합니다.
WebGPU 가속
// gpu-inference.js
import * as ort from 'onnxruntime-web';
// WASM 백엔드 옵션 (WebGPU를 쓸 수 없을 때의 대체 경로)
ort.env.wasm.numThreads = 4;
ort.env.wasm.simd = true;
const session = await ort.InferenceSession.create('./model.onnx', {
executionProviders: ['webgpu', 'wasm'],
});
// 추론 실행 (GPU 가속)
const results = await session.run(feeds);
executionProviders는 우선순위 목록이라, WebGPU를 쓸 수 없으면 WASM으로 내려갑니다. 버전에 따라 WebGPU 실행기가 기본 번들에 없어서 import * as ort from 'onnxruntime-web/webgpu' 경로로 불러와야 하는 경우가 있으니 사용하는 버전의 문서를 확인해야 합니다. numThreads로 멀티스레드 WASM을 쓰려면 SharedArrayBuffer가 필요하고, 이는 페이지가 cross-origin isolated 상태일 때만 허용됩니다. 즉 서버가 Cross-Origin-Opener-Policy: same-origin과 Cross-Origin-Embedder-Policy: require-corp 헤더를 보내야 하며, 이 헤더가 없으면 스레드 설정은 조용히 무시되고 단일 스레드로 돌아갑니다. 이 헤더는 외부 이미지·광고·iframe 로딩을 막을 수 있어서 기존 사이트에 적용할 때 부작용을 먼저 확인해야 합니다. 또 작은 모델에서는 GPU로 데이터를 올리고 내리는 비용이 연산 이득보다 커서 WebGPU가 WASM보다 오히려 느린 경우도 흔합니다. 두 백엔드를 실제 대상 기기에서 측정해 보고 결정하는 것이 맞습니다.
WebLLM으로 브라우저에서 LLM 실행
설치
npm install @mlc-ai/web-llm
예제: 브라우저에서 LLaMA 실행
// llm-chat.js
import * as webllm from "@mlc-ai/web-llm";
async function main() {
// 엔진 생성
const engine = await webllm.CreateMLCEngine(
"Llama-3-8B-Instruct-q4f32_1-MLC",
{
initProgressCallback: (progress) => {
console.log(`로딩: ${progress.text}`);
}
}
);
// 채팅
const reply = await engine.chat.completions.create({
messages: [
{ role: "user", content: "안녕하세요! 자기소개 해주세요." }
],
});
console.log(reply.choices[0].message.content);
}
main();
WebLLM은 OpenAI Chat Completions와 같은 형태의 API(engine.chat.completions.create)를 제공해서, 서버 API를 쓰던 코드를 옮기기 쉽습니다. stream: true를 주면 토큰 단위로 응답을 받을 수 있어 체감 속도가 크게 좋아집니다. 모델 ID는 WebLLM이 미리 컴파일해 둔 목록(webllm.prebuiltAppConfig.model_list)에 있는 이름이어야 하며, 라이브러리 버전이 올라가면서 목록이 바뀌기 때문에 예제의 Llama-3-8B-...가 최신 버전에서는 Llama-3.1-8B-Instruct-q4f32_1-MLC 같은 이름으로 대체되어 있을 수 있습니다. 목록에 없는 ID를 주면 초기화 단계에서 바로 에러가 납니다.
현실적인 제약은 크기입니다. 8B 모델을 4비트로 양자화해도 가중치만 45GB이고, 실행 중에는 그 이상의 GPU 메모리가 필요합니다. 첫 방문자는 이 파일을 모두 내려받아야 하고, 통합 그래픽이나 메모리가 적은 노트북에서는 초기화 중에 탭이 멈추거나 3B급 소형 모델로 시작하고, Device was lost 계열 WebGPU 에러로 실패합니다. 제품에 넣을 때는 1navigator.gpu.requestAdapter() 결과와 adapter.limits를 확인해 사양이 부족한 기기는 서버 API로 돌리는 구조가 안전합니다. WebLLM은 WebGPU가 없으면 동작하지 않으므로, 대체 경로 없이 배포하면 WebGPU를 지원하지 않는 브라우저 사용자는 기능을 아예 쓸 수 없습니다.
React 채팅 인터페이스
// components/BrowserLLMChat.tsx
'use client';
import { useState, useEffect } from 'react';
import * as webllm from "@mlc-ai/web-llm";
export default function BrowserLLMChat() {
const [engine, setEngine] = useState<any>(null);
const [messages, setMessages] = useState<any[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState('');
useEffect(() => {
// 모델 로드
webllm.CreateMLCEngine(
"Llama-3-8B-Instruct-q4f32_1-MLC",
{
initProgressCallback: (prog) => {
setProgress(prog.text);
}
}
).then((eng) => {
setEngine(eng);
setLoading(false);
setProgress('');
});
}, []);
const sendMessage = async () => {
if (!engine || !input.trim()) return;
const userMessage = { role: 'user', content: input };
setMessages([...messages, userMessage]);
setInput('');
const reply = await engine.chat.completions.create({
messages: [...messages, userMessage],
});
const assistantMessage = {
role: 'assistant',
content: reply.choices[0].message.content
};
setMessages([...messages, userMessage, assistantMessage]);
};
if (loading) {
return (
<div className="p-4">
<p>모델 로딩 중...</p>
<p className="text-sm text-gray-600">{progress}</p>
</div>
);
}
return (
<div className="flex flex-col h-screen p-4">
<h1 className="text-2xl font-bold mb-4">브라우저 LLM 채팅</h1>
<div className="flex-1 overflow-y-auto mb-4 space-y-4">
{messages.map((msg, i) => (
<div
key={i}
className={`p-3 rounded ${
msg.role === 'user'
? 'bg-blue-100 ml-auto max-w-[80%]'
: 'bg-gray-100 mr-auto max-w-[80%]'
}`}
>
<p className="font-bold text-sm mb-1">
{msg.role === 'user' ? '사용자' : 'AI'}
</p>
<p>{msg.content}</p>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="메시지를 입력하세요"
className="flex-1 p-2 border rounded"
/>
<button
onClick={sendMessage}
className="bg-blue-500 text-white px-6 py-2 rounded"
>
전송
</button>
</div>
</div>
);
}
이 컴포넌트는 구조를 보여 주기 위한 최소 예제라 실제 서비스에 쓰려면 손볼 곳이 있습니다. sendMessage 안에서 messages를 클로저로 참조하기 때문에 응답을 기다리는 동안 사용자가 한 번 더 전송하면 앞의 메시지가 사라집니다. setMessages(prev => [...prev, userMessage])처럼 함수형 업데이트를 써야 합니다. 응답 생성 중에도 전송 버튼이 활성화되어 있어서 요청이 겹치는데, WebLLM 엔진은 한 번에 하나의 생성만 처리하므로 생성 중에는 입력을 막거나 engine.interruptGenerate()로 이전 생성을 중단해야 합니다. 또 대화가 길어지면 전체 기록을 매번 다시 넣기 때문에 모델의 컨텍스트 길이를 넘는 순간 에러가 나므로, 오래된 메시지를 잘라 내는 로직이 필요합니다. onKeyPress는 React에서 deprecated이므로 onKeyDown을 쓰고, 한글 입력 중 Enter가 조합 완료와 겹쳐 메시지가 두 번 전송되는 문제를 막으려면 e.nativeEvent.isComposing을 확인해야 합니다.
예제: 이미지 분류 웹앱
업로드한 이미지를 ViT(Vision Transformer) 모델로 분류하는 페이지입니다. 이미지가 서버로 전송되지 않는다는 것이 핵심이며, FileReader로 만든 data URL을 그대로 파이프라인에 넘깁니다.
전체 구조
// app/image-classifier/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { pipeline } from '@xenova/transformers';
export default function ImageClassifier() {
const [classifier, setClassifier] = useState<any>(null);
const [image, setImage] = useState<string | null>(null);
const [results, setResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 모델 로드
pipeline('image-classification', 'Xenova/vit-base-patch16-224')
.then(setClassifier);
}, []);
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
setImage(e.target?.result as string);
};
reader.readAsDataURL(file);
};
const classify = async () => {
if (!classifier || !image) return;
setLoading(true);
const output = await classifier(image);
setResults(output);
setLoading(false);
};
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">이미지 분류기</h1>
<p className="text-gray-600 mb-6">
브라우저에서 AI가 직접 이미지를 분석합니다. 서버 전송 없음!
</p>
<div className="mb-6">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="mb-4"
/>
{image && (
<img
src={image}
alt="Upload"
className="max-w-full h-auto rounded shadow-lg"
/>
)}
</div>
<button
onClick={classify}
disabled={!classifier || !image || loading}
className="w-full bg-blue-500 text-white py-3 rounded font-bold disabled:bg-gray-300"
>
{!classifier ? '모델 로딩 중...' : loading ? '분석 중...' : '이미지 분류'}
</button>
{results.length > 0 && (
<div className="mt-6 space-y-2">
<h2 className="text-xl font-bold">결과</h2>
{results.map((result, i) => (
<div key={i} className="flex justify-between p-3 bg-gray-100 rounded">
<span>{result.label}</span>
<span className="font-bold">{(result.score * 100).toFixed(2)}%</span>
</div>
))}
</div>
)}
</div>
);
}
Xenova/vit-base-patch16-224는 ImageNet 1,000개 클래스로 학습된 모델이라 결과 라벨이 tabby, tabby cat, Egyptian cat 같은 영어 ImageNet 클래스명으로 나옵니다. 서비스에 맞는 분류가 필요하면 자체 데이터로 파인튜닝한 모델을 ONNX로 변환해 써야 합니다. 파이프라인은 내부에서 이미지를 224×224로 리사이즈하고 정규화하므로 원본 크기를 신경 쓸 필요는 없지만, 휴대폰 사진처럼 큰 이미지를 data URL로 만들면 문자열 자체가 수 MB가 되어 메모리를 많이 씁니다. URL.createObjectURL(file)을 쓰면 복사 없이 참조만 넘길 수 있어 더 가볍습니다(사용 후 URL.revokeObjectURL로 해제).
모델 캐싱·WebGPU·웹 워커로 성능 올리기
모델 캐싱
// 브라우저에서는 Cache API에 자동 캐싱됨 (env.useBrowserCache, 기본값 true)
import { env } from '@xenova/transformers';
// cacheDir는 Node.js 파일 시스템 캐시 경로 (브라우저에서는 사용되지 않음)
env.cacheDir = './.cache';
// 모델 로드 (캐시 사용)
const classifier = await pipeline('sentiment-analysis');
브라우저에서 Transformers.js는 내려받은 모델 파일을 Cache API(transformers-cache라는 이름의 캐시)에 저장하므로 별도 설정 없이도 두 번째 방문부터는 다운로드가 생략됩니다. 주의할 점은 이 저장소가 브라우저의 저장 공간 할당량에 묶여 있어 공간이 부족하면 브라우저가 예고 없이 비울 수 있다는 것입니다. 수백 MB 이상의 모델을 쓴다면 navigator.storage.persist()로 영구 저장을 요청하고, navigator.storage.estimate()로 남은 공간을 확인해 부족할 때 사용자에게 알리는 것이 좋습니다. 모델을 Hugging Face Hub 대신 자체 CDN에서 내려주려면 env.allowRemoteModels = false와 env.localModelPath를 설정합니다. 외부 서비스 장애나 차단 정책의 영향을 받지 않으니 프로덕션에서는 이 방식을 권합니다.
WebGPU 가속
// WebGPU 지원 확인
if ('gpu' in navigator) {
console.log('WebGPU 지원됨');
// ONNX Runtime Web에서 WebGPU 사용
const session = await ort.InferenceSession.create('./model.onnx', {
executionProviders: ['webgpu'],
});
}
'gpu' in navigator는 API가 존재하는지만 알려 줄 뿐, 실제로 GPU를 쓸 수 있는지는 보장하지 않습니다. 블랙리스트에 오른 드라이버나 원격 데스크톱 환경에서는 navigator.gpu가 있어도 requestAdapter()가 null을 돌려줍니다. 정확하게 판단하려면 const adapter = await navigator.gpu?.requestAdapter()까지 확인하고, null이면 WASM 경로로 가야 합니다.
워커 스레드 활용
추론은 CPU 집약적인 동기 연산이 많아서 메인 스레드에서 돌리면 그동안 렌더링과 입력 처리가 멈춥니다. 모델 로딩과 추론을 웹 워커로 옮기면 UI는 계속 반응하고, 메인 스레드와는 메시지로만 통신합니다.
// ai-worker.js
import { pipeline } from '@xenova/transformers';
let classifier = null;
self.addEventListener('message', async (e) => {
if (e.data.type === 'init') {
classifier = await pipeline('sentiment-analysis');
self.postMessage({ type: 'ready' });
}
if (e.data.type === 'classify') {
const result = await classifier(e.data.text);
self.postMessage({ type: 'result', data: result });
}
});
// main.js
const worker = new Worker('./ai-worker.js', { type: 'module' });
worker.postMessage({ type: 'init' });
worker.addEventListener('message', (e) => {
if (e.data.type === 'ready') {
console.log('모델 준비 완료');
}
if (e.data.type === 'result') {
console.log('결과:', e.data.data);
}
});
// 분류 요청
worker.postMessage({ type: 'classify', text: '좋아요!' });
이 예제에는 경쟁 조건이 하나 있습니다. init 직후 바로 classify를 보내는데, 워커의 메시지 핸들러는 await pipeline(...)이 끝나기 전에 다음 메시지를 받을 수 있어서 classifier가 아직 null인 상태로 호출되어 classifier is not a function 에러가 납니다. ready 메시지를 받은 뒤에 요청을 보내거나, 워커 안에서 로딩 Promise를 저장해 두고 매 요청마다 await하는 방식으로 고쳐야 합니다. 요청이 여러 개 동시에 오가는 경우에는 메시지에 id를 붙여 응답을 요청과 짝지어야 결과가 섞이지 않습니다. 이런 보일러플레이트가 번거롭다면 Comlink 같은 라이브러리로 워커 함수를 Promise처럼 호출하는 방법도 있습니다.
서버 추론 대비 비용과 성능
비용 비교
| 방식 | 비용 구조 | 특징 |
|---|---|---|
| 외부 AI API | 요청·토큰 수에 비례 | 모델 품질 최고, 운영 부담 없음 |
| 자체 GPU 서버 | 서버 대수에 비례 (유휴 시간에도 과금) | 모델 선택 자유, 운영 인력 필요 |
| 브라우저 추론 | 모델 파일 전송량(CDN) | 서버 추론 비용 없음, 사용자 기기 부담 |
성능 비교
성능은 모델 크기와 사용자 기기에 따라 편차가 커서 고정된 수치로 비교하기 어렵습니다. 경향으로 정리하면 다음과 같습니다.
| 작업 | 서버 API | 브라우저 |
|---|---|---|
| 감정 분석 등 소형 분류 | 네트워크 왕복이 대부분을 차지 | 캐시된 뒤에는 대체로 더 빠름 |
| 이미지 분류 | 이미지 업로드 시간 포함 | 중급 이상 기기에서 실시간 수준 가능 |
| LLM 텍스트 생성 | 대형 모델, 안정적인 속도 | 소형 모델만 현실적, 기기 편차 큼 |
실무 팁: 첫 실행 시 모델 다운로드(소형 분류 모델 수십 MB, LLM은 GB 단위)가 필요하므로, 진행률을 보여 주는 로딩 UI를 잘 만들어야 합니다.
비용 비교에서 놓치기 쉬운 부분은 “0원”이 아니라는 점입니다. 모델 파일 전송량은 방문자 수에 비례하고, 캐시가 비워지면 같은 사용자도 다시 내려받습니다. 수백 MB 모델을 쓰는 서비스라면 CDN 전송 비용이 서버 추론 비용보다 커질 수도 있습니다. 결국 판단 기준은 “호출 빈도가 높고 모델이 작은가”입니다. 작은 모델을 자주 호출하는 기능(입력 중 실시간 분류, 임베딩 검색, 음성 인식)은 브라우저가 유리하고, 큰 모델을 가끔 호출하는 기능은 서버가 유리합니다.
모델 크기·첫 실행 지연·메모리 부족 문제
문제 1: 모델이 너무 커요
// ❌ 잘못된 코드 - 큰 모델
const generator = await pipeline('text-generation', 'gpt2-large'); // 774M 파라미터
// ✅ 올바른 코드 - 작은 모델
const generator = await pipeline('text-generation', 'Xenova/distilgpt2'); // 82M 파라미터
모델 크기는 파라미터 수 × 파라미터당 바이트로 대략 계산할 수 있습니다. fp32는 4바이트, fp16은 2바이트, INT8 양자화는 1바이트입니다. 774M 파라미터 모델은 fp32로 3GB가 넘어서 브라우저 메모리 한도를 쉽게 넘깁니다. Transformers.js v2는 기본적으로 양자화된(quantized: true) 가중치를 내려받아 크기를 약 1/4로 줄이지만, 그래도 수백 MB가 남습니다. 또 gpt2-large처럼 Xenova/ 접두사가 없는 원본 저장소에는 ONNX 가중치가 없어서 로딩 자체가 실패합니다. 브라우저용으로는 ONNX 파일이 포함된 저장소를 골라야 합니다. 32비트 WASM의 메모리는 최대 4GB로 제한되고 실제 브라우저에서는 그보다 낮은 지점에서 RangeError: WebAssembly.Memory(): could not allocate memory나 Out of memory 에러가 먼저 발생하는 경우가 많습니다.
문제 2: 첫 실행이 너무 느려요
// ✅ 프리로딩
// 앱 시작 시 백그라운드에서 모델 로드
useEffect(() => {
pipeline('sentiment-analysis').then(setClassifier);
}, []);
// 사용자가 기능을 사용할 때는 이미 로드됨
프리로딩은 효과가 크지만 모든 방문자에게 수십 MB를 내려보낸다는 뜻이기도 합니다. 기능을 쓰지 않을 사용자까지 데이터를 쓰게 되고, 모바일 데이터 환경에서는 불만이 생길 수 있습니다. 페이지 로드 직후 바로 시작하기보다 사용자가 해당 기능 영역에 들어오거나 입력창에 포커스를 줄 때 로딩을 시작하는 편이 균형이 좋습니다. navigator.connection.saveData가 켜져 있으면 프리로딩을 건너뛰는 것도 방법입니다.
문제 3: 메모리 부족
// ❌ 잘못된 코드 - 메모리 누수
for (let i = 0; i < 1000; i++) {
const classifier = await pipeline('sentiment-analysis'); // 매번 새로 로드!
await classifier(texts[i]);
}
// ✅ 올바른 코드 - 재사용
const classifier = await pipeline('sentiment-analysis');
for (let i = 0; i < 1000; i++) {
await classifier(texts[i]);
}
잘못된 코드에서 메모리가 계속 늘어나는 이유는 파이프라인마다 ONNX 세션이 WASM 힙과 GPU 버퍼를 따로 잡는데, JavaScript 가비지 컬렉터는 이 네이티브 메모리를 즉시 회수하지 않기 때문입니다. 파이프라인은 한 번 만들어 재사용하고, 더 이상 쓰지 않는 모델은 await classifier.dispose()(ONNX Runtime은 session.release())로 명시적으로 해제해야 합니다. 여러 모델을 번갈아 쓰는 앱에서 이 해제를 빠뜨리면 몇 번 전환한 뒤 탭이 크래시되는 일이 흔합니다.
브라우저 추론 도입 요점
브라우저 추론은 “서버 비용을 없애는 기술”이라기보다 “추론 위치를 사용자 기기로 옮기는 선택”입니다. 작은 모델을 자주 호출하고 입력 데이터가 민감한 기능에서는 비용·지연·개인정보 면에서 모두 유리하지만, 첫 다운로드 크기, 기기 사양 편차, WebGPU 지원 여부라는 새로운 제약을 떠안게 됩니다. 실무에서는 Transformers.js로 작은 모델부터 붙여 보고, 웹 워커와 캐시 설정으로 사용성을 다듬은 뒤, 사양이 부족한 기기를 위한 서버 대체 경로를 함께 두는 구성이 가장 안정적입니다.
같이 보면 좋은 글
- WebAssembly 가이드
- ChatGPT API 실전 가이드 | OpenAI API로 AI 애플리케이션 만들기
- Edge Computing과 서버리스
- Ollama로 로컬 LLM 실행하기
자주 묻는 질문 (FAQ)
Q. 브라우저에서 AI를 실행하면 느리지 않나요?
A. WASM은 네이티브 코드보다 느리지만 격차는 작업과 최적화(SIMD, 멀티스레드) 여부에 따라 다릅니다. 소형 모델은 네트워크 왕복이 없어서 서버 API보다 빠르게 느껴지는 경우가 많고, 대형 모델은 WebGPU가 있어도 서버 GPU보다 느립니다.
Q. 모든 AI 모델을 브라우저에서 실행할 수 있나요?
A. 수백 MB 이하의 분류·임베딩·음성 인식 모델은 무리 없이 돌아갑니다. LLM은 WebGPU와 충분한 GPU 메모리가 있는 기기에서 양자화된 1~8B급 모델까지가 현실적인 범위이고, GPT-4급 대형 모델은 불가능합니다.
Q. 모바일에서도 작동하나요?
A. WASM은 모든 최신 모바일 브라우저에서 지원되지만 메모리와 발열 제약이 커서 소형 모델만 현실적입니다. WebGPU 지원은 플랫폼과 브라우저 버전에 따라 다르므로 기능 감지 후 대체 경로를 두어야 합니다.
Q. Transformers.js, ONNX Runtime Web, WebLLM 중 무엇을 기본 기능에 써도 되나요?
A. 소형 모델을 돌리는 용도라면 Transformers.js나 ONNX Runtime Web이 무난합니다. WebLLM은 기기 요구 사양이 높아 전체 사용자 대상 기본 기능보다는 선택 기능이나 서버 대체 경로와 함께 쓰는 것을 권합니다.