Published on

React useTransition 무한 로딩·깜빡임 해결법

Authors

서버 요청이나 무거운 렌더링을 useTransition으로 감쌌는데 isPending이 계속 true로 남거나, 로딩 스피너가 깜빡이고 리스트가 순간적으로 비는 현상을 겪는 경우가 많습니다. 특히 검색 자동완성, 필터/정렬, 페이지네이션 같은 UI에서 전환을 잘못 설계하면 “전환이 끝나지 않는 것처럼 보이는” 상태가 쉽게 만들어집니다.

이 글에서는 다음 두 가지를 분리해서 다룹니다.

  • 무한 로딩처럼 보이는 isPending: 실제로 전환이 계속 새로 시작되거나, 전환이 끝나도 곧바로 다음 전환이 발생하는 루프
  • 깜빡임(flicker): 전환 중에 기존 UI를 유지하지 못하고 빈 화면이나 fallback으로 튀는 현상

또한, 폼 제출/중복 요청과 함께 엮이면 체감상 “로딩이 끝나지 않는다”가 더 자주 발생합니다. 폼 계열은 useTransition만으로 해결하려 하지 말고 useActionState 같은 패턴도 고려하세요. 자세한 내용은 React 19 useActionState로 폼 지연·중복 제출 해결도 함께 참고하면 좋습니다.

useTransition이 실제로 보장하는 것

useTransition은 “비동기 요청을 관리하는 훅”이 아니라, 특정 state 업데이트를 낮은 우선순위로 처리해서 입력/스크롤 같은 상호작용을 덜 막도록 하는 도구입니다.

  • startTransition(fn) 안에서 발생한 state 업데이트는 “전환 업데이트”로 분류됩니다.
  • isPending은 “전환 업데이트가 아직 커밋되지 않았는지”를 나타냅니다.
  • 네트워크 요청의 완료 여부와 isPending은 1:1로 연결되지 않습니다.

따라서 다음과 같은 구조는 흔히 오해를 부릅니다.

const [isPending, startTransition] = useTransition();

const onSearch = async (q: string) => {
  startTransition(async () => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
    const data = await res.json();
    setItems(data);
  });
};

위 코드는 얼핏 그럴듯하지만, startTransitionasync 함수의 완료를 기다려서 isPending을 관리하는 API가 아닙니다. 네트워크 요청은 전환의 “범위”와 별개로 진행되고, 전환은 결국 setItems가 발생하는 시점의 렌더링/커밋과 관련됩니다.

증상 1: isPending이 끝나지 않는(것처럼 보이는) 대표 원인

1) 전환 중에 또 전환을 발생시키는 상태 루프

가장 흔한 패턴은 “전환으로 바뀐 상태를 useEffect가 감지해서 다시 전환을 시작”하는 구조입니다.

잘못된 예: query 변경이 fetch를 부르고, fetch 결과가 query를 다시 바꿈

const [query, setQuery] = useState("");
const [items, setItems] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();

useEffect(() => {
  // query가 바뀌면 검색
  startTransition(() => {
    void (async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = (await res.json()) as string[];
      setItems(data);

      // 여기서 query를 정규화한다며 다시 setQuery를 하면 루프가 쉽게 생김
      // setQuery(query.trim());
    })();
  });
}, [query]);

setQuery가 다시 실행되면 useEffect가 다시 실행되고, 전환이 연속으로 발생합니다. 이 경우 isPending이 “항상 true로 보이는” 현상이 생깁니다.

해결: 전환과 사이드이펙트를 분리하고, query 정규화는 입력 단계에서 끝내기

  • 입력 핸들러에서 trim/toLowerCase 등을 적용
  • useEffect에서는 query가 실제로 달라졌을 때만 fetch
  • fetch 결과로 query를 다시 바꾸지 않기
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const next = e.target.value; // 필요하면 여기서 정규화
  setQuery(next);
};

useEffect(() => {
  let cancelled = false;

  const run = async () => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = (await res.json()) as string[];
    if (!cancelled) {
      startTransition(() => {
        setItems(data);
      });
    }
  };

  void run();
  return () => {
    cancelled = true;
  };
}, [query, startTransition]);

핵심은 네트워크는 네트워크대로, 전환은 UI 업데이트에만 적용하는 것입니다.

2) 전환 범위를 너무 넓게 잡아서 매 렌더마다 무거운 작업이 반복

