Published on

Chrome INP 200ms 이상? Long Task 추적·개선

Authors

서버 응답이 빠르고 LCP도 괜찮은데, 유독 클릭이나 타이핑이 “먹통처럼” 느리다면 대개 원인은 메인 스레드의 Long Task 입니다. Chrome에서 INP가 200ms 이상으로 뜨기 시작하면, 단순히 “조금 느린” 수준이 아니라 입력 이벤트 처리 파이프라인이 막히는 상황을 의심해야 합니다.

이 글은 다음 흐름으로 진행합니다.

  • INP와 Long Task의 관계를 짧게 정리
  • Chrome DevTools에서 “문제 입력”과 “막힌 구간”을 연결하는 법
  • Long Task를 분해하고, 메인 스레드 점유를 줄이는 대표 패턴
  • 성능 회귀를 막는 계측 코드(실제 코드 포함)

관련해서 더 깊은 실전 사례는 아래 글도 함께 보면 좋습니다.

INP 200ms가 의미하는 것: “입력”이 끝나지 않았다

INP(Interaction to Next Paint)는 사용자의 상호작용(클릭, 탭, 키 입력 등)이 발생한 시점부터 그 상호작용의 결과가 다음 페인트로 화면에 반영될 때까지 걸린 시간을 봅니다.

INP가 커지는 가장 흔한 이유는 다음 중 하나입니다.

  1. 입력 이벤트 핸들러가 오래 걸림: 클릭 핸들러에서 무거운 연산, 대량 DOM 작업, 동기 네트워크 대기(혹은 동기처럼 보이는 로직)를 수행
  2. 메인 스레드가 다른 작업으로 이미 바쁨: 렌더링/레이아웃/스타일 계산, 다른 JS 실행, 서드파티 스크립트
  3. 페인트가 지연됨: 레이아웃 스래싱, 큰 리플로우, 과도한 DOM 변경으로 다음 paint까지 밀림

여기서 Long Task 는 “메인 스레드에서 50ms를 초과해 실행된 작업”을 의미합니다. Long Task가 길게 이어지면 입력 이벤트가 큐에 쌓이고, 결과적으로 INP가 튀어 오릅니다.

DevTools로 INP 원인 Long Task를 “입력 이벤트”와 연결하기

INP 최적화는 감으로 하면 실패합니다. 어떤 입력에서, 어떤 태스크가, 왜 오래 걸렸는지를 연결해야 합니다.

1) Performance 패널: 문제 재현 후 Trace 확보

  1. Chrome DevTools Performance 탭으로 이동
  2. Record 시작
  3. INP가 튀는 상호작용을 실제로 수행(예: 검색창 타이핑, 필터 클릭, 모달 열기)
  4. Stop 후 타임라인에서 Main 스레드 확인

여기서 중요한 포인트:

  • Main 트랙에서 길게 늘어진 블록이 Long Task 후보
  • 상호작용(클릭/키 입력) 직후에 긴 작업이 붙어 있으면 거의 정답
  • 블록을 클릭해서 Bottom-up, Call tree 로 “시간을 먹은 함수”를 찾습니다

2) Web Vitals overlay 또는 Performance Insights 활용

Chrome 버전에 따라 Performance Insights 에서 INP 관련 힌트를 더 직접적으로 제공합니다. 가능하면 아래도 같이 체크하세요.

  • 어떤 interaction이 “worst”인지
  • interaction 직전/직후에 어떤 long task가 있는지

3) Long Task를 코드 레벨로 빠르게 좁히기: performance.mark

DevTools만으로도 찾을 수 있지만, “기능이 복잡한 화면”은 trace에서 원인을 한 번에 못 찾는 경우가 많습니다. 그럴 땐 특정 구간에 마크를 심어 trace에서 구간을 시각적으로 분리합니다.

// 예: 클릭 핸들러의 단계별 병목 구간을 표시
button.addEventListener('click', async () => {
  performance.mark('filter_click_start');

  performance.mark('step_build_query_start');
  const query = buildQuery();
  performance.mark('step_build_query_end');

  performance.mark('step_update_dom_start');
  updateDom(query);
  performance.mark('step_update_dom_end');

  performance.mark('filter_click_end');

  performance.measure('buildQuery', 'step_build_query_start', 'step_build_query_end');
  performance.measure('updateDom', 'step_update_dom_start', 'step_update_dom_end');
});

