React 18 Concurrent 로 UX 개선하기

sxungchxn.dev·2023년 5월 5일
82

Frontend

목록 보기
6/7
post-thumbnail

🤔 Concurrent Feature 에 대해 알아보기

Concurrent Feature는 리액트 18버전에서 release 된 기능이다. 여기서 Concurrent는 동시성을 의미하며 보통 동시성이라고 하면 운영체제에서 흔히 사용되는 용어로 컴퓨터가 동시에 여러가지 일들을 처리하는 것처럼 보이도록 하기 위해 할 일들을 작게 쪼개고 이를 번갈아가며 실행하는 방식을 의미한다.

동시성이 없었다면 위과 같이 커피를 만드는 것을 기다렸다가 잼바른 빵을 만드는 일을 해야 한다. 이렇게 되면 커피는 식어버릴 것이다.

이를 위와 같이 두 개의 큰 일들을 세부적인 태스크들로 잘게 쪼갠 뒤 이들을 번갈아 진행한다면 두 가지 결과를 거의 동시에 끝 마치며 맛있는 아침식사를 할 수 있다.

이러한 동시성의 개념은 리액트 상에서 복잡한 UI 상호작용을 처리하는데 있어서 필요해졌다. 본격적으로 리액트의 동시성 프로그래밍에 대해 알아보기전에 리액트에서 사용되는 데이터 패칭 전략들을 알아보자


Fetch-on-render

function App(){
	return (
		<>
			<ArticlePage/>
			...
		</>
	);
}

