React concurrent feature

Y·2023년 1월 10일
0
post-thumbnail

React에서 주요 기능을 만들었는데, 바로 concurrent mode이다. “동시성”이란 독립적으로 실행될 수 있는 여러 조각으로 나눠 프로그램을 구조화하는 방식이다. 싱글 스레드의 한계에서 벗어나, 어플리케이션을 효율적으로 만들 수 있는 방법이다.

사용자 경험을 완벽하게 만드는 것과 관련이 있다.

현재로서는, React가 재조정(Reconciliation) 과정을 한번 시작하면, 이 과정이 끝나기 전까지는 이를 멈출 수가 없다. 그러면 브라우저의 메인 UI 스레드는 유저 입력을 받는 등의 다른 작업을 실행할 수 없게 된다.

설령 React의 재조정 알고리즘이 매우 효율적일지라도, 웹 어플리케이션이 비대해지고 DOM 트리가 커지면 프레임 저하로 인한 화면의 버벅임이나 애플리케이션이 반응하지 않는 문제는 흔히 발생하게 될 상황이다.

대부분의 개발자들은 메모이제이션이나 디바운싱 등의 기법을 사용하여 사용자 경험을 개선시키고자 하겠지만, 이는 그저 주된 문제 해결을 뒤로 미뤄두는 것 뿐이다. 렌더링은 여전히 길을 가로막는 큰 트럭과 같은 존재이다.

아무리 최고급 성능의 기기 또는 제일 저렴한 가격의 기기를 사용하든 간에 스피너나 스켈레톤, 플레이스홀더 등으로부터 벗어날 수 없다. 이걸 사용하면 뭔가 일어나고 있다는 것을 알 수 있는 좋은 표시를 나타내기는 하겠지만, 너무 많이 남발하거나 불필요한 상황에서 표시한다면, 경험을 향상시키기보다는 오히려 악화시킬 수 있다. 만약 당신의 기기에서 데이터를 충분히 빨리 불러올 수 있다면, 굳이 처음부터 스피너를 봐야 할 필요가 있을까? 몇 초 조금 기다린 뒤 모든 데이터가 준비되어 렌더링까지 완료된 페이지를 보는 것이 더 나은 경험이지 않을까?

설명적 렌더링(Interpretive Rendering) 이라는 대안

동시성이란 결국 여러 작업을 처리할 수 있도록 작업들을 작은 조각들로 나누는 방법인데, 이것이 바로 React가 하려는 것이다. 즉 렌더링 과정을 더 작은 작업들로 나누고 스케줄러를 통하여 각 작업들에 중요도에 따른 우선 순위를 부여한다. (⇒ Time slicing) 이렇게 하면 Concurrent React는 아래의 것들을 할 수 있게 된다.

  • 메인 스레드를 블록하지 않는다
  • 동시에 여러 작업들을 처리하고, 우선 순위에 따라 각 작업들 간 전환할 수 있다
  • 최종 결과로 확정하지 않고도 부분적으로 트리를 렌더링할 수 있다

렌더링 과정이 더 이상 스레드를 블록하지 않으므로, 이는 설명적(interpretative)이며 이제 유저가 키를 누르는 등의 조금 더 높은 중요도를 가지는 작업이 실행되었을 때 렌더링이 나중으로 미뤄질 수 있다.

--
debouncing과 throttling에서 적절한 딜레이를 선택하는 것은 꽤 어렵다.
유저가 빠르게 입력할 경우, 분명히 그걸 전부 처리하는 것은 비효율적이다. 디바운싱을 적용한다면 delay를 얼마로 주어야 할까? 디바운싱을 적용한다고 해도, 일단 계산을 시작하면 메인 스레드가 거기서 블록되기 때문에 다음 입력을 처리하지 못한다. 한 순간 입력을 멈추면 다음 입력을 받지 못할 정도로 프레임이 저하된다.

반응 속도는 최대한 빠르게 하되, 사용자의 상호작용이 있으면 그걸 우선적으로 처리하여 화면이 멈춘 것처럼 보이지 않게 해야 한다. 무거운 연산은 메인 스레드가 놀고 있을 때만 처리하고, 유저 입력이 들어오면 다시 거기에 집중하는 것이다.

즉, 이벤트의 우선 순위를 나누고, 우선 순위가 높은 이벤트가 발생하면 context switch 하여 그 작업을 먼저 핸들링하면 된다. 근데 자바스크립트에는 병렬 스레드나 스케줄러가 없다.