이렇게 해두면 Performance 타임라인에서 buildQuery, updateDom 같은 구간이 명확히 보이고, “긴 덩어리”를 “긴 함수”로 바꿔 잡을 수 있습니다.

Long Task를 줄이는 6가지 실전 패턴

Long Task를 줄이는 핵심은 하나입니다.

  • 메인 스레드에서 오래 붙잡고 있는 일을 줄이거나
  • 오래 걸리는 일을 잘게 쪼개서 중간중간 브라우저에 제어권을 양보하거나
  • 메인 스레드 밖(Worker)으로 보내거나
  • DOM 변경을 최소화해서 paint까지 빠르게 도달하게 만들기

아래는 현장에서 가장 자주 통하는 패턴들입니다.

1) “큰 루프”를 쪼개고 양보하기: requestAnimationFrame 또는 setTimeout(0)

대량 데이터(예: 5천 개 리스트) 처리, 대량 문자열 파싱, 정렬/필터링을 클릭 핸들러에서 한 번에 하면 바로 Long Task가 됩니다.

function yieldToBrowser() {
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processInChunks(items, chunkSize = 200) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    // 무거운 처리
    out.push(...chunk.map(expensiveTransform));

    // 메인 스레드 양보
    await yieldToBrowser();
  }
  return out;
}

setTimeout(0) 는 단순하지만 효과적입니다. 더 “프레임 친화적”으로 하려면 requestAnimationFrame 기반으로도 쪼갤 수 있습니다.

주의: 너무 잘게 쪼개면 총 처리 시간은 늘 수 있습니다. 하지만 INP는 “사용자 반응성”이 목표이므로, 체감 개선이 더 중요한 구간에 적용합니다.

2) 입력 직후 DOM 대량 변경을 피하기: 배치 업데이트

DOM을 반복문에서 하나씩 바꾸면 레이아웃/스타일 계산이 반복되며 메인 스레드를 더 잡아먹습니다. 가능한 한 메모리에서 만들고 한 번에 붙이기가 좋습니다.

function renderList(container, items) {
  const frag = document.createDocumentFragment();
  for (const item of items) {
    const li = document.createElement('li');
    li.textContent = item.title;
    frag.appendChild(li);
  }
  container.replaceChildren(frag);
}

또는 React/Next.js라면 “상태 변경 횟수”를 줄이고, 파생 데이터는 useMemo 로 캐시하며, 큰 리스트는 가상화(virtualization)로 렌더링 자체를 줄이는 게 정석입니다.

3) 레이아웃 스래싱 제거: 읽기와 쓰기 분리

아래 패턴은 매우 흔한 INP 악화 원인입니다.

  • getBoundingClientRect() 같은 레이아웃 읽기
  • 직후 style 변경 같은 레이아웃 쓰기
  • 이 조합을 루프에서 반복

해결은 “읽기 단계”와 “쓰기 단계”를 분리하는 것입니다.

function updateCards(cards) {
  // 1) 읽기 단계
  const rects = cards.map((el) => el.getBoundingClientRect());

  // 2) 쓰기 단계
  for (let i = 0; i < cards.length; i++) {
    const el = cards[i];
    const r = rects[i];
    el.style.transform = `translateY(${Math.round(r.top)}px)`;
  }
}

4) 무거운 계산은 Worker로 오프로딩

정렬, 검색 인덱싱, 압축/해제, 큰 JSON 처리 등은 Worker로 보내면 메인 스레드 Long Task를 크게 줄일 수 있습니다.

worker.js

self.onmessage = (e) => {
  const { items } = e.data;
  const result = items
    .map(expensiveTransform)
    .sort((a, b) => a.score - b.score);

  self.postMessage({ result });
};

메인 스레드:

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

function runHeavyJob(items) {
  return new Promise((resolve, reject) => {
    worker.onmessage = (e) => resolve(e.data.result);
    worker.onerror = reject;
    worker.postMessage({ items });
  });
}

Worker로 옮긴다고 “공짜”는 아닙니다. 직렬화 비용이 있으니, 큰 객체를 자주 주고받으면 역효과가 날 수 있습니다. 이때는 Transferable(예: ArrayBuffer)를 고려합니다.

