Published on

Chrome INP 점수 급락? Long Task 추적·해결

Authors

서버 지표는 멀쩡한데, 어느 날부터 Chrome에서 INP(Interaction to Next Paint) 점수가 급락하는 경우가 있습니다. 특히 배포 직후가 아니라 기능 추가/분석 스크립트 도입/프론트 번들 변경 이후에 발생한다면, 네트워크나 TTFB보다 메인 스레드가 막히는 Long Task(50ms 이상) 가 원인일 확률이 큽니다.

INP는 “사용자 입력(클릭/탭/키 입력 등) → 다음 페인트까지 걸린 시간”의 상위 백분위(대개 p75)를 보는 지표입니다. 즉, 입력 직후에 JS가 길게 실행되거나, 레이아웃/스타일 계산이 폭발하거나, 렌더링이 막히면 INP가 크게 악화됩니다.

이 글에서는 Long Task를 추적해 ‘누가’ 메인 스레드를 점유하는지 찾아내고, 코드/구조 레벨에서 INP를 회복하는 방법을 단계별로 정리합니다. (캐시/재검증 이슈로 체감 성능이 흔들리는 Next.js 환경이라면 Next.js App Router 캐시 꼬임·재검증 버그 해결도 함께 점검해보는 게 좋습니다.)

INP 급락의 전형적인 패턴: “입력 직후 200~800ms 멈춤”

INP가 나빠질 때 DevTools에서 자주 보이는 패턴은 다음 중 하나입니다.

  • 클릭 이벤트 핸들러가 동기적으로 무거운 계산/정렬/필터링 수행
  • 입력 직후 대량 DOM 업데이트로 style/layout/paint가 연쇄 폭발
  • 라우팅/모달 오픈 시 대형 번들 동적 import + 초기화 코드가 한 번에 실행
  • 3rd-party(태그/광고/분석) 스크립트가 메인 스레드를 점유
  • 프레임워크/상태관리로 인해 불필요한 리렌더가 입력과 같은 타이밍에 겹침

핵심은 “느리다”가 아니라 입력과 같은 타이밍에 메인 스레드가 바쁘다는 점입니다.

1) 현상 재현: ‘필드 데이터’와 ‘실험실 데이터’를 분리

INP는 현장(Real User Monitoring, RUM)에서 더 중요합니다. 먼저 아래를 분리하세요.

  • 필드 데이터(크롬 UX 리포트/GA4/자체 RUM): 실제 사용자 p75 INP가 나빠졌는지
  • 실험실 데이터(DevTools/Lighthouse): 어떤 상호작용에서 Long Task가 발생하는지

실무에서는 “특정 페이지/특정 컴포넌트/특정 브라우저 버전에서만” 악화되는 케이스가 많습니다. 그래서 어떤 interaction(버튼/탭/검색/스크롤) 이 p75를 끌어올리는지부터 좁혀야 합니다.

2) DevTools로 Long Task 범인 찾기 (Performance 패널)

절차

  1. Chrome DevTools → Performance
  2. 톱니바퀴에서 Screenshots, Web Vitals(있다면) 활성화
  3. “Record” 시작 후 문제 상호작용(예: 버튼 클릭) 수행
  4. 정지 후 Main 트랙에서 빨간 삼각형/긴 막대(긴 task)를 확인

무엇을 봐야 하나

  • Long Task(>50ms): INP 악화의 1차 후보
  • Task 내부의 Event Handler / Function Call / Evaluate Script
  • Recalculate Style / Layout / Update Layer Tree / Paint가 연속되는지

특히 Long Task를 클릭하면 하단에 Call Tree / Bottom-Up이 나오는데, 여기서 “가장 시간을 많이 쓴 함수”를 찾습니다.

흔한 함정: “내 코드가 아니라 라이브러리/3rd-party”

Bottom-Up에서 상위에 gtag, tagmanager, analytics, heatmap류가 보이면, 기능 자체보다 측정 스크립트가 입력과 같은 타이밍에 실행되는 것이 문제일 수 있습니다. 이 경우는 “제거”보다 로드/실행 타이밍을 늦추거나, 입력 직후에는 실행하지 않도록” 설계하는 편이 현실적입니다.

3) Long Task를 수치로 잡기: web-vitals + PerformanceObserver

DevTools로 범인을 찾았다면, 이제는 배포 후에도 재발을 감지할 수 있게 계측이 필요합니다. 최소한 아래 두 가지를 권장합니다.

  • web-vitals로 INP를 RUM으로 수집
  • PerformanceObserver로 Long Task를 수집(원인 추적용)

