중앙 집중식 API 에러 핸들링(React)

8분

5/4/2021

Thumbnail

이 글은 재 작성된 글입니다.

- 2021.07.22

이 2개 post를 보고 이해를 하기 위한 포스트입니다.

들어가면서

Perl 언어 철학은 TIMTOWTDI(There is more than one way to do it, 어떠한 일에는 여러 가지 방법이 존재한다) 입니다.

Python은 정반대로 TSBO-APOO-OWTDI(There should be one — and preferably only one — obvious way to do it, 단 하나의 아름다운 답이 존재한다) 라는 철학을 가지고 있습니다.

- timtowtdi-vs-tsboapooowtdi

하나의 결과를 위해서 무수히 많은 방법이 존재한다는 뜻인데 자바스크립트를 처음 접하고 배울 때 이런 느낌이었습니다.

너무 많은 답이 존재하고 상당히 빠른 자바스크립트 발전 속(거기다 하위호환성까지!)에 최적의 방법을 찾아가기엔 뉴비에겐 정보의 바다 속에서 찾기어려운 부분이 많았습니다.

API 에러 핸들링도 마찬가지였습니다.

너무 다양한 상황, 라이브러리가 존재하고 각각에 맞는 에러 핸들링을 다루는게 어려운 느낌이 많았습니다. (사용할 때마다 달라지는 느낌이랄까...)

특히 React처럼 CSR형태의 API 에러 처리방식에 대해서는 개발 할 때마다 다르게 처리하고 있는 제 자신을 발견하게 되었습니다. (CSR과 SSR 혼합방식이거나 SSR 방식일 때, 페이지 렌더링이 서버인 경우 API에러 핸들링 관점이 살짝 다를 수 있다는 점이 있습니다.) (여기서는 React 사용할 때(CSR) 중점으로 진행하고 있습니다.)

그러던 와중 흥미로운 post을 보았고, 나름대로 이해하기 위해 이 글을 작성해보려고 합니다.

중앙 집중식

사용 중인 상태 관리 라이브러리(Redux, Apollo, etc...)에 상관없이 API 오류를 중앙 집중식이며, 쉽게 확장 가능한 방식으로 한 번에 처리하는 방법을 제시합니다.

대부분 최신 앱은 API를 통해서 데이터를 가져옵니다.

대게 요청이 성공적으로 반환되지만, 순조롭게 진행되지 않는 경우도 있습니다.

이러한 응답은 특별한 경우를 제외하곤 특성 상 한 곳에서 모두 처리하는 것이 이상적입니다.

특히 React에서는 각각의 앱이 모두 다른 접근법을 사용하는 경우가 많기 때문에 관리하기가 더 어렵습니다. (Redux, Apollo 등)

여기서는 사용중인 상태 관리 라이브러리에 관계없이 API 오류를 중앙 집중식으로 한 번에 확장 가능한 방식으로 처리하는 방식을 제시합니다.

결론부터 얘기하자면

  • api error state 관리: history API 사용
  • api error component : top-level errorHandler component

앱에서 에러 상태를 관리하기 위한 부분앱에서 그 상태로부터 error를 나타내는 component로 관심사를 분리(SoC)하여 확장 가능한 중앙 집중 방식을 제시합니다.

이 같은 결론에 도달하기 위한 과정으로 다음 예시들을 살펴보는 것도 좋을 것 같습니다.

예제

  • 2개 페이지가 존재하는 React 애플리케이션
  • / : 강아지 품종 리스트 화면
  • /dogs/:breed : 강아지 품종 상세 화면 - 품종 내 무작위 이미지
const App = () => {
  return (
    <BrowserRouter>
      <Route exact path="/" component={IndexPage} />
      <Route exact path="/dogs/:breed/" component={DogPage} />
    </BrowserRouter>
  );
};

이와 같은 router를 가진 React 앱을 예로 들어보입니다.

