# AI 꾸러미 — 무거운 표 미리 · 나눠 만들기 — Time slicing (chunked work)
> 풀이표 · 해법 표를 한 번에 만들어 화면이 0.4초 멈추던 것을, 프레임마다 10ms 씩 나눠 만들어 60fps 를 그대로 지키며 진행 막대를 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i387

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

## 주문서

### 만들어 줘: 무거운 표 미리 · 나눠 만들기 — Time slicing (chunked work)

#### 1. 목표
게임 해법 표 · 풀이표를 만드는 일을 프레임마다 10ms 씩 나눠 줘 — 한 조각이 끝나면 다음 프레임에 이어 하고, 그동안 화면은 60fps 로 움직이며 「진행 막대 + 도는 표시」 진행 표시를 보여 주기.

#### 2. 핵심 기술 용어
- **Time slicing (chunked work)** — 일을 잘게 나눠 프레임마다 조금씩
- **Frame budget** — 한 프레임에 쓸 시간 예산 (10ms)
- **Generator function (yield)** — 멈췄다 이어 하는 함수 — 나눠 하기에 알맞다
- **Web Worker** — 다른 스레드에서 미리 만들기

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

#### 4. 조건
- 한 조각 예산은 10ms (performance.now 로 재기) — 고정 횟수가 아니라 시간으로
- 조각 사이에는 requestAnimationFrame 으로 화면에 차례를 넘긴다
- 진행률을 보여 주고, 다 만들기 전에 표를 쓰지 않게 (Promise 로 끝을 알림)
- 중간에 화면을 닫으면 멈출 수 있게 (취소 표시)
- 전/후 가장 긴 프레임 ms 를 재서 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 「한 번에」 줄은 일하는 동안 도는 표시가 멈추고 프레임 그래프에 400ms 막대 하나 + 빈칸이 생긴다
- 「나눠서」 줄은 프레임마다 +10ms 씩, 도는 표시가 계속 돌고 표 칸이 차례로 채워진다
- 「진짜로 나눠서」를 누르면 가장 긴 프레임이 수십 ms 안으로 재진다

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

## 원리
- 한 프레임은 16.7ms — 그 안에 그리기까지 끝나야 60fps 다. 400ms 짜리 일은 화면을 24프레임 동안 멈춘다.
- 일을 작은 단위(칸 하나 · 상태 하나)로 쪼개 반복하고, 10ms 가 지나면 멈춘 뒤 다음 requestAnimationFrame 에 이어 한다.
- 전체 일은 같다 — 400ms 일이면 40프레임에 걸쳐 끝난다. 대신 화면은 내내 살아 있다.
- 더 무거우면 Web Worker 에서 미리 만들고 결과만 받는다.

## 핵심 코드 — 10ms 예산으로 나눠 만드는 도우미 (제너레이터)
(발췌: 새로 씀 (demos/demosPerf.ts demo387 runChunks — 10ms 조각마다 requestAnimationFrame 과 같은 방식))
```ts
/** 제너레이터 일을 프레임마다 budget ms 씩 — 끝나면 결과로 풀린다 */
function runSliced<T>(work: Generator<number, T>, onProgress: (p: number) => void, budget = 10): Promise<T> {
  return new Promise((resolve) => {
    const tick = (): void => {
      const t0 = performance.now();
      let r = work.next();
      while (!r.done && performance.now() - t0 < budget) r = work.next();
      if (r.done) resolve(r.value);
      else {
        onProgress(r.value); // yield 한 값 = 진행률 0~1
        requestAnimationFrame(tick); // 화면에 차례를 넘기고 다음 프레임에 이어서
      }
    };
    requestAnimationFrame(tick);
  });
}

// 쓰기 — 상태 하나 계산할 때마다 yield
function* buildTable(n: number): Generator<number, Float32Array> {
  const table = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    table[i] = solveState(i); // 무거운 계산 하나
    yield i / n;
  }
  return table;
}

const table = await runSliced(buildTable(50000), (p) => (bar.style.width = (p * 100).toFixed(0) + '%'));
```

## 흔한 실수 · 확인 목록
- [ ] **「100개씩」처럼 고정 횟수로 나누면 느린 폰에서는 조각이 다시 길어진다** — 횟수가 아니라 시간(10ms)으로 끊는다 — 기기 빠르기에 맞춰 조각 수가 저절로 바뀐다.
- [ ] **조각 사이에 setTimeout(0) 만 쓰면 그리기 차례가 안 올 수 있다** — requestAnimationFrame 으로 넘겨야 그 사이 화면이 한 번 그려진다.
- [ ] **표가 다 만들어지기 전에 AI 가 표를 읽으면 엉뚱한 수를 둔다** — Promise 가 풀린 뒤에만 쓰고, 그 전엔 「만드는 중」 표시.