function ArticlePage() {
	const { articles, isLoading } = useArticlesQuery();

	// articles가 로딩되지 않는다면 다른 UI를 보여준다.
	if(isLoading){
		return (
				<Spinner />
		);
	}

	// articles 로딩이 완료되면 해당되는 UI를 보여준다.
	// isLoading이 false가 되기 전까지 TrendArticles는 실행조차 되지 못한다.
	return (
		<>
			{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
			<TrendArticles />
		</>
	);
}

특징

  • 데이터 패칭이 이뤄지고 나서야 관련된 컴포넌트를 렌더링 하는 방식이다.

문제점

  • Waterfall 현상 발생 : TrendArticles 컴포넌트는 articles 데이터가 로딩이 완료되어야 렌더링 되기 시작한다. 만약 TrendArticles 내부에 비동기 데이터를 호출하는 작업이 있다면 이는 articles 데이터가 모두 불러와져야 진행이 가능하다. 이러한 fetch-on-render 방식이 계층 별로 반복되면 데이터 패칭도 순차적으로 일어나면서 렌더링 성능에 나쁜 영향을 끼치게 된다.

  • 나쁜 가독성 : 하나의 컴포넌트 코드 내에서 로딩 상태에 대한 로직이 포함되어야 한다는 것이다. 이 때문에 ArtciePage 에서 핵심로직에 집중하기 어려워 진다.


Fetch-then-render

function ArticlePage({ ... }) {
  const [allArticles, setAllArticles] = useState([]);
  const [trendArticles, setTrendArticles] = useState([]);

  useEffect(() => {
    // 비동기 작업을 동시에 병렬적으로 실행
    Promise.all([fetchAllArticles(), fetchTrendArticles()]).then(([allArticles, trendArticles]) => {
      setAllArticles(allArticles);
      setTrendArticles(trendArticles);
    });
  }, []);

  return (
    <>
      <AllArticleList articles={allArticles} />
      <TrendArticleList articles={trendArticles} />
    </>
  );
}

특징

  • Fetch-on-render 방식의 Waterfall 현상을 해결할 수 있도록 비동기 데이터 호출을 Promise.all 로 묶어 병렬적으로 처리하는 방법이다.
    • 위 예시에서는 기존의 fetch-on-render 방식을 변형해 비동기 데이터들을 호출하는 부분을 최상단에서 몰아 처리할 수 있도록 하고 있다.

문제점

  • 불필요한 관심사들의 결합 : 병렬적으로 처리하는 과정에서 불가피하게 서로 다른 비동기 데이터가 Promise.all 로 묶이게 된다. 이로 인해 서로 다른 데이터가 하나의 코드에서 호출되게 되며 강한 결합도를 가진다.

    • 가령 TrendArticles 를 불러오는 부분이 실패하면 AllArticles 부분도 렌더링 되지 못한다.
  • 다른 비동기 데이터가 완료 되어야 렌더링이 가능 : 가령 TrendArticles 은 받아올 데이터가 극소수여서 20ms만에 완료되는 한편, AllArticles 는 받아올 데이터가 너무 많아 100ms 가 소요된다고 하자. 이렇게되면 TrendArticles 의 데이터는 빠르게 로드되었음에도 AllArticles 때문에 렌더링이 역시 지연 되어 버린다.


Render-as-you-fetch(with Suspense)

function App(){
	return (
	<>
		<Suspense fallback={<Spinner/>}>
			<AllArticlePage/>
		</Suspense>
		<Suspense fallback={<Spinner/>}>
			<TrendArticlesPage/>
		</Suspense>
		...
	</>;
}

function AllArticlesPage() {
	const { articles } = useArticlesQuery();
	
	return (
		<>
			{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
			...
		</>
	);
}

function TrendArticlesPage() {
	const { articles } = useTrendArticlesQuery();
	
	return (
		<>
			{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
			...
		</>
	);
}

특징

  • 렌더링 작업과 비동기 데이터 호출 과정이 동시에 이루어진다.
    • 비동기 데이터를 호출하는 과정, fallback UI를 보여주는 과정, 완성된 UI를 보여주는 과정 등 기존의 렌더링 과정들이 여러 작은 태스크들로 쪼개진 뒤 번갈아가며 진행된다.
  • 비동기 데이터 호출을 통해 로딩이 발생하면 Suspense 가 이를 포착하여 UI는 fallback 으로 보여주고 로딩이 완료되면 완성된 UI를 보여준다
    • 이를 통해 컴포넌트 내부에선 로딩 상태에 대한 분기 처리가 필요없어져 코드의 가독성도 높아진다.
    • 비동기 데이터에 대한 분기처리로 인해 waterfall 현상 역시 사라진다.

🧪 Suspense 동작방식 알아보기

리액트 공식문서 예시에서는 Promise를 사용하는데 있어서 아래와 같은 wrapPromise 함수를 예시로 제공해주고 있다.

function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    },
  };
}

export function fetchProfileData() {
  let userPromise = fetchUser();
  return {
    user: wrapPromise(userPromise),
  };
}

function fetchUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        name: "Ringo Starr",
      });
    }, 2000);
  });
}

