Published on

Chrome INP 급락 원인 - 롱태스크 분해·스케줄링

Authors

서버 응답이 빠르고 LCP도 괜찮은데, 어느 날부터 사용자 체감이 뚝 떨어지고 CrUX나 RUM에서 INP가 급락하는 경우가 있습니다. 특히 Chrome 업데이트 이후나 특정 기능 배포 이후에 이런 현상이 자주 관측됩니다. 결론부터 말하면, INP 급락의 상당수는 네트워크가 아니라 메인 스레드가 너무 오래 붙잡히는 롱태스크와, 작업을 적절한 타이밍에 양보하지 않는 스케줄링 실패에서 시작합니다.

이 글에서는 Chrome에서 INP가 나빠지는 전형적인 패턴을 짚고, DevTools로 원인을 찾는 방법, 그리고 롱태스크 분해스케줄링 전략으로 INP를 안정적으로 복구하는 방법을 코드 예제와 함께 정리합니다.

INP가 무엇을 측정하는가

INP(Interaction to Next Paint)는 사용자의 상호작용(클릭, 탭, 키 입력 등) 이후 다음 페인트가 일어나기까지 걸린 시간을 측정합니다. 즉, 이벤트 핸들러가 빨리 끝나더라도 렌더링이 밀리면 INP는 나빠질 수 있습니다.

INP가 나빠지는 경로는 보통 다음 중 하나입니다.

  • 이벤트 핸들러 자체가 오래 걸림(동기 JS 연산, JSON 파싱, 대량 DOM 조작)
  • 이벤트는 빨리 끝났지만, 뒤에 이어진 레이아웃·스타일 계산·페인트가 밀림
  • 상호작용 직후에 실행되는 다른 작업(타이머, 프라미스 체인, 서드파티 스크립트)이 메인 스레드를 점유
  • 입력 이벤트가 처리되기 전에 이미 큐가 막혀 있음(이미 롱태스크가 실행 중)

핵심은 하나입니다. 사용자 입력이 들어오는 순간 메인 스레드가 비어 있어야 하고, 입력 직후에는 가능한 한 빨리 렌더링까지 도달해야 합니다.

Chrome에서 INP가 “갑자기” 급락하는 흔한 원인

1) 기능 배포로 롱태스크가 새로 생김

예를 들어, 클릭 시 다음을 한 번에 처리하면 쉽게 롱태스크가 됩니다.

  • 상태 업데이트
  • 리스트 정렬·필터링
  • 가상 DOM diff 비용 증가
  • DOM 측정(getBoundingClientRect)과 스타일 변경이 섞임
  • 로깅/분석 이벤트 전송을 동기적으로 처리

이런 작업이 50ms를 넘기면 DevTools에서 롱태스크로 표시되고, 100ms 이상이면 INP를 크게 악화시키는 경우가 많습니다.

2) 서드파티 스크립트가 입력 직후에 무거운 일을 함

광고/분석 SDK가 페이지 전환 직후나 클릭 직후에 큰 작업을 수행하면, “내 코드”가 아니라도 INP는 나빠집니다. 특히 SPA에서 라우트 전환 시점에 여러 SDK가 동시에 초기화되면 급락이 잘 발생합니다.

3) 스케줄링이 잘못되어 렌더링보다 무거운 작업이 먼저 실행됨

예를 들어 클릭 직후에 Promise.then 체인으로 무거운 연산을 이어 붙이면, 브라우저가 페인트로 가기 전에 마이크로태스크가 길게 이어져 렌더링이 지연될 수 있습니다.

  • 마이크로태스크(프라미스)는 렌더링보다 우선적으로 비워지는 경향이 있어, 과도하면 페인트가 밀림
  • setTimeout도 남발하면 이벤트 루프가 어지러워지고, 입력 처리 우선순위가 흐려짐

DevTools로 “INP 급락”의 범인을 찾는 법

