# AI 꾸러미 — 긴 작업 찾기 (longtask · 프레임 그래프) — Long Tasks API (PerformanceObserver "longtask")
> PerformanceObserver 로 50ms 넘는 긴 작업을 잡아 프레임 시간 그래프 아래 빨간 막대로 그려, 무엇이 화면을 멈추는지 눈으로 찾게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i394

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

## 주문서

### 만들어 줘: 긴 작업 찾기 (longtask · 프레임 그래프) — Long Tasks API (PerformanceObserver "longtask")

#### 1. 목표
개발 중인 게임 화면 구석에 긴 작업 찾기 도구를 붙여 줘 — 프레임마다 걸린 ms 를 막대 그래프(16.7 · 50 · 200ms 기준선)로, PerformanceObserver 로 잡은 50ms 넘는 긴 작업을 그 아래 빨간 칸으로. 최근 5초, 어두운 개발자 그래프.

#### 2. 핵심 기술 용어
- **Long Tasks API (PerformanceObserver "longtask")** — 50ms 넘는 메인 스레드 작업 알림
- **Frame time graph** — 프레임마다 걸린 ms 막대
- **Jank / hitch** — 화면 멈칫
- **performance.now** — 정밀 시계

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

#### 4. 조건
- PerformanceObserver.supportedEntryTypes 에 longtask 가 있을 때만 observe — 없으면 프레임 그래프만
- 기록은 최근 5초만 남기고 지우기
- 기준선 16.7 · 50 · 200ms 와 50ms 넘는 막대 위에 ms 숫자
- 화면을 닫을 때 observer.disconnect()

#### 5. 완성 기준 (이게 보이면 성공)
- 「0.3초 멈추기」를 누르면 프레임 그래프에 300ms 막대가 솟고, 아래에 빨간 긴 작업 칸이 같은 자리에 생긴다
- 「40ms 열 번」은 막대가 여럿 솟지만 긴 작업 칸은 안 생긴다
- longtask 를 모르는 브라우저에서도 오류 없이 프레임 그래프는 그려진다

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

## 원리
- 60fps 면 프레임마다 16.7ms. requestAnimationFrame 사이 시간을 재서 막대로 그리면 멈춘 프레임이 높이 솟는다.
- 브라우저는 메인 스레드 일이 50ms 를 넘으면 「longtask」 기록을 남긴다 — PerformanceObserver 로 시작 시각 · 길이를 받는다.
- 프레임 막대와 긴 작업 칸을 같은 시간 축(최근 5초)에 그리면 어느 일이 멈춤을 만들었는지 맞대 볼 수 있다.
- 40ms 일 열 번은 긴 작업이 아니다 — 하나하나가 50ms 를 안 넘기 때문 (프레임은 그래도 떨어진다).

## 핵심 코드 — 긴 작업 잡기 + 프레임 시간 기록
(발췌: demos/demosPerf.ts demo394() 를 정리)
```ts
const WIN = 5000; // 최근 5초
const frames: { t: number; d: number }[] = [];
const longs: { s: number; d: number }[] = [];
let lastNow = 0;
let obs: PerformanceObserver | null = null;

try {
  if ((PerformanceObserver.supportedEntryTypes ?? []).includes('longtask')) {
    obs = new PerformanceObserver((list) => {
      for (const e of list.getEntries()) longs.push({ s: e.startTime, d: e.duration }); // 50ms 넘는 일
    });
    obs.observe({ type: 'longtask', buffered: false });
  }
} catch { /* 지원 안 함 — 프레임 그래프만 */ }

function sample(): void {
  const tn = performance.now();
  if (lastNow) frames.push({ t: tn, d: tn - lastNow }); // 이번 프레임에 걸린 ms
  lastNow = tn;
  while (frames.length && frames[0].t < tn - WIN) frames.shift();
  while (longs.length && longs[0].s + longs[0].d < tn - WIN) longs.shift();
  // 그리기: x = (t − (tn − WIN)) / WIN, 막대 높이 = d (16.7 · 50 · 200ms 기준선)
  requestAnimationFrame(sample);
}
requestAnimationFrame(sample);

// 닫을 때: obs?.disconnect();
```

