Published on

Chrome INP 느림? Long Task·TBT 7분 개선

Authors

서버가 빠른데도 Chrome에서 클릭이나 입력 반응이 굼뜨다면, 대개 원인은 메인 스레드가 바쁜 상태입니다. 특히 INP는 사용자의 실제 상호작용(클릭, 탭, 키 입력)에 대한 반응성을 측정하기 때문에, 네트워크보다 Long TaskTBT가 더 직접적인 적입니다.

이 글은 “원인 파악부터 즉시 적용 가능한 개선”까지를 7분 안에 끝내는 흐름으로 구성했습니다. 성능을 미세 최적화하기 전에, 먼저 어디서 메인 스레드가 막히는지를 빠르게 찾아내는 게 핵심입니다.

INP, Long Task, TBT를 한 번에 정리

INP란

INP(Interaction to Next Paint)는 사용자의 상호작용 이후 화면이 실제로 다음 페인트로 반응하기까지 걸린 시간을 봅니다. 단순히 이벤트 핸들러가 끝나는 시간이 아니라, 다음 화면 업데이트가 가능한 상태가 되었는지까지 포함합니다.

  • 예: 버튼 클릭 onClick이 20ms에 끝나도, 그 직후 렌더링/레이아웃/스타일 계산이 밀리면 INP는 커질 수 있습니다.

Long Task란

메인 스레드에서 50ms를 초과해 실행되는 작업입니다. Long Task가 있으면 사용자 입력이 큐에 쌓이고, 브라우저는 페인트를 제때 못 합니다.

TBT란

TBT(Total Blocking Time)는 페이지 로드 구간에서 Long Task들이 “사용자 입력을 막은 시간”의 누적치입니다. INP는 로드 이후 상호작용도 반영하지만, 현실에서는 로딩 중 스크립트가 과도하면 INP도 같이 나빠지는 경우가 많습니다.

핵심 관계는 이렇습니다.

  • Long Task가 많다 → 입력 처리 지연 증가
  • 입력 처리 지연 증가 → INP 악화
  • 로딩 중 Long Task 누적 → TBT 증가 → 초기 상호작용 INP 악화

7분 개선 플로우: “측정 2분 + 처방 5분”

아래 순서대로 하면, 대부분의 “Chrome에서 INP가 느린데 이유를 모르겠다” 케이스는 방향이 잡힙니다.

  1. DevTools로 Long Task 위치 찾기
  2. 해당 작업이 무엇인지 분류(렌더링, JS, 서드파티)
  3. 큰 것부터 쪼개기(청킹), 미루기(지연 로딩), 옮기기(워커)

1) DevTools Performance로 Long Task 1개만 잡아도 반은 끝

재현 준비

  • Chrome DevTools 열기
  • Performance
  • Web Vitals 확장 또는 Performance 내 Insights가 있으면 함께 활용
  • CPU 스로틀 4x 정도로 걸면 병목이 더 잘 보입니다

기록 방법

  1. Record 시작
  2. 문제 상호작용 수행(클릭, 입력, 스크롤)
  3. Stop

무엇을 봐야 하나

  • Main 트랙에서 긴 막대가 Long Task
  • 막대 클릭 후 하단 Bottom-Up 또는 Call Tree로 “어떤 함수가 시간을 먹는지” 확인
  • Scripting, Rendering, Painting 중 어디가 두꺼운지 먼저 구분

빠른 판단 기준

  • Scripting이 두껍다: JS 실행/파싱/컴파일/GC 의심
  • Rendering이 두껍다: 레이아웃 스래싱, 큰 DOM, 스타일 계산 의심
  • Painting이 두껍다: 큰 영역 리페인트, 이미지/캔버스 의심

2) INP를 코드에서 바로 계측해 “범인 상호작용”을 특정

실서비스에서 재현이 어렵다면, INP를 직접 수집해 어떤 상호작용이 문제인지부터 좁히는 게 빠릅니다.

아래는 web-vitals로 INP를 수집하는 최소 예시입니다.

// analytics/webVitals.ts
import { onINP } from 'web-vitals';

export function initWebVitals() {
  onINP((metric) => {
    // metric.value: INP(ms)
    // metric.attribution: 어떤 상호작용/타겟/이벤트인지 단서
    console.log('INP', metric.value, metric);

    // 예: 사내 수집 API로 전송
    navigator.sendBeacon(
      '/vitals',
      JSON.stringify({
        name: metric.name,
        value: metric.value,
        id: metric.id,
        rating: metric.rating,
        attribution: metric.attribution,
      })
    );
  });
}

