Published on

Chrome INP 개선 - Long Task 분해 실전 가이드

Authors

서버가 빠른데도 사용자 체감이 느리다면, 원인은 대개 브라우저 메인 스레드에 있습니다. 특히 Chrome의 INP(Interaction to Next Paint)는 사용자가 클릭/탭/키입력을 했을 때 다음 페인트가 나오기까지의 지연을 측정하기 때문에, 자바스크립트가 메인 스레드를 점유하는 순간 바로 악화됩니다.

INP를 개선하는 가장 현실적인 방법은 Long Task(통상 50ms 이상 메인 스레드를 점유하는 작업)를 찾아서 분해하고, 입력 처리 경로를 짧게 만들며, 렌더링을 끊김 없이 진행시키는 것입니다. 이 글에서는 DevTools에서 Long Task를 “증거 기반”으로 찾고, 실제 코드 레벨에서 어떻게 쪼개는지, 그리고 쪼갠 뒤 INP가 왜 좋아지는지까지 실전 관점에서 정리합니다.

INP와 Long Task의 관계를 한 문장으로 정리

INP는 “입력 이벤트가 큐에 들어온 시점부터 다음 페인트까지”를 보는데, Long Task가 있으면 입력 이벤트가 큐에서 대기하거나(입력 지연), 이벤트 핸들러가 오래 걸리거나(처리 지연), 렌더링이 밀리면서(프레젠테이션 지연) INP가 커집니다.

즉 INP 개선은 다음 세 가지를 줄이는 싸움입니다.

  • 입력 지연: 메인 스레드가 바빠 이벤트를 바로 처리 못함
  • 처리 지연: 이벤트 핸들러/동기 로직이 무거움
  • 프레젠테이션 지연: 스타일/레이아웃/페인트가 늦음

Long Task 분해는 위 3가지 중 최소 2가지를 동시에 줄여주는 경우가 많습니다.

진단 1: DevTools에서 “문제 상호작용”을 먼저 고정하기

개선은 측정 가능한 목표가 있어야 합니다. 다음 순서로 “나쁜 상호작용” 하나를 먼저 고정하세요.

  1. Chrome DevTools Performance 탭에서 레코딩
  2. 실제로 INP가 나쁠 것 같은 동작(예: 필터 클릭, 검색 입력, 모달 열기)을 3~5회 반복
  3. 타임라인에서 Interactions 또는 이벤트 트랙을 기준으로 가장 느린 구간을 찾기
  4. 해당 구간의 메인 스레드에 길게 이어진 작업(노란색 스크립트, 보라색 렌더링)을 확인

여기서 중요한 건 “전체 페이지가 느리다”가 아니라 “특정 상호작용이 느리다”를 먼저 고정하는 것입니다. Long Task는 여러 곳에 있을 수 있으나, INP는 최악 상호작용에 끌려가므로 최악 1개를 먼저 잡는 편이 효율적입니다.

Long Task를 확인할 때 체크리스트

  • Main 트랙에 50ms 이상 연속 실행 블록이 있는가
  • 입력 이벤트(예: pointerdown, click, keydown) 앞뒤로 긴 스크립트가 붙어 있는가
  • Recalculate Style 또는 Layout이 이벤트 직후 반복되는가
  • 이벤트 핸들러에서 동기 렌더링을 유발하는 DOM 측정/변경이 섞였는가

진단 2: Long Task의 정체를 “콜스택”으로 확정하기

Performance 타임라인에서 Long Task 블록을 클릭하면 하단에 Call Tree/Bottom-Up/Event Log가 나옵니다. 여기서 목표는 “어떤 함수가 시간을 먹는지”를 1~3개로 좁히는 것입니다.

  • Bottom-Up은 “시간을 가장 많이 쓴 함수”를 찾기 좋습니다.
  • Call Tree는 “어떤 경로로 호출됐는지”를 보기 좋습니다.

이 단계에서 흔한 원인:

  • 대량 데이터 map/filter/sort를 이벤트 핸들러에서 동기 실행
  • 큰 JSON 파싱/문자열 처리/마크다운 렌더링
  • DOM 대량 생성 및 즉시 삽입
  • 레이아웃 스래싱(측정과 변경이 번갈아 발생)

실전 패턴 1: 작업을 프레임 단위로 쪼개기(requestAnimationFrame + 청크)

가장 보편적인 Long Task 분해는 “한 번에 다 처리하지 말고, 작은 청크로 나눠 프레임 사이에 양보”하는 것입니다.

예를 들어 대량 리스트 필터링/렌더링을 클릭 이벤트에서 한 번에 처리하면 INP가 튑니다.

나쁜 예: 클릭 핸들러에서 대량 연산 + DOM 업데이트

button.addEventListener('click', () => {
  const result = bigList
    .filter(x => heavyPredicate(x))
    .sort((a, b) => a.score - b.score);

  container.innerHTML = '';
  for (const item of result) {
    const el = document.createElement('div');
    el.textContent = item.title;
    container.appendChild(el);
  }
});