## 흔한 실수 · 확인 목록
- [ ] **사파리에서 observe({ type: 'longtask' }) 를 바로 부르면 오류가 난다** — supportedEntryTypes 로 먼저 확인하고 try 로 감싼다.
- [ ] **긴 작업 칸만 보면 40ms 짜리 잦은 멈춤을 놓친다** — 프레임 시간 그래프를 함께 본다 — 33ms 넘는 막대가 줄지어 있으면 그것도 문제.
- [ ] **헤드리스 측정값은 다른 일 때문에 흔들린다** — 두 번 재서 비교한다 (이 사이트 perfscan 측정에서 겪음).

## 완성 기준 체크리스트
- [ ] 「0.3초 멈추기」를 누르면 프레임 그래프에 300ms 막대가 솟고, 아래에 빨간 긴 작업 칸이 같은 자리에 생긴다
- [ ] 「40ms 열 번」은 막대가 여럿 솟지만 긴 작업 칸은 안 생긴다
- [ ] longtask 를 모르는 브라우저에서도 오류 없이 프레임 그래프는 그려진다

## 이 기술 정보
- id: `i394` · 분류: 게임 시스템 · AI › 속도 기법 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 관찰자 하나 · 최근 5초 기록. 그래프 그리기만 프레임마다 조금.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i394
- 쓰면 좋을 때: 화면이 가끔 멈칫하는데 원인을 모를 때 / 고친 뒤 정말 나아졌는지 전/후로 잴 때
- 쓰지 말 때: 배포판 화면에 늘 띄우기 — 개발 모드에서만 / 사파리 — longtask 를 지원하지 않는다 (supportedEntryTypes 로 확인하고 프레임 그래프만)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demo394 — `src/demos/demosPerf.ts:1413`
```ts
function demo394() {
  const frames: { t: number; d: number }[] = [];
  const longs: { s: number; d: number; real: boolean }[] = [];
  let lastNow = 0;
  let supported = false;
  let obs: PerformanceObserver | null = null;
  try {
    supported = (PerformanceObserver.supportedEntryTypes ?? []).includes('longtask');
    if (supported) {
      obs = new PerformanceObserver((list) => {
        for (const e of list.getEntries()) longs.push({ s: e.startTime, d: e.duration, real: true });
      });
      obs.observe({ type: 'longtask', buffered: false });
    }
  } catch { supported = false; }
  const WIN = 5000;
  // 카드용 기록 재생 (실제 긴 작업이 없을 때)
  const REC = [{ at: 1.1, d: 180, why: '셰이더 컴파일' }, { at: 2.9, d: 70, why: '풀이표' }, { at: 3.9, d: 320, why: 'AI 생각 (메인)' }];
  const draw = (g: G, w: number, h: number, tt: number): void => {
    const tn = now();
    if (lastNow) frames.push({ t: tn, d: tn - lastNow });
    lastNow = tn;
    while (frames.length && frames[0]!.t < tn - WIN) frames.shift();
    while (longs.length && longs[0]!.s + longs[0]!.d < tn - WIN) longs.shift();
    const big = isBig(w);
    const realAny = longs.some((l) => l.real);
    const replay = !big && !realAny;
    view(g, w, h);
    txt(g, replay ? '프레임 그래프 + 긴 작업 (기록 재생)' : '이 브라우저 실측 — 최근 5초', 8, 10, 7.2, C.text, 'left', 800);
    pill(g, supported ? 'PerformanceObserver longtask' : 'longtask 지원 안 됨', 272, 10, 5.6, supported ? '#2b3560' : C.redD, supported ? C.cyan : '#ffc2c8', 'right');
    const x0 = 30;
    const x1 = 270;
    const X = (ms: number): number => x0 + ((x1 - x0) * (ms - (tn - WIN))) / WIN;
    // 프레임 그래프
    panel(g, 6, 20, 268, 92, 'none');
    txt(g, '프레임 시간', 12, 27, 6, C.text, 'left', 800);
    const gy = 34;
    const gh = 70;
    const maxMs = 400;
    const Y = (ms: number): number => gy + gh - (gh * Math.min(maxMs, ms)) / maxMs;
    for (const v of [16.7, 50, 200]) {
      g.strokeStyle = v === 50 ? 'rgba(255,93,108,0.5)' : 'rgba(140,149,180,0.25)';
      g.setLineDash([2, 2]);
      g.lineWidth = 0.6;
      g.beginPath();
      g.moveTo(x0, Y(v));
      g.lineTo(x1, Y(v));
      g.stroke();
      g.setLineDash([]);
      txt(g, `${v === 16.7 ? '16.7' : v}ms`, x0 - 2, Y(v), 4.8, v === 50 ? C.red : C.dim, 'right', 600);
    }
    let fl: { t: number; d: number }[] = frames;
    if (replay) {
      // 기록 재생: 5초짜리 흉내 흐름
      const cyc = 5;
      const ph = tt % cyc;
      fl = [];
      for (let s = 0; s < ph; s += 1 / 60) {
        let d = 16.7 + ((s * 977) % 3);
        let skip = false;
        for (const r of REC) {
          if (s >= r.at && s < r.at + 1 / 60) d = r.d;
          else if (s > r.at && s < r.at + r.d / 1000) skip = true;
        }
        if (!skip) fl.push({ t: tn - WIN + s * 1000 + (WIN - cyc * 1000), d });
      }
    }
    for (const f of fl) {
      const x = X(f.t);
      if (x < x0) continue;
      const bw = Math.max(0.6, ((x1 - x0) * Math.min(f.d, 100)) / WIN);
      g.fillStyle = f.d >= 50 ? C.red : f.d >= 20 ? C.amber : C.green;
      g.fillRect(x - bw, Y(f.d), bw, gy + gh - Y(f.d));
      if (f.d >= 50) txt(g, `${f.d.toFixed(0)}ms`, x, Y(f.d) - 3, 5.2, C.red, 'center', 800);
    }
    // 긴 작업 칸
    panel(g, 6, 116, 268, big ? 54 : 54, 'none');
    txt(g, '긴 작업 (50ms 넘는 일)', 12, 123, 6, C.text, 'left', 800);
    g.fillStyle = 'rgba(0,0,0,0.25)';
    rr(g, x0, 130, x1 - x0, 14, 3);
    g.fill();
    let shown = 0;
    if (replay) {
      const ph = tt % 5;
      for (const r of REC) {
        if (ph < r.at) continue;
        const s = tn - WIN + r.at * 1000;
        const xa = X(s);
        const xb = Math.max(xa + 1.5, X(s + r.d));
        block(g, xa, 131, xb - xa, 12, C.red);
        txt(g, `${r.why} ${r.d}ms`, xa, 150, 5.2, '#ffb0b8', 'left', 700);
        shown++;
      }
    } else {
      for (const l of longs) {
        const xa = Math.max(x0, X(l.s));
        const xb = Math.max(xa + 1.5, X(l.s + l.d));
        block(g, xa, 131, xb - xa, 12, C.red);
        txt(g, `${l.d.toFixed(0)}ms`, xa, 150, 5.4, '#ffb0b8', 'left', 800);
        shown++;
      }
    }
    const worst = frames.reduce((m, f) => Math.max(m, f.d), 0);
    txt(g, replay ? '진짜로 재 보려면 크게 보기에서 「멈추기」 단추' : `잡힌 긴 작업 ${shown}개 · 가장 긴 프레임 ${worst.toFixed(0)}ms`, 12, 162, 5.8, replay ? C.dim : '#a9f2d2', 'left', 700);
    g.restore();
  };
  return {
    draw,
    controls: [
      { type: 'button', label: '0.3초 멈추기 (진짜 긴 작업)', on: () => busy(300) },
      { type: 'button', label: '0.08초 멈추기', on: () => busy(80) },
      { type: 'button', label: '40ms 열 번 (긴 작업 아님)', on: () => { let n = 0; const f = (): void => { busy(40); if (++n < 10) setTimeout(f, 30); }; f(); } },
    ] as Control[],
    dispose() { obs?.disconnect(); },
  };
}
```

### i394 견본 항목 — `src/demos/demosPerf.ts:1621`
```ts
  i394: { kind: '2d', caption: '프레임 시간 그래프 + PerformanceObserver 로 잡은 50ms 넘는 긴 작업', make: demo394 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [느린 화면 슬로모션 없애기 (시간 조각 나누기)](https://ai-techstudio.web.app/ai/t/i384.md) `i384`
- 다음에 해 볼 기술: [무거운 표 미리 · 나눠 만들기](https://ai-techstudio.web.app/ai/t/i387.md) `i387` · [소리 장치 미리 깨우기](https://ai-techstudio.web.app/ai/t/i386.md) `i386`
- 참고 문서: [MDN — PerformanceLongTaskTiming](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming) · [MDN — PerformanceObserver](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver)
