Published on

Chrome INP 낮추기 - Long Task 원인추적·해결

Authors

INP(Interaction to Next Paint)는 사용자가 클릭·탭·키 입력 같은 상호작용을 했을 때, 다음 화면 업데이트가 실제로 그려질 때까지의 체감 지연을 측정합니다. LCP처럼 “한 번만” 잘 나오면 되는 지표가 아니라, 사용자가 앱을 쓰는 내내 반복적으로 발생하는 상호작용을 대상으로 하다 보니, 운영 환경에서 갑자기 나빠지는 경우가 많습니다.

실무에서 INP가 높게 나오는 가장 흔한 패턴은 메인 스레드가 Long Task(일반적으로 50ms 이상)로 막혀서 입력 이벤트 처리 자체가 늦어지는 경우입니다. 이 글은 “INP가 높다”는 결과를 “어떤 코드가 메인 스레드를 얼마 동안 점유했는가”로 역추적하고, 재발 방지까지 가능한 형태로 해결하는 방법을 정리합니다.

참고로 렌더링 폭증이 Long Task를 만들기도 하므로, React를 쓰고 있다면 함께 보면 좋은 글로 React 렌더링 폭증, Chrome Profiler로 잡는 법도 추천합니다.

INP와 Long Task의 관계: 왜 클릭이 늦게 먹는가

INP는 크게 다음 구간의 합으로 생각하면 이해가 쉽습니다.

  • 입력 지연(Input delay): 사용자가 이벤트를 발생시켰지만 메인 스레드가 바빠서 이벤트 루프가 그 이벤트를 꺼내지 못하는 시간
  • 처리 시간(Processing time): 이벤트 핸들러가 실행되는 시간(동기 JS, 레이아웃 측정, 스타일 계산 유발 등)
  • 프레젠테이션 지연(Presentation delay): 상태 변경 이후 실제로 다음 페인트가 일어날 때까지의 시간(렌더링, 레이아웃, 페인트, 컴포지팅)

Long Task는 위 세 구간을 모두 악화시킬 수 있습니다.

  • 이미 Long Task가 돌고 있으면 입력 지연이 커집니다.
  • 이벤트 핸들러 자체가 무거우면 처리 시간이 커집니다.
  • 이벤트 핸들러가 DOM을 크게 바꾸거나 레이아웃 스래싱을 일으키면 프레젠테이션 지연이 커집니다.

결론은 단순합니다. INP를 낮추려면 “사용자 입력 시점 전후”에 메인 스레드를 오래 붙잡는 작업을 찾아 쪼개거나, 다른 스레드로 보내거나, 아예 덜 하게 만들어야 합니다.

1단계: DevTools로 INP가 발생한 상호작용을 정확히 잡기

Performance 패널에서 상호작용 이벤트 찾기

  1. Chrome DevTools Performance 탭을 엽니다.
  2. Web Vitals 또는 Screenshots를 켜고, 문제가 되는 상호작용(예: 버튼 클릭)을 재현합니다.
  3. 타임라인에서 Interaction(또는 입력 이벤트로 표시되는 마커)을 찾고, 해당 구간을 확대합니다.

여기서 핵심은 “느린 클릭” 직후에 어떤 Task가 길게 이어지는지 확인하는 것입니다. 보통 다음 중 하나가 보입니다.

  • Event: click 같은 이벤트 핸들러가 길게 실행됨
  • 그 직후 Recalculate Style / Layout / Update Layer Tree / Paint가 크게 발생
  • Function Call이 길게 이어지고 스택에 앱 코드가 찍힘

Long Task를 빠르게 확인하는 체크 포인트

  • Main 스레드 트랙에 50ms 이상 막대가 길게 이어지면 Long Task 후보입니다.
  • 막대를 클릭하면 하단에 Call TreeBottom-Up이 나오는데, 여기서 “내 코드”가 어디서 시간을 쓰는지 찾습니다.

팁: Bottom-Up 뷰는 “누적 시간” 기준으로 정렬되므로, 원인 함수가 여러 번 호출된 경우에도 상단에 잘 드러납니다.

