Published on

Chrome INP 급락? Long Task 5가지 해결

Authors

서빙 지표가 멀쩡했는데 Chrome에서 INP가 갑자기 급락(악화)하는 순간이 있습니다. 현장에서 가장 흔한 원인은 메인 스레드가 50ms를 넘게 붙잡히는 Long Task가 늘어나면서, 사용자 입력(클릭, 탭, 키 입력)부터 다음 페인트까지의 지연이 커지는 경우입니다.

INP는 단순히 이벤트 핸들러만 빠르게 만든다고 끝나지 않습니다. 이벤트 이후 이어지는 렌더링, 스타일 계산, 레이아웃, 페인트, 그리고 그 사이에 끼어드는 JS 작업까지 합쳐서 “사용자 입장에서 반응이 끝난 시점”을 측정합니다. 따라서 Long Task를 줄이는 접근이 가장 확실합니다.

아래는 DevTools로 Long Task를 찾는 방법과 함께, 실무에서 자주 만나는 Long Task 5가지 패턴을 “어떻게 고치면 INP가 회복되는지”에 초점을 맞춰 정리한 글입니다.

INP와 Long Task를 빠르게 진단하는 루틴

1) DevTools Performance에서 Long Task를 눈으로 찾기

  1. Chrome DevTools Performance
  2. Web Vitals 체크(가능하면)
  3. 문제 재현(클릭, 검색, 탭 전환 등)
  4. 타임라인에서 Main 스레드에 노란색(스크립팅) 블록이 길게 이어지는 구간을 확인

Long Task는 보통 한 덩어리로 보이고, 그 안에 Function Call, Evaluate Script, Recalculate Style, Layout 등이 묶여 있습니다. 중요한 건 “어떤 입력 후에” 발생하는지입니다.

2) Long Task를 코드로 관측하기(간단 계측)

아래는 PerformanceObserver로 Long Task를 콘솔에 찍는 최소 예시입니다.

// long-task-observer.js
export function observeLongTasks({ threshold = 50 } = {}) {
  if (!('PerformanceObserver' in window)) return;

  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.duration >= threshold) {
        // entry.name: 'self' 또는 'same-origin-ancestor' 등
        console.log('[LongTask]', {
          duration: entry.duration,
          startTime: entry.startTime,
          name: entry.name,
        });
      }
    }
  });

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

  return () => po.disconnect();
}

이 계측은 원인 분석의 출발점입니다. Long Task가 “언제” 터지는지 확인한 다음, 아래 5가지 패턴 중 어디에 해당하는지 매칭하면 해결이 빨라집니다.

Long Task 해결 1: 클릭 핸들러에서 동기 대량 연산(정렬, 필터, 파싱)

가장 흔한 형태입니다.

  • 클릭 한 번에 리스트 정렬
  • 입력 한 번에 대량 필터링
  • JSON 대량 파싱 및 가공
  • 마크다운 렌더링, 하이라이팅 같은 무거운 변환

이런 작업을 이벤트 핸들러에서 동기로 처리하면 입력 직후 페인트가 밀리면서 INP가 나빠집니다.

해결 전략

  • 작업을 청크로 쪼개서 프레임 사이에 양보하기
  • 가능하면 Web Worker로 옮기기
  • 데이터 구조/알고리즘 개선(하지만 UI 응답성 관점에서는 “쪼개기/오프로딩”이 더 즉효)

코드: 청크 처리로 메인 스레드 양보

setTimeout보다 requestAnimationFramescheduler를 섞는 방식이 체감이 좋습니다. scheduler가 없으면 폴백합니다.

function yieldToMain() {
  // scheduler.postTask는 지원 브라우저에서 더 정교한 스케줄링이 가능
  if (globalThis.scheduler?.postTask) {
    return scheduler.postTask(() => {}, { priority: 'user-visible' });
  }
  return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}

export async function chunkedFilter(items, predicate, chunkSize = 500) {
  const result = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    for (const it of chunk) {
      if (predicate(it)) result.push(it);
    }
    // 프레임 사이에 양보
    await yieldToMain();
  }
  return result;
}

