당신의 React 코드는 if (loading)을 사용하나요?
1. 우리는 왜 아직도 if (loading)을 쓰고 있을까?
const { data, error, isLoading } = useQuery(GET_TASK);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <TaskEditor task={data.task} />;
정작 중요한 건 <TaskEditor />
인데, 로딩과 에러를 처리하는 보일러플레이트 코드가 더 많은 공간을 차지하고 있다.
누군가는 이렇게 생각할 수도 있을 것이다.
"비동기는 원래 이렇게 처리하는 거 아냐?"
React 팀이 제시한 해결법이 있다. Suspense와 Error Boundary. 하지만 우리는 여전히 과거의 방식으로 코드를 짜고 있을지 모른다.
이 글에서는 if (loading)에서 벗어나, 더 선언적이고 우아한 코드를 통해 향상된 사용자 경험을 만드는 방법에 대해 알아본다.
2. 명령형 vs 선언형: 패러다임의 차이
명령형: "어떻게" 처리할 것인가
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// ...에러 상태, fetch 로직...
if (loading) return <div>Loading...</div>;
if (error) return <div>Error...</div>;
return <Profile user={user} />;
}
우리는 React에게 "어떻게" 데이터를 가져오고, 상태를 바꾸고, UI를 업데이트할지 일일이 명령한다.
선언형: "무엇"을 원하는가
function UserProfile({ userId }) {
const user = useUser(userId); // 그냥 'user' 데이터를 원해!
return <Profile user={user} />;
}
선언형에서는 "나는 user 데이터가 필요해"라고만 선언한다.
로딩과 에러 처리는 우리의 관심사가 아니다. 그 책임은 React의 다른 부분으로 위임된다.
3. '아직 준비되지 않음'을 다루는 방법
1. Suspense란 무엇인가?
Suspense의 핵심 아이디어는 간단하다.
"아직 준비되지 않은 컴포넌트가 있다면, 렌더링을 잠시 멈추고 대신 다른 UI(fallback)를 보여준다."
데이터가 필요한 컴포넌트는 자신이 로딩 중이라는 사실을 떠들 필요 없이, 그저 '아직 준비가 안 됐다'는 신호만 보내면 된다.
이러한 접근 방식은 관심사의 분리를 가능하게 한다.
- 데이터를 소비하는 컴포넌트 : 오직 데이터를 성공적으로 가져왔을 때 무엇을 보여줄지에만 집중한다.
- Suspense 컴포넌트 : 데이터가 로딩 중일 때 어떤 UI를 보여줄지에 대한 책임을 전담한다.
Suspense는 단순한 로딩 처리 도구가 아니다. 이 컴포넌트는 렌더링에 필요한 데이터가 아직 준비되지 않았다고 선언하는 메커니즘이며, 이는 React 18 이후 비동기 처리를 다루는 방식을 바꾸었다.
2. React 18에서의 Suspense: 라이브러리와 함께 사용하기
안정적인 React 18 버전에는 컴포넌트가 스스로 Suspense를 발동시키는 내장 Hook이 없다. 대신, React 팀은 데이터 페칭 라이브러리들이 Suspense와 통합될 수 있는 저수준 메커니즘('Promise throw')을 제공했다.
따라서 React 18 환경에서 Suspense를 사용하는 가장 표준적이고 안정적인 방법은, Suspense를 지원하는 데이터 페칭 라이브러리를 사용하는 것이다.
// TanStack Query (구 React Query) 예시
import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function UserProfile() {
// useSuspenseQuery는 로딩 시 자동으로 Suspense를 발동시킨다.
const { data: user } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser });
return <Profile user={user} />;
}
function App() {
return (
<ErrorBoundary fallback={<p> Something went wrong</p>}>
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile />
</Suspense>
</ErrorBoundary>
)
}
TanStack Query의 useSuspenseQuery, Apollo Client의 useSuspenseQuery, SWR의 suspense: true 옵션 등이 바로 이런 역할을 한다.
3. React 19의 혁신: use Hook의 등장
React 19는 이 패러다임을 한 단계 더 발전시켰다. 이제 라이브러리에 의존하지 않고도, use Hook을 통해 어떤 Promise든 직접 Suspense와 연동할 수 있다.
// React 19+
import { use, Suspense } from 'react';
function UserProfile() {
// `use` Hook에 Promise를 전달하면, 알아서 Suspense를 발동시킨다.
const user = use(fetchUser());
return <Profile user={user} />;
}
// App 컴포넌트는 위와 동일하게 사용
use
Hook의 등장은 Suspense를 라이브러리의 고급 기능에서 React의 핵심 기능으로 끌어올렸으며, 개발자가 더 직접적이고 유연하게 비동기 상태를 선언할 수 있게 만들었다.
4. 서버 컴포넌트 스트리밍
현재, Suspense의 가장 강력한 사용 사례는 React 서버 컴포넌트(RSC)와의 조합일 것이다.
// app/page.js (Server Component)
import { Suspense } from 'react';
import { PostFeed, Weather } from './components';
export default function Page() {
return (
<section>
<h1>My Page</h1>
<Suspense fallback={<p>Loading weather...</p>}>
{/* Weather 컴포넌트는 데이터를 서버에서 기다린다. */}
<Weather />
</Suspense>
<Suspense fallback={<p>Loading posts...</p>}>
{/* PostFeed 컴포넌트 역시 서버에서 데이터를 기다린다. */}
<PostFeed />
</Suspense>
</section>
);
}
// components.js
export async function Weather() {
const weather = await fetch('...'); // 오래 걸리는 API
return <div>Today's weather: {weather}</div>;
}
export async function PostFeed() {
const posts = await db.posts.findMany(); // DB 쿼리
return <div>{/* ... */}</div>;
}
서버 컴포넌트에서 await을 사용하면, React는 자동으로 그 경계를 찾아낸다. 전체 페이지 렌더링을 기다리지 않고, 준비된 HTML(<h1>My Page</h1>
)과 로딩 UI(fallback)를 먼저 브라우저에 보낸다. 그 후 각 데이터(weather, posts)가 준비되는 대로 스트리밍하여 화면을 완성한다. 이것이 바로 Suspense가 추구하는 끊김 없는 사용자 경험의 핵심이다.
5. Error Boundary: 에러도 선언적으로
현재 기준으로 (202505) Error Boundary는 함수형 컴포넌트와 Hook으로 만들 수 없으며, 반드시 클래스 컴포넌트로 작성해야 한다.
Error Boundary는 자식 컴포넌트 트리에서 발생하는 렌더링 에러를 잡아내어, 전체 앱이 다운되는 대신 선언적으로 지정된 에러 UI를 보여준다.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logErrorToService(error, errorInfo); // Sentry 등에 에러 로깅
}
render() {
if (this.state.hasError) {
return this.props.fallback; // 에러 발생 시 보여줄 UI
}
return this.props.children;
}
}
6. 데이터 페칭 라이브러리와의 조합
use Hook이 없던 React 18 환경에서 Suspense가 널리 사용될 수 있었던 것은 바로 데이터 페칭 라이브러리들의 발빠른 지원 덕분이었다. Apollo Client, TanStack Query, SWR 등 대부분의 주요 라이브러리들은 Suspense를 지원하는 자체적인 방법을 제공하고 있다.
TanStack Query
TanStack Query는 useSuspenseQuery
라는 전용 훅을 제공하여 가장 직관적인 Suspense 연동을 지원한다.
// 기존의 코드
function TaskList() {
const { data, isLoading, isError } = useQuery({ queryKey: ['tasks'], queryFn: getTasks });
if (isLoading) return <TaskListSkeleton />;
if (isError) return <TaskListError />;
// ...성공 UI
}
// Suspense와 함께
function TaskList() {
// `useSuspenseQuery` 훅을 사용하면 끝!
const { data } = useSuspenseQuery({ queryKey: ['tasks'], queryFn: getTasks });
// 컴포넌트는 오직 '성공 케이스'만 다루면 된다.
return (
<div>
{data.map(task => <TaskItem key={task.id} task={task} />)}
</div>
);
}
// 사용처
<ErrorBoundary fallback={<TaskListError />}>
<Suspense fallback={<TaskListSkeleton />}>
<TaskList />
</Suspense>
</ErrorBoundary>
Apollo Client
Apollo Client 역시 useSuspenseQuery
훅을 제공한다. 별도의 복잡한 클라이언트 설정 없이, 기존 ApolloProvider 내에서 이 훅을 사용하기만 하면 된다.
// 사용
import { useSuspenseQuery } from '@apollo/client';
function TaskList() {
const { data } = useSuspenseQuery(GET_TASKS);
return (
<div>
{data.tasks.map(task => <TaskItem key={task.id} task={task} />)}
</div>
);
}
SWR
SWR은 기존 useSWR
훅에 suspense: true 옵션을 추가하는 간결한 방식으로 Suspense를 지원한다.
import useSWR from 'swr';
// SWR은 데이터를 어떻게 가져올지에 대한 함수(fetcher)를 필요로 한다.
const fetcher = (url) => fetch(url).then((res) => res.json());
function UserProfile() {
const { data } = useSWR('/api/user', fetcher, { suspense: true });
// JSX는 반드시 태그로 감싸져야 한다.
return <div>Hello, {data.name}</div>;
}
더 나아가, 일부 팀에서는 아예 데이터 페칭 로직을 내장한 선언적 컴포넌트를 직접 만들어 사용하기도 한다. (ex. Toss 팀의 suspensive) (사내 라이브러리로 Apollo Client를 위한 선언적 JSX 컴포넌트 라이브러리를 개발했다. 주제로 다뤄볼 수 있는 기회가 있으면 좋을 것 같다.)
7. 어떻게 적용하면 좋을까?
1. 레이어별 Error Boundary
전역, 페이지 레벨, 기능별로 Error Boundary를 중첩하여 에러의 영향을 최소화하고 각기 다른 폴백 UI를 보여줄 수 있다.
2. 에러 복구 전략 (with react-error-boundary
)
직접 Error Boundary를 만드는 대신, react-error-boundary
라이브러리를 사용하면 재시도 로직을 훨씬 쉽게 구현할 수 있다.
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<MyComponent />
</ErrorBoundary>
3. useTransition으로 부드러운 로딩 상태 만들기
Suspense는 useTransition과 함께할 때 더욱 빛을 발한다. startTransition으로 상태 업데이트를 감싸면, 로딩 중에도 기존 UI를 유지하다가(화면 깜빡임 방지), 데이터가 준비되면 새로운 UI로 부드럽게 전환할 수 있다.
const [isPending, startTransition] = useTransition();
const handleNextPage = () => {
startTransition(() => {
setCurrentPage(prev => prev + 1); // 이 업데이트가 Suspense를 유발
});
};
// isPending으로 로딩 인디케이터를 보여줄 수 있음
{isPending && <Spinner />}
8. 왜 선언적 프로그래밍인가?
- 관심사의 분리 : 데이터 페칭, 로딩, 에러, 성공 상태 로직이 뒤섞이지 않고 각자의 위치(컴포넌트, Suspense, ErrorBoundary)에서 역할을 수행한다.
- 조합 가능성 (Composability) : 각 컴포넌트가 독립적이고 자신의 렌더링에만 집중하므로, 레고 블록처럼 쉽게 조합하고 재사용할 수 있다.
- 테스트 용이성 : 더 이상 isLoading, isError 상태를 수동으로 mocking할 필요 없이, 성공 케이스에 대한 테스트에만 집중할 수 있다.
결론
Suspense와 Error Boundary는 사용자 경험과 개발 경험 모두를 향상시켜준다. 우리가 정말로 집중해야 할 것은 "사용자에게 무엇을 보여줄 것인가"이지, "로딩을 어떻게 처리하고 상태를 어떻게 관리할 것인가"가 아니기 때문이다.