Published on

Chrome INP 폭증? Long Task 원인·해결 가이드

Authors

서버 지표(TTFB, 응답시간)는 멀쩡한데, Chrome에서만 INP(Interaction to Next Paint)가 갑자기 튀어 오르는 경우가 있습니다. 대개 “사용자 클릭/입력은 들어왔는데 화면이 다음 프레임으로 갱신되기까지” 메인 스레드가 바빠서 못 그리는 상황입니다. 이때 범인으로 가장 자주 잡히는 게 Long Task(50ms 이상 메인 스레드 작업) 입니다.

이 글에서는 INP가 왜 Long Task에 민감한지, Chrome DevTools/PerformanceObserver로 원인을 어떻게 좁히는지, 그리고 **코드로 어떻게 쪼개고(Chunking), 양보하고(Yield), 옮기고(Worker), 줄일지(렌더링/JS/서드파티)**를 단계별로 정리합니다.

> 참고로 INP가 아니라 TTFB가 갑자기 늘어나는 문제라면 프론트보다 서버/캐시 쪽이 병목일 수 있습니다. Next.js 환경이라면 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기도 함께 확인해보세요.

INP와 Long Task의 관계: “입력 이벤트”가 아니라 “다음 페인트”가 문제

INP는 대략 아래 흐름에서 가장 느린 상호작용(또는 대표 상호작용)을 잡아냅니다.

  1. 사용자가 클릭/탭/키입력
  2. 브라우저가 이벤트 핸들러 실행
  3. 스타일 계산/레이아웃/페인트/컴포지팅
  4. 다음 화면 업데이트(Next Paint)

여기서 2~3 구간에 메인 스레드가 점유되면 입력은 받았는데 화면이 멈춘 것처럼 보입니다. Long Task는 보통 다음 원인으로 생깁니다.

  • 무거운 JS 실행(파싱/컴파일/실행)
  • 큰 DOM 업데이트로 인한 스타일/레이아웃 폭발
  • 동기식 작업(대량 JSON 처리, 정렬/필터링, 마크다운 파싱 등)
  • 서드파티 스크립트(태그 매니저, A/B 테스트, 광고)
  • 렌더링 파이프라인을 강제로 깨는 코드(레이아웃 스래싱)

핵심은 “이벤트 핸들러가 길어서”만이 아닙니다. 이벤트 후에 이어지는 렌더링 단계까지 포함해 메인 스레드가 막히면 INP가 커집니다.

증상 패턴으로 보는 원인 가설

1) 특정 페이지/컴포넌트에서만 INP 폭증

  • 목록/테이블/차트처럼 DOM이 큰 화면
  • 스크롤/필터/정렬/검색 입력 시 느려짐
  • 원인 후보: 대량 렌더링, 가상화 미적용, 비효율적 상태 업데이트

2) 첫 상호작용만 유난히 느림

  • 첫 클릭에서만 뚝 끊김
  • 원인 후보: 코드 스플리팅 미흡, 첫 상호작용 시 lazy 로딩/초기화가 몰림

3) 모바일/저사양에서만 심각

  • 데스크톱은 괜찮고 모바일에서 INP 급증
  • 원인 후보: JS 실행 비용이 절대적으로 큼(번들 과대), 메인 스레드 여유 부족

진단 1: DevTools Performance로 Long Task를 “증거”로 만들기

  1. Chrome DevTools → Performance
  2. CPU Throttling(예: 4x)로 재현성 확보
  3. 녹화(Record) 후 문제 상호작용(클릭/입력) 수행
  4. Main 트랙에서 긴 노란 블록(스크립팅), 보라색(렌더링) 구간 확인

특히 아래를 보세요.

  • Long Task 표시(빨간 삼각형 경고)
  • Call Tree / Bottom-Up에서 시간이 큰 함수
  • “Recalculate Style / Layout / Update Layer Tree / Paint”가 몰리는지

이 단계에서 목표는 “느리다”가 아니라, 어떤 작업이 몇 ms를 먹는지를 특정하는 것입니다.

진단 2: PerformanceObserver로 Long Task를 운영 환경에서 수집

실제 사용자 환경(RUM)에서는 DevTools만으로 부족합니다. Long Task는 PerformanceObserver로 관측 가능합니다.

