Published on

React 19 use() 무한 Suspense·깜빡임 해결법

Authors

React 19에서 use() 는 Promise를 “읽는” 방식으로 Suspense와 자연스럽게 연결해줍니다. 문제는, 기존 useEffect 기반 데이터 패칭 사고방식 그대로 옮기면 무한 Suspense 루프 또는 로딩과 콘텐츠가 번갈아 보이는 깜빡임이 쉽게 생긴다는 점입니다.

이 글에서는 React 19 use() 사용 시 자주 터지는 문제를 원인별로 분해하고, Next.js 환경(특히 App Router)에서 재현 가능한 코드로 해결책을 정리합니다.

증상 먼저 정리: 무한 Suspense vs 깜빡임

1) 무한 Suspense(영원히 fallback만 보임)

  • 화면이 계속 fallback에 머무르거나
  • 네트워크 탭에는 요청이 반복적으로 찍히고
  • 컴포넌트가 계속 다시 렌더링되며 Promise가 매번 새로 만들어짐

2) 깜빡임(fallback과 실제 UI가 번갈아 등장)

  • 데이터는 오는데도
  • 사용자 상호작용(타이핑, 탭 전환, 상태 변경) 때마다
  • 기존 UI가 잠깐 사라지고 fallback이 다시 나타남

두 증상 모두 렌더링 중에 새 Promise를 만들거나, Promise의 정체성이 렌더마다 바뀌는 것에서 시작하는 경우가 대부분입니다.

핵심 원리: use() 는 “렌더 중”에 Promise를 읽는다

use(promise) 는 다음처럼 동작합니다.

  • Promise가 pending이면 throw해서 가장 가까운 Suspense boundary로 올라감
  • resolved면 값을 반환
  • rejected면 error boundary로 올라감

즉, use()await 처럼 보이지만, 컴포넌트 렌더링 경로에 직접 들어오는 제어 흐름입니다.

따라서 다음이 매우 중요합니다.

  • Promise는 렌더마다 새로 만들면 안 됨
  • Promise는 “동일한 입력”에 대해 동일한 객체(정체성)가 유지되어야 함
  • 상태 업데이트로 재렌더가 일어나도, 불필요하게 Promise가 교체되면 fallback이 다시 뜸

무한 Suspense의 대표 원인 5가지와 해결

원인 1) 렌더에서 fetch() 를 직접 호출

아래 코드는 가장 흔한 실수입니다.

import { use } from "react";

async function getUser() {
  const res = await fetch("/api/user");
  if (!res.ok) throw new Error("failed");
  return res.json();
}

export function Profile() {
  const user = use(getUser()); // 렌더마다 Promise 새로 생성
  return <div>{user.name}</div>;
}

이 컴포넌트가 어떤 이유로든 재렌더되면 getUser() 가 다시 호출되어 새 Promise가 만들어지고, Suspense는 다시 pending으로 떨어질 수 있습니다. 특히 부모가 상태를 자주 바꾸면 사실상 무한 루프처럼 보입니다.

해결: Promise를 “렌더 밖”에서 안정적으로 만들기

가장 단순한 방법은 입력을 기준으로 Promise를 캐시하는 함수 레벨 캐시를 두는 것입니다.

import { use } from "react";

type User = { id: string; name: string };

const userPromiseCache = new Map<string, Promise<User>>();

function getUserPromise(userId: string) {
  const cached = userPromiseCache.get(userId);
  if (cached) return cached;

  const p = fetch(`/api/users/${userId}`).then((res) => {
    if (!res.ok) throw new Error("failed to fetch user");
    return res.json() as Promise<User>;
  });

  userPromiseCache.set(userId, p);
  return p;
}

export function Profile({ userId }: { userId: string }) {
  const user = use(getUserPromise(userId));
  return <div>{user.name}</div>;
}

이제 동일한 userId 에 대해 Promise 정체성이 유지되므로, 재렌더가 발생해도 Suspense가 불필요하게 다시 걸리지 않습니다.

주의: 이 캐시는 프로세스 메모리에 남습니다. 사용자별 데이터, 권한이 다른 데이터는 캐시 키 설계가 매우 중요합니다.

원인 2) 키가 바뀌는 Suspense boundary로 인해 매번 “새 마운트”

Suspense boundary나 그 아래 트리에 key 를 잘못 주면, React는 매번 새로 마운트합니다. 마운트가 새로 되면 캐시가 없거나 Promise가 새로 만들어져 fallback이 반복됩니다.