개선 예: 연산/렌더를 청크로 나누고 프레임 사이에 양보

function chunkedProcess(items, chunkSize, onChunk, onDone) {
  let i = 0;

  function runChunk() {
    const end = Math.min(i + chunkSize, items.length);
    onChunk(items.slice(i, end), i, end);
    i = end;

    if (i < items.length) {
      requestAnimationFrame(runChunk);
    } else {
      onDone?.();
    }
  }

  requestAnimationFrame(runChunk);
}

button.addEventListener('click', () => {
  container.textContent = '';

  const filtered = [];
  // 1) 필터링도 청크로
  chunkedProcess(bigList, 500, (chunk) => {
    for (const x of chunk) {
      if (heavyPredicate(x)) filtered.push(x);
    }
  }, () => {
    // 2) 정렬은 비용이 크므로 가능하면 서버/사전계산/부분정렬 고려
    filtered.sort((a, b) => a.score - b.score);

    // 3) DOM 렌더도 청크로
    chunkedProcess(filtered, 100, (chunk) => {
      const frag = document.createDocumentFragment();
      for (const item of chunk) {
        const el = document.createElement('div');
        el.textContent = item.title;
        frag.appendChild(el);
      }
      container.appendChild(frag);
    });
  });
});

핵심은 “한 프레임에 너무 많은 일을 하지 않기”입니다. requestAnimationFrame으로 분해하면 브라우저가 입력 처리와 렌더링을 끼워 넣을 기회가 생겨 INP가 내려갑니다.

실전 패턴 2: setTimeout(0) 대신 scheduler.postTask로 입력 우선순위 보장

청크 분해를 했는데도 입력이 씹히거나(클릭 후 반응이 늦음) 스크롤이 버벅이면, 작업 우선순위 문제일 수 있습니다.

Chrome은 scheduler.postTask를 통해 작업 우선순위를 지정할 수 있습니다. 지원 환경을 고려해 폴백을 두는 방식이 현실적입니다.

function postTask(cb, priority = 'user-visible') {
  if (globalThis.scheduler?.postTask) {
    return globalThis.scheduler.postTask(cb, { priority });
  }
  // 폴백: 최소한 메인 스레드에 양보
  return new Promise(resolve => {
    setTimeout(() => {
      cb();
      resolve();
    }, 0);
  });
}

button.addEventListener('click', async () => {
  // 클릭 직후에는 UI 피드백을 먼저
  button.disabled = true;
  button.textContent = 'Loading...';

  // 무거운 작업은 우선순위를 낮춰서 입력/렌더링을 방해하지 않게
  await postTask(() => {
    heavyWork();
  }, 'background');

  button.textContent = 'Done';
});

포인트는 “유저가 즉시 체감해야 하는 업데이트”를 먼저 처리하고, 무거운 작업은 뒤로 미루는 것입니다.

실전 패턴 3: 이벤트 핸들러에서 DOM 측정과 변경을 섞지 않기(레이아웃 스래싱 방지)

INP가 나쁜 페이지를 보면, 이벤트 핸들러에서 다음이 자주 섞입니다.

  • getBoundingClientRect() 같은 측정
  • 직후 style 변경 또는 class 토글
  • 다시 측정

이 패턴은 Layout을 반복 유발해 프레젠테이션 지연이 커집니다.

나쁜 예

item.addEventListener('click', () => {
  const rect = panel.getBoundingClientRect();
  panel.classList.toggle('open');
  const rect2 = panel.getBoundingClientRect();
  console.log(rect.height, rect2.height);
});

개선 예: 측정은 먼저 모으고, 변경은 나중에 몰아서

item.addEventListener('click', () => {
  const before = panel.getBoundingClientRect();

  // 변경은 한 번에
  panel.classList.toggle('open');

  // 필요한 측정은 다음 프레임으로
  requestAnimationFrame(() => {
    const after = panel.getBoundingClientRect();
    console.log(before.height, after.height);
  });
});

실전 패턴 4: 무거운 계산은 Web Worker로 옮기기(최후의 한 방이 아니라 정석)

Long Task의 본질이 “CPU 연산”이라면, 메인 스레드에서 아무리 쪼개도 UI가 버벅일 수 있습니다. 이때는 Worker가 가장 깔끔합니다.

메인 스레드

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

button.addEventListener('click', () => {
  button.disabled = true;
  button.textContent = 'Working...';

  worker.postMessage({ type: 'FILTER', items: bigList });
});

worker.addEventListener('message', (e) => {
  const { result } = e.data;
  render(result);
  button.disabled = false;
  button.textContent = 'Done';
});

Worker(/workers/filter-worker.js)

