Frontend Error Architecture #4 - 서버 경계와 네트워크 경계 구현

safeServerAction으로 직렬화 안전한 Result 패턴을 구현하고, networkBoundary로 오프라인·타임아웃·HTTP 에러를 세분화하는 서버/네트워크 경계 래퍼

📄 [4편] 서버 경계와 네트워크 경계 구현

TL;DR

  • safeServerActionhandleServerError를 사용하여 "모든 처리는 handleError로" 규칙을 서버에서도 지킵니다.
  • networkBoundaryAbortSignal.any()로 외부 signal과 내부 타이머를 합성하여 정확한 취소 감지를 수행해요.

1. Result 패턴

// error/result.ts
import type { DomainError, SerializedError } from "./domain-error";
 
export type Success<T> = { ok: true; data: T };
export type Failure = { ok: false; error: SerializedError };
export type Result<T> = Success<T> | Failure;
 
export const actionSuccess = <T>(data: T): Success<T> => ({
  ok: true,
  data,
});
 
export const actionFailure = (error: DomainError): Failure => ({
  ok: false,
  error: error.toSerialized(),
});

2. safeServerAction: 서버 경계 래퍼

⚠️ 구현 안정성 참고: isRedirectErrorisNotFoundError는 Next.js의 내부 모듈 경로에서 import합니다. 공식 API가 아니므로 메이저 버전 업그레이드 시 변경될 수 있어요. adapter 파일로 격리하여 확인 지점을 한 곳으로 모읍니다.

// error/next-internals.ts
// ⚠️ Next.js 내부 경로 import.
// 공식 API가 아니므로 버전 업그레이드 시 이 파일만 확인하면 됩니다.
export { isRedirectError } from "next/dist/client/components/redirect";
export { isNotFoundError } from "next/dist/client/components/not-found";
// error/safe-action.ts
"use server";
import { z } from "zod";
import { isRedirectError, isNotFoundError } from "./next-internals";
import { makeError } from "./make-error";
import { isDomainError } from "./domain-error";
import { isExpectedCode } from "./registry";
import { actionSuccess, actionFailure, type Result } from "./result";
import { handleServerError } from "./handler.server";
 
export const safeServerAction = <S extends z.ZodSchema, R>(
  schema: S,
  action: (parsedData: z.infer<S>) => Promise<R>,
) => {
  return async (data: z.infer<S>): Promise<Result<R>> => {
    try {
      const validation = schema.safeParse(data);
      if (!validation.success) {
        const error = makeError({
          code: "VALIDATION",
          details: {
            fieldErrors: validation.error.flatten().fieldErrors,
          },
          message: "입력값을 확인해주세요.",
        });
        return actionFailure(error); // 🚦 Track 1
      }
 
      const result = await action(validation.data);
      return actionSuccess(result);
    } catch (error) {
      if (isRedirectError(error) || isNotFoundError(error)) {
        throw error;
      }
 
      // 🚦 Track 1: 의도한 비즈니스 에러는 Result로 반환
      if (isDomainError(error) && isExpectedCode(error.code)) {
        return actionFailure(error);
      }
 
      // 🚦 Track 2: handleServerError로 로깅 후 throw
      // "모든 처리는 handleError로" 규칙에 예외 없음.
      // ux: "none" → 서버에서 UX 부수효과 없음 (serverPresenter가 no-op)
      handleServerError(error, { ux: "none" });
 
      // 클라이언트에 노출할 일반화된 에러로 throw
      throw new Error("일시적인 시스템 오류가 발생했습니다.", {
        cause: error,
      });
    }
  };
};

사용 예시:

// features/auth/api/actions.ts
"use server";
import { z } from "zod";
import { safeServerAction } from "@/error/safe-action";
import { makeError } from "@/error/make-error";
 
const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
 
export const loginAction = safeServerAction(LoginSchema, async (data) => {
  const user = await authService.login(data.email, data.password);
 
  if (!user) {
    // 💡 INVALID_CREDENTIALS: 폼 인라인 메시지용 (ux: "none")
    // AUTH_REQUIRED(ux: "redirect")와 분리되어 정책이 충돌하지 않습니다.
    throw makeError({
      code: "INVALID_CREDENTIALS",
      details: null,
      message: "이메일 또는 비밀번호가 올바르지 않습니다.",
    });
  }
 
  return { userId: user.id };
});

3. networkBoundary: 네트워크 경계 래퍼

3.1 throw 모드 (기본)