<Suspense fallback={<Loading />} key={Date.now()}>
  <Profile userId={userId} />
</Suspense>

해결

  • Suspense에 불필요한 key 를 주지 않기
  • 키가 필요하다면 “정말로 화면을 리셋해야 하는 입력”에만 사용하기
<Suspense fallback={<Loading />}>
  <Profile userId={userId} />
</Suspense>

원인 3) 상태 업데이트가 Promise 생성 로직을 자극

예를 들어 검색어 입력을 할 때마다 use(getSearchPromise(q)) 가 매번 새로운 Promise를 만들면, 타이핑할 때마다 fallback이 튀는 현상이 발생합니다.

해결 1: 입력을 디바운스하고, 안정적인 키로 캐시

import { use, useMemo, useState, useEffect } from "react";

function useDebouncedValue(value: string, delayMs: number) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(t);
  }, [value, delayMs]);
  return debounced;
}

type Item = { id: string; title: string };
const searchCache = new Map<string, Promise<Item[]>>();

function getSearchPromise(q: string) {
  const key = q.trim().toLowerCase();
  const cached = searchCache.get(key);
  if (cached) return cached;

  const p = fetch(`/api/search?q=${encodeURIComponent(key)}`).then((r) => {
    if (!r.ok) throw new Error("search failed");
    return r.json() as Promise<Item[]>;
  });

  searchCache.set(key, p);
  return p;
}

export function SearchBox() {
  const [q, setQ] = useState("");
  const debounced = useDebouncedValue(q, 250);

  const promise = useMemo(() => getSearchPromise(debounced), [debounced]);
  const items = use(promise);

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <ul>
        {items.map((it) => (
          <li key={it.id}>{it.title}</li>
        ))}
      </ul>
    </div>
  );
}

여기서 포인트는 두 가지입니다.

  • 디바운스로 “불필요한 렌더 기반 패칭”을 줄이고
  • useMemo 로 현재 렌더에서 사용할 Promise 참조를 안정화합니다

해결 2: startTransition 으로 입력과 로딩을 분리

UI 입력은 즉시 반영하고, 결과 리스트 갱신은 transition으로 보내면 체감 깜빡임이 줄어듭니다.

import { startTransition, useState } from "react";

export function SearchContainer() {
  const [q, setQ] = useState("");
  const [queryForResult, setQueryForResult] = useState("");

  return (
    <div>
      <input
        value={q}
        onChange={(e) => {
          const next = e.target.value;
          setQ(next);
          startTransition(() => {
            setQueryForResult(next);
          });
        }}
      />
      {/* queryForResult를 기준으로 Suspense 데이터 로딩 */}
      <Results q={queryForResult} />
    </div>
  );
}

원인 4) 에러가 반복되는데 Error boundary가 없어서 재시도 루프처럼 보임

Promise가 reject되면 use() 는 에러를 throw합니다. Error boundary가 없으면 상위에서 잡지 못하고, 개발 환경에서 리렌더가 반복되며 “무한 로딩”처럼 보일 수 있습니다.

해결: Suspense와 Error boundary를 세트로 배치

import { Suspense } from "react";

class ErrorBoundary extends React.Component<
  { fallback: (e: unknown) => React.ReactNode; children: React.ReactNode },
  { error: unknown }
> {
  state = { error: null } as { error: unknown };

  static getDerivedStateFromError(error: unknown) {
    return { error };
  }

  render() {
    if (this.state.error) return this.props.fallback(this.state.error);
    return this.props.children;
  }
}

export function Page() {
  return (
    <ErrorBoundary fallback={(e) => <div>에러: {String(e)}</div>}>
      <Suspense fallback={<div>로딩...</div>}>
        <Profile userId="42" />
      </Suspense>
    </ErrorBoundary>
  );
}

원인 5) 개발 모드 Strict Mode로 인한 이중 호출을 오해

개발 환경에서 React Strict Mode는 일부 함수가 두 번 실행되어 부작용을 잡습니다. use() 자체가 원인은 아니지만, 렌더 중 Promise 생성 같은 패턴이 있으면 문제가 더 크게 보입니다.

해결

  • “렌더 중 Promise 생성”을 제거하면 Strict Mode에서도 안정화됩니다.
  • 프로덕션에서만 괜찮은 코드를 목표로 삼지 말고, 개발에서도 안정적인 구조로 바꾸는 게 장기적으로 이득입니다.

