Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 Web Worker 구현하기: React와 TypeScript

Claude Code로 타입 안전한 Web Worker, Vite/React 연결, cleanup, transferable, 테스트를 구현합니다.

Claude Code로 Web Worker 구현하기: React와 TypeScript

브라우저에서 무거운 작업을 그대로 실행하면 앱이 고장 난 것처럼 보입니다. CSV를 집계하는 동안 입력이 멈추고, 이미지 필터를 적용하는 동안 스크롤이 끊기고, 검색 인덱스를 만드는 동안 첫 클릭이 늦어집니다. 로그 파일 몇 MB를 분석하는 작업도 데모에서는 괜찮아 보이다가 실제 운영 데이터에서 UI를 멈추게 만듭니다.

Web Worker는 이런 CPU 중심 작업을 메인 스레드 밖으로 옮기는 브라우저 기능입니다. 메인 스레드는 화면 그리기, 클릭, 입력, 스크롤을 담당합니다. Worker는 별도 작업 공간에서 데이터를 계산하고 결과를 돌려줍니다. 중요한 점은 Worker가 DOM, window, document, React state, routing, toast UI를 직접 다루면 안 된다는 것입니다.

Claude Code는 여러 파일을 한 번에 만들 수 있어 Worker 작업과 잘 맞습니다. 하지만 지시가 모호하면 그럴듯하지만 위험한 코드를 만들 수 있습니다. 공식 자료는 MDN Web Workers API, MDN Transferable objects, Vite Web Workers, Claude Code docs를 확인하세요. 저장소 규칙은 내부 글 CLAUDE.md best practices와 함께 정리하면 좋습니다.

구조

이번 구현은 React UI, Worker lifecycle hook, Worker 계산 모듈을 분리합니다. Claude Code에 이 구조를 먼저 보여 주면 DOM 접근이나 책임 혼합을 줄일 수 있습니다.

flowchart LR
  UI["React UI"]
  Hook["useDataWorker hook"]
  Worker["data.worker.ts"]
  Tasks["CSV / image / search / logs / JSON"]
  UI --> Hook
  Hook --> Worker
  Worker --> Tasks
  Worker --> Hook
  Hook --> UI

다섯 가지 유스케이스를 넣는 이유는 실제 프로젝트에서 자주 만나는 병목을 한 번에 점검하기 위해서입니다. CSV 집계는 숫자 파싱을 확인하고, 이미지 처리는 transferable을 확인합니다. 검색 인덱스는 큰 배열을 다루고, 로그 분석은 문자열 처리량을 확인합니다. 무거운 JSON 변환은 재귀와 타입 경계를 확인합니다.

유스케이스Worker에 보내는 데이터Worker가 반환하는 값
CSV 집계CSV 텍스트행 수, 컬럼, 숫자 통계
이미지 처리ImageData buffer흑백 처리된 buffer
검색 인덱스문서 배열token별 문서 ID 목록
로그 분석로그 텍스트error, warning, 상위 메시지
JSON 변환중첩 JSON평평한 key-value 객체

작은 필터나 짧은 정렬까지 Worker로 보낼 필요는 없습니다. Worker 생성, 메시지 전달, 데이터 복사도 비용입니다. UI가 눈에 띄게 멈추거나, 반복 실행되거나, 데이터 크기를 예측하기 어려운 작업부터 분리하는 것이 현실적입니다.

Claude Code 지시문

처음부터 파일, 요구사항, 금지사항, 검증 방법을 적습니다. 특히 Worker 내부에서 DOM과 React state를 만지지 말라고 명확히 써야 합니다.

Add a Web Worker to an existing Vite + React + TypeScript app.

Files:
- src/workers/worker-protocol.ts
- src/workers/data.worker.ts
- src/hooks/useDataWorker.ts
- src/components/WorkerDemo.tsx

Requirements:
- Support CSV summary, image grayscale, search indexing, log summary, and JSON flattening.
- Define typed request and response messages with TypeScript union types.
- Create the Worker in a React hook and terminate it during unmount cleanup.
- Transfer the ImageData ArrayBuffer instead of copying it.
- Do not touch DOM, window, document, routing, toast UI, or React state inside the Worker.
- Add Playwright or manual verification steps.

