Published on

Chrome INP 폭증 원인 찾기 - Long Task·Event Timing

Authors

서버 지표는 멀쩡한데(응답 시간, 에러율 변화 없음) 갑자기 Chrome에서만 INP(Interaction to Next Paint)가 폭증하는 경우가 있습니다. 이런 케이스는 대개 네트워크가 아니라 메인 스레드(Main thread) 점유 문제로 귀결됩니다. 특히 다음 두 축을 놓치면 원인 규명이 길어집니다.

  • Long Task(50ms 이상 메인 스레드 작업): 렌더/스크립트/레이아웃/GC 등으로 이벤트 처리와 페인트가 밀림
  • Event Timing(느린 입력 이벤트의 실제 처리 시간): 어떤 사용자 입력이, 어디서, 얼마나 지연됐는지 “상호작용 단위”로 추적

이 글은 “INP가 왜 튀었는지”를 재현 → 수집 → 상관관계 분석 → 코드 수준 수정까지 이어지는 체크리스트로 정리합니다.

> 참고로 브라우저 렌더링/컴포지팅 이슈를 파고드는 관점은 Safari 스크롤 잔상? Compositor 레이어 디버깅에서도 유사하게 적용됩니다. INP도 결국 ‘언제 메인 스레드가 막혔나’를 찾는 게임입니다.

INP 폭증을 “Long Task + Event Timing”으로 보는 이유

INP는 사용자의 상호작용(클릭/탭/키 입력 등)부터 다음 페인트까지의 지연을 대표값으로 잡습니다. 즉, 아래 중 하나만 발생해도 INP는 크게 튈 수 있습니다.

  1. 이벤트 핸들러 자체가 오래 걸림(무거운 JS)
  2. 이벤트는 빨리 끝났지만, 그 뒤 레이아웃/스타일 계산/페인트가 밀림
  3. 이벤트가 큐에 들어갔는데, 앞에 다른 작업(Long Task)이 길게 점유해서 시작이 늦음

여기서 Long Task는 ‘왜 이벤트가 늦게 시작/완료됐는지’의 공통 원인이 되고, Event Timing은 ‘어떤 이벤트가 INP를 만들었는지’의 증거가 됩니다.

1) DevTools에서 빠르게 범인 좁히기 (Performance 패널)

재현 환경을 먼저 고정하기

INP 폭증은 “특정 기기/특정 입력/특정 화면”에서만 터지는 경우가 많습니다.

  • Chrome 버전(Stable/Canary), OS, 디바이스(모바일/데스크탑)
  • 입력 종류(클릭 vs 키입력 vs 터치)
  • 페이지 상태(초기 로딩 직후? 스크롤 후? 모달 열린 상태?)

가능하면 CPU 4x/6x throttling을 켜서 재현성을 올립니다.

Performance 기록 시 반드시 볼 것

  1. Main 트랙에서 50ms 이상 긴 막대(= Long Task)
  2. 해당 구간에 Event(click, pointerdown, keydown 등)가 끼어 있는지
  3. Long Task의 원인이 Scripting / Rendering / Painting / System / GC 중 무엇인지

흔한 패턴

  • Scripting이 길다: 대량 JSON 파싱, 정렬/필터, 큰 루프, 동기식 상태 계산, 불필요한 리렌더
  • Rendering/레이아웃이 길다: DOM 대량 변경, 레이아웃 스래싱(읽기/쓰기 교차), 큰 테이블
  • GC가 튄다: 이벤트마다 객체를 과도 생성(배열/클로저), 큰 문자열 조작

“왜 Chrome에서만?”의 단서

Chrome의 스케줄링/최적화 특성, 특정 API 구현 차이, 확장 프로그램, 폰트/캔버스/필터 렌더링 경로 차이로 인해 “Chrome에서만” 더 크게 드러날 수 있습니다. 따라서 Chrome Performance에서 Long Task를 먼저 잡고, 같은 상호작용을 다른 브라우저와 비교해 차이를 확인하는 게 빠릅니다.

2) Event Timing으로 ‘INP를 만든 상호작용’을 특정하기

DevTools만으로도 많은 걸 보지만, 운영 환경에서 “어떤 이벤트가 INP를 만들었는지”를 자동 수집하려면 Event Timing API가 핵심입니다.

