Published on

Chrome INP 느림? Long Task 추적·해결법

Authors

INP(Interaction to Next Paint)가 느리다는 건, 사용자가 클릭·탭·키입력 같은 상호작용을 했는데 “다음 화면 변화(페인트)”가 늦게 나왔다는 뜻입니다. 현장에서 INP가 튀는 케이스를 뜯어보면, 대부분은 메인 스레드가 너무 오래 바빠서(= Long Task) 이벤트 핸들러가 늦게 실행되거나, 실행은 됐지만 렌더링이 밀리는 문제로 귀결됩니다.

이 글은 “INP가 느리다”를 진단 가능한 형태로 쪼개고, Chrome DevTools에서 Long Task를 정확히 특정한 뒤, 실제로 줄이는 패턴을 코드로 정리합니다.

관련해서 INP 급증 패턴(롱태스크·레이아웃 이슈)을 더 넓게 보고 싶다면 아래 글도 함께 참고하세요.

INP가 느려지는 메커니즘: “입력”부터 “다음 페인트”까지

INP는 대략 아래 구간의 합으로 이해하면 실무에서 디버깅이 쉬워집니다.

  1. 입력 지연(Input delay): 메인 스레드가 바빠서 이벤트가 큐에 쌓인 시간
  2. 처리 시간(Processing time): 이벤트 핸들러와 그로 인해 실행된 JS 작업 시간
  3. 프레젠테이션 지연(Presentation delay): 스타일 계산, 레이아웃, 페인트, 컴포지팅이 실제로 화면에 반영되기까지의 시간

여기서 Long Task는 보통 12 를 크게 만들고, 부수적으로 3 도 밀어냅니다. 즉 “클릭했는데 반응이 늦다”는 체감과 INP 악화가 같이 옵니다.

Long Task란 무엇이고, 왜 INP를 망가뜨리나

Long Task는 메인 스레드에서 50ms를 초과해 실행되는 작업을 의미합니다. 50ms가 넘으면 사용자의 입력 처리와 렌더링 타이밍을 계속 밀어내기 시작합니다.

Long Task의 전형적인 원인:

  • 큰 배열/객체를 한 번에 처리(정렬, 그룹핑, 필터링, JSON 파싱)
  • 과도한 DOM 업데이트(루프 안에서 appendChild, innerHTML 반복)
  • 레이아웃 스래싱(layout thrash): 읽기(offsetHeight)와 쓰기(style.width)를 번갈아 수행
  • 무거운 서드파티 스크립트(태그 매니저, 광고, A/B 도구)
  • 입력 이벤트에서 동기 XHR(요즘은 드물지만), 과도한 동기 연산

DevTools로 “INP 느린 상호작용”을 Long Task까지 연결하기

1) Performance 패널로 문제 상호작용을 재현하고 기록

  1. Chrome DevTools Performance 열기
  2. Record 를 누르고, INP가 느리게 느껴지는 상호작용(클릭/입력)을 정확히 수행
  3. Stop 후 타임라인을 확대해서 해당 상호작용 구간을 찾기

이때 핵심은 “내가 한 상호작용 직후”에 어떤 작업이 길게 이어졌는지 보는 것입니다.

2) Main 스레드의 Long Task를 찾는 법

Main 트랙에서 길게 이어진 노란색(스크립팅) 블록이 대표적인 Long Task 후보입니다. 해당 블록을 클릭하면 하단에 Call Tree / Bottom-Up / Event Log로 원인이 되는 함수까지 내려갈 수 있습니다.

  • Bottom-Up: 시간이 많이 든 함수부터 역으로 확인하기 좋아서 실무에서 가장 자주 씁니다.
  • Call Tree: 특정 이벤트 핸들러에서 어떤 함수가 연쇄로 호출됐는지 보기 좋습니다.

3) “왜 이 Long Task가 INP에 영향을 줬는지” 확인 포인트

  • 상호작용 이벤트(click, pointerdown, keydown) 이후에 Long Task가 바로 붙어 있는가
  • Long Task가 끝난 뒤에야 Recalculate Style / Layout / Paint 가 진행되는가
  • 이벤트 핸들러 내부에서 DOM 읽기/쓰기가 섞여 있는가

4) Performance Insights와 Web Vitals 오버레이 활용

DevTools에 Performance insights 가 보이는 버전이라면, “Interaction” 단위로 요약이 나오고, 어떤 작업이 지연을 만들었는지 링크로 따라갈 수 있습니다.

추가로 현장에서는 Web Vitals 확장 또는 RUM(Real User Monitoring)에서 INP가 튄 URL과 상호작용 유형을 먼저 좁힌 뒤 DevTools로 재현하는 흐름이 효율적입니다.

