Published on

Chrome INP 점수 급락 원인 - Long Task 추적법

Authors

서버 지표는 멀쩡한데 Chrome에서만 체감이 뚝 떨어지고, CrUX/PSI에서 INP(Interaction to Next Paint) 점수가 갑자기 급락했다면 가장 먼저 의심해야 할 건 Long Task(50ms 이상 메인 스레드 점유) 입니다. INP는 “사용자 입력(클릭/탭/키 입력) 이후 다음 페인트까지”를 보는데, 메인 스레드가 JS/레이아웃/스타일/페인트 작업으로 막혀 있으면 입력 처리가 큐에 쌓여 INP가 급격히 악화됩니다.

이 글은 INP 급락 → Long Task 재현 → 원인 함수/콜스택 식별 → 코드 레벨 개선까지 한 번에 이어지도록, Chrome DevTools와 브라우저 API 기반의 실전 추적법을 정리합니다.

> 참고: 프론트 성능 이슈는 종종 렌더링 불일치/재렌더 폭증과 함께 옵니다. Next.js를 쓰고 있다면 Next.js Hydration failed 원인 7가지와 해결에서 말하는 “클라이언트에서만 발생하는 추가 렌더/상태 변경”이 Long Task를 키우는지 같이 점검해보세요.

INP가 ‘갑자기’ 나빠지는 전형적인 패턴

INP는 실험실(Lighthouse)보다 실사용(CrUX/RUM) 에서 더 의미가 큽니다. 급락이 발생하는 흔한 트리거는 아래와 같습니다.

  • 배포 직후 특정 화면/컴포넌트에 무거운 동기 작업이 추가됨
    • 예: 클릭 핸들러에서 JSON 대용량 파싱, 정렬/필터링, 마크다운/하이라이트 동기 처리
  • 3rd-party 스크립트 변경/추가
    • 태그매니저, A/B 테스트, 광고, 세션리플레이가 입력 이벤트를 가로채거나 DOM을 과도하게 훑음
  • 리스트/테이블 UI 확장
    • 가상 스크롤 없이 1000행 렌더, 입력마다 필터링 + 재렌더
  • Hydration/초기 렌더 이후 연쇄 setState
    • 마운트 직후 여러 effect가 동기적으로 DOM을 건드려 레이아웃 스래싱 유발

핵심은 “입력 직후”에 메인 스레드를 막는 작업이 생겼는지입니다. INP는 클릭 자체가 느린 게 아니라, 클릭 이후 다음 페인트가 늦는 상황에 민감합니다.

Long Task를 이해해야 INP가 보인다

브라우저 메인 스레드에서 아래 작업은 서로 경쟁합니다.

  • JS 실행(이벤트 핸들러 포함)
  • 스타일 계산 / 레이아웃
  • 페인트 / 컴포지팅
  • 입력 이벤트 처리

여기서 50ms 이상 메인 스레드를 점유하는 작업이 Long Task로 분류됩니다. Long Task가 연속으로 발생하면 입력 이벤트는 처리되지 못하고 대기열에 쌓이고, 결과적으로 INP가 악화됩니다.

중요한 점:

  • “함수가 60ms 걸림”만이 Long Task가 아닙니다.
  • JS 20ms + 레이아웃 25ms + 페인트 15ms처럼 합쳐서 50ms를 넘는 경우도 체감상 입력이 끊깁니다.

1) DevTools로 INP 급락을 ‘재현’하는 방법

Performance 패널: Interaction을 기준으로 본다

  1. Chrome DevTools → Performance
  2. 상단 톱니에서 Web Vitals / Interactions 표시 옵션이 있으면 켭니다(버전에 따라 UI가 다름)
  3. Recording 시작
  4. 문제가 되는 사용자 동작(클릭/입력) 수행
  5. Stop

이후 타임라인에서 다음을 봅니다.

  • Interactions 트랙에서 문제의 interaction 선택
  • 해당 interaction 직후의 Main 트랙에서 긴 노란색(스크립트), 보라색(렌더링) 블록 확인
  • Long Task로 표시되는 덩어리를 클릭해 Bottom-up / Call tree로 원인 함수 탐색

팁:

  • “이벤트 핸들러 함수”가 짧아도, 그 직후 Layout 또는 Recalculate Style이 길면 INP가 커질 수 있습니다.
  • click 핸들러에서 DOM 측정(getBoundingClientRect, offsetHeight) 후 DOM 변경을 반복하면 레이아웃 스래싱이 생깁니다.

Performance Insights: “왜 느린지” 요약을 먼저 본다

최근 Chrome은 Performance Insights에서 “Long task”, “Forced reflow” 같은 요약을 제공합니다. 여기서 Long task의 원인 스크립트 URL이 바로 나오면 3rd-party/번들 구분이 빨라집니다.