Event Timing 관측 코드(운영 계측)

아래 코드는 느린 이벤트를 콘솔로 찍는 예시입니다. 실제로는 로그/분석 파이프라인으로 전송하면 됩니다.

// 느린 입력 이벤트를 수집하는 최소 예시
// Chrome에서 Event Timing이 지원되며, entry.duration이 핵심 단서가 됩니다.

const SLOW_EVENT_THRESHOLD = 100; // ms

const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.name: 'click', 'pointerdown', 'keydown' 등
    // entry.duration: 이벤트 처리 + 다음 페인트까지 영향(브라우저 구현에 따라 세부값은 다를 수 있음)

    if (entry.duration >= SLOW_EVENT_THRESHOLD) {
      console.log('[Slow Event]', {
        name: entry.name,
        duration: entry.duration,
        startTime: entry.startTime,
        processingStart: entry.processingStart,
        processingEnd: entry.processingEnd,
        interactionId: entry.interactionId,
        target: entry.target ? entry.target.tagName : undefined,
      });
    }
  }
});

po.observe({ type: 'event', buffered: true, durationThreshold: SLOW_EVENT_THRESHOLD });

읽는 법(실전 포인트)

  • startTime → processingStart가 크면: 이벤트가 큐에서 대기(앞에 Long Task가 막고 있었을 확률)
  • processingStart → processingEnd가 크면: 핸들러/동기 작업이 무거움
  • processingEnd → 다음 페인트가 크면(간접적으로 duration에 반영): 레이아웃/페인트/스타일 계산이 무거움

그리고 interactionId를 키로 묶으면 “한 번의 상호작용”을 더 정확히 추적할 수 있습니다.

3) Long Task를 코드로 잡아내기 (Long Tasks API)

Performance 패널에서 Long Task가 보이는데, 운영에서 어떤 코드 경로가 원인인지 애매할 때가 많습니다. 그럴 땐 Long Tasks API를 함께 씁니다.

// Long Task 감지: 50ms 이상 메인 스레드 점유
const longTaskObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('[Long Task]', {
      duration: entry.duration,
      startTime: entry.startTime,
      name: entry.name,
      attribution: entry.attribution?.map(a => ({
        name: a.name,
        entryType: a.entryType,
        containerType: a.containerType,
        containerSrc: a.containerSrc,
      }))
    });
  }
});

longTaskObserver.observe({ type: 'longtask', buffered: true });

이 데이터는 Event Timing과 합쳐서 보면 효과가 큽니다.

  • 특정 클릭에서 INP가 튐 → 같은 시점에 Long Task 존재 → 그 Long Task 직전에 어떤 라우트/컴포넌트/핸들러가 실행됐는지 추적

4) 흔한 원인별 “패턴 인식” 체크리스트

(1) 클릭 시 setState 폭발/리렌더 폭발

  • 리스트/테이블 전체가 리렌더
  • memoization 부재
  • 상태가 상위로 과도하게 끌어올려짐

대응

  • 렌더 범위 최소화(컴포넌트 분리)
  • memo, useMemo, useCallback목표 지점 적용(무분별 적용 금지)
  • 가상 스크롤(대량 DOM)

(2) 레이아웃 스래싱

패턴

  • 이벤트 핸들러에서 getBoundingClientRect() 같은 레이아웃 읽기style.width = ... 같은 쓰기가 교차

대응

  • 읽기 → 쓰기 순서로 배치
  • 측정값 캐싱
  • requestAnimationFrame으로 DOM 쓰기를 프레임 경계로 밀기
button.addEventListener('click', () => {
  // 읽기
  const rect = panel.getBoundingClientRect();

  // 쓰기는 다음 프레임에
  requestAnimationFrame(() => {
    panel.style.height = `${rect.height + 40}px`;
  });
});

(3) 무거운 동기 작업(정렬/필터/파싱)이 이벤트에 직결

대응 전략은 “쪼개기/오프로딩”입니다.

  • 작업을 청크로 나누고 setTimeout(0)/requestIdleCallback로 분산
  • 가능하면 Web Worker로 이동