// '/' : 강아지 품종 리스트 화면
// 선택할 수 있는 강아지 품종 리스트를 보여주는 페이지
const breeds = ['husky', 'akita', 'pitbull'];
const IndexPage = () => {
  return (
    <div>
      <h1>View some nice pictures of a dog breed</h1>
      <ul>
        {breeds.map((breed) => (
          <li key={breed}>
            <Link to={`/dogs/${breed}/`}>{breed}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
};
// '/dogs/:breed' : 강아지 품종 상세 페이지 - 품종 내 무작위 이미지
// 선택한 품종 강아지의 무작위 이미지를 보여주는 페이지
const DogPage = () => {
  const { breed } = useParams();
  const [imageSrc, setimageSrc] = React.useState();

  React.useEffect(() => {
    fetch(`https://dog.ceo/api/breed/${breed}/images/random`)
      .then((data) => data.json())
      .then((data) => setimageSrc(data.message));
  }, [breed]);

  return (
    <div>
      <div>
        <Link to="/">back</Link>
      </div>
      {!imageSrc && <p>Loading...</p>}
      {imageSrc && <img alt={`A nice ${breed}`} src={imageSrc} height={200} />}
    </div>
  );
};

이 앱은 정상 동작하지만 만약 없는 페이지를 호출 했을 경우 처리되지 않습니다. 사용자가 존재하지 않는 페이지를 방문하면 '404' 페이지를 나타낼 것입니다.

API에러 시나리오

예외는 2가지 시나리오가 존재합니다.

  1. 잘못된 URL 정규식을 방문했을 때 : /cats/husky
  2. 유효한 URL 정규식이지만 잘못된 품종을 입력했을 경우 : /dogs/jindo

그리고 예외처리는 404 Page를 렌더링 하는 방식으로 처리하도록 합니다.

1. 잘못된 URL 정규식을 방문했을 때

이 경우는 다루기 쉽습니다. API 호출을 하지 않고도 잘못된 것을 알 수 있기 때문입니다. React router에서 catch-all route를 추가하면 됩니다.

catch-all route

router 제일 하단에 route 모두에 체크되지 않고 나머지들을 모두 잡을 수 있는 route를 추가해주면 됩니다. 그리고 그것을 404 페이지로 나타내면 됩니다.

const App = () => {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/" component={IndexPage} />
        <Route exact path="/dogs/:breed/" component={DogPage} />
        <Route component={Page404} />
      </Switch>
    </BrowserRouter>
  );
};

2. 유효한 URL 정규식이지만 잘못된 API 호출일 경우

이 경우엔 앞선 경우 보단 더 어렵습니다. 왜냐하면 사용자가 호출한 API가 유효한지 여부를 미리 알 수 없습니다.

세상에 모든 품종을 알지 못하기 때문에 미리 알 수 없습니다. (알 수 없다고 가정해봅니다...)

NextJS에서는 SSR방식 중 SSG라는 기능을 제공합니다.

SSG는 간단히 말하자면 build 시에 페이지를 미리 생성합니다.

그래서 위 예제 같은 경우 SSG를 사용하기 위해서는 모든 품종에 대해서 알아야만 합니다. 페이지를 미리 렌더링(pre-rendering) 하고 생성하기 위함입니다.

그래서 Nextjs처럼 SSR 방식은 다르게 구성해야 합니다.

그렇기에 반드시 API 호출을 한 뒤 그 응답을 통해서 판단을 해야 합니다.

그럼 에러를 확인하여 404 페이지를 렌더링 하는 것으로 수정해보도록 하겠습니다.

404 페이지 렌더링

const DogPage = () => {
  const { breed } = useParams();
  const [imageSrc, setimageSrc] = React.useState();
  const [httpStatusCode, setHttpStatusCode] = React.useState();

  React.useEffect(() => {
    fetch(`https://dog.ceo/api/breed/${breed}/images/random`)
      .then((data) => data.json())
      .then((data) => {
        setHttpStatusCode(data.code);
        if (data.status === 'success') {
          setimageSrc(data.message);
        }
      });
  }, [breed]);

  if (httpStatusCode === 404) {
    return <Page404 />;
  }

  return (
    <div>
      <div>
        <Link to="/">back</Link>
      </div>
      {!imageSrc && <p>Loading...</p>}
      {imageSrc && <img alt={`A nice ${breed}`} src={imageSrc} height={200} />}
    </div>
  );
};

API 호출 뒤 응답 코드를 httpStatusCode 상태로 관리하여 404일 경우 404 페이지를 렌더링 하도록 수정하였습니다.

이 방식을 잘 동작하지만, 만약 이런 방식으로 코드를 작성했다면 분명 문제가 발생할 것입니다.

  • 중첩 컴포넌트에서 404 처리: 최상위 컴포넌트라면 정상적인 렌더링으로 보이겠지만, 깊게 위치한 컴포넌트라면 부모 컴포넌트의 한 부분으로 렌더링될 것입니다. 그래서 항상 최상위 컴포넌트로 렌더링 될 수 있도록 변경되어야 합니다.
  • 반복적인 코드 & 로직: API를 호출하는 모든 페이지에 반복적인 코드를 작성할 필요는 없습니다. 이 부분을 공통 컴포넌트로 변경되어야 합니다.
  • 여러 에러 응답 다루기: 404 뿐만아니라 401, 403, 500 등 다양한 http status 코드에 대해서도 동일하거나 유사한 작업을 수행하여야 합니다. 이 부분을 처리하기 위해 더 많은 코드와 로직을 추가해야 합니다.
  • 외부에서 API호출 시 상태를 prop으로 넘기기 어려움: 만약 Redux를 사용한다면 saga, thunk를 통해 호출이 발생할 수 있습니다. 깔끔하고 쉽고 공통적으로 컴포넌트에서 적용할 수 있는 방법으로 변경되어야 합니다.

'redirect' 사용하기

가장 일반적이고 쉽게 사용할 수 있는 방식은 <Page404 />를 렌더링 하는 /404 url로 이동시키는 것입니다.

이 방법은 잘 동작하지만, 사용자의 현재 위치의 컨텍스트를 잃습니다.

기존의 잘못 입력했던 URL이 변경되므로 "어떤 부분에서 오류가 났는지?" 알 수 없습니다.

우리가 원하는 것은 404 페이지가 표시되는 동안 잘 못 입력되었던 원래 URL이 유지되는 방법입니다.

route를 변경하지 않고 처리되도록 하는 방법으로 진행해야 될 것 같습니다.

1단계: 'hook' 사용하기

재사용 가능한 커스텀 훅을 사용하는 방법입니다.

이렇게 될 경우 모든 컴포넌트에서 API 상태 코드를 처리해야 하는 부분을 재작성하지 않고 커스텀 훅을 사용하여 반복을 줄일 수 있습니다.

// useQuery.js
import React from 'react';

const useQuery = ({ url }) => {
  const [statusCode, setStatusCode] = React.useState();
  const [apiData, setApiData] = React.useState();

  React.useEffect(() => {
    fetch(url)
      .then((data) => data.json())
      .then(({ code, status, ...apiData }) => {
        setStatusCode(code);
        setApiData(apiData);
      });
  }, [url]);

  return { data: apiData, statusCode };
};
// DogPage.js refactoring
const DogPage = () => {
  ...
  const { breed } = useParams();
  const { data, statusCode } = useQuery({
    url: `https://dog.ceo/api/breed/${breed}/images/random`,
  });

  if (statusCode === 404) {
    return <Page404 />;
  }
  ...
}

이 방법은 반복적인 코드는 해결되었지만, 아직 상태 코드에 따라 오류 페이지를 다르게 렌더링 하는 부분은 해결하진 못했습니다.

또한 여전히 API를 사용하는 컴포넌트 별로 구현되어야 합니다. 그리고 최상위 컴포넌트로써 렌더링 되어야 합니다.

이를 위해 top-level에서 해결할 수 있는 컴포넌트를 구현하여 합니다.

2단계: 'top-level state' 사용

최상위 컴포넌트를 사용하기 위해서는 이 컴포넌트에 알리는 상태 관리 라이브러리가 필요합니다. (Redux, Recoil, Context API 등)

여기서는 내장 되어 있는 Context API를 사용합니다.

이 컴포넌트는

  • 상태 코드를 기반으로 올바른 오류 페이지 렌더링
  • 모든 컴포넌트에서 에러 페이지 렌더링을 발생시킬 수 있도록

이 필요합니다.

import React from 'react';
import { useHistory } from 'react-router-dom';

// 컨텍스트 생성
const ErrorStatusContext = React.createContext();

// 앱의 핵심 기능을 랩핑하는 최상위 컴포넌트
const ErrorHandler = ({ children }) => {
  const history = useHistory();
  const [errorStatusCode, setErrorStatusCode] = React.useState();

  // 사용자가 새 URL을 탐색할 때 마다 이 상태코드를 reset 해야 합니다. 그렇지 않을 경우 사용자는 오류 페이지에 영원히 "갇히게" 됩니다.
  React.useEffect(() => {
    // 현재 위치의 변경 사항을 reset하는 리스너
    const unlisten = history.listen(() => setErrorStatusCode(undefined));
    // unmount될 때 리스너 cleanup
    return unlisten;
  }, []);

  // 컴포넌트를 렌더하는 부분
  // API 오류와 일치하는 errorStatusCode가 있으면 오류 페이지를 렌더링(early return). 오류 상태가 없다면 자식 컴포넌트를 정상적으로 렌더링.
  const renderContent = () => {
    if (errorStatusCode === 404) {
      return <Page404 />;
    }

    // ... 다른 HTTP 코드는 여기서 관리

    return children;
  };

  // 성능상의 이유로 useMemo로 랩핑. 더 궁금하다면 링크 확인
  // https://kentcdodds.com/blog/how-to-optimize-your-context-value/
  const contextPayload = React.useMemo(() => ({ setErrorStatusCode }), [setErrorStatusCode]);

  // 컨텍스트의 값을 컴포넌트에 노출하는 동시에 화면에 적절한 컨텐츠를 렌더링
  return (
    <ErrorStatusContext.Provider value={contextPayload}>
      {renderContent()}
    </ErrorStatusContext.Provider>
  );
};

// 컨텍스트 값을 빠르게 읽을 수 있는 커스텀 훅.
// 빠른 import를 위해 여기서만 허용된다.
const useErrorStatus = () => React.useContext(ErrorStatusContext);
// app.js
const App = () => {
  return (
    <BrowserRouter>
      <ErrorHandler>
        <Switch>
          <Route exact path="/" component={IndexPage} />
          <Route exact path="/dogs/:breed/" component={DogPage} />
          <Route component={Page404} />
        </Switch>
      </ErrorHandler>
    </BrowserRouter>
  );
};

이제 전역 errorStatusCode상태가 정의된 error code이면 화면에는 에러에 해당하는 컴포넌트가 렌더링 되도록 되었습니다.(그렇지 않다면 children을 렌더링 합니다.)

API 호출 응답 코드를 전역 errorStatusCode에 담도록 useQuery를 수정해야 합니다.

import { useErrorStatus } from './ErrorHandler';

const useQuery = ({ url }) => {
  const { setErrorStatusCode } = useErrorStatus();
  const [apiData, setApiData] = React.useState();

  React.useEffect(() => {
    fetch(url)
      .then((data) => data.json())
      .then(({ code, status, ...apiData }) => {
        if (code > 400) {
          setErrorStatusCode(code);
        } else {
          setApiData(apiData);
        }
      });
  }, [url]);

  return { data: apiData };
};

마찬가지로 useQuery를 사용하는 페이지에도 리팩토링 해야 합니다.

import React from 'react';
import { useParams, Link } from 'react-router-dom';
import { get } from 'lodash';
import useQuery from './useQuery';

const DogPage = () => {
  const { breed } = useParams();
  const { data } = useQuery({
    url: `https://dog.ceo/api/breed/${breed}/images/random`,
  });

  const imageSrc = get(data, 'message');
  return (
    <div>
      <div>
        <Link to="/">back</Link>
      </div>
      {!imageSrc && <p>Loading...</p>}
      {imageSrc && <img alt={`A nice ${breed}`} src={imageSrc} height={200} />}
    </div>
  );
};

범용적 해결: history API

위 방식은 잘 작동합니다. 그러나 모든 리액트 프로젝트에서 재사용하기는 힘듭니다.

redux로 변경했다고 한다면 API를 호출할 시에는 장황해집니다. redux state를 변경하기 위한 액션을 트리거해야 하고 ErrorHandler는 redux state를 읽어야 합니다.

이를 위한 많은 보일러플레이트 코드가 들어가야 합니다. 다른 상태 관리 라이브러리도 형태가 달라지면서 공통적으로 사용할 수 없는 구조입니다.

이것을 염두하면서 어떤 부분을 달성해야 하는지 다시 생각해봅시다. URL을 상태 코드와 연결하고 상태 코드의 값에 따라 렌더링할 대상을 결정해야 합니다.

우리가 많이 사용하지 않는 브라우저 기본 기능을 활용하면 이 부분을 쉽게 해결할 수 있습니다.

history API : 브라우저에서 제공되는 Web API로 현재 방문 위치에서 앞, 뒤로 보낼 수 있고 각 위치에 따라 어떤 것이든 저장 할 수 있는 상태 키를 첨부할 수 있습니다. 또한 유용한 메소드와 속성을 제공합니다.

이를 통해 얻을 수 있는 장점은 어떤 것들이 있을까요?

  • 다른 위치로 이동을 할 때 state를 정리할 필요가 없습니다. 새 위치에서는 새로운 state를 가집니다.
  • 이전으로 이동하기(go back)를 눌렀을 때 API 요청을 새로 하지 않아도 404페이지를 자동으로 보여줍니다.(브라우저 기록 스택에 의해)
  • 명령적(imperative)으로 위치에 상태를 할당하기 때문에 어떠한 상태 관리 라이브러리와도 쉽게 통합할 수 있습니다.
  • 원하는 어떠한 키도 저장하고 관리할 수 있으므로 구성이나 확장면에서 뛰어납니다.

이를 이용해 리팩토링을 진행 해봅시다.

import React from "react";
import { useLocation } from "react-router-dom";
import { get } from "lodash";
import Page404 from "./Page404";

const ErrorHandler = ({ children }) => {
  const location = useLocation();

  switch (get(location.state, "errorStatusCode")) {
    case 404:
      return <Page404 />;

    // ... 다른 타입의 상태 코드를 처리한다
    case 401:
      return ...

    default:
      return children;
  }
};

location에서 state를 가져오기 때문에 기존 context API보다도 훨씬 간단해 졌습니다.

그러면 location state에 주입하기 위해 useQuery도 수정해보도록 합시다.

import React from 'react';
import { useHistory } from 'react-router-dom';

const useQuery = ({ url }) => {
  const history = useHistory();
  const [apiData, setApiData] = React.useState();

  React.useEffect(() => {
    fetch(url)
      .then((data) => data.json())
      .then(({ code, status, ...apiData }) => {
        if (code > 400) {
          history.replace(history.location.pathname, {
            errorStatusCode: code,
          });
        } else {
          setApiData(apiData);
        }
      });
  }, [url]);

  return { data: apiData };
};

history.replace를 통해 트리거하면서 errorStatueCode key를 주입합니다.

이 방법의 장점은 useQuery 훅이 ErrorHandler 컴포넌트 존재 자체를 알지 못하는 것입니다.

즉, useQueryErrorHandler는 상호 의존적이지 않으므로 아무 이슈없이 바꿀 수 있는 것을 의미합니다.

각 앱에 따라 요구사항이 다르고, 에러 로직이 다르기 때문에 이런 부분들은 비즈니스 로직과 연결되어 있습니다.

위 처럼 ErrorHandleruseQuery를 통해 중앙 집중식으로 API 에러를 핸들링할 수 있습니다.

추가로 만약 상태 관리 라이브러리를 사용한다면 useQuery 를 사용하지 않을 가능성이 있습니다.

그럴 경우, 라이브러리 마다 다른 HTTP Fetch 로직의 부분에서 수동으로 history.replace를 트리거해야 합니다.

redux

마무리하며

React에서 중앙 집중식으로 API에러를 범용적으로 확장가능한 방법 하나를 배워갑니다. 이를 응용하여 다른 csr 라이브러리에서도 충분히 이용할 수 있을 것 같아요.

그리고 브라우저를 가장 친숙하고 이해하고 있어야 하는데... 이런 방식을 배워가면서 좀 더 알게 되는 것 같습니다. 그러면서도 더 많이 공부해야 겠다는 생각이 듭니다.

그리고 ssr일 경우는 어떻게 해야 될지 고민도 같이 하게 되는 것 같아요. 기회가 되면 정리해보는 것도 좋을 것 같네요.

reference

마지막 업데이트

5/4/2021


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.