Published on

React INP 급락 원인 - 긴 Task 분해·useTransition

Authors

서버 응답이 빠르고 FPS도 그럭저럭인데, 사용자 클릭이나 입력에 대한 반응이 유독 늦게 느껴질 때가 있습니다. 크롬 성능 패널에서 보면 클릭 이벤트 자체는 즉시 들어오지만, 화면이 실제로 바뀌기까지 수백 ms에서 1초 이상 걸리는 식입니다. 이 현상이 반복되면 INP가 급락합니다.

INP는 사용자가 상호작용을 시작한 시점부터, 브라우저가 다음 페인트로 화면을 갱신할 때까지의 지연을 측정합니다. 즉, 네트워크보다도 “메인 스레드가 얼마나 빨리 다음 페인트를 만들 수 있느냐”가 핵심입니다. React 앱에서는 이 지연의 대부분이 Long Task, 그리고 그 Long Task를 유발하는 렌더링 및 동기 계산에 의해 만들어집니다.

이 글에서는 React에서 INP가 급락하는 전형적인 원인(긴 task)과, 이를 줄이기 위한 두 축인 긴 작업 분해useTransition을 실전 코드로 정리합니다.

추적 방법 자체가 필요하면 아래 글을 먼저 보고 오면 맥락이 더 빨리 잡힙니다.

React에서 INP가 떨어지는 대표 시나리오

INP 급락은 보통 “입력 이벤트는 들어왔는데, 다음 페인트가 늦는” 상황에서 발생합니다. React 관점에서 자주 나오는 패턴은 다음과 같습니다.

1) 클릭 한 번에 너무 많은 동기 작업이 실행됨

  • 클릭 핸들러에서 큰 JSON 파싱, 대규모 정렬, 필터링, 그룹핑 등을 바로 수행
  • 상태 업데이트 직후 즉시 무거운 계산을 이어서 실행

브라우저는 이벤트 콜백이 끝나고, 레이아웃 계산과 페인트를 할 기회를 얻습니다. 그런데 이벤트 콜백 내부에서 300ms, 800ms짜리 동기 계산을 해버리면, 그 시간만큼 다음 페인트가 밀려 INP가 커집니다.

2) 렌더링 자체가 무거워짐

  • 리스트가 커졌는데 가상화가 없음
  • memoization이 없어서 매 키 입력마다 전체 트리가 재렌더
  • 불필요한 컨텍스트 업데이트로 광범위 리렌더

이 경우 이벤트 핸들러는 가벼워도, 상태 업데이트로 촉발되는 렌더가 Long Task가 됩니다.

3) “즉시 반응해야 하는 UI”와 “나중에 해도 되는 UI”가 같은 우선순위로 묶임

예를 들어 검색 입력창에서 타이핑할 때

  • 입력창 값 반영은 즉시
  • 결과 리스트 필터링 및 렌더는 느려도 됨

하지만 둘을 같은 업데이트로 처리하면, 결과 리스트 렌더가 입력 반영까지 막아버려 INP가 악화됩니다.

핵심 원리: Long Task를 줄이는 두 가지 접근

React INP 최적화는 크게 두 방향입니다.

  1. 긴 동기 작업을 쪼개서 브라우저에 “숨 쉴 틈”을 준다
  2. 사용자 입력 같은 “즉시 UI”를 높은 우선순위로 처리하고, 무거운 업데이트는 낮은 우선순위로 미룬다

전자는 task 분해이고, 후자는 useTransition이 대표 도구입니다.

긴 Task 분해: 메인 스레드 점유 시간을 잘게 나누기

문제 예시: 클릭 시 대량 데이터 가공을 한 번에 수행

아래 코드는 흔히 보이는 형태입니다. 클릭 한 번에 필터링과 정렬을 한 번에 끝내려다 Long Task가 됩니다.

function expensiveTransform(items: Array<{ score: number; name: string }>) {
  // 아주 큰 배열이라고 가정
  const filtered = items.filter((x) => x.score > 50);
  filtered.sort((a, b) => b.score - a.score);
  return filtered;
}

export function Page({ items }: { items: Array<{ score: number; name: string }> }) {
  const [result, setResult] = useState(items);

  const onClick = () => {
    const next = expensiveTransform(items);
    setResult(next);
  };

  return (
    <div>
      <button onClick={onClick}>Recompute</button>
      <ul>
        {result.map((x) => (
          <li key={x.name}>{x.name}: {x.score}</li>
        ))}
      </ul>
    </div>
  );
}

개선 1: 청크 처리 + setTimeout 또는 requestAnimationFrame