self.addEventListener('message', (e) => {
  const { type, items } = e.data;
  if (type !== 'FILTER') return;

  const result = items
    .filter(x => heavyPredicate(x))
    .sort((a, b) => a.score - b.score);

  self.postMessage({ result });
});

function heavyPredicate(x) {
  // CPU 바운드 작업 가정
  let acc = 0;
  for (let i = 0; i < 2000; i++) acc += (x.score * i) % 7;
  return acc % 2 === 0;
}

Worker로 옮기면 메인 스레드 Long Task가 사라져 입력 지연이 크게 줄어드는 경우가 많습니다. 단, 구조화 복사 비용이 있으니 큰 객체를 자주 주고받는다면 전송량을 줄이거나 Transferable을 고려하세요.

실전 패턴 5: “즉시 반응”은 먼저, “정확한 결과”는 나중에(낙관적 UI)

INP는 사용자가 “반응이 왔다”고 느끼는 순간을 빠르게 만드는 게 중요합니다. 결과 계산이 늦어도, UI 피드백(pressed 상태, 로딩 스피너, 스켈레톤)이 먼저 나오면 INP에 유리합니다.

button.addEventListener('click', () => {
  // 1) 즉시 반응
  button.classList.add('active');

  // 2) 무거운 작업은 다음 틱/프레임으로
  setTimeout(() => {
    doHeavyUpdate();
    button.classList.remove('active');
  }, 0);
});

이 패턴은 단독으로는 한계가 있지만, Long Task 분해와 결합하면 효과가 큽니다.

개선 후 검증: “INP가 내려간 이유”를 로그로 남기기

개선 전후를 비교할 때는 단순히 점수만 보지 말고, Long Task가 어떻게 변했는지 증거를 남기는 게 좋습니다.

  • Performance 레코딩에서 Long Task 길이/개수 변화
  • 입력 이벤트 직후 Recalculate Style/Layout 감소
  • 이벤트 핸들러 실행 시간 감소

또한 RUM 환경에서는 다음처럼 web-vitals로 INP를 수집해 릴리즈 단위로 회귀를 막을 수 있습니다.

import { onINP } from 'web-vitals';

onINP((metric) => {
  // metric.value: ms
  // metric.attribution: 원인 이벤트/타겟 등(환경에 따라 다름)
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    navigationType: metric.navigationType,
  }));
});

자주 하는 실수: Long Task를 “쪼갰는데” INP가 그대로인 이유

다음 케이스가 흔합니다.

  • 청크는 쪼갰지만, 첫 청크가 여전히 너무 큼(예: 200ms)
  • 클릭 핸들러에서 동기 DOM 업데이트가 여전히 큼(대량 innerHTML 교체)
  • 입력 직후 스타일/레이아웃이 폭발(레이아웃 스래싱)
  • 이미지/폰트 로딩으로 렌더링이 막힘(프레젠테이션 지연)

특히 폰트/레이아웃 문제는 INP뿐 아니라 CLS에도 영향을 줍니다. 폰트 로딩과 레이아웃 이슈가 의심된다면 Safari에서만 CLS 튀는 이유 - 폰트 로딩·preload 진단도 함께 점검하는 것이 좋습니다.

운영 관점 팁: 성능도 “회귀”를 전제로 방어하기

INP는 기능 추가/리팩터링 때 쉽게 나빠집니다. 다음을 팀 규칙으로 두면 회귀를 크게 줄일 수 있습니다.

  • “이벤트 핸들러에서 동기 대량 연산 금지” 룰(코드리뷰 체크)
  • 렌더링 비용 큰 컴포넌트는 가상화/지연 렌더 기본값으로
  • 성능 예산(perf budget): 상호작용 경로에서 50ms 이상 작업 금지 목표
  • RUM으로 INP p75/p90 추적, 릴리즈별 비교

프론트 성능 문제도 결국 “진단 가능한 관측”이 핵심입니다. 인프라에서 타임아웃을 추적하듯(예: Spring Boot gRPC DEADLINE_EXCEEDED 타임아웃 진단), 브라우저에서도 상호작용 단위로 원인을 좁혀야 빠르게 해결됩니다.

정리: Long Task 분해 체크리스트

  • DevTools Performance로 최악 상호작용 1개를 고정
  • Long Task의 콜스택에서 상위 1~3개 원인 함수를 확정
  • 대량 연산/렌더는 청크로 분해하고 프레임 사이에 양보(requestAnimationFrame)
  • 입력 우선순위를 보장하고 무거운 작업은 뒤로(scheduler.postTask 폴백 포함)
  • 레이아웃 스래싱 제거(측정과 변경 분리)
  • CPU 바운드면 Worker로 오프로드
  • RUM으로 INP를 수집해 회귀를 막기

Long Task를 “없애는” 게 아니라 “사용자 입력과 렌더링을 막지 않도록 재배치”하는 것이 핵심입니다. 이 관점으로 코드를 쪼개면, INP는 생각보다 빠르게 내려갑니다.