# AI 꾸러미 — 화면 안 멈추는 계산 (Web Worker)
> 무거운 AI 탐색을 화면 그리는 메인 스레드 대신 Web Worker 에 맡기고 결과만 메시지로 받아, 생각하는 동안에도 화면이 멈추지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i359

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 화면 안 멈추는 계산 (Web Worker)

#### 1. 목표
체스 · 오목 · 헥스 AI의 AI 계산을 Web Worker 로 옮겨 줘 — 생각하는 동안에도 화면 · 애니메이션이 멈추지 않게. 분위기는 도는 공이 멈추는지로 비교하는 설명.

#### 2. 핵심 기술 용어
- **Web Worker** — 화면과 따로 도는 계산 일꾼 (다른 스레드)
- **postMessage / onmessage** — 일꾼에게 일을 보내고 결과를 받는 통로
- **Main-thread blocking (long task)** — 메인에서 오래 계산하면 그동안 그리기 · 입력이 멈춤

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 규칙 · 탐색 코드를 DOM 없는 순수 함수로 나눠 워커와 메인이 함께 쓴다
- 워커는 한 번 만들어 계속 쓴다 (수마다 새로 만들지 않기), 닫을 때 terminate
- 요청마다 번호를 붙여 늦게 온 옛 결과를 버린다 (그사이 판이 바뀌었을 수 있다)
- Vite 에서는 new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' }) 꼴로
- 비교 화면: 메인에서 0.6초 · 워커에서 0.7초 진짜 계산 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 워커에서 계산하는 동안 도는 공 · 「생각 중」 표시가 끊기지 않는다
- 메인에서 같은 계산을 하면 공이 멈췄다 튀는 차이가 보인다
- 결과가 오면 수를 둔다 — 그 사이 판을 바꾸면 옛 결과는 버려진다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 브라우저는 한 줄(메인 스레드)로 그리기 · 입력 · 내 코드를 차례로 한다 — 1.1초 계산하면 1.1초 동안 화면이 얼어붙는다.
- Web Worker 는 따로 도는 줄이다. 판을 postMessage 로 보내면 워커가 계산하고, 끝나면 결과만 postMessage 로 돌려준다.
- 그동안 메인은 계속 그린다 — 견본의 공은 워커 쪽에선 멈추지 않고, 메인 쪽에선 멈췄다가 한꺼번에 튄다.
- 워커는 DOM · three.js 장면에 손댈 수 없다. 규칙 · 탐색 코드만 워커로, 결과 적용은 메인에서.

## 핵심 코드 — 워커에 계산 맡기기 (번호 붙인 요청 · 옛 결과 버리기)
(발췌: demos/demosGameAI.ts I359 의 runWorker() 를 일반 꼴로 정리)
```ts
// ai.worker.ts — DOM 없는 순수 계산만
//   import { bestMove } from './search';
//   onmessage = (e) => { const { id, board, ms } = e.data; postMessage({ id, move: bestMove(board, ms) }); };

const worker = new Worker(new URL('./ai.worker.ts', import.meta.url), { type: 'module' });
let reqId = 0;
const waiting = new Map<number, (move: number) => void>();

worker.onmessage = (e: MessageEvent<{ id: number; move: number }>) => {
  const done = waiting.get(e.data.id);
  waiting.delete(e.data.id);
  if (done && e.data.id === reqId) done(e.data.move);   // 늦게 온 옛 결과는 버린다
};

function think(board: number[], ms: number): Promise<number> {
  const id = ++reqId;
  return new Promise((resolve) => {
    waiting.set(id, resolve);
    worker.postMessage({ id, board, ms });              // 판은 복사되어 간다
  });
}

// 쓰기: 화면은 계속 돈다
showThinking(true);
const move = await think(board, 700);
showThinking(false);
play(move);
// 게임을 닫을 때: worker.terminate();
```

