Published on

Chrome INP 급락? Long Task 10분 추적·해결

Authors

서빙 지표가 멀쩡한데 유독 **Chrome INP(Interaction to Next Paint)**만 갑자기 나빠지는 순간이 있습니다. 특히 릴리즈 직후나 특정 페이지/플로우에서만 재현되면, 대부분은 메인 스레드(Main Thread)에서 50ms를 초과하는 Long Task가 사용자 입력을 막아 INP를 끌어내립니다.

이 글은 “원인 추정”이 아니라 DevTools에서 Long Task를 바로 잡아내고, 그 자리에서 코드 레벨로 줄이는 흐름을 10분 루틴으로 정리합니다. (React/Next.js, 바닐라 JS 모두 적용 가능)

INP 급락(악화)의 전형적인 패턴

INP는 “사용자 입력(클릭/탭/키 입력) → 다음 페인트까지 걸린 시간”의 최악 또는 상위 퍼센타일을 대표값으로 잡습니다. 그래서 아래 패턴이 있으면 INP가 급격히 악화됩니다.

  • 클릭 직후 동기 JS가 길게 실행되어 렌더링이 막힘
  • 입력 이벤트 핸들러에서 레이아웃 강제 계산(reflow) 유발
  • 대량 DOM 업데이트/리스트 렌더링이 한 번에 발생
  • JSON 파싱/정렬/압축/암호화 등 CPU 작업을 메인 스레드에서 처리
  • 서드파티 스크립트가 특정 타이밍에 긴 작업을 수행

핵심은 “내 코드가 빠르다”가 아니라, 사용자 입력 직후 200~800ms짜리 블로킹 작업이 한 번이라도 있으면 INP가 무너질 수 있다는 점입니다.

10분 추적 루틴: DevTools로 Long Task를 ‘입력 기준’으로 잡기

1) 재현 플로우를 고정하고 시크릿/캐시 클린

  • 시크릿 창(확장 프로그램 영향 최소화)
  • CPU/네트워크 환경을 고정(가능하면 CPU 4x slowdown도 한 번)
  • 문제 페이지에서 “INP가 나빠지는 클릭/입력”을 딱 하나로 고정

2) Performance 패널에서 “입력 → 페인트” 구간을 캡처

  1. Chrome DevTools → Performance
  2. Record 시작
  3. 문제 입력(클릭/키 입력) 1회 수행
  4. Record 종료

3) Main 트랙에서 Long Task를 찾고, 이벤트 원인을 역추적

  • 타임라인에서 입력 시점 주변을 확대
  • Main 트랙의 긴 막대(노란색 Script, 보라색 Rendering 등)를 클릭
  • 우측/하단의 Call Tree / Bottom-Up에서 가장 무거운 함수를 확인

여기서 중요한 건 “전체 페이지 로드”가 아니라 입력 직후 0~2초 구간만 집중하는 것입니다. INP는 상호작용 지표라서, 로드 타임 최적화와 디버깅 관점이 다릅니다.

4) Long Task가 ‘내 코드’인지 ‘서드파티’인지 먼저 분리

  • Call stack에 내 번들 파일(예: app.js, main.tsx)이 보이면 내 코드
  • gtm.js, analytics.js, ads, tagmanager 등이면 서드파티

서드파티가 원인이라면 “제거”가 최선이지만, 현실적으로는 지연 로드/상호작용 이후 로드/샌드박스(iframe)/샘플링 같은 완화 전략이 필요합니다.

원인별 해결: Long Task를 줄이는 7가지 실전 처방

아래는 Performance 캡처에서 가장 자주 나오는 원인과, 바로 적용 가능한 해결책입니다.

1) 입력 핸들러에서 무거운 동기 작업(파싱/정렬/필터)

증상: 클릭 이벤트 핸들러 안에서 JSON 파싱 + 정렬 + 렌더 트리거를 한 번에 수행.

해결: 입력 직후에는 UI를 먼저 그리게 하고(페인트), 무거운 작업은 다음 틱/idle로 미룹니다.

// 나쁜 예: 클릭 직후 동기 작업으로 메인 스레드 블로킹
button.addEventListener('click', () => {
  const data = JSON.parse(bigJsonString);
  data.sort((a, b) => b.score - a.score);
  render(data);
});

