React 앱이 버벅거리는 진짜 이유: Web Workers로 성능 개선하기
React 앱이 버벅거리는 진짜 이유: Web Workers로 성능 개선하기
이런 경험 다들 있잖아요. 데이터 처리하는 기능 만드는데, 대용량 리스트 필터링이든 CSV 파싱이든 복잡한 계산이든. 개발 환경에서 100건으로 테스트하면 찰떡이에요. 근데 프로덕션에서 50,000건 들어오니까 UI가 3초 동안 얼어버림. 유저가 버튼 클릭해요. 아무 반응 없음. 또 클릭해요. 여전히 없음. 그러다 갑자기 한꺼번에 다 터져요.
메인 스레드 터지기 직전이고, 유저들은 이탈 중이에요.
JavaScript의 뼈아픈 현실이에요. 싱글 스레드라서요. 모든 애니메이션, 클릭 핸들러, API 응답, 복잡한 계산이 그 하나뿐인 스레드에서 자리싸움을 해요. 무거운 작업이 스레드를 점령하면, 공들여 만든 60fps UI가 0fps로 뚝 떨어져요. 브라우저가 프레임도 못 그리고 입력에 반응도 못 해요.
해결책? Web Workers예요. 비싼 작업을 메인 스레드에서 완전히 빼버려요. UI 스레드는 렌더링이랑 유저 반응에 집중하게 하고, 백그라운드 스레드가 병렬로 숫자 계산하면 돼요.
근데 문제가 하나 있어요. 대부분의 Web Worker 튜토리얼이 "숫자 두 개 더하기" 수준 예제 보여주고 끝이거든요. 실제 React 앱에서 Worker 쓰려고 하면 막히는 게 한둘이 아니에요:
- 복잡한 데이터 구조는 어떻게 공유하지?
- TypeScript 타입은?
- 에러 처리는 어떻게 제대로 하지?
- Worker에서 npm 패키지 쓸 수 있어?
- 내 앱에 맞는 아키텍처는 뭐지?
이 글에서 다 다뤄볼게요. 제대로 파봅시다.
메인 스레드 문제 제대로 이해하기
문제를 고치려면 먼저 이해해야죠. JavaScript는 "메인 스레드"라는 단일 스레드에서 돌아가요. 이 스레드가 처리하는 것들:
- JavaScript 실행 (여러분 코드)
- DOM 업데이트 (렌더링)
- 유저 입력 처리 (클릭, 타이핑)
- 타이머랑 애니메이션 (requestAnimationFrame, setTimeout)
- 네트워크 콜백 (fetch 응답)
500ms 걸리는 함수를 실행하면, 다른 모든 게 기다려요. 브라우저가 다시 그리지도 못해요. 클릭도 처리 못 해요. 유저 입장에서 페이지가 얼어버린 거예요.
// 이건 메인 스레드를 ~500ms 동안 블로킹함 function heavyComputation(data) { // 50,000개 아이템을 처리한다 치면 return data.map(item => { // 복잡한 변환 return expensiveOperation(item); }); } // 이게 실행되면 UI 얼어버림 const result = heavyComputation(hugeDataset);
"async/await 쓰면 되는 거 아냐?"라고 생각할 수 있는데, 여기선 안 통해요:
// 여전히 블로킹됨! async는 I/O에만 도움되고 CPU 작업엔 소용없음 async function stillBlocking(data) { // 이 계산은 여전히 메인 스레드에서 돌아감 return data.map(item => expensiveOperation(item)); }
async 키워드는 외부 뭔가를 기다릴 때만 도움이 돼요 (네트워크, 디스크). CPU 먹는 작업에는 진짜 병렬 처리가 필요해요. 그게 바로 Web Workers고요.
Web Workers 기본 개념
Web Worker는 메인 스레드랑 병렬로 돌아가는 별도의 JavaScript 스레드예요. 자체 이벤트 루프, 자체 글로벌 스코프가 있고, 중요한 건 DOM에 접근할 수 없어요.
┌─────────────────────────────────────────────────────────────┐
│ 브라우저 프로세스 │
├─────────────────────────┬───────────────────────────────────┤
│ 메인 스레드 │ 워커 스레드 │
├─────────────────────────┼───────────────────────────────────┤
│ • DOM 접근 가능 │ • DOM 접근 불가 │
│ • window 객체 │ • self 객체 │
│ • 유저 이벤트 │ • 무거운 계산 │
│ • 렌더링 │ • 데이터 처리 │
│ • React 상태 │ • 백그라운드 작업 │
├─────────────────────────┴───────────────────────────────────┤
│ postMessage() / onmessage │
│ (structured cloning으로 통신) │
└─────────────────────────────────────────────────────────────┘
스레드 간 통신은 postMessage()랑 onmessage로 해요. 데이터는 "structured clone algorithm"을 통해 스레드 간에 복사돼요. 공유가 아니라 복사예요. 대부분의 JavaScript 타입을 지원하는 딥 카피라고 보면 돼요.
가장 간단한 Worker 예시:
// worker.js self.onmessage = function(event) { const data = event.data; const result = heavyComputation(data); self.postMessage(result); }; function heavyComputation(data) { // 무거운 작업 여기서 return data.map(item => item * 2); }
// main.js const worker = new Worker('worker.js'); worker.onmessage = function(event) { console.log('결과:', event.data); }; worker.postMessage([1, 2, 3, 4, 5]);
충분히 간단하죠? 근데 여기서 의문이 생겨요. React에선 어떻게 써? TypeScript는? 복잡한 로직은 어떻게 공유해? 실전 솔루션을 만들어봅시다.
모던 React에서 Web Workers 세팅하기
Vite, Next.js, Create React App 쓰고 있다면, 각각 Worker 지원 방식이 좀 달라요. 모든 환경에서 먹히는 모던한 방법들 알려드릴게요.
방법 1: Blob URL로 인라인 Worker
간단한 케이스에서는 인라인 코드로 Worker를 만들 수 있어요:
// utils/createWorker.ts export function createWorkerFromFunction<T, R>( fn: (data: T) => R ): (data: T) => Promise<R> { const workerCode = ` self.onmessage = function(e) { const fn = ${fn.toString()}; const result = fn(e.data); self.postMessage(result); }; `; const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); const worker = new Worker(workerUrl); return (data: T): Promise<R> => { return new Promise((resolve, reject) => { worker.onmessage = (e) => resolve(e.data); worker.onerror = (e) => reject(e); worker.postMessage(data); }); }; }
쓰는 법:
const processInWorker = createWorkerFromFunction((numbers: number[]) => { return numbers.map(n => n * n).reduce((a, b) => a + b, 0); }); // 이제 Worker에서 돌아감! const sum = await processInWorker([1, 2, 3, 4, 5]);
제한사항: 함수가 다른 모듈 import하거나 클로저 쓸 수 없어요. 완전히 독립적이어야 해요.
방법 2: Vite 네이티브 Worker 지원
Vite는 ?worker 접미사로 깔끔한 Worker 지원을 제공해요:
// workers/dataProcessor.worker.ts export interface WorkerInput { data: number[]; operation: 'sum' | 'average' | 'max'; } export interface WorkerOutput { result: number; processingTime: number; } self.onmessage = (event: MessageEvent<WorkerInput>) => { const start = performance.now(); const { data, operation } = event.data; let result: number; switch (operation) { case 'sum': result = data.reduce((a, b) => a + b, 0); break; case 'average': result = data.reduce((a, b) => a + b, 0) / data.length; break; case 'max': result = Math.max(...data); break; } const output: WorkerOutput = { result, processingTime: performance.now() - start, }; self.postMessage(output); };
// hooks/useDataProcessor.ts import { useCallback, useRef, useEffect } from 'react'; import DataProcessorWorker from '../workers/dataProcessor.worker?worker'; import type { WorkerInput, WorkerOutput } from '../workers/dataProcessor.worker'; export function useDataProcessor() { const workerRef = useRef<Worker | null>(null); useEffect(() => { workerRef.current = new DataProcessorWorker(); return () => workerRef.current?.terminate(); }, []); const process = useCallback((input: WorkerInput): Promise<WorkerOutput> => { return new Promise((resolve, reject) => { if (!workerRef.current) { reject(new Error('Worker not initialized')); return; } workerRef.current.onmessage = (e) => resolve(e.data); workerRef.current.onerror = (e) => reject(e); workerRef.current.postMessage(input); }); }, []); return { process }; }
방법 3: Comlink로 깔끔한 API 만들기
구글 크롬 랩스에서 만든 Comlink는 Worker를 일반 async 함수처럼 쓸 수 있게 해줘요:
// workers/imageProcessor.worker.ts import * as Comlink from 'comlink'; const api = { async processImage(imageData: ImageData): Promise<ImageData> { // 무거운 이미지 처리 const pixels = imageData.data; for (let i = 0; i < pixels.length; i += 4) { // 그레이스케일 변환 const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3; pixels[i] = avg; // R pixels[i + 1] = avg; // G pixels[i + 2] = avg; // B } return imageData; }, async analyzeData(data: number[]): Promise<{ mean: number; stdDev: number; median: number; }> { const sorted = [...data].sort((a, b) => a - b); const mean = data.reduce((a, b) => a + b, 0) / data.length; const squaredDiffs = data.map(x => Math.pow(x - mean, 2)); const variance = squaredDiffs.reduce((a, b) => a + b, 0) / data.length; return { mean, stdDev: Math.sqrt(variance), median: sorted[Math.floor(sorted.length / 2)], }; }, }; Comlink.expose(api); export type WorkerApi = typeof api;
// hooks/useImageProcessor.ts import { useEffect, useRef } from 'react'; import * as Comlink from 'comlink'; import type { WorkerApi } from '../workers/imageProcessor.worker'; export function useImageProcessor() { const workerRef = useRef<Comlink.Remote<WorkerApi> | null>(null); useEffect(() => { const worker = new Worker( new URL('../workers/imageProcessor.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<WorkerApi>(worker); return () => worker.terminate(); }, []); return workerRef.current; } // 컴포넌트에서 쓰는 법 function ImageEditor() { const processor = useImageProcessor(); const handleProcess = async () => { if (!processor) return; // 그냥 async 함수 호출하듯이! const result = await processor.processImage(imageData); setProcessedImage(result); }; }
Comlink가 postMessage 처리를 다 해주니까 Worker가 훨씬 자연스럽게 느껴져요.
실전 예제: UI 안 멈추는 CSV 파서
뭔가 실용적인 걸 만들어봅시다. CSV 파일 임포트하는 앱을 만든다고 해봐요. 유저가 50MB짜리, 500,000 행 CSV를 업로드해요. Worker 없이는 파싱하는 동안 UI가 5-10초 동안 멈춰버려요.
// workers/csvParser.worker.ts import * as Comlink from 'comlink'; interface ParseOptions { delimiter?: string; hasHeader?: boolean; } interface ParseResult { headers: string[]; rows: string[][]; rowCount: number; parseTimeMs: number; } interface ProgressCallback { (progress: number): void; } const csvParser = { async parse( csvText: string, options: ParseOptions = {}, onProgress?: ProgressCallback ): Promise<ParseResult> { const start = performance.now(); const { delimiter = ',', hasHeader = true } = options; const lines = csvText.split('\n'); const totalLines = lines.length; const headers: string[] = []; const rows: string[][] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const values = parseCsvLine(line, delimiter); if (i === 0 && hasHeader) { headers.push(...values); } else { rows.push(values); } // 1000 행마다 진행 상황 리포트 if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / totalLines) * 100)); } } return { headers, rows, rowCount: rows.length, parseTimeMs: performance.now() - start, }; }, async filter( rows: string[][], columnIndex: number, predicate: string, onProgress?: ProgressCallback ): Promise<string[][]> { const result: string[][] = []; for (let i = 0; i < rows.length; i++) { const value = rows[i][columnIndex]; if (value?.toLowerCase().includes(predicate.toLowerCase())) { result.push(rows[i]); } if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / rows.length) * 100)); } } return result; }, async aggregate( rows: string[][], groupByColumn: number, aggregateColumn: number ): Promise<Map<string, number>> { const groups = new Map<string, number[]>(); for (const row of rows) { const key = row[groupByColumn]; const value = parseFloat(row[aggregateColumn]); if (!isNaN(value)) { if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(value); } } const result = new Map<string, number>(); for (const [key, values] of groups) { result.set(key, values.reduce((a, b) => a + b, 0)); } return result; }, }; function parseCsvLine(line: string, delimiter: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === delimiter && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; } Comlink.expose(csvParser); export type CsvParser = typeof csvParser;
// hooks/useCsvParser.ts import { useEffect, useRef, useState, useCallback } from 'react'; import * as Comlink from 'comlink'; import type { CsvParser } from '../workers/csvParser.worker'; export function useCsvParser() { const workerRef = useRef<Comlink.Remote<CsvParser> | null>(null); const [progress, setProgress] = useState(0); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { const worker = new Worker( new URL('../workers/csvParser.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<CsvParser>(worker); return () => worker.terminate(); }, []); const parseFile = useCallback(async (file: File) => { if (!workerRef.current) throw new Error('Worker not ready'); setIsProcessing(true); setProgress(0); try { const text = await file.text(); const result = await workerRef.current.parse( text, { hasHeader: true }, Comlink.proxy((p: number) => setProgress(p)) ); return result; } finally { setIsProcessing(false); setProgress(100); } }, []); return { parseFile, progress, isProcessing, parser: workerRef.current, }; }
// components/CsvImporter.tsx import { useState } from 'react'; import { useCsvParser } from '../hooks/useCsvParser'; export function CsvImporter() { const { parseFile, progress, isProcessing } = useCsvParser(); const [result, setResult] = useState<ParseResult | null>(null); const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; const parsed = await parseFile(file); setResult(parsed); }; return ( <div> <input type="file" accept=".csv" onChange={handleFileChange} disabled={isProcessing} /> {isProcessing && ( <div className="progress-bar"> <div className="progress-fill" style={{ width: `${progress}%` }} /> <span>처리 중... {progress}%</span> </div> )} {result && ( <div className="result"> <p>{result.rowCount.toLocaleString()} 행 파싱 완료</p> <p>소요 시간: {result.parseTimeMs.toFixed(2)}ms</p> <table> <thead> <tr> {result.headers.map((h, i) => ( <th key={i}>{h}</th> ))} </tr> </thead> <tbody> {result.rows.slice(0, 100).map((row, i) => ( <tr key={i}> {row.map((cell, j) => ( <td key={j}>{cell}</td> ))} </tr> ))} </tbody> </table> </div> )} </div> ); }
이제 CSV 파싱이 백그라운드에서 돌아가요. UI는 쌩쌩하게 살아있고, 유저는 진행률 보면서 기다릴 수 있어요. 버벅임? 없어요.
대용량 데이터 처리: Transferable Objects
스레드 간에 대량의 데이터를 복사하면 느려요. ArrayBuffer, MessagePort, OffscreenCanvas는 transferable objects라고 해서 복사 없이 이동시킬 수 있어요:
// 복사 대신 소유권 이전 const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB // 느림: 버퍼 전체를 복사 worker.postMessage({ buffer }); // 빠름: 소유권 이전 (메인 스레드에서 buffer 못 씀) worker.postMessage({ buffer }, [buffer]);
이미지 처리할 때 이건 엄청난 차이예요:
// workers/imageProcessor.worker.ts self.onmessage = async (e: MessageEvent) => { const { imageData } = e.data; // 이미지 데이터 처리 const processed = applyFilter(imageData); // 복사 말고 이전 const buffer = processed.data.buffer; self.postMessage({ imageData: processed }, [buffer]); };
// 메인 스레드 const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]);
병렬 처리를 위한 Worker 풀
Worker 하나도 좋지만, 여러 개가 병렬로 일하면 더 좋죠. 간단한 Worker 풀 코드예요:
// utils/WorkerPool.ts export class WorkerPool<TInput, TOutput> { private workers: Worker[] = []; private queue: Array<{ data: TInput; resolve: (result: TOutput) => void; reject: (error: Error) => void; }> = []; private activeWorkers = new Set<Worker>(); constructor( private createWorker: () => Worker, private poolSize: number = navigator.hardwareConcurrency || 4 ) { for (let i = 0; i < poolSize; i++) { this.workers.push(this.createWorker()); } } async execute(data: TInput): Promise<TOutput> { return new Promise((resolve, reject) => { const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { this.runTask(availableWorker, data, resolve, reject); } else { this.queue.push({ data, resolve, reject }); } }); } private runTask( worker: Worker, data: TInput, resolve: (result: TOutput) => void, reject: (error: Error) => void ) { this.activeWorkers.add(worker); worker.onmessage = (e) => { resolve(e.data); this.activeWorkers.delete(worker); this.processQueue(); }; worker.onerror = (e) => { reject(new Error(e.message)); this.activeWorkers.delete(worker); this.processQueue(); }; worker.postMessage(data); } private processQueue() { if (this.queue.length === 0) return; const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { const { data, resolve, reject } = this.queue.shift()!; this.runTask(availableWorker, data, resolve, reject); } } async executeAll(items: TInput[]): Promise<TOutput[]> { return Promise.all(items.map(item => this.execute(item))); } terminate() { this.workers.forEach(w => w.terminate()); this.workers = []; } }
이미지 병렬 처리에 쓰면:
const pool = new WorkerPool( () => new Worker(new URL('./imageWorker.ts', import.meta.url)), 4 // 4개 워커 병렬로 ); // 100개 이미지를 4개 워커한테 나눠서 처리 const results = await pool.executeAll(images);
자주 하는 실수들
실수 1: 작은 작업에 Worker 남발
Worker에도 오버헤드가 있어요. Worker 생성, 데이터 직렬화, 결과 역직렬화에 시간이 들어요. 작은 작업에선 이 오버헤드가 계산 시간보다 커질 수 있어요.
경험칙: 작업이 16ms 이상 (60fps에서 한 프레임) 걸릴 때만 Worker 쓰세요.
// ❌ 이건 손해 - 오버헤드가 이득보다 큼 worker.postMessage([1, 2, 3]); // 숫자 3개 더하기 // ✅ 이건 이득 - 충분히 무거워서 오버헤드 상쇄 worker.postMessage(bigArray); // 100,000개 아이템 처리
실수 2: Worker 안 끄고 방치
Worker는 메모리 먹어요. 기존 Worker 안 끄고 새로 계속 만들면, 메모리 누수 나요:
// ❌ 메모리 누수 function processData(data) { const worker = new Worker('worker.js'); worker.postMessage(data); // Worker 안 끔! } // ✅ 깔끔하게 정리 function processData(data) { const worker = new Worker('worker.js'); worker.onmessage = (e) => { console.log(e.data); worker.terminate(); // 끝나면 정리 }; worker.postMessage(data); }
React에선 cleanup에서 항상 terminate:
useEffect(() => { const worker = new Worker('worker.js'); workerRef.current = worker; return () => { worker.terminate(); // ✅ 언마운트될 때 정리 }; }, []);
실수 3: 에러 처리 안 함
Worker 에러는 자동으로 메인 스레드 에러 핸들러한테 안 가요:
// ❌ 에러 조용히 무시됨 worker.postMessage(data); // ✅ 에러 잡아주기 worker.onerror = (error) => { console.error('Worker 에러:', error.message); // 에러 처리 }; worker.onmessageerror = (error) => { console.error('메시지 에러:', error); };
실수 4: Worker 스레드도 막아버리기
작업을 Worker로 옮겼다고 블로킹에서 해방되는 건 아니에요. Worker가 5초 동안 동기 작업 돌리면, 그 사이 다른 메시지 처리 못 해요.
// ❌ 긴 동기 작업이 모든 메시지 막음 self.onmessage = (e) => { const result = process5MillionItems(e.data); // 5초 동안 블로킹 self.postMessage(result); }; // ✅ 청크로 나눠서 중간중간 숨쉴 틈 주기 self.onmessage = async (e) => { const items = e.data; const results = []; const chunkSize = 10000; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); results.push(...processChunk(chunk)); // 다른 메시지 처리할 수 있게 잠깐 양보 await new Promise(resolve => setTimeout(resolve, 0)); // 진행률 리포트 self.postMessage({ type: 'progress', value: i / items.length }); } self.postMessage({ type: 'complete', results }); };
Web Workers 쓰면 안 되는 경우
Worker가 만능은 아니에요. 이럴 땐 피하세요:
-
I/O 작업이지 CPU 작업이 아닐 때 - 데이터 fetching, DB 쿼리 같은 거. 그냥 async/await 쓰면 돼요.
-
DOM 건드려야 할 때 - Worker는 DOM 못 만져요. 처리 중간에 UI 업데이트해야 하면, 메인 스레드한테 메시지 보내야 해요.
-
데이터 전송 비용이 계산 절약보다 클 때 - 100ms 계산 아끼려고 1GB 데이터 직렬화하는 건 본전도 못 찾아요.
-
이미 빠른 작업일 때 - 16ms 안에 끝나면 오버헤드가 더 커요.
-
오래된 브라우저 지원해야 하는데 폴백도 없을 때 - 브라우저 지원 범위 체크하세요.
성능 비교: Before vs After
실제로 100만 개 숫자 정렬해보면:
// Worker 없이 function sortData() { const start = performance.now(); const sorted = data.sort((a, b) => a - b); console.log(`메인 스레드: ${performance.now() - start}ms`); console.log(`이 시간 동안 UI 멈춤`); } // Worker로 async function sortDataWorker() { const start = performance.now(); const sorted = await workerSort(data); console.log(`Worker: ${performance.now() - start}ms`); console.log(`UI는 쌩쌩`); }
일반적인 머신에서 결과:
- Worker 없이: 1,200ms (UI 멈춤)
- Worker로: 1,250ms (UI 쌩쌩)
직렬화 오버헤드 때문에 총 시간은 Worker가 살짝 더 길어요. 근데 UI가 절대 안 멈추니까 체감 성능은 비교도 안 돼요.
정리하면
Web Workers는 브라우저 API 중에서 가장 저평가된 것 중 하나예요. 제대로 쓰면 뻑뻑한 앱을 찰떡같이 반응하는 앱으로 바꿔줘요.
핵심 포인트:
-
메인 스레드는 소중해요 - 렌더링이랑 유저 반응용으로 아껴두세요.
-
Worker는 CPU 먹는 작업용 - 데이터 처리, 파싱, 계산, 이미지 조작.
-
Comlink 쓰면 API가 깔끔해요 - Worker가 그냥 async 함수처럼 느껴져요.
-
복사 말고 전송 - 큰 ArrayBuffer는 transferable objects로.
-
Worker 풀로 병렬 처리 - 여러 워커가 동시에 일하면 더 빠름.
-
최적화 전에 측정부터 - Worker도 오버헤드 있어요. 이득이 손해보다 클 때만 쓰세요.
-
정리 잊지 마세요 - 끝나면 terminate 꼭.
다음에 React 앱이 무거운 작업 때문에 버벅거리면, 뭘 해야 하는지 이제 알겠죠? 그 작업 Worker로 빼고, 메인 스레드 비워두고, 유저한테 부드러운 경험 선물하세요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요