react 18 은 fiber 라는 엔진을 개선하여 자체적인 스케줄러를 가지게 되었다. 마치 OS 처럼 작업의 우선순위를 정하고, 우선순위 높은 작업이 들어오면 먼저 처리하는 기능이 구현되었다. 무겁고 유저 경험에 중요하지 않은 작업은 우선순위를 낮춰 프레임률을 유지할 수 있다.

useTransition

useTransition을 사용하면 DOM에 변동 사항을 적용하기 앞서 데이터가 모두 준비될 때까지 기다릴 수 있다. 따라서, 변동이 실제 발생하기 전까지 사용자는 아무 것도 보지 않게 된다.

  • isPending: 전환(transition)이 현재 이루어지고 있는지 알려주는 불리언 값

  • startTransition: 어떤 state를 지연시키고자 하는지 React에게 알려주는 함수 (낮은 우선순위로 실행할 함수를 인자로 받음)

간단한 예제이다. 검색어를 입력하여 밑에 관련 결과가 나오는 것이다. (예제)

const Transition = () => {
  const [name, setName] = useState("");
  const [result, setResult] = useState("");

  const onChange = useCallback((e) => {
    setName(e.target.value);
    setResult(e.target.value + "의 결과");
  }, []);

  return (
    <>
      <input value={name} onChange={onChange} />
      {name
        ? Array(10000)
            .fill()
            .map((v, i) => <div key={i}>{result}</div>)
        : null}
    </>
  );
};

바로 입력되어 화면에 업데이트되어야하는 부분과, 업데이트가 조금 느려도 되는 부분을 구분하여, 낮은 우선순위로 실행할 함수를 startTransition의 인자로 넣어준다.

const [isPending, startTransition] = useTransition();

const onChange = useCallback((e) => {
    setName(e.target.value);
    startTransition(() => {
      setResult(e.target.value + "의 결과");
    });
  }, [startTransition]);

/* ... */
{ isPending ? <Spinner/> : <div>내용</div>}

useDeferredValue

  • value: defer(연기)하고 싶은 값

이 또한 useTransition 과 유사하게 낮은 우선순위를 지정하기 위한 훅이다. 차이점이라면 useTransition은 함수 실행의 우선순위를 지정하는 반면, useDeferredValue는 값의 업데이트 우선순위를 지정한다.

이 훅은 useMemo와 함께 사용하면 더 효과가 좋다. 종속된 값들을 memoize 시키면 불필요한 재 랜더링을 막으면서 하위 컴포넌트나 상태의 업데이트를 지연시킬 수 있다.

const DeferredValue = () => {
  const [name, setName] = useState("");
  const deferredValue = useDeferredValue(name); // 덜 중요한 것
  const result = useMemo(() => deferredValue + "의 결과", [deferredValue]);

  const onChange = useCallback((e) => {
    setName(e.target.value);
  }, []);

  return (
    <>
      <input value={name} onChange={onChange} />
      {deferredValue
        ? Array(10000)
            .fill(0)
            .map((v, i) => <div key={i}>{result}</div>)
        : null}
    </>
  );
};

언제 또 유용하게 사용할 수 있을까?

Showing stale content while fresh content is loading

새로운 결과가 준비되기 전까지 stale한 (기존) 결과를 보여주고 있는 것이다.

useDeferredValue 훅은 deferred 버전의 쿼리를 넘겨줄 수 있게 해준다.

export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}

Suspense

Concurrent Mode에서는 "우선순위에 따른 화면 렌더", "컴포넌트의 지연 렌더" 그리고 "로딩 화면의 유연한 구성" 등을 쉽게 구성할 수 있도록 특성화된 기능들을 제공하고 있다. 이러한 기능들을 사용한 UI 개발 패턴을 React 팀에서는 "Concurrent UI Pattern"이라고 부르고 있다.

이 기능은 16.8 버전에서 React.lazy와 함께 소개되었지만, 실험적(experimental) 기능이었고 React 18에서 릴리즈 되어 정식으로 사용할 수 있다.

Suspense를 사용하면 컴포넌트가 렌더링하기 전에 다른 작업이 먼저 이루어지도록 ‘대기한다’.

Suspense는 React Component 내부에서 비동기적으로 다른 요소를 불러올 때 해당 요소가 불러와질 때까지 Component의 렌더링을 잠시 멈추는 용도로 사용할 수 있는 컴포넌트이다.

우선 아래의 예시를 봐보자.