1) Performance 패널에서 상호작용을 재현

  1. Chrome DevTools Performance 탭에서 녹화 시작
  2. 문제 상호작용(클릭, 입력)을 실제로 수행
  3. 녹화 종료 후 메인 스레드 타임라인 확인

여기서 확인할 포인트는 다음입니다.

  • Main 스레드에 50ms 이상 블록이 있는지(롱태스크)
  • 입력 이벤트 직후에 스크립팅이 길게 이어지는지
  • 레이아웃/리칼큘레이트 스타일이 반복되는지
  • 특정 함수가 Hot path인지(콜스택)

2) Long task를 “왜” 길어졌는지 분해해서 본다

롱태스크는 보통 “큰 덩어리 한 번”이 아니라, 여러 작업이 한 이벤트 핸들러 안에서 연쇄적으로 일어나 길어집니다.

  • 대량 데이터 처리
  • DOM 업데이트
  • DOM 측정
  • 렌더링 트리 계산

이 중 무엇이 큰지부터 분리해야 해결이 명확해집니다.

3) 실서비스에서는 INP 원인 로그를 같이 남겨라

RUM을 사용한다면 PerformanceObserver로 INP에 해당하는 interaction entry를 수집하고, “어떤 화면/어떤 컴포넌트/어떤 클릭”에서 나쁜지 태깅해야 합니다.

아래 코드는 INP를 관측해 상호작용의 타겟과 시간을 로깅하는 예시입니다.

// INP 관측 (최신 브라우저)
// entry의 구조는 브라우저 버전에 따라 차이가 있을 수 있습니다.
const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // interactionId 기반으로 묶이는 경우가 많습니다.
    const name = entry.name;
    const duration = entry.duration;

    // duration이 클수록 INP 후보
    if (duration > 200) {
      // 실제 서비스에서는 라우트/사용자 상태/AB버전 등을 함께 전송
      console.log('[INP candidate]', { name, duration });
    }
  }
});

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

해결 전략 1: 롱태스크를 “작게 쪼개고” 중간에 양보하기

INP 개선의 가장 확실한 방법은 메인 스레드 점유 시간을 짧게 만드는 것입니다. 즉, 한 번에 200ms 쓰는 작업을 20ms짜리 10번으로 나누고, 중간에 브라우저가 입력 처리와 렌더링을 할 수 있도록 양보합니다.

1) 나쁜 예: 클릭 핸들러에서 동기 루프 + DOM 업데이트

document.querySelector('#btn').addEventListener('click', () => {
  // 무거운 계산
  const items = [];
  for (let i = 0; i < 200000; i++) {
    items.push({ i, v: Math.sqrt(i) });
  }

  // DOM 업데이트까지 한 번에
  const el = document.querySelector('#out');
  el.textContent = items.length + ' items';
});

2) 개선: 청크로 나누고 requestAnimationFrame 또는 setTimeout으로 양보

아래는 계산을 청크로 나누고, 프레임 사이에 작업을 진행하는 방식입니다.

function chunkedWork(total, chunkSize, onProgress) {
  let i = 0;
  const items = [];

  return new Promise((resolve) => {
    function runChunk() {
      const end = Math.min(i + chunkSize, total);
      for (; i < end; i++) {
        items.push({ i, v: Math.sqrt(i) });
      }

      onProgress?.(i / total);

      if (i < total) {
        // 다음 프레임으로 양보: 입력/렌더링 기회 제공
        requestAnimationFrame(runChunk);
      } else {
        resolve(items);
      }
    }

    requestAnimationFrame(runChunk);
  });
}

document.querySelector('#btn').addEventListener('click', async () => {
  const el = document.querySelector('#out');
  el.textContent = 'working...';

  const items = await chunkedWork(200000, 5000, (p) => {
    el.textContent = `working... ${Math.round(p * 100)}%`;
  });

  el.textContent = items.length + ' items';
});

