Published on

Chrome INP 튀는 원인 찾기 - Long Task 추적법

Authors

서버 응답이 빠른데도 INP(Interaction to Next Paint)가 특정 순간에만 크게 튀는 경우가 있습니다. 이런 패턴은 대부분 메인 스레드가 잠깐이라도 ‘바빠져서’ 입력 처리(이벤트)와 다음 페인트가 밀리는 상황에서 발생합니다. 핵심은 그 바쁨의 실체를 Long Task(50ms 이상 메인 스레드 작업) 로 잡아내는 것입니다.

이 글은 “INP가 왜 튀는지”를 감으로 추측하지 않고, Long Task를 증거로 수집하고 범인을 특정하는 추적 절차를 다룹니다.

  • INP와 Long Task의 관계를 정리하고
  • DevTools Performance로 재현·캡처·분석하는 방법
  • PerformanceObserver로 현장에서 로그를 남겨 “간헐적 스파이크”를 잡는 방법
  • 흔한 원인 패턴(동기 JS, 레이아웃 스래싱, 서드파티)과 해결 방향

관련해서 Long Task/TBT를 줄여 INP를 개선하는 접근은 아래 글도 함께 보면 좋습니다.

INP가 ‘튀는’ 전형적인 메커니즘

INP는 사용자 상호작용(클릭/탭/키 입력 등) 이후 다음 화면 반영(페인트) 까지 걸린 시간을 봅니다. 즉, 다음 중 하나라도 막히면 INP가 커집니다.

  1. 이벤트가 큐에 쌓여서 늦게 실행됨(메인 스레드 점유)
  2. 이벤트 핸들러가 오래 걸림(동기 JS, 무거운 로직)
  3. 핸들러 이후 렌더링 경로가 막힘(스타일 계산, 레이아웃, 페인트)

여기서 Long Task는 “1번과 2번”을 강하게 의심하게 만드는 신호입니다. 특히 입력 직전/직후 타임라인에 50ms 이상 Task가 있으면, 그 Task가 INP 스파이크의 직접 원인일 가능성이 큽니다.

1단계: DevTools Performance로 스파이크를 ‘증거화’하기

간헐적으로 튀는 INP는 “재현이 어렵다”가 가장 큰 난관입니다. 하지만 다음 체크리스트로 성공률을 올릴 수 있습니다.

캡처 전 준비

  • Chrome DevTools Performance
  • Screenshots 체크(시각적으로 어느 시점인지 확인)
  • Web Vitals 또는 Event Log 관련 옵션이 보이면 활성화(버전에 따라 UI가 다름)
  • CPU 스로틀링 4x 정도로 걸고 재현해보면(개발 PC가 너무 빠른 경우) Long Task가 더 잘 드러납니다.

캡처 요령

  1. Record 시작
  2. 문제 상호작용을 수행(예: 특정 버튼 클릭, 입력, 드롭다운 열기)
  3. INP가 튀는 순간을 포함해 3~5초 더 기록
  4. Stop

분석 포인트(타임라인에서 무엇을 봐야 하나)

Performance 타임라인에서 아래 순서로 좁혀가면 빠릅니다.

  1. Main 트랙에서 빨간 삼각형/경고 표시가 있는 긴 막대(롱 태스크) 찾기
  2. 그 Long Task를 클릭해서 Bottom-up / Call Tree에서 시간을 먹는 함수 확인
  3. 해당 Task의 앞뒤로 Input(클릭/포인터/키) 이벤트가 있는지 확인
  4. Task 내부에 Recalculate Style, Layout, Update Layer Tree, Paint 같은 렌더링 작업이 길게 붙어 있는지 확인

중요한 감각은 이겁니다.

  • “클릭했는데 반응이 늦다”는 것은 대부분 클릭 이벤트 자체가 늦게 실행되었거나, 실행은 됐지만 다음 페인트가 늦은 것입니다.
  • 타임라인에서 “입력 이벤트”와 “Long Task”의 상대 위치를 보면 어떤 케이스인지 분류할 수 있습니다.

2단계: Long Task를 코드 레벨 범인으로 연결하기

DevTools에서 Call Tree를 보면 대략적인 함수 이름이 보이지만, 번들링/미니파이로 인해 애매할 수 있습니다. 이때는 소스맵과 함께 다음을 확인합니다.

  • 번들러 소스맵이 프로덕션에서도 적용되는지(최소한 스테이징)
  • 프레임워크 런타임 내부가 아니라 “우리 코드” 호출 지점이 어디인지
  • 특정 컴포넌트 렌더/상태 업데이트가 연쇄적으로 발생하는지