2단계: Long Task 유형별 원인 분류(실무에서 제일 자주 나오는 6가지)

1) 동기 JSON 파싱·대용량 데이터 가공

API 응답이 크거나, 클릭 시점에 대량 정렬/그룹핑/필터링을 하면 쉽게 Long Task가 됩니다.

  • JSON.parse 자체가 무거울 수 있음
  • Array.sort는 데이터가 커지면 급격히 느려짐
  • map/reduce 체인이 클릭 이벤트 안에서 실행되면 INP에 직격탄

2) 렌더링 폭증(상태 업데이트로 대규모 리렌더)

클릭 한 번에 컴포넌트 트리가 대거 리렌더되면 JS 실행 시간과 렌더링 비용이 같이 커집니다. 특히 다음 패턴이 위험합니다.

  • 전역 상태 변경으로 상관없는 컴포넌트까지 리렌더
  • 리스트 렌더링에서 key 불안정
  • 메모이제이션 부재로 매번 비싼 계산 재실행

이 영역은 React 렌더링 폭증, Chrome Profiler로 잡는 법에서 더 깊게 다룹니다.

3) 레이아웃 스래싱(강제 동기 레이아웃)

이벤트 핸들러에서 DOM을 쓰고(변경) 곧바로 읽으면(측정) 브라우저가 레이아웃을 강제로 확정합니다.

  • element.offsetHeight, getBoundingClientRect() 같은 읽기
  • 직전에 style 변경이나 클래스 토글 같은 쓰기

쓰기-읽기-쓰기-읽기 패턴이 반복되면 레이아웃이 여러 번 강제되어 Long Task로 커집니다.

4) 과도한 이벤트 핸들링(특히 scroll, pointermove)

상호작용 자체는 클릭인데, 클릭 직전/직후에 scroll/pointermove에서 무거운 핸들러가 돌고 있으면 입력 지연이 커집니다.

  • passive 옵션 미사용
  • 매 프레임마다 DOM 측정/변경
  • 디바운스/스로틀 부재

5) 서드파티 스크립트(태그, A/B, 광고, 분석)

서드파티는 메인 스레드를 같이 씁니다. “내 코드가 가벼운데도” Long Task가 보이면, 콜스택에 서드파티가 찍히는지 확인하세요.

6) 초기화 작업이 상호작용 시점까지 미뤄짐

코드 스플리팅을 해도 클릭 시점에 큰 번들을 로드하고 초기화하면, 그 순간이 Long Task가 됩니다.

  • 클릭 시점에 동적 import 후 모듈 초기화가 무거움
  • 첫 사용 시점에만 수행되는 캐시 구축/인덱싱

3단계: 해결 전략 1 — Long Task를 “쪼개기”(Yield)

메인 스레드를 오래 점유하는 작업을 “작은 청크”로 나눠서, 중간중간 브라우저가 입력 처리와 렌더링을 할 기회를 주면 INP가 크게 개선됩니다.

requestAnimationFrame로 프레임 경계에 맞춰 나누기

function chunkedWork(items, chunkSize, work) {
  let i = 0;

  function runChunk() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) work(items[i], i);

    if (i < items.length) {
      requestAnimationFrame(runChunk);
    }
  }

  requestAnimationFrame(runChunk);
}

// 클릭 이벤트에서 대량 처리 대신 청크 처리
button.addEventListener('click', () => {
  chunkedWork(bigList, 200, (item) => {
    // 무거운 계산/가공
  });
});

requestAnimationFrame은 다음 페인트 타이밍에 맞춰 실행되므로, 사용자에게 “멈춘 느낌”을 줄이는 데 유리합니다.

setTimeout(0)는 최후의 수단, scheduler가 있으면 더 좋음

Chrome에는 scheduler.postTask가 있는 환경이 있습니다. 지원 환경이라면 우선순위를 조절해 “입력 관련 작업”을 먼저 처리하게 할 수 있습니다.

