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무효화
401 + refresh 자체가 5xx·네트워크 실패 (transient)502 BACKEND_ERROR유지 (강제 로그아웃 X)
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. refresh 호출 자체의 5xx·네트워크 실패도 동일하다 — forceRefresh"invalid"(401/403)와 "transient"(그 외)를 구분해 돌려주고, transient 면 세션을 죽이지 않고 502 BACKEND_ERROR 로 surface 한다. 무인 매장 기기가 일시 장애로 /login 에 갇히는 것을 막는 규칙이다(Auth Flow §2-0).
  • catch-all proxy 의 상태 변경은 동일 출처만 — GET/HEAD 외 메서드는 assertSameOrigin 통과 필수 (403 CSRF_ORIGIN_MISMATCH/CSRF_REFERER_MISMATCH/CSRF_NO_ORIGIN/CSRF_NO_HOST). SameSite=Lax 위의 심층 방어.
  • 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 가능
}

App Router error / 404 바운더리 (SPEC #153)

backend→BFF→client 데이터 에러와 별개로, 렌더 예외·잘못된 URL 이 Next 기본 화면(복구 CTA·네비 없는 흰 화면)으로 떨어지던 dead-end 를 양 앱(apps/admin·apps/space)에서 차단한다.

파일트리거핵심
app/error.tsx라우트 트리(루트 layout 아래) 렌더 예외루트 layout(html/body·Providers) 유지, 페르소나 셸은 미유지"use client" · [다시 시도]reset() + 홈 링크 · role="alert"
app/global-error.tsx루트 layout 자체 예외 (error.tsx 가 못 잡음)없음 — 자체 <html><body> 렌더 필수토큰/Tailwind 미의존 인라인 스타일 · [다시 시도]
app/not-found.tsx어떤 세그먼트에도 매칭 안 되는 전역 URL루트 layout 유지, 페르소나 셸 미유지정적 안내 + 홈 링크(/)
(protected)/not-found.tsx (admin)protected 세그먼트 내 notFound()운영사 셸 유지(PageHeader)기존(SPEC #030) — 루트 not-found 와 역할 분리(충돌 아님)

규약·정책:

  • 보안error.message·error.digest 를 화면에 노출하지 않는다. 상세는 useEffectconsole.error(error) 만. 사용자에겐 일반 안내(“일시적인 문제가 발생했습니다”)만 보인다.
  • error.tsx/global-error.tsx 는 Client Component 필수(reset 콜백·이벤트 핸들러). global-error 는 layout 밖이라 셸·globals.css·Providers 가 없어 반드시 <html><body> 를 직접 렌더하고 외부 의존 없이 최소 인라인 스타일만 쓴다.
  • 인증/리다이렉트 로직을 error 바운더리에 넣지 않는다 — 단순 복구 UI 만(인증 가드는 layout 책임).
  • 과배치 금지 — 루트 1개(app/error.tsx·app/not-found.tsx)로 대부분 커버. route group 별 중복 배치하지 않는다. 단 admin (protected)/not-found.tsx 는 “셸 유지 404”라는 별도 역할로 유지.
  • 카드 톤 재사용(protected)/not-found.tsx 의 hairline·surface·text-center·lucide·Button 관용구를 앱·바운더리 전반에 일관 적용(셸 밖 바운더리는 min-h-screen 중앙 정렬 full-screen).
  • /audit 루트 redirect (L4) — 탭형 audit 세그먼트는 루트 화면이 없어 /audit 직접 진입이 404 였다. app/(protected)/audit/page.tsx 가 사이드바 기본 탭 redirect("/audit/impersonation").

References

  • SPEC #003 §2-9
  • SPEC #006 §2-5
  • SPEC #153 (error/404 dead-end 바운더리)
  • linkmusic-frontend-space/apps/{admin,space}/src/app/{error,global-error,not-found}.tsx
  • linkmusic-frontend-space/apps/admin/src/app/(protected)/not-found.tsx (셸 유지 404, SPEC #030)
  • .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