목표는 “한 번에 500ms”를 “10번에 50ms”로 나눠 브라우저가 중간중간 페인트할 기회를 얻도록 만드는 것입니다.

type Item = { score: number; name: string };

function chunkArray<T>(arr: T[], size: number) {
  const chunks: T[][] = [];
  for (let i = 0; i < arr.length; i += size) {
    chunks.push(arr.slice(i, i + size));
  }
  return chunks;
}

async function filterInChunks(items: Item[], chunkSize: number) {
  const chunks = chunkArray(items, chunkSize);
  const out: Item[] = [];

  for (const c of chunks) {
    // 청크 단위로 조금씩 처리
    for (const x of c) {
      if (x.score > 50) out.push(x);
    }

    // 브라우저에 제어권을 넘겨 페인트 기회를 제공
    await new Promise<void>((r) => setTimeout(r, 0));
  }

  out.sort((a, b) => b.score - a.score);
  return out;
}

export function Page({ items }: { items: Item[] }) {
  const [result, setResult] = useState<Item[]>(items);
  const [loading, setLoading] = useState(false);

  const onClick = async () => {
    setLoading(true);
    const next = await filterInChunks(items, 2000);
    setResult(next);
    setLoading(false);
  };

  return (
    <div>
      <button onClick={onClick} disabled={loading}>
        {loading ? "Working..." : "Recompute"}
      </button>
      <ul>
        {result.map((x) => (
          <li key={x.name}>{x.name}: {x.score}</li>
        ))}
      </ul>
    </div>
  );
}

이 방식의 장점은 구현이 단순하고 효과가 즉각적이라는 점입니다. 단점은 총 처리 시간은 늘 수 있고, 청크 사이에 상태가 부분 반영되지 않으면 사용자 입장에서는 여전히 “기다림”이 남을 수 있습니다.

개선 2: Web Worker로 CPU 작업을 메인 스레드 밖으로 이동

데이터 가공이 “UI와 무관한 순수 계산”이라면 Web Worker가 가장 강력합니다. 메인 스레드의 Long Task를 근본적으로 제거할 수 있습니다.

간단 예시(번들러 설정은 환경마다 다르므로 개념 위주):

// worker.ts
self.onmessage = (e: MessageEvent) => {
  const items = e.data as Array<{ score: number; name: string }>;
  const filtered = items.filter((x) => x.score > 50).sort((a, b) => b.score - a.score);
  self.postMessage(filtered);
};
// Page.tsx
export function Page({ items }: { items: Array<{ score: number; name: string }> }) {
  const [result, setResult] = useState(items);

  const onClick = () => {
    const worker = new Worker(new URL("./worker.ts", import.meta.url));
    worker.onmessage = (e) => {
      setResult(e.data);
      worker.terminate();
    };
    worker.postMessage(items);
  };

  return (
    <div>
      <button onClick={onClick}>Recompute</button>
      <ul>
        {result.map((x) => (
          <li key={x.name}>{x.name}: {x.score}</li>
        ))}
      </ul>
    </div>
  );
}

UI 스레드는 클릭 이후에도 비교적 자유롭게 페인트할 수 있으므로 INP에 특히 유리합니다.

useTransition: 즉시 업데이트와 무거운 업데이트를 분리

긴 task를 분해해도, “입력 반영”과 “무거운 렌더”가 한 덩어리로 묶여 있으면 여전히 INP가 나빠질 수 있습니다. React 18의 useTransition은 업데이트에 우선순위를 부여해, 사용자 입력 같은 긴급 업데이트가 먼저 처리되도록 돕습니다.

문제 예시: 검색 입력이 결과 렌더 때문에 버벅임

type Item = { id: string; title: string };

function filterItems(items: Item[], q: string) {
  const query = q.trim().toLowerCase();
  if (!query) return items;
  return items.filter((x) => x.title.toLowerCase().includes(query));
}

export function Search({ items }: { items: Item[] }) {
  const [q, setQ] = useState("");

  const filtered = filterItems(items, q);

  return (
    <div>
      <input
        value={q}
        onChange={(e) => setQ(e.target.value)}
        placeholder="Search"
      />
      <ul>
        {filtered.map((x) => (
          <li key={x.id}>{x.title}</li>
        ))}
      </ul>
    </div>
  );
}

문제는 setQ가 즉시 실행되면서, 동시에 큰 리스트 필터링과 렌더가 따라붙는다는 점입니다. 타이핑 이벤트가 연속으로 들어오면 메인 스레드가 계속 바빠지고 INP가 악화됩니다.

개선: 입력 값과 결과 업데이트를 분리하고 결과는 transition으로 처리