async function yieldToBrowser() {
  if ('scheduler' in window && 'postTask' in window.scheduler) {
    await window.scheduler.postTask(() => {}, { priority: 'user-visible' });
    return;
  }

  await new Promise((r) => setTimeout(r, 0));
}

async function heavyWorkWithYield(items) {
  for (let i = 0; i < items.length; i++) {
    // 작업
    doSomething(items[i]);

    if (i % 200 === 0) {
      await yieldToBrowser();
    }
  }
}

핵심은 “총량이 큰 작업”을 “사용자 입력 직후 한 번에” 처리하지 않는 것입니다.

4단계: 해결 전략 2 — 메인 스레드 밖으로 보내기(Worker)

데이터 파싱/가공/검색/정렬처럼 DOM과 무관한 작업은 Web Worker로 옮길 수 있습니다. INP 관점에서 가장 확실한 개선 중 하나입니다.

Worker로 정렬/필터링 옮기기 예시

worker.js

self.onmessage = (e) => {
  const { items, query } = e.data;

  // 무거운 작업: 필터 + 정렬
  const filtered = items
    .filter((x) => x.name.includes(query))
    .sort((a, b) => a.score - b.score);

  self.postMessage({ filtered });
};

main.js

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

worker.onmessage = (e) => {
  const { filtered } = e.data;
  renderList(filtered);
};

searchInput.addEventListener('input', (e) => {
  worker.postMessage({ items: bigList, query: e.target.value });
});

주의할 점은 구조화 복사 비용입니다. items가 너무 크면 전송 자체가 비싸질 수 있습니다. 가능하면 다음을 고려하세요.

  • 필요한 필드만 전송
  • Transferable(예: ArrayBuffer) 사용
  • 캐시를 Worker 내부에 두고, 메인에서는 query만 전송

5단계: 해결 전략 3 — 이벤트 핸들러를 “가볍게” 만들기

클릭 핸들러에서 비싼 작업을 직접 하지 않기

클릭에서 해야 할 일은 “상태 변경 예약” 정도로 줄이고, 실제 무거운 작업은 다음 틱으로 미룹니다.

button.addEventListener('click', () => {
  // 즉시 UI 피드백(로딩 상태 등)
  setLoading(true);

  // 무거운 작업은 다음 틱으로
  setTimeout(() => {
    const result = expensiveCompute();
    setData(result);
    setLoading(false);
  }, 0);
});

이 패턴은 절대적인 처리량을 줄이진 못하지만, “입력 직후의 체감”을 개선해 INP에 도움이 됩니다.

스크롤/포인터 이벤트는 passive + 스로틀

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

window.addEventListener(
  'scroll',
  throttle(() => {
    // DOM 측정/변경은 최소화
  }, 100),
  { passive: true }
);

passive: true는 스크롤을 막지 않겠다는 힌트를 주어 브라우저가 스크롤을 더 부드럽게 처리할 수 있게 합니다.

6단계: 해결 전략 4 — 레이아웃 스래싱 제거(읽기/쓰기 분리)

DOM 읽기와 쓰기를 섞지 말고, 읽기는 먼저 모아서 하고 쓰기는 나중에 모아서 하세요.

function updateCards(cards) {
  // 1) 읽기(측정) 먼저
  const rects = cards.map((el) => el.getBoundingClientRect());

  // 2) 쓰기(변경) 나중에
  for (let i = 0; i < cards.length; i++) {
    const el = cards[i];
    const r = rects[i];

    // transform 위주로(레이아웃 영향 최소)
    el.style.transform = `translateY(${Math.round(r.top)}px)`;
  }
}

가능하면 위치 이동은 top/left 대신 transform을 우선 고려하고, 애니메이션은 opacity/transform 중심으로 구성하면 레이아웃 비용을 줄일 수 있습니다.

7단계: 해결 전략 5 — React/SPA에서 INP를 악화시키는 패턴 정리

React 기반 앱에서 Long Task의 흔한 원인은 “렌더링 폭증 + 동기 계산”의 결합입니다.