유저에게 보여지는 모든 UI 구성 요소는 코드를 통해 "명령형"으로 작성되어 있다. <Spinner/> 컴포넌트와 <ErrorMessage/> 컴포넌트는 <ImperativeComponent/> 의 state에 따라 화면에 보여지거나 보여지지 않는다. 다시 말해 <ImperativeComponent/>UI를 어떻게(HOW) 보여줄 것이냐 에 집중하고 있다.

import { useState, useEffect } from 'react';
const ImperativeComponent = () => {
  const [ isLoading, setIsLoading ] = useState(false);
  const [ data, setData ] = useState();
  const [ error, setError ] = useState();
  useEffect(() => {
    !async () => {
      try {
        setIsLoading(true);
        const { json } = await fetch(URL);
        setData(json());
        setError(undefined);
        setIsLoading(false);
      } catch(e) {
        setData(undefined);
        setError(e);
        setIsLoading(false);
      }
    }();
  }, []);
  if (isLoading) {
    return <Spinner/>
  }
  if (error) {
    return <ErrorMessage error={error}/>
  }
  return <DataView data={data}/>;
}
export default ImperativeComponent;

선언형 컴포넌트는 ‘무엇을(What)을 보여줄 것이냐’에 집중하는 것이다.

이 정의대로라면 선언형 컴포넌트를 사용한 컴포넌트는 state에 따라 UI를 화면에 그리는 것이 아니라 상황에 따라 적절한 UI를 화면에 보여주어야 할 것이다.

import { Suspense, lazy } from 'react';
const HugeComponent = lazy(() => import('./HugeComponent'));
const ComponentWithSuspense = () => {
  return (
    <Suspense fallback={<Spinner />}>
      <HugeComponent />
    </Suspense>
  );
};
export default ComponentWithSuspense;

<ComponentWithSuspense/> 컴포넌트는 내부적으로 <HugeComponent/>를 화면에 그려주는 역할을 한다. <HugeComponent/>는 이름과 같이 엄청나게 용량이 큰 컴포넌트여서, 우리는 이 컴포넌트가 화면에 그려져야 할 때 비동기적으로 사용자에게 전달되기를 바란다.

원하는 바를 이루기 위해 위 코드에서는 lazy를 사용해서 <HugeComponent/>를 비동기적으로 불러오게끔 구성하였다. <ComponentWithSuspense/> 컴포넌트에서는 <HugeComponent/>Suspense를 사용해서 컴포넌트 내부에 비동기적으로 불러오고, <HugeComponent/>가 불러와지는 중에는 Suspense의 fallback Prop을 통해 <Spinner/>를 화면에 보여준다.

특정 컴포넌트를 비동기적으로 불러와서 화면에 보여주는데, 비동기 로딩이 진행 중인 상태에는 그에 맞추어 스피너를 화면에 노출하는 이 상황, 화면을 어떻게(HOW) 그릴지에 집중하는 것이 아니라 무엇을(WHAT) 보여줄 것인지에 집중하였다고 할 수 있다.

데이터 불러오기를 위한 Suspense (w/ React-Query)

Suspense를 사용해서 비동기 데이터도 선언적으로 처리할 수 있을까? 만약 가능하다면 API 호출 상태에 따라서 화면을 어떻게(HOW) 그릴지 고민하지 않고 “API 로딩 중인 경우”와 “비동기 데이터가 불러와진 경우”에 따라 무엇을(WHAT) 사용자에게 보여줄지를 바탕으로 컴포넌트를 구성할 수 있을 것이다.

