From 1adb20fafb90a5d9f9d86e6f33e6db864c5a65c1 Mon Sep 17 00:00:00 2001 From: Cyjin-jani Date: Fri, 1 May 2026 20:50:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=AC=B8=EC=84=9C=20=EC=B4=88=EC=95=88=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/.vitepress/config.mts | 4 + docs/async-error-handling/boundary-design.md | 102 +++++++ .../declarative-async-basics.md | 269 ++++++++++++++++++ .../error-handling-strategy.md | 203 +++++++++++++ docs/async-error-handling/index.md | 9 +- .../suspense-query-concurrency.md | 203 +++++++++++++ 6 files changed, 786 insertions(+), 4 deletions(-) create mode 100644 docs/async-error-handling/boundary-design.md create mode 100644 docs/async-error-handling/declarative-async-basics.md create mode 100644 docs/async-error-handling/error-handling-strategy.md create mode 100644 docs/async-error-handling/suspense-query-concurrency.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 074b9ce..f14b920 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -62,6 +62,10 @@ export default defineConfig({ text: '비동기 처리와 에러 핸들링', items: [ { text: '개요', link: '/async-error-handling/' }, + { text: '선언형 비동기 처리 기본', link: '/async-error-handling/declarative-async-basics' }, + { text: '에러 바운더리 설계', link: '/async-error-handling/boundary-design' }, + { text: '에러 처리 전략', link: '/async-error-handling/error-handling-strategy' }, + { text: 'Suspense + Query 동시성', link: '/async-error-handling/suspense-query-concurrency' }, ], }, ], diff --git a/docs/async-error-handling/boundary-design.md b/docs/async-error-handling/boundary-design.md new file mode 100644 index 0000000..b6490a5 --- /dev/null +++ b/docs/async-error-handling/boundary-design.md @@ -0,0 +1,102 @@ +# Boundary 설계와 실패 격리 전략 + +도메인 경계를 기준으로 Boundary를 배치해 한쪽의 실패가 다른 영역을 방해하지 않도록 에러 전파를 차단한다. 단, 비즈니스 로직상 함께 노출되어야 하는 데이터는 하나의 경계로 묶어 일관된 UX를 유지한다. + +## 규칙: 독립적인 영역은 Boundary를 분리해 부분 실패를 허용하세요 + +페이지 최상단에만 단일 Boundary를 두면, 부가적인 영역 하나의 실패가 전체 화면을 망가뜨린다. 필수 데이터와 부가 데이터의 경계를 나누어 한쪽의 실패가 다른 정상적인 영역의 렌더링을 방해하지 않도록 격리한다. + +:::tabs +== Bad + +```tsx +// 추천 목록 하나만 API 에러가 나도 페이지 전체가 작동 불능 +function DashboardPage() { + return ( + }> + + + {/* 👈 일부 기능의 장애가 전체 경험을 파괴함 */} + + ); +} +``` + +== Good + +```tsx +// 독립적인 도메인 단위로 Boundary를 분리해 가용성을 극대화 +function DashboardPage() { + return ( + + {/* 필수 데이터: 실패 시 메인 에러 화면 노출 */} + }> + + + + {/* 부가 데이터: 실패해도 나머지 기능은 사용 가능하도록 격리 */} + }> + + + + ); +} +``` + +::: + +## 규칙: 의미 단위(결합도/과업) 기준으로 Boundary를 묶어 파편화를 막으세요 + +반대로, 서로 강하게 연관된 데이터이거나, 사용자가 하나의 과업을 완료하기 위해 함께 필요한 정보라면 하나의 Boundary로 묶는다. +개별 Boundary로 과도하게 분리하면 로딩/에러 UI가 파편화되어, 화면은 일부 보이더라도 실제 사용자 과업 흐름이 끊길 수 있다. + +:::tabs +== Bad + +```tsx +// 연관된 데이터인데 각자 따로 로딩/에러 처리가 되어 UI가 파편화됨 +function ProductDetail() { + return ( + <> + }> + + + }> + + + }> + + + + ); +} +``` + +== Good + +```tsx +// 하나의 맥락으로 묶인 데이터는 하나의 Boundary에서 관리해 일관성 유지 +function ProductDetail() { + return ( + }> + {/* 과업에 필요한 모든 데이터가 준비된 뒤 일관된 화면을 한 번에 노출 */} + + + ); +} +``` + +::: + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +| ------------------------------------------------------------ | ---------------------------------------------------------------- | +| 페이지 최상단 단일 Boundary에 모든 컴포넌트를 넣음 | 필수/부가 데이터 기준으로 Boundary를 분리해 Partial Failure 허용 | +| 부가 데이터 하나의 에러로 전체 페이지가 에러 화면으로 전환됨 | 독립적인 영역은 별도 Boundary로 격리해 나머지 화면은 정상 유지 | +| 연관된 데이터를 각자 Boundary로 감싸 로딩/에러 UI가 파편화됨 | 과업 단위로 하나의 Boundary로 묶어 일관된 화면을 한 번에 노출 | + +## 참고 자료 + +- [React 공식 문서 - Suspense](https://react.dev/reference/react/Suspense) +- [react-error-boundary](https://github.com/bvaughn/react-error-boundary) diff --git a/docs/async-error-handling/declarative-async-basics.md b/docs/async-error-handling/declarative-async-basics.md new file mode 100644 index 0000000..5e8706d --- /dev/null +++ b/docs/async-error-handling/declarative-async-basics.md @@ -0,0 +1,269 @@ +# 선언적 비동기 처리 기본 원칙 + +UI 컴포넌트는 로딩/에러 분기까지 모두 떠안기보다, "성공적으로 데이터를 받은 상태(Happy Path)"를 표현하는 데 집중해야 한다. 비동기 상태 관리는 `Suspense`, `ErrorBoundary`, `AsyncBoundary`등을 사용하여 컴포넌트 외부(상위)로 위임한다. + +## 규칙: 비동기 상태 처리는 상위 경계에서 담당하세요 + +`isLoading`, `isError`, `data?` 같은 방어 코드가 컴포넌트 내부에 많아질수록 UI 의도가 흐려진다. 데이터가 필요한 컴포넌트는 "데이터가 준비되었다"는 전제 위에서 렌더링되도록 설계한다. 이때 하위 UI 컴포넌트는 `User | undefined` 같은 모호한 타입보다 `User`처럼 non-null 계약을 사용해 Happy Path를 명확히 드러낸다. + +:::tabs +== Bad + +```tsx +function UserProfile({ userId }: { userId: string }) { + const { data, isLoading, error } = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + + // UI 내부에 비동기 상태 분기 로직이 혼재되어 있다 + if (isLoading) return ; + if (error) return ; + + return
{data?.name}
; +} +``` + +== Good + +```tsx +import { Suspense } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +function UserProfile({ userId }: { userId: string }) { + // data 존재가 보장되어 Happy Path에만 집중할 수 있다 + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + + return
{data.name}
; +} + +function UserProfileWrapper({ userId }: { userId: string }) { + return ( + }> + }> + + + + ); +} +``` + +::: + +## 규칙: `useQuery`의 에러는 자동으로 ErrorBoundary에 전달되지 않아요 + +`useQuery`를 쓰면서 ErrorBoundary를 함께 배치했을 때 에러가 잡히지 않는 경우가 있다. TanStack Query는 기본적으로 에러를 ErrorBoundary로 전파하지 않고 `error` 상태로만 반환한다. ErrorBoundary로 위임하려면 `throwOnError: true`를 명시해야 한다. 단, 이 케이스는 `useSuspenseQuery`로 전환할 수 있다면 그쪽이 더 일관된 선택이다. + +:::tabs +== Bad + +```tsx +// ErrorBoundary를 배치했지만 에러가 잡히지 않는다 + + + + +function UserProfile({ userId }: { userId: string }) { + const { data, error } = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + // throwOnError가 없으면 에러는 error 변수에만 담기고 Boundary로 전파되지 않는다 + }); + ... +} +``` + +== Good + +```tsx +// throwOnError: true로 ErrorBoundary에 위임 +const { data } = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + throwOnError: true, +}); + +// 또는 useSuspenseQuery로 전환 +const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), +}); +``` + +::: + +## 규칙: `useSuspenseQuery`를 적용하기 어려운 케이스는 명시적 처리를 유지하세요 + +선언적 패턴을 무리하게 적용하려다 오히려 코드가 복잡해지는 경우가 있다. 아래 케이스는 `useSuspenseQuery` 대신 `useQuery`의 명시적 상태를 그대로 쓰는 게 자연스럽다: + +- **조건부 실행 쿼리**: `enabled: !!userId`처럼 특정 조건이 충족될 때만 실행되는 쿼리. `useSuspenseQuery`는 `enabled: false`를 지원하지 않는다. +- **사용자 인터랙션 후 실행되는 쿼리**: 검색어 입력 후 실행되는 쿼리처럼, 초기에는 결과가 없는 게 정상인 케이스. + +:::tabs +== Bad + +```tsx +// enabled를 우회하려고 억지로 Suspense 패턴에 끼워 맞춤 +function UserDetail({ userId }: { userId?: string }) { + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => { + if (!userId) throw new Error('no userId'); // ❌ 억지로 에러를 던져 Boundary에 위임 + return fetchUser(userId); + }, + }); + ... +} +``` + +== Good + +```tsx +// 조건부 쿼리는 useQuery + 명시적 분기가 더 명확하다 +function UserDetail({ userId }: { userId?: string }) { + const { data, isLoading } = useQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId!), + enabled: !!userId, + }); + + if (!userId || isLoading) return null; + return
{data?.name}
; +} +``` + +::: + +## 규칙: 반복되는 Boundary 중첩은 AsyncBoundary로 통합하세요 + +기본 원리를 이해한 뒤에는 반복되는 중첩을 추상화해 가독성을 높인다. 로딩과 에러 처리가 항상 같이 움직이는 구간은 두 경계를 감싸는 `AsyncBoundary` 컴포넌트로 묶어 보일러플레이트를 줄인다. + +:::tabs +== Bad + +```tsx +// 화면마다 같은 중첩 패턴이 반복된다 +function OrderPage() { + return ( + }> + }> + + + + ); +} + +function ProfilePage() { + return ( + }> + }> + + + + ); +} + +function DashboardPage() { + return ( + }> + }> + + + + ); +} +``` + +== Good + +```tsx +import { Suspense, ReactNode } from 'react'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; + +interface AsyncBoundaryProps { + pendingFallback: ReactNode; + rejectedFallback: (props: FallbackProps) => ReactNode; + children: ReactNode; +} + +function AsyncBoundary({ pendingFallback, rejectedFallback, children }: AsyncBoundaryProps) { + return ( + + {children} + + ); +} + +// 로딩/에러 정책을 하나의 선언으로 표현한다 +function OrderPage() { + return ( + } + rejectedFallback={({ error }) => } + > + + + ); +} +``` + +::: + +## 규칙: Mutation 에러는 경계(Boundary)가 아닌 호출부에서 직접 처리하세요 + +Mutation 에러는 Query 에러와 성격이 다르다. Query 에러는 해당 UI 영역 전체를 대체 UI로 교체해도 자연스럽지만, Mutation 에러는 폼이 살아있어야 하고 사용자가 즉시 수정 후 재시도할 수 있어야 한다. Boundary에 던지면 폼 영역 전체가 날아가 사용자 입력이 초기화되는 UX 문제가 생긴다. + +:::tabs +== Bad + +```tsx +function SubmitButton({ orderId }: { orderId: string }) { + const { mutate } = useMutation({ + mutationFn: submitOrder, + onError: (error) => { + throw error; + }, // ❌ Boundary가 폼 전체를 날려버린다 + }); + return ; +} +``` + +== Good + +```tsx +function SubmitButton({ orderId }: { orderId: string }) { + const { mutate, isError, error } = useMutation({ + mutationFn: submitOrder, + }); + + return ( + <> + {isError && } {/* 폼은 유지 */} + + + ); +} +``` + +::: + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +| ------------------------------------------------------ | ------------------------------------------------------------- | +| 컴포넌트마다 `isLoading`, `isError` 분기 반복 | `useSuspenseQuery` + 상위 `ErrorBoundary`/`Suspense`로 위임 | +| `user?: User` 같은 모호한 props 계약 사용 | 상위 경계에서 데이터 보장 후 UI 컴포넌트는 non-null 타입 사용 | +| `useQuery` 사용 중 ErrorBoundary에 에러가 잡히지 않음 | `throwOnError: true` 추가 또는 `useSuspenseQuery`로 전환 | +| `enabled` 조건을 `useSuspenseQuery`에 억지로 끼워 맞춤 | `useQuery` + 명시적 분기로 유지 | +| 중첩된 Boundary 패턴이 화면마다 복붙됨 | `AsyncBoundary`로 로딩/에러 정책을 단일 인터페이스로 통합 | +| Mutation `onError`에서 에러를 throw해 Boundary로 위임 | `isError` + 인라인 에러 메시지로 호출부에서 직접 처리 | + +## 참고 자료 + +- [React 공식 문서 - Suspense](https://react.dev/reference/react/Suspense) +- [TanStack Query - Suspense](https://tanstack.com/query/latest/docs/framework/react/guides/suspense) +- [react-error-boundary](https://github.com/bvaughn/react-error-boundary) diff --git a/docs/async-error-handling/error-handling-strategy.md b/docs/async-error-handling/error-handling-strategy.md new file mode 100644 index 0000000..0556bf5 --- /dev/null +++ b/docs/async-error-handling/error-handling-strategy.md @@ -0,0 +1,203 @@ +# 에러 대응 및 복구 전략 + +에러가 발생했을 때 모든 에러를 동일하게 처리하면 사용자 경험이 어색해진다. 에러의 성격에 따라 전역(Global)과 지역(Local) 처리를 분리하고, 사용자가 직접 복구할 수 있는 Retry 경험을 선언적으로 제공한다. + +## 규칙: 에러 성격에 따라 Global과 Local 처리를 분리하세요 + +모든 에러를 ErrorBoundary로 던지는 것이 정답은 아니다. 401(인증 에러)처럼 화면 전환이 필요한 에러는 전역 Interceptor에서 처리하고, 500번대 서버 에러나 도메인 에러처럼 해당 영역에 에러 UI를 노출해야 하는 경우만 ErrorBoundary로 위임한다. + +:::tabs +== Bad + +```tsx +// 모든 에러를 무조건 ErrorBoundary로 던진다 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + throwOnError: true, + }, + }, +}); +``` + +== Good + +```tsx +// 전역 Interceptor에서 인증/권한 에러를 처리한다 +axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (isAxiosError(error) && error.response?.status === 401) { + // 로그인 페이지로 이동하거나 토큰 갱신 로직 실행 + redirectToLogin(); + } + return Promise.reject(error); + }, +); + +// throwOnError에서 인증/권한 에러는 Boundary로 넘기지 않는다 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + throwOnError: (error) => { + if (isAxiosError(error) && error.response?.status === 401) { + return false; // Interceptor에서 이미 처리됨 + } + return true; // 나머지 에러는 지역 Boundary로 위임 + }, + }, + }, +}); +``` + +::: + +## 규칙: 선언적 Retry 패턴으로 사용자 복구 경험을 제공하세요 + +일시적인 네트워크 오류가 발생했을 때, 새로고침 없이 해당 영역만 다시 요청할 수 있는 경험을 제공해야 한다. `QueryErrorResetBoundary`와 `ErrorBoundary`를 결합하면 실패한 쿼리만 재시도하는 선언적 Retry 패턴을 구성할 수 있다. + +:::tabs +== Bad + +```tsx +// 새로고침 외에는 에러 상태에서 벗어날 방법이 없다 +function ErrorFallback({ error }: { error: Error }) { + return
문제가 발생했습니다: {error.message}
; +} +``` + +== Good + +```tsx +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ErrorBoundary } from 'react-error-boundary'; + +// QueryErrorResetBoundary로 실패한 쿼리 캐시를 초기화하고 재요청 +function RetryableBoundary({ + children, + resetKeys, +}: { + children: ReactNode; + resetKeys?: unknown[]; +}) { + return ( + + {({ reset }) => ( + ( + + )} + > + {children} + + )} + + ); +} +``` + +::: + +## 규칙: 에러 메시지는 사용자 관점에서 작성하세요 + +에러 UI에 기술적인 메시지를 그대로 노출하면 사용자는 무엇을 해야 할지 알 수 없다. 에러 성격에 따라 사용자가 이해할 수 있는 메시지와 다음 행동(Retry, 홈으로 이동 등)을 함께 제공해야 한다. 이때 분기 로직은 컴포넌트 외부로 분리해 ErrorFallback은 렌더링에만 집중하도록 한다. + +:::tabs +== Bad + +```tsx +// 기술적인 에러 정보를 그대로 노출하고, 분기 로직이 컴포넌트에 혼재된다 +function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { + const isNetworkError = isAxiosError(error) && !error.response; + const isServerError = isAxiosError(error) && (error.response?.status ?? 0) >= 500; + + if (isNetworkError) { ... } + if (isServerError) { ... } + return
{error.message}
; +} +``` + +== Good + +```tsx +// 에러 분류 로직을 외부로 분리한다 +type ErrorType = 'network' | 'server' | 'unknown'; + +function classifyError(error: unknown): ErrorType { + if (isAxiosError(error) && !error.response) return 'network'; + if (isAxiosError(error) && (error.response?.status ?? 0) >= 500) return 'server'; + return 'unknown'; +} + +const ERROR_MESSAGES: Record = { + network: '네트워크 연결을 확인해 주세요.', + server: '일시적인 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', + unknown: '요청을 처리할 수 없습니다.', +}; + +// ErrorFallback은 분류 결과만 받아 렌더링에 집중한다 +function ErrorFallback({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + const errorType = classifyError(error); + const message = ERROR_MESSAGES[errorType]; + + return ( +
+ {message} + {onRetry && errorType !== 'unknown' && } + {errorType === 'unknown' && 홈으로 이동} +
+ ); +} +``` + +::: + +## 규칙: 에러 경계 초기화 시점을 resetKeys로 명시적으로 제어하세요 + +ErrorBoundary는 한 번 에러를 잡으면 에러 상태가 유지된다. 필터나 검색어처럼 사용자 조작으로 상태가 바뀌었을 때 이전 에러가 잔존하면 UX가 어색해진다. `RetryableBoundary`의 `resetKeys`에 실제 관리하는 queryKey를 넘기면 해당 쿼리가 변경될 때 ErrorBoundary가 자동으로 리셋된다. + +:::tabs +== Bad + +```tsx +// 필터가 바뀌어도 이전 에러 상태가 그대로 남아있다 +function ProductList({ category }: { category: string }) { + return ( + + + + ); +} +``` + +== Good + +```tsx +// 실제 관리하는 queryKey를 넘겨 어떤 쿼리가 리셋 기준인지 명시적으로 표현한다 +function ProductList({ category }: { category: string }) { + return ( + + + + ); +} +``` + +::: + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +| --------------------------------------------------------- | ----------------------------------------------------------------------- | +| 모든 에러를 `throwOnError: true`로 Boundary에 위임 | `throwOnError`에 조건 함수를 사용해 에러 성격별로 처리 전략을 분리 | +| 401 인증 에러가 ErrorBoundary로 넘어가 에러 화면이 노출됨 | 인증/권한 에러는 전역 Interceptor에서 처리하고 Boundary로 위임하지 않음 | +| 에러 발생 후 새로고침 없이는 복구할 방법이 없음 | `QueryErrorResetBoundary` + `ErrorBoundary`로 선언적 Retry 패턴 구성 | +| 에러 분기 로직이 ErrorFallback 컴포넌트에 혼재됨 | 에러 분류 함수를 외부로 분리하고 ErrorFallback은 렌더링에만 집중 | +| 상태 변경 후에도 이전 에러가 ErrorBoundary에 잔존 | `RetryableBoundary`의 `resetKeys`에 초기화 기준 값을 넘겨 자동 리셋 | + +## 참고 자료 + +- [TanStack Query - QueryErrorResetBoundary](https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary) +- [react-error-boundary](https://github.com/bvaughn/react-error-boundary) diff --git a/docs/async-error-handling/index.md b/docs/async-error-handling/index.md index 22c5801..de95290 100644 --- a/docs/async-error-handling/index.md +++ b/docs/async-error-handling/index.md @@ -2,8 +2,9 @@ 선언적 프로그래밍으로 비동기 상태와 에러를 체계적으로 관리합니다. -## 다루는 내용 +## 지침 목록 -- **Suspense와 ErrorBoundary**: 선언적으로 로딩과 에러 상태를 처리합니다 -- **AsyncBoundary**: 로딩과 에러를 계층별로 격리하여 서비스 가용성을 확보합니다 -- **에러 대응 전략**: Global(공통 에러) vs Local(도메인 에러) 에러 코드에 따른 대응 전략을 세웁니다 +- [선언적 비동기 처리 기본 원칙](./declarative-async-basics): Suspense, ErrorBoundary, AsyncBoundary를 활용해 로딩/에러 상태를 컴포넌트 외부로 위임하고 Happy Path 중심으로 UI를 구성합니다. +- [Boundary 설계와 실패 격리 전략](./boundary-design): 도메인 경계를 기준으로 Boundary를 배치해 Partial Failure를 허용하고, 결합도가 높은 화면은 하나의 경계로 묶어 일관된 UX를 유지합니다. +- [다중 쿼리 성능 최적화](./suspense-query-concurrency): Suspense 환경의 Waterfall을 방지하고, 병렬 요청 전략과 부분 실패 대응 방법을 다룹니다. +- [에러 대응 및 복구 전략](./error-handling-strategy): 에러 성격에 따라 Global/Local 처리를 분리하고, 선언적 Retry 패턴으로 사용자 복구 경험을 제공합니다. diff --git a/docs/async-error-handling/suspense-query-concurrency.md b/docs/async-error-handling/suspense-query-concurrency.md new file mode 100644 index 0000000..6657838 --- /dev/null +++ b/docs/async-error-handling/suspense-query-concurrency.md @@ -0,0 +1,203 @@ +# 다중 쿼리 성능 최적화 + +Suspense 환경에서 다중 쿼리를 다룰 때는 요청 시작 시점과 컴포넌트 배치가 성능을 결정한다. 핵심은 Waterfall을 만들지 않는 것이다. + +## 규칙: Suspense 다중 쿼리 Waterfall은 "호출 위치" 기준으로 제거하세요 + +Suspense 환경의 Waterfall은 주로 두 패턴에서 발생한다. + +- 패턴 A: 한 컴포넌트 안에서 독립 쿼리를 순차 호출 +- 패턴 B: 부모-자식 렌더 순서로 자식 쿼리가 늦게 시작 + +두 경우 모두 해결 원칙은 같다. 독립 쿼리를 가능한 같은 시점에 시작하고, 하위 컴포넌트는 조회보다 렌더링에 집중시킨다. + +### 패턴 A: same component waterfall + +:::tabs +== Bad + +```tsx +// 같은 컴포넌트 안에서 순차 호출되어 두 번째 요청 시작이 지연됨 +function ReservationDetailData({ id }: { id: string }) { + const { data: reservation } = useSuspenseQuery(reservationQueryOptions(id)); + const { data: rooms } = useSuspenseQuery(roomsQueryOptions()); + + const roomName = rooms.find((room) => room.id === reservation.roomId)?.name ?? reservation.roomId; + + return ; +} +``` + +== Good + +```tsx +// SuspenseQueries: @suspensive/react-query-5에서 제공하는 컴포넌트형 유틸리티. +// TanStack Query의 useSuspenseQueries와 동일한 목적(병렬 시작)을 렌더-프롭 형태로 사용할 수 있다. + +// 독립 쿼리를 동시에 시작해 Waterfall을 제거 +function ReservationDetailData({ id }: { id: string }) { + return ( + + {([{ data: reservation }, { data: rooms }]) => { + const roomName = + rooms.find((room) => room.id === reservation.roomId)?.name ?? reservation.roomId; + + return ; + }} + + ); +} +``` + +::: + +### 패턴 B: parent-child waterfall + +아래 예시는 두 쿼리가 **서로 독립적인 경우**다. 부모 쿼리 결과에 자식 쿼리가 의존하는 경우(예: 부모에서 받은 `userId`로 자식 쿼리를 시작해야 하는 경우)는 순차 실행이 구조적으로 불가피하며, 이는 Waterfall이 아닌 **의존성**이다. 이 경우에는 라우트 진입 시점에 미리 요청하는 prefetch 전략이나, `enabled` 옵션으로 선행 데이터가 준비된 시점에 쿼리를 시작하는 방식을 고려할 수 있다. + +:::tabs +== Bad + +```tsx +// 부모 렌더 후 자식이 마운트되며 자식 쿼리가 늦게 시작됨 +function ProfilePage() { + const { data: user } = useSuspenseQuery(userQueryOptions()); + + return ( +
+ + +
+ ); +} + +function UserPosts() { + const { data: posts } = useSuspenseQuery(userPostsQueryOptions()); + return ; +} +``` + +== Good + +```tsx +// 상위에서 독립적인 두 쿼리를 함께 시작하고 자식에는 데이터만 전달 +function ProfilePage() { + const [{ data: user }, { data: posts }] = useSuspenseQueries({ + queries: [userQueryOptions(), userPostsQueryOptions()], + }); + + return ( +
+ + +
+ ); +} +``` + +::: + +## 규칙: 병렬 쿼리 중 일부 실패를 허용해야 한다면 Boundary 분리 또는 useQueries를 선택하세요 + +`useSuspenseQueries`는 하나라도 실패하면 전체가 ErrorBoundary로 위임된다. 병렬 요청 중 일부 실패를 허용해야 하는 케이스라면 목적에 따라 두 가지 전략을 선택한다. + +- **UI가 영역으로 분리 가능하다면** → 케이스 A: 각 영역을 컴포넌트로 분리하고 Boundary로 감싸 독립적으로 실패를 격리한다. +- **하나의 컴포넌트로 통합되어 있고 일부 데이터 없이도 렌더링이 가능하다면** → 케이스 B: `useQueries`로 각 쿼리 상태를 개별 핸들링한다. 단, 이 방식은 컴포넌트 내부에 상태 분기가 다시 생기는 트레이드오프가 있다. 선언적 패턴이 적용되는 일반적인 케이스가 아닌, 부분 실패 허용이 꼭 필요한 경우에만 선택한다. + +### 케이스 A: 실패한 영역만 에러 UI로 교체하고 나머지는 정상 노출 + +쿼리를 컴포넌트로 분리하고 각자 Boundary로 감싸면, 하나가 실패해도 나머지 영역은 영향을 받지 않는다. + +:::tabs +== Bad + +```tsx +// useSuspenseQueries로 묶으면 recommendations 하나의 실패가 전체를 날린다 +function DashboardPage() { + const [{ data: user }, { data: recommendations }] = useSuspenseQueries({ + queries: [userQueryOptions(), recommendationsQueryOptions()], + }); + + return ( +
+ + +
+ ); +} +``` + +== Good + +```tsx +// 컴포넌트를 분리하고 각자 Boundary로 감싸 독립적으로 실패를 격리 +function DashboardPage() { + return ( +
+ }> + + + }> + + +
+ ); +} +``` + +::: + +### 케이스 B: 성공한 데이터는 그대로 쓰고 실패한 쿼리만 개별 핸들링 + +UI가 하나의 컴포넌트에 통합되어 있고 일부 데이터가 없어도 렌더링이 가능한 경우라면, `useQueries`로 각 쿼리의 상태를 개별적으로 핸들링한다. 이 방식은 컴포넌트 내부에 상태 분기가 다시 생기므로, 부분 실패 허용이 꼭 필요한 경우에만 선택한다. + +:::tabs +== Bad + +```tsx +// useSuspenseQueries는 하나가 실패하면 전체가 ErrorBoundary로 빠진다 +function DashboardView() { + const [{ data: stats }, { data: recentActivity }] = useSuspenseQueries({ + queries: [statsQueryOptions(), recentActivityQueryOptions()], + }); + + return ; +} +``` + +== Good + +```tsx +// useQueries로 각 쿼리 상태를 개별 핸들링해 성공한 데이터는 그대로 활용 +// 선언적 패턴의 예외 케이스로, 부분 실패 허용이 필요할 때만 선택한다 +function DashboardView() { + const [statsQuery, recentActivityQuery] = useQueries({ + queries: [statsQueryOptions(), recentActivityQueryOptions()], + }); + + return ( + + ); +} +``` + +::: + +## 빠른 참조 + +| 코드 냄새 | 개선 방법 | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| 독립 쿼리를 `useSuspenseQuery`로 순차 호출 | `SuspenseQueries` 또는 `useSuspenseQueries`로 병렬 시작 | +| 부모/자식에서 독립 쿼리가 순차 실행되어 지연 발생 | 상위에서 쿼리를 함께 시작하고 자식에는 데이터 전달 | +| 부모 쿼리 결과에 의존하는 자식 쿼리의 순차 실행을 Waterfall로 오해 | 의존성 있는 순차 실행은 불가피하며, prefetch 또는 `enabled` 활용을 고려 | +| `useSuspenseQueries` 사용 중 일부 실패로 전체가 에러 화면으로 전환됨 | 영역 분리 가능하면 Boundary 분리, 통합 컴포넌트라면 `useQueries`로 개별 핸들링 | + +## 참고 자료 + +- [TanStack Query - Suspense](https://tanstack.com/query/latest/docs/framework/react/guides/suspense) +- [TanStack Query - useSuspenseQueries](https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQueries) From 5208ce997123ee3e11c6821568a56b7587642246 Mon Sep 17 00:00:00 2001 From: Cyjin-jani Date: Fri, 1 May 2026 22:07:10 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=9D=BC=EB=B6=80=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/async-error-handling/boundary-design.md | 9 ++++-- .../declarative-async-basics.md | 4 +-- .../error-handling-strategy.md | 31 ++++++++++++------- docs/async-error-handling/index.md | 2 ++ .../suspense-query-concurrency.md | 6 ++-- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/async-error-handling/boundary-design.md b/docs/async-error-handling/boundary-design.md index b6490a5..c9dbbf7 100644 --- a/docs/async-error-handling/boundary-design.md +++ b/docs/async-error-handling/boundary-design.md @@ -13,7 +13,7 @@ // 추천 목록 하나만 API 에러가 나도 페이지 전체가 작동 불능 function DashboardPage() { return ( - }> + } errorFallback={}> {/* 👈 일부 기능의 장애가 전체 경험을 파괴함 */} @@ -30,12 +30,15 @@ function DashboardPage() { return ( {/* 필수 데이터: 실패 시 메인 에러 화면 노출 */} - }> + } errorFallback={}> {/* 부가 데이터: 실패해도 나머지 기능은 사용 가능하도록 격리 */} - }> + } + errorFallback={} + > diff --git a/docs/async-error-handling/declarative-async-basics.md b/docs/async-error-handling/declarative-async-basics.md index 5e8706d..a2aacb5 100644 --- a/docs/async-error-handling/declarative-async-basics.md +++ b/docs/async-error-handling/declarative-async-basics.md @@ -226,7 +226,7 @@ function SubmitButton({ orderId }: { orderId: string }) { mutationFn: submitOrder, onError: (error) => { throw error; - }, // ❌ Boundary가 폼 전체를 날려버린다 + }, // ❌ onError에서 throw는 Boundary에 안전하게 연결된다고 보기 어렵고, 폼/UI 복구 흐름도 깨지기 쉽다 }); return ; } @@ -260,7 +260,7 @@ function SubmitButton({ orderId }: { orderId: string }) { | `useQuery` 사용 중 ErrorBoundary에 에러가 잡히지 않음 | `throwOnError: true` 추가 또는 `useSuspenseQuery`로 전환 | | `enabled` 조건을 `useSuspenseQuery`에 억지로 끼워 맞춤 | `useQuery` + 명시적 분기로 유지 | | 중첩된 Boundary 패턴이 화면마다 복붙됨 | `AsyncBoundary`로 로딩/에러 정책을 단일 인터페이스로 통합 | -| Mutation `onError`에서 에러를 throw해 Boundary로 위임 | `isError` + 인라인 에러 메시지로 호출부에서 직접 처리 | +| Mutation `onError`에서 에러를 throw하는 패턴 | `isError` + 인라인 에러 메시지 등으로 호출부에서 직접 처리 | ## 참고 자료 diff --git a/docs/async-error-handling/error-handling-strategy.md b/docs/async-error-handling/error-handling-strategy.md index 0556bf5..3723dda 100644 --- a/docs/async-error-handling/error-handling-strategy.md +++ b/docs/async-error-handling/error-handling-strategy.md @@ -52,6 +52,8 @@ const queryClient = new QueryClient({ ::: +`throwOnError`에 함수를 넘겨 **`false`**를 반환하면 해당 에러는 throw되지 않고 `query.state.status === 'error'`로 남습니다. 보통의 경우, 인증 처리 후 UI가 정체되지 않도록 **invalidate/retry/redirect 플래그** 등 후속 처리가 필요하다. + ## 규칙: 선언적 Retry 패턴으로 사용자 복구 경험을 제공하세요 일시적인 네트워크 오류가 발생했을 때, 새로고침 없이 해당 영역만 다시 요청할 수 있는 경험을 제공해야 한다. `QueryErrorResetBoundary`와 `ErrorBoundary`를 결합하면 실패한 쿼리만 재시도하는 선언적 Retry 패턴을 구성할 수 있다. @@ -102,7 +104,7 @@ function RetryableBoundary({ ## 규칙: 에러 메시지는 사용자 관점에서 작성하세요 -에러 UI에 기술적인 메시지를 그대로 노출하면 사용자는 무엇을 해야 할지 알 수 없다. 에러 성격에 따라 사용자가 이해할 수 있는 메시지와 다음 행동(Retry, 홈으로 이동 등)을 함께 제공해야 한다. 이때 분기 로직은 컴포넌트 외부로 분리해 ErrorFallback은 렌더링에만 집중하도록 한다. +에러 UI에 기술적인 메시지를 그대로 노출하면 사용자는 무엇을 해야 할지 알 수 없다. 에러 성격에 따라 사용자가 이해할 수 있는 메시지와 다음 행동(Retry, 홈으로 이동 등)을 함께 제공해야 한다. **에러 분류·문구 선택·로깅 같은 “정책” 함수는 테스트하기 쉬운 순수 함수로 분리**하고, 컴포넌트에서는 그 결과만 받아 레이아웃을 렌더하는 것이 좋다. (아래 예시처럼 `ErrorFallback`이 `classifyError`를 호출하는 형태여도 무관함) :::tabs == Bad @@ -137,7 +139,7 @@ const ERROR_MESSAGES: Record = { unknown: '요청을 처리할 수 없습니다.', }; -// ErrorFallback은 분류 결과만 받아 렌더링에 집중한다 +// ErrorFallback은 메시지·액션 UI에 집중하고, 분류는 classifyError에 위임한다 function ErrorFallback({ error, onRetry }: { error: unknown; onRetry?: () => void }) { const errorType = classifyError(error); const message = ERROR_MESSAGES[errorType]; @@ -156,7 +158,9 @@ function ErrorFallback({ error, onRetry }: { error: unknown; onRetry?: () => voi ## 규칙: 에러 경계 초기화 시점을 resetKeys로 명시적으로 제어하세요 -ErrorBoundary는 한 번 에러를 잡으면 에러 상태가 유지된다. 필터나 검색어처럼 사용자 조작으로 상태가 바뀌었을 때 이전 에러가 잔존하면 UX가 어색해진다. `RetryableBoundary`의 `resetKeys`에 실제 관리하는 queryKey를 넘기면 해당 쿼리가 변경될 때 ErrorBoundary가 자동으로 리셋된다. +ErrorBoundary는 한 번 에러를 잡으면 상태가 고정되므로, 필터·검색어·페이지네이션 등 의미 있는 입력이 바뀌었을 때는 이전 에러를 초기화해야 한다. + +`react-error-boundary`의 `resetKeys` 비교는 참조 동등성이 아니라 “이전 값과 달라졌는지(얕은 비교)”를 본다. 따라서 **매 렌더마다 새 배열 참조가 만들어지는 값**은 `resetKeys`로 쓰지 않도록 하고, `resetKeys`에는 **안정적인 원시 키**나, 같은 의미 단위 내에서 참조가 안정화된 객체를 넘기도록 한다. :::tabs == Bad @@ -175,27 +179,30 @@ function ProductList({ category }: { category: string }) { == Good ```tsx -// 실제 관리하는 queryKey를 넘겨 어떤 쿼리가 리셋 기준인지 명시적으로 표현한다 +// 의미 단위 식별자(원시값)를 넘겨 입력이 바뀔 때만 ErrorBoundary가 리셋되게 한다 function ProductList({ category }: { category: string }) { return ( - + ); } + +// queryKey 변경과 동일한 순간만 리셋하려면 원시 문자열화를 검토할 수 있다 +// resetKeys={[JSON.stringify(productsQueryOptions(category).queryKey)]} ``` ::: ## 빠른 참조 -| 코드 냄새 | 개선 방법 | -| --------------------------------------------------------- | ----------------------------------------------------------------------- | -| 모든 에러를 `throwOnError: true`로 Boundary에 위임 | `throwOnError`에 조건 함수를 사용해 에러 성격별로 처리 전략을 분리 | -| 401 인증 에러가 ErrorBoundary로 넘어가 에러 화면이 노출됨 | 인증/권한 에러는 전역 Interceptor에서 처리하고 Boundary로 위임하지 않음 | -| 에러 발생 후 새로고침 없이는 복구할 방법이 없음 | `QueryErrorResetBoundary` + `ErrorBoundary`로 선언적 Retry 패턴 구성 | -| 에러 분기 로직이 ErrorFallback 컴포넌트에 혼재됨 | 에러 분류 함수를 외부로 분리하고 ErrorFallback은 렌더링에만 집중 | -| 상태 변경 후에도 이전 에러가 ErrorBoundary에 잔존 | `RetryableBoundary`의 `resetKeys`에 초기화 기준 값을 넘겨 자동 리셋 | +| 코드 냄새 | 개선 방법 | +| --------------------------------------------------------- | ------------------------------------------------------------------------------ | +| 모든 에러를 `throwOnError: true`로 Boundary에 위임 | `throwOnError`에 조건 함수를 사용해 에러 성격별로 처리 전략을 분리 | +| 401 인증 에러가 ErrorBoundary로 넘어가 에러 화면이 노출됨 | 인증/권한 에러는 전역 Interceptor에서 처리하고 Boundary로 위임하지 않음 | +| 에러 발생 후 새로고침 없이는 복구할 방법이 없음 | `QueryErrorResetBoundary` + `ErrorBoundary`로 선언적 Retry 패턴 구성 | +| 에러 분기 로직이 ErrorFallback 컴포넌트에 혼재됨 | 정책(분류·문구)은 순수 함수로 분리하고 ErrorFallback은 메시지·액션 UI에 집중 | +| 상태 변경 후에도 이전 에러가 ErrorBoundary에 잔존 | `resetKeys`에 `category` 등 안정 원시 키를 넘기고, 매 렌더 새 배열 참조는 피함 | ## 참고 자료 diff --git a/docs/async-error-handling/index.md b/docs/async-error-handling/index.md index de95290..76d6e77 100644 --- a/docs/async-error-handling/index.md +++ b/docs/async-error-handling/index.md @@ -2,6 +2,8 @@ 선언적 프로그래밍으로 비동기 상태와 에러를 체계적으로 관리합니다. +현재 각 지침은 React Query Suspense 패턴과 `ErrorBoundary`를 전제로 합니다. 팀 표준 패키지(예: `@suspensive/react-query` 사용 여부), axios/fetch, 라우팅 라이브러리에 따라 전역 처리 지점만 조정하면 됩니다. + ## 지침 목록 - [선언적 비동기 처리 기본 원칙](./declarative-async-basics): Suspense, ErrorBoundary, AsyncBoundary를 활용해 로딩/에러 상태를 컴포넌트 외부로 위임하고 Happy Path 중심으로 UI를 구성합니다. diff --git a/docs/async-error-handling/suspense-query-concurrency.md b/docs/async-error-handling/suspense-query-concurrency.md index 6657838..22fbd7e 100644 --- a/docs/async-error-handling/suspense-query-concurrency.md +++ b/docs/async-error-handling/suspense-query-concurrency.md @@ -31,8 +31,8 @@ function ReservationDetailData({ id }: { id: string }) { == Good ```tsx -// SuspenseQueries: @suspensive/react-query-5에서 제공하는 컴포넌트형 유틸리티. -// TanStack Query의 useSuspenseQueries와 동일한 목적(병렬 시작)을 렌더-프롭 형태로 사용할 수 있다. +// 예시의 는 커뮤니티/팀 패키지(예: @suspensive/react-query 계열)에서 제공하는 패턴입니다. +// 패키지를 쓰지 않는 경우 동일 목적은 useSuspenseQueries({ queries: [...] }) 한 번의 호출로 달성할 수 있습니다. // 독립 쿼리를 동시에 시작해 Waterfall을 제거 function ReservationDetailData({ id }: { id: string }) { @@ -53,7 +53,7 @@ function ReservationDetailData({ id }: { id: string }) { ### 패턴 B: parent-child waterfall -아래 예시는 두 쿼리가 **서로 독립적인 경우**다. 부모 쿼리 결과에 자식 쿼리가 의존하는 경우(예: 부모에서 받은 `userId`로 자식 쿼리를 시작해야 하는 경우)는 순차 실행이 구조적으로 불가피하며, 이는 Waterfall이 아닌 **의존성**이다. 이 경우에는 라우트 진입 시점에 미리 요청하는 prefetch 전략이나, `enabled` 옵션으로 선행 데이터가 준비된 시점에 쿼리를 시작하는 방식을 고려할 수 있다. +아래 예시는 두 쿼리가 **서로 독립적인 경우**다. 부모 쿼리 결과에 자식 쿼리가 의존하는 경우(예: 부모에서 받은 `userId`로 자식 쿼리를 시작해야 하는 경우)는 순차 실행이 구조적으로 불가피하며, 이는 Waterfall이 아닌 **의존성**이다. 의존성이 있는 순차 실행은 피하기 어렵기 때문에 별도의 개선이 필요한데, **의존 그래프를 줄이는 API 설계**, **라우트/상위에서의 prefetch**, **`placeholderData`/`initialData`로 체감 대기 축소** 같은 방법을 검토하고, `enabled` 옵션으로 선행 데이터가 준비된 시점에 쿼리를 시작하는 방식을 고려할 수 있다. :::tabs == Bad