Published on

Chrome INP 급등? Long Task 추적·해결 가이드

Authors

서버가 빠른데도 “클릭이 늦게 먹는다”, “입력 후 UI가 멈춘다” 같은 체감이 생기면 대개 프론트의 메인 스레드(Main Thread) 가 막힌 상황입니다. 최근 Chrome/CrUX에서 INP(Interaction to Next Paint) 가 핵심 지표로 자리 잡으면서, 예전엔 TBT/TTI로 뭉뚱그려 보던 문제가 INP 급등으로 더 또렷하게 드러납니다.

이 글은 “INP가 왜 튀는지”를 감으로 추측하는 대신, Long Task를 근거로 추적하고 재현→원인 규명→해결→재검증까지 이어지는 실전 흐름을 정리합니다.

> 참고: 프론트 이슈도 결국 배포/캐시/서버 액션 등 백엔드 상태와 얽힐 때가 많습니다. Next.js 환경에서 캐시/CSRF/500이 섞여 증상을 왜곡하는 케이스는 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결도 함께 보시면 원인 분리가 쉬워집니다.

INP와 Long Task의 관계를 정확히 잡기

INP가 측정하는 것

INP는 사용자의 상호작용(클릭/탭/키 입력 등) 부터 다음 페인트(Next Paint) 까지 걸린 시간을 봅니다. 즉,

  • 이벤트가 큐에 들어왔는데 메인 스레드가 바빠서 처리 시작이 늦거나
  • 핸들러가 무겁거나(동기 JS, 레이아웃 스래싱 등) 처리 자체가 오래 걸리거나
  • 처리 후 UI 업데이트가 렌더링/페인트에서 막히는

모든 케이스가 INP를 악화시킵니다.

Long Task란?

브라우저 관점에서 50ms를 넘는 메인 스레드 작업을 Long Task로 봅니다. Long Task가 길고 자주 발생하면 입력 이벤트가 처리되지 못하고 대기합니다. 결과적으로 INP가 뛸 확률이 급격히 올라갑니다.

특히 다음 상황이 흔한 트리거입니다.

  • 대용량 리스트 렌더링/정렬/필터링을 메인 스레드에서 동기 처리
  • JSON 큰 덩어리 파싱, 마크다운/문서 변환, 이미지 처리(캔버스) 등 CPU 집약 작업
  • 복잡한 상태 업데이트로 인한 연쇄 렌더링(React/Vue)
  • 스크롤/리사이즈에 무거운 핸들러 + passive 미설정
  • 서드파티 스크립트(태그 매니저, A/B, 광고) 로딩 후 초기화가 메인 스레드를 점유

1단계: DevTools로 “INP가 튀는 상호작용”을 재현하기

Performance 패널에서 상호작용 기반으로 보기

  1. Chrome DevTools → Performance
  2. “Web Vitals” 또는 “Screenshots” 옵션을 켜고(버전에 따라 다름)
  3. 문제 되는 클릭/키 입력을 실제로 수행한 뒤 Record 종료

여기서 핵심은 다음입니다.

  • Main 트랙에서 50ms 이상 막대(롱 태스크)가 있는지
  • 이벤트(Click/Pointer/Key) 이후 다음 Paint까지 어떤 작업이 길게 이어지는지
  • “Recalculate Style / Layout / Update Layer Tree / Paint”가 연쇄로 터지는지

Long Task의 “원인 함수”까지 내려가기

Performance 프로파일에서 Long Task를 클릭하면 하단에 Call Tree / Bottom-Up가 나옵니다.

  • Bottom-Up: “시간을 제일 많이 쓴 함수”를 빠르게 찾기 좋음
  • Call Tree: 어떤 호출 경로로 들어왔는지(프레임워크/번들/서드파티 포함) 보기 좋음

여기서 (anonymous)minified 로만 보이면, 소스맵이 제대로 잡히는지 확인하세요. 프로덕션에서 재현이 어렵다면, 동일 번들을 스테이징에서 재현하거나 “production sourcemap 업로드(에러/성능 관측 도구 연동)”를 고려합니다.

2단계: Long Task를 “계측”해서 지속적으로 잡기

재현이 어렵거나 특정 사용자/특정 기기에서만 튀는 INP는 DevTools만으로 부족합니다. 이때는 런타임에서 Long Task를 계측해 “언제/어디서/얼마나” 발생하는지 로그로 남겨야 합니다.

PerformanceObserver로 Long Task 수집

브라우저는 longtask 엔트리를 제공합니다(지원 범위는 브라우저별 상이). 아래 코드는 Long Task를 감지해 콘솔/로그로 남기는 기본 형태입니다.

