Intro
현대 웹 애플리케이션은 대부분 서버나 외부 API로부터 데이터를 가져와 사용자에게 보여주는 구조를 가지고 있습니다. 이러한 데이터는 즉시 사용 가능한 값이 아닌 비동기(Promise 기반)이기 때문에, React에서도 데이터를 언제, 어떻게 가져와서 렌더링할지가 매우 중요한 이슈로 떠올랐습니다.
초기 React에서는 컴포넌트가 마운트된 이후에 데이터를 가져오는 방식이 일반적이었습니다. 하지만 이 방식은 몇 가지 UX 문제와 코드 복잡성을 유발합니다.
React 18부터 도입된
Suspense
, 그리고 React 19의 use()
훅은 이러한 문제를 해결하기 위한 새로운 비동기 렌더링 철학을 보여줍니다. 본 글에서는 기존 방식의 한계를 살펴보고, React 19가 제시하는 새로운 방식이 어떻게 더 선언적이고 예측 가능한 렌더링을 가능하게 하는지 설명합니다.왜 비동기 처리 방식이 중요한가?
- 대부분의 데이터는 즉시 사용할 수 없다
- 예: API 응답, DB 요청, 리소스 로딩
- React 컴포넌트가 렌더링되는 시점에 데이터가 아직 준비되지 않은 경우가 대부분입니다.
- UX에서의 속도와 일관성
- 사용자는 화면이 빈 상태로 잠깐 멈추는 것보다, 로딩 중이라는 명확한 상태를 보고 싶어합니다.
- 데이터를 기다리는 동안에도 사용자 경험을 망치지 않도록 로딩 상태(fallback UI)를 제공하는 것이 중요합니다.
- 서버 사이드 렌더링(SSR)과의 호환
- 서버에서 HTML을 렌더링할 때도 비동기 데이터가 필요합니다.
- 이 때는
await
이나Suspense
없이 동기적인 코드처럼 데이터를 다룰 수 있어야 전체 앱의 렌더링 흐름이 깔끔해집니다.
기존 방식의 한계: useEffect
+ useState
React의 전통적인 비동기 데이터 처리 방식은
useEffect
와 useState
의 조합입니다.'use client';
import { useEffect, useState } from 'react';
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser);
}, []);
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;
}
이 방식은 다음과 같은 문제점이 있습니다:
- 데이터는 컴포넌트 렌더 이후에야 가져온다
- 첫 렌더링 시
user
는null
→ 이후에야fetch
실행 - 즉, 항상 로딩 상태 → 실제 데이터로 2번 렌더링
- 로딩, 에러, 성공 상태를 수동으로 관리해야 한다
isLoading
,isError
,data
상태를 매번 직접 분기- 복잡한 컴포넌트일수록 상태 관리 코드가 많아짐
- 서버 사이드 렌더링과 호환이 어렵다
useEffect
는 클라이언트에서만 실행되기 때문에 SSR에서는 작동하지 않음- 서버에서 데이터를 미리 가져오지 못해, 클라이언트에서 다시 한번 fetch해야 함
- 코드가 선언적이지 않다
- UI는 데이터 도착 이후에야 정의 가능
- 로딩/에러 상태 분기 직접 해야 함
Suspense란 무엇인가
<Suspense>
는 React에서 비동기 렌더링을 제어하기 위한 컴포넌트입니다.준비되지 않은 데이터나 리소스가 있을 때, 해당 컴포넌트의 렌더링을 일시 중단하고, 대신 fallback UI를 보여주는 역할을 합니다.
어떻게 작동할까?
React의 Suspense는 일반적인 try/catch 방식이 아닌 throw된 Promise를 잡아서 대기 하는 독특한 메커니즘으로 작동합니다.
작동 흐름
- 컴포넌트가 렌더링 중 use(promise) 또는 lazy 컴포넌트 등을 통해 Promise를 throw함
- React는 이 Promise를 감지하고 해당 컴포넌트의 렌더링을 일시 중단(Suspend)
- 대신
에서 지정한 UI를 먼저 렌더링 - Promise가 resolve되면, 정상적으로 다시 렌더링됨
use() 훅 소개
use()
는 React 19에서 도입된 새로운 훅으로, 비동기 데이터를 컴포넌트에서 직접 사용할 수 있도록 해주는 기능입니다.기존에는 useEffect + useState 또는 React Query 같은 도구가 필요했지만, 이제는 Promise를 use()에 넘기기만 하면 Suspense를 통해 자동으로 렌더링 흐름이 제어됩니다.
const data = use(fetchData());
React는 fetchData()가 아직 완료되지 않았다면 Promise를 throw하고, Suspense fallback UI로 일시적으로 대체합니다. 완료되면 다시 원래 컴포넌트를 렌더링합니다.
use()는 무엇을 받을 수 있나요?
- Promise (ex: fetch, API 요청)
- Context 객체 (React의 context 값)
- 리소스 객체 (React cache 등에서 지원하는 fetch 결과)
이 글에서는 주로 Promise를 사용하는 use(promise) 패턴을 중심으로 설명합니다.
Context에 use()를 사용하는 방법이 궁금하시다면 React 공식 문서: use() 를 참고해주세요.
서버 컴포넌트에서의 비동기 처리
왜 서버 컴포넌트에서는 await가 사용가능한지?
React에서는 서버 컴포넌트를 사용할 때, 비동기 데이터를 처리하는 방식으로
use()
대신 async/await
를 직접 사용할 수 있습니다. 이는 클라이언트 컴포넌트와의 가장 큰 차이점 중 하나입니다.export default async function Page() {
const user = await getUser(); // API 또는 DB에서 데이터 요청
return <UserProfile user={user} />;
}
서버 컴포넌트는 React에 의해 비동기 함수(async)로 실행되기 때문에, 내부에서 자연스럽게 await를 사용할 수 있습니다. 이 덕분에 렌더링 전에 데이터를 먼저 가져온 뒤, 준비된 상태로 렌더링을 시작할 수 있습니다.
왜 서버 컴포넌트에서는 await가 가능한가?
서버 컴포넌트는 브라우저가 아닌 서버에서 실행되므로, 렌더링 직전에 필요한 데이터를 동기처럼 가져오는 것이 가능합니다. React는 서버에서 컴포넌트를 렌더링하기 전에 await가 걸려 있는 모든 작업을 먼저 처리한 뒤, HTML을 생성합니다.
이 방식은 다음과 같은 장점이 있습니다:
- 데이터가 준비된 상태로 클라이언트에 전달됨
- 클라이언트 측의 로딩 상태 없이 바로 렌더링 가능
- 클라이언트 측 fetch 비용과 코드 복잡도 감소
클라이언트 컴포넌트에서의 use(Promise) 사용법
React 19에서는 클라이언트 컴포넌트에서도 use() 훅을 통해 비동기 데이터를 직접 사용할 수 있게 되었습니다.
이전에는 useEffect + useState 조합이 필요했지만, 이제는 Promise를 use()에 넘기면 React가 자동으로 Suspense 기반 렌더링 흐름을 제어해줍니다.
'use client';
import { use } from 'react';
export function Message({ messagePromise }) {
const message = use(messagePromise);
return <p>{message}</p>;
}
이렇게 작성하면 messagePromise가 아직 완료되지 않은 경우, React는 자동으로 해당 컴포넌트 렌더링을 중단하고 의 fallback UI 를 보여줍니다.
Promise가 해결되면 컴포넌트는 정상적으로 다시 렌더링됩니다.
잘못된 사용 사례와 문제점
'use client';
import { use } from 'react';
export function Message() {
const messagePromise = fetch('/api/message').then(res => res.text());
const message = use(messagePromise);
return <p>{message}</p>;
}
이와 같이 클라이언트 컴포넌트 안에서 Promise를 직접 생성하면, 다음과 같은 문제가 발생합니다:
| 문제점 | 설명 |
| --- | --- |
| 렌더링할 때마다 새로운 Promise 생성 |
use(promise)
는 항상 새로운 Promise를 받게 됨 |
| React가 같은 Promise라고 인식하지 못함 | 매번 fallback UI로 돌아감 → 깜빡임 발생 |
| 캐싱이 불가능하고 비효율적 | React 내부 캐시 및 리소스 추적 불가능 |권장 패턴
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import PhoneDetails from './PhoneDetails';
function App() {
const messagePromise = fetch('/api/message').then(res => res.text());
return (
<ErrorBoundary fallback={<div>문제가 발생했습니다.</div>}>
<Suspense fallback={<div>휴대전화 정보를 불러오는 중...</div>}>
<PhoneDetails messagePromise={messagePromise}/>
</Suspense>
</ErrorBoundary>
);
}
이 방식의 장점
| 장점 | 설명 |
| --- | --- |
| 렌더링 일관성 | 서버에서 한 번 생성한 Promise를 재사용 가능 |
| 깜빡임 방지 | 매 렌더링마다 Promise가 새로 생성되지 않음 |
| 코드 분리 명확 | 데이터 생성과 UI 렌더링이 역할에 따라 분리됨 |
| 에러·로딩 처리 분리 가능 |
ErrorBoundary
, Suspense
로 관리 책임 구분 |마무리
use()와 Suspense는 단순히 새로운 API가 아니라, React가 비동기 렌더링을 바라보는 철학의 전환점입니다.
직접 데이터를 await하거나 상태를 일일이 관리하던 방식에서 벗어나, 이제는 더 선언적이고 구조화된 방식으로 비동기 흐름을 처리할 수 있게 되었습니다.
아직 use()가 낯설고 Suspense가 복잡하게 느껴질 수도 있지만, 아래의 액션 아이템들을 통해 차근차근 실전 프로젝트에 적용해보시길 권장합니다.
- 기존 useEffect + useState 기반의 데이터 요청 로직을 use()로 바꿔보세요.
- 특히 서버 컴포넌트에서 데이터를 await한 뒤 클라이언트로 전달해보는 연습이 좋습니다.
- Suspense와 ErrorBoundary를 함께 적용해, 비동기와 에러 흐름을 분리해보세요.
- 각각의 책임을 나누면 유지보수성과 사용자 경험이 훨씬 좋아집니다.
- 클라이언트 컴포넌트에서 직접 fetch()하는 패턴을 줄이고, 상위 컴포넌트에서 Promise를 주입하는 패턴을 연습해보세요.
- 깜빡임 없는 UI, 일관된 캐싱을 위해 중요한 구조입니다.