React Query와 Error Boundary로 우아하게 에러 처리하기

2024-11-10조회수 0
React
Post thumbnail

1. 개요

React 애플리케이션을 개발하다 보면 이런 경험 한번쯤 해보셨을겁니다.

  • API 호출이 실패했는데 흰 화면만 보이는 경우
  • 데이터 불러오기가 실패해서 앱 전체가 중단되는 상황
  • 네트워크 에러가 발생했는데 사용자에게 아무런 피드백을 주지 못하는 경우

이러한 문제들을 해결하기 위해 우리는 보통 이런 코드를 작성합니다.

const Component = () => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('/api/data');
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err);
      }
    };
    fetchData();
  }, []);

  if (error) return <div>에러가 발생했습니다</div>;
  if (!data) return <div>로딩중...</div>;
  
  return <div>{/* 데이터 표시 */}</div>;
};

하지만 이런 방식에는 몇 가지 문제가 있습니다.

  1. 반복적인 보일러플레이트 코드
  2. 에러 상태 관리의 복잡성
  3. 일관되지 않은 에러 처리
  4. 재시도 로직 구현의 어려움

이 글에서는 React Query 와 Error Boundary를 함께 사용해서 이러한 문제들을 어떻게 해결할 수 있는지 알아보겠습니다.

2. 자바스크립트의 에러 전파 매커니즘

우선 Error Boundary를 이해하기 전에, 자바스크립트에서 에러가 어떻게 전파되는지 이해해야 합니다. 자바스크립트에서 함수가 실행될 때마다 실행컨텍스트(함수 실행컨텍스트)가 생성되고, 이는 실행 컨텍스트 스택에 쌍이게 됩니다.

2.1 실행 컨텍스트 스택과 에러 전파

예를 들어 다음과 같은 코드가 있다고 가정해봅시다.

function handleClick() {
    try {
        fetchUserData();
    } catch (error) {
        console.error('에러 발생:', error);
    }
}

function fetchUserData() {
    processData();
}

function processData() {
    throw new Error('데이터 처리 중 에러 발생!');
}

// 실행
handleClick();

이 코드가 실행되면 실행 컨텍스트 스택은 다음과 같이 쌓입니다.

3. processData EC    (스택 최상단) - 여기서 에러 발생!
2. fetchUserData EC  (중간)
1. handleClick EC    (스택 맨 아래)

에러가 발생하면 다음과 같은 순서로 전파됩니다.

  1. processData 에러 발생
  2. fetchUserData로 전파
  3. handleClick의 try-catch로 전파되어 최종적으로 에러가 잡힘

2.2 에러가 처리되지 않을 때의 결과

실행 컨텍스트 스택에서 에러가 전파되는 과정에서 적절한 에러 처리가 없다면 어떻게 될까요?

3. processUserData  (에러 발생) ⬇️
2. fetchUserData    (에러 처리 없음) ⬇️
1. handleUserAction (에러 처리 없음) ⬇️
전역 컨텍스트     → 앱 중단! Uncaught Error

이 경우 다음과 같은 문제가 발생합니다.

  1. 에러가 잡히지 않고 전역까지 전파
  2. 자바스크립트 엔진이 프로그램 실행을 중단
  3. 이후 코드가 실행되지 않음
  4. 사용자에게 좋지 않은 경험 제공

2.3 에러 처리 전략과 예방법

여러가지 방법이 있겠지만 try-catch문을 사용을 사용해서 에러를 처리하겠습니다.

조금 더 복잡한 예시로 들어보겠습니다.

function firstFunction() {
    try {
        console.log("1. firstFunction 시작");
        secondFunction();        // secondFunction 호출
        // secondFunction에서 에러가 catch되었기 때문에
        console.log("2. firstFunction 끝"); // ✅ 이 코드는 실행됨
    } catch (e) {
        // secondFunction에서 이미 에러를 처리했기 때문에
        console.log("3. 여기는 실행 안됨"); // ❌ 실행 안됨
    }
}

// 2번 함수 (중간 실행 컨텍스트)
function secondFunction() {
    try {
        console.log("4. secondFunction 시작");
        thirdFunction();        // thirdFunction 호출
        // 에러 발생 이후의 코드는 실행되지 않음
        console.log("5. 여기도 실행 안됨"); // ❌ 실행 안됨
    } catch (e) {
        console.log("6. 에러 잡힘:", e.message); // ✅ 실행됨
    }
}