import { Suspense } from 'react';
const User = () => {
  return (
    // UserProfile에서 비동기 데이터를 로딩하고 있는 경우
    // Suspense의 fallback을 통해 Spinner를 보여줍니다.
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
};
const UserProfile = () => {
  // userProfileRepository는 Suspense를 지원하는 "특별한 객체"
  const { data } = userProfileRepository();
  return (
    // 마치 데이터가 "이미 존재하는 것처럼" 사용합니다.
    <span>
      {data.name} / {data.birthDay}
    </span>
  );
};
export default User;

참고로, 위의 userProfileRepository는 Promise를 반환하는 일반적인 fetch 함수가 아니다.

데이터 불러오기를 위한 Suspense를 사용하려면 Promise를 함수로 감싸야한다.

이는 각 처리 단계에서 Suspense가 기대한 값에 따라 다른 결과를 반환한다. React 팀은 이러한 기능의 함수를 제공하는 react-cache라는 라이브러리를 만들고 있지만, 아직 완성되지 않았다.

Only Suspense-enabled data sources will activate the Suspense component. They include:

  • Data fetching with Suspense-enabled frameworks like Relay and Next.js
  • Lazy-loading component code with lazy

    Suspense does not detect when data is fetched inside an Effect or event handler.
    The exact way you would load data in the Albums component above depends on your framework. If you use a Suspense-enabled framework, you’ll find the details in its data fetching documentation.

    Suspense-enabled data fetching without the use of an opinionated framework is not yet supported. The requirements for implementing a Suspense-enabled data source are unstable and undocumented. An official API for integrating data sources with Suspense will be released in a future version of React.

위 예시 코드에서는 데이터 불러오기가 완료된 후 화면은 전적으로 <UserProfile/> 컴포넌트에서 담당한다. <UserProfile/> 컴포넌트는 이미 데이터가 불러와져 있음을 전제로 작성되어 있고, 비동기 요청이 진행 중인 상태에서 사용자에게 보일 화면에 대해서는 일체 관심이 없다. 대신 <UserProfile/> 컴포넌트를 불러오는 <User/> 컴포넌트가 비동기 요청 상태에 따라 어떤(WHAT) 화면을 보여줄지를 관리합니다.
만약 <UserProfile/> 컴포넌트 내부의 (Suspense를 지원하는 특별한)userProfileRepository 객체에서 "데이터를 불러오는 중" 이라면 유저 정보를 렌더링하지 않고 Suspense의 fallback으로 지정된 <Spinner/> 컴포넌트를 화면에 보여주는 것이다.

(참고) React-Query에서 Suspense 사용하기

React에서 비동기 데이터 관리를 위해 사용되는 라이브러리 React Query에서는 비동기 데이터 요청 시 Suspense와 Error Boundary를 활용할 수 있는 옵션을 제공한다.

import { useQuery } from 'react-query';
const queryKey = 'user';
const queryFn = () => axios('/user').then((res) => res.data);
const UserProfile = () => {
  const { data } = useQuery(queryKey, queryFn, {
    // 데이터 불러오기를 위한 Suspense를 활성화하는 옵션
    suspense: true,
    // Error Boundary 사용을 위한 옵션.
    // suspense 옵션이 true인 경우에는 기본값이 true로 설정된다.
    useErrorBoundary: true,
  });
  return (
    <span>
      {data.name} / {data.birthDay}
    </span>
  );
};
export default UserProfile;

suspense 옵션을 선택할 경우 useQuery hook은 위에서 언급한 “Suspense를 지원하는 "특별한 객체"로써 동작하여 데이터 불러오기를 위한 Suspense, 그리고 Error Boundary를 통한 에러 Fallback UI 처리 사용이 가능해진다.

React Query의 Suspense 옵션 또한 아직 실험적 기능이므로, 운영 환경에서는 사용 전 신중한 확인이 필요하다.

스켈레톤 UI 표시하기

Suspense를 사용해서 fallback UI를 구성할 경우, 데이터를 보여주는 컴포넌트에서 비동기 데이터 불러오기 상태에 따른 화면 분기를 완전히 분리할 수 있다.

// app.tsx
import { lazy, Suspense } from 'react';
import Skeleton from './MainPage/index.skeleton';
const MainPage = lazy(() => import('./MainPage'));
const App = () => (
  <Suspense fallback={<Skeleton />}>
    <MainPage />
  </Suspense>
);
export default App;
// MainPage/index.tsx
import { Suspense } from 'react';
import Banner from '../Banner';
import BannerSkeleton from '../Banner/index.skeleton';
import CustomMenu from '../CustomMenu';
import CustomMenuSkeleton from '../CustomMenu/index.skeleton';
import User from '../User';
const MainPage = () => (
  <div>
    <header>
      <div className="title"> Simple Demo App </div>
    </header>
    <section className="banner__container">
      <Suspense fallback={<BannerSkeleton />}>
        <Banner />
      </Suspense>
    </section>
    <main>
      <nav className="custom-menu__container">
        <Suspense fallback={<CustomMenuSkeleton />}>
          <CustomMenu />
        </Suspense>
      </nav>
      <User />
    </main>
  </div>
);
export default MainPage;

Suspense는 계층(Hierarchy) 관계를 갖는다. A Suspense가 있고 A Suspense 하위에 B Suspense가 존재한다고 가정하였을 때, B Suspense 하위의 컴포넌트가 아직 준비되지 않은 경우 A Suspense 하위의 컴포넌트도 (당연히) 아직 준비되지 않은 것으로 간주한다. 이러한 Suspense의 특성을 사용하면 복잡한 React Component의 비동기 데이터 불러오기 상태 관리를 간단하게 수행할 수 있다.

하지만 이런 Suspense의 동작이 장점만을 갖고 있는 것은 아니다. 이러한 Suspense의 동작 방식 때문에 우리는 적재적소에 꼼꼼히 Suspense를 추가해주어야 한다. 만약 위 예시 코드에서 Suspense를 실수로 누락했다면 사용자에게는 어떤 화면이 보여질까? Hierarchy를 갖는 Suspense의 특성에 따라 상위 Suspense를 찾아 Component Tree의 상단으로 거슬러 올라가게 된다. 이렇게 Suspense가 누락된 경우 User 컴포넌트와 가장 가까운 상위 Suspense는 App 컴포넌트에 있는 "비동기로 MainPage 번들을 불러올 때 처리를 위한 Suspense" 이므로, 위 예시 코드에 Suspense가 누락될 경우 사용자는 "전체 화면 Skeleton"을 보게 된다.

무조건 스켈레톤 화면을 보여주는게 사용자 경험에 도움이 될까?

해당 내용은 카카오페이 테크 블로그를 참고하였다.

100ms 지연시간

API 지연 시간이 100ms인 경우를 봐보자. 실제로 요청한 화면은 더 빠르게 노출되었지만 스켈레톤이 화면에 보여지는 시간이 너무 짧았기 때문에 되려 거슬리는 인상이 든다. 중간 화면이 스켈레톤임을 인지하지 못하고 봤다면 화면이 깨진 느낌, 혹은 살짝 덜그럭거리는 느낌을 받을 수 있을 것 같다.

"API 응답 시간이 짧은 경우에는 스켈레톤이 보여지지 않게끔" 하는 기능을 넣어주는게 오히려 더 좋은 사용자 경험을 줄 수 있지 않을까?

// ...전략
<Route exact path={ROUTE.CATEGORY_LIST}>
  <Suspense fallback={<HomeSkeleton />}>
    <CategoryList />
  </Suspense>
</Route>
// ...후략

Suspense를 활용하여 API를 호출하는 CategoryList 컴포넌트는 Suspense로 감싸 스켈레톤을 보여준다.

그리고 로딩이 시작된 후 특정 시점까지는 스켈레톤을 보여주지 않기 위한 용도로 유틸성 컴포넌트를 하나 만들어준다.

const DeferredComponent = ({ children }: PropsWithChildren<{}>) => {
  const [isDeferred, setIsDeferred] = useState(false);
  useEffect(() => {
    // 200ms 지난 후 children Render
    const timeoutId = setTimeout(() => {
      setIsDeferred(true);
    }, 200);
    return () => clearTimeout(timeoutId);
  }, []);
  if (!isDeferred) {
    return null;
  }
  return <>{children}</>;
};

위의 DeferredComponent는 children을 Props로 받고, 200ms이 지나기 전에는 children을 화면에 렌더하지 않는 컴포넌트이다. Suspense의 fallback으로 내린 스켈레톤을 이 컴포넌트로 한번 감싸 특정 시점이 지난 후 스켈레톤을 사용자에게 노출시키도록 구성해보겠다.

// ...전략
<Route exact path={ROUTE.CATEGORY_LIST}>
  <Suspense
    fallback={
      <DeferredComponent>
        <HomeSkeleton />
      </DeferredComponent>
    }
  >
    <CategoryList />
  </Suspense>
</Route>
// ...후략

DeferredComponent 내부의 지연 시간을 200ms으로 잡았기 때문에, 응답 지연이 100ms인 경우에는 스켈레톤을 보여주는 대신 사용자에게 잠시 빈 화면이 노출된 뒤 화면이 제공된다. 응답 지연이 더 긴 1000ms인 경우에는 200ms 동안은 빈 화면이 뜨고 나머지 800ms 동안 스켈레톤이 노출된다.

이러한 식으로 스켈레톤 UI가 노출되는 타이밍을 조절할 수 있다는 예시를 보여준 것이고,

서비스의 성능 지표 및 사용자들에 대한 데이터를 분석하고 고민하면서 사용자 경험 개선을 개선해볼 수 있겠다는 점에서 유용한 예시인 것 같다.


[References]

profile
기록중

0개의 댓글