// 개선 1: UI 업데이트를 먼저 확정하고, 무거운 작업은 다음 프레임 이후로
button.addEventListener('click', () => {
  showLoadingUI();

  requestAnimationFrame(() => {
    // 다음 페인트 이후 실행
    const data = JSON.parse(bigJsonString);
    data.sort((a, b) => b.score - a.score);
    render(data);
    hideLoadingUI();
  });
});

추가로 requestIdleCallback(지원 범위 주의)로 “사용자 입력 직후”를 피하는 것도 효과적입니다.

const ric = window.requestIdleCallback || ((cb) => setTimeout(() => cb({ timeRemaining: () => 0 }), 1));

button.addEventListener('click', () => {
  showLoadingUI();
  ric(() => {
    const data = JSON.parse(bigJsonString);
    render(data);
    hideLoadingUI();
  });
});

2) 대량 DOM 업데이트(리스트/테이블)로 렌더링 폭발

증상: 클릭 한 번에 1,000개 DOM 노드를 생성/갱신 → Rendering/Style/Layout가 길어짐.

해결: 가상화(virtualization) 또는 incremental rendering(조각 렌더링).

// 조각 렌더링: 한 번에 다 붙이지 말고 50개씩 나눠서 붙이기
function renderChunked(items, container, chunkSize = 50) {
  let i = 0;

  function appendChunk() {
    const frag = document.createDocumentFragment();
    for (let c = 0; c < chunkSize && i < items.length; c++, i++) {
      const el = document.createElement('div');
      el.textContent = items[i].title;
      frag.appendChild(el);
    }
    container.appendChild(frag);

    if (i < items.length) {
      requestAnimationFrame(appendChunk); // 프레임마다 조금씩
    }
  }

  requestAnimationFrame(appendChunk);
}

React/Next.js라면 react-window, react-virtualized 계열로 리스트 가상화가 정석입니다.

3) 레이아웃 스래싱(Layout Thrashing)

증상: DOM 읽기(offsetHeight, getBoundingClientRect)와 쓰기(style.width=...)가 반복되며 강제 reflow.

해결: 읽기/쓰기를 분리해서 배치.

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

// 개선: 먼저 읽기만 모으고, 그 다음 쓰기
const heights = elements.map(el => el.getBoundingClientRect().height);
requestAnimationFrame(() => {
  elements.forEach((el, idx) => {
    el.style.height = (heights[idx] + 10) + 'px';
  });
});

4) 메인 스레드에서 CPU 작업(압축/암호화/이미지 처리)

증상: 입력 직후 Script Long Task가 200ms~수 초.

해결: Web Worker로 오프로딩.

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

button.addEventListener('click', () => {
  showLoadingUI();
  worker.postMessage({ type: 'heavy', payload: bigArray });
});

worker.onmessage = (e) => {
  const { result } = e.data;
  render(result);
  hideLoadingUI();
};
// worker.js
self.onmessage = (e) => {
  if (e.data.type === 'heavy') {
    const payload = e.data.payload;
    // 예: 무거운 정렬/집계
    payload.sort((a, b) => a - b);
    self.postMessage({ result: payload });
  }
};

Worker로 보내기 어려운 객체는 structuredClone 비용도 고려해야 합니다. 대용량은 Transferable(ArrayBuffer)로 전송하는 게 좋습니다.

5) 서드파티 스크립트가 입력 타이밍에 끼어듦

증상: Call Tree에 gtm.js, analytics, heatmap 등이 보이고, 입력 직후 긴 작업.

해결:

  • 상호작용 이후 로드(첫 입력 전에는 로드하지 않기)
  • defer/async로 로드 순서 조정
  • 특정 페이지에서만 로드(전역 삽입 금지)
<!-- 예: 최초 사용자 상호작용 이후에만 로드 -->
<script>
  let loaded = false;
  function loadThirdParty() {
    if (loaded) return;
    loaded = true;
    const s = document.createElement('script');
    s.src = 'https://example.com/third-party.js';
    s.async = true;
    document.head.appendChild(s);
  }

  window.addEventListener('pointerdown', loadThirdParty, { once: true });
  window.addEventListener('keydown', loadThirdParty, { once: true });
</script>

6) 이벤트 핸들러가 너무 자주 실행(스크롤/포인터 이동)

