ErrorBoundary / Suspense
데이터를 불러오는 중 에러가 발생했을 때 전체 페이지가 깨지거나, 로딩 상태를 처리하는 코드가 비즈니스 로직보다 많아지는 문제가 이번 프로젝트에서 나왔던 문제들이에요.
각 컴포넌트마다 if (isLoading), if (isError) 같은 조건문들이 반복되고, try-catch 블록이 중복되면서 정작 중요한 비즈니스 로직은 코드 사이에 묻혀버렸죠.
더 큰 문제는 부분 UI에서 에러가 발생했을 때인데요. 사이드바의 필터 데이터를 불러오지 못했을 뿐인데, 전체 페이지가 하얀 화면으로 바뀌는 문제가 발생했어요.
이러한 문제들을 ErrorBoundary / Suspense 패턴으로 설계하여 해결했던 경험을 공유해보려 합니다.
문제상황
명령형 에러, 로딩 처리의 한계
초기 코드는 각 컴포넌트에서 로딩과 에러 상태를 직접 관리하는 방식이었어요.
function SpaceList() {
const [spaces, setSpaces] = useState<Space[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchSpaces = async () => {
try {
setLoading(true);
const response = await fetch('/api/spaces');
const data = await response.json();
setSpaces(data);
} catch (error) {
setError(error as Error);
} finally {
setLoading(false);
}
};
fetchSpaces();
}, []);
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorMessage message={error.message} />;
}
return (
<ul>
{spaces.map((space) => (
<SpaceItem key={space.id} space={space} />
))}
</ul>
);
}
Tanstack Query를 사용하면 조금 나아지지만 여전히 조건부 렌더링은 필요했고,
function SpaceList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['spaces'],
queryFn: fetchSpaces,
});
if (isLoading) {
return <LoadingSpinner />;
}
if (isError) {
return <ErrorMessage message={error.message} />;
}
return (
<ul>
{data?.map((space) => (
<SpaceItem key={space.id} space={space} />
))}
</ul>
);
}
하위 컴포넌트도 마찬가지로 loading prop을 받아 처리해야만 했어요.
function Dashboard() {
const { data: spaces, isLoading: spacesLoading } = useQuery(
spaceQueries.list({ space: 'all' })
);
const { data: floors, isLoading: floorsLoading } = useQuery(
floorQueries.list({ floor: 'all' })
);
return (
<div>
<SpaceSection spaces={spaces} loading={spacesLoading} />
<FloorSection floors={floors} loading={floorsLoading} />
</div>
);
}
이 코드의 가장 큰 문제는 관심사가 섞여있다는 점이에요. 컴포넌트는 데이터를 어떻게 보여줄지에 집중해야 하는데, “데이터가 있는지”, “로딩 중인지”, “에러가 났는지”를 모두 직접 처리하고 있어요. 이로 인해 다음과 같은 사이드 이펙트가 발생해요.
-
Props Drilling: loading, error 상태를 모든 하위 컴포넌트에 전달해야 해서 Props 인터페이스가 비대해지고 수정 시 어려움
-
코드 중복: 모든 데이터 페칭 컴포넌트마다
if (isLoading),if (isError)블록 반복 -
전체 페이지 깨짐: 하나의 API에러가 발생해도 전체 페이지가 렌더링 되지 않음
-
비즈니스 로직 묻힘: 정작 중요한 데이터 가공 및 렌더링 UI 로직이 상태 체크 코드에 묻혀 가독성 저하
해결 방법: ErrorBoundary와 Suspense
핵심 아이디어는 에러와 로딩 처리를 컴포넌트 외부로 분리하자였어요. React는 이를 위한 두 가지 방법을 제공해요.
- ErrorBoundary: 하위 컴포넌트에서 발생한 에러를 캐치하고 Fallback UI 렌더링
- Suspense: 하위 컴포넌트가 “아직 준비되지 않았음”을 감지하고 Fallback UI 렌더링
이 둘을 조합하면 컴포넌트는 “성공 케이스만 처리”하고, 에러와 로딩은 상위 계층이 자동으로 처리하게 할 수 있어요.
기능 구현
1. ErrorBoundary
React팀은 왜 ErrorBoundary를 만들었는지 먼저 알아봤는데요. 16버전 이전엔 컴포넌트 렌더링 중 발생한 에러를 잡아낼 방법이 없었어요. 한 컴포넌트에서 에러가 발생하면 전체 앱이 깨져버렸죠.
function Dashboard() {
const { data: spaces, isLoading: spacesLoading } = useQuery(
spaceQueries.list({ space: 'all' })
);
const { data: floors, isLoading: floorsLoading } = useQuery(
floorQueries.list({ floor: 'all' })
);
return (
<div>
<SpaceSection spaces={spaces} loading={spacesLoading} />
<FloorSection floors={floors} loading={floorsLoading} />
</div>
);
}
이 문제를 해결하기 위해 ErrorBoundary 개념을 도입했는데, JS의 try-catch와 유사하지만 컴포넌트 트리 레벨에서 동작해요.
// try-catch는 명령형 코드에서 작동
try {
foo();
} catch (error) {
console.error(error);
}
// ErrorBoundary는 선언적 JSX 트리에서 작동
<ErrorBoundary fallback={<ErrorUI />}>
<MyComponent /> {/* 여기서 에러 발생해도 앱은 안전 */}
</ErrorBoundary>;
React 16에서 ErrorBoundary가 도입되면서 다음이 가능해졌어요
- 에러 발생 시 대체 UI(Fallback) 표시
- 에러를 격리해서 전체 앱 깨짐 방지
- 에러 로깅 및 모니터링
하지만 한 가지 제약이 있었는데, 함수 컴포넌트에서는 에러를 잡을 수 없어요. 잡으려면 componentDidCatch 생명주기 메서드가 필요한데, 이건 클래스 컴포넌트에서만 사용할 수 있었어요.
클래스 컴포넌트란?
React는 초기에 클래스 기반으로 컴포넌트를 작성했어요. Hooks가 도입되기 전(16.8 이전)에는 상태 관리나 생명주기를 다루려면 클래스 컴포넌트를 써야 했죠.
// 클래스 컴포넌트 (예전 방식)
class Greeting extends Component {
render() {
return <div>안녕하세요 {this.props.name}!</div>;
}
}
// 함수 컴포넌트 (현재 주로 사용)
function Greeting({ name }) => {
return <div>안녕하세요 {name}!</div>;
};
지금은 거의 모든 기능을 함수 컴포넌트 + Hooks로 구현할 수 있지만, 에러 처리는 클래스 컴포넌트가 필수에요.
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
// 에러가 발생하면 이 메서드가 호출되어 상태 업데이트
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
// 에러 로깅 등 부수 효과 처리
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <DefaultErrorFallback error={this.state.error} />;}
}
return this.props.children;
}
}
getDerivesStateFormError: 에러가 발생하면 호출되어hasError: true로 상태를 변경해요. 이건 렌더링 단계에서 실행되므로 부수 효과(로깅 등)는 여기서 하면 안돼요.componentDisCatch: 에러 로깅이나 외부 서비스 전송 같은 부수 효과를 처리해요. 이건 커밋 단계에서 실행돼요.
클래스 컴포넌트가 필수이지만, 사용하는 입장에서라도 함수형으로 방법이 없을까 찾아보니 react-error-boundary 라이브러리가 있었고 이번 프로젝트에선 해당 라이브러리를 사용하기로 결정했어요.
import { ErrorBoundary } from 'react-error-boundary';
<ErrorBoundary
fallback={<ErrorFallback />}
onError={(error, errorInfo) => {
console.error('에러 발생:', error, errorInfo);
}}
>
<MyComponent>
</ErrorBoundary>;
2. Suspense
Suspense가 왜 만들어졌는지도 조사해봤는데요. 예전 React에선 각 컴포넌트가 자신의 로딩 상태를 직접 관리했어요.
function SpaceDetail() {
const [space, setSpace] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSpace().then((data) => {
setSpace(data);
setLoading(false);
});
}, []);
if (loading) {
return <Spinner />;
}
return <div>{space.name}</div>;
}
이 방식의 문제점은 다음과 같아요.
- 워터폴 문제: 부모가 로딩 완료되어야 자식이 로딩 시작
- Props Drilling: loading 상태를 모든 하위 컴포넌트에 전달
- 로딩 UI 일관성 부족: 각 컴포넌트마다 다른 로딩 처리
React 팀은 이를 해결하기 위해 “선언적 로딩 처리” 개념인 Suspense를 도입했어요.
Suspense의 핵심 아이디어: “아직 준비 안 됨”을 throw 하기
Suspense는 독특한 방식으로 동작해요. 컴포넌트의 데이터가 없을 때 Promise를 throw하면, Suspense가 이를 캐치해요.
function SpaceDetail() {
// 데이터가 없으면 Promise throw
const space = readSpace(); // 내부적으로 Promise throw
// 데이터가 준비되면 이 코드 실행
return <div>{space.name}</div>;
}
// Suspense가 Promise를 캐치
<Suspense fallback={<Spinner />}>
<SpaceDetail /> {/* Promise throw -> Spinner 표시 */}
</Suspense>;
이게 가능한 이유는 Javascript에서는 무엇이든 throw 할 수 있기 때문이에요. Suspense는 하위 컴포넌트에서 Promise가 throw되면
- 일시적으로 렌더링 중단
- fallback UI 표시
- Promise가 resolve되면 다시 렌더링 시도
Suspense의 진화
16.6 버전에서 처음 도입되었지만, 초기에는 React.lazy()를 통한 코드 스플리팅에만 사용할 수 있었어요. 18 버전에서 Suspense가 본격적으로 데이터 페칭에 사용 가능해졌어요.
const LazyComponent = React.lazy(() => import('./Component'));
<Suspense fallback={<Spinner />}>
<LazyComponent>
</Suspense>
Suspense의 장점
병렬 데이터 페칭
기존 방식은 부모가 로딩 완료되어야 자식이 시작
// 순차적 로딩 (느림))
function FloorDetail() {
const { data: floor, isLoading } = useQuery(['floor']);
if (isLoading) {
return <Spinner />;
}
// floor 로딩 완료 후에야 reservations 시작
return <ReservationList floorId={floor.id} />;
}
로딩 상태 중앙 관리
각 컴포넌트가 isLoading을 체크하는 대신, Suspense가 한 곳에서 관리해요.
코드 간결화
// Before: 로딩 상태 체크 필수
if (isLoading) {
return <Spinner />;
}
// After: 체크 불필요
const { data } = useSuspenseQuery(...);
Tanstack Query v5부터는 useSuspenseQuery를 제공하는데, 이런식으로 queryOptions와 함께 사용하면 타입 안정성과 재사용성을 모두 확보 가능해요.
// queries/spaces.ts
import { queryOptions } from '@tanstack/react-query';
export const spaceQueires = {
all: () => ['spaces'] as const,
list: (filters: SpaceFilter) =>
queryOptions({
queryKey: [...spaceQueires.all(), 'list', filters],
queryFn: () => fetchSpaces(filters),
}),
};
// SpaceList.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
function SpaceList() {
// useSuspenseQuery는 isLoading을 반환하지 않고,
// 데이터가 준비되지 않으면 자동으로 Suspense로 전파
const { data } = useSuspenseQuery(spaceQueries.list({ category: 'all' }));
return (
<ul>
{/* 옵셔널 체이닝 불필요, 데이터 보장 */}
{data.map((space) => (
<SpaceItem key={space.id} space={space} />
))}
</ul>
);
}
3. Tanstack Query의 throwOnError 설정
에러를 throw하도록 설정하면, ErrorBoundary가 자동으로 잡아요.
// config/queryClient.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: true, // 쿼리 에러를 Boundary로 전파
retry: 1,
staleTime: FIVE_MINUTES,
},
mutations: {
throwOnError: false, // Mutation은 수동 에러 처리
},
},
});
// App.tsx
function App() {
return (
<QueryClientProvider client={queryClient}>{/* ... */}</QueryClientProvider>
);
}
이제 컴포넌트에서 isError 체크가 불필요해져요.
function SpaceList() {
const { data } = useSuspenseQuery(spaceQueries.list({ space: 'all' }));
// isError 체크 없이, 에러 발생시 자동으로 ErrorBoundary가 처리
return (
<ul>
{data?.map((space) => (
<SpaceItem key={space.id} space={space} />
))}
</ul>
);
}
4. AsyncBoundary로 간편하게 사용하기
매번 ErrorBoundary와 Suspense를 중첩해서 쓰는건 번거로워서 합성 컴포넌트를 만들었어요.
import { type ReactNode, Suspense } from 'react';
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
interface AsyncBoundaryProps {
children: ReactNode;
errorFallback: (props: FallbackProps) => ReactNode;
suspenseFallback: ReactNode;
}
export function AsyncBoundary({
children,
errorFallback,
suspenseFallback,
}: AsyncBoundaryProps) {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary fallbackRender={errorFallback} onReset={reset}>
<Suspense fallback={suspenseFallback}>{children}</Suspense>
</ErrorBoundary>
);
}
5. 계층별 Boundary 설정
페이지의 어느 계층에 Boundary를 두느냐에 따라 에러/로딩 발생 시 영향 범위가 달라져요. 각 계층마다 적절한 Fallback을 제공하면 에러 발생 시 영향 범위를 최소화 할 수 있어요.
1단계: 앱 전체 Boundary(최상위)
앱 최상위에는 “마지막 방어선” 역할의 Boundary를 둬요. 인증, 라우팅 등 앱 전체를 감싸는 계층이에요. 예상치 못한 에러를 잡아내고, 여기서 잡히는 에러는 “앱 전체가 동작하지 않는” 수준의 문제에요.
import { AsyncBoundary } from './components/AsyncBoundary';
import { FullPageError } from './components/ErrorFallback';
import { FullPageSkeleton } from './components/Skeleton';
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* 1단계: 앱 전체 Boundary - 마지막 방어선 */}
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<FullPageError error={error} onReset={resetErrorBoundary} />
)}
suspenseFallback={<FullPageSkeleton />}
>
<Router />
</AsyncBoundary>
</QueryClientProvider>
);
}
2단계: 페이지 레벨 Boundary
각 페이지마다 Boundary를 두면 페이지 전체가 에러가 나도 다른 페이지는 정상 작동해요.
function DashboardPage() {
return (
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<Container>
<PageError error={error} onReset={resetErrorBoundary} />
</Container>
)}
suspenseFallback={<LoadingSpinner />}
>
<Container>
<Title>대시보드</Title>
{/* 3단계: 위젯별 Boundary로 더 세분화 */}
<SummaryCardsSection />
<ChartSection />
<DataTableSection />
</Container>
</AsyncBoundary>
);
}
3단계: 섹션/위젯 레벨 Boundary (가장 세밀한 단위)
페이지 내부의 각 섹션이나 위젯마다 독립적인 Boundary를 두면 부분 UI 실패 시에도 나머지는 정상 동작해요.
function DashboardPage() {
return (
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<PageError error={error} onReset={resetErrorBoundary} />
)}
suspenseFallback={<DashboardPageSkeleton />}
>
<Container>
<Title>대시보드</Title>
{/* 요약 카드: 핵심 정보, 명확한 에러 처리 */}
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<ErrorCard
title="요약 정보를 불러올 수 없습니다"
error={error}
onReset={resetErrorBoundary}
/>
)}
suspenseFallback={<SummaryCardsSkeleton />}
>
<SummaryCardsSection />
</AsyncBoundary>
{/* 차트: 핵심 시각화, 에러 시 재시도 가능 */}
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<ErrorCard
title="차트를 불러올 수 없습니다"
error={error}
onReset={resetErrorBoundary}
/>
)}
suspenseFallback={<ChartSkeleton />}
>
<ChartSection />
</AsyncBoundary>
{/* 데이터 테이블: 상세 정보 */}
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<ErrorCard
title="데이터를 불러올 수 없습니다"
error={error}
onReset={resetErrorBoundary}
/>
)}
suspenseFallback={<TableSkeleton />}
>
<DataTableSection />
</AsyncBoundary>
</Container>
</AsyncBoundary>
);
}
이런 구조의 장점은 다음과 같아요.
- 단계별 에러 격리: 차트 데이터 페칭이 실패해도 요약 카드와 데이터 테이블은 정상 동작
- 중요도별 차등 처리: 핵심 기능은 명확한 에러 메시지, 부가 기능은 최소한의 표시
- 빠른 복구: 특정 섹션만 “다시 시도” 가능
계층 설계 원칙
| 계층 | 위치 | Fallback 전략 | 예시 |
|---|---|---|---|
| 1단계: 앱 전체 | App.tsx | 전체 화면 에러 페이지 | 네트워크 에러, 인증 실패, |
| 2단계: 페이지 | 각 페이지 컴포넌트 | 페이지 내 에러 안내 | 페이지 데이터 로딩 실패 |
| 3단계: 섹션/위젯 | 페이지 내부 영역 | 인라인 에러 메시지 | 차트, 테이블, 위젯 개별 실패 |
핵심: 에러가 발생한 지점에 가장 가까운 Boundary가 차리하고, 복구 가능하면 “다시 시도” 버튼을 제공해요.
6. useQueryErrorResetBoundary 메서드 활용
AsyncBoundary 구현에서 사용한 useQueryErrorResetBoundary는 React Query의 에러 상태를 초기화 해요.
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
export function AsyncBoundary({
children,
errorFallback,
suspenseFallback,
}: AsyncBoundaryProps) {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
fallbackRender={errorFallback}
onReset={reset} // ErrorBoundary 리셋 시 React Query 캐시도 함께 리셋
>
<Suspense fallback={suspenseFallback}>{children}</Suspense>
</ErrorBoundary>
);
}
이렇게 하면 사용자가 “다시 시도” 버튼을 클릭할 때 Tanstack Query의 실패한 쿼리도 함게 재시도해요.
export function ErrorCard = ({
title,
error,
onReset
}: {
title: string;
error: Error;
onReset?: () => void;
}) => {
return (
<Container>
<Title>
{title}
</Title>
<Text>{error.message}</Text>
{onReset && (
<Button
onClick={onReset} // ErrorBoundary + Query 모두 리셋
>
다시 시도
</Button>
)}
</Container>
);
};
Fallback UI 디자인 전략
ErrorBoundary와 Suspense를 도입하면서 로딩/에러 상태일 때 UI를 어떻게 처리할지에 대한 고민 과정이에요.
무조건 Skeleton만이 정답일까?
처음엔 모든 로딩 상태에 Skeleton UI를 적용하려 했지만, 저희 서비스는 B2B 환경이에요. B2C처럼 수많은 사용자가 다양한 네트워크 환경에서 접속하는 게 아니라, 비교적 안정적인 사내 네트워크에서 소수의 사용자가 사용해요.
이런 환경에서는 대부분의 API 응답이 빠르게 돌아오기 때문에, 모든 곳에 Skeleton을 넣으면 오히려 UX를 해칠 수 있다고 판단했고, 데이터의 양과 로딩 시간을 기준으로 로딩 UI를 구분했어요.
| 데이터 특성 | 로딩 UI | 이유 |
|---|---|---|
| 차트 데이터, Raw Data 등 대용량 | Skeleton UI | 로딩 시간이 길어질 수 있어 레이아웃 예측이 필요 |
| 필터 옵션, 메타 정보 등 경량 | Spinner 또는 null | 빠른 응답이 예상되어 최소한의 인디케이터로 충분 |
/* 차트 영역: 데이터가 무거워 Skeleton 적용 */
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<ErrorCard
title="차트를 불러올 수 없습니다"
error={error}
onReset={resetErrorBoundary}
/>
)}
suspenseFallback={<ChartSkeleton />}
>
<ChartSection />
</AsyncBoundary>;
/* 필터 영역: 가벼운 데이터라 Spinner로 처리 */
<AsyncBoundary
errorFallback={({ error, resetErrorBoundary }) => (
<ErrorCard
title="필터를 불러올 수 없습니다"
error={error}
onReset={resetErrorBoundary}
/>
)}
suspenseFallback={<Spinner size="sm" />}
>
<FilterSection />
</AsyncBoundary>;
이렇게 구분하니 무거운 데이터를 불러올 땐 사용자가 “무엇이 로딩되고 있는지” 예측할 수 있고, 가벼운 데이터는 빠르게 전환되면서 자연스러운 흐름을 유지할 수 있었어요.
에러 Fallback: 상황별 전략
에러가 발생한 위치와 영향 범위에 따라 다르게 처리했어요.
전체 페이지 에러: 안내 페이지 표시
라우팅 레벨이나 페이지 전체에서 에러가 발생하면 전체 화면을 차지하는 에러 페이지를 보여줘요.
export function FullPageError({
error,
onReset,
}: {
error: Error;
onReset?: () => void;
}) {
return (
<Container>
<Container>
<Icon>{/* 에러 아이콘 */}</Icon>
<Title>문제가 발생했습니다</Title>
<Content>
일시적인 오류로 페이지를 불러올 수 없습니다. 잠시 후 다시
시도해주세요.
</Content>
{onReset && <Button onClick={onReset}>다시 시도</Button>}
<Button onClick={() => ()}>
홈으로 이동
</Button>
</Container>
</Container>
);
}
부분 UI 에러: 인라인 에러 메시지
위젯이나 컴포넌트 일부에서 에러가 발생하면 해당 영역에만 간단하게 에러 메시지를 표시해요
export function ErrorCard({
title,
error,
onReset,
}: {
title: string;
error: Error;
onReset?: () => void;
}) {
return (
<Container>
<Container>
<Icon>{/* 경고 아이콘 */}</Icon>
<Container>
<Title>{title}</Title>
<Content>
일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.
</Content>
{onReset && <Button onClick={onReset}>다시 시도</Button>}
</Container>
</Container>
</Container>
);
}
디자이너와의 협업
Fallback UI를 설계하면서 디자이너와 다음과 같이 협업했어요.
Skeleton UI는 선별적으로 디자인: 모든 컴포넌트에 Skeleton을 만들지 않고, 차트나 테이블처럼 로딩이 길어질 수 있는 핵심 영역만 디자이너가 직접 디자인했어요. 나머지는 공용 Spinner로 처리했죠.
에러 UI는 중요도에 따라 분류: 디자이너와 함께 에러를 두 가지 레벨로 나눴어요.
| 레벨 | 상황 | UI 전략 | 디자인 필요 여부 |
|---|---|---|---|
| Critical | 전체 페이지 / 핵심 기능 | 전체 화면 에러 페이지 | 디자이너 작업 필요 |
| Minor | 부분 UI / 보조 위젯 | 인라인 에러 메시지 | 공용 컴포넌트 활용 |
핵심 개념 정리
1. 관심사 분리
기존 방식에서는 하나의 컴포넌트가 다음을 모두 처리했었고,
- 데이터 페칭
- 로딩 상태 처리
- 에러 상태 처리
- 비즈니스 로직
- UI 렌더링
리팩터링 후에는 각 계층이 단일 책임만 가져요.
| 계층 | 책임 |
|---|---|
| ErrorBoundary | 에러 캐치 및 Fallback UI 렌더링 |
| Suspense | 로딩 감지 및 Skeleton UI 렌더링 |
| Component | 데이터 가공 및 비즈니스 로직, 성공 케이스 UI 렌더링 |
2. 명령형 vs 선언형
명령형: “어떻게” 처리할지 단계별로 명령
function SpaceList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['spaces'],
queryFn: fetchSpaces,
});
// 명령형: 상태를 체크하고 분리 처리
if (isLoading) {
return <LoadingSpinner />;
}
if (isError) {
return <ErrorMessage error={error} />;
}
return (
<ul>
{data?.map((space) => (
<SpaceIten key={space.id} space={space} />
))}
</ul>
);
}
선언적: “무엇을” 보여줄지만 정의
function SpaceList() {
const { data } = useSuspenseQuery({
queryKey: ['spaces'],
queryFn: fetchSpaces,
});
// 선언적: 성공 케이스만 처리
return (
<ul>
{data.map((space) => (
<SpaceItem key={space.id} space={space} />
))}
</ul>
);
}
// 사용하는 쪽
<AsyncBoundary
errorFallback={({ error }) => <ErrorMessage error={error} />}
suspenseFallback={<Skeleton />}
>
<SpaceList />
</AsyncBoundary>;
선언적 방식은 제어 흐름을 프레임워크에 위임하므로 코드가 간결하고 의도가 명확해요.
3. 데이터 보장
useSuspenseQuery를 사용하면 컴포넌트는 데이터가 항상 존재한다고 가정할 수 있어요.
- Before: 옵셔널 체이닝 필수
function SpaceList() {
const { data } = useQuery({
queryKey: ['spaces'],
queryFn: fetchSpaces,
});
// data가 undefined일 수 있음
return (
<div>
<h2>총 {data?.length ?? 0}개</h2>
{data?.map((space) => (
<SpaceCard key={space.id} space={space} />
))}
</div>
);
}
- After: 데이터 보장
function SpaceList() {
const { data } = useSuspenseQuery({
queryKey: ['spaces'],
queryFn: fetchSpaces,
});
// data는 항상 존재함 (로딩 중이면 Suspense가 처리)
return (
<div>
<h2>총 {data.length}개</h2>
{data.map((space) => (
<SpaceCard key={space.id} space={space} />
))}
</div>
);
}
리팩터링 후 결과
코드 복잡도 감소
try-catch블록 제거if (isLoading),if (isError)분기문 제거- 옵셔널 체이닝(
?.) 사용 감소
Props 인터페이스 간소화
// Before
interface SpaceListProps {
spaces?: Space[];
loading?: boolean;
error?: Error;
onRetry?: () => void;
emptyMessage?: string;
}
// After
interface SpaceListProps {
spaces: Space[]; // 하나의 필수 prop
emptyMessage?: string; // 선택적 커스터마이징만 허용
}
부분 UI 실패 시 전체 페이지 안정성 확보
- Before: 하나의 위젯 에러 → 전체 페이지 깨짐
[사이드바 정상] [메인 콘텐츠 정상] [위젯 에러 발생]
↓
[전체 화면 에러]
- After: 하나의 위젯 에러 → 해당 위젯만 Fallback
[사이드바 정상] [메인 콘텐츠 정상] [위젯 에러 발생]
↓
[사이드바 정상] [메인 콘텐츠 정상] [위젯 에러 UI]
비즈니스 로직에 집중 가능
컴포넌트에서 더 이상 isLoading, isError를 신경 쓰지 않고, 데이터를 어떻게 가공하고 보여줄지에만 집중할 수 있어요.
// Before: 상태 관리 코드에 묻힌 비즈니스 로직
function Dashboard() {
const { data: buildings, isLoading: buildingsLoading } = useQuery(/*...*/);
const { data: floors, isLoading: floorsLoading } = useQuery(/*...*/);
if (buildingsLoading || floorsLoading) {
return <Skeleton />;
}
// 비즈니스 로직이 여기서부터 시작
const totalFloors = floors.length;
const occupiedFloors = floors.filter(f => f.occupancyRate > 0).length;
const averageOccupancy = floors.reduce((sum, floor) => sum + floor.occupancyRate, 0) / floors.length;
return (/* ... */);
}
// After: 처음부터 비즈니스 로직에만 집중
function Dashboard() {
const { data: floors } = useSuspenseQuery(/*...*/);
const { data: buildings } = useSuspenseQuery(/*...*/);
const totalFloors = floors.length;
const occupiedFloors = floors.filter(f => f.occupancyRate > 0).length;
const averageOccupancy = floors.reduce((sum, floor) => sum + floor.occupancyRate, 0) / floors.length;
return (/* ... */);
}
이렇게 코드가 단순해지면서 다음과 같은 장점이 생겼어요.
- 가독성 향상: 핵심 로직이 명확히 보임
- 테스트 용이성: 순수 함수에 가까워짐
- 유지보수성: 수정할 때 사이드이펙트 걱정 불필요
마치며
에러·로딩 처리는 사용자 경험에 직결되는 중요한 부분이에요. 한번 구조가 잘못 잡히면 기능이 늘어날수록 방어 코드가 늘어나고, 컴포넌트는 비대해지죠.
이번 개선을 통해 제어 흐름을 프레임워크에 위임하는 것이 얼마나 유용한지 다시 배웠으며, 앞으로도 선언적이고 예측 가능한 코드로 안정적인 사용자 경험을 제공할 수 있게끔 꾸준히 고민하고 개선해나가려 합니다.