ArchitectureError Handling

Error Handling (backend → BFF → client)

SPEC #003 §2-9 (backend) + SPEC #006 §2-5 (BFF) 정합 + Copilot 패턴 [[feedback-1]].

Overview

세 layer 별 error 처리 정책:

  1. Backend@RestControllerAdvice GlobalExceptionHandler 가 모든 예외를 표준 ErrorResponse 로 정규화
  2. BFF (apps/admin) — backend 응답을 받아 인증/장애/검증 별로 status / code 매핑 (5xx vs 401 분리)
  3. ClientextractCode(payload) 헬퍼로 backend 코드 우선 + fallback 메시지 (인자는 응답 JSON payload — 에러 객체 아님)

Backend ErrorResponse 표준

{
  "success": false,
  "error": {
    "code": "AUTH_INVALID_CREDENTIALS",
    "message": "이메일 또는 비밀번호가 일치하지 않습니다.",
    "details": [
      { "field": "email", "code": "INVALID_FORMAT", "message": "..." }
    ]
  }
}

detailsMethodArgumentNotValidException (검증 실패) 에서만.

Backend exception hierarchy

sealed class DomainException(
  val httpStatus: HttpStatus,
  val code: String,
  message: String,
) : RuntimeException(message)
 
class InvalidCredentialsException(...) : DomainException(UNAUTHORIZED, "AUTH_INVALID_CREDENTIALS", ...)
class ExpiredRefreshTokenException(...) : DomainException(UNAUTHORIZED, "AUTH_REFRESH_EXPIRED", ...)
class DuplicateEmailException(email) : DomainException(409, "DUPLICATE_EMAIL", ...)
// ... 등

GlobalExceptionHandler:

  • DomainExceptionhttpStatus + code/message
  • MethodArgumentNotValidException → 400 + VALIDATION_FAILED + details[]
  • AccessDeniedException → 403 + FORBIDDEN
  • AuthenticationException → 401 + UNAUTHENTICATED
  • 기타 Exception → 500 + INTERNAL_ERROR (production message 마스킹)

BFF Layer 분기 ([[feedback-1]])

// apps/admin/src/lib/backend.ts (의사)
export const callBackend = async (
  path: string,
  init: RequestInit,
  session: Session,
): Promise<Response> => {
  await refreshIfNeeded(session);
  let res: Response;
  try {
    res = await fetch(BACKEND_URL + path, withAuth(init, session));
  } catch (err) {
    // 네트워크 도달 실패 — 생성자: (status, body: unknown, code?: string)
    throw new BackendCallError(502, err, "BACKEND_UNREACHABLE");
  }
  if (res.status === 401) {
    // refresh 한 번 시도
    try { await forceRefresh(session); } catch { session.destroy(); throw ... }
    res = await fetch(BACKEND_URL + path, withAuth(init, session));
    if (res.status === 401) { session.destroy(); throw ... 401 ... }
  }
  return res;
};
 
// BFF route handler 에서
try {
  const res = await callBackend(...);
  if (!res.ok) {
    if (res.status >= 500) {
      // 5xx → session 유지 + 502 BACKEND_ERROR (잠시 후 다시 시도)
      return NextResponse.json({ code: "BACKEND_ERROR" }, { status: 502 });
    }
    // 4xx 인증 외 → backend code/status 그대로 surface
    const body = await res.json();
    return NextResponse.json(body, { status: res.status });
  }
  return NextResponse.json(await res.json());
} catch (err) {
  if (err instanceof BackendCallError) {
    return NextResponse.json({ code: err.code }, { status: err.status });
  }
  throw err;
}

핵심 분기

backend statusBFF 처리session
200~2xxpass-through유지
401refresh 1회 → 또 401 → session.destroy() + 401무효화
403 (인증 외)pass-through유지
403 PASSWORD_CHANGE_REQUIRED (#022)pass-through (destroy X) — code 그대로 surface유지
4xx (기타)err.code/err.message 그대로 surface유지
5xx502 BACKEND_ERROR유지 (강제 로그아웃 X)
네트워크 도달 실패502 BACKEND_UNREACHABLE유지

Client extractCode helper

// apps/admin/src/lib/error.ts
// backend ErrorResponse({ error: { code } }) 와 BFF normalize({ code }) 두 형태를 모두 흡수.
// 인자는 응답 payload(JSON) — 에러 객체가 아니다. client/server 양쪽에서 import 가능.
export const extractCode = (body: unknown): string | undefined => {
  if (typeof body !== "object" || body === null) return undefined;
  const nested = (body as { error?: { code?: unknown } }).error;
  if (nested && typeof nested.code === "string") return nested.code;
  const root = (body as { code?: unknown }).code;
  return typeof root === "string" ? root : undefined;
};
 
// 사용 — payload(JSON body)를 넘긴다 (에러 객체 X)
try {
  await login.mutateAsync({ email, password });
} catch (err) {
  const code = extractCode((err as ApiError).body);
  switch (code) {
    case "AUTH_INVALID_CREDENTIALS":
      toast.error("이메일 또는 비밀번호가 일치하지 않습니다.");
      break;
    case "BACKEND_ERROR":
    case "BACKEND_UNREACHABLE":
      toast.error("잠시 후 다시 시도해 주세요.");
      break;
    default:
      toast.error("알 수 없는 오류");
  }
}

backend code 우선, 안전한 fallback 메시지. err.code 가 있으면 그것을, 없으면 err.message.

Constraints (재구현 Blueprint)

  • 5xx 는 session 유지 — 일시 backend 장애로 강제 로그아웃 X.
  • 401 만 session.destroy() — refresh 도 만료된 진짜 인증 실패. 403 은 destroy 하지 않는다 — 특히 #022 PASSWORD_CHANGE_REQUIRED (비번 변경 전 보호 endpoint 차단) 는 정상 인증 상태이므로 세션을 파괴하지 않고 code 그대로 surface (BFF catch-all apps/admin/src/app/api/backend/[...path]/route.ts 가 401 만 destroy). FE 의 주 강제 경로는 layout redirect 이고, 이 403 은 직접 API 우회 방어용 이중 장치다.
  • 네트워크 실패와 5xx 구분 — 의미 다름 (BACKEND_UNREACHABLE vs BACKEND_ERROR).
  • VALIDATION_FAILED 의 details[] 는 폼 inline — Toast 가 아니라 field 별 error 메시지.
  • production message 마스킹 — backend 가 prod 에서 INTERNAL_ERROR 메시지를 generic 으로.

React Query 통합

// queryFn 에서 ApiError 던지면 react-query 가 error state 로
const { data, error, isError } = useQuery({
  queryKey: ["me"],
  queryFn: () => apiFetch<MeResponse>("/api/auth/me", { method: "GET" }),
});
if (isError && extractCode((error as ApiError)?.body) === "BACKEND_ERROR") {
  // 사용자에게 "잠시 후 다시" 안내, retry 가능
}

References

  • SPEC #003 §2-9
  • SPEC #006 §2-5
  • .claude/rules/frontend.md Copilot 반복 지적 패턴 1
  • linkmusic-frontend-space/apps/admin/src/lib/backend.ts (BackendCallError)
  • linkmusic-frontend-space/apps/admin/src/lib/error.ts (extractCode)
  • linkmusic-msa-space-was/.../api/error/GlobalExceptionHandler.kt