// 3번 함수 (최상단 실행 컨텍스트)
function thirdFunction() {
    console.log("7. thirdFunction 시작");
    throw new Error("에러 발생!");        // 에러 발생 지점!
    console.log("8. 절대 실행 안되는 코드"); // ❌ 실행 안됨
}

firstFunction();

보면 알 수 있듯이 에러예상 지점에 try-catch 문을 작성해서 에러처리를 하는것을 볼 수 있습니다.

3. React에서 ErrrorBoundary로 에러 처리하기

3.1 Error Boundary란?

React 16에서 도입된 ErrorBoundary는 자식 컴포넌트 트리에서 발생하는 Javascript 에러를 포착하고, 에러가 발생했을 때 fallback UI를 보여주는 컴포넌트입니다.

3.2 Error Boundary 생명주기 메서드

Error Boundary는 두 가지 핵심 생명주기 메서드를 사용합니다.

  1. static getDerivedStateFromError()
    • 자식 컴포넌트에서 에러가 발생했을 때 가장 먼저 호출
    • 에러에 따른 UI상태 업데이트를 위해 사용
    • 반드시 새로운 state 객체를 반환해야 함
  2. componentDidCatch()
    • getDerivedStateFromError 이후에 호출
    • 에러 로깅, 분석 등의 부수 효과를 처리할 때 사용
    • 에러 발생 시의 추가적인 처리를 담당
    • state가 변경이 되면 호출

3.3 ErrorBoundary 구현

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // UI 업데이트를 위한 상태 변경
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 로깅
    console.error('에러가 발생했습니다:', error);
    console.error('컴포넌트 스택:', errorInfo.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>문제가 발생했습니다.</h1>;
    }

    return this.props.children;
  }
}

3.4 ErrorBoundary 시용하기

const App = () => {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  );
};

3.5 Fallback UI 커스터마이징

const ErrorFallback = ({ error }) => (
  <div className="error-container">
    <h2>문제가 발생했습니다 😭</h2>
    <p>{error.message}</p>
    <button onClick={() => window.location.reload()}>
      새로고침
    </button>
  </div>
);

const App = () => (
  <ErrorBoundary fallback={<ErrorFallback />}>
    <MyComponent />
  </ErrorBoundary>
);

3.6 에러 전파 과정의 실제 예시

// 최상위 Error Boundary
const App = () => (
  <ErrorBoundary
    fallback={<h1>앱에 문제가 발생했습니다 😭</h1>}
  >
    <div className="app">
      <Navigation />
      <MainContent />
    </div>
  </ErrorBoundary>
);

// 메인 컨텐츠 컴포넌트
const MainContent = () => (
  <main>
    <Sidebar />
    <ProductSection />
  </main>
);

// 상품 섹션 컴포넌트
const ProductSection = () => (
  <section>
    <ProductHeader />
    <ProductList /> {/* 여기서 에러 발생! */}
  </section>
);

// 에러가 발생하는 상품 목록 컴포넌트
const ProductList = () => {
  throw new Error('상품 데이터 로딩 중 에러 발생!');
  return <div>상품 목록</div>;
};

이 경우 에러 전파 과정은 다음과 같습니다.

ProductList (에러 발생)
ProductSection (에러 전파)
MainContent (에러 전파)
App의 ErrorBoundary (에러 캐치, 전체 앱이 fallback UI로 대체)

이제 동일한 구조에 중간 계층에 Error Boundary를 추가한 예시를 보겠습니다:

const App = () => (
  <ErrorBoundary
    fallback={<h1>앱에 문제가 발생했습니다 😭</h1>}
  >
    <div className="app">
      <Navigation />
      <MainContent />
    </div>
  </ErrorBoundary>
);

// 메인 컨텐츠 컴포넌트 (Error Boundary 추가)
const MainContent = () => (
  <ErrorBoundary
    fallback={<div>컨텐츠 영역에 문제가 발생했습니다 😅</div>}
  >
    <main>
      <Sidebar />
      <ProductSection />
    </main>
  </ErrorBoundary>
);

// 상품 섹션 컴포넌트 (Error Boundary 추가)
const ProductSection = () => (
  <ErrorBoundary
    fallback={<div>상품 목록을 불러오는 중 문제가 발생했습니다 🙇‍♂️</div>}
  >
    <section>
      <ProductHeader />
      <ProductList /> {/* 여기서 에러 발생! */}
    </section>
  </ErrorBoundary>
);

