Published on

Chrome INP 급증 원인 - Long Task·Layout Shift 잡기

Authors

서버 지표는 멀쩡한데, Chrome에서만 INP(Interaction to Next Paint)가 갑자기 튀는 상황이 종종 있습니다. 특히 배포 직후나 특정 페이지/컴포넌트에서만 급증한다면 원인은 대개 **메인 스레드의 Long Task(50ms+)**와 **상호작용(클릭/탭/키 입력) 중 Layout Shift(레이아웃 재계산/리플로우)**가 겹치면서 발생합니다.

INP는 “사용자가 상호작용한 시점부터 다음 페인트까지”의 지연을 보므로, 네트워크가 빠르더라도 JS 실행, 스타일 계산, 레이아웃, 페인트가 막히면 그대로 악화됩니다. 이 글에서는 (1) INP가 왜 급증하는지, (2) Chrome DevTools로 어떻게 원인을 특정하는지, (3) Long Task와 Layout Shift를 실제 코드에서 어떻게 줄이는지 순서대로 정리합니다.

> 참고: 네트워크/프록시/타임아웃 이슈가 섞여 “체감이 느리다”가 같이 발생하는 경우도 있습니다. 스트리밍/프록시 튜닝 관점은 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트도 함께 보시면 원인 분리가 빨라집니다.

INP 급증의 전형적 패턴

INP가 나빠지는 패턴은 크게 두 갈래입니다.

1) 상호작용 직후 메인 스레드가 오래 잠김 (Long Task)

  • 클릭 핸들러에서 큰 JSON 파싱, 대량 DOM 조작, 무거운 정렬/필터, 동기식 루프를 돌림
  • 3rd-party 스크립트(태그 매니저, A/B, 광고)가 상호작용 타이밍에 실행
  • React/Vue 등에서 state 업데이트가 연쇄 렌더링을 유발(특히 리스트/테이블)

2) 상호작용 중 레이아웃이 흔들림 (Layout Shift/Forced Reflow)

  • 클릭으로 펼쳐지는 UI(아코디언/모달/툴팁)가 공간을 미리 확보하지 않아 주변 요소가 밀림
  • 이미지/폰트 로딩이 늦어 클릭 타이밍에 레이아웃이 재배치
  • JS가 offsetHeight 같은 레이아웃 값을 읽고 곧바로 스타일을 쓰는 패턴(레이아웃 스래싱)

INP는 “입력 → 이벤트 처리 → 렌더링”의 전체 경로를 보기 때문에, Long Task와 Layout Shift가 동시에 있으면 폭발적으로 커집니다.

Chrome에서 INP를 재현·계측하는 방법

DevTools Performance로 “INP 후보 상호작용” 찾기

  1. DevTools → Performance
  2. “Web Vitals” 또는 “Interactions”가 보이면 활성화(버전에 따라 UI가 다름)
  3. Record 후 문제 동작(클릭/입력) 재현
  4. 타임라인에서 Interaction 이벤트를 클릭
  5. 하단/우측 패널에서 해당 상호작용의 Event duration / Presentation delay 등을 확인

여기서 중요한 건 “클릭 이벤트 핸들러 자체가 느린지” vs “핸들러는 짧은데 렌더링이 밀리는지”를 분리하는 것입니다.

  • Scripting이 길다 → Long Task
  • Rendering/Style/Layout가 길다 → Layout/리플로우/페인트 병목

Long Task는 Main thread flame chart로 바로 보인다

Performance 타임라인의 Main 스레드에서 50ms 이상 덩어리로 보이는 작업이 Long Task입니다. 클릭 직후에 이런 덩어리가 있으면 INP 악화의 1순위 후보입니다.

Layout Shift는 “레이아웃 이동” 이벤트로 확인

Performance에서 “Experience” 혹은 “Layout Shifts” 트랙이 보이면, 상호작용 직후에 shift가 발생하는지 확인합니다. shift가 클릭과 같은 프레임에 겹치면 INP가 크게 튈 수 있습니다.

Long Task 줄이기: ‘작업을 쪼개고, 늦추고, 옮기기’

Long Task 대응은 요약하면 3가지입니다.

  1. 쪼개기: 한 번에 200ms 쓰는 일을 10~20ms 단위로 나눠 프레임을 양보
  2. 늦추기: 꼭 “클릭 직후”가 아니어도 되는 작업은 idle/after paint로 미룸
  3. 옮기기: CPU 집약 작업은 Web Worker로 이동

1) requestAnimationFrame / setTimeout으로 작업 분할

대량 DOM 업데이트나 큰 배열 처리 같은 작업을 프레임 단위로 나눕니다.

