Skip to content

Chore: Web Worker 개념과 리액트에서 멀티스레딩 구현하기 #1

Description

@elian118

웹 워커는 **"진짜 멀티스레딩(Real Multi-threading)"**이다.

많은 사람들이 "자바스크립트는 싱글 스레드다"라고 알고 있는데, 정확히는 **"자바스크립트 엔진(V8 등)의 메인 이벤트 루프가 싱글 스레드"**다. 웹 워커는 브라우저 수준에서 이 제약을 넘어서게 해준다.


1. 싱글 스레드 vs 웹 워커 (OS 관점)

■ 일반적인 자바스크립트 (Single Thread)

  • 우리가 작성하는 일반적인 코드는 Main Thread 하나에서 실행
    • UI 렌더링, 이벤트 리스너(클릭 등), HTTP 요청 처리가 모두 이 스레드 하나에서 줄을 서서 대기
    • 만약 for문을 10억 번 돌리면, 그동안 브라우저는 클릭도 못 받고 화면도 멈춤(Blocking).

■ 웹 워커를 쓴 경우 (Multi-threading)

  • 웹 워커를 생성하면 브라우저는 OS(운영체제)에게 **"새로운 스레드를 하나 더 할당해줘"**라고 요청
    • Main Thread: 여전히 UI를 그리고 사용자의 클릭 이벤트 추적 (60fps 유지)
    • Worker Thread: 메인 스레드와 완전히 분리된 별도의 CPU 코어 자원을 점유하며 무거운 계산(거리 계산 등)을 수행
      • 이 둘은 서로의 메모리를 공유하지 않고, postMessage라는 통로를 통해 데이터를 주고받을 뿐

2. "유사"가 아닌 "진짜"인 이유

웹 워커가 진짜 멀티스레딩인 이유는 병렬성(Parallelism) 때문

  • 동시 실행: 메인 스레드에서 애니메이션이 돌아가는 "동시에", 워커 스레드에서 수학 계산이 물리적으로 다른 CPU 코어에서 실행
  • 독립적 컨텍스트: 워커는 자신만의 스택과 힙 메모리를 가진다. 메인 스레드가 에러로 멈춰도 워커는 계속 돌아갈 수 있고, 반대의 경우도 마찬가지.
  • OS 스레드 매핑: 최신 브라우저들은 웹 워커를 실제 운영체제의 시스템 스레드(System Thread)에 매핑하여 처리

3. 그런데 왜 자바스크립트를 싱글 스레드라고 부를까?

  • 자바스크립트 언어 자체의 설계 철학 때문
  • 공유 메모리 금지: 멀티스레딩의 가장 큰 골칫덩이는 여러 스레드가 동시에 같은 변수를 수정하려다 발생하는 'Race Condition'.
    • 자바스크립트는 이를 원천 차단하기 위해 워커가 메인 스레드의 DOM에 직접 접근하거나 변수를 직접 수정하는 것을 금지
  • 메시지 패싱: 데이터를 보낼 때도 원본을 주는 게 아니라 복사본을 만들어 전송(Structured Clone). "안전한 멀티스레딩"

4. 요약: 게임 개발에서의 체감

  • 웹 워커 미사용: 오답 클릭 시 거리 계산($N$번) 때문에 아주 찰나의 순간(0.01~0.1초) 화면이 툭 끊김 → 고득점을 노리는 사용자에게 불쾌한 경험
  • 웹 워커 사용: 클릭 즉시 반응. 계산은 백그라운드에서 조용히 처리 → 사용자는 멀티스레딩의 존재를 모르지만 부드러운 게임 플레이를 즐김

결국 웹 워커는 자바스크립트라는 언어의 싱글 스레드 특성을 유지하면서도, 하드웨어의 멀티코어 성능을 뽑아 쓸 수 있게 해주는 브라우저의 마법 같은 존재


예제

1. Worker 로직 작성 (public/workers/distance.worker.js)

먼저 복잡한 거리 계산을 담당할 워커 파일 생성. 이 파일은 메인 스레드와 별개로 작동

