Published on

React 19 use() 무한 Suspense·리렌더 해결법

Authors

서버 컴포넌트와 Suspense가 본격적으로 일상 도구가 되면서 React 19의 use()를 도입하는 팀이 빠르게 늘었습니다. 문제는 use()가 Promise를 “렌더링 중” 소비(consumption)한다는 특성 때문에, Promise의 정체성이 조금만 흔들려도 무한 Suspense(로딩이 끝나지 않음), 깜빡임(fallback 반복), 혹은 리렌더 루프가 쉽게 발생한다는 점입니다.

이 글은 React 19에서 use()로 데이터를 읽을 때 자주 겪는 무한 Suspense·무한 리렌더의 원인을 “왜 그런지”까지 포함해 정리하고, Promise 안정화(캐싱) + 렌더-이펙트 분리 관점에서 재발 방지 패턴을 제시합니다.

관련해서 깜빡임 중심의 케이스는 아래 글도 함께 참고하면 좋습니다.

문제 증상 체크리스트

다음 중 하나라도 해당하면, 대부분 use()에 전달되는 Promise가 렌더마다 새로 생성되거나 캐시되지 않은 비결정적 호출일 가능성이 큽니다.

  • Suspense fallback이 계속 보이며 본 UI로 절대 전환되지 않는다
  • fallback과 본 UI가 번갈아 깜빡인다
  • 네트워크 탭에서 동일 API가 계속 반복 호출된다
  • React DevTools에서 해당 컴포넌트가 과도하게 리렌더된다
  • 상태 업데이트를 하지 않았는데도 렌더가 계속 돈다

핵심 원리: use()는 “Promise 정체성”에 매우 민감하다

use(promise)는 대략 다음처럼 동작한다고 이해하면 디버깅이 쉬워집니다.

  • Promise가 pending이면 렌더링을 중단하고 Suspense로 “던진다”(fallback으로 이동)
  • Promise가 fulfilled이면 값을 반환하고 렌더를 계속한다
  • Promise가 rejected이면 에러 경로(에러 바운더리 등)로 이동한다

여기서 중요한 사실은, 렌더가 다시 실행될 때 동일한 Promise를 다시 use()로 읽어야 이전에 기다리던 작업을 이어받을 수 있다는 점입니다.

반대로 렌더마다 fetch()를 새로 호출해서 새 Promise를 만들면,

  • 렌더 #1은 Promise A를 기다림
  • 어떤 이유로든 재렌더 발생
  • 렌더 #2는 Promise B를 기다림
  • 또 재렌더
  • 렌더 #3은 Promise C...

이런 식으로 “기다리는 대상”이 계속 바뀌면서 영원히 pending처럼 보이는 상황이 만들어집니다. 이게 use() 기반 무한 Suspense의 대표 패턴입니다.

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

가장 흔한 실수입니다.

import { use } from "react";

function UserPanel({ userId }: { userId: string }) {
  // ❌ 렌더마다 새로운 Promise 생성
  const user = use(fetch(`/api/users/${userId}`).then((r) => r.json()));

  return <div>{user.name}</div>;
}

위 코드는 “겉보기엔” 한 번만 호출될 것 같지만, 실제로는 다음 요인들로 쉽게 재렌더가 발생합니다.

  • 부모 컴포넌트의 상태 변경
  • 개발 환경 Strict Mode의 추가 렌더
  • 컨텍스트 값 변경
  • Suspense 경계 재시도(retry)

이때마다 fetch()가 다시 실행되어 Promise가 바뀌고, use()는 새 Promise를 또 기다리게 됩니다.

해결 1-A) 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 promise = fetch(`/api/users/${userId}`)
    .then((r) => {
      if (!r.ok) throw new Error("Failed to fetch user");
      return r.json() as Promise<User>;
    });

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

export function UserPanel({ userId }: { userId: string }) {
  // ✅ 동일 userId에 대해 동일 Promise를 사용
  const user = use(getUserPromise(userId));
  return <div>{user.name}</div>;
}

이 패턴의 핵심은 다음 두 가지입니다.

  • getUserPromise(userId)가 같은 userId에 대해 항상 같은 Promise 인스턴스를 반환
  • 렌더 중 fetch를 “새로 만들지” 않음

해결 1-B) 캐시 무효화(리프레시) 전략도 함께 설계