// 큰 작업을 청크로 나눠 메인 스레드 점유를 줄이는 예
function processInChunks(items, chunkSize = 500) {
  let i = 0;

  function runChunk() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) {
      // 무거운 계산/가공
      items[i].score = heavyCompute(items[i]);
    }

    if (i < items.length) {
      // 다음 프레임에 이어서 실행
      requestAnimationFrame(runChunk);
    }
  }

  requestAnimationFrame(runChunk);
}
  • 핵심은 “사용자 입력 직후”에 메인 스레드를 100ms 이상 독점하지 않는 것입니다.
  • 단, 너무 잘게 쪼개면 오히려 총 시간이 늘 수 있으니 INP가 터지는 구간만 타겟팅하세요.

2) 클릭 핸들러는 ‘상태만 바꾸고’ 무거운 일은 뒤로

클릭 이벤트 핸들러에서 모든 일을 처리하지 말고, UI 반응(버튼 눌림/로딩 표시)을 먼저 보여준 뒤 나머지를 처리합니다.

button.addEventListener('click', () => {
  // 1) 즉시 반응: UI 상태만 업데이트
  button.disabled = true;
  button.textContent = 'Loading...';

  // 2) 페인트 이후(또는 다음 틱)에 무거운 작업 실행
  setTimeout(() => {
    doHeavyWork();
    button.disabled = false;
    button.textContent = 'Done';
  }, 0);
});
  • setTimeout(0)는 “지금 이벤트 루프를 끝내고” 다음 기회에 실행하게 하여, 입력 직후 페인트가 먼저 나가도록 도와줄 때가 많습니다.
  • 더 정교하게는 requestAnimationFrame + setTimeout 조합, 또는 scheduler.postTask(지원 브라우저)도 고려합니다.

3) Web Worker로 CPU 작업 이동

정렬/검색 인덱싱/압축/대량 파싱 등은 Worker로 보내면 메인 스레드 INP가 크게 개선됩니다.

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

worker.onmessage = (e) => {
  const result = e.data;
  renderResult(result);
};

function onSearch(input) {
  worker.postMessage({ type: 'SEARCH', input });
}
// worker.js
self.onmessage = (e) => {
  const { type, input } = e.data;
  if (type === 'SEARCH') {
    const result = heavySearch(input);
    self.postMessage(result);
  }
};
  • Worker로 옮겨도 DOM 조작은 메인에서 해야 하므로, 계산과 DOM을 분리하는 구조가 중요합니다.

4) 3rd-party 스크립트가 상호작용을 잡아먹는 경우

태그 매니저/광고/분석 스크립트가 클릭 시점에 동기적으로 실행되면 INP가 튑니다.

  • 가능하면 defer, async 사용
  • 상호작용 직후에 실행되는 트래킹은 batching 또는 idle 전송
  • “클릭 이벤트에 리스너를 너무 많이 달아둔” 구조(이벤트 위임 미사용)도 점검

Layout Shift 줄이기: ‘공간 예약’과 ‘레이아웃 스래싱 제거’

Layout Shift는 “화면 요소가 움직이는 것” 자체도 문제지만, INP 관점에선 레이아웃 계산이 입력 직후에 겹치는 것이 치명적입니다.

1) 이미지/미디어는 크기를 고정(공간 예약)

가장 흔한 shift 원인입니다.

<!-- width/height로 aspect ratio를 고정해 레이아웃 이동을 방지 -->
<img src="/banner.jpg" width="1200" height="400" alt="banner" loading="lazy" />

또는 CSS로 비율 박스를 만들 수도 있습니다.

.card-thumb {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #eee;
  overflow: hidden;
}

.card-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

2) 폰트 로딩으로 인한 shift 줄이기

  • font-display: swap은 FOUT로 shift가 생길 수 있고, optional은 표시 지연이 생길 수 있습니다.
  • 중요한 건 “상호작용 시점”에 폰트가 뒤늦게 적용되어 레이아웃이 바뀌지 않게 하는 것입니다.
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

추가로 preload를 고려합니다.

<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>

3) 아코디언/토스트/배너는 “밀지 말고 겹치기”

상단 배너가 나타나며 콘텐츠를 아래로 밀면 shift가 발생합니다. 가능하면 overlay로 띄우거나, 미리 공간을 확보합니다.

.toast {
  position: fixed;
  left: 16px;
  bottom: 16px;
  transform: translateY(0);
}

4) Forced reflow(레이아웃 스래싱) 제거

아래 패턴은 레이아웃 값을 읽는 순간 브라우저가 강제로 레이아웃을 계산하게 만들 수 있습니다.

// 나쁜 예: 읽기(offsetHeight)와 쓰기(style 변경)를 반복
for (const el of items) {
  const h = el.offsetHeight; // layout read
  el.style.height = (h + 10) + 'px'; // layout write
}