startTransition으로 상태를 바꿨는데, 그 상태를 기반으로 매 렌더마다 큰 계산을 다시 수행하면 전환이 연쇄적으로 길어지고 isPending이 “계속 true”처럼 보일 수 있습니다.

잘못된 예: 렌더링 중 대량 정렬/필터

const filtered = items
  .filter((x) => x.includes(query))
  .sort((a, b) => a.localeCompare(b));

해결: 메모이제이션, 혹은 서버로 이동

const filtered = useMemo(() => {
  return items
    .filter((x) => x.includes(query))
    .sort((a, b) => a.localeCompare(b));
}, [items, query]);

또는 정말 비용이 큰 계산이라면, 클라이언트에서 억지로 처리하지 말고 서버 쿼리로 밀어내는 편이 낫습니다. 이때도 UI 반응성 문제를 체감적으로 확인하려면 INP/Long Task를 같이 봐야 합니다. 관련해서는 Chrome INP 점수 급락? Long Task 추적·해결 글이 도움이 됩니다.

3) Strict Mode에서 “두 번 실행”을 무한 로딩으로 착각

개발 모드에서 React Strict Mode는 일부 로직을 의도적으로 두 번 실행해 부작용을 찾습니다. 이때 fetch가 2번 나가고 로딩이 길어져서 isPending이 이상해 보일 수 있습니다.

  • 개발 모드에서만 재현되는지 확인
  • useEffect 내부 fetch에 취소 플래그/AbortController를 추가
useEffect(() => {
  const ac = new AbortController();

  const run = async () => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: ac.signal,
    });
    const data = (await res.json()) as string[];
    startTransition(() => setItems(data));
  };

  void run();
  return () => ac.abort();
}, [query, startTransition]);

증상 2: 로딩 스피너/리스트가 깜빡이는 대표 원인

1) 전환 중 기존 UI를 유지하지 않고 “초기화”해버림

검색할 때 흔히 다음을 합니다.

  • query가 바뀌면 setItems([])로 비우고
  • 요청이 끝나면 새 items를 채움

이러면 전환 중에 리스트가 비었다가 다시 채워져서 깜빡임이 발생합니다.

잘못된 예

const onQueryChange = (q: string) => {
  setQuery(q);
  startTransition(() => {
    setItems([]); // 깜빡임 유발
  });
};

해결: 이전 결과를 유지하고 “오버레이 로딩”만 표시

return (
  <div style={{ position: "relative" }}>
    {isPending && (
      <div
        style={{
          position: "absolute",
          inset: 0,
          background: "rgba(255,255,255,0.6)",
        }}
      >
        Loading...
      </div>
    )}

    <ul aria-busy={isPending}>
      {items.map((x) => (
        <li key={x}>{x}</li>
      ))}
    </ul>
  </div>
);

핵심은 데이터를 비우지 말고, 기존 화면을 유지한 채 “업데이트 중”임을 표현하는 것입니다.

2) Suspense fallback이 자주 나타나며 화면이 튐

App Router, RSC, Suspense를 같이 쓰면 전환 중에 fallback이 등장하며 깜빡임이 생길 수 있습니다. 특히 캐시 설정이나 데이터 패칭 위치에 따라 “항상 새로 로드”되는 것처럼 보이기도 합니다.

  • Suspense 경계를 너무 바깥에 두면 작은 변경에도 전체가 fallback으로 떨어짐
  • 전환과 함께 라우트 세그먼트 전체가 다시 로드되면 깜빡임이 커짐

이 경우 Next.js 캐시/재검증 설정이 원인인 경우가 많습니다. 관련해서는 Next.js 14 RSC 캐시로 데이터가 안 갱신될 때도 함께 확인해보세요.

해결 방향

  • Suspense 경계를 더 안쪽으로 옮겨 부분만 fallback
  • 전환 중에도 유지되어야 하는 영역은 클라이언트 상태로 유지
  • 라우팅 전환은 loading.tsx에만 의존하지 말고, 화면 내 오버레이 로딩도 병행

실전 패턴: 검색 UI에서 깜빡임 없이 전환 적용하기

요구사항을 다음처럼 잡아보겠습니다.

  • 입력은 즉시 반응해야 함
  • 결과 렌더는 전환으로 처리해 타이핑이 끊기지 않게
  • 전환 중에도 기존 결과는 유지
  • 오래 걸린 요청은 취소하여 최신 query만 반영