INP 수집 (web-vitals)

// npm i web-vitals
import { onINP } from 'web-vitals';

function sendToAnalytics(metric: any) {
  // metric.name === 'INP'
  // metric.value: ms
  // metric.id: unique id
  // metric.attribution: 이벤트/타겟 힌트(브라우저 지원 범위 내)
  navigator.sendBeacon('/rum', JSON.stringify(metric));
}

onINP(sendToAnalytics, { reportAllChanges: true });

reportAllChanges를 켜면 상호작용이 누적되며 값이 업데이트될 때마다 보고되어, “어떤 릴리즈 이후 상호작용이 나빠졌는지”를 비교하기 좋습니다.

Long Task 수집 (PerformanceObserver)

// Long Task: entryType = 'longtask'
// Chrome 계열에서 지원
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration: ms
    // entry.startTime: navigationStart 기준 ms
    // entry.attribution: 어떤 작업이 원인인지 힌트(브라우저별 상이)
    const payload = {
      type: 'longtask',
      duration: entry.duration,
      startTime: entry.startTime,
      name: entry.name,
      attribution: entry.attribution || [],
      url: location.href,
    };

    // 너무 자주 보내면 비용이 커지므로 샘플링 권장
    if (entry.duration > 200) {
      navigator.sendBeacon('/rum/longtask', JSON.stringify(payload));
    }
  }
});

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

이렇게 모은 Long Task를 INP 이벤트와 같은 세션/페이지/릴리즈로 묶으면, “INP 급락 시점에 어떤 Long Task가 늘었는지”를 빠르게 역추적할 수 있습니다. (운영에서 원인 추적을 체계화한다는 점에서는 장애 원인 파악 방법론과 유사합니다. 예를 들어 리눅스에서 리소스 이슈를 파고드는 방식은 리눅스 OOM Killer로 프로세스 죽음 원인 추적과도 결이 같습니다.)

4) 해결 전략 1: “쪼개기” — 한 번에 300ms 하던 일을 3번에 20ms로

INP 관점에서 가장 강력한 처방은 긴 동기 작업을 쪼개서 메인 스레드에 숨 쉴 틈을 주는 것입니다.

requestIdleCallback(가능할 때만) + setTimeout 분할

function chunkedWork(items, handler, chunkSize = 200) {
  let i = 0;

  function runChunk(deadline) {
    // requestIdleCallback은 idle time 기반이라 환경에 따라 호출이 늦어질 수 있음
    // deadline.timeRemaining()이 충분하지 않으면 다음 idle로 넘김
    while (i < items.length && (!deadline || deadline.timeRemaining() > 5)) {
      const end = Math.min(i + chunkSize, items.length);
      for (; i < end; i++) handler(items[i]);
    }

    if (i < items.length) {
      schedule();
    }
  }

  function schedule() {
    if ('requestIdleCallback' in window) {
      requestIdleCallback(runChunk, { timeout: 500 });
    } else {
      setTimeout(() => runChunk(null), 0);
    }
  }

  schedule();
}

// 클릭 핸들러에서 무거운 작업을 즉시 수행하지 말고 분할
button.addEventListener('click', () => {
  chunkedWork(bigArray, (x) => {
    // 가벼운 단위 작업
    doSomething(x);
  });
});

핵심은 “총 시간”이 비슷하더라도, 입력 직후 첫 페인트를 막지 않게 만드는 것입니다.

React/Next.js라면: 긴 상태 업데이트를 transition으로

import { useTransition } from 'react';

function Search() {
  const [isPending, startTransition] = useTransition();
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function onChange(e) {
    const next = e.target.value;
    setQuery(next); // 입력 반영은 즉시

    startTransition(() => {
      // 무거운 필터링/렌더링은 transition으로 밀기
      setResults(expensiveFilter(next));
    });
  }

  return (
    <>
      <input value={query} onChange={onChange} />
      {isPending ? <span>Updating…</span> : <ResultList items={results} />}
    </>
  );
}

이렇게 하면 입력 반응성이 좋아져 INP 악화를 줄이는 데 도움이 됩니다.

5) 해결 전략 2: “오프로딩” — CPU 작업은 Web Worker로

정렬/압축/파싱/대량 데이터 가공처럼 DOM이 필요 없는 CPU 작업은 Web Worker로 보내는 게 정석입니다.

Worker 예시

// worker.js
self.onmessage = (e) => {
  const { items } = e.data;
  // 무거운 계산
  const result = items
    .map(x => ({ ...x, score: heavyScore(x) }))
    .sort((a, b) => b.score - a.score);

  self.postMessage({ result });
};