흔한 패턴 1: 동기 루프/JSON 파싱/정렬 같은 CPU 작업

예를 들어 클릭 시 다음 같은 작업이 동기로 돌면 바로 Long Task가 됩니다.

button.addEventListener('click', () => {
  // 예: 큰 배열 정렬/필터링을 동기 수행
  const sorted = bigList
    .filter(x => x.enabled)
    .sort((a, b) => a.score - b.score);

  render(sorted);
});

이 경우 해결 방향은 보통 다음 중 하나입니다.

  • 작업을 쪼개서 프레임 사이에 양보(setTimeout, requestAnimationFrame, scheduler.postTask)
  • Web Worker로 오프로딩
  • 데이터 크기 자체를 줄이거나 캐시

흔한 패턴 2: 렌더링 폭발(상태 업데이트 연쇄)

React/Vue 등에서 “클릭 한 번”이 수십 개 컴포넌트 리렌더로 이어지면 Long Task가 됩니다.

  • 메모이제이션(memo, useMemo, computed)이 무력화되는 의존성
  • 리스트 렌더에서 key 불안정
  • 상태를 상위로 끌어올려 불필요한 하위 트리까지 갱신

이 경우 DevTools Performance에서 Scripting이 길게 나오면서 함수 호출이 프레임워크 렌더 쪽으로 몰리는 형태가 자주 보입니다.

흔한 패턴 3: 레이아웃 스래싱(강제 동기 레이아웃)

다음처럼 DOM 읽기(getBoundingClientRect)와 쓰기(style.width = ...)가 섞이면 브라우저가 레이아웃을 반복 강제할 수 있습니다.

function badLayoutLoop(items) {
  for (const el of items) {
    const rect = el.getBoundingClientRect(); // read
    el.style.width = (rect.width + 1) + 'px'; // write
  }
}

해결은 “읽기-쓰기 분리”가 기본입니다.

function goodLayoutLoop(items) {
  const widths = items.map(el => el.getBoundingClientRect().width); // read phase
  items.forEach((el, i) => {
    el.style.width = (widths[i] + 1) + 'px'; // write phase
  });
}

INP가 튀는 케이스에서 이 패턴이 무서운 이유는, 이벤트 핸들러는 짧아 보이는데도 Layout/Recalculate Style이 길게 붙어 “렌더링 때문에 늦는 것처럼” 보이기 때문입니다.

3단계: “간헐적” 스파이크를 잡는 현장 계측(PerformanceObserver)

DevTools로 재현이 잘 되면 좋지만, 실제로는 “특정 사용자/특정 데이터/특정 기기에서만” 튀는 경우가 많습니다. 이때는 Long Task와 INP 후보 이벤트를 현장에서 로깅해야 합니다.

아래 코드는 Long Task를 수집해 합계/최대값을 기록합니다. (브라우저 지원을 고려해 방어 코드를 포함하세요.)

export function observeLongTasks({ onEntry } = {}) {
  if (typeof PerformanceObserver === 'undefined') return () => {};

  let observer;
  try {
    observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // entry.duration: ms
        onEntry?.({
          name: entry.name,
          startTime: entry.startTime,
          duration: entry.duration,
        });
      }
    });

    observer.observe({ entryTypes: ['longtask'] });
  } catch {
    return () => {};
  }

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

이걸 “사용자 상호작용 직후 N초” 구간에만 켜서, 로그 볼륨을 줄이고 원인 상관관계를 만들 수 있습니다.

import { observeLongTasks } from './observeLongTasks.js';

function withInteractionLongTaskProbe(fn) {
  return (...args) => {
    const longTasks = [];
    const stop = observeLongTasks({
      onEntry: (e) => longTasks.push(e),
    });

    const t0 = performance.now();

    try {
      return fn(...args);
    } finally {
      // 상호작용 후 2초만 관찰
      setTimeout(() => {
        stop();
        const total = longTasks.reduce((s, e) => s + e.duration, 0);
        const max = longTasks.reduce((m, e) => Math.max(m, e.duration), 0);

        console.log('interaction longtask stats', {
          since: t0,
          count: longTasks.length,
          total,
          max,
          sample: longTasks.slice(0, 5),
        });

        // TODO: 필요하면 서버로 전송(샘플링 권장)
      }, 2000);
    }
  };
}

document.querySelector('#buy').addEventListener(
  'click',
  withInteractionLongTaskProbe(() => {
    // 실제 클릭 로직
  })
);