// 사용 예
button.addEventListener('click', async () => {
  showLoading(true);
  const filtered = await chunkedFilter(bigList, (x) => x.score > 80);
  render(filtered);
  showLoading(false);
});

핵심은 “로딩 UI를 먼저 그릴 기회를 주고”, 그 다음에 계산을 이어가는 것입니다. 이 한 가지로 INP가 크게 개선되는 케이스가 많습니다.

Long Task 해결 2: 렌더 폭발(대량 DOM 업데이트, 리스트 한 번에 그리기)

Long Task가 JS 계산이 아니라 Recalculate Style, Layout, Update Layer Tree 같은 렌더 파이프라인에 잡히는 경우입니다. 대표 증상은 아래와 같습니다.

  • 검색 결과가 2천 건인데 한 번에 DOM으로 렌더
  • 테이블 셀을 반복문에서 하나씩 appendChild
  • 상태 업데이트가 너무 자주 발생해 리렌더가 연쇄적으로 일어남

해결 전략

  • 리스트는 가상 스크롤(virtualization) 적용
  • DOM 추가는 DocumentFragment로 묶어서 한 번에
  • 상태 업데이트는 배치하거나, 입력 중에는 디바운스

코드: 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);
  }

  container.replaceChildren(frag);
}

코드: 입력 이벤트 디바운스로 리렌더 빈도 줄이기

function debounce(fn, delay = 150) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), delay);
  };
}

const onSearch = debounce((q) => {
  const result = searchIndex(q);
  renderList(listEl, result);
}, 150);

inputEl.addEventListener('input', (e) => onSearch(e.target.value));

렌더 폭발 문제는 “한 번의 입력에 너무 많은 UI를 바꾸는 것”이 본질입니다. 가상 스크롤까지 가기 어렵다면, 우선 디바운스와 Fragment만으로도 Long Task가 줄어드는 경우가 많습니다.

Long Task 해결 3: 강제 동기 레이아웃(forced reflow) 루프

다음 패턴이 보이면 강제 레이아웃이 의심됩니다.

  • 반복문에서 getBoundingClientRect()를 호출하면서 동시에 스타일을 변경
  • offsetHeight, scrollTop 같은 레이아웃 값을 읽고 곧바로 DOM 스타일 쓰기

읽기(read)와 쓰기(write)가 섞이면 브라우저가 “지금 당장 레이아웃을 확정”해야 해서 비용이 커지고 Long Task로 이어집니다.

해결 전략

  • DOM 측정은 한 번에 모아서 읽기
  • 스타일 변경은 그 다음 단계에서 한 번에 쓰기
  • 가능하면 transformopacity 중심으로 애니메이션(레이아웃 영향 최소화)

코드: read/write 분리

function layoutSafeUpdate(rows) {
  // 1) read phase
  const rects = rows.map((row) => row.getBoundingClientRect());

  // 2) write phase
  for (let i = 0; i < rows.length; i++) {
    const row = rows[i];
    const r = rects[i];
    // 예: 높이에 따라 클래스 토글
    row.classList.toggle('is-tall', r.height > 40);
  }
}

DevTools Performance에서 Layout이 반복적으로 끼어드는 “톱니 모양” 타임라인이 보이면 이 케이스일 확률이 큽니다.

Long Task 해결 4: 무거운 서드파티 스크립트(태그, A/B, 광고)

INP 급락이 배포 직후가 아니라 “마케팅 태그 추가”, “A/B 도구 삽입” 같은 변경 이후라면, 서드파티가 메인 스레드를 점유했을 가능성이 큽니다.

특징:

  • Evaluate Script가 길고, call stack에 낯선 도메인/번들 이름이 보임
  • 입력과 무관하게도 주기적으로 Long Task가 발생

해결 전략

  • 가능한 스크립트는 defer 또는 동적 로딩
  • 상호작용 이전에 꼭 필요하지 않다면 requestIdleCallback로 지연
  • Tag Manager에서 불필요한 태그 정리, 로딩 조건 강화
  • 서드파티가 DOM을 과도하게 건드리면 대체/제거 검토

코드: 사용자 상호작용 이후 로딩(게이트)

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = src;
    s.async = true;
    s.onload = resolve;
    s.onerror = reject;
    document.head.appendChild(s);
  });
}