5) 서드파티 스크립트가 만든 Long Task 분리: 로딩 전략 조정

INP가 특정 페이지에서만 튄다면, 광고/태그매니저/AB테스트 같은 서드파티가 메인 스레드를 점유하는 경우가 많습니다.

  • 꼭 필요한지 재검토
  • 초기 상호작용 이전에는 로드하지 않기
  • defer 또는 동적 import로 늦추기
<script defer src="/third-party.js"></script>

Next.js라면 next/scriptstrategy 를 조정하는 방식도 효과적입니다(단, 여기서는 개념만 언급).

6) “입력 직후”에는 결과를 즉시 보여주고, 나머지는 나중에

검색 필터 클릭 시 모든 결과를 즉시 재계산/재렌더링하면 INP가 악화됩니다. 대신:

  • 클릭 즉시 로딩 상태(스켈레톤, 버튼 pressed 상태 등)를 먼저 paint
  • 실제 무거운 작업은 다음 tick이나 다음 프레임으로 넘기기
button.addEventListener('click', () => {
  // 1) 즉시 UI 피드백
  button.disabled = true;
  button.textContent = 'Applying...';

  // 2) 무거운 작업은 다음 작업 큐로
  setTimeout(async () => {
    try {
      await applyFilters();
    } finally {
      button.disabled = false;
      button.textContent = 'Apply';
    }
  }, 0);
});

이 패턴은 “정답을 빨리 계산”하는 게 아니라 “사용자에게 반응을 빨리 보여주는” 전략이라 INP에 특히 잘 맞습니다.

Long Task를 지속적으로 감시하는 계측: PerformanceObserver

개선 후에 다시 나빠지는 걸 막으려면, 운영/스테이징에서 Long Task를 계측해두는 게 좋습니다.

아래 코드는 브라우저가 제공하는 longtask 엔트리를 수집합니다.

export function observeLongTasks({ minDuration = 50 } = {}) {
  if (!('PerformanceObserver' in window)) return () => {};

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration: Long Task 지속 시간
      if (entry.duration >= minDuration) {
        // 여기서 Sentry/Datadog/자체 로그로 전송 가능
        console.log('[LongTask]', {
          name: entry.name,
          startTime: Math.round(entry.startTime),
          duration: Math.round(entry.duration),
        });
      }
    }
  });

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

운영에서 바로 콘솔 로그를 남기기보다는, 샘플링해서 전송하거나 특정 라우트에서만 켜는 식으로 비용을 통제하세요.

디버깅 체크리스트: “200ms를 넘기는” 흔한 실수들

아래 항목은 INP가 200ms 를 넘길 때 실제로 자주 나오는 원인입니다.

  • 클릭 핸들러에서 JSON.parse 나 큰 배열 sort 를 바로 실행
  • 입력 이벤트마다 API 호출 + 결과로 큰 DOM 갱신
  • 루프 안에서 offsetHeight 같은 레이아웃 강제 계산을 반복
  • 서드파티 스크립트가 초기 로드 직후 메인 스레드 점유
  • 상태 업데이트가 연쇄적으로 발생(React 렌더 폭발)
  • 이미지/폰트 로딩 후 레이아웃 변동으로 페인트가 늦어짐(엄밀히는 CLS/LCP 이슈와 겹치지만 INP 체감에도 영향)

마무리: INP는 “빠른 코드”보다 “막히지 않는 메인 스레드”

INP가 200ms 를 넘는 문제는 대부분 “특정 입력에서 메인 스레드가 1번 크게 막힌다”로 귀결됩니다. 그래서 최적화도 다음 순서가 가장 성공률이 높습니다.

  1. DevTools Performance로 문제 interaction과 Long Task를 연결
  2. Call tree로 가장 비싼 함수 1개를 특정
  3. 그 함수를 분해 하거나 양보 하거나 오프로딩 해서 50ms 이하로 쪼개기
  4. PerformanceObserver 로 회귀 감시

INP는 결국 사용자 경험 지표입니다. “총 처리 시간”을 조금 늘리더라도, 입력 직후 다음 paint까지의 시간을 줄이면 체감은 확 좋아집니다.