본문 바로가기

React

React에서 웹 브라우저 멀티 스레드(Web Workers) 활용하기

React 앱에서 스크롤 끊김, 입력 지연, 렌더링 프리즈가 발생한다면 CPU 바운드 작업을 메인 스레드에서 수행하고 있을 가능성이 큽니다. 브라우저의 Web Workers를 사용하면 무거운 계산을 별도 스레드로 분리해 UI 반응성을 유지할 수 있습니다. 실무에서 바로 쓸 수 있는 설정, 코드, 최적화 팁을 정리했습니다.

1. 언제 Web Workers를 써야 하나요?

다음과 같은 CPU 바운드 작업을 메인 스레드에서 분리할 때 적합합니다.

- 대량 데이터 가공(집계, 정렬, 통계)
- 이미지 처리, 압축/해제, 암호화
- 복잡한 수치 연산(시뮬레이션, 경로 탐색)
- JSON 대용량 파싱/검증

DOM 접근은 워커에서 불가합니다. UI 업데이트는 메인 스레드에서만 수행해야 합니다.

2. 번들러별 기본 세팅(Vite/CRA)

대부분의 현대 번들러는 Worker 엔트리를 URL로 임포트하는 방식을 지원합니다. Vite/CRA 모두 아래 문법을 사용하면 동작합니다.

// App.jsx (또는 .tsx)
const worker = new Worker(
  new URL('./heavy.worker.js', import.meta.url),
  { type: 'module' }
);

주의사항:

- 워커 파일은 별도 엔트리이므로 상대 경로와 확장자를 정확히 지정합니다.
- type: 'module'을 권장합니다(ESM, 트리쉐이킹, 최신 문법).

3. Worker 코드 작성(heavy.worker.js)

워커는 메시지 기반으로 통신합니다. onmessage에서 작업을 수행하고 postMessage로 결과를 돌려줍니다. 대용량 버퍼는 전송(transfer)하여 복사를 피합니다.

// heavy.worker.js
// 워커는 self가 글로벌 컨텍스트입니다.

function fib(n) {
  // 데모용 순차 구현(실무는 반복/메모이제 권장)
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) {
    const t = a + b;
    a = b; b = t;
  }
  return b;
}

self.onmessage = (e) => {
  const { type, payload } = e.data || {};
  try {
    switch (type) {
      case 'FIB': {
        const n = Number(payload || 0);
        const result = fib(n);
        self.postMessage({ type: 'RESULT', task: 'FIB', result });
        break;
      }
      case 'SUM_BUFFER': {
        // 메인 스레드에서 넘긴 ArrayBuffer를 전송받아 합계 계산
        const buf = payload; // ArrayBuffer
        const arr = new Float64Array(buf);
        let sum = 0;
        for (let i = 0; i < arr.length; i++) sum += arr[i];
        // 결과만 보내고, 원본 버퍼는 이미 워커로 소유권 이전됨
        self.postMessage({ type: 'RESULT', task: 'SUM_BUFFER', result: sum });
        break;
      }
      case 'PING': {
        self.postMessage({ type: 'PONG', ts: Date.now() });
        break;
      }
      default:
        self.postMessage({ type: 'ERROR', error: 'Unknown message type' });
    }
  } catch (err) {
    self.postMessage({ type: 'ERROR', error: err?.message || String(err) });
  }
};

4. React 컴포넌트에서 안전하게 사용하기

생성: 마운트 시 1회 생성하고 언마운트 시 terminate로 정리합니다. 상태 업데이트는 onmessage에서 처리합니다.

// WorkerDemo.jsx
import { useEffect, useRef, useState } from 'react';