코드로 Long Task를 “측정”해 원인을 좁히기

DevTools 재현이 어려운 경우(특정 사용자 데이터, 특정 기기에서만 발생)에는 런타임 계측이 도움이 됩니다.

1) Long Task를 PerformanceObserver로 수집

아래 코드는 Long Task 항목을 수집해 콘솔에 남깁니다. 운영에서는 샘플링해서 로그/지표로 보내면 “어느 페이지에서 어떤 스크립트가 Long Task를 만드는지”를 빠르게 찾을 수 있습니다.

// Long Task 관측 (Chrome 지원)
if ('PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration: ms
      // entry.attribution: 원인 스크립트/컨테이너 정보(지원 범위는 브라우저마다 다름)
      console.log('[LongTask]', {
        name: entry.name,
        startTime: entry.startTime,
        duration: entry.duration,
        attribution: entry.attribution,
      });
    }
  });

  observer.observe({ entryTypes: ['longtask'] });
}

2) 이벤트 핸들러별 처리 시간을 간단히 측정

INP는 “다음 페인트”까지 포함하지만, 우선 핸들러가 너무 긴지부터 확인하는 것이 1차 진단에 좋습니다.

function measureHandler(name, fn) {
  return function wrappedHandler(e) {
    const t0 = performance.now();
    try {
      return fn.call(this, e);
    } finally {
      const t1 = performance.now();
      const dur = t1 - t0;
      if (dur > 16) {
        console.warn('[SlowHandler]', name, dur.toFixed(1) + 'ms');
      }
    }
  };
}

document.querySelector('#buy')
  .addEventListener('click', measureHandler('buy.click', () => {
    // 무거운 작업이 여기 있으면 INP가 악화될 확률이 큼
  }));

Long Task 해결 전략 1: “쪼개기”로 메인 스레드 양보하기

한 번에 200ms 처리하던 일을 10~20ms 단위로 쪼개고, 중간에 브라우저가 입력 처리/렌더링을 할 수 있게 양보하면 INP가 눈에 띄게 좋아집니다.

1) requestAnimationFramesetTimeout 으로 작업 분할

아래는 큰 배열 처리 작업을 프레임 사이에 나눠 실행하는 예시입니다.

function chunkedProcess(items, processItem, chunkSize = 200) {
  let index = 0;

  return new Promise((resolve) => {
    function runChunk() {
      const end = Math.min(index + chunkSize, items.length);
      for (; index < end; index++) {
        processItem(items[index]);
      }

      if (index < items.length) {
        // 다음 프레임으로 양보
        requestAnimationFrame(runChunk);
      } else {
        resolve();
      }
    }

    requestAnimationFrame(runChunk);
  });
}
  • 포인트: “입력 직후”에 무거운 연산을 몰아 넣지 말고, UI 업데이트를 먼저 하고 처리 작업을 뒤로 미루거나 쪼개세요.

2) scheduler.postTask 로 우선순위 낮추기(지원되는 환경에서)

Chrome 계열에서 지원되는 경우가 있어, 백그라운드성 작업을 낮은 우선순위로 밀어둘 수 있습니다.

async function runNonUrgentWork(fn) {
  if (window.scheduler?.postTask) {
    return scheduler.postTask(fn, { priority: 'background' });
  }
  return new Promise((r) => setTimeout(() => r(fn()), 0));
}

Long Task 해결 전략 2: DOM 업데이트 비용을 “배치 처리”로 줄이기

DOM을 루프에서 계속 건드리면 스타일/레이아웃/페인트가 반복돼 Long Task로 이어지기 쉽습니다.

1) DocumentFragment로 DOM 추가를 묶기

function renderList(container, items) {
  const frag = document.createDocumentFragment();

  for (const item of items) {
    const li = document.createElement('li');
    li.textContent = item.title;
    frag.appendChild(li);
  }

  // 실제 DOM 반영은 한 번만
  container.appendChild(frag);
}

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

나쁜 패턴은 “읽고-쓰고-읽고-쓰고”를 반복하는 것입니다.

// 나쁜 예: 루프 안에서 레이아웃 읽기와 쓰기가 섞임
for (const el of document.querySelectorAll('.card')) {
  const h = el.offsetHeight; // read (layout)
  el.style.height = (h + 10) + 'px'; // write
}

// 좋은 예: 읽기 단계와 쓰기 단계를 분리
const els = [...document.querySelectorAll('.card')];
const heights = els.map((el) => el.offsetHeight);

els.forEach((el, i) => {
  el.style.height = (heights[i] + 10) + 'px';
});

