Published on

Chrome INP 폭증 원인 찾기 - Long Task 분해

Authors

INP(Interaction to Next Paint)가 갑자기 폭증하면 체감은 대개 “클릭했는데 늦게 반응한다”로 나타납니다. 문제는 네트워크가 아니라 브라우저 메인 스레드가 입력 이벤트를 제때 처리하지 못하는 경우가 많습니다. 특히 Chrome에서 INP가 튀는 케이스는 대부분 Long Task(대략 50ms 이상 메인 스레드를 점유하는 작업)로 귀결됩니다.

이 글은 INP 폭증 상황에서 “어떤 Long Task가 입력을 막았는지”를 찾아내고, 그 Long Task를 분해해서 입력 지연을 낮추는 과정을 단계별로 설명합니다. 단순히 setTimeout으로 쪼개는 요령이 아니라, DevTools에서 근거를 확보하고, 코드 레벨로 원인을 좁히고, 재발 방지까지 연결하는 흐름에 집중합니다.

관련 성능 이슈로 스크롤 잔상이 심하거나 프레임 드랍이 동반된다면, 렌더링 측면의 튜닝도 같이 보세요: Safari iOS 스크롤 끊김 해결 - layer·contain 튜닝

INP와 Long Task의 관계를 짧게 정리

INP는 사용자의 상호작용(클릭, 탭, 키 입력 등)에 대해 “다음 페인트가 일어나기까지” 걸린 지연을 측정합니다. 보통 다음 3가지가 합쳐져 INP를 악화시킵니다.

  1. Input delay: 이벤트가 큐에 쌓여 대기하는 시간(메인 스레드가 바쁨)
  2. Processing time: 이벤트 핸들러 실행 시간(자바스크립트가 무거움)
  3. Presentation delay: 렌더링/레이아웃/페인트가 늦는 시간(스타일/레이아웃/페인트 비용)

Long Task는 위 1번과 2번을 동시에 악화시키는 대표 원인입니다. 메인 스레드가 Long Task로 막혀 있으면 입력 이벤트는 큐에서 기다리다가 늦게 처리되고, 처리 이후에도 렌더링이 밀리며 “다음 페인트”가 늦어집니다.

재현 가능한 측정 환경 만들기

원인 분석 전에 “재현 + 측정”이 가능해야 합니다.

  • Chrome DevTools Performance에서 기록
  • 가능하면 CPU Throttling 4x 또는 6x로 증폭(실사용 저사양 기기 근사)
  • 문제 상호작용을 딱 1회 수행(클릭 한 번, 입력 한 번)
  • 기록 종료 후, 그 상호작용 주변만 확대해서 분석

또한 실서비스에서 INP가 튄다면 RUM을 붙여 “어떤 상호작용에서 튀는지”를 먼저 좁히는 게 좋습니다.

INP 후보 상호작용을 RUM으로 수집(간단 예시)

다음은 web-vitals를 이용해 INP 값을 수집하고, 상호작용 대상 요소 힌트를 함께 남기는 방식입니다.

import { onINP } from 'web-vitals';

onINP((metric) => {
  // metric.value: INP(ms)
  // metric.attribution: 상호작용 관련 힌트(브라우저 지원 범위 내)
  const payload = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    navigationType: metric.navigationType,
    attribution: metric.attribution,
  };

  navigator.sendBeacon('/rum', JSON.stringify(payload));
});

이 단계의 목표는 “특정 페이지/특정 버튼/특정 입력”에서만 튀는지, 혹은 전반적으로 튀는지 갈라내는 것입니다.

DevTools Performance로 Long Task를 특정하는 법

1) Performance 패널에서 상호작용 지점을 찾기

  1. Performance 기록 시작
  2. 문제 상호작용(예: 버튼 클릭) 수행
  3. 기록 종료
  4. 타임라인에서 Events(또는 Main) 트랙을 확대

여기서 중요한 포인트는 “클릭 이벤트가 발생한 시점”과 “다음 페인트가 발생한 시점” 사이에 무엇이 끼어 있는지입니다.

2) Main 트랙에서 Long Task 덩어리(노란색) 확인

Main 트랙에 길게 이어진 자바스크립트 실행 덩어리가 보이면, 그게 Long Task일 가능성이 큽니다. 클릭 직후에 긴 덩어리가 있다면 거의 확정입니다.

  • 해당 덩어리를 클릭
  • 오른쪽(또는 하단) 상세에서 Call Tree / Bottom-Up 확인
  • 시간이 큰 함수부터 역추적

3) “이벤트 핸들러 자체가 무거운지” vs “이벤트 전에 이미 막혀있었는지” 구분

  • 클릭 직전에 이미 Long Task가 돌고 있었다면: Input delay가 크다
  • 클릭 이후 핸들러가 길게 실행된다면: Processing time이 크다

