Published on

Chrome INP 악화? Long Task 원인추적·해결

Authors

서버 응답은 빠른데도 사용자 체감이 “버벅임”으로 바뀌는 순간이 있습니다. 특히 Chrome에서 INP가 악화되면, 대개 메인 스레드가 한 번에 너무 오래 점유되는 Long Task가 숨어 있습니다. 문제는 “느리다”가 아니라 “어떤 입력(클릭/탭/키보드)에 대해 어떤 작업이 언제 길어졌는지”를 찾아야 한다는 점입니다.

이 글은 Chrome DevTools 기준으로 Long Task를 원인별로 분해해 추적하고, INP를 실제로 개선하는 패턴(코드 포함)을 정리합니다. LCP/CLS처럼 한 번 측정하고 끝나는 지표가 아니라, 사용자 입력의 연속에서 발생하는 병목을 다루므로 재현·측정·수정의 루프가 중요합니다.

INP와 Long Task의 관계를 빠르게 이해하기

INP(Interaction to Next Paint)는 사용자의 입력이 발생한 시점부터, 그 입력에 대한 UI 업데이트가 화면에 “그려질 때”까지의 지연을 측정합니다. 즉 아래 구간이 길어지면 INP가 나빠집니다.

  • 입력 이벤트가 큐에 쌓여 대기하는 시간(메인 스레드가 바쁨)
  • 이벤트 핸들러 실행 시간(자바스크립트가 오래 돎)
  • 스타일 계산/레이아웃/페인트 등 렌더링 파이프라인 비용

Long Task는 보통 “메인 스레드에서 50ms 이상 연속 실행되는 작업”을 의미합니다. Long Task가 있으면 입력 이벤트가 제때 처리되지 못하고 대기열에서 밀리며, 그 결과 INP가 급격히 악화됩니다.

핵심은 “Long Task를 없애는 것”이 아니라, 사용자 입력 직후의 경로에서 Long Task를 제거하거나 쪼개는 것입니다.

1단계: DevTools에서 Long Task를 재현하고 범인 좁히기

Performance 패널로 입력 한 번을 잡아내기

  1. Chrome DevTools Performance
  2. Screenshots 켜기(시각적으로 언제 멈추는지 확인)
  3. 페이지에서 문제 입력(클릭/스크롤/타이핑)을 수행
  4. 녹화 중지 후, 메인 스레드(Main) 타임라인에서 긴 노란색(스크립트) 구간 확인

여기서 중요한 포인트:

  • 문제 입력 직후에 긴 작업이 있는지 확인
  • Summary에서 Scripting/Rendering 중 무엇이 큰지 확인
  • 콜스택(Call Tree)에서 “내 코드”인지 “서드파티”인지 먼저 분리

Long Task가 내 코드인지 서드파티인지

  • 번들 내부 함수로 이어지면 내 코드일 확률이 큼
  • gtag, fbq, clarity, 광고 SDK 등으로 이어지면 서드파티 가능성이 큼
  • 소스맵이 있다면 함수/파일 단위로 바로 식별 가능

서드파티가 원인이라면 “제거”가 최선이지만, 현실적으로는 지연 로딩/격리/우선순위 조정으로 피해를 줄이는 전략이 필요합니다.

2단계: 원인 유형별로 Long Task를 부수는 방법

Long Task는 대개 아래 네 가지로 수렴합니다.

  1. 입력 이벤트 핸들러가 무거움
  2. 렌더링(레이아웃/스타일/페인트)이 비쌈
  3. 대량 연산(JSON 파싱, 정렬, 마크다운 렌더 등)을 메인 스레드에서 수행
  4. 서드파티 스크립트가 메인 스레드를 점유

각 유형별로 “바로 적용 가능한” 처방을 보겠습니다.

2-1) 입력 이벤트 핸들러가 무거운 경우

증상

  • 클릭 한 번에 API 호출 + 상태 업데이트 + 대량 DOM 변경이 한 이벤트 루프에 몰림
  • onClick 내부에서 동기 작업이 길게 이어짐

해결: 작업을 쪼개고, 입력 직후에는 최소 작업만

아래는 “입력 직후 UI 피드백을 먼저 주고”, 무거운 작업은 다음 틱으로 미루는 패턴입니다.

button.addEventListener('click', async () => {
  // 1) 즉시 피드백(최소한의 DOM 변경)
  button.disabled = true;
  button.textContent = '처리 중...';

  // 2) 다음 프레임으로 넘겨서 페인트 기회를 줌
  await new Promise(requestAnimationFrame);

  // 3) 무거운 작업은 이후에 수행
  const data = await fetch('/api/heavy').then(r => r.json());
  renderResult(data);

  button.disabled = false;
  button.textContent = '완료';
});

포인트는 requestAnimationFrame을 이용해 브라우저가 한 번 그릴 시간을 확보하는 것입니다. 이것만으로도 “클릭했는데 멈춘 느낌”이 크게 줄어 INP가 개선되는 경우가 많습니다.

해결: 디바운스/스로틀은 “입력 종류”에 맞게

스크롤/리사이즈에 무거운 로직을 붙이면 INP뿐 아니라 전체 인터랙션이 나빠집니다.