Checks:
- npm run typecheck
- npm run test
- npm run dev and confirm the UI stays responsive

이 지시의 핵심은 “무엇을 만들지 말아야 하는지”입니다. Claude Code가 Worker 안에 document.querySelector나 UI 메시지 처리를 넣으면 바로 수정해야 합니다.

타입이 있는 메시지

프로토콜 파일을 먼저 만듭니다. payload: any로 시작하면 처음에는 빠르지만 기능이 늘어날수록 호출부가 불안정해집니다.

// src/workers/worker-protocol.ts
export type CsvSummary = {
  rows: number;
  columns: string[];
  numeric: Record<string, { count: number; average: number; max: number }>;
};

export type ImageResult = {
  width: number;
  height: number;
  buffer: ArrayBuffer;
};

export type SearchDocument = {
  id: string;
  title: string;
  body: string;
};

export type SearchIndex = {
  documents: number;
  tokens: Record<string, string[]>;
};

export type LogSummary = {
  lines: number;
  errors: number;
  warnings: number;
  topMessages: string[];
};

export type JsonFlatResult = Record<string, string | number | boolean | null>;

export type WorkerJob =
  | { type: "csv:summary"; text: string; delimiter?: "," | "\t" }
  | { type: "image:grayscale"; width: number; height: number; buffer: ArrayBuffer }
  | { type: "search:index"; documents: SearchDocument[] }
  | { type: "log:summary"; text: string }
  | { type: "json:flatten"; value: unknown };

export type WorkerResultMap = {
  "csv:summary": CsvSummary;
  "image:grayscale": ImageResult;
  "search:index": SearchIndex;
  "log:summary": LogSummary;
  "json:flatten": JsonFlatResult;
};

export type WorkerRequest = {
  id: string;
  job: WorkerJob;
};

export type WorkerResponse<T = unknown> =
  | { id: string; ok: true; result: T }
  | { id: string; ok: false; error: string };

새 작업을 추가할 때는 이 파일을 먼저 바꾸고, Worker와 테스트를 그 다음에 바꾸게 하세요. 그러면 메시지 형식이 여러 컴포넌트에 흩어지지 않습니다.

Worker 구현

아래 코드는 원시 postMessage를 사용합니다. Comlink도 좋은 선택이지만, 처음에는 메시지 흐름이 직접 보이는 코드가 리뷰하기 쉽습니다. RPC 스타일이 필요해지면 Comlink README를 참고하세요.

// src/workers/data.worker.ts
import type {
  CsvSummary,
  ImageResult,
  JsonFlatResult,
  LogSummary,
  SearchIndex,
  WorkerRequest,
  WorkerResponse,
} from "./worker-protocol";

const workerScope = self as unknown as DedicatedWorkerGlobalScope;

workerScope.onmessage = (event: MessageEvent<WorkerRequest>) => {
  const { id, job } = event.data;

  try {
    const result = runJob(job);
    const response: WorkerResponse = { id, ok: true, result };
    const transfer = resultHasBuffer(result) ? [result.buffer] : [];
    workerScope.postMessage(response, transfer);
  } catch (error) {
    const message = error instanceof Error ? error.message : "Worker failed";
    workerScope.postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
  }
};

function runJob(job: WorkerRequest["job"]) {
  switch (job.type) {
    case "csv:summary":
      return summarizeCsv(job.text, job.delimiter ?? ",");
    case "image:grayscale":
      return grayscale(job.width, job.height, job.buffer);
    case "search:index":
      return buildSearchIndex(job.documents);
    case "log:summary":
      return summarizeLogs(job.text);
    case "json:flatten":
      return flattenJson(job.value);
    default:
      return assertNever(job);
  }
}