캐시를 넣으면 “언제 갱신할지”가 바로 다음 문제로 옵니다. 간단한 전략은 아래 중 하나입니다.

  • TTL 기반: 일정 시간 후 캐시 삭제
  • 이벤트 기반: 사용자 액션(저장, 로그아웃 등)에서 캐시 삭제
  • 키 변경 기반: userId 외에 version을 키에 포함

예시(TTL):

const userCache = new Map<string, { promise: Promise<any>; expiresAt: number }>();

function getUserPromiseWithTTL(userId: string, ttlMs = 10_000) {
  const now = Date.now();
  const cached = userCache.get(userId);
  if (cached && cached.expiresAt > now) return cached.promise;

  const promise = fetch(`/api/users/${userId}`).then((r) => r.json());
  userCache.set(userId, { promise, expiresAt: now + ttlMs });
  return promise;
}

원인 2) useMemo로 Promise를 만들었는데도 무한 루프

“그럼 useMemo로 감싸면 되지 않나?”라고 생각하기 쉽습니다. 하지만 의존성이 흔들리면 똑같이 새 Promise가 만들어집니다.

import { use, useMemo } from "react";

function UserPanel({ filter }: { filter: { userId: string } }) {
  // ❌ filter 객체가 매 렌더마다 새로 만들어지면 useMemo도 무력화
  const p = useMemo(() => {
    return fetch(`/api/users/${filter.userId}`).then((r) => r.json());
  }, [filter]);

  const user = use(p);
  return <div>{user.name}</div>;
}

해결 2) 의존성을 “원시값”으로 축소하거나, 상위에서 안정화

function UserPanel({ userId }: { userId: string }) {
  const p = useMemo(() => {
    return fetch(`/api/users/${userId}`).then((r) => r.json());
  }, [userId]);

  const user = use(p);
  return <div>{user.name}</div>;
}

다만 useMemo는 “동일 렌더 트리에서의 메모”에 가깝고, 컴포넌트가 언마운트되면 사라집니다. 데이터 계층에서 재사용/중복 제거가 필요하면 앞의 Map 캐시처럼 모듈 스코프 캐시가 더 단단합니다.

원인 3) Suspense 경계가 너무 작거나, 경계가 재생성됨

Suspense는 경계가 안정적으로 유지되어야 “한 번 로딩하고 끝”이 됩니다. 아래처럼 경계가 조건부 렌더로 자주 갈아끼워지면, 로딩이 반복되는 느낌(혹은 실제 반복)이 날 수 있습니다.

function Page({ show }: { show: boolean }) {
  return (
    <div>
      {show ? (
        // ❌ show 토글 때마다 경계가 새로 생겼다가 사라짐
        <Suspense fallback={<div>Loading...</div>}>
          <UserPanel userId="1" />
        </Suspense>
      ) : null}
    </div>
  );
}

해결 3) 경계를 더 상위로 올리고, 조건부는 내부로

function Page({ show }: { show: boolean }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      {show ? <UserPanel userId="1" /> : <div>Hidden</div>}
    </Suspense>
  );
}

경계를 상위로 올리면 “경계 인스턴스”가 덜 흔들리고, retry/전환이 더 예측 가능해집니다.

원인 4) Strict Mode 개발 환경에서만 유독 심해 보임

개발 환경 Strict Mode는 부작용을 찾기 위해 렌더/이펙트가 더 자주 실행되는 것처럼 보일 수 있습니다. 이때 렌더 중 네트워크 요청을 만들면 문제가 과장되어 보입니다.

  • 운영에서는 “가끔 깜빡임”인데
  • 개발에서는 “거의 무한”처럼 보이는

상황이 생깁니다.

해결 4) 렌더 중 side effect를 만들지 말고, 캐시된 Promise만 소비

정리하면 다음 규칙을 지키면 Strict Mode에서도 안정적으로 동작합니다.

  • 렌더 단계: use()로 “이미 존재하는 Promise”를 읽기만 한다
  • 생성 단계: Promise 생성은 캐시 계층에서 1회만

원인 5) 서버/클라이언트 경계에서 다른 값을 만들어 hydration이 흔들림