이 패턴의 장점은 다음과 같습니다.

  • 입력 이벤트가 들어와도 프레임 사이에 처리될 수 있음
  • 렌더링이 중간중간 진행되어 “멈춘 느낌”이 줄어듦

단점은 총 처리 시간이 약간 늘 수 있다는 점입니다. 하지만 INP는 “총 시간”보다 “사용자가 멈췄다고 느끼는 구간”을 줄이는 것이 핵심입니다.

해결 전략 2: 입력 직후에는 “렌더링 먼저”, 무거운 작업은 뒤로 미루기

클릭 직후에 해야 할 일이 2종류라고 가정해봅시다.

  • 즉시 UI 반응(버튼 비활성화, 스피너 표시)
  • 무거운 데이터 처리(정렬, 필터, 대량 변환)

이때는 UI 반응이 먼저 페인트되도록 스케줄링해야 합니다.

1) 렌더링 우선 패턴: requestAnimationFrame을 이용해 페인트를 유도

function nextFrame() {
  return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}

async function onClick() {
  // 1) 즉시 UI 업데이트
  document.querySelector('#btn').setAttribute('disabled', 'true');
  document.querySelector('#out').textContent = 'loading...';

  // 2) 다음 프레임까지 양보해서 페인트 기회를 줌
  await nextFrame();

  // 3) 무거운 작업 수행(여전히 메인 스레드라면 청크로)
  let sum = 0;
  for (let i = 0; i < 50_000_00; i++) sum += i;

  document.querySelector('#out').textContent = 'done: ' + sum;
  document.querySelector('#btn').removeAttribute('disabled');
}

document.querySelector('#btn').addEventListener('click', () => {
  onClick();
});

여기서 핵심은 await nextFrame()으로 “페인트 가능한 지점”을 만들어주는 것입니다.

2) 마이크로태스크 폭주 주의

다음처럼 프라미스를 과도하게 체인으로 연결하면, 브라우저가 렌더링으로 가기 전에 마이크로태스크를 계속 처리해 페인트가 밀릴 수 있습니다.

// 피해야 하는 패턴(예시)
let p = Promise.resolve();
for (let i = 0; i < 10000; i++) {
  p = p.then(() => {
    // 작은 일이라도 누적되면 렌더링이 밀릴 수 있음
  });
}

이럴 때는 청크를 매크로태스크로 넘기거나(setTimeout) 프레임 단위(requestAnimationFrame)로 분산하는 쪽이 INP에 유리한 경우가 많습니다.

해결 전략 3: Web Worker로 “진짜로” 메인 스레드에서 치우기

연산이 크고 UI와 직접 상호작용하지 않는다면 Web Worker가 가장 강력합니다. 특히 대량 JSON 변환, 검색 인덱싱, 암호화, 이미지 처리 같은 작업은 Worker로 보내는 순간 INP가 크게 개선됩니다.

1) Worker 코드

// worker.js
self.onmessage = (e) => {
  const { total } = e.data;
  let acc = 0;
  for (let i = 0; i < total; i++) acc += Math.sqrt(i);
  self.postMessage({ acc });
};

2) 메인 스레드에서 호출

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

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

document.querySelector('#btn').addEventListener('click', async () => {
  document.querySelector('#out').textContent = 'loading...';

  // UI가 먼저 그려지도록 한 프레임 양보
  await new Promise((r) => requestAnimationFrame(r));

  const { acc } = await runHeavyInWorker(5_000_000);
  document.querySelector('#out').textContent = 'done: ' + acc;
});

Worker는 메시지 직렬화 비용이 있고, DOM 접근이 불가하므로 “연산”과 “UI”를 분리하는 설계가 필요합니다. 하지만 INP 관점에서는 가장 확실한 해법인 경우가 많습니다.

해결 전략 4: DOM 측정과 DOM 변경을 섞지 말기(레이아웃 스래싱 방지)