개선: 읽기와 쓰기를 분리하거나, 한 번에 처리합니다.

// 개선 예: 먼저 읽고, 그 다음에 쓰기
const heights = items.map(el => el.offsetHeight);
items.forEach((el, idx) => {
  el.style.height = (heights[idx] + 10) + 'px';
});

또는 DOM 업데이트를 DocumentFragment로 모아서 한 번에 반영합니다.

const frag = document.createDocumentFragment();
for (const data of list) {
  const li = document.createElement('li');
  li.textContent = data.title;
  frag.appendChild(li);
}
document.querySelector('#list').appendChild(frag);

“INP가 Chrome에서만 튄다”의 실무적 해석

Chrome에서만 유독 튀는 케이스는 다음을 의심할 수 있습니다.

  • **필드 데이터(CrUX)**가 Chrome 사용자 기반이라 Chrome에서만 관측되는 것처럼 보임
  • 특정 Chrome 버전/플래그/확장프로그램이 상호작용 경로에 영향을 줌(특히 입력 이벤트 처리)
  • DevTools 없이도 web-vitals로 수집한 INP가 Chrome에서 더 많이 쌓임

즉, “Chrome만 문제”라기보다 Chrome에서 측정이 더 잘 잡히거나, 사용자 풀이 크거나, 특정 코드 경로가 Chrome에서 더 자주 타는 경우가 많습니다.

INP를 코드로 계측해 ‘문제 상호작용’을 로그로 남기기

재현이 어렵다면 운영 환경에서 INP를 수집하고, 어떤 interaction이 문제인지 식별해야 합니다. web-vitals 라이브러리를 쓰면 비교적 쉽게 시작할 수 있습니다.

import { onINP } from 'web-vitals';

onINP((metric) => {
  // metric.value: INP(ms)
  // metric.attribution: 어떤 이벤트/타겟에서 지연이 컸는지 힌트
  // 전송은 sendBeacon 권장
  navigator.sendBeacon('/vitals', JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    rating: metric.rating,
    attribution: metric.attribution,
    url: location.href,
  }));
});
  • 이 로그가 쌓이면 “특정 버튼 클릭에서만 INP가 800ms”처럼 원인 후보를 좁힐 수 있습니다.
  • 네트워크 이슈(예: API 지연)와 섞이지 않도록, INP는 메인 스레드 병목이라는 점을 팀에 명확히 공유하세요.

실전 체크리스트: Long Task·Layout Shift를 빠르게 줄이는 순서

1단계: 재현과 범인 찾기

  • Performance 기록에서 문제 상호작용 1~3개를 고정
  • 클릭 직후 Main 스레드에 50ms+ 덩어리(Long Task)가 있는지 확인
  • Layout Shifts 트랙에서 상호작용 직후 shift가 있는지 확인

2단계: Long Task부터 먼저 깎기(효과가 큼)

  • 클릭 핸들러에서 무거운 계산/대량 DOM 조작 제거
  • 큰 리스트 렌더링은 가상 스크롤(virtualization) 또는 점진 렌더링
  • 3rd-party 스크립트 실행 타이밍 조정

3단계: Layout Shift 제거(체감/안정성 개선)

  • 이미지/광고/임베드 영역 크기 예약
  • 배너/토스트/모달은 overlay 우선
  • 폰트 preload 및 폴백 폰트 메트릭 조정(가능하면)

4단계: 회귀 방지

  • 주요 페이지에서 INP/CLS/LCP를 릴리즈마다 비교
  • 문제가 컸던 interaction을 E2E 시나리오로 고정

마무리

INP 급증은 “서버가 느리다”와 결이 다릅니다. 대부분은 메인 스레드가 입력 직후 오래 바쁘거나(Long Task), 그 순간 레이아웃이 흔들리며(Layout Shift/forced reflow) 렌더링이 밀려 발생합니다. DevTools Performance로 문제 상호작용을 고정하고, (1) 작업을 쪼개거나 뒤로 미루고, (2) 공간을 예약해 shift를 없애고, (3) 필요하면 Worker로 계산을 옮기면 INP는 눈에 띄게 내려갑니다.

추가로, 사용자가 “느리다”고 느끼는 원인이 프론트 렌더링 병목인지, 프록시/네트워크/타임아웃인지 혼재되는 경우가 많습니다. 응답 지연/끊김 이슈까지 함께 의심된다면 AWS ALB 502·504 난사 - 원인별 해결 체크리스트처럼 서버 경로도 병행 점검해 원인을 분리해두면, INP 개선 작업의 우선순위가 훨씬 명확해집니다.