Next.js를 포함한 SSR 환경에서는 서버 렌더 결과와 클라이언트 초기 렌더 결과가 달라지면 hydration mismatch가 발생하고, 그 여파로 재렌더/경계 재시도가 연쇄적으로 일어날 수 있습니다.

특히 아래 같은 코드가 위험합니다.

  • Date.now()Math.random()으로 키/요청 파라미터 생성
  • 클라이언트에서만 가능한 값(로컬스토리지, 브라우저 API)을 렌더 중에 사용

이 케이스는 use() 문제처럼 보이지만, 실제 뿌리는 hydration 불일치인 경우가 많습니다. 자세한 체크 포인트는 아래 글이 도움이 됩니다.

디버깅: “Promise가 렌더마다 바뀌는지”부터 확인

무한 Suspense를 빠르게 좁히는 방법은 간단합니다.

  1. use()에 전달하는 Promise를 변수로 빼고
  2. 렌더마다 동일 참조인지 로그로 확인
import { use } from "react";

const cache = new Map<string, Promise<any>>();

function getP(key: string) {
  if (!cache.has(key)) {
    cache.set(key, fetch(`/api/users/${key}`).then((r) => r.json()));
  }
  return cache.get(key)!;
}

export function DebugUser({ userId }: { userId: string }) {
  const p = getP(userId);

  // ✅ 같은 userId인데 이 로그에서 p가 계속 달라지면 캐시가 깨진 것
  console.log("promise identity", userId, p);

  const user = use(p);
  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}

만약 p가 바뀐다면 원인은 대개 아래입니다.

  • 캐시 키가 안정적이지 않다(객체를 키로 쓰거나, 랜덤/시간 값 포함)
  • 모듈 스코프가 아닌 컴포넌트 내부에 캐시를 만들었다
  • HMR로 모듈이 재로드되며 캐시가 초기화된다(개발 환경)

권장 아키텍처: “요청 생성”과 “요청 소비”를 분리

실전에서 가장 안전한 구조는 아래처럼 계층을 나누는 것입니다.

  • api.ts: fetch 함수(순수 함수)
  • resource.ts: 캐시를 포함한 Promise 생성/재사용
  • component.tsx: use(resourcePromise)로 소비

예시:

// api.ts
export async function fetchUser(userId: string) {
  const r = await fetch(`/api/users/${userId}`);
  if (!r.ok) throw new Error("fetchUser failed");
  return r.json();
}
// resource.ts
import { fetchUser } from "./api";

const cache = new Map<string, Promise<any>>();

export function userResource(userId: string) {
  const hit = cache.get(userId);
  if (hit) return hit;

  const p = fetchUser(userId);
  cache.set(userId, p);
  return p;
}

export function invalidateUser(userId: string) {
  cache.delete(userId);
}
// UserPanel.tsx
import { use } from "react";
import { userResource } from "./resource";

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

이렇게 분리하면 다음이 좋아집니다.

  • 컴포넌트는 “데이터 읽기”만 담당해서 단순해짐
  • 캐시 무효화 정책을 한 곳에서 관리 가능
  • 무한 Suspense/리렌더의 원인(불안정 Promise) 자체가 구조적으로 차단됨

체크리스트: 무한 Suspense·리렌더 재발 방지 규칙

  • use()에 넘기는 Promise는 렌더마다 새로 만들지 않는다
  • Promise는 키 기반 캐시로 재사용한다(Map, 라이브러리 캐시 등)
  • Suspense 경계는 가능하면 상위에 두고, 경계 자체가 자주 재생성되지 않게 한다
  • 의존성은 객체보다 원시값 중심으로 안정화한다
  • SSR 환경에서는 랜덤/시간 기반 값으로 요청 키를 만들지 않는다

마무리

React 19의 use()는 “비동기 값을 렌더에서 직접 읽는다”는 점에서 강력하지만, 그만큼 Promise의 정체성 안정화가 필수 전제입니다. 무한 Suspense와 리렌더 루프는 대부분 use() 자체의 버그라기보다, 렌더 단계에서 매번 새로운 Promise를 만들어 생기는 구조적 문제입니다.

우선 use()에 전달되는 Promise가 렌더마다 바뀌는지부터 확인하고, 바뀐다면 캐시/리소스 패턴으로 “요청 생성”을 컴포넌트 밖으로 빼는 것만으로도 대다수 문제가 깔끔하게 해결됩니다.