설치도 로그인도 없이 웹 페이지를 열었더니 그 안에서 언어 모델이 돌아간다. 입력한 문장은 서버로 안 가고, 요금도 안 나가고, 비행기 안에서도 동작한다. WebGPU는 브라우저가 GPU의 계산 유닛을 직접 쓸 수 있게 해 주는 표준이고, 이것이 나오면서 이 그림이 실제로 가능해졌다.
지난 글에서는 GPU 없이 CPU만으로 모델을 돌릴 때 메모리 대역폭이 초당 토큰 수의 천장을 정한다는 이야기를 했다. 브라우저 추론은 그 천장을 우리 서버가 아니라 사용자의 장치로 옮겨 놓는 선택이다 — 서빙 비용은 0에 가까워지지만, 대신 어떤 장치가 들어올지 우리가 고를 수 없다. 다만 "된다"와 "쓸 만하다" 사이에 넘어야 할 것이 몇 개 있고, 그 대부분은 모델이 아니라 첫 방문의 경험에 관한 것이다.
WebGL로는 안 되던 것이 왜 되는가
브라우저에서 GPU를 쓰는 길은 예전에도 있었다. WebGL이다. 하지만 WebGL은 그래픽을 그리는 API라서, 행렬 곱을 하려면 데이터를 텍스처인 척 포장해 픽셀 셰이더에 통과시키고 결과를 다시 읽어 오는 우회를 해야 했다. 느리고, 정밀도가 부족하고, 무엇보다 큰 버퍼를 다루지 못했다.
WebGPU는 계산 전용 경로인 컴퓨트 셰이더(compute shader)를 정식으로 제공한다. 데이터를 그래픽으로 위장할 필요 없이 버퍼에 올리고 계산 커널을 돌린다. 여기에 큰 저장 버퍼를 다룰 수 있게 되면서, 수 GB짜리 모델 가중치를 GPU에 올려 두는 것이 처음으로 현실적인 이야기가 됐다.
지금 브라우저에서 모델을 돌리는 도구는 대체로 셋이다.
| 도구 | 무엇에 맞는가 | 특징 |
|---|---|---|
| WebLLM | 대화형 LLM을 그대로 붙일 때 | OpenAI 호환 형태의 API라 기존 코드를 거의 안 고친다 |
| Transformers.js | 임베딩·분류·음성 등 작은 모델을 여럿 쓸 때 | 파이썬 transformers와 사용법이 닮았고 WebGPU와 WASM을 함께 지원 |
| ONNX Runtime Web | 자체 모델을 직접 변환해 넣을 때 | 통제가 가장 넓고, 그만큼 손이 많이 간다 |
WebLLM으로 채팅을 붙이는 최소 형태는 이 정도다.
import { CreateMLCEngine } from "@mlc-ai/web-llm";
const engine = await CreateMLCEngine("Llama-3.2-3B-Instruct-q4f16_1-MLC", {
initProgressCallback: (p) => {
// p.progress 는 0~1. 이 값을 화면에 반드시 보여 준다
setProgress(p.progress, p.text);
},
});
const reply = await engine.chat.completions.create({
messages: [{ role: "user", content: "이 문단을 세 줄로 줄여 줘" }],
stream: true,
});
initProgressCallback을 주석 처리하고 넘어가고 싶은 마음이 들지만, 다음 절이 그것을 못 하게 한다.
첫 방문의 1분이 이 기술의 승부처다
서버 API를 부르는 코드는 첫 호출이든 천 번째 호출이든 비슷하게 걸린다. 브라우저 추론은 다르다. 첫 방문과 두 번째 방문이 한 자릿수 차이로 다르다.
첫 방문에 일어나는 일은 셋이다. 모델 가중치를 내려받고(1~4GB), 그것을 브라우저 저장소에 캐시하고, GPU가 실행할 셰이더를 컴파일한다. 마지막 단계가 의외로 오래 걸린다 — 모델 구조에 맞는 계산 커널 수십 개를 그 장치의 GPU용으로 만드는 작업이라 5초에서 20초가 든다.
두 번째 방문부터는 내려받기가 사라지고, 브라우저의 셰이더 캐시가 살아 있으면 컴파일도 건너뛴다. 몇 초 만에 준비가 끝난다.
그래서 이 기술을 쓸 수 있느냐는 "첫 1분을 사용자가 어떻게 보내게 할 것인가"에 거의 다 걸려 있다. 실무에서 통하는 방법은 대체로 이렇다.
진행률을 정직하게 보여 준다. 돌아가는 스피너 하나로 40초를 버티게 할 수는 없다. "가중치 내려받는 중 620MB / 1.9GB"처럼 지금 무엇을 하고 있고 얼마나 남았는지를 그대로 보여 준다. 위 콜백이 그 데이터를 준다.
모델을 켜기 전에 값어치를 먼저 보여 준다. 페이지를 열자마자 1.9GB를 내려받기 시작하면 대부분은 그냥 나간다. 화면을 먼저 쓰게 하고, 사용자가 AI 기능을 처음 누를 때 "이 기능은 처음 한 번 약 1.9GB를 내려받습니다. 이후에는 인터넷 없이 동작하고 입력한 내용은 기기 밖으로 나가지 않습니다"라고 알린 뒤 시작한다. 내려받는 이유가 납득되면 기다리는 시간의 성격이 달라진다.
돌아올 사용자에게만 값이 있다는 것을 인정한다. 한 번 쓰고 안 돌아오는 방문자에게 이 구조는 순수한 손해다. 매일 여는 도구, 사내 대시보드, 문서 편집기처럼 재방문이 전제인 서비스에서만 첫 방문 비용이 회수된다.
진짜 벽은 GPU 메모리가 아니라 버퍼 한계다
"8GB GPU면 8GB짜리 모델이 들어가겠지"라고 생각하면 어긋난다. 브라우저는 장치 메모리 전부를 페이지에 내주지 않는다. 탭 하나가 GPU를 독차지하면 다른 탭과 OS의 화면 합성까지 멈추기 때문이다.
WebGPU에는 maxBufferSize와 maxStorageBufferBindingSize 같은 한계값이 있고, 브라우저와 장치에 따라 이 값이 다르다. 실제로 쓸 수 있는 양은 장치 VRAM보다 눈에 띄게 작다.
여기서 나오는 현실적인 규칙이 있다. 브라우저에서 편하게 돌아가는 크기는 1B에서 3B다. 4비트로 담으면 1B가 0.7GB, 3B가 1.9GB고, KV 캐시를 더해도 대부분의 장치에서 여유가 있다. 8B는 고사양 장치에서만 돌아가고, 그것도 컨텍스트를 길게 잡으면 KV 캐시가 한계를 넘긴다.
그리고 이 값들은 코드로 확인할 수 있다. 모델을 내려받기 전에 장치가 무엇을 감당하는지 물어보고, 안 되면 서버 경로로 돌리는 것이 옳은 순서다.
if (!navigator.gpu) {
return fallbackToServer("WebGPU 미지원");
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
return fallbackToServer("GPU 어댑터 없음");
}
const maxBuffer = adapter.limits.maxStorageBufferBindingSize;
if (maxBuffer < 1.5 * 1024 ** 3) {
return fallbackToServer("버퍼 한계 부족");
}
navigator.gpu가 없는 경우는 생각보다 흔하다. 오래된 브라우저, 일부 모바일 환경, GPU 드라이버가 차단 목록에 오른 장치가 그렇다. 서버 경로를 함께 두지 않으면 그 사용자들에게는 기능이 통째로 없는 것과 같다.
무엇이 좋아지고 무엇을 포기하는가
| 얻는 것 | 잃는 것 |
|---|---|
| 입력이 기기 밖으로 안 나간다 | 첫 방문에 GB 단위 내려받기가 필요하다 |
| 토큰 요금이 0이다 | 쓸 수 있는 모델이 1~3B로 제한된다 |
| 네트워크가 끊겨도 동작한다 | 장치마다 속도가 열 배씩 차이 난다 |
| 서버 확장을 신경 쓸 필요가 없다 | 모델을 바꾸면 사용자가 다시 내려받는다 |
| 지연이 왕복 시간에 안 묶인다 | WebGPU를 못 쓰는 사용자가 남는다 |
왼쪽 첫 줄이 이 기술을 고르는 가장 큰 이유다. 의료 기록 초안, 법률 문서 검토, 사내 회의록처럼 "이 텍스트는 어디로도 보내지 않았다"가 기능 요구사항인 경우, 브라우저 안에서 도는 것은 편의가 아니라 요건 충족이다. 서버에서 안 쓰고 바로 버린다는 약속과, 애초에 네트워크를 안 탄다는 사실은 다른 무게를 가진다.
오른쪽 셋째 줄은 자주 과소평가된다. 같은 3B 모델이 최신 노트북에서 초당 30토큰, 3년 전 보급형에서 초당 5토큰이 나온다. 서버 API라면 모두가 같은 속도를 보지만 여기서는 사용자마다 다른 제품을 쓰는 셈이다. 첫 응답에서 실제 속도를 재어 두고, 너무 느리면 서버 경로를 제안하는 정도의 대응이 필요하다.
어떤 서비스에 맞는가
정리하면 이 기술이 제자리를 찾는 조건이 셋이다.
- 재방문이 잦다 — 첫 방문 비용이 여러 번에 걸쳐 회수된다
- 작은 모델로 충분하다 — 분류, 요약, 문장 다듬기, 자동 완성처럼 3B가 감당하는 일이다
- 데이터가 안 나가는 것이 값을 가진다 — 그냥 좋은 정도가 아니라 요건이면 더 확실하다
반대로 맞지 않는 조건도 분명하다. 랜딩 페이지의 일회성 데모, 프런티어 모델급 추론이 필요한 작업, 모델을 자주 갈아 끼우는 서비스는 다른 길이 낫다. 특히 마지막 것이 놓치기 쉽다 — 서버 모델은 배포하면 끝이지만, 브라우저 모델은 버전을 올릴 때마다 모든 사용자가 GB를 다시 내려받는다.
현실적인 형태는 대개 혼합이다. 짧고 잦은 일은 브라우저에서 처리하고, 무거운 요청은 서버로 보낸다. 브라우저 쪽이 준비되지 않았거나 장치가 못 버티면 조용히 서버로 넘긴다. 사용자에게는 하나의 기능이고, 뒤에서 어느 쪽이 답했는지는 응답 메타에 남겨 둔다. 어떤 요청을 장치에 맡기고 어떤 요청을 서버로 보낼지 가르는 기준은 CPU 추론에서 워크로드를 가르던 기준과 같다 — 지연에 여유가 있고 동시성이 낮은 쪽이 장치로 간다.
정리
- WebGPU의 컴퓨트 셰이더와 큰 버퍼 지원이 브라우저 안 모델 추론을 현실로 만들었다. WebGL 시절의 우회는 이제 필요 없다
- 첫 방문에는 내려받기·캐시·셰이더 컴파일로 1분 안팎이 든다. 진행률을 정직하게 보여 주고, 시작 전에 이유를 설명하는 것이 도입 성패를 가른다
- 벽은 장치 VRAM이 아니라 브라우저의 버퍼 한계다. 실용 구간은 4비트 1B~3B이고, 모델을 받기 전에
adapter.limits로 확인한다 - WebGPU를 못 쓰는 사용자가 남으므로 서버 경로를 함께 둔다
- 재방문이 잦고, 작은 모델로 충분하고, 데이터가 기기를 안 떠나는 것이 요건인 서비스에 맞는다. 모델을 자주 바꾸는 서비스에는 안 맞는다
읽어주셔서 감사합니다. 😊