// 큰 작업을 청크로 쪼개 메인 스레드 점유를 줄이는 예
function chunkedProcess(items, chunkSize = 500) {
  let i = 0;

  return new Promise((resolve) => {
    function run() {
      const end = Math.min(i + chunkSize, items.length);
      for (; i < end; i++) {
        // 무거운 계산
        items[i].computed = heavy(items[i]);
      }

      if (i < items.length) {
        setTimeout(run, 0);
      } else {
        resolve(items);
      }
    }

    run();
  });
}

(4) 서드파티 스크립트(광고/태그/위젯)로 인한 Long Task

  • 특정 페이지/특정 사용자 세그먼트에서만 INP 폭증
  • Performance에서 호출 스택이 외부 스크립트로 이어짐

대응

  • 지연 로딩(상호작용 이후 로드)
  • 우선순위 낮추기(defer, async, 동적 import)
  • 필요 시 격리(iframe, worker)

(5) 메인 스레드가 “작업 큐”로 막히는 구조적 문제

  • 스크롤/입력과 무관한 주기 작업(setInterval)로 계속 바쁨
  • 애니메이션을 JS 타이머로 구현

대응

  • CSS 애니메이션/컴포지터 친화 속성(transform/opacity) 활용
  • 폴링 제거, 이벤트 기반으로 전환

5) DevTools에서 ‘이벤트-롱태스크-페인트’ 연결하기

실무에서 가장 시간을 절약하는 방법은 한 번의 느린 상호작용을 기준으로 타임라인을 묶어서 읽는 것입니다.

  1. Event Timing으로 느린 interactionId/이벤트 종류를 특정
  2. 해당 시점의 Performance 트레이스에서 Main thread Long Task 확인
  3. Long Task 내부에서 무슨 일이 있었는지(스크립팅/레이아웃/GC) 분류
  4. 원인 코드 경로를 찾고(컴포넌트/함수/서드파티) 재현 케이스로 수정 검증

이때 “캐시/상태 꼬임”이 원인처럼 보이는 프런트 이슈도 종종 섞입니다. 특히 Next.js App Router에서 캐시/데이터 동기화가 어긋나면 불필요한 렌더/재요청으로 메인 스레드가 바빠질 수 있어, 유사 증상일 땐 Next.js 14 App Router 캐시 꼬임 해결법도 같이 점검해볼 만합니다.

6) “수정했는데도 INP가 들쭉날쭉”할 때 보는 것

샘플링/대표값의 함정

INP는 단일 이벤트가 아니라 “세션 내 상호작용” 중 대표값을 사용합니다. 특정 사용자만 겪는 극단값이 전체 지표를 흔들 수 있습니다.

  • 특정 입력(예: 첫 클릭)만 느린지
  • 특정 기능(필터/정렬/검색)에서만 느린지
  • 특정 브라우저 확장/번역/보안 제품이 개입하는지

관측 오버헤드 주의

PerformanceObserver로 과도한 로깅을 하면 오히려 메인 스레드를 압박할 수 있습니다.

  • 임계치 이상만 수집
  • 샘플링(예: 1~5%)
  • 전송은 navigator.sendBeacon 또는 배치 전송
function report(payload) {
  const body = JSON.stringify(payload);
  // 페이지 이동/종료에도 유실이 적음
  navigator.sendBeacon('/rum', body);
}

7) 실전 결론: INP 폭증 대응 플레이북

정리하면, Chrome INP 폭증은 대부분 아래 순서로 해결 속도가 빨라집니다.

  1. 재현 조건 고정(기기/화면/입력)
  2. Performance에서 Long Task 위치를 찾고 원인 분류(Scripting/Rendering/GC)
  3. Event Timing으로 어떤 이벤트가 문제인지 특정(큐 대기 vs 핸들러 vs 렌더)
  4. 코드 수정은 “작업 쪼개기/렌더 범위 축소/레이아웃 스래싱 제거/서드파티 지연”의 우선순위로 적용
  5. 운영 계측으로 재발 방지(느린 이벤트/롱태스크 자동 수집)

INP는 최적화의 끝이 아니라, 사용자 경험을 수치로 드러내는 경보 장치에 가깝습니다. Long Task와 Event Timing을 같이 보면 ‘감’이 아니라 증거 기반으로 원인을 좁힐 수 있고, 팀 내 커뮤니케이션도 “어느 클릭에서 무엇이 300ms를 먹었는지”로 정교해집니다.