Next.js라면 클라이언트 엔트리에서 한 번만 실행되게 붙이면 됩니다.

// app/layout.tsx 또는 pages/_app.tsx에서 클라이언트에서만 실행
'use client';

import { useEffect } from 'react';
import { initWebVitals } from './analytics/webVitals';

export default function WebVitalsInit() {
  useEffect(() => {
    initWebVitals();
  }, []);

  return null;
}

이렇게 수집하면 “특정 버튼 클릭에서만 INP가 튄다”, “검색 입력에서만 느리다”처럼 타겟팅이 됩니다.

3) Long Task를 5분 안에 줄이는 실전 처방 6가지

여기부터가 진짜 “7분 개선” 파트입니다. 가장 흔하고 효과 큰 순서대로 정리합니다.

3-1. 큰 루프/파싱/정렬을 청킹해서 메인 스레드 숨통 트기

한 번에 200ms를 써버리는 작업은 20ms씩 10번으로 나누는 것만으로도 INP가 크게 좋아집니다.

function yieldToMain() {
  return new Promise<void>((resolve) => {
    // requestIdleCallback이 없으면 setTimeout으로 폴백
    if ('requestIdleCallback' in window) {
      (window as any).requestIdleCallback(() => resolve());
    } else {
      setTimeout(() => resolve(), 0);
    }
  });
}

export async function chunkedMap<T, R>(items: T[], fn: (item: T) => R) {
  const out: R[] = [];
  for (let i = 0; i < items.length; i++) {
    out.push(fn(items[i]));

    // 50개마다 메인 스레드에 양보
    if (i % 50 === 0) {
      await yieldToMain();
    }
  }
  return out;
}

포인트는 “완료 시간이 약간 늘어도, 사용자 입력을 막지 않게” 만드는 겁니다.

3-2. 입력 이벤트에서 무거운 작업을 즉시 실행하지 말고 디바운스

검색 자동완성, 필터링, 실시간 검증에서 흔합니다.

function debounce<F extends (...args: any[]) => void>(fn: F, delayMs: number) {
  let t: number | undefined;
  return (...args: Parameters<F>) => {
    if (t) window.clearTimeout(t);
    t = window.setTimeout(() => fn(...args), delayMs);
  };
}

const onChange = debounce((value: string) => {
  // 여기서 큰 필터링/검색 호출
  expensiveFilter(value);
}, 150);

입력 중 매 키스트로크마다 30ms씩만 써도, 사용자는 “키가 씹힌다”고 느낍니다.

3-3. React를 쓰면 startTransition으로 렌더 우선순위 낮추기

상호작용 직후 UI 업데이트가 무거운 렌더를 동반하면, INP가 튀는 패턴이 나옵니다.

import { startTransition, useState } from 'react';

export function SearchBox() {
  const [q, setQ] = useState('');
  const [results, setResults] = useState<string[]>([]);

  function handleChange(v: string) {
    setQ(v); // 입력값은 즉시 반영

    startTransition(() => {
      // 무거운 결과 렌더는 낮은 우선순위로
      setResults(expensiveSearch(v));
    });
  }

  return (
    <div>
      <input value={q} onChange={(e) => handleChange(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
    </div>
  );
}

startTransition은 Long Task 자체를 없애진 않지만, “입력 반응”을 먼저 살려 체감 INP를 개선하는 데 효과가 큽니다.

3-4. 레이아웃 스래싱 제거: 읽기와 쓰기를 분리

getBoundingClientRect() 같은 레이아웃 읽기와 style 변경 같은 쓰기가 섞이면 강제 리플로우가 반복됩니다.

나쁜 예(읽기-쓰기-읽기-쓰기 반복):

for (const el of elements) {
  const rect = el.getBoundingClientRect();
  el.style.transform = `translateY(${rect.top}px)`;
}

개선(읽기 먼저, 쓰기 나중에):

const rects = elements.map((el) => el.getBoundingClientRect());

rects.forEach((rect, i) => {
  elements[i].style.transform = `translateY(${rect.top}px)`;
});

이것만으로 Rendering 시간이 눈에 띄게 줄어드는 경우가 많습니다.

3-5. 서드파티 스크립트 지연 로딩: 초기 상호작용 보호

광고/태그매니저/AB테스트/채팅 위젯은 Long Task의 단골입니다. “당장 필요하지 않다면” 상호작용 이후로 미루세요.

<script>
  function loadThirdParty() {
    const s = document.createElement('script');
    s.src = 'https://example.com/third-party.js';
    s.async = true;
    document.head.appendChild(s);
  }

  // 첫 사용자 입력 이후 로드
  window.addEventListener('pointerdown', loadThirdParty, { once: true });
</script>

핵심은 “초기 상호작용 구간의 메인 스레드 독점”을 피하는 것입니다.

3-6. 진짜 무거운 계산은 Web Worker로 옮기기

암호화, 대용량 JSON 변환, 복잡한 정렬/스코어링은 워커가 정답인 경우가 많습니다.

// worker.ts
self.onmessage = (e) => {
  const data = e.data as number[];
  const sorted = data.slice().sort((a, b) => a - b);
  (self as any).postMessage(sorted);
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));

export function sortInWorker(data: number[]) {
  return new Promise<number[]>((resolve) => {
    worker.onmessage = (e) => resolve(e.data as number[]);
    worker.postMessage(data);
  });
}

메인 스레드 Long Task를 구조적으로 제거할 수 있어, INP 개선 폭이 큽니다.

4) “어디서부터 손대야 할지” 우선순위 체크리스트