// error/network-boundary.ts
import { z } from "zod";
import { makeError } from "./make-error";
import { isDomainError } from "./domain-error";
import { isExpectedCode } from "./registry";
import { actionSuccess, actionFailure, type Result } from "./result";
 
type NetworkOptions<T> = RequestInit & {
  responseSchema?: z.ZodType<T>;
  timeout?: number;
};
 
export async function networkBoundary<T>(
  input: RequestInfo | URL,
  options?: NetworkOptions<T>,
): Promise<T> {
  const { responseSchema, timeout = 10000, ...init } = options ?? {};
  const internalController = new AbortController();
  const timer = setTimeout(() => internalController.abort(), timeout);
 
  // 💡 외부 signal과 내부 타이머 signal을 합성합니다.
  // 외부 signal이 있으면 AbortSignal.any()로 결합하여
  // 어느 쪽이든 abort되면 fetch가 취소됩니다.
  // AbortSignal.any()는 주요 브라우저에서 지원됩니다 (Chrome 116+, Firefox 124+, Safari 17.4+).
  // Node.js 20+ 환경에서도 사용 가능합니다.
  const signals = [internalController.signal];
  if (init.signal) signals.push(init.signal);
  const combinedSignal = AbortSignal.any(signals);
 
  try {
    if (typeof navigator !== "undefined" && !navigator.onLine) {
      throw makeError({
        code: "OFFLINE",
        details: null,
        message: "인터넷에 연결되어 있지 않습니다.",
      });
    }
 
    const response = await fetch(input, {
      ...init,
      signal: combinedSignal,
    });
 
    if (!response.ok) {
      const body = await response.json().catch(() => ({}));
 
      if (body.code && body.message) {
        throw makeError({
          code: body.code,
          details: body.details ?? null,
          message: body.message,
        });
      }
 
      if (response.status >= 500) {
        throw makeError({
          code: "HTTP_SERVER_ERROR",
          details: { status: response.status },
          message: body.message ?? "서버에 문제가 발생했습니다.",
        });
      }
 
      throw makeError({
        code: "HTTP_CLIENT_ERROR",
        details: { status: response.status },
        message: body.message ?? `요청 실패 (${response.status})`,
      });
    }
 
    const data = await response.json();
 
    if (responseSchema) {
      const parsed = responseSchema.safeParse(data);
      if (!parsed.success) {
        throw makeError({
          code: "SCHEMA_MISMATCH",
          details: { endpoint: input.toString() },
          message: "서버 응답 형식이 예상과 다릅니다.",
          cause: parsed.error,
        });
      }
      return parsed.data;
    }
 
    return data as T;
  } catch (error) {
    if (isDomainError(error)) throw error;
 
    if (error instanceof DOMException && error.name === "AbortError") {
      // 외부 signal이 abort되었는지 확인하여 REQUEST_ABORTED / TIMEOUT 구분.
      // 💡 실용적 수준의 구분입니다.
      // 외부 signal과 내부 타이머가 거의 동시에 abort되는 엣지 케이스에서는
      // 오분류될 수 있습니다. 100% 정확한 구분이 필요하다면
      // AbortController를 커스텀 래핑하여 abort reason을 명시적으로
      // 태깅하는 방식을 고려하세요.
      if (init.signal?.aborted) {
        throw makeError({
          code: "REQUEST_ABORTED",
          details: null,
          message: "요청이 취소되었습니다.",
          cause: error,
        });
      }
      throw makeError({
        code: "TIMEOUT",
        details: null,
        message: "서버 응답이 지연되고 있습니다. 다시 시도해주세요.",
        cause: error,
      });
    }
 
    throw makeError({
      code: "NETWORK_ERROR",
      details: null,
      message: "네트워크 연결을 확인해주세요.",
      cause: error,
    });
  } finally {
    clearTimeout(timer);
  }
}

3.2 Result 모드

export async function networkBoundaryResult<T>(
  input: RequestInfo | URL,
  options?: NetworkOptions<T>,
): Promise<Result<T>> {
  try {
    const data = await networkBoundary<T>(input, options);
    return actionSuccess(data);
  } catch (error) {
    if (isDomainError(error) && isExpectedCode(error.code)) {
      return actionFailure(error);
    }
    throw error;
  }
}

4. 결론

서버 경계와 네트워크 경계가 완성되었어요. safeServerActionhandleServerError를 사용하여 "모든 처리는 handleError로" 규칙에 예외를 두지 않습니다. 다음 편에서는 클라이언트 측 경계를 구현합니다.