function ProfileDetails({ resource }) {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfilePage({ resource }) {
  return (
    <>
      <Suspense fallback={<h2>Loading details...</h2>}>
        <ProfileDetails resource={resource} />
      </Suspense>
    </>
  );
}

function App() {
  const [tab, setTab] = useState("home");
  const [resource, setResource] = useState(fetchProfileData());

  return <ProfilePage resource={resource} />
}

위 코드의 실행과정을 살펴보면 아래와 같다.

  • fetchProfileData 실행 → wrapPromise 는 실행 시 pending 중인 Promise를 반환하는 read 함수를 반환한다.
  • App 컴포넌트 렌더링 → ProfilePageProfileDetails 이 실행되면서 read함수가 실행된다
  • 이로 인해 read 함수는 pending 중인 Promise를 반환하고 이를 ProfileDetails 상단의 Suspense 에 의해 캐치되어 fallback UI를 띄운다.
  • Promise의 비동기 처리가 완료되어 status가 success 로 전환되면 read 함수가 재실행되며 결과값을 반환한다.
  • 이후에는 ProfileDetails 컴포넌트가 다시 실행되며 완성된 UI를 보여준다.

여기서 read 함수가 다시 실행되는 것이나, ProfileDetails 컴포넌트가 진짜 다시 실행되는지 의문이 든다면 아래의 샌드박스를 확인해보면 좋을 것 같다.

추가적으로 확인할 수 있는 한가지 흥미로운 점은 Suspense 가 연달아 존재하더라도 블락킹 되는 것이 아니라 병렬적으로 실행된다는 점이다.

function ProfilePage({ resource, showProfile }) {
  return (
    <>
      <Suspense fallback={<h2>Loading details...</h2>}>
        <ProfileDetails resource={resource} />
      </Suspense>
      <button onClick={showProfile}>Refresh</button>
      <Suspense fallback={<h2>Loading posts...</h2>}>
        <ProfileTimeline resource={resource} />
      </Suspense>
    </>
  );
}

위 코드에서 보이는 ProfileDetailsProfileTimeline 은 서로 다른 비동기 처리를 함에도 조건문을 통해 분기 처리 했을때 아래 내용들이 블로킹 되는 것과 다르게 서로 동시에 실행된다. 이로 인해 waterfall 현상이 발생하지도 않게되며 여기서 리액트가 도입한 Concurrent 모드의 진가가 확인된다.


💣 Suspense의 문제점

Suspense 로 로딩상태를 분리함으로 인해서 코드는 훨씬 간결하게 처리할 수는 있었으나 만약 응답속도가 매우 빠르게 이루어지는 비동기 요청에 대해서는 Spinner 로 인해서 오히려 깜빡임이 발생할 수 있다.

위 사진에서 보이는 것 처럼 비동기 호출이 이뤄질때 어느정도 로딩이 발생한다면 Spinner 를 보여주는 UI는 자연스러운 과정으로 이해해볼 수 있다.

하지만 두번째 사진처럼 비동기 처리가 매우 빠르게 처리된다면 Spinner를 띄우는 과정 때문에 오히려 깜빡임을 발생시킨다. 이는 사용자 경험상 좋지 않은 UI가 되어버린다.

이를 해결해줄 수 있는 방법으로 리액트 18에서는 useTransition 이라는 API를 제공한다.

const [isPending, startTransition] = useTransition();

const onClick = id => {
  startTransition(() => {
    setId(id);
  });
};

return(
	<Suspense fallback={<Spinner />}>
      <TvShowDetails id={id} />
	</Suspense>
);

const Details = ({ id }) => {
  const tvShowResource = getTvDataResource(id).read();
  return (
    <div className="flex">
      ...
    </div>
  );
};

export const TvShowDetails = ({ id }) => {
  return (
    <div className="tvshow-details">
      <Suspense fallback={<Spinner />}>
        <Details id={id} />
      </Suspense>
    </div>
  );
};

useTransition 으로 부터 나온 startTransition 이라는 함수에 상태 업데이트 로직을 부여하면 해당 상태 업데이트로 인해 새롭게 발생하는 비동기 처리가 끝날때까지 화면 렌더링 변화를 지연시킨다. 정확히는 원래의 UI를 보여주다가 업데이트된 UI를 보여주는 형태다. 이를 적용해보면 아래처럼 깜빡임 없이 개선해볼 수 있다.


🪡 Transition의 동작과 그 차이

💡 timeout으로 인해 Receded 상태로 넘어가는 것은 현재 삭제되었다. 즉, 얼마나 오래걸리든 비동기 상태가 완료될때까지는 원래의 UI를 계속 유지하게 된다.

그렇다면 Transition 은 어떠한 이유로 기존의 방식과 다른 동작 결과를 낳는 것일까? setState로 인해 state 업데이트가 일어나면 다음의 3가지 단계로 나뉘어 화면 렌더링에 반영된다.

Transition 단계

  • Receded : 영어단어 자체로는 물러난다는 의미를 가진다. Transition을 사용하지 않는 경우 발생하는 단계이며, ProfileDetails 내부에서 비동기 작업이 일어나면 Suspense 에 의해서 둘러 쌓인 상위 계층의 Fallback 으로 물러남을 의미한다.

  • Pending : Transition 을 사용하는 경우 발생하는 단계이며, Suspense 내부에서 비동기 작업이 일어나면 UI 업데이틀르 지연시키고 원래 보여주던 UI를 계속 유지 한다.

Loading 단계

  • Loading 은 현재 컴포넌트(ProfilePage)의 자식 요소에서 발생하는 비동기를 처리하는 과정을 처리중인 단계이다. 여기서는 ProfileTimeline 을 띄울때가 해당되며 Loading posts…가 UI 상에서 나오게 된다.

Done 단계

  • 비동기 처리가 완료됨에 따라 완성된 UI를 보여준다

상태변화에 따른 UI 변화 단계들을 transition을 쓰는 경우와 쓰지 않는 경우로 나누어 정리해보면 아래와 같이 표현해볼 수 있다.

Transition 단계Loading 단계Done 단계
Transition 사용PendingSkeletonComplete
Transition 미사용RecededSkeletonComplete

즉,Transition 을 도입하면 Receded 상태가 아닌Pending 상태로 전환되면서 비동기가 처리 되는 동안에는 이전의 UI를 유지하도록 하였고, 이는 기존의 업데이트 시작 -> 로딩 -> 업데이트 완료가 너무 빠르게 이뤄지면서 발생한 깜빡임을 해소할 수 있게 만들어준 것이다.


🧶 Recoil과 Transition

아쉽게도 transition 은 아직 리액트 18에서도 개발중인 기능 중 하나이기에 써드파티 라이브러리에서 공식적으로 지원되고 있지는 않다. 다만, Recoil 의 경우 아직 불안전한 형태이지만 transition 이 적용되는 상태 API를 제공하고 있다.

// TrendSelect.tsx

import { trendAtom } from '@/recoils/trendAtom';
import { useRecoilState_TRANSITION_SUPPORT_UNSTABLE } from 'recoil';

const TrendSelect = () => {
  const [selectedTrend, setSelectedTrend] = useRecoilState_TRANSITION_SUPPORT_UNSTABLE(trendAtom);

  const handleClickTrend = (word: Trend) => () => {
    startTransition(() => {
      setSelectedTrend(word);
    });
  };
	
	return (...)	
}

// TrendArticlesContent.tsx

import { useRecoilValue_TRANSITION_SUPPORT_UNSTABLE } from 'recoil';

import { trendAtom } from '@/recoils/trendAtom';

const TrendArticlesContent = ({ isMobile }: Props) => {
  const trendType = useRecoilValue_TRANSITION_SUPPORT_UNSTABLE(trendAtom);
  const { data: trends } = useTrendingArticlesQuery({ type: trendType });

  return (
    <div>
      {trends?.slice(0, isMobile ? 1 : 3).map(({ id, title, author, timestamp }, idx) => (
        <TrendArticleItem
          key={idx}
          href={`${process.env.NEXT_PUBLIC_API_URL as string}/articles/${id}`}
          title={title}
          author={author}
          timestamp={timestamp}
        />
      ))}
    </div>
  );
};

// App.tsx

<Suspense
    fallback={
          <LoadingWrapper>
            <Loading type="spinner" />
          </LoadingWrapper>
    }
	>
        <TrendArticlesContent isMobile={isMobile} />
</Suspense>

위에서 볼 수 있듯이 불안정 버전이긴 하나 transtition 과 호환되는 recoil 전역 상태를 정의하여 사용 가능하다.

⛳️ 출처

Concurrent UI Patterns (Experimental) – React

What is React Concurrent Mode?

React 18 둘러보기 | nana.log

React v18.0 – React

Conceptual Model of React Suspense

profile
🏠 버튼을 누르면 더 많은 글들을 보실 수 있습니다

1개의 댓글

comment-user-thumbnail
2023년 11월 20일

좋은 정리 감사합니다

답글 달기