아래 순서대로 보면 시행착오가 줄어듭니다.

  1. Performance에서 가장 긴 Long Task 하나를 고른다
  2. 그 작업이 Scripting이면
    • 대용량 루프 청킹
    • 디바운스/스로틀
    • 서드파티 지연 로딩
    • 워커 이전
  3. 그 작업이 Rendering이면
    • 레이아웃 스래싱 제거
    • DOM 크기 줄이기(가상 스크롤)
    • 불필요한 리렌더 줄이기
  4. 개선 후 다시 측정해서 “Longest 1개”가 바뀌는지 확인한다

성능은 대부분 “상위 1개 또는 2개 Long Task”가 지배합니다. 잔챙이를 10개 없애는 것보다, 1개를 반으로 줄이는 게 더 큽니다.

5) 자주 나오는 함정: INP는 이벤트 핸들러만의 문제가 아니다

INP가 느릴 때 흔히 onClick 함수만 들여다보고 끝내는데, 실제로는 다음이 더 흔합니다.

  • 클릭 이후 상태 업데이트로 인해 렌더 트리가 커지고 커밋이 길어짐
  • 모달 오픈 시 폰트 로딩, 이미지 디코딩, 레이아웃 변화가 겹침
  • 입력창 포커스 시 스크롤/레이아웃 보정 로직이 동작

그래서 Performance에서 “상호작용 타임라인”을 보고, 이벤트 이후에 이어지는 Rendering 비용까지 함께 봐야 합니다.

6) 운영에서 성능 회귀를 막는 방법

한 번 개선하고 끝내면 다시 느려집니다. 배포 파이프라인이나 PR 단계에서 성능 회귀를 감지하는 장치를 두는 편이 장기적으로 이득입니다.

  • Lighthouse CI로 TBT, INP 관련 지표를 수치로 관리
  • 특정 페이지 번들 크기 상한 설정
  • 서드파티 스크립트 추가 시 성능 영향 검토

CI/CD가 복잡한 모노레포라면 워크플로우가 과도하게 돌면서 팀의 개선 속도를 떨어뜨릴 수 있습니다. 이런 경우에는 빌드/테스트 비용을 줄여 성능 개선 작업 자체를 빠르게 반복할 수 있게 만드는 것도 중요합니다. 관련해서는 GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기도 함께 참고할 만합니다.

또한 타입 안정성이 떨어져 런타임 가드나 방어 코드가 과도해지면, 핫패스에서 불필요한 분기와 변환이 늘어날 수 있습니다. 객체 검증을 타입 시스템으로 더 밀어넣는 접근은 유지보수와 성능 모두에 도움이 됩니다. 예시는 TS 5.x satisfies로 타입 안전 유지하며 객체 검증에서 아이디어를 얻을 수 있습니다.

마무리: “INP는 메인 스레드의 여유”를 사는 게임

정리하면, Chrome에서 INP가 느릴 때 가장 빠른 해결 루트는 다음입니다.

  • Performance로 Long Task 하나를 정확히 잡는다
  • 그 작업을 청킹하거나, 지연시키거나, 워커로 옮긴다
  • 렌더링 병목이면 레이아웃 스래싱과 DOM/리렌더를 줄인다

이 과정을 한 번만 제대로 해두면, “왜 느린지 모르는 상태”에서 벗어나 매번 같은 방식으로 재현하고 고칠 수 있습니다. INP는 감이 아니라, 타임라인에서 보이는 작업을 줄이는 것으로 확실하게 개선됩니다.