INP가 나쁜 화면을 보면 클릭 직후에 getBoundingClientRect 같은 측정이 여러 번 호출되고, 그 사이에 스타일 변경이 섞여 레이아웃이 반복 계산되는 경우가 흔합니다.

나쁜 예: 측정과 변경이 번갈아 수행됨

const box = document.querySelector('#box');

function bad() {
  for (let i = 0; i < 100; i++) {
    const w = box.getBoundingClientRect().width; // 측정
    box.style.width = (w + 1) + 'px';           // 변경
  }
}

개선: 읽기 단계와 쓰기 단계를 분리

const box = document.querySelector('#box');

function good() {
  // 1) 읽기(측정) 모으기
  const w = box.getBoundingClientRect().width;

  // 2) 쓰기(변경) 모으기
  box.style.width = (w + 100) + 'px';
}

복잡한 UI에서는 “읽기 phase”와 “쓰기 phase”를 명시적으로 분리하는 것만으로도 상호작용 직후의 렌더링 지연이 크게 줄어듭니다.

체크리스트: INP 급락을 막는 배포 전 점검

  • 클릭/입력 핸들러에서 50ms 이상 동기 작업이 있는가
  • 클릭 직후 실행되는 작업(분석, 로깅, 초기화)이 메인 스레드를 오래 점유하는가
  • 프라미스 체인이 과도하게 길어 렌더링이 밀리는가
  • DOM 측정과 변경이 섞여 레이아웃 스래싱이 발생하는가
  • 무거운 연산을 Worker로 옮길 수 있는가
  • 화면 전환 시점에 서드파티 스크립트가 동시에 초기화되는가

실전 팁: “원인 규명”을 빠르게 만드는 습관

성능 문제는 원인 파악이 절반입니다. 배포 후 문제가 커지면, 해결보다 “재현과 증거 수집”이 더 오래 걸립니다. 그래서 다음을 추천합니다.

  • 문제 상호작용에 대한 Performance 트레이스 템플릿을 팀에 공유
  • RUM에서 INP 상위 구간을 화면/기능 단위로 태깅
  • 릴리스마다 특정 상호작용(검색, 장바구니, 결제 버튼 등)을 성능 회귀 테스트

이런 접근은 백엔드 성능 이슈를 추적할 때 로그와 메트릭을 먼저 정리하는 것과 유사합니다. 예를 들어 장애 원인 추적을 체계화하는 글로는 GitLab CI 캐시 안 먹을 때 - 키·경로·권한 점검처럼 “체크리스트 기반” 접근이 큰 도움이 되는데, 프론트 성능도 같은 방식으로 운영하면 회귀를 크게 줄일 수 있습니다.

또한 대규모 화면에서 데이터 접근 패턴이 폭발해 UI 스레드를 압박하는 경우도 있어, 데이터 계층 최적화 관점에서는 Spring Boot 3 JPA N+1 폭발 대응 - fetch join·EntityGraph·Batch처럼 병목을 구조적으로 제거하는 사고방식이 참고가 됩니다.

마무리: INP는 “더 빠르게”가 아니라 “덜 막히게”

INP 급락은 대개 특정 순간에 메인 스레드가 막히면서 발생합니다. 따라서 해결도 평균 처리량을 올리는 것보다, 입력 직후에 롱태스크를 피하고 렌더링을 우선시키는 방향이 효과적입니다.

  • 롱태스크를 청크로 분해하고 중간에 양보하기
  • 클릭 직후에는 UI를 먼저 그리고, 무거운 작업은 뒤로 미루기
  • 가능하면 Worker로 연산을 분리하기
  • DOM 읽기/쓰기 순서를 정리해 레이아웃 스래싱을 없애기

이 네 가지를 적용하면, “Chrome에서 갑자기 INP가 나빠졌다” 같은 이슈의 대부분은 재현 가능하게 만들고, 수치도 안정적으로 회복시킬 수 있습니다.