import React, { useEffect, useMemo, useState, useTransition } from "react";

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

export function SearchBox() {
  const [input, setInput] = useState("");
  const [query, setQuery] = useState("");
  const [items, setItems] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  // 타이핑은 즉시, query 반영은 약간 늦춰도 되면 디바운스 가능
  useEffect(() => {
    const t = setTimeout(() => setQuery(input), 200);
    return () => clearTimeout(t);
  }, [input]);

  useEffect(() => {
    const ac = new AbortController();

    const run = async () => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: ac.signal,
      });
      const data = (await res.json()) as Item[];

      // 네트워크 완료 후 UI 업데이트만 transition
      startTransition(() => {
        setItems(data);
      });
    };

    // 빈 query는 요청 스킵 (불필요한 pending 방지)
    if (query.trim().length === 0) {
      startTransition(() => setItems([]));
      return () => ac.abort();
    }

    void run();
    return () => ac.abort();
  }, [query, startTransition]);

  const countText = useMemo(() => {
    return `${items.length} results`;
  }, [items.length]);

  return (
    <section>
      <label>
        Search
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          aria-label="search"
        />
      </label>

      <div style={{ position: "relative", marginTop: 12 }}>
        <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <span>{countText}</span>
          {isPending && <span>Updating...</span>}
        </div>

        <ul aria-busy={isPending}>
          {items.map((it) => (
            <li key={it.id}>{it.title}</li>
          ))}
        </ul>
      </div>
    </section>
  );
}

이 패턴의 포인트는 다음입니다.

  • startTransitionfetch를 감싸지 않고, 결과를 setItems로 반영할 때만 사용
  • 전환 중에도 기존 items를 유지하므로 깜빡임이 줄어듦
  • AbortController로 “느린 이전 요청”이 최신 결과를 덮어쓰는 문제 방지
  • 빈 query 처리로 불필요한 전환/로딩 상태 최소화

체크리스트: 무한 로딩·깜빡임을 빠르게 줄이는 진단 순서

1) 전환이 “끊임없이 시작되는”지 확인

  • useEffect 의존성 배열에 상태가 과하게 들어가 있지 않은가
  • 전환으로 바뀐 상태가 다시 전환을 트리거하지 않는가
  • 개발 모드 Strict Mode에서만 재현되는가

필요하면 아래처럼 로깅해 전환 시작 빈도를 확인합니다.

const [isPending, startTransition] = useTransition();

const safeTransition = (label: string, fn: () => void) => {
  console.log("transition start:", label);
  startTransition(() => {
    fn();
  });
};

2) 전환 중 “데이터를 비우는 코드”가 있는지 찾기

  • setItems([])
  • setData(null)
  • setState(initialState)

이런 코드는 대부분 깜빡임의 직접 원인입니다. 전환 중에는 기존 UI를 유지하고, 로딩 표시는 오버레이/스켈레톤으로 처리하는 편이 안정적입니다.

3) Suspense 경계가 너무 큰지 확인

  • 작은 변경에도 전체가 fallback으로 떨어지면 경계를 쪼개야 합니다.
  • 라우트 전환과 데이터 전환이 섞이면 깜빡임이 커집니다.

4) 성능 병목이 있는지 확인

전환이 끝나지 않는 게 아니라, 렌더가 너무 무거워서 “끝나기까지 오래 걸리는” 경우도 많습니다.

  • 큰 리스트 렌더링이면 가상화 고려
  • 정렬/필터는 useMemo
  • 불필요한 재렌더를 줄이기 위해 컴포넌트 분리

마무리

useTransition 문제의 대부분은 훅 자체의 버그라기보다, 전환의 의미(낮은 우선순위 렌더)와 네트워크/사이드이펙트의 의미(비동기 작업)가 섞이면서 생깁니다.

  • startTransitionUI state 업데이트에만 적용
  • fetch는 useEffect 등에서 수행하고, 완료 후 상태 반영을 전환으로 처리
  • 전환 중 데이터 초기화는 피하고, 기존 UI를 유지하며 로딩을 표시
  • Suspense 경계는 작게, 성능 병목은 측정으로 확인

이 4가지만 지켜도 isPending 무한 로딩처럼 보이는 현상과 깜빡임은 대부분 빠르게 안정화됩니다.