Published on

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

Authors
Binance registration banner

서버가 빠른데도 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는 감이 아니라, 타임라인에서 보이는 작업을 줄이는 것으로 확실하게 개선됩니다.