깜빡임을 줄이는 Suspense 설계 패턴

무한 Suspense를 막았더라도, UX 관점에서 깜빡임은 별개로 남을 수 있습니다. 특히 리스트 페이지에서 필터가 바뀔 때마다 전체가 fallback으로 바뀌면 사용자가 피로합니다.

패턴 1) “전체 페이지”가 아니라 “일부 영역”만 Suspense로 감싸기

export function Dashboard() {
  return (
    <div>
      <Header />
      <Suspense fallback={<div>차트 로딩...</div>}>
        <Charts />
      </Suspense>
      <Suspense fallback={<div>테이블 로딩...</div>}>
        <Table />
      </Suspense>
    </div>
  );
}

이렇게 쪼개면 한 영역이 로딩되어도 나머지는 유지되어 깜빡임이 줄어듭니다.

패턴 2) fallback을 “레이아웃 점프가 없는 스켈레톤”으로

fallback이 실제 콘텐츠와 크기가 다르면 레이아웃이 흔들리고, 이를 사용자는 깜빡임으로 인지합니다. 스켈레톤을 실제 높이에 맞추면 체감이 크게 좋아집니다.

이때 CLS 관점에서 문제를 추적하는 방법은 다음 글이 도움이 됩니다.

Next.js(App Router)에서 특히 조심할 점

Next.js App Router에서는 서버 컴포넌트와 클라이언트 컴포넌트의 경계가 있고, 데이터 패칭의 기본 위치가 달라집니다.

권장: 서버에서 데이터 준비, 클라이언트는 상호작용에 집중

서버 컴포넌트에서 데이터를 가져와 props로 내려주면, 클라이언트에서 use() 로 Promise를 다루는 빈도가 줄고 깜빡임도 줄어듭니다.

// app/users/[id]/page.tsx (Server Component)
import { Suspense } from "react";

async function getUser(id: string) {
  const res = await fetch(`https://example.com/api/users/${id}`, {
    cache: "no-store",
  });
  if (!res.ok) throw new Error("failed");
  return res.json();
}

export default async function Page({ params }: { params: { id: string } }) {
  const user = await getUser(params.id);

  return (
    <div>
      <h1>{user.name}</h1>
      <Suspense fallback={<div>활동 로딩...</div>}>
        <UserActivity userId={params.id} />
      </Suspense>
    </div>
  );
}

클라이언트 컴포넌트에서 굳이 use() 로 fetch를 반복하면, 라우팅 전환이나 상태 변화에 따라 깜빡임이 더 커질 수 있습니다.

또한 이미지가 많은 페이지라면 로딩 fallback과 이미지 최적화 설정이 맞물려 “로딩이 길어져 깜빡임이 더 도드라져 보이는” 상황이 생깁니다. Next.js 이미지 최적화 이슈가 의심되면 아래 글도 함께 점검하세요.

실전 체크리스트: use() 도입 전후로 이것만 확인

무한 Suspense 방지

  • 렌더 함수에서 fetch() 또는 async 함수 호출로 Promise를 만들지 않았는가
  • 동일 입력에 대해 Promise 정체성이 유지되는가(캐시 또는 상위에서 주입)
  • Suspense boundary에 불필요한 key 가 없는가
  • 에러가 발생할 때 Error boundary가 있는가

깜빡임(UX) 완화

  • Suspense를 페이지 전체가 아닌 영역 단위로 분리했는가
  • fallback이 스켈레톤 형태로 레이아웃 점프를 줄이는가
  • 입력과 결과 갱신을 transition으로 분리했는가
  • 검색, 필터 등 빈번한 변경은 디바운스 또는 캐시 키 설계를 했는가

결론

React 19의 use() 는 “Promise를 렌더에서 읽는다”는 강력한 모델을 제공하지만, 그만큼 Promise 정체성 관리가 설계의 중심이 됩니다. 무한 Suspense와 깜빡임의 대부분은

  • 렌더마다 새 Promise 생성
  • boundary 재마운트
  • 잦은 상태 변경과 결합

에서 발생합니다.

해결은 의외로 단순합니다.

  • Promise를 캐시하거나 상위에서 생성해 주입하고
  • Suspense를 잘게 쪼개며
  • transition과 스켈레톤으로 UX를 다듬는 것

이 3가지만 지켜도 use() 는 “실험적 기능”이 아니라, 충분히 안정적인 데이터 로딩 도구가 됩니다.