function summarizeCsv(text: string, delimiter: "," | "\t"): CsvSummary {
  const rows = text.trim().split(/\r?\n/).filter(Boolean);
  const headers = rows.shift()?.split(delimiter).map((cell) => cell.trim()) ?? [];
  const numeric: CsvSummary["numeric"] = {};

  for (const row of rows) {
    row.split(delimiter).forEach((cell, index) => {
      const key = headers[index] ?? `column_${index + 1}`;
      const value = Number(cell);
      if (!Number.isFinite(value)) return;

      const current = numeric[key] ?? { count: 0, average: 0, max: Number.NEGATIVE_INFINITY };
      current.count += 1;
      current.average += (value - current.average) / current.count;
      current.max = Math.max(current.max, value);
      numeric[key] = current;
    });
  }

  return { rows: rows.length, columns: headers, numeric };
}

function grayscale(width: number, height: number, buffer: ArrayBuffer): ImageResult {
  const pixels = new Uint8ClampedArray(buffer);

  for (let index = 0; index < pixels.length; index += 4) {
    const gray = Math.round(pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114);
    pixels[index] = gray;
    pixels[index + 1] = gray;
    pixels[index + 2] = gray;
  }

  return { width, height, buffer: pixels.buffer };
}

function buildSearchIndex(documents: Array<{ id: string; title: string; body: string }>): SearchIndex {
  const tokens: SearchIndex["tokens"] = {};

  for (const document of documents) {
    const words = `${document.title} ${document.body}`
      .toLowerCase()
      .split(/[^a-z0-9]+/g)
      .filter((word) => word.length >= 3);

    for (const word of new Set(words)) {
      tokens[word] = [...(tokens[word] ?? []), document.id];
    }
  }

  return { documents: documents.length, tokens };
}

function summarizeLogs(text: string): LogSummary {
  const lines = text.split(/\r?\n/).filter(Boolean);
  const counts = new Map<string, number>();
  let errors = 0;
  let warnings = 0;

  for (const line of lines) {
    if (/\berror\b/i.test(line)) errors += 1;
    if (/\bwarn(ing)?\b/i.test(line)) warnings += 1;
    const normalized = line.replace(/\d{4}-\d{2}-\d{2}[^\s]*/g, "").replace(/\s+/g, " ").trim();
    counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
  }

  const topMessages = [...counts.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([message, count]) => `${count}x ${message}`);

  return { lines: lines.length, errors, warnings, topMessages };
}

function flattenJson(value: unknown, prefix = ""): JsonFlatResult {
  if (value === null || typeof value !== "object") {
    return { [prefix || "value"]: value as string | number | boolean | null };
  }

  return Object.entries(value as Record<string, unknown>).reduce<JsonFlatResult>((acc, [key, child]) => {
    const path = prefix ? `${prefix}.${key}` : key;
    return { ...acc, ...flattenJson(child, path) };
  }, {});
}

function resultHasBuffer(result: unknown): result is ImageResult {
  return typeof result === "object" && result !== null && "buffer" in result && result.buffer instanceof ArrayBuffer;
}

function assertNever(value: never): never {
  throw new Error(`Unsupported worker job: ${JSON.stringify(value)}`);
}

이미지 처리에서는 transferable이 핵심입니다. 큰 buffer를 복사하지 않고 넘길 수 있지만, 보낸 쪽의 buffer는 detached 상태가 됩니다. 전송 후 기존 ImageData를 다시 읽는 코드는 피해야 합니다.

Hook과 cleanup

React 컴포넌트가 직접 Worker를 만들면 종료를 잊기 쉽습니다. hook에서 생성, 응답 처리, 에러 처리, cleanup을 한곳에 둡니다.

// src/hooks/useDataWorker.ts
import { useCallback, useEffect, useRef } from "react";
import type { WorkerJob, WorkerRequest, WorkerResponse } from "../workers/worker-protocol";

type PendingJob = {
  resolve: (value: unknown) => void;
  reject: (error: Error) => void;
};