// longtask-observer.js
export function observeLongTasks({ threshold = 50 } = {}) {
  if (typeof PerformanceObserver === 'undefined') return;

  try {
    const obs = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        const dur = entry.duration;
        if (dur < threshold) continue;

        // entry.attribution에는 원인 프레임/스크립트 힌트가 들어갈 수 있음(브라우저별)
        const attribution = entry.attribution?.map((a) => ({
          name: a.name,
          containerType: a.containerType,
          containerSrc: a.containerSrc,
          containerId: a.containerId,
        }));

        console.log('[LongTask]', {
          duration: dur,
          startTime: entry.startTime,
          name: entry.name,
          attribution,
        });
      }
    });

    obs.observe({ entryTypes: ['longtask'] });
    return () => obs.disconnect();
  } catch {
    // 일부 환경에서 longtask 미지원
  }
}

이 데이터를 Sentry/Datadog/RUM 같은 곳으로 보내면, “특정 라우트에서만 Long Task 폭증” 같은 패턴이 보입니다.

web-vitals로 INP 자체도 같이 수집

INP 급등은 Long Task의 결과인 경우가 많지만, “어떤 상호작용에서 INP가 높았는지”도 같이 저장해야 디버깅이 빨라집니다.

import { onINP } from 'web-vitals';

export function reportINP(send) {
  onINP((metric) => {
    send({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      id: metric.id,
      // metric.attribution에 이벤트 타입/타겟 셀렉터 등 유용한 힌트가 포함될 수 있음
      attribution: metric.attribution,
    });
  });
}

metric.attribution에는 “어떤 이벤트가 문제였는지(클릭/키다운 등)”와 “처리/프레젠테이션 지연” 힌트가 포함될 수 있어, Long Task 로그와 매칭하면 원인 좁히기가 매우 쉬워집니다.

3단계: Long Task의 대표 원인별 해결 전략

아래는 실제로 INP를 튀게 만드는 빈도가 높은 원인과, 가장 효과가 큰 처방을 우선순위로 정리한 것입니다.

3.1 무거운 동기 JS(파싱/정렬/변환)를 쪼개기

증상

  • 클릭 후 200~800ms 동안 Main thread가 노란색(스크립트)로 꽉 참
  • Bottom-Up에 JSON.parse, 큰 배열 정렬, 마크다운 렌더러, 날짜 포맷터 등이 상위

해결 1) 작업을 청크로 쪼개서 이벤트 루프에 양보

requestIdleCallback(지원 주의) 또는 setTimeout(0)/scheduler.postTask 등을 활용해 큰 작업을 나눕니다.

export async function chunkedMap(items, fn, chunkSize = 200) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    for (const it of chunk) out.push(fn(it));

    // 메인 스레드에 숨 쉴 틈 제공
    await new Promise((r) => setTimeout(r, 0));
  }
  return out;
}

해결 2) Web Worker로 CPU 작업을 분리

정렬/검색 인덱싱/문서 변환 같은 CPU 작업은 Worker로 보내는 게 INP 개선에 직빵입니다.

// 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));

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

3.2 렌더링 폭발(연쇄 리렌더/대량 DOM) 줄이기

증상

  • 스크립트뿐 아니라 Layout/Paint가 길게 이어짐
  • 클릭 한 번에 React commit이 크고, DOM 노드가 급증

해결 1) 가상화(virtualization)

대량 리스트는 “모두 렌더” 대신 “보이는 것만 렌더”가 기본입니다(react-window 등).

해결 2) 상태 업데이트 배치/불필요한 렌더 제거

  • React: memo, useMemo, useCallback을 “측정 기반”으로 적용
  • 파생 상태는 계산 비용이 크면 캐시하거나 Worker로 이동

해결 3) 입력 직후 UI 피드백을 먼저, 무거운 작업은 뒤로

사용자 인풋에 즉시 반응하는 작은 상태(버튼 로딩, 하이라이트)만 먼저 반영하고, 데이터 처리/정렬/렌더는 뒤로 미룹니다.

button.addEventListener('click', () => {
  button.disabled = true;
  button.textContent = '처리 중...';

  // 무거운 작업은 다음 틱으로
  setTimeout(() => {
    heavyWork();
    button.textContent = '완료';
  }, 0);
});

3.3 Layout Thrashing(레이아웃 스래싱) 제거

증상

  • Performance에서 “Recalculate Style / Layout”이 반복
  • 코드에서 getBoundingClientRect() 같은 측정과 style 변경이 교차

해결: 읽기(Read)와 쓰기(Write) 분리

function updatePositions(nodes) {
  // 1) 읽기 단계
  const rects = nodes.map((el) => el.getBoundingClientRect());

  // 2) 쓰기 단계
  rects.forEach((r, i) => {
    const el = nodes[i];
    el.style.transform = `translate(${Math.round(r.left)}px, 0)`;
  });
}

