Error Handling (backend → BFF → client)
SPEC #003 §2-9 (backend) + SPEC #006 §2-5 (BFF) 정합 + Copilot 패턴 [[feedback-1]].
Overview
세 layer 별 error 처리 정책:
- Backend —
@RestControllerAdvice GlobalExceptionHandler가 모든 예외를 표준ErrorResponse로 정규화 - BFF (apps/admin) — backend 응답을 받아 인증/장애/검증 별로 status / code 매핑 (5xx vs 401 분리)
- Client —
extractCode(payload)헬퍼로 backend 코드 우선 + fallback 메시지 (인자는 응답 JSON payload — 에러 객체 아님)
Backend ErrorResponse 표준
{
"success": false,
"error": {
"code": "AUTH_INVALID_CREDENTIALS",
"message": "이메일 또는 비밀번호가 일치하지 않습니다.",
"details": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "..." }
]
}
}details 는 MethodArgumentNotValidException (검증 실패) 에서만.
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:
DomainException→httpStatus+code/messageMethodArgumentNotValidException→ 400 +VALIDATION_FAILED+details[]AccessDeniedException→ 403 +FORBIDDENAuthenticationException→ 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 status | BFF 처리 | session |
|---|---|---|
| 200~2xx | pass-through | 유지 |
| 401 | refresh 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 | 유지 |
| 5xx | 502 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면 세션을 죽이지 않고 502BACKEND_ERROR로 surface 한다. 무인 매장 기기가 일시 장애로/login에 갇히는 것을 막는 규칙이다(Auth Flow §2-0). - catch-all proxy 의 상태 변경은 동일 출처만 — GET/HEAD 외 메서드는
assertSameOrigin통과 필수 (403CSRF_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-allapps/admin/src/app/api/backend/[...path]/route.ts가 401 만 destroy). FE 의 주 강제 경로는 layout redirect 이고, 이 403 은 직접 API 우회 방어용 이중 장치다. - 네트워크 실패와 5xx 구분 — 의미 다름 (
BACKEND_UNREACHABLEvsBACKEND_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를 화면에 노출하지 않는다. 상세는useEffect의console.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}.tsxlinkmusic-frontend-space/apps/admin/src/app/(protected)/not-found.tsx(셸 유지 404, SPEC #030).claude/rules/frontend.mdCopilot 반복 지적 패턴 1linkmusic-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