/* * 이벤트 루프 지체 — **화면이 느린 이유를 지어내지 않기 위해.** 순수(import 없음). * * ── 왜 필요한가 (2026-08-21) ──────────────────────────────────────────────── * 「UI 반응성이 느리다」를 진단하려 했는데, 있는 계기판으로는 답이 나오지 않았다. `twinLoad` 는 **트윈의 * 계측된 작업**만 재고, `processCpuRatio` 는 프로세스 전체를 재지만 **언제 얼마나 멈췄나**를 말하지 * 못한다. 밖에서 HTTP 지연을 재 보니 같은 요청이 44ms 와 5.8초로 갈라졌는데, 그 5.8초가 트윈 때문인지 * webpack 재빌드 때문인지 남의 재기동 때문인지 **가릴 근거가 없었다.** * * 근거 없이 원인을 고르면 엉뚱한 곳을 고친다. 그래서 루프가 실제로 얼마나 막혔는지를 잰다. * * ── 어떻게 재나 ───────────────────────────────────────────────────────────── * 일정 간격으로 깨어나는 타이머를 두고, **예정 시각과 실제 시각의 차이**를 본다. 루프가 막혀 있었으면 * 그만큼 늦게 깨어난다. 재는 비용은 그 차이 계산 하나뿐이다(창마다 값 다섯). * * 이 값은 **원인을 말하지 않는다** — 「200ms 이상 막힌 일이 이 창에서 세 번, 가장 긴 것은 4.6초」까지가 * 사실이다. 원인은 그 시각의 트윈 부하(`twinLoad`)와 프로세스 CPU 와 나란히 놓고 사람이 가린다. * 재지 않은 것을 0 으로 적지 않는다 — 한 번도 깨어나지 않았으면 `null` 이다. */ /** 표본 간격(ms) — 촘촘할 필요가 없다. 긴 정체를 놓치지 않는 정도면 된다. */ export const LAG_SAMPLE_MS = 500 /** 이 값을 넘는 지체만 「정체」로 센다(ms). 사람이 화면에서 느끼기 시작하는 크기. */ export const LAG_STALL_MS = 200 /** 창 길이(ms) — `twinLoad` 의 창과 같은 크기로 두어 나란히 읽는다. */ export const LAG_WINDOW_MS = 10_000 export interface LoopLagWindow { /** 이 창에서 가장 길게 막힌 시간(ms). */ maxMs: number /** 정체(문턱 초과) 횟수. */ stalls: number /** 표본 수 — 분모다. */ samples: number /** 지체 합(ms) — 평균은 읽는 쪽에서 낸다. */ totalMs: number /** 창이 열린 시각. */ startMs: number } export interface LoopLagView { /** 마지막으로 닫힌 창 — 「지금 어떤가」. 아직 한 창도 안 지났으면 null. */ recent: (LoopLagWindow & { endMs: number }) | null /** 기동 이후 누적. */ total: LoopLagWindow /** 기동 이후 가장 길게 막힌 시각(ms, epoch) — 언제였는지가 원인을 가리는 데 쓰인다. */ worstAtMs: number | null /** 표본 간격 — 읽는 쪽이 이 값보다 짧은 정체는 못 본다는 것을 알아야 한다. */ sampleMs: number /** 정체로 세는 문턱. */ stallMs: number } const emptyWindow = (startMs: number): LoopLagWindow => ({ maxMs: 0, stalls: 0, samples: 0, totalMs: 0, startMs }) export class LoopLagSampler { private window: LoopLagWindow private closed: (LoopLagWindow & { endMs: number }) | null = null private total: LoopLagWindow private worstAtMs: number | null = null private timer: any private expectedMs: number private sampleMs: number private stallMs: number private windowMs: number private now: () => number /* 생성자 파라미터 프로퍼티(`private x = …`)는 쓰지 않는다 — 시험이 소스를 그대로 실행하는데 (타입 제거 실행 방식) 그 문법은 지원되지 않아 파일 전체가 열리지 않는다. */ constructor(sampleMs = LAG_SAMPLE_MS, stallMs = LAG_STALL_MS, windowMs = LAG_WINDOW_MS, now: () => number = Date.now) { this.sampleMs = sampleMs this.stallMs = stallMs this.windowMs = windowMs this.now = now const t = this.now() this.window = emptyWindow(t) this.total = emptyWindow(t) this.expectedMs = t + sampleMs } /** 표본을 하나 넣는다 — 타이머가 부르고, 시험은 직접 부른다(시계를 밀어 넣어 잰다). */ sample(atMs = this.now()): number { const lag = Math.max(0, atMs - this.expectedMs) this.expectedMs = atMs + this.sampleMs for (const w of [this.window, this.total]) { w.samples += 1 w.totalMs += lag if (lag > this.stallMs) w.stalls += 1 if (lag > w.maxMs) w.maxMs = lag } if (lag >= this.total.maxMs && lag > this.stallMs) this.worstAtMs = atMs if (atMs - this.window.startMs >= this.windowMs) { this.closed = { ...this.window, endMs: atMs } this.window = emptyWindow(atMs) } return lag } /** 계기판이 읽는 값. **한 창도 닫히지 않았으면 `recent` 는 null** — 채우는 중인 창은 과소 집계다. */ view(): LoopLagView { return { recent: this.closed, total: this.total, worstAtMs: this.worstAtMs, sampleMs: this.sampleMs, stallMs: this.stallMs } } /** 표본 타이머 기동(1회) — 프로세스 종료를 막지 않는다. */ start(): void { if (this.timer) return this.timer = setInterval(() => this.sample(), this.sampleMs) if (typeof this.timer?.unref === 'function') this.timer.unref() } stop(): void { if (!this.timer) return clearInterval(this.timer) this.timer = undefined } } /** 프로세스 하나의 루프를 재는 것이므로 표본기도 하나다. */ export const loopLag = new LoopLagSampler()