이 방식의 장점은 “INP가 튄 사용자 세션”에서 Long Task의 존재 여부와 크기를 바로 확인할 수 있다는 점입니다. 이후엔 그 시점의 추가 힌트(라우트, 화면 상태, 데이터 크기, 활성화된 기능 플래그, 로드된 서드파티 등)를 같이 보내 원인을 좁힙니다.

4단계: Long Task의 ‘정체’를 분류하는 체크리스트

Long Task가 발견되면, 아래 질문에 답하면서 원인을 빠르게 분류할 수 있습니다.

A. Long Task가 Scripting 비중이 큰가?

  • 우리 코드의 무거운 계산
  • 프레임워크 렌더/리컨실리에이션 폭발
  • 서드파티 스크립트(태그 매니저, A/B 테스트, 광고, 분석)

대응:

  • 계산 오프로딩(Worker)
  • 렌더 범위 축소(virtualization, memo)
  • 서드파티 지연 로드/조건부 로드

B. Rendering(Style/Layout/Paint) 비중이 큰가?

  • 레이아웃 스래싱
  • 큰 DOM 트리 변경
  • 폰트 로딩/이미지 리사이즈로 레이아웃 변화

대응:

  • DOM 읽기/쓰기 분리
  • 애니메이션을 transform, opacity 중심으로
  • 레이아웃 비용 큰 컴포넌트 격리

C. 특정 상호작용에서만 발생하는가?

  • “장바구니 담기”처럼 데이터가 커지는 동작
  • “필터 적용”처럼 리스트 재계산/재렌더

대응:

  • 데이터 크기 기반 샘플링 로그
  • 입력 직후 무거운 작업을 지연시키고 먼저 UI 피드백(로딩/스켈레톤) 표시

5단계: 개선을 검증하는 방법(숫자로 끝내기)

원인 후보를 고쳤다면, 다시 Performance 캡처로 다음을 확인합니다.

  • 문제 상호작용 근처 Long Task가 사라졌는지(또는 50ms 미만으로 줄었는지)
  • 이벤트 핸들러 이후 첫 페인트가 앞당겨졌는지
  • CPU 스로틀링 환경에서도 INP 스파이크가 재현되지 않는지

가능하면 필드 데이터(실사용자)로도 확인해야 합니다.

  • 동일 페이지/동일 상호작용에서 Long Task 로그(개수/최대/합계)가 줄었는지
  • INP 분포의 꼬리(특히 p95, p99)가 줄었는지

자주 놓치는 함정 4가지

  1. “클릭 핸들러는 짧은데 INP가 큼”

    • 핸들러 이후에 발생하는 스타일/레이아웃/페인트가 길 수 있습니다. Performance에서 Rendering 구간을 꼭 확인하세요.
  2. 서드파티가 원인인데 우리 코드만 보고 끝냄

    • Long Task의 호출 스택에 외부 도메인 스크립트가 보이면, 로드 타이밍/조건부 로드를 검토해야 합니다.
  3. 개발 환경에서만 측정

    • 개발 모드 오버헤드(소스맵, HMR, devtools hook)로 패턴이 달라질 수 있습니다. 스테이징/프로덕션에 가까운 빌드로 확인하세요.
  4. 한 번의 캡처로 결론

    • INP 스파이크는 분산이 큽니다. 최소 5회 이상 반복 캡처하고, 공통적으로 등장하는 Long Task를 우선순위로 잡는 게 안전합니다.

마무리: INP 스파이크 추적의 핵심은 “상호작용 근처 Long Task”

INP가 튄다는 건 “입력 처리부터 다음 페인트까지” 어딘가가 막힌 것입니다. 그 막힘을 가장 효율적으로 드러내는 증거가 Long Task이고, DevTools Performance와 PerformanceObserver를 조합하면 재현이 어려운 간헐적 스파이크도 데이터로 잡아낼 수 있습니다.

정리하면 다음 순서가 가장 실전적입니다.

  1. Performance로 문제 상호작용 구간을 캡처
  2. Main 트랙에서 Long Task를 찾고 Call Tree로 범인 함수 식별
  3. 재현이 어렵다면 PerformanceObserver로 현장 Long Task 로그 수집
  4. Scripting vs Rendering vs 서드파티로 분류해 해결
  5. p95/p99 INP와 Long Task 통계를 다시 확인

이 흐름만 습관화해도 “INP 튀는 이유를 모르겠다”에서 “어떤 Task가 몇 ms를 먹는다”로 대화가 바뀌고, 해결 속도가 크게 빨라집니다.