## 흔한 실수 · 확인 목록
- [ ] **탐색 코드가 DOM · three.js 를 import 하면 워커가 안 뜬다** — 규칙 · 탐색을 순수 모듈로 나눠 워커에서는 그것만 import.
- [ ] **수마다 new Worker 를 만들면 첫 생각이 매번 느리다** — 게임을 열 때 한 번 만들어 두고 계속 쓴다, 닫을 때 terminate.
- [ ] **판을 바꾼 뒤 옛 요청 결과가 와서 엉뚱한 수를 둔다** — 요청 번호를 붙여 지금 번호가 아니면 버린다.
- [ ] **Blob URL 로 만든 워커를 안 치우면 메모리가 남는다** — 견본처럼 URL.createObjectURL 로 만들었으면 닫을 때 terminate + revokeObjectURL.

## 완성 기준 체크리스트
- [ ] 워커에서 계산하는 동안 도는 공 · 「생각 중」 표시가 끊기지 않는다
- [ ] 메인에서 같은 계산을 하면 공이 멈췄다 튀는 차이가 보인다
- [ ] 결과가 오면 수를 둔다 — 그 사이 판을 바꾸면 옛 결과는 버려진다

## 이 기술 정보
- id: `i359` · 분류: 게임 시스템 · AI › 게임 AI · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 워커 하나 만들기 수 ms, 메시지는 판 크기만큼 복사. 계산 비용은 같고 화면 멈춤만 없어진다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i359
- 쓰면 좋을 때: 0.1초 넘게 걸리는 탐색 · 풀이 · 판 만들기 / 생각하는 척 연출(0.4~0.7초) 중에도 애니메이션이 돌아야 할 때
- 쓰지 말 때: 몇 ms 면 끝나는 계산 — 메시지 왕복 비용만 든다 / DOM · 캔버스 그리기 자체 — 워커에선 OffscreenCanvas 가 아니면 못 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### I359 — `src/demos/demosGameAI.ts:3848`
```ts
const I359: DemoMap = {
  i359: {
    kind: '2d',
    caption: '무거운 AI 계산을 화면 그리는 줄(메인)에서 하면 그동안 공이 멈추고(빨강), Web Worker 에 맡기면 공은 계속 돌고 결과만 나중에 받아요',
    make() {
      const st = { speed: 1 };
      let T = 0;
      const P = 4;
      const T0 = 1.0;
      const T1 = 2.1;
      let angL = 0;
      let angR = 0;
      const gaps: number[] = [];
      let worker: Worker | null = null;
      let wUrl = '';
      let realMain = 0;
      let wState: '' | 'run' | 'done' = '';
      let wMsg = '';
      let wStart = 0;
      let pendingMain = false;
      const busy = (ms: number): number => {
        const t0 = performance.now();
        let x = 0;
        while (performance.now() - t0 < ms) for (let i = 0; i < 2000; i++) x += Math.sqrt(i * x + 1) % 7;
        return x;
      };
      const runWorker = (): void => {
        if (!worker) {
          const src = 'onmessage=function(e){var t0=performance.now(),x=0;while(performance.now()-t0<e.data){for(var i=0;i<2000;i++)x+=Math.sqrt(i*x+1)%7;}postMessage(performance.now()-t0);}';
          wUrl = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
          worker = new Worker(wUrl);
          worker.onmessage = (e: MessageEvent<number>) => {
            wState = 'done';
            wMsg = `워커가 ${Math.round(e.data)}ms 계산 → 결과 받음 (화면은 안 멈춤)`;
          };
        }
        wState = 'run';
        wStart = performance.now();
        wMsg = '워커 계산 중… 공은 계속 돌아요';
        worker.postMessage(700);
      };
      const spinner = (g: G, cx: number, cy: number, r: number, ang: number, col: string, frozen: boolean): void => {
        g.strokeStyle = 'rgba(255,255,255,0.08)';
        g.lineWidth = r * 0.16;
        g.beginPath();
        g.arc(cx, cy, r, 0, TAU);
        g.stroke();
        for (let k = 0; k < 14; k++) {
          const a = ang - k * 0.13;
          g.globalAlpha = (1 - k / 14) * 0.6;
          g.fillStyle = col;
          g.beginPath();
          g.arc(cx + Math.cos(a) * r, cy + Math.sin(a) * r, r * 0.16 * (1 - k / 20), 0, TAU);
          g.fill();
        }
        g.globalAlpha = 1;
        glow(g, cx + Math.cos(ang) * r, cy + Math.sin(ang) * r, r * 0.6, col, 0.5);
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(cx + Math.cos(ang) * r, cy + Math.sin(ang) * r, r * 0.17, 0, TAU);
        g.fill();
        if (frozen) {
          txt(g, '멈춤!', cx, cy, r * 0.5, C.red, 'center', 900);
        }
      };
      return {
        draw(g, w, h, _t, dt) {
          if (pendingMain) {
            pendingMain = false;
            const t0 = performance.now();
            busy(600);
            realMain = performance.now() - t0;
          }
          reset(g);
          gaps.push(dt * 1000);
          if (gaps.length > 150) gaps.shift();
          const d = Math.min(dt, 0.1) * st.speed;
          T = (T + d) % P;
          const blocked = T > T0 && T < T1;
          if (!blocked) angL += d * 5;
          if (T >= T1 && T - d < T1) angL += (T1 - T0) * 5; // 늦게 한꺼번에 튐
          angR += d * 5;
          const u = scaleOf(w, h);
          bg(g, w, h);
          header(g, w, u, '화면 안 멈추는 계산', C.text, 'AI 생각 1.1초', C.sub);
          const pw = w / 2;
          const ph = h * 0.56;
          const lanes = (side: number): void => {
            const px = side * pw;
            const cx = px + pw * 0.24;
            const cy = 22 * u + ph * 0.45;
            const r = Math.min(pw * 0.15, ph * 0.3);
            spinner(g, cx, cy, r, side ? angR : angL, side ? C.green : C.o, !side && blocked);
            txt(g, side ? 'Web Worker 에서' : '메인 스레드에서', px + pw * 0.24, 22 * u + ph * 0.92, 7.5 * u, side ? C.green : C.red, 'center', 900);
            // 시간 줄 (최근 3초)
            const lx = px + pw * 0.46;
            const lw = pw * 0.5;
            const ly = 30 * u;
            const rowH = Math.min(14 * u, ph * 0.22);
            const W3 = 3;
            const X = (tt: number): number => lx + ((tt - (T - W3)) / W3) * lw;
            const names = side ? ['메인', '워커'] : ['메인'];
            names.forEach((nm, li) => {
              const y = ly + li * (rowH + 8 * u);
              txt(g, nm, lx - 3 * u, y + rowH / 2, 6 * u, C.sub, 'right', 700);
              g.fillStyle = 'rgba(255,255,255,0.04)';
              g.fillRect(lx, y, lw, rowH);
              g.save();
              g.beginPath();
              g.rect(lx, y, lw, rowH);
              g.clip();
              for (const off of [-P, 0]) {
                const a0 = T0 + off;
                const a1 = T1 + off;
                if (li === 0 && !side) {
                  g.fillStyle = 'rgba(255,93,108,0.85)';
                  g.fillRect(X(a0), y, X(Math.min(a1, T)) - X(a0), rowH);
                }
                if (li === 1) {
                  g.fillStyle = 'rgba(184,146,255,0.85)';
                  g.fillRect(X(a0), y, X(Math.min(a1, T)) - X(a0), rowH);
                }
              }
              if (li === 0) {
                // 그리기 틱
                g.fillStyle = C.green;
                for (let k = 0; k < 36; k++) {
                  const tt = T - k / 12;
                  const tm = ((tt % P) + P) % P;
                  if (!side && tm > T0 && tm < T1) continue;
                  g.fillRect(X(tt) - 0.5 * u, y + rowH * 0.2, 1 * u, rowH * 0.6);
                }
              }
              g.restore();
            });
            if (side) {
              for (const off of [-P, 0]) {
                const a0 = T0 + off;
                const a1 = T1 + off;
                const y0 = ly + rowH;
                const y1 = ly + rowH + 8 * u;
                if (X(a0) > lx && a0 <= T) arrow(g, X(a0), y0, X(a0), y1, '#fff', 0.9 * u, 3 * u);
                if (X(a1) > lx && a1 <= T) arrow(g, X(a1), y1, X(a1), y0, C.gold, 0.9 * u, 3 * u);
              }
              txt(g, '↓ 일 보내기 · ↑ 결과 받기', lx, ly + 2 * rowH + 15 * u, 5.8 * u, C.sub, 'left', 700);
            } else txt(g, blocked ? '그리기 못 함 — 화면 얼어붙음' : '초록 = 화면 그린 순간', lx, ly + rowH + 9 * u, 5.8 * u, blocked ? C.red : C.sub, 'left', 700);
          };
          lanes(0);
          lanes(1);
          g.strokeStyle = 'rgba(255,255,255,0.08)';
          g.beginPath();
          g.moveTo(pw, 22 * u);
          g.lineTo(pw, 22 * u + ph);
          g.stroke();
          // 아래: 이 화면의 진짜 프레임 간격
          const gy = 26 * u + ph;
          const gh = h - gy - 8 * u;
          const gx = 10 * u;
          const gw = w - 20 * u;
          rr(g, gx - 2 * u, gy - 2 * u, gw + 4 * u, gh + 4 * u, 4 * u);
          g.fillStyle = 'rgba(0,0,0,0.25)';
          g.fill();
          const Y = (ms: number): number => gy + gh - (Math.min(ms, 800) / 800) * gh;
          const Ylog = (ms: number): number => gy + gh - (Math.log(1 + Math.min(ms, 1000)) / Math.log(1001)) * gh;
          g.strokeStyle = 'rgba(111,227,160,0.35)';
          g.setLineDash([2 * u, 2 * u]);
          g.lineWidth = 0.8 * u;
          g.beginPath();
          g.moveTo(gx, Ylog(16.7));
          g.lineTo(gx + gw, Ylog(16.7));
          g.stroke();
          g.setLineDash([]);
          g.strokeStyle = C.o;
          g.lineWidth = 1.2 * u;
          g.beginPath();
          gaps.forEach((ms, i) => {
            const x = gx + (i / 149) * gw;
            if (i) g.lineTo(x, Ylog(ms));
            else g.moveTo(x, Ylog(ms));
          });
          g.stroke();
          void Y;
          const maxGap = Math.max(...gaps);
          txt(g, '이 화면의 진짜 프레임 간격', gx + 3 * u, gy + 5 * u, 5.8 * u, C.sub, 'left', 700);
          txt(g, `가장 긴 틈 ${Math.round(maxGap)}ms`, gx + gw - 3 * u, gy + 5 * u, 5.8 * u, maxGap > 100 ? C.red : C.green, 'right', 800);
          if (realMain) txt(g, `방금 메인에서 ${Math.round(realMain)}ms 계산 — 그동안 페이지 전체가 멈췄어요`, gx + gw / 2, gy + gh - 5 * u, 6 * u, C.red, 'center', 800);
          else if (wState) {
            const el = wState === 'run' ? ` (${Math.round(performance.now() - wStart)}ms)` : '';
            txt(g, wMsg + el, gx + gw / 2, gy + gh - 5 * u, 6 * u, wState === 'run' ? C.violet : C.green, 'center', 800);
          }
        },
        controls: [
          speedCtl(st),
          { type: 'button', label: '진짜로: 메인에서 0.6초 계산', on: () => { pendingMain = true; wState = ''; } },
          { type: 'button', label: '진짜로: 워커에서 0.7초 계산', on: () => { realMain = 0; runWorker(); } },
        ] as Control[],
        dispose() {
          if (worker) worker.terminate();
          if (wUrl) URL.revokeObjectURL(wUrl);
          worker = null;
        },
      };
    },
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [반복 심화 + 시간 예산](https://ai-techstudio.web.app/ai/t/i343.md) `i343` · [몬테카를로 트리 탐색 (UCT)](https://ai-techstudio.web.app/ai/t/i347.md) `i347`
- 참고 문서: [MDN — Using Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers)
