React 19의 use() API로 Promise와 Context를 조건부로 소비하는 방법, 서버-클라이언트 스트리밍 패턴, 기존 훅과의 차이를 다룹니다.
3장에서 Server Actions로 데이터를 변경하는 방법을 다루었습니다. 이번 장에서는 React 19에 새로 도입된 use() API를 살펴봅니다. use()는 Promise와 Context를 렌더링 중에 읽을 수 있는 새로운 API로, 기존 훅과 달리 조건문 안에서도 호출할 수 있다는 특별한 성질을 갖습니다.
use()는 Promise 또는 Context를 인자로 받아 그 값을 반환하는 API입니다.
import { use } from 'react';
// Promise를 읽기
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
// Context를 읽기
function ThemedButton() {
const theme = use(ThemeContext);
return <button style={{ background: theme.primary }}>클릭</button>;
}use()는 React 훅처럼 보이지만, 훅의 규칙(Rules of Hooks)을 따르지 않습니다. 조건문, 반복문, 조기 반환 이후에도 호출할 수 있습니다. 이것이 useContext와의 가장 큰 차이점입니다.
use()로 Promise를 읽으면, Promise가 해결될 때까지 가장 가까운 Suspense 경계가 fallback을 표시합니다.
import { Suspense } from 'react';
import { use } from 'react';
// Server Component에서 Promise 생성
async function ArticlePage({ params }: { params: { id: string } }) {
const article = await getArticle(params.id);
// Promise를 await하지 않고 전달
const commentsPromise = getComments(params.id);
return (
<article>
<h1>{article.title}</h1>
<p>{article.content}</p>
<Suspense fallback={<CommentsSkeleton />}>
<CommentList commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}
// Client Component에서 use()로 소비
'use client';
function CommentList({ commentsPromise }: {
commentsPromise: Promise<Comment[]>
}) {
const comments = use(commentsPromise);
return (
<section>
<h2>댓글 {comments.length}개</h2>
{comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>
<p>{comment.text}</p>
</div>
))}
</section>
);
}이 패턴에서 데이터 흐름은 다음과 같습니다.
article을 await로 즉시 가져옵니다 (블로킹).commentsPromise는 await하지 않고 Client Component에 전달합니다.article 내용을 먼저 스트리밍합니다.use(commentsPromise)로 댓글을 읽으려 시도하고, 아직 해결되지 않았으면 Suspense가 skeleton을 표시합니다.이 패턴의 핵심 가치는 서버에서 생성된 Promise를 클라이언트로 스트리밍할 수 있다는 점입니다. 서버는 Promise의 해결을 기다리지 않고 즉시 응답을 시작하며, Promise가 해결되면 결과를 클라이언트로 스트리밍합니다.
async function DashboardPage() {
// 즉시 필요한 데이터
const user = await getCurrentUser();
// 병렬로 시작하되, 스트리밍할 데이터
const statsPromise = fetchStats(user.id);
const notificationsPromise = fetchNotifications(user.id);
const activityPromise = fetchRecentActivity(user.id);
return (
<div>
<header>
<h1>{user.name}의 대시보드</h1>
</header>
<div className="grid grid-cols-3 gap-6">
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel statsPromise={statsPromise} />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<NotificationsPanel notificationsPromise={notificationsPromise} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed activityPromise={activityPromise} />
</Suspense>
</div>
</div>
);
}세 개의 Promise가 병렬로 실행되며, 각각 독립적으로 스트리밍됩니다. 가장 빠르게 해결되는 데이터부터 화면에 나타나므로 사용자는 점진적으로 콘텐츠를 볼 수 있습니다.
useContext는 훅이므로 조건문 안에서 호출할 수 없습니다. use()는 이 제약이 없습니다.
import { use } from 'react';
function UserGreeting({ user }: { user: User | null }) {
if (!user) {
return <p>로그인해주세요.</p>;
}
// 조기 반환 이후에 use() 호출 가능
const theme = use(ThemeContext);
const locale = use(LocaleContext);
return (
<div style={{ color: theme.text }}>
<p>
{locale === 'ko'
? `${user.name}님, 환영합니다.`
: `Welcome, ${user.name}.`}
</p>
</div>
);
}useContext로는 이 패턴이 불가능합니다.
function UserGreeting({ user }: { user: User | null }) {
// useContext는 항상 최상위에서 호출해야 함
const theme = useContext(ThemeContext); // 항상 실행됨
const locale = useContext(LocaleContext); // 항상 실행됨
if (!user) {
return <p>로그인해주세요.</p>;
}
// theme과 locale을 사용하지 않는 경로에서도
// 이미 호출됨 (불필요한 구독)
return <div style={{ color: theme.text }}>...</div>;
}function DynamicFieldList({ fields }: { fields: Field[] }) {
return (
<div>
{fields.map(field => {
if (field.type === 'themed') {
const theme = use(ThemeContext);
return (
<input
key={field.id}
style={{ borderColor: theme.border }}
placeholder={field.label}
/>
);
}
return <input key={field.id} placeholder={field.label} />;
})}
</div>
);
}use()에서 Promise가 reject되면, 가장 가까운 Error Boundary가 에러를 잡습니다.
function ArticleWithComments({ articleId }: { articleId: string }) {
const commentsPromise = fetchComments(articleId);
return (
<div>
<ErrorBoundary fallback={<p>댓글을 불러오지 못했습니다.</p>}>
<Suspense fallback={<CommentsSkeleton />}>
<CommentList commentsPromise={commentsPromise} />
</Suspense>
</ErrorBoundary>
</div>
);
}Error Boundary 대신 Promise의 catch로 기본값을 제공할 수도 있습니다.
async function Page() {
// Promise가 실패하면 빈 배열 반환
const commentsPromise = fetchComments(articleId)
.catch(() => [] as Comment[]);
return (
<Suspense fallback={<CommentsSkeleton />}>
<CommentList commentsPromise={commentsPromise} />
</Suspense>
);
}use()를 try-catch 블록 안에서 사용할 수 없습니다. Promise의 에러 처리는 반드시 Error Boundary 또는 Promise.catch를 통해 해야 합니다.
// 이 코드는 동작하지 않습니다
function BadExample({ promise }: { promise: Promise<Data> }) {
try {
const data = use(promise); // Suspense와 충돌
} catch (e) {
return <p>에러</p>;
}
}'use client';
function Comments({ articleId }: { articleId: string }) {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchComments(articleId)
.then(data => {
if (!cancelled) {
setComments(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [articleId]);
if (loading) return <Spinner />;
if (error) return <p>에러: {error.message}</p>;
return <CommentList comments={comments} />;
}// Server Component
async function ArticlePage({ params }: { params: { id: string } }) {
const commentsPromise = fetchComments(params.id);
return (
<ErrorBoundary fallback={<p>댓글을 불러오지 못했습니다.</p>}>
<Suspense fallback={<Spinner />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</ErrorBoundary>
);
}
// Client Component
'use client';
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
const comments = use(commentsPromise);
return <CommentList comments={comments} />;
}새 패턴의 장점은 다음과 같습니다.
| 관점 | useEffect + useState | use() + Suspense |
|---|---|---|
| 보일러플레이트 | 로딩/에러 상태 수동 관리 | 선언적으로 처리 |
| 경쟁 조건 | 수동 취소 로직 필요 | 자동 처리 |
| 워터폴 | 클라이언트에서 순차 요청 | 서버에서 병렬 시작 |
| 번들 | 데이터 페칭 로직이 클라이언트에 포함 | 서버에서 실행 |
// useContext: 항상 최상위에서 호출
function Component() {
const theme = useContext(ThemeContext); // 항상 실행
const auth = useContext(AuthContext); // 항상 실행
if (!auth.user) return <LoginPrompt />;
return <Dashboard theme={theme} />;
}
// use(): 필요한 시점에 호출
function Component() {
const auth = use(AuthContext);
if (!auth.user) return <LoginPrompt />;
const theme = use(ThemeContext); // 인증된 경우에만 실행
return <Dashboard theme={theme} />;
}// Server Component
async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id);
// 모든 탭의 데이터를 병렬로 시작
const postsPromise = getUserPosts(params.id);
const commentsPromise = getUserComments(params.id);
const likesPromise = getUserLikes(params.id);
return (
<div>
<UserHeader user={user} />
<UserTabs
postsPromise={postsPromise}
commentsPromise={commentsPromise}
likesPromise={likesPromise}
/>
</div>
);
}'use client';
import { use, useState, Suspense } from 'react';
type Tab = 'posts' | 'comments' | 'likes';
function UserTabs({ postsPromise, commentsPromise, likesPromise }: {
postsPromise: Promise<Post[]>;
commentsPromise: Promise<Comment[]>;
likesPromise: Promise<Like[]>;
}) {
const [activeTab, setActiveTab] = useState<Tab>('posts');
return (
<div>
<nav>
<button onClick={() => setActiveTab('posts')}>글</button>
<button onClick={() => setActiveTab('comments')}>댓글</button>
<button onClick={() => setActiveTab('likes')}>좋아요</button>
</nav>
<Suspense fallback={<TabSkeleton />}>
{activeTab === 'posts' && <PostsTab promise={postsPromise} />}
{activeTab === 'comments' && <CommentsTab promise={commentsPromise} />}
{activeTab === 'likes' && <LikesTab promise={likesPromise} />}
</Suspense>
</div>
);
}
function PostsTab({ promise }: { promise: Promise<Post[]> }) {
const posts = use(promise);
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}
function CommentsTab({ promise }: { promise: Promise<Comment[]> }) {
const comments = use(promise);
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
function LikesTab({ promise }: { promise: Promise<Like[]> }) {
const likes = use(promise);
return (
<ul>
{likes.map(l => <li key={l.id}>{l.articleTitle}</li>)}
</ul>
);
}서버에서 세 개의 데이터 요청이 병렬로 시작되므로, 사용자가 어떤 탭을 선택하든 데이터가 이미 로드 중이거나 완료되어 있을 가능성이 높습니다.
'use client';
import { use } from 'react';
interface UserDashboardProps {
user: User;
analyticsPromise?: Promise<Analytics>;
adminDataPromise?: Promise<AdminData>;
}
function UserDashboard({
user,
analyticsPromise,
adminDataPromise,
}: UserDashboardProps) {
return (
<div>
<h1>{user.name}의 대시보드</h1>
{analyticsPromise && (
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection promise={analyticsPromise} />
</Suspense>
)}
{user.role === 'admin' && adminDataPromise && (
<Suspense fallback={<AdminSkeleton />}>
<AdminSection promise={adminDataPromise} />
</Suspense>
)}
</div>
);
}
function AnalyticsSection({ promise }: { promise: Promise<Analytics> }) {
const analytics = use(promise);
return <div>방문자: {analytics.visitors}</div>;
}'use client';
// 나쁜 예: 매 렌더링마다 새 Promise 생성
function BadExample({ articleId }: { articleId: string }) {
// fetchComments가 매 렌더링마다 호출됨
const comments = use(fetchComments(articleId));
return <CommentList comments={comments} />;
}클라이언트 컴포넌트에서 use()에 전달하는 Promise는 매 렌더링마다 새로 생성되어서는 안 됩니다. 새 Promise가 생성되면 Suspense가 다시 활성화되어 UI가 깜빡입니다. Promise는 Server Component에서 생성하여 props로 전달하는 것이 올바른 패턴입니다.
// Server Component에서는 use() 대신 async/await 사용
async function ServerComponent() {
// 좋은 예: async/await
const data = await fetchData();
return <div>{data.title}</div>;
}
// use()는 Client Component에서 서버 Promise를 소비할 때 사용Server Component에서는 async/await를 직접 사용하는 것이 더 직관적이고 명확합니다. use()는 주로 Client Component에서 서버가 전달한 Promise를 소비하는 데 사용합니다.
use()는 Promise와 Context를 렌더링 중에 읽는 새로운 API로, 조건문 안에서도 호출할 수 있습니다.use()로 읽으면 Suspense와 통합되어 선언적 로딩 UI를 구현할 수 있습니다.useContext와 달리 use(Context)는 조건부, 반복문 내에서 호출할 수 있습니다.Promise.catch를 사용합니다 (try-catch 불가).다음 장에서는 React 19에 새로 추가된 useActionState, useFormStatus, useOptimistic 훅을 심층적으로 다���니다.
이 글이 도움이 되셨나요?
React 19의 새로운 훅 3종을 심층 분석합니다. 폼 상태 관리, 제출 상태 추적, 낙관적 UI 업데이트의 실전 패턴을 다룹니다.
Server Actions의 동작 원리, 폼 처리 패턴, 데이터 뮤테이션, 에러 핸들링, 보안 고려사항을 실전 코드와 함께 다룹니다.
React 19에서 강화된 Suspense의 고급 패턴, 스트리밍 SSR, 중첩 Suspense 전략, 배칭 동작, Partial Pre-rendering을 다룹니다.