let loaded = false;
async function loadAfterFirstInteraction() {
  if (loaded) return;
  loaded = true;

  // 첫 입력 이후에 로드
  await loadScript('https://example.com/third-party.js');
}

['pointerdown', 'keydown', 'touchstart'].forEach((evt) => {
  window.addEventListener(evt, loadAfterFirstInteraction, { once: true, passive: true });
});

이 접근은 “초기 상호작용 품질”을 우선시할 때 특히 효과적입니다. 단, 비즈니스 요구(측정 누락 허용 여부)와 함께 조율해야 합니다.

Long Task 해결 5: 이벤트 폭주(스크롤, 리사이즈, 포인터무브)와 과한 핸들러

INP는 주로 클릭/키 입력 같은 상호작용을 보지만, 실제로는 스크롤 중 UI가 버벅이면 사용자는 “반응이 느리다”고 느끼고, 다른 입력에서도 지연이 커지기 쉽습니다. 특히 아래가 흔합니다.

  • scroll에서 매 픽셀마다 계산/DOM 업데이트
  • mousemove에서 툴팁 위치 갱신을 동기로 처리
  • resize에서 레이아웃 재계산 + 리렌더

해결 전략

  • 스크롤/포인터 계열은 passive: true
  • requestAnimationFrame으로 프레임당 1회로 제한
  • 계산은 캐시하고, DOM 업데이트 최소화

코드: 스크롤 핸들러를 requestAnimationFrame으로 스로틀

function rafThrottle(fn) {
  let scheduled = false;
  let lastArgs;

  return (...args) => {
    lastArgs = args;
    if (scheduled) return;
    scheduled = true;

    requestAnimationFrame(() => {
      scheduled = false;
      fn(...lastArgs);
    });
  };
}

const onScroll = rafThrottle(() => {
  const y = window.scrollY;
  // DOM 업데이트는 필요한 최소만
  header.classList.toggle('is-compact', y > 40);
});

window.addEventListener('scroll', onScroll, { passive: true });

이 패턴은 Long Task 자체를 “0으로” 만들기보다, 이벤트 폭주로 인한 연쇄적인 작업을 막아 INP와 체감 성능을 동시에 개선합니다.

보너스: INP 급락이 “서버/네트워크”처럼 보일 때

INP는 클라이언트 지표지만, 실무에서는 백엔드 지연이 프론트의 Long Task로 전이되는 경우가 있습니다. 예를 들어 API 응답 후 대량 데이터 렌더가 한 번에 터지면, 사용자는 “클릭했는데 늦게 반응”으로 느낍니다.

  • 응답 데이터 크기 줄이기(페이지네이션, 필드 선택)
  • 응답 후 렌더를 점진적으로(스켈레톤, 청크 렌더)

백엔드가 병목이면 아래 글도 함께 보면 원인 분리가 빨라집니다.

체크리스트: 오늘 바로 적용할 우선순위

  1. DevTools Performance로 “문제 입력”을 재현하고 Long Task 구간을 고정
  2. Long Task가 Scripting 중심이면
    • 동기 대량 연산을 청크 처리 또는 Worker로 분리
  3. Long Task가 Layout/Style 중심이면
    • read/write 분리, forced reflow 제거
  4. Rendering이 길면
    • 대량 DOM 렌더를 줄이고(가상 스크롤), 업데이트를 배치
  5. 서드파티가 보이면
    • 로딩 시점 지연, 불필요 태그 제거, 조건부 로딩

마무리

Chrome에서 INP가 급락했다면, 대부분은 “메인 스레드를 오래 붙잡는 작업이 늘었다”로 귀결됩니다. 중요한 건 막연히 최적화하지 말고, 어떤 입력에서 어떤 Long Task가 발생하는지를 먼저 고정한 뒤, 패턴에 맞는 처방(청크 처리, 렌더 폭발 완화, forced reflow 제거, 서드파티 지연 로딩, 이벤트 폭주 제어)을 적용하는 것입니다.

위 5가지는 서로 독립적이면서도 동시에 발생할 수 있습니다. 하나씩 제거해 나가면 INP는 대체로 눈에 띄게 회복됩니다.