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에서 값을 쏙 빼올 수 있어요. 다른 훅이랑 다르게 특별한 게 있거든요:
- 조건부로 호출 가능 (if문, 반복문 안에서도 OK)
- Suspense와 통합돼서 로딩 상태 처리
- Error Boundary와 통합돼서 에러 처리
- Promise에서 값을 꺼내줌 (비동기 결과를 바로 사용)
기본 사용법은 이래요:
import { use } from 'react'; // Promise랑 쓸 때 const value = use(promise); // Context랑 쓸 때 const theme = use(ThemeContext);
내부에서 뭔 일이 일어나는 걸까요?
use()를 제대로 쓰려면 Suspense가 어떻게 돌아가는지 좀 알아야 해요.
use(promise)를 호출하면:
-
Promise가 pending 상태면: React가 컴포넌트를 "서스펜드"해요. Suspense가 잡는 특별한 객체를 throw해서 fallback UI가 나타나요.
-
Promise가 resolve되면: React가 resolve된 값을 바로 반환해요.
-
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()는 더 강력해져요:
- Server Components에서 직접 await 가능
- 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()가 항상 정답은 아니에요:
- 뮤테이션: 폼 제출에는
useTransition이나useActionState사용 - 실시간 데이터: 구독(WebSocket 등)에는
useSyncExternalStore - 브라우저 API: localStorage, window 크기 등은 적절한 훅 사용
- 단순한 상태: 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하기" 같은 명령형 패턴에서 "이 컴포넌트는 이 데이터가 필요해" 같은 선언형 패턴으로 옮겨가는 거예요.
핵심 정리:
use()로 Promise/Context에서 값 읽기 — Suspense랑 연동됨- Promise 캐싱 필수 — 안 하면 무한 루프
- Suspense = 로딩, Error Boundary = 에러
- if문 안에서도 호출 OK — 다른 훅이랑 다름
- 라우터/로더에서 미리 fetch — 성능 최적화
- Server Components랑 찰떡 — 스트리밍 가능
배우는 데 시간이 걸려요. 데이터 플로우에 대해 다르게 생각해야 해요. 하지만 한번 감이 오면 useEffect 데이터 페칭으로 다시는 돌아가고 싶지 않을 거예요.
React 팀이 수년간 이 순간을 위해 작업해왔어요. React 19에서 비전이 드디어 실현됐어요: 컴포넌트가 어떤 데이터가 필요한지 선언하기만 하면, React가 로딩, 에러 상태, 레이스 컨디션의 모든 복잡성을 처리해요.
React 데이터 페칭의 미래에 오신 걸 환영해요.
// 미래가 왔어요 function UserProfile({ userId }) { const user = use(fetchUserCached(userId)); return <div>{user.name}</div>; }
진짜 이렇게 간단해요.