export function useDataWorker() {
  const workerRef = useRef<Worker | null>(null);
  const pendingRef = useRef(new Map<string, PendingJob>());
  const nextIdRef = useRef(0);

  useEffect(() => {
    const worker = new Worker(new URL("../workers/data.worker.ts", import.meta.url), {
      type: "module",
    });

    workerRef.current = worker;

    worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
      const response = event.data;
      const pending = pendingRef.current.get(response.id);
      if (!pending) return;

      pendingRef.current.delete(response.id);
      if (response.ok) {
        pending.resolve(response.result);
      } else {
        pending.reject(new Error(response.error));
      }
    };

    worker.onerror = (event) => {
      for (const pending of pendingRef.current.values()) {
        pending.reject(new Error(event.message));
      }
      pendingRef.current.clear();
    };

    return () => {
      for (const pending of pendingRef.current.values()) {
        pending.reject(new Error("Worker was terminated"));
      }
      pendingRef.current.clear();
      worker.terminate();
      workerRef.current = null;
    };
  }, []);

  const runJob = useCallback(<T,>(job: WorkerJob, transfer: Transferable[] = []) => {
    const worker = workerRef.current;
    if (!worker) return Promise.reject(new Error("Worker is not ready"));

    const id = `worker-job-${Date.now()}-${nextIdRef.current}`;
    nextIdRef.current += 1;

    return new Promise<T>((resolve, reject) => {
      pendingRef.current.set(id, {
        resolve: resolve as (value: unknown) => void,
        reject,
      });

      worker.postMessage({ id, job } satisfies WorkerRequest, transfer);
    });
  }, []);

  return { runJob };
}

cleanup은 메모리만의 문제가 아닙니다. pending promise, 오래된 이벤트 핸들러, 화면 전환 후 남은 작업, 반복 mount까지 함께 정리해야 합니다.

UI 연결

아래 UI는 작지만 검증에는 충분합니다. 버튼별로 서로 다른 작업을 Worker에 보내고 결과를 status 영역에 표시합니다.

// src/components/WorkerDemo.tsx
import { useRef, useState } from "react";
import { useDataWorker } from "../hooks/useDataWorker";
import type { CsvSummary, ImageResult, LogSummary, SearchIndex } from "../workers/worker-protocol";

const sampleCsv = `team,score,cost
alpha,91,1200
beta,84,950
gamma,96,1430`;

const sampleLogs = `2026-06-02T10:00:00Z INFO started
2026-06-02T10:01:00Z WARN cache miss
2026-06-02T10:02:00Z ERROR payment retry failed
2026-06-02T10:03:00Z ERROR payment retry failed`;

export function WorkerDemo() {
  const { runJob } = useDataWorker();
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [message, setMessage] = useState("Idle");

  async function handleCsv() {
    const summary = await runJob<CsvSummary>({ type: "csv:summary", text: sampleCsv });
    setMessage(`CSV rows: ${summary.rows}, score average: ${summary.numeric.score.average.toFixed(1)}`);
  }

  async function handleSearch() {
    const index = await runJob<SearchIndex>({
      type: "search:index",
      documents: [
        { id: "a", title: "CSV reports", body: "Aggregate revenue and cost columns" },
        { id: "b", title: "Log monitor", body: "Find warning and error messages quickly" },
      ],
    });
    setMessage(`Search index tokens: ${Object.keys(index.tokens).length}`);
  }

  async function handleLogs() {
    const summary = await runJob<LogSummary>({ type: "log:summary", text: sampleLogs });
    setMessage(`Errors: ${summary.errors}, warnings: ${summary.warnings}`);
  }

  async function handleImage() {
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    if (!canvas || !context) return;

    context.fillStyle = "#2f80ed";
    context.fillRect(0, 0, canvas.width, canvas.height);
    context.fillStyle = "#f2994a";
    context.fillRect(20, 20, 80, 80);

    const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    const buffer = imageData.data.buffer as ArrayBuffer;
    const result = await runJob<ImageResult>(
      { type: "image:grayscale", width: imageData.width, height: imageData.height, buffer },
      [buffer],
    );

    context.putImageData(
      new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height),
      0,
      0,
    );
    setMessage("Image converted in worker");
  }

  async function handleJson() {
    const result = await runJob<Record<string, unknown>>({
      type: "json:flatten",
      value: { user: { id: 1, plan: "pro" }, flags: { beta: true } },
    });
    setMessage(`Flattened keys: ${Object.keys(result).join(", ")}`);
  }

  return (
    <section>
      <div>
        <button onClick={handleCsv}>Summarize CSV</button>
        <button onClick={handleSearch}>Build search index</button>
        <button onClick={handleLogs}>Analyze logs</button>
        <button onClick={handleImage}>Process image</button>
        <button onClick={handleJson}>Flatten JSON</button>
      </div>
      <p role="status">Worker finished: {message}</p>
      <canvas ref={canvasRef} width={160} height={120} aria-label="Image processing preview" />
    </section>
  );
}