대응 방식이 달라집니다.

Long Task가 생기는 흔한 패턴 6가지

아래 패턴은 실무에서 INP 폭증을 자주 만드는 원인들입니다.

패턴 1) 클릭 핸들러에서 동기 루프/정렬/필터를 한 번에 수행

예: 대량 배열을 클릭 한 번에 정렬하고, DOM 업데이트까지 한 번에 수행.

button.addEventListener('click', () => {
  // 나쁜 예: 큰 배열 정렬/가공을 메인 스레드에서 단번에
  const sorted = bigList.sort((a, b) => a.score - b.score);
  render(sorted);
});

패턴 2) 상태 업데이트 연쇄로 인한 렌더 폭발

React/Vue 등에서 클릭 한 번이 여러 상태 변경을 유발하고, 그 결과 레이아웃/페인트가 연쇄로 발생.

패턴 3) 강제 동기 레이아웃(Forced Reflow)

읽기-쓰기 순서가 섞여 레이아웃 스래싱이 발생.

// 나쁜 예: 레이아웃 읽기와 쓰기를 반복
for (const el of items) {
  const h = el.offsetHeight; // read
  el.style.height = (h + 1) + 'px'; // write
}

패턴 4) JSON 파싱/문자열 처리/템플릿 생성이 과도

큰 JSON을 클릭 직후 파싱하거나, 대량 문자열 템플릿을 동기 생성.

패턴 5) 서드파티 스크립트가 메인 스레드를 점유

광고/분석/위젯이 상호작용 타이밍에 겹치면 INP가 튀기 쉽습니다.

패턴 6) 이벤트 위임 범위가 과도하거나, 핸들러가 너무 자주 실행

pointermove/scroll/input에 무거운 처리가 붙어있고, 그게 클릭 타이밍까지 영향을 줌.

Long Task “분해” 전략: 쪼개기 전에 먼저 나눌 경계를 잡자

Long Task 분해는 무조건 setTimeout을 넣는 게 아닙니다. 핵심은 “사용자 입력에 대한 즉시 반응”을 먼저 확보하고, 무거운 작업을 뒤로 미루거나 분산하는 것입니다.

아래는 실전에서 가장 효과가 좋았던 분해 순서입니다.

  1. UI 즉시 반응(optimistic UI) 먼저
  2. 작업을 단계로 쪼개고, 단계 사이에 브라우저에 숨 쉴 틈을 제공
  3. 우선순위 낮은 작업은 idle 시간으로 이동
  4. 계산량이 큰 작업은 Worker로 이동
  5. 레이아웃/페인트 비용은 읽기/쓰기 배치 및 렌더 단순화로 절감

분해 패턴 A: “즉시 반응” 후 무거운 작업을 뒤로 미루기

클릭 시 즉시 버튼 상태를 바꾸고(로딩 표시), 무거운 작업은 다음 틱으로 넘깁니다.

async function onClick() {
  // 1) 즉시 반응: 버튼 비활성화, 스피너 표시 등
  setLoading(true);

  // 2) 다음 페인트를 한 번 보장한 뒤 무거운 작업 수행
  await new Promise(requestAnimationFrame);

  // 3) 무거운 작업
  const result = heavyCompute();
  setData(result);
  setLoading(false);
}

이 방식은 INP의 “다음 페인트”를 앞당기는 데 도움이 됩니다. 단, 무거운 작업 자체가 사라진 건 아니므로, 이후 프레임 드랍이 남으면 추가 분해가 필요합니다.

분해 패턴 B: 작업을 청크로 나눠 yield 하기

대량 루프를 한 번에 돌리지 말고, 일정 단위로 나눠 이벤트 루프에 제어권을 돌려줍니다.

function yieldToMain() {
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processInChunks(items, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < items.length; i++) {
    out.push(transform(items[i]));

    if (i % chunkSize === 0) {
      // 메인 스레드에 숨 쉴 틈 제공
      await yieldToMain();
    }
  }
  return out;
}

포인트는 “청크 사이에 입력 이벤트가 처리될 기회”를 만드는 것입니다. 청크 크기는 DevTools로 확인하면서 조정합니다.

분해 패턴 C: requestIdleCallback로 우선순위 낮은 작업 보내기

사용자 입력 직후 꼭 필요하지 않은 일(프리페치, 로그, 정리 작업)은 idle 시간으로 보냅니다.

function runWhenIdle(task) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => task(), { timeout: 1000 });
  } else {
    setTimeout(task, 200);
  }
}

button.addEventListener('click', () => {
  // 즉시 필요한 UI 업데이트
  openModal();

  // 나중에 해도 되는 작업
  runWhenIdle(() => {
    warmCache();
    sendAnalytics();
  });
});

