프론트엔드에서 에러 핸들링은 "잘 안 해도 일단 돌아가는" 영역이다. happy path만 구현해도 기능은 동작한다. 문제는 유저가 실제로 쓸 때 터진다. 네트워크가 끊기고, 서버가 500을 뱉고, 토큰이 만료된다. 이 상황들을 안 다루면 유저는 흰 화면을 보거나, 아무 반응 없는 버튼을 계속 누르게 된다.
회사에서 처음 에러 핸들링을 제대로 잡은 건, QA에서 "결제 버튼 누르면 아무 일도 안 일어나요"라는 리포트가 3건 연속 올라온 뒤였다. 전부 API 에러를 catch하지 않아서 생긴 문제였다.
try-catch의 한계
가장 기본적인 에러 핸들링이다.
async function handleSubmit() {
try {
setIsLoading(true);
const result = await createOrder(orderData);
router.push(`/orders/${result.id}`);
} catch (error) {
console.error(error);
alert("주문에 실패했습니다.");
} finally {
setIsLoading(false);
}
}동작은 한다. 근데 문제가 있다.
모든 에러를 같은 방식으로 처리하고 있다. 네트워크 에러든, 400 Bad Request든, 401 Unauthorized든 전부 "주문에 실패했습니다." 하나로 퉁친다. 유저 입장에서는 왜 실패했는지 모른다. 입력값이 잘못된 건지, 로그인이 필요한 건지, 서버가 죽은 건지.
async function handleSubmit() {
try {
setIsLoading(true);
const result = await createOrder(orderData);
router.push(`/orders/${result.id}`);
} catch (error) {
if (error instanceof ApiError) {
switch (error.status) {
case 400:
setFieldErrors(error.data.errors);
break;
case 401:
router.push("/login?redirect=/checkout");
break;
case 409:
toast.error("이미 처리된 주문입니다.");
break;
case 429:
toast.error("잠시 후 다시 시도해주세요.");
break;
default:
toast.error("일시적인 오류가 발생했습니다.");
}
} else if (error instanceof NetworkError) {
toast.error("네트워크 연결을 확인해주세요.");
} else {
toast.error("알 수 없는 오류가 발생했습니다.");
}
} finally {
setIsLoading(false);
}
}이건 제대로 된 처리지만, 이걸 매번 모든 API 호출마다 써야 한다. 주문도, 장바구니 추가도, 프로필 수정도. 코드가 중복된다.
API 클라이언트 레벨의 에러 처리
공통 에러(401, 500, 네트워크 에러)는 API 클라이언트에서 한 번에 처리하는 게 낫다.
// api/client.ts
import axios, { AxiosError } from "axios";
export class ApiError extends Error {
constructor(
public status: number,
public data: any,
message: string
) {
super(message);
this.name = "ApiError";
}
}
export class NetworkError extends Error {
constructor() {
super("네트워크 연결에 실패했습니다.");
this.name = "NetworkError";
}
}
const client = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
});
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// 네트워크 에러
if (!error.response) {
return Promise.reject(new NetworkError());
}
const { status, data } = error.response;
// 401: 인증 만료 → 자동 로그아웃
if (status === 401) {
// 토큰 갱신 로직 또는 로그아웃
window.location.href = "/login";
return new Promise(() => {}); // 이후 처리 차단
}
// 500+: 서버 에러 → 에러 리포팅
if (status >= 500) {
reportError(error); // Sentry 등
}
return Promise.reject(new ApiError(status, data, data?.message || "요청에 실패했습니다."));
}
);
export default client;이제 개별 호출에서는 해당 API에 특화된 에러만 처리하면 된다.
async function handleSubmit() {
try {
setIsLoading(true);
const result = await createOrder(orderData);
router.push(`/orders/${result.id}`);
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
setFieldErrors(error.data.errors);
} else if (error instanceof ApiError && error.status === 409) {
toast.error("이미 처리된 주문입니다.");
} else {
// 네트워크 에러, 서버 에러 등은 이미 인터셉터에서 처리됨
toast.error(error instanceof ApiError ? error.message : "오류가 발생했습니다.");
}
} finally {
setIsLoading(false);
}
}Error Boundary
React의 Error Boundary는 렌더링 중 발생하는 에러를 잡아준다. API 에러와는 다른 맥락이지만, 전체적인 에러 핸들링 전략에서 중요한 역할을 한다.
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
}
interface State {
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
reportError(error, errorInfo);
}
reset = () => {
this.setState({ error: null });
};
render() {
if (this.state.error) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(this.state.error, this.reset);
}
return fallback;
}
return this.props.children;
}
}
Error Boundary를 어디에 배치하느냐가 중요하다. 앱 최상단에 하나만 두면 에러가 발생했을 때 전체 화면이 에러 페이지로 바뀐다. 유저가 다른 기능도 쓸 수 없게 된다.
// 나쁜 예: 최상단에만 Error Boundary
<ErrorBoundary fallback={<FullScreenError />}>
<App />
</ErrorBoundary>
// 좋은 예: 기능 단위로 Error Boundary
<div className="dashboard">
<ErrorBoundary fallback={<WidgetError />}>
<SalesChart />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError />}>
<RecentOrders />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError />}>
<UserStats />
</ErrorBoundary>
</div>SalesChart에서 에러가 나도 RecentOrders와 UserStats는 정상 동작한다. 에러가 발생한 영역만 대체 UI를 보여준다.
토스트 vs 인라인 에러
에러를 유저에게 보여주는 방식이 두 가지다.
토스트(Toast): 화면 모서리에 뜨는 알림. 잠시 보이다가 사라진다. 인라인 에러: 에러가 발생한 위치에 직접 표시한다.
둘을 언제 쓸지 기준이 필요하다.
// 토스트: 전역적 알림. 유저의 현재 작업을 방해하지 않음
toast.error("네트워크 연결을 확인해주세요.");
toast.error("서버에 일시적인 문제가 있습니다.");
toast.success("저장되었습니다.");
// 인라인: 특정 입력값이나 영역에 대한 에러
<div className="field">
<input
value={email}
onChange={(e)=> setEmail(e.target.value)}
className={errors.email ? "input-error" : ""}
/>
{errors.email && (
<span className="error-text">{errors.email}</span>
)}
</div>내가 쓰는 기준은 이렇다.
인라인을 쓰는 경우: 유저가 직접 고칠 수 있는 에러. 폼 유효성 검사 실패, 중복된 이메일, 잘못된 형식 등. 어디가 잘못됐는지 바로 보여줘야 한다.
토스트를 쓰는 경우: 유저가 직접 고칠 수 없는 에러. 네트워크 문제, 서버 에러, 권한 부족 등. 또는 성공/완료 알림.
async function handleProfileUpdate(data: ProfileData) {
try {
await updateProfile(data);
toast.success("프로필이 수정되었습니다.");
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
// 유효성 에러 → 인라인
const fieldErrors = error.data.errors;
setErrors({
nickname: fieldErrors.nickname?.[0],
bio: fieldErrors.bio?.[0],
});
} else {
// 서버/네트워크 에러 → 토스트
toast.error("프로필 수정에 실패했습니다. 잠시 후 다시 시도해주세요.");
}
}
}React Query와의 조합
React Query(TanStack Query)를 쓰면 에러 핸들링 패턴이 달라진다. retry, 에러 상태, 로딩 상태가 내장되어 있어서 직접 관리할 게 줄어든다.
function OrderList() {
const { data, error, isLoading, refetch } = useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
retry: (failureCount, error) => {
// 4xx 에러는 재시도하지 않음
if (error instanceof ApiError && error.status < 500) {
return false;
}
return failureCount < 3;
},
});
if (isLoading) return <Skeleton />;
if (error) {
return (
<div className="error-state">
<p>주문 목록을 불러올 수 없습니다.</p>
<button onClick={()=> refetch()}>다시 시도</button>
</div>
);
}
return (
<ul>
{data.map((order) => (
<OrderItem key={order.id} order={order} />
))}
</ul>
);
}retry 옵션에서 에러 종류에 따라 재시도 여부를 결정한다. 서버 에러(500)는 일시적일 수 있으니 재시도하고, 클라이언트 에러(400, 404)는 재시도해도 같은 결과이니 바로 에러를 보여준다.
mutation에서의 에러 핸들링도 깔끔해진다.
function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createOrder,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["orders"] });
router.push(`/orders/${data.id}`);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 400) {
// 유효성 에러는 상위 컴포넌트에서 처리
return;
}
toast.error("주문에 실패했습니다.");
},
});
}
// 컴포넌트에서
function CheckoutForm() {
const { mutate, error, isPending } = useCreateOrder();
const fieldErrors =
error instanceof ApiError && error.status === 400
? error.data.errors
: null;
return (
<form onSubmit={(e)=> {
e.preventDefault();
mutate(formData);
}}>
<input
name="address"
className={fieldErrors?.address ? "input-error" : ""}
/>
{fieldErrors?.address && (
<span className="error-text">{fieldErrors.address[0]}</span>
)}
<button type="submit" disabled={isPending}>
{isPending ? "처리 중..." : "주문하기"}
</button>
</form>
);
}에러 리포팅
에러를 유저에게 보여주는 것만큼 개발자에게 알려주는 것도 중요하다. 프로덕션에서 발생하는 에러를 모르면 고칠 수 없으니까.
// utils/errorReporting.ts
export function reportError(error: unknown, context?: Record<string, any>) {
if (process.env.NODE_ENV === "development") {
console.error("[Error Report]", error, context);
return;
}
// Sentry 등 에러 리포팅 서비스
Sentry.captureException(error, {
extra: context,
});
}
API 인터셉터에서 500 에러가 발생하면 자동으로 Sentry에 보고한다. 여기에 요청 URL, 응답 상태 코드, 사용자 정보 등을 함께 넘기면 디버깅이 수월해진다.
에러 핸들링을 잘 해놓으면 "이상한 버그가 있는데 재현이 안 돼요" 같은 상황이 줄어든다. Sentry에 스택 트레이스와 컨텍스트가 다 찍혀 있으니까. 에러 핸들링은 유저 경험을 위한 것이기도 하지만, 개발자 자신을 위한 것이기도 하다. 새벽에 장애 콜 받을 때 에러 로그가 없으면 진짜 막막하다.