증상: mousemove, scroll에서 매 프레임 무거운 작업 → 입력 시점에도 메인이 바쁨.

해결: passive + requestAnimationFrame 스로틀.

let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;

  requestAnimationFrame(() => {
    // 여기서만 계산/DOM 업데이트
    updateStickyHeader();
    ticking = false;
  });
}, { passive: true });

7) “나중에 하려다” 더 나빠지는 케이스: 마이크로태스크 폭주

증상: Promise.then() 체인이 길게 이어져 메인 스레드가 계속 점유(렌더링 기회 박탈).

해결: 적절히 macrotask로 끊기(setTimeout(0)) 또는 프레임 단위로 분할.

async function processInBatches(items, batchSize = 100) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    doWork(batch);

    // 렌더링 기회를 주기 위해 macrotask로 양보
    await new Promise(r => setTimeout(r, 0));
  }
}

“잡았는지” 확인: INP 관점의 재측정 체크리스트

Long Task를 줄였다고 끝이 아닙니다. INP는 사용자 입력-페인트이므로, 다음을 확인해야 합니다.

  • 문제 입력을 3~5회 반복해도 긴 작업이 다시 튀지 않는가?
  • 같은 입력이라도 캐시 유무/데이터 크기에 따라 Long Task가 커지지 않는가?
  • 스로틀 CPU(4x)에서 최악값이 과도하게 튀지 않는가?
  • 모바일 실기기(특히 중저가)에서 메인 스레드가 더 쉽게 막히지 않는가?

가능하면 RUM(Real User Monitoring)에서 INP 분포를 같이 보세요. “일부 유저만” 겪는 경우는 데이터 크기(계정별), 서드파티, 확장 프로그램, 기기 성능 편차가 원인인 경우가 많습니다.

Next.js/React에서 특히 자주 터지는 포인트

  • 상태 업데이트 한 번에 큰 컴포넌트 트리가 리렌더
  • 리스트 키(key) 불안정으로 DOM 재생성
  • useEffect에서 동기 연산 + 연쇄 setState
  • RSC/캐시로 인한 stale UI를 해결하려다 클라이언트에서 보정 로직이 커짐

Next.js 캐시/렌더링 전략 때문에 UI가 꼬여서 “클라이언트에서 후처리”를 덧대다 보면 Long Task가 생기기도 합니다. 관련해서는 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 참고하면, 불필요한 클라이언트 보정 로직을 줄이는 데 도움이 됩니다.

운영 관점: ‘급락’은 보통 배포/외부요인과 같이 온다

INP가 갑자기 나빠졌다면, 코드 변경 외에도 환경/의존성 변화가 트리거였을 수 있습니다.

  • 서드파티 태그 버전 변경(GTM 컨테이너 업데이트)
  • A/B 실험 스크립트 추가
  • 특정 API 응답이 커져서(예: payload 증가) 파싱/렌더 비용 증가
  • 에러 재시도 루프/폴링 증가로 메인 스레드가 상시 바쁨

이런 “외부요인”은 백엔드/인프라 이슈처럼 원인을 분해하는 접근이 유효합니다. 예를 들어 네트워크 egress 문제가 생기면 프론트에서 재시도/타임아웃 처리로 메인 스레드가 바빠지는 식의 2차 효과도 생깁니다. 비슷한 진단 관점은 GCP Cloud NAT 포트 고갈로 egress 실패 진단법 같은 글의 체크리스트 접근이 참고가 됩니다.

마무리: 10분 안에 끝내는 핵심 요약

  • INP 급락(악화)의 1순위는 입력 직후 Main Thread Long Task
  • Performance 캡처는 “전체”가 아니라 입력 후 0~2초만 확대해서 본다
  • Call Tree/Bottom-Up으로 가장 무거운 함수를 특정한다
  • 해결은 대체로 3가지로 수렴
    • 작업을 쪼개서 프레임에 분산(rAF/배치)
    • 메인 스레드 밖으로 이동(Web Worker)
    • 불필요한 렌더/레이아웃을 줄임(가상화/스래싱 제거)

이 루틴으로도 Long Task가 안 보이는데 INP만 나쁘다면, 다음 단계로는 Event Timing API 기반 RUM, 특정 입력의 핸들러 체인(캡처/버블), 프레임 드랍(GPU/애니메이션)까지 확장해서 보는 게 좋습니다.