function throttle(fn, wait) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn(...args);
    }
  };
}

window.addEventListener('scroll', throttle(() => {
  // 스크롤마다 무거운 계산 금지: 필요한 최소만
  updateStickyShadow();
}, 100));

2-2) 렌더링이 비싼 경우(레이아웃 스래싱, 대량 DOM)

증상

  • Performance에서 Scripting보다 Rendering 비중이 큼
  • Recalculate Style, Layout이 입력 직후 길게 나타남

원인 1: 레이아웃 스래싱(읽기/쓰기 교차)

아래처럼 DOM 측정과 DOM 변경이 반복되면 레이아웃이 강제 동기 실행됩니다.

// 나쁜 예: 읽기-쓰기-읽기-쓰기 반복
items.forEach(el => {
  const h = el.offsetHeight; // 읽기
  el.style.height = (h + 10) + 'px'; // 쓰기
});

개선: 읽기 단계와 쓰기 단계를 분리합니다.

// 좋은 예: 읽기 먼저, 쓰기 나중
const heights = items.map(el => el.offsetHeight);
items.forEach((el, i) => {
  el.style.height = (heights[i] + 10) + 'px';
});

원인 2: 한 번에 너무 많은 DOM 업데이트

리스트 2천 개를 한 번에 렌더링하면 입력 이벤트 직후 렌더링이 터집니다.

해결 옵션

  • 가상 스크롤(virtualization)
  • 점진 렌더링(청크로 나눠 추가)

점진 렌더링 예시:

function appendInChunks(container, nodes, chunkSize = 50) {
  let i = 0;

  function work() {
    const frag = document.createDocumentFragment();
    for (let c = 0; c < chunkSize && i < nodes.length; c++, i++) {
      frag.appendChild(nodes[i]);
    }
    container.appendChild(frag);

    if (i < nodes.length) {
      // 브라우저가 입력/페인트 처리할 틈을 주기 위해 다음 프레임으로
      requestAnimationFrame(work);
    }
  }

  requestAnimationFrame(work);
}

이 방식은 총 렌더링 시간은 비슷할 수 있어도, “입력에 대한 응답”이 끊기지 않게 만들어 INP에 특히 유리합니다.

2-3) 대량 연산을 메인 스레드에서 수행하는 경우

증상

  • 긴 노란색 스크립트 구간이 입력 직후 나타남
  • JSON 파싱, 정렬, 텍스트 변환, 이미지 처리 등이 콜스택 상단에 있음

해결: Web Worker로 메인 스레드에서 분리

정렬/가공 같은 순수 계산은 Worker로 빼면 체감이 확 달라집니다.

worker.js

self.onmessage = (e) => {
  const { items } = e.data;
  items.sort((a, b) => b.score - a.score);
  self.postMessage({ items });
};

메인 코드:

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

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

button.addEventListener('click', async () => {
  button.disabled = true;
  await new Promise(requestAnimationFrame);

  const items = await fetch('/api/items').then(r => r.json());
  const sorted = await sortInWorker(items);
  render(sorted);

  button.disabled = false;
});

주의할 점은 Worker로 넘기는 데이터가 너무 크면 복사 비용이 생긴다는 것입니다. 가능하면 구조를 단순화하거나, 필요 시 Transferable을 고려하세요.

2-4) 서드파티 스크립트가 원인인 경우

증상

  • 콜스택에 분석/광고/태그 매니저 관련 함수가 보임
  • 입력과 무관한 시점에 긴 작업이 발생해 입력을 막음

해결 1: 로딩 전략 변경(지연, 조건부, 상호작용 이후)

Next.js라면 next/scriptstrategy를 조정합니다.

import Script from 'next/script';

export default function Page() {
  return (
    <>
      <Script
        src="https://example.com/third-party.js"
        strategy="afterInteractive"
      />
    </>
  );
}

가능하면 lazyOnload로 더 늦추거나, 특정 페이지에서만 로드하도록 조건을 거세요.

해결 2: 태그를 “기능 단위로” 줄이기

서드파티는 하나만 느린 게 아니라, 여러 개가 합쳐져 Long Task를 만들기도 합니다. 실제로는 “측정 도구가 성능을 망치는” 아이러니가 자주 발생합니다.

  • 꼭 필요한 태그만 유지
  • 동일 목적의 태그 중복 제거
  • A/B 테스트 도구는 대상 페이지를 제한

3단계: Long Task를 코드로 감지하고 회귀를 막기

성능은 한 번 고치고 끝나지 않습니다. 배포 후 다시 악화되는 경우가 대부분이라, 회귀 방지 장치가 필요합니다.

Long Task 관측: PerformanceObserver

브라우저에서 Long Task 엔트리를 수집할 수 있습니다.

if ('PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration: Long Task 지속 시간(ms)
      // entry.startTime: 시작 시점
      console.log('Long Task', {
        duration: entry.duration,
        startTime: entry.startTime,
        name: entry.name,
      });
    }
  });

  try {
    observer.observe({ entryTypes: ['longtask'] });
  } catch {
    // 일부 환경에서는 longtask 미지원
  }
}

