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 | 무효화 |
| 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.
- 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 가능
}References
- SPEC #003 §2-9
- SPEC #006 §2-5
.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