Frontend Error Architecture #6 - DAL, TanStack Query, 그리고 완성된 방어선
Data Access Layer와 TanStack Query에 투 트랙 전략을 실전 적용하고, HydrationBoundary·throwOnError로 완성된 에러 아키텍처 조감도를 그리기
📄 [6편] 실전 적용 — DAL, TanStack Query, 그리고 완성된 방어선
TL;DR
- DAL에서 터지는 인프라 에러는
handleServerError로 로깅 후 throw하여error.tsx가 처리해요.- SSR 초기 데이터는
dehydrate+HydrationBoundary를 사용해요.- TanStack Query에서
queryFn의 반환 타입은Result<T>이므로,data가 성공 또는 비즈니스 에러를 모두 포함한다는 점을 팀 규칙으로 명시해요.
1. Data Access Layer (DAL)
// entities/document/api/queries.ts
import "server-only";
import { makeError } from "@/error/make-error";
import { actionSuccess, actionFailure, type Result } from "@/error/result";
import { handleServerError } from "@/error/handler.server";
export const getDocument = async (
docId: string,
): Promise<Result<DocumentDTO>> => {
try {
const doc = await db.collection("documents").doc(docId).get();
if (!doc.exists) {
return actionFailure(
makeError({
code: "NOT_FOUND",
details: { resource: "document" },
message: "요청하신 문서를 찾을 수 없습니다.",
}),
);
}
return actionSuccess(doc.data() as DocumentDTO);
} catch (error) {
// "모든 처리는 handleError로" — 서버에서도 예외 없음
handleServerError(error, { ux: "none" });
throw new Error("데이터를 불러오는 중 문제가 발생했습니다.", {
cause: error,
});
}
};Server Component에서의 활용:
// app/documents/[docId]/page.tsx
import { handleServerError } from "@/error/handler.server";
export default async function DocumentPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const { docId } = await params;
const result = await getDocument(docId);
if (!result.ok) {
// serverPresenter가 no-op이므로 UX 부수효과 없음
const error = handleServerError(result.error);
return <NotFoundView message={error.message} />;
}
return <DocumentViewer document={result.data} />;
}2. TanStack Query + HydrationBoundary
2.1 왜 initialData가 아니라 HydrationBoundary인가요?
initialData를 쓰면 데이터가 항상 stale로 취급되어 즉시 불필요한 background refetch가 발생해요. dehydrate + HydrationBoundary를 쓰면 staleTime 내에는 refetch가 일어나지 않습니다.
// app/documents/[docId]/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
export default async function DocumentPage({
params,
}: {
params: Promise<{ docId: string }>;
}) {
const { docId } = await params;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["document", docId],
queryFn: () => getDocument(docId),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentClientViewer docId={docId} />
</HydrationBoundary>
);
}2.2 useQuery에서의 투 트랙
// features/document-viewer/ui/DocumentClientViewer.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { networkBoundaryResult } from "@/error/network-boundary";
import { handleError } from "@/error/handler";
import type { Result } from "@/error/result";
export const DocumentClientViewer = ({ docId }: { docId: string }) => {
// 💡 아키텍처 규칙: 우리 구조에서 queryFn은 Result<T>를 반환합니다.
// 따라서 data는 성공(ok: true) 또는 비즈니스 에러(ok: false)를 모두 포함합니다.
// 인프라 에러(Track 2)만 reject되어 isError / throwOnError 대상이 됩니다.
// React Query의 기본 멘탈 모델("data = 성공")과 다르므로 주의하세요.
const { data: result } = useQuery({
queryKey: ["document", docId],
queryFn: () =>
networkBoundaryResult<DocumentDTO>(`/api/documents/${docId}`),
throwOnError: true,
});
if (!result) return null;
if (!result.ok) {
const error = handleError(result.error);
return <div className="text-yellow-600">안내: {error.message}</div>;
}
return <div>{result.data.title}</div>;
};2.3 useMutation에서의 투 트랙
// features/document-editor/ui/CreateDocumentForm.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { networkBoundaryResult } from "@/error/network-boundary";
import { handleError } from "@/error/handler";
export const CreateDocumentForm = () => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (payload: CreateDocumentPayload) =>
networkBoundaryResult<DocumentDTO>("/api/documents", {
method: "POST",
body: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
}),
onSuccess: (result) => {
if (!result.ok) {
const error = handleError(result.error);
if (error.code === "VALIDATION") {
setFieldErrors(error.details.fieldErrors);
}
return;
}
toast.success("문서가 생성되었습니다.");
queryClient.invalidateQueries({ queryKey: ["documents"] });
},
throwOnError: true,
});
// ...
};3. 전체 아키텍처 조감도
┌──────────────────────────────────────────────────────────────┐
│ Browser Boundary (window.onerror) — 최후의 안전망 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Rendering Boundary (error.tsx / global-error.tsx) │ │
│ │ handleError(error, { log: "none" }) — 중복 방지 │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ │ │ │
│ │ │ ┌─ useActionState ───────────────────────────┐ │ │ │
│ │ │ │ Track 1 → handleError(state.error) │ │ │ │
│ │ │ │ → 반환값으로 필드/인라인 에러 렌더링 │ │ │ │
│ │ │ │ Track 2 → throw → error.tsx │ │ │ │
│ │ │ └────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─ safeHandler (인터랙션 경계) ─────────────────┐ │ │ │
│ │ │ │ Track 1 → handleError(result.error) │ │ │ │
│ │ │ │ Track 2 → catch → handleError(error) │ │ │ │
│ │ │ └────────────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ │ ┌─ TanStack Query ───────────────────────────┐ │ │ │
│ │ │ │ data = Result<T> (성공 + 비즈니스 에러) │ │ │ │
│ │ │ │ Track 1 → handleError(result.error) │ │ │ │
│ │ │ │ Track 2 → throwOnError → error.tsx │ │ │ │
│ │ │ └────────────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ ▲ │
│ Network Boundary (fetch 래퍼) │
│ AbortSignal.any()로 외부/내부 signal 합성 │
│ OFFLINE / TIMEOUT / REQUEST_ABORTED │
│ HTTP_CLIENT_ERROR / HTTP_SERVER_ERROR │
│ SCHEMA_MISMATCH / NETWORK_ERROR │
│ ▲ │
│ Server Boundary (safeServerAction) │
│ Track 1 → Result(SerializedError) 반환 │
│ Track 2 → handleServerError + throw │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ handleError / handleServerError — 단일 처리기 (DI) │
│ 서버: createHandleError (팩토리 함수, 불변 인스턴스) │
│ 클라이언트: initHandleError (싱글턴 init 패턴) │
│ → 정규화 → 로깅 → UX → DomainError 반환 │
└──────────────────────────────────────────────────────┘
4. 마무리: 하나의 규칙으로 수렴하는 아키텍처
6편에 걸쳐 프론트엔드 에러 처리의 완전한 지도를 그렸습니다.
전달 방식은 직렬화 제약 때문에 두 트랙(Return vs Throw)으로 나뉘지만, 처리 방식은 handleError 하나로 수렴합니다. 서버 경계도 예외가 아닙니다. 개발자가 기억해야 할 아키텍처 규칙은 네 가지예요.
- 단일 처리기: 어떤 에러든
handleError를 거친다. 모든 경계에서 예외 없다.- 중복 방지: 이미 리포트된 에러는
log: "none"으로 재리포트를 방지한다.- 렌더링 안전: 렌더링 중
handleError는ux: "none"+log: "none"인 에러에 한정한다.- 반환값 활용: 맥락별 추가 처리는
handleError의 반환값으로 수행한다.
이 규칙들 덕분에 ERROR_REGISTRY의 정책이 모든 경계에서 일관되게 적용되고, 정책 변경은 레지스트리 한 곳, 구현체 교체는 reporter/presenter 한 곳만 수정하면 됩니다.
직렬화의 한계와 Error Boundary의 렌더링 전용 특성이라는 근본적 제약 덕분에, 이 투 트랙 + 단일 처리기 전략은 비단 Next.js뿐 아니라 React Router v7이나 TanStack Start 등 모던 프레임워크 어디에나 통용되는 패러다임입니다.
5. 참고 자료
5.1 공식 문서
- Next.js — Error Handling (App Router)
- Next.js — Error Handling (
error.tsx,global-error.tsx) - React —
Component: Catching rendering errors with an Error Boundary - React — eslint rule: error-boundaries
- TanStack Query — Advanced Server Rendering
- TanStack Query — Hydration /
dehydrate/HydrationBoundary - TanStack Query — SSR Guide (historical reference, v4)
5.2 실무 참고 아티클
- NamasteDev — Error Handling Architecture for Modern Web Applications
- Swiftmade — Sentry Best Practices: What to Report and What to Ignore
- freeCodeCamp — The Modern React Data Fetching Handbook: Suspense, use(), and ErrorBoundary Explained
- Medium / Hashbyt — Frontend Error Boundaries in React: Complete Guide to Prevent App Crashes
- LogRocket — A developer’s guide to designing AI-ready frontend architecture
- JavaScript Jobs Hub — JavaScript Application Architecture in 2026 and Why System Design Is the One Skill AI Cannot Automate
- Medium — Beyond UI: The 2026 Frontend Architecture Audit Standard