7. 런타임 경계
화면 표시를 막는 로딩과 오류는 경계에서 처리합니다. Suspense와 오류 경계의 위치를 정하고, 경계가 처리할 분기는 화면 본문에 남기지 않습니다.
7.1 Suspense 경계는 섹션 소유자에 둡니다
초기 로딩을 섹션 소유자의 경계에서 처리합니다.
Suspense 쿼리를 쓰는 컴포넌트의 바로 위 섹션 소유자에 경계와 대체 화면을 둡니다.
쿼리를 호출하는 컴포넌트는 자기 자신을 경계로 감쌀 수 없습니다.
flowchart LR
q1{"쿼리를 호출하는 쪽이<br>섹션인가?"} -- 예 --> q2{"섹션이 독립적으로<br>채워져야 하는가?"} -- 예 --> r2("섹션마다 경계")
q1 -- 아니요 --> r3("레이아웃, 상위 라우트에 경계")
q2 -- 아니요 --> r1("라우트 진입에 경계 하나")| 상황 | 경계 위치 |
|---|---|
| 섹션이 따로 없음 | 라우트 진입 |
| 라우트 진입이 직접 쿼리를 호출함 | 해당 라우트의 레이아웃 또는 상위 라우트 |
| 섹션이 독립적으로 채워져야 함 | 이때만 경계를 나눕니다. 한 화면에 불필요하게 여러 겹 쌓지 않습니다 |
대체 화면의 컨테이너, 높이는 CSS 규칙 6.3을 따릅니다. 본문에 남은 로딩 분기는 규칙 7.2로 판단합니다.
진입에 경계가 없어 화면 전체가 함께 멈춥니다.
// page/products/pg-products.tsx: PgProductTreeSection이 Suspense 쿼리를 부르는데 감싸는 경계 없음
return <PgProductTreeSection />;
섹션 소유자가 경계와 대체 화면을 가집니다.
// page/products/pg-products.tsx: 쿼리를 부르는 섹션을 경계로 감쌈
return (
<Suspense fallback={<PgProductTreeSkeleton />}>
<PgProductTreeSection />
</Suspense>
);
한 화면에 경계를 여러 겹 쌓습니다.
// page/products/_pg-product-tree-section.tsx: 진입 파일에 이미 경계가 있는데 같은 쿼리를 다시 감쌈
export const PgProductTreeSection = () => {
return (
<Suspense fallback={<PgProductTreeSkeleton />}>
<PgProductTreeInner />
</Suspense>
);
};
라우트 진입이 직접 쿼리를 부르면 진입을 감싸는 레이아웃이 경계를 가집니다.
// page/products/pg-products.tsx: 섹션이 따로 없어 진입이 쿼리 호출. 경계는 이 진입을 그리는 셸 몫
export const PgProducts = () => {
const responseProductListSuspense = useProductListSuspense();
return <UiTable rows={responseProductListSuspense.data.items} />;
};
쿼리를 부르는 섹션은 자기 자신을 감싸지 않습니다.
export const PgProductTreeSection = () => {
/**
* 사이드바 분류 트리 조회. 이 쿼리가 멈추는 동안은 진입 파일의 경계가 처리
*/
const responseProductTreeSuspense = useProductTreeSuspense();
return <UiTree items={responseProductTreeSuspense.data.categoryNodes} />;
};함께 보기
- 7.2 화면 본문에 초기 로딩, 실패 분기를 추가하지 않습니다
- 6.3 런타임 경계가 있는 섹션만 화면 지역 컴포넌트로 추출합니다
- 7.3 오류 뒤에도 유지할 화면 범위에 맞춰 경계를 둡니다
- CSS 6.3 레이아웃의 기준과 역할을 클래스 이름과 선언에 드러냅니다
7.2 화면 본문에 초기 로딩, 실패 분기를 추가하지 않습니다
초기 로딩과 실패는 경계가 맡고 화면 본문에는 데이터가 있는 경로만 남습니다.
Suspense 쿼리의 초기 로딩은 경계나 상위 레이아웃이 처리하므로 화면 본문에서 다시 분기하지 않습니다.
| 플래그 | 사용 기준 |
|---|---|
Suspense 쿼리의 isPending | 타입이 false로 고정되어 분기가 죽은 코드입니다 |
쿼리의 isFetching | 백그라운드 재조회 표시처럼 이미 렌더된 화면을 보조할 때만 씁니다 |
쿼리의 isError | 초기 실패 대체 화면을 본문에 만들지 않습니다 |
뮤테이션의 isPending | 버튼 비활성화, 저장 중 배지 등에 씁니다 |
캐시가 있는 재조회 실패는 규칙 7.3를 따릅니다. 외부 SDK나 폼이 잘못된 값으로 초기화될 때만 본문에 가림 분기를 두고, TypeScript 규칙 6.5에 따라 이유를 남깁니다.
Suspense 쿼리의 isPending을 다시 분기합니다. 타입이 false라 죽은 코드입니다.
if (responseUserGetItemSuspense.isPending) {
return <UiSpinner />;
}
return <WgUserCardName value={responseUserGetItemSuspense.data.name} />;
초기 로딩은 경계가 받으므로 본문은 데이터가 있는 경로만 렌더합니다.
return <WgUserCardName value={responseUserGetItemSuspense.data.name} />;
다시 불러오는 중에 화면 전체를 가립니다.
if (responseUserGetItemSuspense.isFetching) {
return <UiSpinner />;
}
return <WgUserCardName value={responseUserGetItemSuspense.data.name} />;
갱신 상태는 이미 렌더된 화면을 보조하는 표시에만 씁니다.
return (
<Fragment>
<WgUserCardName value={responseUserGetItemSuspense.data.name} />
{responseUserGetItemSuspense.isFetching && <UiRefreshIndicator />}
</Fragment>
);
외부 SDK가 잘못 초기화되므로 이유 주석을 남기고 로딩 동안 가립니다.
// 결제 위젯은 마운트할 때 금액을 한 번만 읽음. 다시 불러오는 중에 그리면 옛 금액으로 초기화
if (responseOrderAmountSuspense.isFetching) {
return <PgOrderAmountLoadingScreen />;
}
return <PgPaymentWidgetSection amount={responseOrderAmountSuspense.data.confirmedAmount} />;
함께 보기
- 2.4 응답, 뮤테이션, 스토어는 JSX까지 원본 이름으로 읽습니다
- 6.4 파생값은 쓰는 자리 가까이에 둡니다
- TypeScript 5.1
??,||, 기본 매개변수 자리에 리터럴을 적지 않습니다
7.3 오류 뒤에도 유지할 화면 범위에 맞춰 경계를 둡니다
쿼리 실패를 정해진 경계에서 처리하고 필요한 화면을 유지합니다.
오류 경계는 실패 후에도 남겨야 할 화면 범위로 정합니다. 초기 실패를 받을 경계가 없으면 화면 전체가 빈 채로 남을 수 있으므로 앱 경계는 반드시 둡니다.
경계를 둘 층
flowchart LR
q1{"나머지 섹션만으로도<br>쓸모가 있는가?"} -- 아니요 --> q2{"내비게이션과 레이아웃 셸은<br>남겨야 하는가?"} -- 아니요 --> r3("앱 경계")
q1 -- 예 --> r1("섹션 경계")
q2 -- 예 --> r2("화면 경계")| 층 | 위치 | 오류 뒤 남는 화면 |
|---|---|---|
| 앱 | 루트에 한 번 | 없음. 마지막 안전망입니다 |
| 화면 | 라우트 진입 | 내비게이션과 레이아웃 셸 |
| 섹션 | Suspense 경계와 같은 소유자 | 같은 화면의 다른 섹션 |
목록 실패 후 옆 필터로 할 수 있는 일이 없다면 화면 경계로 충분합니다. 로딩, 오류 경계는 같은 소유자가 조립하며, 위치는 규칙 7.1를 따릅니다.
실패 상황별 처리
| 실패 상황 | 처리 |
|---|---|
| Suspense 쿼리에 표시할 캐시 데이터가 없음 | 렌더 중 던진 오류를 경계가 받습니다 |
| 기존 데이터가 있는 재조회 실패 | 기본적으로 데이터를 계속 보여 줍니다. 모든 실패를 경계로 보내야 할 때만 재조회가 끝난 뒤 명시적으로 던집니다 |
| 일반 이벤트 핸들러, 비동기 콜백 오류 | 경계가 자동으로 받지 않습니다. 사용자 액션은 규칙 2.5를 따릅니다 |
| 트랜지션 Action 오류, 라이브러리가 렌더에서 다시 던진 오류 | 일반 핸들러 오류와 구분합니다 |
경계 구현과 재시도
오류 포착 구현은 ui의 UiErrorBoundary에서 한 번만 제공하며,
react-error-boundary 같은 라이브러리에 맡길 수 있습니다.
데이터 라우터의 errorElement와 UiErrorBoundary를 같은 실패 범위에 겹쳐 쓰지 않습니다.
정상 화면은 유지하면서 오류 상태만 초기화하도록 URL 변경은 resetKeys에 연결하고, 경계의 key는 바꾸지 않습니다.
재시도 버튼은 대체 화면에서 오류 경계의 재시도 함수를 호출하고,
경계의 onReset에는 useQueryErrorResetBoundary의 reset을 연결합니다.
캐시가 있는 재조회 실패도 자동으로 경계에 전달된다고 가정합니다.
// 이 화면은 낡은 추천을 계속 보이면 안 되는데 재조회 실패를 던지지 않음
return <WgProductRecommendations items={responseProductRecommendationsSuspense.data.items} />;
낡은 데이터를 허용하지 않는 화면만 재조회 실패를 경계로 보냅니다.
// 추천을 확정하는 화면은 재조회 실패 시 이전 추천을 계속 선택하게 두지 않음
if (responseProductRecommendationsSuspense.error && !responseProductRecommendationsSuspense.isFetching) {
throw responseProductRecommendationsSuspense.error;
}
return <WgProductRecommendations items={responseProductRecommendationsSuspense.data.items} />;
경계 없이 화면 본문에서 실패를 분기합니다.
export const PgProducts = () => {
const responseProductListSuspense = useProductListSuspense();
if (responseProductListSuspense.isError) {
return <UiErrorState />;
}
return <UiTable rows={responseProductListSuspense.data.items} />;
};
화면 층 경계가 오류를 받으므로 본문에 성공 경로만 남깁니다.
export const PgProducts = () => {
/**
* 실패는 셸의 화면 층 경계가 처리. 본문은 성공 경로만 그림
*/
const responseProductListSuspense = useProductListSuspense();
return <UiTable rows={responseProductListSuspense.data.items} />;
};셸이 화면 층 오류 경계와 로딩 경계를 조립합니다.
export const WgAppShell = (props: WgAppShellProps) => {
return (
<div className={clsx("wg_appShell__root")}>
<WgAppNavigation />
<main className={clsx("wg_appShell__main")}>
<UiErrorBoundary fallback={<UiScreenErrorState />}>
<Suspense fallback={<UiScreenSkeleton />}>{props.children}</Suspense>
</UiErrorBoundary>
</main>
</div>
);
};나머지 섹션만으로도 쓸모가 있을 때만 섹션 경계를 둡니다.
export const PgProducts = () => {
return (
<div className={clsx("pg_products__layout")}>
<PgProductTreeSection />
{/**
* 추천 목록이 실패해도 본문 표는 그대로 사용 가능
*/}
<UiErrorBoundary fallback={<UiInlineErrorState />}>
<Suspense fallback={<UiRecommendationSkeleton />}>
<PgProductRecommendationSection />
</Suspense>
</UiErrorBoundary>
<PgProductTableSection />
</div>
);
};
재시도는 오류 경계와 쿼리 오류를 초기화하고 하위 트리를 다시 마운트합니다.
export const PgProductRecommendationBoundary = () => {
const queryErrorResetBoundary = useQueryErrorResetBoundary();
return (
<UiErrorBoundary
onReset={queryErrorResetBoundary.reset}
fallbackRender={(fallbackProps) => (
<UiInlineErrorState
retryLabel="다시 불러오기"
onRetry={fallbackProps.resetErrorBoundary}
/>
)}
>
<Suspense fallback={<UiRecommendationSkeleton />}>
<PgProductRecommendationSection />
</Suspense>
</UiErrorBoundary>
);
};
함께 보기