이 경우에는 에러가 가장 가까운 Error Boundary에서 캐치됩니다:

ProductList (에러 발생)
↓
ProductSection의 ErrorBoundary (에러 캐치)
↓
상품 섹션만 fallback UI로 대체되고, 다른 부분은 정상 작동

4. ErrorBoundary가 포작하지 못하는 에러들

4.1 비동기 작업의 에러

비동기 작업에서 발생하는 에러는 Errro Boundary가 포착하지 못합니다.

const AsyncComponent = () => {
  useEffect(() => {
    // Web API로 넘어가는 비동기 작업
    setTimeout(() => {
      throw new Error('비동기 에러!');  // Error Boundary가 포착하지 못함
    }, 1000);

    fetch('/api/data')
      .then(res => res.json())
      .then(() => {
        throw new Error('API 에러!');  // Error Boundary가 포착하지 못함
      });
  }, []);

  return <div>비동기 컴포넌트</div>;
};

이런 비동기 작업의 에러를 Error Boundary가 포착하지 못하는 이유는:

  1. setTimeout, fetch 등의 비동기 API는 Web API로 넘어감
  2. 비동기 작업이 완료되면 해당 콜백이 태스크 큐로 이동
  3. 이벤트 루프가 콜 스택이 비었을 때 태스크 큐의 작업을 콜 스택으로 가져옴
  4. 이때 발생하는 에러는 React의 렌더링 사이클과 무관한 시점이라 Error Boundary가 포착할 수 없음

4.2 일반적인 에러 처리 패턴

비동기 작업의 에러를 처리하는 일반적인 패턴입니다.

const App = () => {
  return (
    <ErrorBoundary fallback={<div>에러가 발생했습니다 😭</div>}>
      <ProductList />
    </ErrorBoundary>
  )
}

const ProductList = () => {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('/api/products');
        const data = await response.json();
        setData(data);
      } catch (error) {
        setError(error);
      }
    };

    fetchData();
  }, []);

  // 에러가 있으면 throw -> ErrorBoundary가 캐치
  if (error) {
    // 에러를 던짐
    throw error;
  }

  if (!data) {
    return <div>로딩중...</div>;
  }

  return (
    <div>{/* 데이터 표시 */}</div>
  );
};

4.3 이벤트 핸들러의 에러

이벤트 헨들러에서 발생하는 에러도 Error Boundary가 포착하지 못합니다.

const Button = () => {
  const handleClick = () => {
    throw new Error('클릭 이벤트 에러!');  // Error Boundary가 포착하지 못함
  };

  return <button onClick={handleClick}>클릭</button>;
};

이벤트 핸들러의 에러를 포착하지 못하는 이유:

  1. 이벤트 핸들러는 React의 렌더링 사이클 밖에서 실행됨
  2. 사용자 인터랙션에 의해 직접 트리거되는 비동기적인 작업
const Button = () => {
  const [error, setError] = useState(null);

  const handleClick = () => {
    try {
      // 위험한 작업
      throw new Error('클릭 이벤트 에러!');
    } catch (error) {
      setError(error);
    }
  };

  // 에러가 있으면 throw하여 ErrorBoundary가 처리하도록 함
  if (error) {
    throw error;
  }

  return <button onClick={handleClick}>클릭</button>;
};

5. React Query와 ErrorBoundary로 구현해보기

React에서 에러 처리는 일반적으로 try-catch문이나 Error Boundary를 사용합니다. 하지만 이 방식들에는 몇 가지 문제점이 있습니다.

  • try-catch 모든 비동기 통신에 작성해야 하는 번거로움
  • Error Boundary 사용 시 에러가 발생하면 전체(특정컴포넌트) UI가 대체되어 사용자 경험이 저하
  • 토스트 메시지나 부분적인 에러 처리가 어려움(Error Boundary를 사용하면)

5.1 전체 아키텍처

@tanstack/react-query v5버전 기준으로 작성을 합니다.

src/
├── providers/
│   └── ErrorBoundaryProvider.tsx    # 최상위 에러 처리 프로바이더
├── components/
│   ├── QueryErrorBoundary.tsx       # 커스텀 에러 바운더리
│   └── ErrorFallback/
│       ├── NetworkErrorFallback.tsx # 네트워크 에러 UI
│       ├── ServerErrorFallback.tsx  # 서버 에러 UI
│       └── DefaultErrorFallback.tsx # 기본 에러 UI
├── lib/
│   └── queryClient.ts              # React Query 설정
└── utils/
    └── error.ts                    # 에러 처리 유틸리티