추가로 애니메이션은 top/left보다 transform을 우선하고, 필요하면 will-change를 제한적으로 사용합니다.

3.4 이벤트 핸들러/리스너 최적화(스크롤/포인터)

증상

  • 스크롤/드래그 중 INP가 튐
  • 이벤트 리스너가 너무 자주 실행되거나, passive가 아니라서 스크롤을 막음

해결 1) passive 옵션

document.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('wheel', onWheel, { passive: true });

해결 2) throttle/debounce + requestAnimationFrame

function rafThrottle(fn) {
  let scheduled = false;
  return (...args) => {
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      fn(...args);
    });
  };
}

window.addEventListener('scroll', rafThrottle(() => {
  // 가벼운 작업만
}));

3.5 서드파티 스크립트가 메인 스레드를 점유하는 경우

증상

  • Long Task attribution에 외부 도메인 스크립트가 보이거나
  • 특정 태그(광고/AB/분석) 활성화 시점부터 INP가 악화

해결 체크리스트

  • 가능하면 defer/async 적용 + 초기화 타이밍을 늦추기
  • 라우트별로 필요한 페이지에서만 로드(전 페이지 공통 삽입 지양)
  • Tag Manager에서 태그 발화 조건을 보수적으로
  • “사용자 상호작용 후 로드” 전략(첫 입력 전까지 로드 지연)

4단계: “원인이 서버 지연처럼 보이는” 함정 분리

INP는 본질적으로 클라이언트 지표지만, 실무에서는 서버/캐시 문제로 UX가 꼬이면서 “클릭 후 멈춤”처럼 보이는 경우가 있습니다.

  • 클릭 → 서버 응답 지연 → UI가 스피너도 못 띄운 채 멈춘 느낌
  • 서버액션/라우터 전환에서 캐시 꼬임 → 반복 리렌더/에러 처리 루프

이때는 네트워크 워터폴과 메인 스레드 Long Task를 동시에 봐야 합니다. Next.js를 쓰는 팀이라면 위에서 언급한 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결처럼 “서버가 불안정해서 프론트가 과도한 재시도/상태 갱신을 하는” 패턴도 함께 점검하세요.

5단계: 개선 효과를 검증하는 방법(재현성 있게)

로컬에서만 빠르게 끝내면 안 되는 이유

개발 PC는 빠르고, 프로덕션 사용자는 느립니다. INP는 특히 저사양/모바일에서 악화가 잘 드러납니다.

  • DevTools Performance에서 CPU 4x/6x slowdown
  • Network throttling(단, INP는 네트워크보다 메인 스레드 영향을 더 받는 경우가 많음)
  • 실제 모바일 기기 원격 디버깅

“전/후 비교”를 숫자로 남기기

  • 동일 상호작용(예: 검색 버튼 클릭, 필터 토글) 시나리오 고정
  • Performance trace 3~5회 측정 후 중앙값 비교
  • Long Task 총합/최대값, INP(또는 Interaction latency) 비교

가능하면 배포 후 RUM으로 INP p75(CrUX 기준의 대표 지표)를 추적해 회귀(regression)를 막습니다.

실전 체크리스트 요약

  • Performance에서 문제 상호작용 직후 Main thread Long Task 확인
  • Bottom-Up으로 “시간 상위 함수” 1~3개를 먼저 제거
  • 큰 동기 작업은 청크 분할 또는 Web Worker로 이동
  • 대량 DOM은 가상화, 불필요 렌더는 측정 기반 최적화
  • Layout thrashing 제거(읽기/쓰기 분리)
  • 스크롤/휠/터치는 passive + rafThrottle
  • 서드파티 스크립트는 로드/초기화 타이밍을 늦추고 페이지별로 제한
  • RUM으로 INP + Long Task를 함께 수집해 “특정 라우트/기기/릴리즈” 상관관계 확보

마무리

INP 급등은 대부분 “이벤트 핸들러가 느려서”라기보다, 그 순간 메인 스레드가 다른 일을 너무 오래 하고 있어서 발생합니다. 그래서 해결도 “코드 한 줄 최적화”보다 Long Task를 찾아 쪼개고(청크/Worker), 렌더링 폭발을 줄이고(가상화/불필요 렌더 제거), 레이아웃 스래싱을 끊는 것이 정공법입니다.

다음 단계로는, 실제 서비스에서 Long Task/INP를 RUM으로 수집해 릴리즈별 회귀를 감시하고, “특정 기능 토글/서드파티 태그”가 INP를 망가뜨리는 지점을 자동으로 찾는 체계를 만드는 것을 권합니다.