// public/workers/distance.worker.js
self.onmessage = (e) => {
  const { clickCoord, remainingTargets } = e.data;

  // 모든 남은 객체와의 거리 계산 (피타고라스 정리)
  const distances = remainingTargets.map((target) => {
    const dx = target.x - clickCoord.x;
    const dy = target.y - clickCoord.y;
    const distance = Math.sqrt(dx * dx + dy * dy);
    
    return {
      target_id: target.id,
      distance: parseFloat(distance.toFixed(2)), // 소수점 2자리까지
    };
  });

  // 계산 완료 후 메인 스레드로 결과 전송
  self.postMessage(distances);
};

2. Next.js 클라이언트 컴포넌트 (components/GameCanvas.tsx)

워커를 생성하고 클릭 이벤트를 처리하는 부분

'use client';

import { useEffect, useRef, useState } from 'react';

export default function GameCanvas() {
  const workerRef = useRef<Worker>();
  const [logs, setLogs] = useState<any[]>([]);
  const [remainingTargets, setRemainingTargets] = useState([
    { id: 'tree_01', x: 100, y: 200 },
    { id: 'bird_02', x: 450, y: 150 },
    // ... 더 많은 객체들
  ]);

  useEffect(() => {
    // 1. 워커 초기화
    workerRef.current = new Worker(new URL('../public/workers/distance.worker.js', import.meta.url));

    // 2. 워커로부터 계산된 데이터를 받았을 때의 처리
    workerRef.current.onmessage = (e: MessageEvent) => {
      const distances = e.data;
      
      // 기존 로그 리스트의 마지막 항목에 거리 데이터 업데이트 (함수형 업데이트 사용)
      setLogs((prev) => {
        const newLogs = [...prev];
        if (newLogs.length > 0) {
          newLogs[newLogs.length - 1].distances_to_remaining_targets = distances;
        }
        return newLogs;
      });
      
      console.log('거리 계산 완료:', distances);
    };

    return () => workerRef.current?.terminate(); // 컴포넌트 언마운트 시 워커 종료
  }, []);

  const handleClick = (e: React.MouseEvent) => {
    const x = e.clientX;
    const y = e.clientY;
    const playTime = 50000; // 예시 실행 시간

    // 새로운 로그 객체 생성 (거리는 아직 비어있음)
    const newLog = {
      user_id: "user_123",
      game_id: "map_01",
      event_id: crypto.randomUUID(),
      click_coordinate: { x, y },
      play_time_ms: playTime,
      result_type: "MISS", // 실제 로직에선 정답 체크 함수 호출
      distances_to_remaining_targets: [] 
    };

    setLogs((prev) => [...prev, newLog]);

    // 3. 워커에게 무거운 계산 작업 위임
    workerRef.current?.postMessage({
      clickCoord: { x, y },
      remainingTargets: remainingTargets
    });
  };

  const handleGameEnd = async () => {
    // 게임 종료 시 FastAPI로 로그 전송
    try {
      const response = await fetch('https://your-fastapi-server.com/logs', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ logs }),
      });
      if (response.ok) alert('로그 전송 완료!');
    } catch (error) {
      console.error('전송 실패', error);
    }
  };

  return (
    <div 
      onClick={handleClick} 
      style={{ width: '100vw', height: '100vh', background: '#eee' }}
    >
      <h1>게임 화면 (클릭해보세요)</h1>
      <button onClick={handleGameEnd}>게임 종료  데이터 전송</button>
    </div>
  );
}

💡 코드 포인트

  • Non-blocking UI: handleClick 함수 내에서 setLogs로 클릭 지점은 즉시 기록하지만, 실제 Math.sqrt가 반복되는 루프 연산은 workerRef.current.postMessage를 통해 백그라운드에서 진행. 따라서 객체가 1000개여도 화면은 멈추지 않음

  • crypto.randomUUID(): 각 로그를 구분하기 위한 고유 ID를 생성

  • FastAPI 연동: handleGameEnd 함수에서 수집된 logs 배열을 한꺼번에 JSON으로 직렬화하여 서버로 전송

  • terminate(): 워커는 리소스를 사용하므로 페이지를 벗어날 때 반드시 종료

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions