Published on

Chrome INP 급등 원인 - Long Task 분해 실전

Authors

서론

INP(Interaction to Next Paint)는 “사용자 입력(클릭/탭/키 입력)에 앱이 다음 화면을 그려주기까지 걸린 시간”을 대표하는 Core Web Vitals 지표입니다. 현업에서 흔한 상황은 이렇습니다.

  • 어느 날부터 Chrome에서 INP가 200ms 이하(좋음)에서 500~1000ms(나쁨)로 급등
  • 서버 응답은 빠른데 “클릭이 먹히는 느낌”이 늦고, 스크롤/탭 전환이 버벅임
  • Lighthouse는 애매하고, 실제 사용자(RUM)에서만 심각하게 튐

대부분의 경우 원인은 단순합니다. **메인 스레드(Main thread)를 오래 점유하는 Long Task(보통 50ms 초과)**가 입력 이벤트 처리와 렌더링을 막아 INP를 폭발시킵니다. 이 글에서는 “Long Task를 실제로 찾아서, 쪼개고, 다시 측정하는” 실전 흐름을 다룹니다.

> 참고: INP는 단일 상호작용이 아니라 페이지 수명 동안의 상호작용 중 **대표값(대개 98th percentile 근처)**을 쓰기 때문에, “가끔 발생하는 큰 멈춤”이 전체 지표를 망가뜨립니다.

INP 급등의 전형적인 메커니즘

INP가 커지는 경로는 대체로 다음 중 하나입니다.

  1. Input delay: 사용자가 클릭했지만 메인 스레드가 바빠 이벤트 루프가 입력을 꺼내지 못함
  2. Processing time: 이벤트 핸들러가 무거움(동기 JS, 큰 루프, JSON 파싱, 레이아웃 스래싱)
  3. Presentation delay: 핸들러는 끝났는데 다음 paint까지 DOM/스타일/레이아웃/페인트가 밀림

Long Task는 이 3가지를 한 번에 악화시킵니다. 특히 “클릭 → 핸들러에서 대량 연산 → setState/DOM 변경 → 강제 레이아웃 → 페인트 지연” 조합이 자주 나옵니다.

DevTools로 Long Task 범인 찾기(실전 체크리스트)

1) Performance 패널로 재현과 캡처

  1. Chrome DevTools → Performance
  2. CPU Throttling(예: 4x) 켜고, Network는 실제와 비슷하게
  3. Record 후, 문제가 되는 상호작용(클릭/입력/탭 전환)을 수행
  4. Stop

이때 확인 포인트:

  • Main 트랙에서 긴 노란색(JavaScript) 블록이 있는지
  • Long Task 표시(빨간 삼각형/경고) 또는 50ms 이상 task
  • Event Log에서 클릭/포인터/키 이벤트가 언제 처리되었는지

2) “왜 길었는지”를 콜스택으로 파고들기

Long Task를 클릭하면 Summary/Bottom-up/Call tree에서 시간이 어디에 쓰였는지 보입니다.

  • Scripting이 길면: JS 연산/파싱/프레임워크 렌더
  • Rendering이 길면: style/layout/paint, 레이아웃 스래싱 가능
  • System이 길면: GC, 이미지 디코드, 폰트 등도 의심

3) Long Task가 한 번만 뜨는지, 반복되는지 구분

  • “특정 버튼 클릭 시 1회 크게 멈춤” → 초기화/대량 데이터 처리/동기 렌더
  • “스크롤/타이핑마다 계속 멈춤” → 이벤트 핸들러/리렌더/레이아웃 스래싱

이 구분이 해결책을 결정합니다.

Long Task 분해 전략 1: 작업을 ‘프레임 단위’로 쪼개기

가장 효과적인 첫 수는 한 번에 200500ms 먹는 일을 1020ms 조각으로 나눠 브라우저가 입력 처리와 렌더링을 끼워 넣게 만드는 것입니다.

setTimeout(0)만으로는 부족한 이유

setTimeout(fn, 0)은 매크로태스크로 밀어내지만, 연속 호출 시 여전히 메인 스레드를 바쁘게 만들 수 있고, 페인트 타이밍을 보장하지 않습니다.