Long Task 해결 전략 3: 무거운 계산은 Web Worker로 오프로드

메인 스레드에서 해야 할 일은 “입력 처리와 화면 갱신”입니다. 큰 계산은 Worker로 보내고, 메인은 결과를 받아 렌더링만 하게 구조를 바꾸면 INP 개선 효과가 큽니다.

1) Worker로 JSON 파싱·가공 보내기

worker.js

self.onmessage = (e) => {
  const { raw } = e.data;
  const data = JSON.parse(raw);

  // 예: 무거운 집계
  const result = data.reduce((acc, x) => acc + x.value, 0);

  self.postMessage({ result });
};

main.js

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

worker.onmessage = (e) => {
  const { result } = e.data;
  document.querySelector('#sum').textContent = String(result);
};

async function onClick() {
  // UI를 먼저 바꾸고
  document.querySelector('#status').textContent = 'processing...';

  const raw = await fetch('/big.json').then((r) => r.text());
  worker.postMessage({ raw });
}

document.querySelector('#run').addEventListener('click', onClick);
  • 포인트: Worker로 보내는 데이터 직렬화 비용도 있으니, 너무 자주/너무 큰 객체를 왕복시키지 않게 설계합니다.

Long Task 해결 전략 4: “입력 직후”에는 최소 작업만 하고 나머지는 지연

INP 관점에서 가장 위험한 순간은 사용자가 상호작용한 바로 그 직후입니다. 이때는 아래 원칙이 잘 먹힙니다.

  • 즉시 필요한 UI 반응(버튼 로딩 상태, 토글, 포커스 이동)을 먼저 처리
  • 로그 전송, 추천 계산, 비동기 프리패치 같은 작업은 뒤로 미루기
document.querySelector('#pay').addEventListener('click', async () => {
  // 1) 즉시 반응
  const btn = document.querySelector('#pay');
  btn.disabled = true;
  btn.textContent = '결제 중...';

  // 2) 중요하지 않은 작업은 지연
  setTimeout(() => {
    // 예: analytics
    window.myAnalytics?.track?.('pay_click');
  }, 0);

  // 3) 핵심 네트워크는 비동기로
  const res = await fetch('/api/pay', { method: 'POST' });
  // ...
});

Long Task 해결 전략 5: 서드파티 스크립트가 원인일 때의 실전 대응

서드파티는 “내 코드가 아닌데 INP를 망가뜨리는” 대표 원인입니다. Performance 타임라인에서 스크립트 URL이 보이거나, Bottom-Up 에서 특정 vendor 함수가 상위에 뜨면 아래를 검토합니다.

  • 로드 시점 지연: 초기 상호작용 이전에 꼭 필요한가
  • defer / async 적용 가능 여부
  • Tag Manager에서 트리거를 “첫 상호작용 이후”로 바꾸기
  • 동일 목적 스크립트 중복 로드 제거
  • 샌드박싱: 가능하면 iframe 또는 별도 경로로 격리

예시로, 초기 렌더에 필수 아닌 스크립트는 defer 로 미루고, 더 나아가 사용자가 특정 기능을 열 때만 동적 로드합니다.

<script src="/vendor/abtest.js" defer></script>
async function loadChatWidget() {
  await import('/vendor/chat-widget.js');
  window.ChatWidget.init();
}

document.querySelector('#open-chat')
  .addEventListener('click', loadChatWidget, { once: true });

체크리스트: INP 느림을 빠르게 줄이는 우선순위

  1. Performance 기록에서 상호작용 직후 Main 트랙 Long Task부터 찾기
  2. Long Task의 최상위 원인이 내 코드인지, 서드파티인지 분리
  3. 내 코드라면
    • 큰 연산은 쪼개기 또는 Worker로 이동
    • DOM 업데이트는 배치 처리
    • 레이아웃 읽기/쓰기를 분리
  4. 서드파티라면
    • 로드/실행 시점을 늦추고 중복 제거
    • 필요 시 기능 단위로 동적 로드

마무리: “Long Task를 줄이면 INP는 따라온다”

INP가 느릴 때 가장 생산적인 접근은 “상호작용 직후 메인 스레드를 50ms 이상 점유한 범인”을 찾아내는 것입니다. DevTools Performance에서 Long Task를 함수 단위로 좁히고, 작업을 쪼개거나(양보), DOM 비용을 줄이거나(배치), 계산을 Worker로 빼면(오프로드) INP가 안정적으로 내려갑니다.

INP가 특정 페이지에서만 급증하거나, 레이아웃/렌더링 요소까지 함께 의심된다면 아래 글에서 추가 패턴을 더 확인해 보세요.