type Item = { id: string; title: string };

function filterItems(items: Item[], q: string) {
  const query = q.trim().toLowerCase();
  if (!query) return items;
  return items.filter((x) => x.title.toLowerCase().includes(query));
}

export function Search({ items }: { items: Item[] }) {
  const [inputValue, setInputValue] = useState("");
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  const filtered = useMemo(() => filterItems(items, query), [items, query]);

  return (
    <div>
      <input
        value={inputValue}
        onChange={(e) => {
          const v = e.target.value;
          // 1) 입력창 반영은 즉시
          setInputValue(v);

          // 2) 결과 갱신은 transition으로 낮은 우선순위
          startTransition(() => {
            setQuery(v);
          });
        }}
        placeholder="Search"
      />

      {isPending && <div>Updating results...</div>}

      <ul>
        {filtered.map((x) => (
          <li key={x.id}>{x.title}</li>
        ))}
      </ul>
    </div>
  );
}

이 패턴의 포인트는 다음과 같습니다.

  • 입력 필드 값은 즉시 업데이트되어 “타이핑 반응성”이 좋아짐
  • 무거운 리스트 렌더는 transition으로 밀려, React가 더 중요한 업데이트를 먼저 처리할 수 있음
  • isPending으로 사용자에게 “작업 중” 피드백을 줄 수 있음

useTransition은 “연산을 더 빠르게” 해주는 게 아니라, “급한 UI를 먼저 살리고, 덜 급한 UI를 나중에 하게” 만들어 INP를 개선합니다.

긴 Task 분해와 useTransition을 같이 쓰는 전략

둘은 경쟁 관계가 아니라 조합 관계입니다.

  • useTransition은 우선순위를 조정하지만, transition 업데이트 자체가 너무 무거우면 결국 Long Task가 생깁니다.
  • task 분해는 Long Task를 잘게 쪼개지만, 어떤 업데이트가 먼저 보여야 하는지의 정책은 제공하지 않습니다.

실전에서는 보통 아래 순서가 효율적입니다.

  1. 크롬 Performance에서 INP 후보 상호작용을 고르고, Long Task 구간을 찾는다
  2. Long Task가 “순수 계산”이면 Worker, “부분 계산 가능”이면 청크 분해
  3. 입력, 클릭 직후 반응해야 하는 UI와, 늦어도 되는 UI를 분리하고 useTransition 적용
  4. 리스트가 크면 가상화, 불필요 리렌더 제거를 병행

추가로 Long Task를 짧게 만드는 실전 팁은 아래 글도 함께 참고하면 좋습니다.

체크리스트: INP 급락을 재발시키는 흔한 함정

1) transition 안에서 동기 무거운 계산을 그대로 수행

startTransition으로 감싸도, 그 안에서 300ms짜리 동기 계산을 돌리면 메인 스레드는 여전히 막힙니다. transition은 우선순위일 뿐, CPU 시간을 마법처럼 줄이지 않습니다.

대응:

  • 계산은 Worker로 이동하거나 청크 분해
  • UI 렌더는 가상화

2) 상태 구조가 커서 업데이트 자체가 비쌈

큰 객체를 매번 깊은 복사로 갱신하거나, 전역 상태 한 번 변경에 구독 컴포넌트가 과도하게 리렌더되는 경우가 있습니다.

대응:

  • 상태를 더 작게 쪼개고, 구독 범위를 줄임
  • useMemo, memo, selector 기반 구독 도입

3) “한 번만” 실행되는 관리 화면도 INP는 측정된다

INP는 실제 사용자 상호작용 기반이므로, 관리 화면이나 특정 플로우에서만 발생해도 지표에 반영될 수 있습니다. 특히 검색, 필터, 테이블 정렬 같은 기능은 Long Task가 되기 쉽습니다.

대응:

  • 특정 라우트에서만 무거운 컴포넌트가 뜬다면 해당 라우트에만 가상화, Worker, transition을 집중 적용

결론

React에서 INP 급락의 가장 흔한 원인은 메인 스레드를 오래 점유하는 Long Task입니다. 해결은 두 갈래로 접근하는 것이 효과적입니다.

  • 긴 작업 분해: 청크 처리로 브라우저에 페인트 기회를 주거나, 가능하면 Web Worker로 계산을 분리
  • useTransition: 입력 같은 즉시 반응해야 하는 업데이트와, 무거운 결과 렌더를 분리해 우선순위를 재조정

이 두 가지를 함께 적용하면 “클릭은 먹었는데 화면이 늦게 바뀌는” 체감 문제와, 실제 INP 지표를 동시에 개선할 수 있습니다.