Back

React 19 use() 훅 완전 정복: 데이터 페칭의 패러다임이 바뀌다

React 19에 여러 기능이 추가됐는데, 그중에서도 우리가 React 코드를 작성하는 방식을 근본적으로 바꿔놓는 건 바로 use() 훅이에요.

React 개발 좀 해보셨다면 이런 패턴 수백 번 작성해보셨죠?

function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function fetchUser() { try { setLoading(true); const response = await fetch(`/api/users/${userId}`); const data = await response.json(); if (!cancelled) { setUser(data); } } catch (err) { if (!cancelled) { setError(err); } } finally { if (!cancelled) { setLoading(false); } } } fetchUser(); return () => { cancelled = true; }; }, [userId]); if (loading) return <Spinner />; if (error) return <Error message={error.message} />; return <div>{user.name}</div>; }

개념적으로 단순한 거잖아요. "유저 데이터 가져와서 이름 보여줘". 근데 40줄이에요.

React 19의 use() 훅으로 똑같은 걸 하면:

function UserProfile({ userId }) { const user = use(fetchUser(userId)); return <div>{user.name}</div>; }

3줄. 같은 기능. 로딩 상태 관리 없음. 에러 처리 보일러플레이트 없음. 레이스 컨디션 버그도 없음.

단순히 코드 줄이는 게 아니에요. React가 비동기를 다루는 방식이 완전히 바뀐 거예요. 어떻게 돌아가는지, 언제 쓰면 좋은지, 삽질 포인트는 뭔지 뜯어봅시다.

use() 훅이 뭔가요?

use() 훅은 Promise나 Context에서 값을 쏙 빼올 수 있어요. 다른 훅이랑 다르게 특별한 게 있거든요:

  1. 조건부로 호출 가능 (if문, 반복문 안에서도 OK)
  2. Suspense와 통합돼서 로딩 상태 처리
  3. Error Boundary와 통합돼서 에러 처리
  4. Promise에서 값을 꺼내줌 (비동기 결과를 바로 사용)

기본 사용법은 이래요:

import { use } from 'react'; // Promise랑 쓸 때 const value = use(promise); // Context랑 쓸 때 const theme = use(ThemeContext);

내부에서 뭔 일이 일어나는 걸까요?

use()를 제대로 쓰려면 Suspense가 어떻게 돌아가는지 좀 알아야 해요.

use(promise)를 호출하면:

  1. Promise가 pending 상태면: React가 컴포넌트를 "서스펜드"해요. Suspense가 잡는 특별한 객체를 throw해서 fallback UI가 나타나요.

  2. Promise가 resolve되면: React가 resolve된 값을 바로 반환해요.

  3. Promise가 reject되면: React가 에러를 throw하고, Error Boundary가 잡아요.

간단한 멘탈 모델로 보면:

function use(promise) { if (promise.status === 'pending') { throw promise; // Suspense가 이걸 잡음 } if (promise.status === 'rejected') { throw promise.reason; // Error Boundary가 이걸 잡음 } return promise.value; // resolve된 값 반환 }

핵심은: use()는 상태를 안 건드려요. 그냥 "이 데이터 줘" 하고 React한테 맡기는 거예요.

핵심 패턴: Promise 캐싱

여기서 많은 개발자가 헷갈려요. 이건 동작 안 해요:

// ❌ 잘못됨: 렌더마다 새 Promise 생성 function UserProfile({ userId }) { const user = use(fetch(`/api/users/${userId}`).then(r => r.json())); return <div>{user.name}</div>; }

왜냐면 컴포넌트가 렌더될 때마다 새 Promise가 만들어져요. React가 새 Promise를 보고 서스펜드하고, fallback 보여주고, Promise resolve되고, React가 다시 렌더하고, 새 Promise 만들고... 무한 루프.

Promise는 컴포넌트 밖에 두거나, 렌더할 때마다 바뀌지 않게 잡아둬야 해요.

패턴 1: 부모 컴포넌트에서 캐싱

// ✅ 부모에서 Promise 만들고 자식에 전달 function App() { const [userId, setUserId] = useState(1); const userPromise = useMemo( () => fetchUser(userId), [userId] ); return ( <Suspense fallback={<Spinner />}> <UserProfile userPromise={userPromise} /> </Suspense> ); } function UserProfile({ userPromise }) { const user = use(userPromise); return <div>{user.name}</div>; }

패턴 2: 데이터 라이브러리 사용

대부분의 데이터 페칭 라이브러리는 이미 캐싱을 처리해요:

// ✅ React Query / TanStack Query function UserProfile({ userId }) { const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); return <div>{user.name}</div>; } // ✅ SWR with Suspense function UserProfile({ userId }) { const { data: user } = useSWR( `/api/users/${userId}`, fetcher, { suspense: true } ); return <div>{user.name}</div>; }

패턴 3: 간단한 캐시 만들기

더 단순한 경우에는 직접 캐시를 만들 수 있어요:

// 간단한 캐시 구현 const cache = new Map(); function fetchUserCached(userId) { if (!cache.has(userId)) { cache.set(userId, fetchUser(userId)); } return cache.get(userId); } function UserProfile({ userId }) { const user = use(fetchUserCached(userId)); return <div>{user.name}</div>; }

use()와 Context: useContext보다 강력해요

use()는 Context도 읽을 수 있는데, 특별한 능력이 있어요: 조건부로 호출 가능해요.

// ❌ useContext는 조건부 불가 function Button({ showTheme }) { // Rules of Hooks 위반 if (showTheme) { const theme = useContext(ThemeContext); } } // ✅ use()는 조건부 가능 function Button({ showTheme }) { if (showTheme) { const theme = use(ThemeContext); return <button style={{ color: theme.primary }}>Click</button>; } return <button>Click</button>; }

이전에는 불가능했던 패턴이 가능해져요:

function ConditionalFeature({ featureFlags }) { // 기능이 인증을 요구할 때만 auth context에 접근 if (featureFlags.requiresAuth) { const auth = use(AuthContext); if (!auth.user) { return <LoginPrompt />; } } return <Feature />; }

use()의 에러 처리

use()에 전달된 Promise가 reject되면 에러를 throw해요. Error Boundary로 잡아야 해요:

function App() { return ( <ErrorBoundary fallback={<ErrorMessage />}> <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense> </ErrorBoundary> ); }

더 세밀한 에러 처리를 위해 Error Boundary를 중첩할 수 있어요:

function Dashboard() { return ( <div className="dashboard"> <ErrorBoundary fallback={<UserError />}> <Suspense fallback={<UserSkeleton />}> <UserWidget /> </Suspense> </ErrorBoundary> <ErrorBoundary fallback={<StatsError />}> <Suspense fallback={<StatsSkeleton />}> <StatsWidget /> </Suspense> </ErrorBoundary> </div> ); }

재사용 가능한 Error Boundary 만들기

프로덕션에서 쓸 수 있는 Error Boundary 컴포넌트예요:

import { Component } from 'react'; class ErrorBoundary extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error, errorInfo) { // 에러 리포팅 서비스에 로그 console.error('Error caught by boundary:', error, errorInfo); } render() { if (this.state.error) { return this.props.fallback ?? ( <div className="error-container"> <h2>문제가 발생했습니다</h2> <button onClick={() => this.setState({ error: null })}> 다시 시도 </button> </div> ); } return this.props.children; } }

병렬 데이터 페칭

use()의 가장 강력한 패턴 중 하나는 병렬 데이터 페칭이에요. 워터폴 요청 대신 한 번에 다 가져올 수 있어요:

// ❌ 워터폴: 각 fetch가 이전 것을 기다림 function Dashboard({ userId }) { const user = use(fetchUser(userId)); const posts = use(fetchPosts(userId)); // user 기다림 const comments = use(fetchComments(userId)); // posts 기다림 return <DashboardView user={user} posts={posts} comments={comments} />; } // ✅ 병렬: 모든 fetch가 동시에 시작 function Dashboard({ userId }) { const userPromise = fetchUserCached(userId); const postsPromise = fetchPostsCached(userId); const commentsPromise = fetchCommentsCached(userId); const user = use(userPromise); const posts = use(postsPromise); const comments = use(commentsPromise); return <DashboardView user={user} posts={posts} comments={comments} />; }

핵심 차이: 병렬 버전에서는 use() 호출 전에 모든 Promise가 만들어져요. 모든 요청이 즉시 시작된다는 뜻이에요.

더 좋은 방법: 라우트 로더에서 페칭

최적의 성능을 위해 가능한 한 일찍 페칭을 시작하세요. 이상적으로는 라우터에서:

// React Router 로더 export async function dashboardLoader({ params }) { return { userPromise: fetchUser(params.userId), postsPromise: fetchPosts(params.userId), commentsPromise: fetchComments(params.userId), }; } function Dashboard() { const { userPromise, postsPromise, commentsPromise } = useLoaderData(); const user = use(userPromise); const posts = use(postsPromise); const comments = use(commentsPromise); return <DashboardView user={user} posts={posts} comments={comments} />; }

컴포넌트가 렌더되기도 전에 페칭이 시작돼요!

use() vs 전통적인 패턴 비교

use()를 다른 데이터 페칭 방식과 비교해볼게요:

useEffect + useState 방식

// 전통적인 방식 function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); fetchUser(userId) .then(data => !cancelled && setUser(data)) .catch(err => !cancelled && setError(err)) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; }, [userId]); if (loading) return <Spinner />; if (error) return <Error />; return <div>{user.name}</div>; }

문제점:

  • 장황한 보일러플레이트
  • cleanup 깜빡하기 쉬움
  • 레이스 컨디션 처리가 수동
  • 모든 컴포넌트에서 loading/error 상태 관리

use() 방식

// 모던한 방식 function UserProfile({ userId }) { const userPromise = useMemo(() => fetchUser(userId), [userId]); const user = use(userPromise); return <div>{user.name}</div>; } // 상위 레벨에서 Suspense/ErrorBoundary로 감싸기 function App() { return ( <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense> </ErrorBoundary> ); }

장점:

  • 깔끔하고 읽기 쉬운 컴포넌트 코드
  • Loading/error 처리가 비즈니스 로직과 분리
  • 레이스 컨디션 없음 (React가 처리)
  • 조합 가능 (Suspense 경계 중첩해서 세밀한 로딩 상태)

흔한 실수와 해결법

실수 1: 컴포넌트 안에서 Promise 생성

// ❌ 렌더마다 새 Promise 생성 function Bad({ id }) { const data = use(fetch(`/api/${id}`).then(r => r.json())); } // ✅ Promise 캐시 const cache = new Map(); function Good({ id }) { if (!cache.has(id)) { cache.set(id, fetch(`/api/${id}`).then(r => r.json())); } const data = use(cache.get(id)); }

실수 2: Suspense Boundary 깜빡

// ❌ Suspense 없으면 Promise pending일 때 크래시 function App() { return <UserProfile userId={1} />; } // ✅ Suspense로 감싸기 function App() { return ( <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense> ); }

실수 3: Error Boundary 깜빡

// ❌ ErrorBoundary 없으면 에러가 앱 크래시 function App() { return ( <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense> ); } // ✅ ErrorBoundary 추가 function App() { return ( <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Spinner />}> <UserProfile userId={1} /> </Suspense> </ErrorBoundary> ); }

실수 4: Promise rejection 제대로 처리 안 함

// ❌ Fetch가 조용히 실패할 수 있음 const promise = fetch('/api/data').then(r => r.json()); // ✅ HTTP 에러 처리 const promise = fetch('/api/data').then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); });

Server Components에서 use()

React Server Components에서 use()는 더 강력해져요:

  1. Server Components에서 직접 await 가능
  2. Client Components에 Promise 전달 가능
// Server Component async function Page({ params }) { const userPromise = fetchUser(params.id); return ( <Suspense fallback={<Spinner />}> <UserProfile userPromise={userPromise} /> </Suspense> ); } // Client Component 'use client'; function UserProfile({ userPromise }) { const user = use(userPromise); return <div>{user.name}</div>; }

이 패턴으로 스트리밍이 가능해요: 서버가 shell을 즉시 보내고 데이터는 백그라운드에서 로드돼요.

성능 최적화 팁

1. 일찍 페칭 시작하기

// 라우터나 레이아웃에서 const userPromise = fetchUser(userId); // 컴포넌트에 전달 <UserProfile userPromise={userPromise} />

2. Suspense로 스트리밍 활용

function Page() { return ( <> {/* 중요한 컨텐츠 먼저 로드 */} <Header /> {/* 덜 중요한 컨텐츠는 스트리밍 */} <Suspense fallback={<CommentsSkeleton />}> <Comments /> </Suspense> </> ); }

3. 세밀한 Suspense 경계

function Dashboard() { return ( <div className="grid"> <Suspense fallback={<CardSkeleton />}> <RevenueCard /> </Suspense> <Suspense fallback={<CardSkeleton />}> <UsersCard /> </Suspense> <Suspense fallback={<CardSkeleton />}> <ActivityCard /> </Suspense> </div> ); }

각 카드가 독립적으로 로드돼요. 가장 느린 걸 기다릴 필요 없어요.

use()를 쓰면 안 되는 경우

use()가 항상 정답은 아니에요:

  1. 뮤테이션: 폼 제출에는 useTransition이나 useActionState 사용
  2. 실시간 데이터: 구독(WebSocket 등)에는 useSyncExternalStore
  3. 브라우저 API: localStorage, window 크기 등은 적절한 훅 사용
  4. 단순한 상태: UI 상태에는 그냥 useState

마이그레이션 가이드: useEffect에서 use()로

기존 코드 마이그레이션 방법:

1단계: 데이터 페칭 useEffect 찾기

이런 패턴들:

useEffect(() => { fetchData().then(setData); }, [dep]);

2단계: 캐시된 Promise로 추출

const cache = new Map(); function fetchDataCached(dep) { const key = JSON.stringify(dep); if (!cache.has(key)) { cache.set(key, fetchData(dep)); } return cache.get(key); }

3단계: use()로 교체

function Component({ dep }) { const data = use(fetchDataCached(dep)); return <View data={data} />; }

4단계: Suspense와 Error Boundary 추가

<ErrorBoundary fallback={<Error />}> <Suspense fallback={<Loading />}> <Component dep={dep} /> </Suspense> </ErrorBoundary>

마무리

use() 훅은 React 개발의 패러다임 전환을 대표해요. "데이터 fetch하고 setState하기" 같은 명령형 패턴에서 "이 컴포넌트는 이 데이터가 필요해" 같은 선언형 패턴으로 옮겨가는 거예요.

핵심 정리:

  1. use()로 Promise/Context에서 값 읽기 — Suspense랑 연동됨
  2. Promise 캐싱 필수 — 안 하면 무한 루프
  3. Suspense = 로딩, Error Boundary = 에러
  4. if문 안에서도 호출 OK — 다른 훅이랑 다름
  5. 라우터/로더에서 미리 fetch — 성능 최적화
  6. Server Components랑 찰떡 — 스트리밍 가능

배우는 데 시간이 걸려요. 데이터 플로우에 대해 다르게 생각해야 해요. 하지만 한번 감이 오면 useEffect 데이터 페칭으로 다시는 돌아가고 싶지 않을 거예요.

React 팀이 수년간 이 순간을 위해 작업해왔어요. React 19에서 비전이 드디어 실현됐어요: 컴포넌트가 어떤 데이터가 필요한지 선언하기만 하면, React가 로딩, 에러 상태, 레이스 컨디션의 모든 복잡성을 처리해요.

React 데이터 페칭의 미래에 오신 걸 환영해요.

// 미래가 왔어요 function UserProfile({ userId }) { const user = use(fetchUserCached(userId)); return <div>{user.name}</div>; }

진짜 이렇게 간단해요.

reactreact-19use-hookdata-fetchingsuspensejavascriptfrontendasync