React.Suspense - 1

Park June Chul·2022년 1월 14일
4

React

목록 보기
5/7

Suspense가 뭘까요?

이 글은 Suspense가 무엇인지에 대해 다루지 않습니다.

그래도 Suspense가 어떻게 구성되어있는지에 대해 한번 짚고 넘어가 보도록 하겠습니다.
(왜냐면, 제가 <React.Suspense fallback={<div>loading</div>} >을 어떻게 쓰는지에 대한 글만 보고 잘못 이해한게 많았고, 그걸 바로잡고자 하는게 이 글이기 때문에...)

Suspense를 알기 위해 가장 먼저 알아야할것은, Suspense가 아니라 fetch입니다.

Suspense 는 그 자체로 혼자서 동작하지 않습니다. 반드시 Suspense를 지원하는 fetcher (useSWR, useQuery, relay)와 같이 사용해야 합니다.

그러한 유형의 fetcher는 대충 아래와 같이 구성되어 있습니다.

const useFetch = (url: string) => {
   if (isFetching) throw fetchPromise;
   if (hasError) throw fetchError;
  
   useMemo(() => fetch(url), [url]);
  
   return data;
}

그러니까 Suspense의 개념은, 만약 loading 상태(컴포넌트를 그리기 위한 데이터가 충분하지 않은 상태)이면 loading을 나타내는 Promise를 throw하고, 컴포넌트 트리의 상단(React.Suspense)에서 캐치해서 스피너를 돌린다고 보시면 됩니다.

위 문장을 구현한 간단한 형태의 Suspense의 구현은 아래처럼 될 수 있겠네요.

// 수도 코드입니다.
const MySuspense = ({ children }) => {
  try {
    return render(children);
  } catch(e) {
    if (e is Promise)
      e.then(() => {
        // 작업이 완료되었습니다.
        // 컴포넌트를 리셋하고, children을 그릴준비를 합니다.
        resetState();
      });
      return render(fallback);
    throw e;
  }
};

이 내용이 이 글에서 다루는 Suspense의 전부입니다!

Suspense가 if(loading) throw prom; 으로 동작하며, 알맞는 fetcher와 쌍을 이뤄야 한다는 사실을 아셔야 아래 내용으로 넘어갈 수 있기 때문에 이 부분에 대한 설명만 적었습니다.

Suspense와 ErrorBoundary는 한쌍이다.

저는 처음에 ErrorBoundaryAppDomain.UnhandledException 같은건 줄 알고 있었습니다.
적절한 상단 영역에서 에러를 받아서 메세지를 띄우던가, 재구동같은 처리를 해주기 위해서 만든 도구인줄 알았고 실제로도 그렇게 적용해서 쓰고 있었습니다.

도입하면서 새롭게 깨달은 점이 있다면. ErrorBoundary는 좀 더 작은 범위에도 적용 가능하다는것입니다.

fetch를 수행하는 모든 컴포넌트는 로딩 - 표시 - 에러 3개의 상태를 가집니다. (만약 그렇지 않다면 잘못 짠...)

여기서

  • 표시 는 그냥 return (<div />)
  • 로딩 은 Suspense가 담당합니다.
  • 에러 상태를 ErrorBoundary가 담당합니다.

ErrorBoundary는 최후의 수단같은게 아니라, 에러가 흘러넘치지 않아야 할 지점을 설정하는 역할을 합니다. 이는 대부분의 Suspense를 적용해야 하는 지점과 정확하게 일치합니다.

ErrorBoundary를 보다 작은 범위에 적용시키면, 페이지의 각 부분이 독립적으로 에러 상태를 가질 수 있고, 독립적인 재시도-복구 로직을 수행할 수 있는걸 기대할 수 있겠죠?

에러 상태가 뭔가요?

위 화면을 구성하는 컴포넌트 중