CPU 4x/6x Throttling으로 ‘실사용’에 가깝게

로컬 머신이 너무 빠르면 Long Task가 잘 안 보입니다.

  • Performance 패널에서 CPU Throttling을 4x~6x로 설정
  • 모바일에 가까운 조건에서 재현

2) Long Task를 “코드로” 기록하는 방법 (PerformanceObserver)

DevTools만으로는 “특정 사용자/특정 페이지에서만” 발생하는 급락을 잡기 어렵습니다. 이때는 RUM 방식으로 Long Task를 수집하면 재현 비용이 확 줄어듭니다.

아래는 Long Task(및 attribution)를 콘솔에 남기거나, 필요하면 서버로 전송할 수 있는 예시입니다.

// longtask-observer.js
export function observeLongTasks({ report = console.log } = {}) {
  if (!('PerformanceObserver' in window)) return;

  try {
    const obs = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // entry.duration: ms
        // entry.attribution: 원인 컨텍스트(브라우저/버전에 따라 제한적)
        const payload = {
          name: entry.name,
          startTime: Math.round(entry.startTime),
          duration: Math.round(entry.duration),
          // attribution은 실무에서 큰 힌트가 되지만, 항상 존재하진 않습니다.
          attribution: entry.attribution?.map((a) => ({
            name: a.name,
            entryType: a.entryType,
            startTime: Math.round(a.startTime),
            duration: Math.round(a.duration),
            containerType: a.containerType,
            containerSrc: a.containerSrc,
            containerId: a.containerId,
            containerName: a.containerName,
          })),
        };
        report(payload);
      }
    });

    obs.observe({ type: 'longtask', buffered: true });
  } catch {
    // 일부 환경에서 type 기반 observe가 막혀있을 수 있음
  }
}

페이지 진입 시 한 번만 붙이면 됩니다.

import { observeLongTasks } from './longtask-observer.js';

observeLongTasks({
  report: (p) => {
    // TODO: 샘플링/PII 제거 후 전송
    console.log('[LongTask]', p);
  },
});

이렇게 모으면 다음 질문에 답할 수 있습니다.

  • 어떤 URL/라우트에서 Long Task가 많이 터지는가?
  • 특정 브라우저/디바이스에서만 심한가?
  • 배포 버전과 상관관계가 있는가?
  • 3rd-party 스크립트가 attribution에 잡히는가?

3) “입력 직후”에 무엇이 실행되는지: 이벤트 핸들러 계측

INP는 입력과 직접 연결됩니다. 따라서 “클릭 → 무엇이 얼마나 걸렸는지”를 빠르게 보기 위해 핸들러 자체를 계측하는 것도 효과적입니다.

export function instrumentHandler(name, fn) {
  return function (...args) {
    const t0 = performance.now();
    try {
      return fn.apply(this, args);
    } finally {
      const dt = performance.now() - t0;
      if (dt > 16) {
        // 1프레임(16.6ms) 초과면 후보로 기록
        console.warn(`[HandlerSlow] ${name}: ${dt.toFixed(1)}ms`);
      }
    }
  };
}

// 사용 예시
button.addEventListener(
  'click',
  instrumentHandler('open-modal', () => {
    // 무거운 동기 작업이 숨어있지 않은지 확인
    openModal();
  })
);

핸들러가 빠른데도 INP가 나쁘면, 핸들러 이후에 이어지는 렌더링(레이아웃/페인트) 쪽이 범인일 확률이 큽니다.

4) Long Task의 대표 원인과 “쪼개는” 해결 전략

Long Task는 보통 “한 번에 너무 많은 일을 동기적으로 처리”해서 생깁니다. 해결은 대개 분할, 지연, 오프로딩의 조합입니다.

4.1 큰 작업을 프레임 단위로 쪼개기 (yield)

대용량 루프/정렬/필터링을 클릭 핸들러에서 바로 돌리면 입력이 얼어붙습니다. 아래처럼 작업을 chunk로 나눠 메인 스레드에 숨 쉴 틈을 줍니다.

function yieldToMain() {
  // requestAnimationFrame은 다음 프레임에 실행되어 페인트 기회를 줍니다.
  return new Promise((r) => requestAnimationFrame(() => r()));
}

export async function processInChunks(items, chunkSize, onChunk) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    onChunk(chunk);
    await yieldToMain();
  }
}

// 사용 예
await processInChunks(bigList, 200, (chunk) => {
  // 부분 렌더/부분 계산
  renderRows(chunk);
});

이 방식은 총 처리 시간은 비슷해도, INP와 체감 반응성이 크게 개선됩니다.

4.2 Web Worker로 CPU 작업 오프로딩

파싱/압축/암호화/대규모 정렬 같은 CPU 작업은 Worker로 보내는 게 정석입니다.

