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..c9dbbf7
--- /dev/null
+++ b/docs/async-error-handling/boundary-design.md
@@ -0,0 +1,105 @@
+# Boundary 설계와 실패 격리 전략
+
+도메인 경계를 기준으로 Boundary를 배치해 한쪽의 실패가 다른 영역을 방해하지 않도록 에러 전파를 차단한다. 단, 비즈니스 로직상 함께 노출되어야 하는 데이터는 하나의 경계로 묶어 일관된 UX를 유지한다.
+
+## 규칙: 독립적인 영역은 Boundary를 분리해 부분 실패를 허용하세요
+
+페이지 최상단에만 단일 Boundary를 두면, 부가적인 영역 하나의 실패가 전체 화면을 망가뜨린다. 필수 데이터와 부가 데이터의 경계를 나누어 한쪽의 실패가 다른 정상적인 영역의 렌더링을 방해하지 않도록 격리한다.
+
+:::tabs
+== Bad
+
+```tsx
+// 추천 목록 하나만 API 에러가 나도 페이지 전체가 작동 불능
+function DashboardPage() {
+ return (
+ } errorFallback={}>
+
+
+ {/* 👈 일부 기능의 장애가 전체 경험을 파괴함 */}
+
+ );
+}
+```
+
+== Good
+
+```tsx
+// 독립적인 도메인 단위로 Boundary를 분리해 가용성을 극대화
+function DashboardPage() {
+ return (
+
+ {/* 필수 데이터: 실패 시 메인 에러 화면 노출 */}
+ } errorFallback={}>
+
+
+
+ {/* 부가 데이터: 실패해도 나머지 기능은 사용 가능하도록 격리 */}
+ }
+ errorFallback={}
+ >
+
+
+
+ );
+}
+```
+
+:::
+
+## 규칙: 의미 단위(결합도/과업) 기준으로 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..a2aacb5
--- /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;
+ }, // ❌ onError에서 throw는 Boundary에 안전하게 연결된다고 보기 어렵고, 폼/UI 복구 흐름도 깨지기 쉽다
+ });
+ 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하는 패턴 | `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..3723dda
--- /dev/null
+++ b/docs/async-error-handling/error-handling-strategy.md
@@ -0,0 +1,210 @@
+# 에러 대응 및 복구 전략
+
+에러가 발생했을 때 모든 에러를 동일하게 처리하면 사용자 경험이 어색해진다. 에러의 성격에 따라 전역(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로 위임
+ },
+ },
+ },
+});
+```
+
+:::
+
+`throwOnError`에 함수를 넘겨 **`false`**를 반환하면 해당 에러는 throw되지 않고 `query.state.status === 'error'`로 남습니다. 보통의 경우, 인증 처리 후 UI가 정체되지 않도록 **invalidate/retry/redirect 플래그** 등 후속 처리가 필요하다.
+
+## 규칙: 선언적 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`이 `classifyError`를 호출하는 형태여도 무관함)
+
+:::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은 메시지·액션 UI에 집중하고, 분류는 classifyError에 위임한다
+function ErrorFallback({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
+ const errorType = classifyError(error);
+ const message = ERROR_MESSAGES[errorType];
+
+ return (
+
+ );
+}
+```
+
+:::
+
+## 규칙: 에러 경계 초기화 시점을 resetKeys로 명시적으로 제어하세요
+
+ErrorBoundary는 한 번 에러를 잡으면 상태가 고정되므로, 필터·검색어·페이지네이션 등 의미 있는 입력이 바뀌었을 때는 이전 에러를 초기화해야 한다.
+
+`react-error-boundary`의 `resetKeys` 비교는 참조 동등성이 아니라 “이전 값과 달라졌는지(얕은 비교)”를 본다. 따라서 **매 렌더마다 새 배열 참조가 만들어지는 값**은 `resetKeys`로 쓰지 않도록 하고, `resetKeys`에는 **안정적인 원시 키**나, 같은 의미 단위 내에서 참조가 안정화된 객체를 넘기도록 한다.
+
+:::tabs
+== Bad
+
+```tsx
+// 필터가 바뀌어도 이전 에러 상태가 그대로 남아있다
+function ProductList({ category }: { category: string }) {
+ return (
+
+
+
+ );
+}
+```
+
+== Good
+
+```tsx
+// 의미 단위 식별자(원시값)를 넘겨 입력이 바뀔 때만 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은 메시지·액션 UI에 집중 |
+| 상태 변경 후에도 이전 에러가 ErrorBoundary에 잔존 | `resetKeys`에 `category` 등 안정 원시 키를 넘기고, 매 렌더 새 배열 참조는 피함 |
+
+## 참고 자료
+
+- [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..76d6e77 100644
--- a/docs/async-error-handling/index.md
+++ b/docs/async-error-handling/index.md
@@ -2,8 +2,11 @@
선언적 프로그래밍으로 비동기 상태와 에러를 체계적으로 관리합니다.
-## 다루는 내용
+현재 각 지침은 React Query Suspense 패턴과 `ErrorBoundary`를 전제로 합니다. 팀 표준 패키지(예: `@suspensive/react-query` 사용 여부), axios/fetch, 라우팅 라이브러리에 따라 전역 처리 지점만 조정하면 됩니다.
-- **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..22fbd7e
--- /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
+// 예시의 는 커뮤니티/팀 패키지(예: @suspensive/react-query 계열)에서 제공하는 패턴입니다.
+// 패키지를 쓰지 않는 경우 동일 목적은 useSuspenseQueries({ queries: [...] }) 한 번의 호출로 달성할 수 있습니다.
+
+// 독립 쿼리를 동시에 시작해 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이 아닌 **의존성**이다. 의존성이 있는 순차 실행은 피하기 어렵기 때문에 별도의 개선이 필요한데, **의존 그래프를 줄이는 API 설계**, **라우트/상위에서의 prefetch**, **`placeholderData`/`initialData`로 체감 대기 축소** 같은 방법을 검토하고, `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)