이 로그를 Sentry 같은 곳으로 보내면 “어느 사용자 환경에서 Long Task가 폭발했는지”를 추적할 수 있습니다.

INP 수집: web-vitals

현업에서는 실사용자(RUM) 기반으로 INP를 보고, 특정 릴리즈에서 악화되었는지 확인해야 합니다.

import { onINP } from 'web-vitals';

onINP((metric) => {
  // metric.value, metric.id, metric.navigationType
  console.log('INP', metric);
});

수집한 INP와 Long Task 로그를 같은 세션 키로 묶으면, “INP가 튄 순간의 Long Task”를 역추적하기 쉬워집니다.

4단계: 디버깅 체크리스트(현업용)

아래 순서로 보면 시간을 아낄 수 있습니다.

  1. 재현 입력 1개를 고정(예: 검색 버튼 클릭)
  2. Performance에서 입력 직후 Main 스레드가 막히는지 확인
  3. Scripting vs Rendering 중 큰 쪽을 먼저 처리
  4. 콜스택으로 내 코드/서드파티 분리
  5. 내 코드면
    • 이벤트 핸들러에서 동기 연산 제거
    • 렌더링 청크 분리, 가상화
    • Worker로 계산 분리
  6. 서드파티면
    • 로딩 전략 변경
    • 필요 태그만 남기기
  7. 수정 후
    • 동일 입력으로 재측정
    • 회귀 방지(RUM + Long Task 관측) 추가

이 접근은 “원인 추적”이 핵심이라는 점에서, 장애 원인 추적 글들과 결이 같습니다. 예를 들어 리눅스에서 프로세스가 죽는 근본 원인을 파고드는 방식처럼, 프론트에서도 Long Task의 근본 원인을 끝까지 좁히는 게 가장 빠른 해결책입니다. 참고로 원인 추적 사고방식은 리눅스 OOM Killer로 프로세스 죽을 때 원인 추적 같은 글과도 유사한 구조로 적용할 수 있습니다.

실전 예시: “클릭하면 모달이 늦게 뜨는” INP 개선

상황:

  • 클릭 시 모달을 열면서 동시에 큰 데이터를 정렬해 목록을 채움
  • 모달이 뜨기 전 화면이 멈춘 느낌

개선 목표:

  • 모달은 즉시 열기
  • 데이터 정렬/렌더는 모달 오픈 후 분리
openButton.addEventListener('click', async () => {
  // 1) 모달 먼저 열기
  modal.classList.add('open');
  modal.innerHTML = '<p>불러오는 중...</p>';

  // 2) 페인트 기회 제공
  await new Promise(requestAnimationFrame);

  // 3) 데이터 로드
  const items = await fetch('/api/items').then(r => r.json());

  // 4) 정렬은 Worker로
  const sorted = await sortInWorker(items);

  // 5) 렌더는 청크로(대량일 때)
  const nodes = sorted.map(item => {
    const li = document.createElement('li');
    li.textContent = item.title;
    return li;
  });

  const ul = document.createElement('ul');
  modal.innerHTML = '';
  modal.appendChild(ul);

  appendInChunks(ul, nodes, 80);
});

이 조합은 INP를 악화시키는 대표적인 “한 방에 다 하기”를 피합니다.

자주 하는 실수

setTimeout(fn, 0)만으로 충분하다고 믿기

setTimeout은 분할에 도움이 되지만, 사용자 입력 직후 페인트를 보장하지는 않습니다. UI를 먼저 보여줘야 한다면 requestAnimationFrame을 함께 고려하세요.

성능 측정이 개발자 PC에서만 끝나는 경우

INP는 저사양 모바일에서 더 크게 악화됩니다. 최소한 CPU 스로틀링을 걸고 확인하세요.

“최적화”가 아니라 “우선순위 재배치”가 필요한데 계속 미시 최적화만 하는 경우

INP는 특히 “입력 직후”가 중요합니다. 전체 처리 시간을 10퍼센트 줄이는 것보다, 입력 직후 200ms를 비워주는 게 효과가 더 큰 경우가 많습니다.

마무리

Chrome에서 INP가 악화되면, 대부분은 Long Task가 메인 스레드를 점유해 입력 이벤트가 밀리는 문제로 귀결됩니다. 해결의 핵심은

  • Performance로 입력 직후의 Long Task를 잡고
  • Scripting/Rendering/서드파티 중 범주를 나눈 뒤
  • 작업을 쪼개고(프레임 양보), 계산을 분리하고(Worker), 렌더를 줄이는(가상화/청크) 방식으로

“사용자 입력에 대한 다음 페인트”를 빠르게 만드는 것입니다.

원인 추적을 체계화해두면, 다음 릴리즈에서 INP가 다시 나빠져도 빠르게 되돌릴 수 있습니다. 이런 진단 루틴은 인프라 문제를 짧은 시간에 좁혀가는 방식과도 닮아 있으니, 트러블슈팅 관점이 필요하다면 systemd 서비스 재시작 루프 10분 진단 가이드 같은 글의 접근법도 함께 참고하면 도움이 됩니다.