검증

테스트는 결과가 돌아오는지 확인하고, 수동 검사는 UI가 멈추지 않는지 확인합니다. 실제 데이터 크기로 확인해야 Worker의 의미가 드러납니다.

// tests/worker-demo.spec.ts
import { expect, test } from "@playwright/test";

test("heavy worker jobs finish without blocking the page", async ({ page }) => {
  await page.goto("http://localhost:5173/");

  await page.getByRole("button", { name: "Summarize CSV" }).click();
  await expect(page.getByRole("status")).toContainText("CSV rows");

  await page.getByRole("button", { name: "Build search index" }).click();
  await expect(page.getByRole("status")).toContainText("Search index tokens");

  await page.getByRole("button", { name: "Analyze logs" }).click();
  await expect(page.getByRole("status")).toContainText("Errors:");
});
Manual inspection checklist:
1. Open Chrome DevTools Performance tab.
2. Click each worker button with a large sample.
3. Confirm typing and scrolling still respond while the worker runs.
4. Navigate away from the component and confirm no new Worker remains.
5. Check that image processing transfers an ArrayBuffer and does not reuse the detached buffer.

Claude Code 리뷰 지시는 범위를 좁혀야 합니다.

Review only the Web Worker implementation.

Find:
- code that touches DOM, window, or React state inside the worker
- untyped postMessage payloads
- missing terminate cleanup
- incorrect transferable usage
- bundler paths that fail in Vite
- responsibilities that should stay on the main thread

Return file paths, line numbers, and concrete fixes.

흔한 함정

첫째, Worker에서 DOM을 만지는 실수입니다. Worker는 결과만 반환하고 React가 화면을 갱신해야 합니다. 둘째, postMessage 타입이 흐트러지는 문제입니다. union type 없이 문자열과 any만 쓰면 유지보수가 어려워집니다. 셋째, transferable을 복사로 오해하는 문제입니다. buffer는 이동되며 원본은 다시 읽으면 안 됩니다.

넷째, Worker 종료 누락입니다. terminate()와 pending promise 정리가 함께 있어야 합니다. 다섯째, Vite 번들러 경로입니다. new URL("../workers/data.worker.ts", import.meta.url) 패턴을 쓰는 편이 안전합니다. 여섯째, 책임 분리 실패입니다. Worker는 무거운 변환만 맡고 API 호출, analytics, 알림, 라우팅은 메인 스레드에 둡니다.

상담과 검증 메모

개인 프로젝트라면 이 예제를 작은 브랜치에 넣고 실제 CSV, 로그, 이미지로 테스트하세요. 팀에서는 CLAUDE.md, 리뷰 프롬프트, Playwright 검사, 성능 기록까지 규칙으로 만들어야 합니다. ClaudeCodeLab은 Claude Code 교육 및 상담을 통해 실제 저장소 기준으로 Worker 경계, cleanup, transferable 검토, 검증 리포트를 정리할 수 있습니다. 더 넓은 작업 습관은 Claude Code 생산성 팁도 참고하세요.

이 글의 구현을 검토한 결과, CSV 집계, 검색 인덱스, 로그 분석은 원시 postMessage로 충분히 명확했습니다. 이미지 처리는 transferable 이해가 가장 중요했고, JSON 변환은 재귀 경계와 입력 검증을 더 꼼꼼히 봐야 했습니다. 공개 전에는 코드 fence, 내부 링크, 공식 링크, updatedDate, cleanup 설명을 함께 확인하는 것이 안전합니다.

#Claude Code #Web Worker #parallel processing #performance #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.