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 하나로 수렴합니다. 서버 경계도 예외가 아닙니다. 개발자가 기억해야 할 아키텍처 규칙은 네 가지예요.

  1. 단일 처리기: 어떤 에러든 handleError를 거친다. 모든 경계에서 예외 없다.
  2. 중복 방지: 이미 리포트된 에러는 log: "none"으로 재리포트를 방지한다.
  3. 렌더링 안전: 렌더링 중 handleErrorux: "none" + log: "none"인 에러에 한정한다.
  4. 반환값 활용: 맥락별 추가 처리는 handleError의 반환값으로 수행한다.

이 규칙들 덕분에 ERROR_REGISTRY의 정책이 모든 경계에서 일관되게 적용되고, 정책 변경은 레지스트리 한 곳, 구현체 교체는 reporter/presenter 한 곳만 수정하면 됩니다.

직렬화의 한계와 Error Boundary의 렌더링 전용 특성이라는 근본적 제약 덕분에, 이 투 트랙 + 단일 처리기 전략은 비단 Next.js뿐 아니라 React Router v7이나 TanStack Start 등 모던 프레임워크 어디에나 통용되는 패러다임입니다.

5. 참고 자료

5.1 공식 문서

5.2 실무 참고 아티클