// worker.js
self.onmessage = (e) => {
  const { items } = e.data;
  items.sort((a, b) => a.score - b.score);
  self.postMessage({ items });
};
// main.js
const worker = new Worker(new URL('./worker.js', import.meta.url));

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

// 클릭 핸들러에서 동기 정렬 대신
const sorted = await sortInWorker(items);
render(sorted);

메인 스레드가 렌더링과 입력 처리에 집중할 수 있어 Long Task가 크게 줄어듭니다.

4.3 레이아웃 스래싱 제거: 읽기/쓰기 분리

다음 패턴은 매우 흔한 Long Task 유발자입니다.

  • DOM 측정(읽기) → DOM 변경(쓰기) → DOM 측정(읽기) → DOM 변경(쓰기)…

해결은 “읽기 단계”와 “쓰기 단계”를 분리하는 것입니다.

// 나쁜 예: 루프 안에서 읽기/쓰기 섞임
for (const el of els) {
  const h = el.getBoundingClientRect().height; // read
  el.style.height = (h + 10) + 'px';          // write
}

// 개선: 먼저 읽고, 나중에 한 번에 쓰기
const heights = els.map((el) => el.getBoundingClientRect().height);
els.forEach((el, i) => {
  el.style.height = (heights[i] + 10) + 'px';
});

4.4 3rd-party 스크립트 격리/지연

INP 급락의 상당수는 “내 코드”가 아니라 외부 스크립트가 원인입니다.

  • 로딩을 defer/async로 바꾸거나
  • 상호작용 이후에 로드(예: 첫 클릭 이후)
  • 필요 없는 페이지에서는 아예 제외(라우트 단위)

DevTools의 Performance에서 Long Task를 클릭했을 때 script URL이 외부 도메인으로 나오면, 우선순위를 높여 대응해야 합니다.

5) “Chrome에서만” INP가 떨어지는 이유 체크리스트

특정 브라우저에서만 악화되는 경우는 보통 아래입니다.

  • Chrome 확장/자동 번역/접근성 도구 영향(재현 시 시크릿 모드로 비교)
  • 특정 Chrome 버전에서의 스케줄링/렌더링 변화
  • passive 옵션 없는 터치/휠 리스너로 스크롤 블로킹

특히 스크롤/터치 상호작용이 문제라면 이벤트 리스너를 점검하세요.

// 스크롤 성능에 영향 큰 이벤트는 passive 고려
window.addEventListener('touchmove', onMove, { passive: true });
window.addEventListener('wheel', onWheel, { passive: true });

6) 실무용 디버깅 플로우(재현 → 증거 → 수정 → 검증)

  1. CrUX/RUM에서 INP 급락 구간과 라우트/디바이스를 특정
  2. 해당 조건에 맞춰 DevTools Performance로 문제 interaction을 재현
  3. Interaction 직후 Main 트랙에서 Long Task 덩어리를 찾고, Call tree로 상위 비용 함수를 식별
  4. 동시에 PerformanceObserver('longtask')현장 데이터를 모아 “정말 그게 상위 원인인지” 확인
  5. 해결은 아래 우선순위로 적용
    • (1) 불필요한 작업 제거
    • (2) 작업 분할(yield)
    • (3) Worker 오프로딩
    • (4) DOM 읽기/쓰기 분리로 레이아웃 스래싱 제거
  6. 수정 후에는
    • 로컬(쓰로틀링)에서 INP 개선 확인
    • 실사용 RUM/CrUX에서 1~2주 단위 추세 확인

> 배포 이후 “클라이언트에서만” 상태가 꼬여 불필요한 재렌더가 폭증하는 케이스도 있습니다. Next.js RSC를 쓴다면 Next.js 14 RSC 캐시 꼬임·stale 데이터 해결법처럼 캐시/데이터 일관성 이슈가 UI 갱신 폭주로 이어지지 않는지도 같이 점검해보면 좋습니다.

마무리: INP는 ‘입력 직후의 메인 스레드 건강검진’이다

INP 점수 급락은 대개 “특정 상호작용 직후 메인 스레드를 오래 붙잡는 작업”이 생겼다는 신호입니다. DevTools Performance에서 Interaction을 기준으로 Long Task를 찾고, 필요하면 PerformanceObserver로 현장 데이터를 수집하면 원인 추적이 훨씬 빨라집니다.

결국 해결책은 단순합니다. 큰 동기 작업을 줄이거나(제거), 쪼개거나(yield), 다른 스레드로 보내고(Worker), 렌더링 비용을 낮추는 것(DOM/레이아웃 최적화). 이 네 가지를 체계적으로 적용하면 “Chrome에서만 갑자기 느려진” INP 급락도 대부분 복구할 수 있습니다.