requestAnimationFrame + chunking(권장)

렌더 프레임 경계에 맞춰 조금씩 처리하면 체감이 좋아집니다.

// 무거운 리스트 처리(예: 50,000개)를 프레임마다 조금씩 수행
function processInChunks(items, handler, chunkSize = 200) {
  let index = 0;

  return new Promise((resolve) => {
    function frame() {
      const end = Math.min(index + chunkSize, items.length);
      for (; index < end; index++) {
        handler(items[index], index);
      }

      if (index < items.length) {
        requestAnimationFrame(frame);
      } else {
        resolve();
      }
    }

    requestAnimationFrame(frame);
  });
}

// 사용 예
await processInChunks(bigArray, (item) => {
  // DOM 조작은 여기서 최소화하고, 데이터 계산 위주로
  compute(item);
});

핵심은 “한 프레임에서 너무 오래 일하지 않기”입니다. chunkSize는 정답이 없고, DevTools에서 Main thread task가 50ms를 넘지 않도록 조절합니다.

Long Task 분해 전략 2: Idle time 활용(requestIdleCallback)

사용자 입력 반응이 중요한 화면에서는 “지금 당장 필요 없는 일”을 idle로 미루는 게 INP에 특히 잘 먹힙니다.

function runWhenIdle(work, timeout = 1000) {
  if ('requestIdleCallback' in window) {
    return requestIdleCallback((deadline) => {
      // 남은 idle 시간이 충분할 때만 실행
      while (deadline.timeRemaining() > 5 && work.hasMore()) {
        work.step();
      }
      if (work.hasMore()) runWhenIdle(work, timeout);
    }, { timeout });
  }

  // 폴백: 다음 tick으로
  return setTimeout(() => {
    while (work.hasMore()) work.step();
  }, 0);
}

// 사용 예: 검색 인덱스 구성, 프리페치, 로그 정리 등
const work = {
  i: 0,
  hasMore() { return this.i < 50000; },
  step() { this.i++; heavyCompute(this.i); }
};

runWhenIdle(work);

주의할 점:

  • idle은 “언제든 끊길 수 있는” 실행입니다. **중단/재개 가능한 형태(step)**로 설계하세요.
  • timeout을 걸어 무한히 밀리지 않게 합니다.

Long Task 분해 전략 3: Web Worker로 메인 스레드에서 제거

Long Task가 “순수 계산/파싱/정렬”이라면 Worker로 옮기는 것이 가장 깔끔합니다.

Worker로 JSON 파싱/정렬 오프로드

// worker.js
self.onmessage = (e) => {
  const { raw } = e.data;
  const data = JSON.parse(raw);
  data.sort((a, b) => a.score - b.score);
  self.postMessage({ data });
};
// main.js
const worker = new Worker('/worker.js');

function parseAndSortInWorker(raw) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data.data);
    worker.postMessage({ raw });
  });
}

// 사용 예
const sorted = await parseAndSortInWorker(hugeJSONString);
render(sorted);

Worker로 옮기면 메인 스레드의 Input delay가 크게 줄어 INP가 안정화되는 경우가 많습니다.

단, Worker도 만능은 아닙니다.

  • DOM 접근 불가(계산 전용)
  • 메시지 직렬화 비용(큰 객체는 structuredClone 비용 발생)
  • 가능하면 전송 데이터는 최소화(원시 타입/TypedArray 활용)

Long Task 분해 전략 4: 렌더링 비용 줄이기(레이아웃 스래싱 제거)

INP가 나쁜데 JS 자체는 그리 길지 않다면, “Rendering” 시간이 문제일 수 있습니다.

전형적인 레이아웃 스래싱 패턴

// 나쁜 예: 읽기/쓰기가 교차되며 레이아웃 강제 계산이 반복
for (const el of elements) {
  const h = el.offsetHeight;      // layout read
  el.style.height = (h + 10) + 'px'; // layout write
}

읽기-쓰기 분리로 개선

// 좋은 예: 먼저 읽기만 모으고, 그 다음 쓰기만 수행
const heights = elements.map(el => el.offsetHeight);