// longtask 관측 (Chrome 등 지원)
// 주의: 샘플링/전송량 관리 필요
export function observeLongTasks({ onEntry } = {}) {
  if (!('PerformanceObserver' in window)) return;

  try {
    const obs = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // entry.duration: Long Task 지속시간(ms)
        // entry.attribution: 어떤 작업이 원인인지(브라우저/버전에 따라 제한)
        onEntry?.({
          name: entry.name,
          duration: entry.duration,
          startTime: entry.startTime,
          attribution: entry.attribution,
        });
      }
    });

    obs.observe({ type: 'longtask', buffered: true });
    return () => obs.disconnect();
  } catch {
    // Safari 등 미지원/예외
  }
}

운영에서는 다음처럼 묶어서 보내면 좋습니다.

  • 페이지/라우트
  • 상호작용 직전/직후 N초 동안의 Long Task 합/최대
  • 디바이스 메모리/하드웨어 동시성(navigator.deviceMemory, hardwareConcurrency)
  • 번들 버전/릴리즈 태그

이렇게 하면 “특정 릴리즈 이후 모바일에서만 INP가 튄다” 같은 회귀를 빠르게 잡습니다.

해결 1: Long Task를 쪼개기(Chunking) + 양보하기(Yield)

가장 흔하고 효과적인 처방은 큰 동기 루프를 잘게 쪼개고 브라우저에 제어권을 돌려주는 것입니다.

requestIdleCallback로 유휴 시간 활용(가능하면)

function chunkedProcess(items, fn, { chunkSize = 200 } = {}) {
  let i = 0;

  return new Promise((resolve) => {
    function run(deadline) {
      while (i < items.length && (deadline.timeRemaining() > 0)) {
        const end = Math.min(i + chunkSize, items.length);
        for (; i < end; i++) fn(items[i], i);
      }

      if (i < items.length) {
        requestIdleCallback(run);
      } else {
        resolve();
      }
    }

    requestIdleCallback(run);
  });
}
  • 장점: 렌더링을 방해하지 않으면서 처리
  • 단점: 유휴 시간이 부족하면 완료가 늦어질 수 있음(중요 작업에는 부적합)

setTimeout(0) / scheduler.yield로 프레임을 살리기

requestIdleCallback이 애매하거나 “빠르게 끝나야 하는데 끊김만 줄이고 싶다”면 프레임 사이에 양보하세요.

const yieldToMain = () => new Promise((r) => setTimeout(r, 0));

async function processInChunks(items, fn, chunkSize = 500) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    for (const item of chunk) fn(item);

    // 메인 스레드에 양보 → 입력/페인트 처리 기회 제공
    await yieldToMain();
  }
}

Chrome에서는 scheduler.yield()(실험/지원 상황 확인)가 더 의도에 맞을 수 있습니다.

해결 2: 무거운 계산은 Web Worker로 옮기기

메인 스레드에서 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), {
  type: 'module',
});

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

포인트:

  • Worker로 보낼 때 복사 비용이 생깁니다. 큰 데이터는 Transferable(ArrayBuffer 등)로 전송 고려
  • UI 업데이트는 메인에서 하되, 계산만 Worker로

해결 3: 렌더링 비용 줄이기(레이아웃 스래싱/대량 DOM)

Long Task는 JS만이 아니라 스타일/레이아웃에서도 발생합니다.

레이아웃 스래싱 방지: 읽기/쓰기를 분리

나쁜 예(읽기-쓰기-읽기 반복):

for (const el of elements) {
  el.style.height = (el.getBoundingClientRect().height + 10) + 'px';
}

개선(읽기 먼저, 쓰기 나중):

const heights = elements.map((el) => el.getBoundingClientRect().height);
heights.forEach((h, idx) => {
  elements[idx].style.height = (h + 10) + 'px';
});

대량 리스트는 가상화(virtualization)

  • 화면에 보이는 만큼만 DOM을 유지
  • React/Vue/Svelte 모두 가상화 라이브러리나 자체 구현 가능

가상화가 어려우면 최소한:

  • DOM 노드 수 줄이기(불필요한 wrapper 제거)
  • content-visibility: auto;로 오프스크린 렌더 비용 절감
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 1px 80px;
}

해결 4: “첫 상호작용에 몰리는 초기화” 분리

