TensorFlow.js에서 LiteRT.js로 갈아탈 때 마주하는 세 가지 지뢰밭
TuBrief 편집팀
2026년 7월 18일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
웹 브라우저에서 AI 모델을 돌리는 일은 짜릿하지만, 금세 한계에 부딪힌다. 고해상도 이미지를 처리하거나 무거운 연산을 시작하면 화면이 뚝뚝 끊기기 때문이다. TensorFlow.js(TF.js)는 WebGL 백엔드와 자바스크립트 커널 바인딩을 쓰는데, 이게 대규모 행렬 연산에서 심각한 오버헤드를 만든다.
반면 LiteRT.js는 C++ 네이티브 런타임을 WebAssembly(Wasm)로 컴파일해서 브라우저에 이식했다. 병목을 근본적으로 해결하는 구조다. 게다가 PyTorch 모델을 웹으로 가져오는 과정도 단순해진다. 예전에는 PyTorch에서 ONNX로, 다시 TensorFlow를 거쳐 TF.js 포맷으로 바꾸느라 연산자 호환성이 깨지고 정밀도가 손실됐다. 이제는 라이브러리(ai-edge-torch) 하나로 표준 .tflite 파일을 뽑아내면 끝난다.
기존 자산을 버리기 아깝다면 Google이 제공하는 @litertjs/tfjs-interop 패키지를 쓰면 된다. 데이터 전처리나 후처리는 TF.js 파이프라인을 그대로 두고, 핵심 모델 예측 실행부만 LiteRT.js로 교체하는 방식이다.
PyTorch에서 변환한 TFLite 모델은 대개 채널 우선 구조인 (채널, 높이, 너비)를 요구한다. 하지만 브라우저 Canvas의 ImageData 배열은 픽셀이 나열된 (높이, 너비, 채널) 포맷이다. 이 격차를 메인 스레드 부하 없이 동기 처리하는 전처리 코드가 필요하다.
먼저 ImageData의 전체 픽셀 수에 3을 곱해 Float32Array를 만든다. 그다음 빨강, 초록, 파랑 각 채널의 평탄화 오프셋 위치를 0, 전체 픽셀 수, 전체 픽셀 수의 2배로 지정한다.마지막으로 0~255 사이의 픽셀 값을 255.0으로 나누어 정규화한 뒤 각 채널 오프셋에 할당한다. 입력 데이터를 평탄화 텐서 버퍼로 재정렬하는 과정이다.
/**
* NHWC 포맷의 ImageData 버퍼를 고성능 NCHW Float32Array로 고속 변환하는 전처리 유틸리티
* @param {ImageData} imageData - HTML5 Canvas로부터 획득한 원본 픽셀 데이터
* @param {number} width - 대상 모델이 요구하는 입력 이미지 가로 해상도
* @param {number} height - 대상 모델이 요구하는 입력 이미지 세로 해상도
* @returns {Float32Array} NCHW 배열 형식으로 재정렬된 평탄화 텐서 버퍼
*/
export function preprocessNHWCToNCHW(imageData, width, height) {
const { data } = imageData;
const totalPixels = width * height;
const nchwBuffer = new Float32Array(totalPixels * 3);
const rChannelOffset = 0;
const gChannelOffset = totalPixels;
const bChannelOffset = totalPixels * 2;
for (let i = 0; i < totalPixels; i++) {
const srcIndex = i * 4;
nchwBuffer[rChannelOffset + i] = data[srcIndex] / 255.0;
nchwBuffer[gChannelOffset + i] = data[srcIndex + 1] / 255.0;
nchwBuffer[bChannelOffset + i] = data[srcIndex + 2] / 255.0;
}
return nchwBuffer;
}
프론트엔드에서 딥러닝을 구동할 때 가장 끔찍한 상황은 UI가 멈추는 현상이다. 브라우저가 초당 60프레임의 부드러운 애니메이션을 보여주려면 이벤트 루프가 16.6ms 이내에 동기 작업을 처리해야 한다. 하지만 텐서 연산은 싱글 스레드인 메인 렌더러 루프를 쉽게 가로막는다.
LiteRT.js는 기존 자바스크립트 기반 도구보다 기본 실행 속도가 약 3배 빠르다. WebGPU나 WebNN 가속 하드웨어를 붙이면 CPU 모드보다 최소 5배에서 최대 60배까지 빨라진다. 전용 NPU를 쓰는 WebNN 백엔드는 동기식 WebAssembly 커널 스케줄러와 브라우저의 비동기 하드웨어 제어 루프를 연결하기 위해 자바스크립트 프로미스 통합(JSPI) 기능을 무조건 켜야 한다. 이 가속 자원을 쓰면서 메인 스레드를 살리려면, 라이브러리 초기화와 추론 파이프라인 전체를 웹 워커(Web Worker) 내부로 격리해야 마땅하다.
이때 스레드 간 데이터 통신에서 버퍼를 그냥 넘기면 내부 메모리 복사가 일어나 CPU와 힙 메모리에 오버헤드가 쌓인다. 전송 가능 객체(Transferable Objects)를 써서 물리 메모리 주소 영역의 소유권 자체를 넘겨야 지연 시간이 사라진다. 소유권이 이전된 버퍼는 전송 측 컨텍스트에서 즉시 무효화되므로 스레드 간 안전성도 지킬 수 있다.
// litert-worker.js - 백그라운드 추론 연산 전용 웹 워커 모듈
import { loadLiteRt, loadAndCompile, Tensor } from '@litertjs/core';
let compiledModel = null;
let isLoaded = false;
self.onmessage = async (event) => {
const { type, payload } = event.data;
switch (type) {
case 'LOAD_MODEL':
try {
await loadLiteRt(payload.wasmDirectory, { jspi: payload.enableJspi || false });
compiledModel = await loadAndCompile(payload.modelUrl, {
accelerator: payload.accelerator || 'webgpu'
});
isLoaded = true;
self.postMessage({ type: 'MODEL_READY' });
} catch (err) {
self.postMessage({ type: 'ERROR', error: `Initialization failed: ${err.message}` });
}
break;
case 'RUN_INFERENCE':
if (!isLoaded || !compiledModel) {
self.postMessage({ type: 'ERROR', error: 'Model has not been loaded' });
return;
}
try {
const rawInputData = payload.bufferData;
const inputShape = payload.shape;
const inputTensor = new Tensor(rawInputData, inputShape);
const results = await compiledModel.run(inputTensor);
const cpuOutputTensor = await results[0].moveTo('wasm');
const outputBuffer = cpuOutputTensor.toTypedArray();
inputTensor.delete();
cpuOutputTensor.delete();
results[0].delete();
self.postMessage(
{
type: 'INFERENCE_COMPLETE',
payload: {
data: outputBuffer,
shape: results[0].shape
}
},
[outputBuffer.buffer]
);
} catch (err) {
self.postMessage({ type: 'ERROR', error: `Inference failed: ${err.message}` });
}
break;
default:
self.postMessage({ type: 'UNKNOWN_OP' });
}
};
// litert-bridge.js - 메인 스레드용 AI 오케스트레이터 클래스
export class LiteRtBridge {
constructor(workerPath) {
this.worker = new Worker(workerPath);
this.promiseMap = new Map();
this.tokenCounter = 0;
this.worker.onmessage = (event) => {
const { type, payload, error } = event.data;
if (type === 'MODEL_READY') {
if (this.initResolve) this.initResolve();
} else if (type === 'INFERENCE_COMPLETE') {
const currentToken = this.tokenCounter;
const promiseHandler = this.promiseMap.get(currentToken);
if (promiseHandler) {
promiseHandler.resolve(payload);
this.promiseMap.delete(currentToken);
}
} else if (type === 'ERROR') {
const currentToken = this.tokenCounter;
const promiseHandler = this.promiseMap.get(currentToken);
if (promiseHandler) {
promiseHandler.reject(new Error(error));
this.promiseMap.delete(currentToken);
} else if (this.initReject) {
this.initReject(new Error(error));
}
}
};
}
bootstrap(wasmDirectory, modelUrl, accelerator = 'webgpu') {
return new Promise((resolve, reject) => {
this.initResolve = resolve;
this.initReject = reject;
this.worker.postMessage({
type: 'LOAD_MODEL',
payload: { wasmDirectory, modelUrl, accelerator, enableJspi: true }
});
});
}
execute(inputFloat32Array, inputShape) {
return new Promise((resolve, reject) => {
this.tokenCounter++;
this.promiseMap.set(this.tokenCounter, { resolve, reject });
this.worker.postMessage(
{
type: 'RUN_INFERENCE',
payload: {
bufferData: inputFloat32Array,
shape: inputShape
}
},
[inputFloat32Array.buffer]
);
});
}
}
TF.js를 쓸 때는 tf.tidy()로 동기 호출 스코프 안의 텐서들을 날려버리는 패턴이 표준이었다. 하지만 비동기 코드나 프로미스가 얽히면 비동기 작업이 끝나기도 전에 스코프를 벗어나 텐서를 터뜨리거나 수거를 누락하는 버그를 만들었다.
LiteRT.js는 더 가차 없다. 브라우저 엔진의 가비지 컬렉션(GC) 대상이 아니다. WebAssembly 선형 가상 메모리 공간과 WebGPU 버퍼는 V8 같은 자바스크립트 엔진이 힙 상태를 추적할 수 없다. 코드가 끝난 텐서 인스턴스에 명시적으로 .delete()를 호출하지 않으면, 브라우저 메모리는 무한히 쌓인다. 고화질 비디오 프레임을 초당 수십 번씩 스트리밍하는 서비스라면 몇 분 안에 탭이 죽어버린다.
비동기 파이프라인 전반에서 생성된 텐서들의 수명을 기록하고 일괄 파괴를 보장하는 스코프 트래커 클래스를 만들어 수동으로 관리해야 마음이 편하다.
/**
* WebAssembly 힙 텐서들의 수동 추적 및 확실한 파괴를 매개하는 비동기 메모리 범위 관리자
*/
export class LiteRtScopeTracker {
constructor() {
this.trackList = new Set();
}
/**
* 생성되거나 이동 완료된 텐서를 라이프사이클 관리 명단에 편입
* @param {Tensor} tensor - 추적 및 소멸 처리를 수렴할 LiteRT.js 텐서
* @returns {Tensor} 입수된 텐서 객체를 그대로 리턴하여 인라인 코드 작성 지원
*/
register(tensor) {
if (tensor && typeof tensor.delete === 'function') {
this.trackList.add(tensor);
}
return tensor;
}
/**
* 비동기 실행 블록 내의 안전한 텐서 파이프라인 관리 구조 강제화
* @param {Function} asyncCallable - 비동기 추론 비즈니스 로직 함수
* @returns {Promise<*>} 임의의 실행 블록이 반환하는 최종 원시 데이터 결과
*/
async enforceScope(asyncCallable) {
try {
const outputResult = await asyncCallable(this);
if (Array.isArray(outputResult)) {
outputResult.forEach((item) => this.trackList.delete(item));
} else {
this.trackList.delete(outputResult);
}
return outputResult;
} finally {
this.disposeAll();
}
}
/**
* 관리 대상에 바인딩되어 생존해 있는 모든 네이티브 TFLite 텐서들을 Wasm 영역에서 영구 격리 해제
*/
disposeAll() {
for (const tensor of this.trackList) {
try {
tensor.delete();
} catch (err) {
console.error('An error occurred while cleaning the native Wasm tensor memory:', err);
}
}
this.trackList.clear();
}
}
// 메모리 스코프 트래커를 활용한 안전하고 견고한 다중 비동기 AI 추론 처리 구현 예시
export async function runRobustVisionInference(rawPixelArray, compiledModel) {
const scopeTracker = new LiteRtScopeTracker();
try {
return await scopeTracker.enforceScope(async (scope) => {
const inputTensor = scope.register(new Tensor(rawPixelArray, [1, 3, 224, 224]));
const predictionResults = await compiledModel.run(inputTensor);
predictionResults.forEach((tensor) => scope.register(tensor));
const firstOutputTensor = predictionResults[0];
const wasmTransferTensor = scope.register(await firstOutputTensor.moveTo('wasm'));
const targetJsArray = wasmTransferTensor.toTypedArray();
return targetJsArray;
});
} catch (err) {
console.error('Fatal crash occurred during the model pipeline execution:', err);
throw err;
}
}
리소스 다운로드 크기가 커지는 문제도 무시할 수 없다. 빌드 번들러에서 트리 셰이킹이 돌게 하려면 CommonJS 스타일의 정적 참조를 지우고, ES6 모듈 구문(import/export) 기반으로 소스코드를 짜야 한다. 빌드 툴의 sideEffects: false 설정도 챙겨야 번들이 가벼워진다.
LiteRT.js 코어 런타임인 @litertjs/core는 기기 성능에 맞춰 세 가지 WebAssembly 커널 빌드를 선택 로드한다. 크롬이나 에지 같은 최신 브라우저에서는 멀티스레드와 SIMD를 지원하는 모듈(litert_wasm_simd.wasm)을 선택하고, Safari 같은 레거시 환경에서는 기본 폴백 모듈(litert_wasm.wasm)을 가져온다. 만약 GPU 컴파일이 실패하면 하드웨어 연산자 전체를 CPU Wasm 샌드박스로 밀어내는 XNNPACK 런타임이 보조 장치로 돌아간다.
초기 로딩 지연을 막으려면 사양별 가속기를 체크해 동적으로 모듈을 가져와야 한다.
// litert-loader.js - 런타임 기기 탐색 및 가속기 동적 결합 엔진
export async function bootstrapHighPerformanceInferenceEngine() {
const supportsWebGpu = 'gpu' in navigator;
let chosenAccelerator = 'wasm';
if (supportsWebGpu) {
try {
const gpuAdapter = await navigator.gpu.requestAdapter();
if (gpuAdapter) {
const info = await gpuAdapter.requestDevice();
if (info) {
chosenAccelerator = 'webgpu';
}
}
} catch (e) {
console.warn("GPU profile probe failed, resolving execution chain to fallback WASM.");
}
}
const { loadLiteRt, loadAndCompile } = await import('@litertjs/core');
const cdnWasmHostPath = 'https://cdn.jsdelivr.net/npm/@litertjs/core/wasm/';
await loadLiteRt(cdnWasmHostPath, {
jspi: chosenAccelerator === 'webnn'
});
return {
loadAndCompile,
chosenAccelerator
};
}
웹 워커를 통한 메모리 소유권 이전과 Wasm 영역의 명시적 객체 소멸 처리를 설계 기조에 올려두자. 데이터 흐름을 직접 통제하기 시작하면 브라우저 폭발을 걱정하지 않고 온디바이스 AI 서비스를 프로덕션에 올릴 수 있다.