elements.forEach((el, i) => {
  el.style.height = (heights[i] + 10) + 'px';
});

추가 팁:

  • 애니메이션은 top/left보다 transform 사용
  • 큰 DOM 업데이트는 DocumentFragment/배치 업데이트
  • 가상 스크롤(virtualization)로 렌더 대상 자체를 줄이기

“INP는 가끔만 튄다”를 잡는 관측 코드

현상 재현이 어렵거나, 특정 사용자 환경에서만 튄다면 “상호작용이 느린 순간에 어떤 Long Task가 있었는지”를 현장에서 수집해야 합니다.

Long Task 관측(PerformanceObserver)

// Long Task(50ms+)를 관측해 콘솔/로그로 남김
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration: ms
    // entry.attribution: 어떤 컨테이너/프레임에서 발생했는지 힌트
    console.log('[LongTask]', {
      startTime: entry.startTime,
      duration: entry.duration,
      name: entry.name,
      attribution: entry.attribution
    });
  }
});

longTaskObserver.observe({ entryTypes: ['longtask'] });

여기에 “사용자 상호작용 직전/직후”의 Long Task를 묶어서 전송하면, RUM에서 INP 급등 원인을 좁히는 데 도움이 됩니다.

React/Vue 등 프레임워크에서 자주 터지는 케이스

1) 큰 상태 변경 한 번에 몰아치기

  • 필터 변경 시 리스트 1만 개를 한 번에 리렌더
  • 입력창 타이핑마다 비싼 파생 계산 수행

해결:

  • 파생 계산 메모이제이션
  • 입력은 debounce(단, UX에 맞게)
  • 리스트는 virtualization
  • “즉시 반응이 필요한 UI”와 “나중에 그려도 되는 UI”를 분리

2) 이벤트 핸들러 안에서 동기 작업 과다

예: 클릭 핸들러에서 analytics payload 생성/압축/대량 stringify

해결:

  • 클릭 핸들러에서는 상태 업데이트만 하고, 로깅/전송은 idle/worker로

실전 디버깅 플로우(요약)

  1. RUM/DevTools에서 INP가 튀는 상호작용을 특정
  2. Performance 기록에서 해당 타이밍의 Main thread Long Task 확인
  3. Long Task가 Scripting이면: chunking/idle/worker로 분해
  4. Long Task가 Rendering이면: 레이아웃 스래싱 제거/DOM 업데이트 배치/virtualization
  5. 수정 후 다시 측정: CPU throttling + 동일 시나리오 반복

배포 파이프라인에서 성능 회귀 막기

INP는 기능 추가보다 “사소한 코드 한 줄”로도 회귀합니다. 그래서 성능 회귀를 배포 단계에서 최대한 빨리 잡는 게 중요합니다. 특히 모노레포/다중 워크플로 환경에서는 성능 측정 잡이 동시에 돌아가거나 취소되면서 데이터가 꼬이기도 합니다. CI 안정화는 성능 관리의 기본 체력입니다.

또한, 타입스크립트/번들 설정 변화가 런타임 동작을 바꿔 예상치 못한 렌더 폭발을 만들기도 합니다. 예를 들어 클래스 필드 초기화 방식 변경은 이벤트 핸들러 바인딩/참조를 꼬이게 해 불필요한 리렌더를 유발할 수 있습니다.

결론

Chrome에서 INP가 급등할 때 “대부분”의 출발점은 Long Task입니다. 해결은 거창한 최적화보다, 아래 3가지를 정확히 실행하는 데서 시작합니다.

  • DevTools Performance로 문제 상호작용 시점의 Long Task를 잡는다
  • Long Task를 쪼개거나(chunking/idle), 아예 메인 스레드 밖으로 옮긴다(worker)
  • Rendering 병목이면 레이아웃 스래싱/DOM 폭발을 제거한다

INP는 사용자 체감과 가장 직결되는 지표입니다. “가끔 1초 멈추는 순간”을 없애는 것만으로도, 평균 성능보다 훨씬 큰 만족도를 만들 수 있습니다.