뒤로가기
프론트엔드 API 에러 핸들링 패턴 정리

March 31, 2020

frontendreact

프론트엔드에서 에러 핸들링은 "잘 안 해도 일단 돌아가는" 영역이다. happy path만 구현해도 기능은 동작한다. 문제는 유저가 실제로 쓸 때 터진다. 네트워크가 끊기고, 서버가 500을 뱉고, 토큰이 만료된다. 이 상황들을 안 다루면 유저는 흰 화면을 보거나, 아무 반응 없는 버튼을 계속 누르게 된다.

회사에서 처음 에러 핸들링을 제대로 잡은 건, QA에서 "결제 버튼 누르면 아무 일도 안 일어나요"라는 리포트가 3건 연속 올라온 뒤였다. 전부 API 에러를 catch하지 않아서 생긴 문제였다.

try-catch의 한계#

가장 기본적인 에러 핸들링이다.

typescript
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든 전부 "주문에 실패했습니다." 하나로 퉁친다. 유저 입장에서는 왜 실패했는지 모른다. 입력값이 잘못된 건지, 로그인이 필요한 건지, 서버가 죽은 건지.

typescript
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 클라이언트에서 한 번에 처리하는 게 낫다.

typescript
// 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에 특화된 에러만 처리하면 된다.

typescript
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 에러와는 다른 맥락이지만, 전체적인 에러 핸들링 전략에서 중요한 역할을 한다.

tsx
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를 어디에 배치하느냐가 중요하다. 앱 최상단에 하나만 두면 에러가 발생했을 때 전체 화면이 에러 페이지로 바뀐다. 유저가 다른 기능도 쓸 수 없게 된다.

tsx
// 나쁜 예: 최상단에만 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): 화면 모서리에 뜨는 알림. 잠시 보이다가 사라진다. 인라인 에러: 에러가 발생한 위치에 직접 표시한다.

둘을 언제 쓸지 기준이 필요하다.

tsx
// 토스트: 전역적 알림. 유저의 현재 작업을 방해하지 않음
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>

내가 쓰는 기준은 이렇다.

인라인을 쓰는 경우: 유저가 직접 고칠 수 있는 에러. 폼 유효성 검사 실패, 중복된 이메일, 잘못된 형식 등. 어디가 잘못됐는지 바로 보여줘야 한다.

토스트를 쓰는 경우: 유저가 직접 고칠 수 없는 에러. 네트워크 문제, 서버 에러, 권한 부족 등. 또는 성공/완료 알림.

tsx
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, 에러 상태, 로딩 상태가 내장되어 있어서 직접 관리할 게 줄어든다.

tsx
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에서의 에러 핸들링도 깔끔해진다.

tsx
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>
  );
}

에러 리포팅#

에러를 유저에게 보여주는 것만큼 개발자에게 알려주는 것도 중요하다. 프로덕션에서 발생하는 에러를 모르면 고칠 수 없으니까.

typescript
// 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에 스택 트레이스와 컨텍스트가 다 찍혀 있으니까. 에러 핸들링은 유저 경험을 위한 것이기도 하지만, 개발자 자신을 위한 것이기도 하다. 새벽에 장애 콜 받을 때 에러 로그가 없으면 진짜 막막하다.