function heavyScore(x) {
  let s = 0;
  for (let i = 0; i < 20000; i++) s = (s + x.id * i) % 1000003;
  return s;
}
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));

worker.onmessage = (e) => {
  render(e.data.result); // DOM 업데이트는 메인에서
};

button.addEventListener('click', () => {
  // 입력 직후 메인 스레드 점유를 피함
  worker.postMessage({ items: bigItems });
});

Worker로 옮기면 Long Task가 크게 줄고, INP가 눈에 띄게 회복되는 경우가 많습니다.

6) 해결 전략 3: “DOM/레이아웃 폭발” 줄이기

Long Task가 JS가 아니라 Layout/Recalculate Style에 쏠려 있다면, 아래를 우선 점검하세요.

  • 한 번의 입력에 수백~수천 개 노드를 추가/삭제하지 않는가
  • offsetHeight, getBoundingClientRect() 같은 레이아웃 스래싱이 반복되는가
  • 애니메이션을 top/left로 돌려 layout을 유발하지 않는가 (transform 권장)
  • 리스트는 가상화(virtualization) 적용이 가능한가

레이아웃 스래싱 방지 예시

// 나쁜 예: 읽기-쓰기-읽기-쓰기 반복
for (const el of items) {
  const h = el.getBoundingClientRect().height; // read
  el.style.height = (h + 10) + 'px';           // write
}

// 개선: 읽기 먼저 모으고, 쓰기를 나중에 일괄 처리
const heights = items.map(el => el.getBoundingClientRect().height);
items.forEach((el, idx) => {
  el.style.height = (heights[idx] + 10) + 'px';
});

7) 해결 전략 4: 3rd-party 스크립트 실행 타이밍 재설계

INP 급락의 숨은 주범은 “기능 코드”가 아니라 태그/광고/세션리플레이인 경우가 많습니다.

  • 초기 로드에 꼭 필요 없다면 defer/async + 사용자 첫 상호작용 이후 로드
  • 라우트 전환/모달 오픈 같은 상호작용 직후에는 추적 이벤트를 배치로 모아서 idle에 전송

입력 직후 추적을 idle로 미루기

function track(event) {
  const send = () => {
    navigator.sendBeacon('/track', JSON.stringify(event));
  };

  if ('requestIdleCallback' in window) {
    requestIdleCallback(send, { timeout: 1000 });
  } else {
    setTimeout(send, 200);
  }
}

button.addEventListener('click', () => {
  openModal();           // UX 우선
  track({ type: 'open' }); // 추적은 뒤로
});

8) 배포 후 검증: “INP는 평균이 아니라 p75 싸움”

수정 후에는 단순히 Lighthouse 점수만 보지 말고, 아래로 검증하세요.

  • RUM에서 p75 INP가 회복되는지(릴리즈 전/후 비교)
  • Long Task(>200ms 등) 발생 빈도가 줄었는지
  • 특정 페이지/상호작용에만 남아있는지(세그먼트)

또한 성능 회귀는 기능 회귀처럼 CI에서 잡기 어렵기 때문에, 릴리즈 단위로 성능 예산(performance budget) 을 두고 “Long Task가 일정 횟수 이상이면 경고” 같은 운영 가드레일을 만드는 것이 좋습니다. (워크플로우/배포 파이프라인을 체계화하는 관점에서는 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리처럼 자동화 전략을 참고해도 좋습니다.)

체크리스트: INP 급락 시 30분 안에 보는 순서

  1. 어떤 상호작용에서 INP가 튀는가? (페이지/버튼/입력)
  2. DevTools Performance에서 Main Long Task를 클릭 → Bottom-Up 상위 함수 확인
  3. Long Task가
    • JS라면: 분할/transition/worker
    • Layout이라면: 스래싱 제거/DOM 업데이트 최소화/가상화
    • 3rd-party라면: 로드/실행 타이밍 조정
  4. web-vitals + longtask observer로 재발 감시

마무리

INP는 “자바스크립트가 빠르냐”보다 “사용자 입력 순간에 메인 스레드가 비어 있냐”에 더 가깝습니다. 그래서 해결도 최적화(빠르게) 보다 스케줄링(적절한 타이밍에) 이 더 큰 효과를 내는 경우가 많습니다.

INP가 급락했다면, 먼저 Long Task를 잡아 “누가 입력 직후를 점유하는지”를 명확히 한 뒤, 쪼개기·오프로딩·DOM 폭발 방지·3rd-party 지연을 적용해 보세요. 대부분의 케이스는 이 네 가지 안에서 깔끔하게 정리됩니다.