Suspense와 Error Boundary: 선언적 비동기 처리의 진짜 가치
Suspense와 Error Boundary: 선언적 비동기 처리의 진짜 가치
1. 서론: 우리는 왜 아직도 if (loading)을 쓰고 있을까?
const {
data: taskByUuid,
isError: isEditorPageError,
error: taskPageError,
isLoading: isEditorPageLoading,
refetch: refetchTask,
} = useQueryEditorPage(taskUuid);
if (isEditorPageLoading) return <LoadingSpinner />;
if (isEditorPageError) return <ErrorMessage error={taskPageError} />;
return <TaskEditor task={taskByUuid} />;
이 코드를 볼 때마다 불편했다.
정작 중요한 건 <TaskEditor />
컴포넌트인데, 로딩과 에러 처리가 더 많은 공간을 차지한다. 모든 컴포넌트마다 이 패턴이 반복된다. Copy & Paste의 향연이다.
더 큰 문제는 이게 "당연하다"고 생각했다는 거다. "비동기는 원래 이렇게 처리하는 거 아냐?"
React 팀은 이미 답을 제시했다. Suspense와 Error Boundary. 하지만 우리는 여전히 2018년의 방식으로 코드를 짜고 있다.
2. 명령형 vs 선언형: 패러다임의 차이
명령형: "어떻게" 처리할 것인가
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <Profile user={user} />;
}
우리는 React에게 "어떻게" 해야 하는지 일일이 설명한다:
- 로딩 상태를 true로 설정해
- 데이터를 가져와
- 성공하면 로딩을 false로 바꾸고 데이터를 저장해
- 실패하면 로딩을 false로 바꾸고 에러를 저장해
선언형: "무엇"을 원하는가
function UserProfile({ userId }) {
const user = useUser(userId); // 그냥 데이터를 원해!
return <Profile user={user} />;
}
선언형에서는 "나는 user 데이터가 필요해"라고만 선언한다. 어떻게 가져올지, 로딩을 어떻게 표시할지, 에러를 어떻게 처리할지는 관심사가 아니다.
3. Suspense의 본질: "아직 준비되지 않았다"는 선언
Suspense는 단순한 로딩 처리 도구가 아니다. "이 컴포넌트는 아직 렌더링할 준비가 되지 않았다"는 선언이다.
Promise를 throw하는 마법
function useData(url) {
const [resource, setResource] = useState(null);
if (!resource) {
// Promise를 throw하면 Suspense가 잡아낸다!
throw fetchData(url).then(data => {
setResource(data);
});
}
return resource;
}
일반적인 JavaScript에서 Promise를 throw하는 건 말이 안 된다. 하지만 React는 이를 "아직 준비 중"이라는 신호로 해석한다.
Suspense Boundary: 로딩의 경계 설정
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>
</Suspense>
);
}
중첩된 Suspense로 로딩 UI의 세밀한 제어가 가능하다. 더 이상 각 컴포넌트가 자신의 로딩 상태를 관리할 필요가 없다.
4. Error Boundary: 에러도 선언적으로
에러를 "경계"에서 처리하기
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 에러 로깅 (DataDog, Sentry 등)
logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorFallback onRetry={() => this.setState({ hasError: false })} />;
}
return this.props.children;
}
}
Error Boundary는 컴포넌트 트리에서 발생하는 에러를 "경계"에서 잡아낸다. try-catch처럼 명령형이 아니라, 선언적으로 에러 처리 영역을 정의한다.
에러의 전파와 격리
function App() {
return (
<ErrorBoundary fallback={<AppCrashed />}>
<Header />
<ErrorBoundary fallback={<ContentError />}>
<MainContent />
</ErrorBoundary>
<ErrorBoundary fallback={<SidebarError />}>
<Sidebar />
</ErrorBoundary>
</ErrorBoundary>
);
}
에러가 발생해도 전체 앱이 죽지 않는다. 각 영역별로 독립적인 에러 처리가 가능하다.
5. 실전: Apollo Client와의 조합
기존의 지옥
function TaskList() {
const { data, loading, error } = useQuery(GET_TASKS);
if (loading) return <TaskListSkeleton />;
if (error) return <TaskListError error={error} />;
return (
<div>
{data.tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
모든 컴포넌트마다 반복되는 패턴. 지겹다.
Suspense와 함께라면
// Apollo Client 설정
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Suspense를 위한 설정
}
}
}
}),
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
notifyOnNetworkStatusChange: true,
}
}
});
// 사용
function TaskList() {
const { data } = useSuspenseQuery(GET_TASKS);
return (
<div>
{data.tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
로딩? Suspense가 처리한다. 에러? Error Boundary가 처리한다. 컴포넌트는 오직 "성공 케이스"만 다루면 된다.
6. 실무 적용을 위한 패턴들
패턴 1: 레이어별 Error Boundary
// 전역 에러 처리
<GlobalErrorBoundary>
{/* 페이지 레벨 에러 처리 */}
<PageErrorBoundary>
{/* 기능별 에러 처리 */}
<FeatureErrorBoundary>
<Feature />
</FeatureErrorBoundary>
</PageErrorBoundary>
</GlobalErrorBoundary>
패턴 2: 에러 복구 전략
function RetryErrorBoundary({ children, maxRetries = 3 }) {
const [retryCount, setRetryCount] = useState(0);
const [hasError, setHasError] = useState(false);
const resetError = useCallback(() => {
setRetryCount(prev => prev + 1);
setHasError(false);
}, []);
if (hasError && retryCount < maxRetries) {
return (
<ErrorRetrying
onRetry={resetError}
attempt={retryCount + 1}
maxAttempts={maxRetries}
/>
);
}
if (hasError) {
return <FinalError />;
}
return (
<ErrorBoundary
onError={() => setHasError(true)}
resetKeys={[retryCount]}
>
{children}
</ErrorBoundary>
);
}
패턴 3: 통합 데이터 페칭 훅
// 우리가 만들 패키지의 모습
function useApolloSuspense(query, options) {
const client = useApolloClient();
const cacheKey = getCacheKey(query, options);
// 캐시 확인
const cached = suspenseCache.get(cacheKey);
if (cached) return cached;
// Promise 생성 및 throw
const promise = client
.query({ query, ...options })
.then(result => {
suspenseCache.set(cacheKey, result);
return result;
});
throw promise;
}
7. 왜 선언적 프로그래밍인가?
관심사의 분리
// 명령형: 모든 게 뒤섞여 있다
function ProductPage() {
// 제품 데이터 로딩 로직
// 리뷰 데이터 로딩 로직
// 추천 상품 로딩 로직
// 각각의 로딩, 에러 상태 관리
// UI 렌더링
}
// 선언형: 각자의 역할이 명확하다
function ProductPage() {
return (
<Layout>
<ProductInfo /> {/* 제품 정보만 */}
<ProductReviews /> {/* 리뷰만 */}
<Recommendations /> {/* 추천 상품만 */}
</Layout>
);
}
조합 가능성 (Composability)
선언형 컴포넌트는 레고 블록처럼 조합이 쉽다. 각 컴포넌트가 독립적이고, 자신의 책임만 가지기 때문이다.
테스트 용이성
// 로딩과 에러 상태를 mocking할 필요가 없다!
test('ProductInfo renders correctly', () => {
render(
<MockedProvider mocks={[productMock]}>
<ProductInfo productId="123" />
</MockedProvider>
);
// 바로 제품 정보가 렌더링되는지 확인
expect(screen.getByText('iPhone 15')).toBeInTheDocument();
});
8. 마이그레이션 전략
Step 1: 작은 단위부터 시작
// 기존 코드를 감싸는 래퍼 컴포넌트
function withSuspense(Component) {
return function SuspenseWrapper(props) {
return (
<ErrorBoundary>
<Suspense fallback={<ComponentSkeleton />}>
<Component {...props} />
</Suspense>
</ErrorBoundary>
);
};
}
Step 2: 점진적 확대
- 리프 컴포넌트부터 시작
- 독립적인 기능 단위로 확대
- 페이지 레벨로 확장
- 전체 앱 적용
9. 결론: 진짜 React다운 코드를 향해
// Before: 명령형 지옥
if (loading1 || loading2 || loading3) return <Loading />;
if (error1 || error2 || error3) return <Error />;
// After: 선언형 천국
return <App />;
Suspense와 Error Boundary는 단순한 기능이 아니다. React가 추구하는 "선언적 UI"의 완성이다.
우리가 정말로 집중해야 할 것은 "사용자에게 무엇을 보여줄 것인가"이지, "로딩을 어떻게 처리할 것인가"가 아니다.
이제 진짜 React다운 코드를 작성할 시간이다.
"로딩과 에러는 더 이상 우리의 관심사가 아니다. 우리는 단지 선언할 뿐이다."