주의: idle로 보냈다고 해서 “언제나 빠르다”는 뜻은 아닙니다. 하지만 INP 관점에서는 상호작용 경로에서 빼는 것만으로도 큰 개선이 됩니다.

분해 패턴 D: Web Worker로 계산 분리

정렬/검색/파싱처럼 CPU를 많이 쓰는 작업은 Worker로 옮기면 메인 스레드 점유를 크게 줄일 수 있습니다.

Worker 코드 예시

worker.js

self.onmessage = (e) => {
  const { items } = e.data;
  // 무거운 계산
  items.sort((a, b) => a.score - b.score);
  self.postMessage({ items });
};

메인 스레드

const worker = new Worker('/worker.js');

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

button.addEventListener('click', async () => {
  setLoading(true);
  await new Promise(requestAnimationFrame);

  const sorted = await sortInWorker([...bigList]);
  render(sorted);
  setLoading(false);
});

Worker로 옮길 때는 “데이터 복사 비용”이 새 병목이 될 수 있습니다. 구조화 복사 비용이 큰 객체를 자주 넘기면 역효과가 날 수 있으니, 필요한 데이터만 최소화하거나 전송 빈도를 줄이세요.

분해 패턴 E: 강제 레이아웃을 제거하는 읽기/쓰기 배치

레이아웃 읽기와 쓰기를 분리하면 렌더링 비용이 크게 줄어드는 경우가 많습니다.

// 개선 예: 먼저 읽기만 모아서 수행
const heights = items.map((el) => el.offsetHeight);

// 그 다음 쓰기만 모아서 수행
items.forEach((el, idx) => {
  el.style.height = (heights[idx] + 1) + 'px';
});

여기서 더 나아가면, DOM 업데이트 자체를 줄이거나(가상화), contain 같은 CSS 격리로 영향 범위를 줄이는 전략도 고려할 수 있습니다.

“Long Task가 어디서 왔는지”를 더 빨리 찾는 팁

1) Bottom-Up에서 Self Time이 큰 함수를 먼저 본다

Call Tree는 호출 구조를 보여주지만, 실제로 시간을 먹는 지점을 찾을 땐 Bottom-Up이 더 직관적입니다.

2) Scripting만이 아니라 Rendering 구간도 같이 본다

INP는 “다음 페인트”까지 포함하므로, 자바스크립트를 줄였는데도 INP가 남으면 Rendering(스타일/레이아웃/페인트) 비용이 남아 있을 수 있습니다.

3) 서드파티가 의심되면 차단 실험부터

태그 매니저/광고/위젯을 잠깐 끄고 재측정하면 원인 분리가 빨라집니다.

개선 후 검증: INP는 “한 번”이 아니라 “분포”로 본다

개선 전후를 비교할 때는 다음을 같이 봐야 합니다.

  • DevTools에서 문제 상호작용 1회의 타임라인이 짧아졌는지
  • RUM에서 INP p75가 내려갔는지
  • 특정 페이지/특정 컴포넌트에서만 튀는 outlier가 남았는지

또한 코드 변경이 타입 안정성이나 리팩터링을 동반한다면, 컴파일 타임 검증을 강화하는 것도 도움이 됩니다. 타입 수준에서 “허용된 형태의 데이터만 Worker로 보낸다” 같은 제약을 두면 회귀를 줄일 수 있습니다: TS 5.6 satisfies로 타입 유지하며 검증하는 법

체크리스트: INP 폭증을 줄이는 실전 우선순위

  • 상호작용 경로(클릭 핸들러)에서 동기 CPU 작업 제거 또는 분산
  • 긴 루프는 청크 처리 + yield
  • 즉시 반응 UI를 먼저 그리고, 무거운 작업은 다음 프레임 이후로
  • idle로 보내도 되는 작업은 과감히 분리
  • 계산은 Worker로 이동(데이터 전송 비용 주의)
  • 레이아웃 스래싱 제거(읽기/쓰기 분리)
  • 서드파티 스크립트 격리/지연 로딩/빈도 축소

마무리

Chrome에서 INP가 튄다는 건 “사용자 입력이 메인 스레드에서 막혔다”는 신호인 경우가 많고, 그 막힘의 실체가 Long Task로 드러납니다. DevTools Performance에서 상호작용 시점 주변 Long Task를 정확히 집어낸 뒤, 작업을 UI 반응과 무거운 처리로 분리하고, 청크/idle/Worker/렌더링 최적화를 조합하면 INP는 눈에 띄게 안정화됩니다.

중요한 건 감으로 고치는 게 아니라, 타임라인에서 근거를 잡고 “어떤 작업이 몇 ms를 먹는지”를 확인하면서 분해하는 것입니다. 그렇게 하면 INP 폭증은 재현 가능한 디버깅 문제로 바뀌고, 해결도 훨씬 빨라집니다.