## 완성 기준 체크리스트
- [ ] 「한 번에」 줄은 일하는 동안 도는 표시가 멈추고 프레임 그래프에 400ms 막대 하나 + 빈칸이 생긴다
- [ ] 「나눠서」 줄은 프레임마다 +10ms 씩, 도는 표시가 계속 돌고 표 칸이 차례로 채워진다
- [ ] 「진짜로 나눠서」를 누르면 가장 긴 프레임이 수십 ms 안으로 재진다

## 이 기술 정보
- id: `i387` · 분류: 게임 시스템 · AI › 속도 기법 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 전체 일은 그대로, 가장 긴 프레임만 일 크기 → 10ms + 그리기로 준다. 조각 사이 이어 하기 비용은 거의 없다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i387
- 쓰면 좋을 때: 게임을 열 때 · 판을 바꿀 때 표를 만드느라 화면이 멈출 때 / 진행 막대를 보여 줄 수 있는 준비 시간
- 쓰지 말 때: 10ms 안에 끝나는 일 — 나눌 필요 없다 / 사람 차례마다 바로 답이 필요한 짧은 AI 계산 — 대신 미리 생각하기(i389) · 워커

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo387 — `src/demos/demosPerf.ts:805`
```ts
function demo387() {
  let total = 400;
  const CYC = 3.2;
  const START = 0.45;
  // 실측
  const frames: { t: number; d: number }[] = [];
  let lastNow = 0;
  let real = '';
  let chunkLeft = 0;
  let chunkTotal = 0;
  let worst = 0;
  let measuring = false;
  const lane = (g: G, y: number, kind: Kind, t: number): void => {
    const bad = kind === 'bad';
    panel(g, 6, y, 268, 66, kind);
    tag(g, 11, y + 8, bad ? '끔' : '켬', kind);
    txt(g, bad ? `풀이표를 한 번에 (${total}ms 쉬지 않고)` : '10ms 조각으로 나눠 프레임 사이 쉬는 틈에', 25, y + 8.3, 6.4, C.text, 'left', 700);
    const T = total / 1000;
    const nFrames = Math.ceil(total / 10);
    const chunkEnd = START + nFrames / 60;
    // 진행도
    let prog = 0;
    if (bad) prog = t >= START + T ? 1 : 0;
    else prog = clamp01((t - START) / (chunkEnd - START));
    // 화면 시계 (멈춘 동안 서 있음)
    let st = t;
    if (bad && t > START) st = t < START + T ? START : t - T;
    const frozen = bad && t > START && t < START + T;
    // 살아 있는 표시
    rr(g, 12, y + 16, 50, 44, 5);
    g.fillStyle = '#0a0e1c';
    g.fill();
    spinner(g, 37, y + 33, 9, st * 7, frozen ? C.red : C.cyan);
    txt(g, frozen ? '멈춤!' : '움직임 OK', 37, y + 53, 5.6, frozen ? C.red : '#a9f2d2', 'center', 800);
    // 표 칸 (채워지는 격자)
    const gx = 70;
    const gy = y + 17;
    const cols = 24;
    const rows = 4;
    const cw = 4.6;
    const filled = Math.floor(prog * cols * rows);
    for (let i = 0; i < cols * rows; i++) {
      const cx = gx + (i % cols) * cw;
      const cy = gy + Math.floor(i / cols) * cw;
      g.fillStyle = i < filled ? (bad ? '#7c8bd6' : C.green) : '#232b4c';
      g.fillRect(cx, cy, cw - 0.8, cw - 0.8);
    }
    txt(g, `풀이표 ${Math.round(prog * 100)}%`, gx + cols * cw + 4, gy + 9, 6, C.text, 'left', 800);
    // 프레임 그래프
    const fx = 70;
    const fw = 196;
    const slot = fw / (CYC * 30);
    const ms: number[] = [];
    const n = Math.floor(t * 30);
    for (let i = 0; i <= n; i++) {
      const ti = i / 30;
      let v = 14 + ((i * 29) % 4);
      if (bad && ti >= START && ti < START + 1 / 30) v = total;
      else if (bad && ti > START && ti < START + T) continue;
      if (!bad && ti >= START && ti < chunkEnd) v = 16.7 + 3;
      ms.push(v);
    }
    // 멈춘 동안은 막대가 없다(프레임이 안 나옴) → 큰 막대 하나 뒤 빈칸
    const msFull: number[] = [];
    let k = 0;
    for (let i = 0; i <= n; i++) {
      const ti = i / 30;
      if (bad && ti > START + 1 / 30 && ti < START + T) { msFull.push(0); continue; }
      msFull.push(ms[k++] ?? 0);
    }
    frameBars(g, fx, y + 38, fw, 15, msFull, 120, slot);
    if (bad && t >= START) {
      g.fillStyle = 'rgba(255,93,108,0.16)';
      g.fillRect(fx + START * 30 * slot, y + 38, T * 30 * slot, 15);
      txt(g, `${total}ms 동안 프레임 0장`, fx + (START + T) * 30 * slot + 3, y + 43, 5.2, C.red, 'left', 800);
    }
    if (!bad && t >= START) txt(g, '프레임마다 +10ms — 60fps 그대로', fx + chunkEnd * 30 * slot + 3, y + 43, 5.2, '#a9f2d2', 'left', 700);
    playhead(g, fx + t * 30 * slot, y + 37, y + 53);
    txt(g, '프레임 시간', fx, y + 59, 5, C.dim, 'left', 600);
  };
  const runChunks = (): void => {
    if (chunkLeft <= 0) { measuring = false; real = `실측 — 나눠서: 가장 긴 프레임 ${worst.toFixed(0)}ms (일은 똑같이 ${chunkTotal}ms)`; return; }
    busy(Math.min(10, chunkLeft));
    chunkLeft -= 10;
    requestAnimationFrame(runChunks);
  };
  return {
    draw(g: G, w: number, h: number, tt: number) {
      const tn = now();
      if (lastNow) {
        const d = tn - lastNow;
        frames.push({ t: tn, d });
        if (measuring) worst = Math.max(worst, d);
      }
      lastNow = tn;
      while (frames.length && frames[0]!.t < tn - 4000) frames.shift();
      const big = isBig(w);
      const t = tt % CYC;
      view(g, w, h, big ? 218 : 175);
      txt(g, '해법 표 만들기 — 같은 일, 다른 나눔', 8, 10, 7.2, C.text, 'left', 800);
      pill(g, '피그 · 피카리아 · 고누', 272, 10, 6, '#2b3560', C.cyan, 'right');
      lane(g, 21, 'bad', t);
      lane(g, 103, 'good', t);
      if (big) {
        panel(g, 6, 174, 268, 40, 'none');
        txt(g, '이 브라우저 실측 (최근 4초 프레임)', 12, 181, 6, C.text, 'left', 800);
        txt(g, real || '단추로 진짜 멈춤 / 나눠서를 눌러 보세요', 268, 181, 5.6, real ? '#bdf7df' : C.dim, 'right', 700);
        const ms = frames.map((f) => f.d);
        const fw = 256;
        const slot = fw / 240;
        frameBars(g, 12, 187, fw, 23, ms.slice(-240), 450, slot);
      }
      g.restore();
    },
    controls: [
      { type: 'range', label: '표 만드는 일 (ms)', min: 100, max: 500, step: 50, value: total, on: (v) => { total = v; } },
      { type: 'button', label: '진짜로 한 번에 (0.4초 멈춤)', on: () => { if (measuring) return; const t0 = now(); busy(400); real = `실측 — 한 번에: 화면이 ${(now() - t0).toFixed(0)}ms 멈춤`; } },
      { type: 'button', label: '진짜로 나눠서 (10ms 조각)', on: () => { if (measuring) return; measuring = true; worst = 0; chunkTotal = 400; chunkLeft = 400; requestAnimationFrame(runChunks); } },
    ] as Control[],
  };
}
```

### i387 견본 항목 — `src/demos/demosPerf.ts:1614`
```ts
  i387: { kind: '2d', caption: '풀이표 한 번에 = 0.4초 프레임 0장(멈춤) vs 10ms 조각 = 60fps 그대로 진행', make: demo387 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [긴 작업 찾기 (longtask · 프레임 그래프)](https://ai-techstudio.web.app/ai/t/i394.md) `i394`
- 다음에 해 볼 기술: [미리 생각하기 (상대가 두는 동안)](https://ai-techstudio.web.app/ai/t/i389.md) `i389` · [느린 화면 슬로모션 없애기 (시간 조각 나누기)](https://ai-techstudio.web.app/ai/t/i384.md) `i384`
- 참고 문서: [MDN — Web Workers API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)