INP가 첫 클릭에서만 튀면, 흔히 아래가 숨어 있습니다.

  • 첫 클릭 시점에만 로딩되는 대형 모듈 import
  • 첫 클릭 때 분석 SDK 초기화
  • 첫 입력 때만 생성되는 거대한 데이터 구조

해결책:

  • 사용자 상호작용 전에(단, 초기 로딩을 망치지 않는 선에서) requestIdleCallback로 준비
  • 또는 진짜 필요한 기능만 클릭 시 로딩하고, 나머지는 지연
// 페이지 로드 후 유휴 시간에 미리 준비
requestIdleCallback(async () => {
  const { warmUp } = await import('./heavy-init.js');
  warmUp();
});

해결 5: 서드파티 스크립트가 Long Task를 만들 때

광고/태그/AB 테스트/챗 위젯은 Long Task의 단골입니다.

체크리스트:

  • async/defer 적용 여부
  • 초기 렌더/상호작용 이후로 로딩 지연
  • 동일한 기능 중복 삽입 여부(태그 매니저에서 중복 태그)
  • 특정 페이지에서만 필요한지(전 페이지 공통 삽입 금지)

가능하면 “사용자 입력 직후”가 아니라 “첫 페인트 이후/유휴 시간”로 미루세요.

해결 6: 프레임워크/라우팅 전환에서 INP가 튈 때

SPA에서 라우팅 전환 시 다음이 겹치면 Long Task가 커집니다.

  • 라우트 전환 + 대형 컴포넌트 마운트
  • 상태 관리 스토어 업데이트 폭발
  • 데이터 페치 결과로 대량 렌더

대응:

  • 라우트 단위 코드 스플리팅
  • 상태 업데이트 배치(batch) 및 불필요한 리렌더 제거
  • 큰 UI는 “스켈레톤 + 점진적 렌더”

서버 렌더/스트리밍이 섞여 TTFB/렌더링이 요동친다면, 앞서 언급한 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기처럼 캐시/재검증 전략도 함께 보정해야 체감이 안정됩니다.

실전 디버깅 루틴(10분 안에 원인 좁히기)

  1. 재현 조건 고정: 특정 디바이스/CPU throttling/네트워크
  2. Performance 기록 후 문제 상호작용 1회만 수행
  3. Main에서 가장 긴 Task 클릭 → Bottom-Up에서 Top offender 확인
  4. offender가 JS면:
    • 큰 루프/파싱/정렬/직렬화/마운트 로직 찾기
    • Chunking/Yield/Worker 중 하나 적용
  5. offender가 Render/Layout면:
    • 레이아웃 스래싱 여부(읽기/쓰기 섞임)
    • DOM 크기, 가상화, content-visibility 검토
  6. 서드파티가 보이면:
    • 로딩 시점 지연/조건부 로딩
  7. 수정 후 같은 조건으로 재측정(전/후 비교)

체크리스트: INP를 안정적으로 낮추는 우선순위

  • 50ms+ Long Task를 “없애기”보다 쪼개서 10~20ms 단위로 만들기
  • 입력 직후(클릭/키입력)에 무거운 작업 실행 금지
  • 큰 계산은 Worker로
  • 대량 DOM은 가상화 또는 content-visibility
  • 레이아웃 스래싱 제거(읽기/쓰기 분리)
  • 서드파티 스크립트는 늦게/필요한 곳에만
  • 운영에서 Long Task를 샘플링 수집해 회귀 감지

마무리: INP 폭증은 “한 방짜리 큰 작업”을 찾는 게임

INP가 나빠졌다는 건 사용자의 입력이 UI 업데이트로 이어지는 경로 어딘가에서 메인 스레드가 길게 붙잡혔다는 뜻입니다. DevTools로 Long Task를 특정하고, 그 작업을 쪼개거나(Chunk/Yield), 옮기거나(Worker), 줄이거나(렌더/서드파티 최적화) 하면 대부분 눈에 띄게 개선됩니다.

다음 단계로는 “어떤 상호작용에서 INP가 나쁜지”를 RUM으로 수집해, 특정 컴포넌트/릴리즈/디바이스에 대해 자동으로 경보를 걸어두면 재발을 크게 줄일 수 있습니다.