React 19 `use()` Hook: Promise는 Suspense로, Context는 조건부로
React 19가 2024년 12월에 정식 릴리즈되면서 use()라는 API가 experimental 딱지를 떼고 stable로 승격되었습니다. 이름만 보면 Hook처럼 보이지만, 실제로는 Hook의 호출 규칙과는 다른 규칙을 따르는 별개의 렌더 타임 API입니다.
이 글이 다루려는 문제는 둘입니다. 하나는 데이터 페칭에서 반복되는 useState + useEffect + 로딩 플래그 보일러플레이트, 다른 하나는 useContext의 최상위 호출 제약 때문에 생기는 우회 코드입니다. use()는 이 둘을 각각 다른 메커니즘으로 다룹니다. Promise를 넘기면 Suspense 경계와 Error Boundary로 흐름을 위임하고, Context를 넘기면 useContext처럼 값을 반환하되 조건문·루프 안에서도 호출할 수 있습니다. Context 소비 자체는 Suspense와 무관하다는 점을 먼저 분명히 해둡니다.
이 글에서는 use()의 두 가지 모드가 각각 어떻게 다른지, 실무에서 어떤 패턴이 자리잡고 있는지, 그리고 복붙하면 바로 터지는 함정들이 무엇인지를 정리합니다.
use()의 두 가지 모드
use()는 단일 시그니처 use(resource)를 갖지만, resource의 타입에 따라 완전히 다른 분기를 탑니다.
Promise 분기만 Suspense 경계를 요구합니다. Context 분기는 그냥 값을 꺼내는 동기 연산이고, suspend를 발생시키지 않습니다. 두 모드를 섞어서 이해하면 뒤에 나오는 Error Boundary 배치나 Stable Promise 문제가 왜 생기는지도 설명이 꼬입니다.
Hook과의 관계
use()를 "Hook 규칙을 깨는 Hook"으로 소개하는 글이 많은데, 정확히 말하면 Hook이 아니라 Hook과 다른 규칙을 가진 별개의 렌더 타임 API입니다. 조건부·반복문 안에서 호출할 수 있는 것은 use()만의 특성이고, 이게 다른 useState·useEffect에도 적용된다는 뜻이 아닙니다.
Context 모드: 조건부 소비가 열리는 지점
Context 소비는 useContext가 지금까지 담당해 왔습니다. 하지만 useContext는 컴포넌트 최상위에서만 호출해야 하기 때문에, 특정 조건에서만 Context가 필요한 경우 억지로 최상위에서 꺼낸 뒤 조건문에서 쓰는 식의 우회가 흔했습니다.
import { createContext, use } from 'react';
type Permissions = { canEditUsers: boolean };
const PermissionsContext = createContext<Permissions | null>(null);
function NavItem({ isAdmin }: { isAdmin: boolean }) {
if (isAdmin) {
const permissions = use(PermissionsContext);
if (!permissions) return null;
return <AdminLink canEdit={permissions.canEditUsers} />;
}
return <GuestLink />;
}use(Context)는 useContext(Context)와 반환 값·동작이 동일합니다. 유일한 차이는 호출 위치의 제약입니다.
| 항목 | useContext |
use(Context) |
|---|---|---|
| 조건부 호출 | 불가 | 가능 |
| 루프 내 호출 | 불가 | 가능 |
| 반환 값 | Provider의 현재 value | 동일 |
| Suspense 영향 | 없음 | 없음 |
Context 모드에서는 Suspense도 Error Boundary도 등장하지 않습니다. 그 부분은 Promise 모드만의 이야기입니다.
Promise 모드: Suspense와 Error Boundary로의 위임
Promise를 넘기면 비로소 Suspense 경계가 역할을 합니다. 이때 가장 많이 쓰이는 조합은 "서버에서 Promise를 만들고, 클라이언트에서 use()로 언래핑"하는 RSC 브릿지 패턴입니다.
패턴 1: Server Component → Client Component Promise 전달
// app/users/[id]/page.tsx — Server Component
import { Suspense } from 'react';
import { UserCard } from './UserCard';
async function fetchUser(id: string) {
const res = await fetch(`${process.env.API_BASE_URL}/users/${id}`, {
cache: 'no-store',
});
return res.json();
}
export default function Page({ params }: { params: { id: string } }) {
const userPromise = fetchUser(params.id);
return (
<Suspense fallback={<Skeleton />}>
<UserCard promise={userPromise} />
</Suspense>
);
}// UserCard.tsx — Client Component
'use client';
import { use } from 'react';
type User = { id: string; name: string };
export function UserCard({ promise }: { promise: Promise<User> }) {
const user = use(promise);
return <div>{user.name}</div>;
}서버는 await 없이 Promise 그대로 내려주고, 스트리밍이 시작됩니다. 클라이언트는 Promise가 resolve되는 시점에 렌더링을 완료합니다. use(promise)의 반환 타입은 Awaited<T>로 추론되므로 Promise<User>를 넘기면 User가 반환되고 별도 타입 단언이 필요 없습니다.
패턴 2: Error Boundary + Suspense 중첩
Promise 모드에서 거부 처리를 하려면 Error Boundary가 Suspense 바깥을 감싸야 합니다. 순서가 뒤집히면 reject된 Promise가 Error Boundary까지 도달하기 전에 Suspense가 다시 pending으로 돌아가면서 의도치 않은 상태가 됩니다.
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Spinner />}>
<UserCard promise={userPromise} />
</Suspense>
</ErrorBoundary>구조를 외우기 쉽게 요약하면 에러 경계는 더 바깥, 로딩 경계는 더 안쪽입니다. React 자체는 ErrorBoundary 컴포넌트를 기본 제공하지 않으므로 react-error-boundary 같은 라이브러리나 자체 클래스 컴포넌트가 필요합니다.
패턴 3: Server Component에서 React.cache()로 중복 요청 제거
같은 렌더 사이클 안에서 여러 서버 컴포넌트가 동일한 데이터를 요청할 때, React.cache()로 fetch 함수를 감싸면 요청이 하나로 합쳐집니다.
// Server Component 전용
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
const res = await fetch(`${process.env.API_BASE_URL}/users/${id}`);
if (!res.ok) throw new Error('user fetch failed');
return res.json();
});서버 컴포넌트는 Node.js 런타임에서 실행되므로 /api/... 같은 상대 경로는 base URL이 없으면 resolve되지 않습니다. 환경변수로 절대 URL을 조립하거나, Next.js라면 headers()로 호스트를 조합해 완전한 URL을 만들어야 합니다. React.cache()는 서버 컴포넌트 전용이며 클라이언트에서는 동작하지 않습니다.
Promise 모드의 가장 큰 함정: Stable Promise
use()를 처음 쓸 때 거의 모든 사람이 한 번은 밟는 지뢰입니다. 렌더 함수 안에서 Promise를 새로 만들면 영구 suspend 상태(perpetual suspension)에 빠집니다. 전통적 의미의 무한 루프(동기 블로킹)가 아니라, "suspend → fallback → Promise resolve 감지 → 재렌더 → 새 pending Promise 생성 → 다시 suspend"가 반복되는 상태라서 디버깅할 때 콜 스택보다는 Suspense fallback이 끊이지 않는 현상으로 드러납니다.
해결 원칙은 하나입니다. Promise의 참조가 렌더 간에 안정적으로 유지되어야 합니다. 실무에서 쓸 수 있는 선택지는 보통 다음과 같습니다.
- 부모(서버) 컴포넌트에서 생성해 props로 전달 — 이 글의 패턴 1
- 라우터 로더(Remix, React Router v7)에서 생성해 소비 쪽에서 받기
- 클라이언트에서 생성해야 한다면
useMemo로 참조 고정, 또는 TanStack Query 같은 캐시 레이어 사용
// 안티패턴: 렌더마다 새 Promise
function Bad({ userId }: { userId: string }) {
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
// 권장: 부모에서 생성해 props로 전달
function Parent({ userId }: { userId: string }) {
const userPromise = useMemo(() => fetchUser(userId), [userId]);
return (
<Suspense fallback={<Skeleton />}>
<Good promise={userPromise} />
</Suspense>
);
}
function Good({ promise }: { promise: Promise<User> }) {
const user = use(promise);
return <div>{user.name}</div>;
}모듈 수준에서 const userPromise = fetchUser()를 선언하는 트릭이 종종 소개되지만, 이는 SSR에서 모든 사용자 요청이 같은 Promise 인스턴스를 공유하고, 파라미터를 받을 수 없으며, 무효화·재시도가 불가능해 데모 이상의 용도로는 추천하기 어렵습니다.
설계적 함의와 트레이드오프
거부 처리는 Error Boundary로만
use(promise)를 try/catch로 감쌀 수 없다는 점은 단순한 제약이 아니라 설계 결정입니다. React는 거부된 Promise를 지역적 catch 블록이 아닌 컴포넌트 트리 상 가장 가까운 Error Boundary로 올리도록 강제합니다. 결과적으로 에러 UI가 트리 구조와 함께 선언적으로 결정되고, 지역적으로 삼켜지는 에러가 줄어듭니다. 반면 특정 호출만 로컬하게 복구하고 싶다면 Error Boundary를 더 안쪽에 중첩하거나, use() 바깥에서 Promise 체인에 .catch를 걸어 resolve된 결과로 변환하는 식의 우회가 필요합니다.
요약 표
| 항목 | 내용 |
|---|---|
| 보일러플레이트 | useState + useEffect + 로딩 플래그 → use() + <Suspense> |
| Context 조건부 호출 | useContext의 최상위 제약 해제 (Suspense와 무관) |
| 타입 추론 | use(Promise<T>) 반환 타입은 Awaited<T>로 자동 추론 |
| 내장 캐시 | 없음. 동일 Promise 참조를 재사용하지 않으면 중복 요청 발생 |
| 에러 처리 경로 | Error Boundary 전용, 지역 try/catch 불가 |
| side-effect | 타이머·구독은 여전히 useEffect 필요 |
| Suspense 경계 | Promise 모드에서만 필수, Context 모드에서는 불필요 |
TanStack Query와의 역할 구분
State of React 2025 조사에서 TanStack Query는 여전히 사용률이 높은 클라이언트 데이터 페칭 솔루션으로 집계되었습니다. 해당 보고서에 use()에 대한 정량 지표가 포함되어 있는지는 각자 원문에서 확인하는 편이 안전합니다. 여기서 추가 해석을 덧붙이기보다는 두 도구의 레이어 차이에 집중하는 쪽이 유용합니다.
use()— Promise를 렌더 시점에 언래핑하는 메커니즘. 캐시·무효화·재시도 없음.- TanStack Query — 서버 상태 관리 라이브러리. 캐시·무효화·낙관적 업데이트·백그라운드 리페치를 제공.
TanStack Query v5는 useSuspenseQuery로 이미 Suspense와 통합되어 있어, 그 반환값을 다시 use()로 감쌀 필요는 없습니다. 둘을 "함께" 쓰는 실제 시나리오는, 예를 들어 서버 컴포넌트에서 생성한 Promise는 use()로 받고 클라이언트에서 발생하는 가변 데이터는 TanStack Query로 관리하는 식의 역할 분담입니다. 선택 기준은 대략 이렇게 정리할 수 있습니다.
| 상황 | 적합한 도구 |
|---|---|
| 캐싱, 무효화, 낙관적 업데이트, 폴링 | TanStack Query v5의 useSuspenseQuery 또는 useQuery |
| RSC → Client Promise 브릿지, 단순 읽기 | use(promise) + <Suspense> |
| 조건부 Context 소비 | use(Context) |
| Remix / React Router v7 로더 결과 | use(loaderData.promise) 네이티브 지원 |
마무리하며
use()의 가장 실용적인 가치는 결국 네트워크 타임라인 자체가 달라진다는 점에 있습니다. 기존의 useEffect 기반 페칭은 "클라이언트 번들 다운로드 → 하이드레이션 완료 → effect 실행 → fetch 시작 → 응답 수신 → 렌더" 순서로 흘러갑니다. 반면 서버 컴포넌트에서 Promise를 만들고 클라이언트가 use()로 받는 흐름은, 서버가 요청을 처리하는 즉시 fetch가 시작되고 HTML 스트리밍과 데이터 수신이 겹쳐서 진행됩니다.
사용자 입장에서 보이는 효과는, 느린 네트워크일수록 뚜렷해지는 "첫 유의미한 콘텐츠까지의 시간" 단축입니다. 복잡한 캐싱이나 변경 처리까지 필요하다면 여전히 TanStack Query 쪽이 안정적이지만, 서버에서 내려받아 그대로 뿌리는 성격의 화면이라면 use() + Suspense가 코드 양과 네트워크 타임라인 양쪽에서 돌려주는 이득이 꽤 큽니다. 하나 조심할 것은, 어디서 Promise를 만들고 있는지를 항상 의식해 두는 습관입니다. 이 글의 Stable Promise 섹션에서 다룬 함정 하나만 피해도 도입 초기의 디버깅 시간은 눈에 띄게 줄어듭니다.
참고 자료
- React 공식 문서 —
use - React 공식 문서 — Suspense
- React 공식 문서 —
cache - React 19 use() Hook: Guide to Promises and Context — DEV Community
- use() Explained: Promises, Context, Suspense — Medium
- React 19 use() Hook Deep Dive — DEV Community
- React Suspense & the use() Hook — StackNotice
- React 19 Suspense for Data Fetching — Syncfusion Blog
- React 19 use() Hook — Fix Infinite Suspension Loop — The Code Forge
- Limitations of React v19 use() API in Full-Stack Frameworks — DEV
- React 19 use() 2 Weeks in Production — DEV
- React 19 use() Hook Data Fetching Patterns That Actually Work — SitePoint
- Breaking Down State of React 2025 Results — certificates.dev
- Can React v19 Replace React Query/TanStack? — DEV
- TanStack Query v5 —
useSuspenseQuery