5.2 React Query 클라이언트 설정

먼저 React Query의 클라이언트를 설정합니다. 여기서 전역적인 에러 처리 정책을 정의 할 수 있습니다.

// src/lib/queryClient.ts
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,  // 기본적으로 재시도하지 않음
      throwOnError: handleQueryError,  // 에러 전파 제어
    },
  },
  queryCache: new QueryCache({
    onError: (error) => {
      // 전역 에러 로깅
      console.log(error, 'react query');
    },
  }),
});

여기서 중요한 옵션들을 살펴보겠습니다.

  1. retry: false
    • 기본적으로 API 호출 실패 시 재시도 하지 않음
    • 필요한 경우 개별 쿼리에서 재시도 설정 가능
  2. throwOnError
    • 에러를 ErrorBoundary로 전파할지 결정 하는 옵션
    • Boolean 값으로 설정 true전파 false 전파안됨(기본값)
    • 상황에 따라 토스트 메시지 표시 또는 Error Boundary로 전파
  3. queryCache.onError
    • 전역적인 에러 처리와 로깅을 담당
    • 모든 쿼리의 에러를 중앙에서 모니터링 가능(useQuery만 해당)
// src/utils/error.ts

import { Query, QueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import toast from 'react-hot-toast';

// 커스텀 에러 메시지를 위한 타입 정의
export type QueryErrorMeta = {
  errorMessage?: string;
};

// 서버 응답 에러 타입 정의
type ErrorResponse = {
  message: string;
  code?: string;
};

// 에러 메시지 추출 함수
export const extractErrorMessage = (
  error: AxiosError<ErrorResponse>, 
  query: Query
): string => {
  const meta = query.meta as QueryErrorMeta | undefined;
  
  // 우선순위: 
  // 1. meta에 정의된 커스텀 메시지
  // 2. 서버에서 전달된 에러 메시지
  // 3. axios 기본 에러 메시지
  return meta?.errorMessage || error.response?.data?.message || error.message;
};

// 핵심 에러 처리 함수
export const handleQueryError = (error: unknown, query: Query): boolean => {
  // axios 에러가 아닌 경우 Error Boundary로 전파
  if (!(error instanceof AxiosError)) {
    return true;
  }

  const status = error.status || 500;

  // 예상치 못한 에러는 Error Boundary로 전파
  if (!isExpectedError(status)) {
    return true;
  }

  // 인증 에러는 별도 처리
  if (isAuthOrConflictError(status)) {
    return false;
  }

  // 일반적인 에러는 토스트로 표시
  const message = extractErrorMessage(error, query);
  toast.error(message);

  return false;
};

// 유틸리티 함수들
export const isNetworkError = (error: unknown): boolean => {
  return error instanceof AxiosError && !error.response;
};

export const isServerError = (error: unknown): boolean => {
  return (
    error instanceof AxiosError && 
    error.response?.status !== undefined && 
    error.response.status >= 500
  );
};

export const isAuthOrConflictError = (status: number) => {
  return status === 401 || status === 403;
};

위 함수처럼 true false 리턴값을 통해 ErrorBoundary로 전파 할 지 정할 수 있습니다.

토스트의 내부 커스텀 메세지를 보여주고 싶으면(Query 마다 다른 메세지를 보여주고 싶은경우) meta 필드를 사용하면 됩니다. meta는 원하는 정보로든 채울 수 있는 임의의 객체인데, 전역 콜백등 Query에 접근할 수 있는 모든곳에서 접근이 가능합니다.

const UserProfile = () => {
  const { data } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
    meta: {
      // 이 쿼리에서 발생하는 에러에 대한 커스텀 메시지
      errorMessage: '사용자 정보를 불러오는데 실패했습니다.'
    }
  });

  return <div>{data?.name}</div>;
};

5.3 ErrorBoundary Provider 구현부

import React from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { isNetworkError, isServerError } from '../utils/error';
import NetworkErrorFallback from './ErrorFallback/NetworkErrorFallback';
import ServerErrorFallback from './ErrorFallback/ServerErrorFallback';
import DefaultErrorFallback from './ErrorFallback/DefaultErrorFallback';

type QueryErrorBoundaryProps = {
  children: React.ReactNode;
  fallback: React.ComponentType<FallbackProps>;
  onReset?: () => void;
  keys?: Array<unknown>;
};