export default function WorkerDemo() {
  const workerRef = useRef(null);
  const [fibInput, setFibInput] = useState(42);
  const [fibResult, setFibResult] = useState(null);
  const [sumResult, setSumResult] = useState(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  useEffect(() => {
    const worker = new Worker(new URL('./heavy.worker.js', import.meta.url), { type: 'module' });
    workerRef.current = worker;

    worker.onmessage = (e) => {
      const { type, task, result, error: err } = e.data || {};
      if (type === 'RESULT') {
        if (task === 'FIB') setFibResult(result);
        if (task === 'SUM_BUFFER') setSumResult(result);
        setBusy(false);
      } else if (type === 'ERROR') {
        setError(err || 'Worker error');
        setBusy(false);
      }
    };

    worker.onerror = (e) => {
      setError(e.message || 'Worker crashed');
      setBusy(false);
    };

    return () => {
      worker.terminate();
      workerRef.current = null;
    };
  }, []);

  const runFib = () => {
    setBusy(true);
    setError('');
    workerRef.current?.postMessage({ type: 'FIB', payload: Number(fibInput) });
  };

  const runSum = () => {
    setBusy(true);
    setError('');
    const len = 500_000;
    const arr = new Float64Array(len);
    for (let i = 0; i < len; i++) arr[i] = Math.random();
    // ArrayBuffer를 전송(transfer)하여 복사 비용 제거
    workerRef.current?.postMessage({ type: 'SUM_BUFFER', payload: arr.buffer }, [arr.buffer]);
    // 여기서부터 arr.buffer는 메인 스레드에서 사용 불가(바이트 길이 0)
  };

  return (
    <div>
      <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
        <input
          type="number"
          value={fibInput}
          onChange={(e) => setFibInput(e.target.value)}
          min={0}
          style={{ width: 120 }}
        />
        <button onClick={runFib} disabled={busy}>피보나치 계산</button>
        <span>결과: {fibResult ?? '-'}</span>
      </div>

      <div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
        <button onClick={runSum} disabled={busy}>대용량 합계(SIMD 미사용)</button>
        <span>결과: {sumResult ?? '-'}</span>
      </div>

      {busy && <p>계산 중입니다... UI는 계속 반응합니다.</p>}
      {error && <p style={{ color: 'crimson' }}>오류: {error}</p>}
    </div>
  );
}

5. TypeScript 적용 시 팁

- new URL 방식은 별도 .d.ts 없이도 동작합니다. Worker 인스턴스 타입은 Worker로 추론됩니다.
- 메시지 페이로드에 인터페이스를 정의해 안전하게 사용하세요.

// types.ts
export type InMsg =
  | { type: 'FIB'; payload: number }
  | { type: 'SUM_BUFFER'; payload: ArrayBuffer };

export type OutMsg =
  | { type: 'RESULT'; task: 'FIB' | 'SUM_BUFFER'; result: number }
  | { type: 'ERROR'; error: string };

6. 전송 가능한 객체(Transferables)로 복사 비용 제거

대용량 데이터를 구조적 복제(structured clone)하면 복사 비용으로 역효과가 납니다. ArrayBuffer, MessagePort, ImageBitmap 등은 소유권 전송이 가능하므로 반드시 전송 목록에 두 번째 인자로 전달하세요.

// 메인 스레드
const buf = new ArrayBuffer(8 * 1_000_000);
worker.postMessage({ type: 'SUM_BUFFER', payload: buf }, [buf]);
// buf는 더 이상 메인 스레드에서 사용 불가

7. 성능/안정성 최적화 체크리스트

- 작업 단위 쪼개기: 초 장기 실행을 여러 청크로 나눠 중간 결과를 전달하세요.
- 워커 풀: 동시에 여러 작업이 있다면 워커 여러 개를 라운드로빈으로 활용합니다.
- 단일 워커 재사용: 짧은 작업은 생성/종료 오버헤드를 줄이기 위해 재사용하세요.
- 백프레셔: 사용자가 입력을 연타하면 이전 작업을 취소하거나 최신 입력만 처리하세요.
- 에러/타임아웃: onerror, try/catch, 타임아웃 로직을 추가합니다.
- SSR/Next.js: typeof window === 'undefined' 가드로 서버에서는 워커 생성 금지.

8. 흔한 오류와 해결

- 워커 경로 에러: new URL('./heavy.worker.js', import.meta.url) 형태를 사용하세요.
- DOM 접근 시도: 워커는 DOM API에 접근할 수 없습니다. 계산만 수행하세요.
- 메시지 복사 지연: ArrayBuffer 전송(transfer)을 사용하세요.
- CORS/파일 프로토콜: 파일 프로토콜(file://)에서는 워커 로드 문제가 발생할 수 있으니 로컬 서버를 사용하세요.
- 타입 불일치: 메시지 타입 유효성 검사로 방어 코드를 추가하세요.

9. Comlink로 API처럼 사용하기(선택)

Comlink는 postMessage를 숨기고 함수 호출처럼 워커를 다룰 수 있게 합니다. 복잡한 양방향 RPC가 필요할 때 고려하세요.

// worker.js
import { expose } from 'comlink';
const api = {
  fib(n) { /* ... */ return n; },
  sum(buf) { /* ... */ return 0; }
};
expose(api);

// App.jsx
import { wrap } from 'comlink';
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
const api = wrap(worker);
const result = await api.fib(42);

10. 결론: React + Workers, 어디부터 적용할까요?

먼저 성능 문제의 원인이 CPU 바운드인지 파악한 뒤, 가장 무거운 순수 계산 로직 하나를 워커로 분리해보세요. 전송 가능한 객체를 적극 활용하고, 워커 생명주기와 에러 처리를 표준화하면 UI는 매끄럽고, 코드베이스는 안정적으로 확장됩니다.