const TodaysDeal = () => {
  const shops = useFetch('/deals/today');
  return (<div>{shops.map({/*...*/})</div>);
};

/deals/today API가 500을 반환해 shops.map 을 할 수 없게 되었습니다.

에러가 앱 전체로 퍼져야 할까요, 아니면 해당 부분만 에러가 표시되고, 해당 컴포넌트만 에러 복구 로직을 수행할 수 있는게 좋을까요?

Suspense는 로딩 처리 도구가 아니라 하나의 개념이자 철학입니다.

Facebook은 왜 로딩 처리 도구를 3년도 넘게 만들고 있을까요? 바보인가요?

Suspense가 왜 단순 로딩처리 도구가 아닌 좀 더 복잡한 개념인지 알아보도록 하겠습니다.
예를들어, 여기서는 내 프로필 정보를 받아서 이름을 띄우는 간단한 컴포넌트를 만든다고 가정해 보겠습니다.

가장 대표적인 코드는 아래와 같이 되어있을 것입니다.

const MyProfile = () => {
  const { data: me, isLoading, error } = useMyProfile();
  
  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;
  
  return (
    <div>
      {me.name}님 안녕하세요.
    </div>
  );
};

Suspense는 위 코드에 대한 좀 더 깊은 고민을 담고 있습니다.

  • 단순히 내 이름을 띄울 컴포넌트 치고는 코드가 더럽지 않은가요?

  • 무엇보다, 정보를 가져오는 구현을 숨기기 위해 useMyProfile를 사용했는데, 거기서 파생되는 네트워크 요청 과정에 대한 정보를 다 알아야 하는게 맞을까요?

  • 마지막으로 useMyProfile은 순수함수인가요? 같은 입력에 대해 항상 같은 결과를 반환하나요?

그냥 다 떼어내고 아래와 같은 코드가 되면 더 좋지 않을까요?

const MyProfile = () => {
  const me = useMyProfile();
  
  return (
    <div>
      {me.name}님 안녕하세요.
    </div>
  );
};

Suspense는 위 코드가 단순히 상상이 아니라, 실제로 동작할 수 있도록 도와줍니다.

여기에는 여러가지 의미가 있습니다.

  • useProfile 은 완전한 함수가 됩니다.
    • useProfile 은 항상 내 프로필 정보를 반환합니다. 다른 경우는 없습니다.

* 비동기 로직을 동기 로직으로 만듭니다.

  • 에러 처리와 로딩 처리를 컴포넌트 밖으로 분리해서 공통 관심사로 만들 수 있도록 합니다.

여러가지가 이유가 있겠지만, 여기서 가장 중요한건 비동기 로직을 동기 로직으로 만드는 것이라고 할 수 있습니다.

비동기를 동기로 만든다는것은 단순히 fs.readAsync -> fs.readSync 같은 개념이 아닙니다.
비동기 에서 비롯되는 불완전 상태를 제거하고, 이로 인해 발생할 수 있는 모든 문제를 제거합니다. (컴포넌트에서 상태를 하나 뺸다고 생각하면 되겠네요. 당연하게도 소프트웨어는 상태가 많을수록 복잡성이 증가합니다.)

아래 코드를 보시면 이게 무슨뜻인지 이해하기 쉬우실 것 같습니다.
조금 전의 예시 코드를 살짝 변형했습니다.

const MyProfile = () => {
  const { data: me, isLoading, error } = useMyProfile();
  
  // 로딩 처리가 없습니다.
  
  return (
    <div>
      {me.name}님 안녕하세요.
    </div>
  );
};

위 코드를 실행하면 어떤일이 일어날까요?

me 가 null인데 me.name에 접근해서 앱이 터집니다.

이렇게 5줄짜리 코드를 놓고 보면 꽤나 바보같지만, 실제로는 꽤 많이 발생하는 케이스입니다.
Suspense는 컴포넌트에서 불완전 상태를 제거해줌으로써 이러한 경우를 원천적으로 차단해줍니다.


만약 이 주제에 대해 관심이 있으시다면,
모두를 위한 대수적 효과 도 한번 읽어보시기를 추천드립니다.
(물론 위 글에서도 나온 설명이지만 Suspense는 대수적 효과가 아니라, 함수형 프로그래밍의 멱등성을 이용한 대수적 효과 흉내내기라고 나와 있습니다.)

Suspense는 실험적 기능이며 몇몇 프로덕트는 Suspense를 완전하게 적용할 준비가 되어있지 않습니다.

이 문장은 React.Suspense가 실험적이라는것과, 프로덕트가 Suspense 적용 준비가 되어있지 않다는 것 두개의 뜻을 포함하고 있습니다.

그러니까 Suspense가 실험 단계라서 적용할수 없는것이랑 별개로, Suspense가 완전해지더라도 프로덕트 자체가 이것을 받아드리기에 부적합할 수 있다는 뜻입니다.

이 글의 맨 위에서 소개한 Suspense를 지원하는 fetcher를 생각하면서 아래 코드를 읽어보세요.

const profile = useFetch('/profile');
const stats = useFetch('/stats');

return (
  <div>
    {profile.name}님의 총 플레이시간 {stats.playtime}시간.
  </div>
);

언뜻 보면 충분히 나올 수 있는 코드인 것 같습니다만, 실제로 실행하면 어떻게 되는지 살펴보겠습니다.

이 코드를 실행하면 가장 먼저 여기서 throw됩니다.
const profile = useFetch('/profile');

위 코드가 resolve된 다음에서야
const stats = useFetch('/stats');
가 실행됩니다.

결국에는 프론트엔드 개발자가 경계해야할 대상중 하나인 waterfall이 생기겠네요.

위와 같은 이유로 Suspense를 지원하면서 waterfall이 생기지 않게 하려면 API 설계를 바꿔야할 수도 있습니다.
(가장 적절한 형태는 한개의 Suspense단위를 그리는데 무조건 1개의 GET이 되도록 되어야 합니다.)

다행히 graphql은 어느정도 대중화되어 있고, 프로젝트에서 graphql을 사용하고 계시다면 이런 문제가 발생할 여지가 없습니다.
(facebook이 만든 공식 suspense fetcher도 graphql 클라이언트 입니다.)

도입을 고려하고 있다면

그냥 하시면 됩니다.

Suspense는 Hermes(js AOT compiler/runtime) 같은 선택적 적용사항이 아닙니다.
로딩 처리와 에러처리는 Suspense개념이 없던 시절부터도 당연하게 적용해야 했던것들이고, 이미 프로덕트에 적용되어 있으실겁니다.

Suspense는 이러한 당연히 해결해야 하는 문제에 대한 우아한 해법을 제공합니다.

React 의 표현을 빌려오면 Suspense가 적용된 코드는 오히려 오작동하는 코드를 만드는게 더 어렵다고 하네요.

그리고 또 하나는, 제가 React를 좋아하는 이유 중 하나인데, React는 기능의 점진적 도입이 쉽습니다.
작은 부분에서 테스트 해보시고 판단해보세요.

profile
다른 곳에서 볼 수 없는 이상한 주제를 다룹니다. https://pjc0247.github.io/new-home

0개의 댓글