const QueryErrorBoundary = ({
  children,
  fallback: CustomFallback,
  onReset,
  keys,
}: QueryErrorBoundaryProps) => {
  const FallbackComponent = ({ error, resetErrorBoundary }: FallbackProps) => {
    // 네트워크 에러 처리
    if (isNetworkError(error)) {
      return (
        <NetworkErrorFallback
          error={error}
          resetErrorBoundary={resetErrorBoundary}
        />
      );
    }

    // 서버 에러 처리
    if (isServerError(error)) {
      return (
        <ServerErrorFallback
          error={error}
          resetErrorBoundary={resetErrorBoundary}
        />
      );
    }

    // 커스텀 폴백 또는 기본 폴백
    if (CustomFallback) {
      return (
        <CustomFallback
          error={error}
          resetErrorBoundary={resetErrorBoundary}
        />
      );
    }

    return (
      <DefaultErrorFallback
        error={error}
        resetErrorBoundary={resetErrorBoundary}
      />
    );
  };

  return (
    <QueryErrorResetBoundary>
      <ErrorBoundary
        fallbackRender={({ error, resetErrorBoundary }) => (
          <FallbackComponent
            error={error}
            resetErrorBoundary={resetErrorBoundary}
          />
        )}
        onReset={() => {
          console.log('ErrorBoundary');
          onReset?.();
        }}
      >
        {children}
      </ErrorBoundary>
    </QueryErrorResetBoundary>
  );
};

export default QueryErrorBoundary;

5.4 장점

  1. 중앙집중식 에러 처리 기존에는 각 컴포넌트마다 try-catch를 사용해 에러를 처리했기 때문에, 에러 처리 로직이 여러 곳에 분산되어 있었습니다. 이는 코드 중복을 야기하고 유지보수를 어렵게 만들었죠. 하지만 React Query의 throwOnError와 Error Boundary를 활용하면, 하나의 중앙 처리 로직으로 모든 에러를 관리할 수 있습니다. 에러 처리 정책이 바뀌더라도 한 곳만 수정하면 되기 때문에 유지보수가 훨씬 쉬워집니다. 이는 마치 고객 서비스 센터와 비슷합니다. 각 매장에서 개별적으로 고객 불만을 처리하는 것보다, 중앙 고객 서비스 센터에서 일관된 방식으로 처리하는 것이 더 효율적인 것처럼요.
  2. 테스트 코드 작성 용이 중앙집중식 에러 처리는 테스트 작성에도 큰 장점을 가져옵니다. 기존에는 각 컴포넌트마다 에러 상황에 대한 테스트를 작성해야 했습니다. 네트워크 에러, 서버 에러, 인증 에러 등 다양한 케이스를 각각 테스트해야 했죠. 하지만 중앙화된 에러 처리 로직을 사용하면, 이 핵심 로직만 철저히 테스트하면 됩니다. 마치 공장의 품질 관리처럼, 핵심 공정만 잘 관리하면 전체 제품의 품질을 보장할 수 있는 것과 같은 원리입니다. 이는 테스트 코드의 중복을 줄이고, 테스트 커버리지를 높이는데 매우 효과적입니다.
  3. 향상된 사용자 경험 가장 큰 장점은 사용자 경험의 향상입니다. 기존의 Error Boundary만 사용했을 때는 에러가 발생하면 화면 전체가 에러 페이지로 대체되었습니다. 사소한 에러 상황에서도 전체 화면이 바뀌는 것은 사용자에게 좋지 않은 경험이었죠. React Query와 결합하면 에러의 심각도에 따라 다른 처리가 가능합니다:

일시적인 에러는 토스트 메시지로 알림 , 인증 관련 에러는 로그인 페이지로 리다이렉트 ,심각한 에러만 Error Boundary UI로 표시

6 결론

결론 이러한 장점들은 특히 규모가 큰 애플리케이션에서 더욱 빛을 발합니다. 중앙집중식 에러 처리로 개발 생산성이 향상되고, 손쉬운 테스트로 애플리케이션의 안정성이 높아지며, 상황에 맞는 에러 처리로 사용자 경험이 크게 개선됩니다. 다만, 이러한 시스템을 구축할 때는 팀 내에서 일관된 에러 처리 정책을 수립하고, 이를 문서화하는 것이 중요합니다. 모든 팀원이 동일한 방식으로 에러를 처리할 때 이러한 장점들이 극대화될 수 있기 때문입니다.

참고

https://tkdodo.eu/blog/react-query-error-handling