• 2021. 10. 21.

    by. 문익점

    반응형

    마이 리얼 트립 (https://brunch.co.kr/@toqha7822/11)

    웹이나 앱은 대부분 처음 입장하게 되면 서버에서 받아온 데이터가 모두 준비되어야지만 정보를 보여줄 수 있습니다. 서버에서 데이터를 받을 동안 아무런 표현이 없다면 사용자는 아무런 사진과 버튼이 없는 흰 백지 페이지에 당황스러울 것 입니다. 이를 극복하기 위해서 데이터를 가져오는 동안에는 로딩스피너나 스켈레톤 UI를 많이 사용하게 됩니다. 이렇게 된다면 데이터를 서버에서 받아오는 동안에는 스켈레톤UI를 보여주게 되어 사용자가 데이터를 받아오는 중이라고 인식하게 됩니다.

    또한 데이터를 불러오는 동안 오류가 발생하거나 데이터를 요청한지 너무 오래 걸렸거나 여러 예상치 못한 오류가 발생하거나 등등 에러 상황이 발생했다면 이를 사용자에게 알려줘야 합니다.

    디스코드 접속 오류

    이러한 처리가 없다면 사용자는 한 평생동안 로딩UI만 쳐다보게 되거나 인내심이 없는 사용자(99%)는 바로 이탈하게 됩니다.

    이 포스트는 제목에서도 알 수 있듯이 React를 사용하면서 이러한 문제를 쉽게 다룰 수 있도록 하고자 작성되었습니다.

    React 컴포넌트내에서의 API 통신처리

    React는 서버와의 api 통신 방법, 라우팅 컨트롤, 컴포넌트를 관리하는 폴더구조 등 딱 정해진 방식대로 코딩하지 않습니다. 그러다보니 많은 예제들이 한 컴포넌트안에서 api 핸들링처리를 하는 경우가 많습니다.

    다음과 같은 컴포넌트가 있습니다.

    • 유저의 이름을 보여주는 컴포넌트
    • 유저 데이터의 이름이 있다면 이름을 보여준다.
    • 데이터를 가져오는 동안에는 로딩 중...을 띄운다.
    • 에러가 발생하면 error 발생!을 띄운다.
    function UserInfo({}: UserProps) {
      const [error, setError] = useStaet(false);
      const [data, setData] = useState<User | null>(null);
    
      useEffect(() => {
        (async () => {
          try {
            const user = await getUserInfo();
            setData(user);
          } catch (error) {
            console.log(error);
            setError(true);
          }
        })();
      }, []);
    
        if (error) return <div>error 발생!</div>
      if (!data) return <div>로딩 중...</div>;
      return <div>data.name</div>;
    }
    
    export default UserInfo;

    간단한 컴포넌트 이지만 이 컴포넌트안에는 많은 로직이 들어 간 것을 확인 할 수 있습니다. useEffect에 들어간 로직부터 보겠습니다.

    useEffect(() => {
      (async () => {
        try {
          const user = await getUserInfo();
          setData(user);
        } catch (error) {
          console.log(error);
          setError(true);
        }
      })();
    }, []);

    이는 데이터를 가져오면서 로딩 상태인지, 에러 상태인지 등 어떠한 상태인지 체크 하여 이에따라 상태 값을 변경해주는 로직입니다. 이는 아래에서 이 상태 값에 따라 랜더링을 다르게해야 하기 때문입니다.

    if (error) return <div>error 발생!</div>
    if (!data) return <div>로딩 중...</div>;
    return <div>data.name</div>;

    최종적으로 상태 값에 따라 어떤 UI로 표현 할 지 분기 처리를 해줍니다. 데이터 팬딩 중 일 때, 데이터 패칭이 성공 하였을 때 그리고 에러가 발생 했을 때에 따른 어떠한 컴포넌트를 랜더링 해줘야하는지 판단하는 로직이 들어가게 됩니다. 만약 여기서 받아야 할 데이터가 몇 가지더 추가 되면 어떻게 될까 상상만해도 힘이 듭니다.

    결국 이 컴포넌트는 아래와 같은 조건을 갖게 됩니다.

    • 데이터 여부 판단 ( 로딩 중인지. 데이터를 받아 왔는지, 에러가 발생 했는지) 로직
    • 에러에 따른 핸들링 로직 (e.g. 권한이 없다면 로그인 페이지로 이동)
    • 데이터 여부에 따른 어떠한 컴포넌트를 랜더링 할 지 결정하는 로직

    이 컴포넌트의 문제점은 데이터 패칭의 성공의 경우와 실패의 경우, 상태 값에 따른 랜더링 여부를 처리하는 로직들이 모두 다 한 컴포넌트에 있다는 것입니다. 이는 진짜 이 컴포넌트가 하는일인 유저의 이름을 보여주는 컴포넌트를 파악하기 어렵게 됩니다. 또한 상태 값에 따른 랜더링 여부, 에러에 따른 핸들링 등 모든 컴포넌트에서 공통되게 적용 될만한 로직도 컴포넌트 마다 반복되어 들어가다보면 유지 보수에도 힘이 듭니다.

    컴포넌트는 그 컴포넌트가 하는 일이 무엇인지 명확하게 파악 할 수 있어야합니다. 즉 비지니스 로직을 파악하기 쉽게 설계되어야 합니다. 데이터 패칭 여부나 에러 핸들링 로직은 외부로 위임하고 컴포넌트는 비지니스 로직만 책임지는 설계를 하기 위해 React의 Suspense와 Errorboundary를 사용합니다.

    React Suspense

    리액트 18버전에서 정식 출시 하게 될 Suspense는 데이터를 가져 올 때에 데이터의 준비가 끝나지 않았을 때에는 컴포넌트를 랜더링하지 않고 지정한 컴포넌트를 보여 줄 수 있는 컴포넌트입니다. chidren으로 들어간 컴포넌트가 비동기 처리를 할때의 처리를 외부인 Suspense로 위임 받을 수 있습니다.

    const ProfilePage = React.lazy(() => import('./ProfilePage')); // 지연 로딩
    
    // 프로필을 불러오는 동안 스피너를 표시합니다
    <Suspense fallback={<}>
      <ProfilePage />
    </Suspense>

    ProfilePage를 불러올 동안 Suspense는 fallback에 지정된 Spinner 컴포넌트를 랜더링하게 됩니다. ProfilePage를 모두 불러온 상태라면 당연히 ProfilePage를 다시 랜더링하여 보여주게 됩니다.

    if (error) return <div>error 발생!</div>
    return <div>data.name</div>;

    이렇게 된다면 위에서 정의한 상태 값에 따라 랜더링 로직에서 반복되는 if문들은 없어지게 될 것입니다.

    React ErrorBoundary

    ErrorBoundary는 데이터를 가져 올 때에 오류가 발생하면 그 에러에 대한 핸들링처리를 위임 받을 수 있는 컴포넌트입니다. 이 방식은 마치 try/catch 동작하게 됩니다.

    <ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </ErrorBoundary>;

    이는 ProfileTimeline에서 발생한 오류는 <h2>Could not fetch posts.</h2>로 위임하게 되며 에러 처리는 해당 컴포넌트가 처리하게 됩니다. 즉 ProfileTimeLIne에서 데이터 패칭을 하다가 실패하게 된다면 <h2>Could not fetch posts.</h2> 컴포넌트가 랜더링 됩니다.

    return <div>data.name</div>;

    이처럼 비동기처리는 ProfileTimeline, 로딩 처리는 Suspense, 에러 처리는 ErrorBoundary가 처리하게 하므로써 컴포넌트는 비지니스 로직에만 신경 쓸 수 있도록 할 수 있습니다.

    useEffect에서의 비동기 처리는 Recoil의 비동기 셀럭터나 React-query의 서스팬스모드를 이용하여 더욱 깔금하게 처리가 가능합니다.

    react-query, suspense, errorboundary로 개선된 코드

    function Main() {
      return (
        <ErrorBoundary fallback={<div>에러 발생!</div>}>
          <Suspense fallback={<div>로딩 중...</div>}>
            <UserInfo />
          </Suspense>
        </ErrorBoundary>
      )
    }
    
    function useUser() {
      return useQuery(
        `getUser`,
        () => {
          return apiClient.get<User>(`URL`)
        },
        **{ suspense: true }**
      )
    }
    
    function UserInfo() {
      const { data: user } = useUser()
      return <div>{user.name}</div>
    }

    UserInfo 컴포넌트는 비로서 유저의 이름을 보여주는 역할만 하는 컴포넌트로 탄생 했습니다. UserInfo 컴포넌트는 위에서 제기한 문제점인 여러가지의 로직이 한 컴포넌트에 있어 복잡한 문제를 해결 하였고 컴포넌트의 비지니스 로직을 쉽게 파악 할 수 있는 컴포넌트로 탄생하게 되었습니다.

    React에서 기본적으로 제공하는 ErrorBoundary에는 한계가 있습니다. 외부로 위임된 에러는 한번 발생하면 reset 할 수 없는 문제가 있습니다.(e.g 다시 시도하기 버튼을 만들 수 없음) 대부분 react-error-boundary(라이브러리)로 사용하게 됩니다. 다음 포스팅에선 Suspense와 위의 ErrorBoundary를 활용하여 비동기 처리를 하는 컴포넌트를 만들어서 활용하는 방법에 대한 포스팅하도록 하겠습니다.

    2탄: https://varletc0nst.tistory.com/39?category=855233

    Refs.

    https://www.youtube.com/watch?v=FvRtoViujGg

    https://ko.reactjs.org/docs/error-boundaries.html

    https://ko.reactjs.org/docs/concurrent-mode-suspense.html

    반응형