1) 비싼 계산은 렌더링에서 분리

import { useMemo } from 'react';

function List({ items, query }) {
  const filtered = useMemo(() => {
    return items
      .filter((x) => x.name.includes(query))
      .sort((a, b) => a.score - b.score);
  }, [items, query]);

  return filtered.map((x) => <Row key={x.id} item={x} />);
}

useMemo는 만능은 아니지만, 렌더링 중에 매번 같은 계산을 반복하는 상황을 줄여 Long Task 가능성을 낮춥니다.

2) 입력 중 상태 업데이트는 우선순위를 낮추기

React 18 환경이라면 startTransition으로 입력 반응성을 지킬 수 있습니다.

import { startTransition, useState } from 'react';

function Search() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => {
        const v = e.target.value;
        setQuery(v);

        startTransition(() => {
          setDeferredQuery(v);
        });
      }}
    />
  );
}

입력 값 자체는 즉시 반영하고, 무거운 리스트 필터링에 쓰는 상태는 전환으로 낮은 우선순위로 처리하는 방식입니다.

8단계: “원인추적”을 자동화하는 최소 계측 코드

DevTools로 잡는 것만으로는 재발 방지가 어렵습니다. 운영 환경에서 Long Task를 수집해 “어떤 페이지/상호작용에서” 터지는지 알아야 합니다.

Long Task 관측: PerformanceObserver

export function observeLongTasks(report) {
  if (!('PerformanceObserver' in window)) return;

  try {
    const obs = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // entry.duration: Long Task 지속 시간
        report({
          type: 'longtask',
          duration: entry.duration,
          startTime: entry.startTime,
          name: entry.name,
        });
      }
    });

    obs.observe({ entryTypes: ['longtask'] });
    return () => obs.disconnect();
  } catch {
    // 일부 브라우저/환경 예외 방어
  }
}

이 데이터를 사용자 세션, 라우트, 최근 상호작용(클릭한 버튼 id 등)과 엮으면 “INP가 나쁜 사용자 경험”을 재현 가능한 티켓으로 만들 수 있습니다.

9단계: 개선 검증 체크리스트(재현성 있게)

  1. 동일 디바이스/동일 네트워크에서 측정(가능하면 CPU 스로틀 고정)
  2. 문제 상호작용을 5회 이상 반복 재현 후 중앙값 비교
  3. Performance 타임라인에서
    • 입력 직후 Long Task가 사라졌는지
    • Event: click 핸들러 시간이 줄었는지
    • Layout/Recalculate Style이 입력 직후 몰리지 않는지
  4. 운영 계측에서 Long Task 빈도와 95퍼센타일 duration이 줄었는지

자주 하는 실수: INP를 “네트워크”로만 해결하려는 접근

INP는 네트워크가 아니라 메인 스레드 점유가 주범인 경우가 많습니다. 물론 클릭 후 API를 기다리느라 화면이 늦게 바뀌는 상황도 있지만, 그 경우에도 “로딩 상태를 즉시 페인트”하도록 만들면 INP를 줄일 수 있습니다.

  • 클릭 즉시 로딩 UI를 보여주고(짧은 핸들러)
  • 무거운 계산은 청크/Worker로 분리하고
  • 렌더링 폭증은 Profiler로 줄이는 것

이 조합이 가장 효과적입니다.

마무리: INP는 결국 “메인 스레드 예산” 관리다

INP 최적화는 요령보다 습관에 가깝습니다. 상호작용 직후에 메인 스레드를 50ms 이상 잡아먹는 코드가 있다면, 언젠가 트래픽·데이터·서드파티 변화로 지표가 무너집니다.

  • DevTools Performance로 상호작용 구간을 고정하고
  • Long Task의 콜스택에서 원인 함수를 찾은 다음
  • 쪼개기, Worker, 이벤트 경량화, 레이아웃 스래싱 제거, 렌더링 폭증 완화

이 다섯 축으로 정리해 적용하면, INP는 “감”이 아니라 재현 가능한 엔지니어링 대상이 됩니다.