Published on

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

Authors
Binance registration banner

서버 지표는 멀쩡한데, 어느 날부터 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 지연을 적용해 보세요. 대부분의 케이스는 이 네 가지 안에서 깔끔하게 정리됩니다.