startTransition, useDeferredValue 활용한 React 18 동시성 렌더링

전세영·2024년 12월 9일
1

대상 독자

  • 무거운 작업 시 React 컴포넌트가 반응성을 잃는 문제를 해결하려는 프론트엔드 개발자
  • 비싼 API 작업이나 렌더링 작업중에도 즉각적인 상호작용을 보여주고 싶은 개발자
  • React 18에 도입된 Suspense, useDeferredValue, startTransition에 대한 이해를 하고싶은 프론트엔드 개발자

동시성 렌더링이란?

동시성 렌더링은 무거운 작업을 수행하면서도 여전히 반응성을 유지할 수 있는 기능입니다.

JS는 싱글 스레드이기 때문에, 무거운 작업을 실행하는 동안 리액트의 모든 상호작용이 정지됩니다. 그 동안 리액트는 사용자의 입력에 응답하지 않으며 사용자는 불쾌한 경험을 하게 됩니다. 이 현상을 반응성 중단(responsive block)이라고 부르겠습니다.

예시로 필요성 이해하기

App.js에서 <TabButton> 중 클릭된 버튼에 해당하는 내용을 화면에 보여주는 리액트 코드입니다.
About, Posts(slow), Contact 버튼 클릭시 버튼이 볼드처리되며, 각 버튼에 할당된 내용이 하단에 렌더링됩니다.
하지만 Posts(slow) 클릭시 내부에 500개의 컴포넌트를 렌더링하는 매우 느린 SlowPost 컴포넌트를 렌더링합니다.

Posts(slow) 버튼을 클릭하면 어떻게될까요?
SlowPost 컴포넌트가 모두 렌더링되는 몇초동안, 사용자의 탭버튼 클릭 등을 포함해 모든 상호작용이 정지합니다.

스크린샷

[영상] 버전0. 최적화 적용 전

반응성 중단이 일어난 동안, 탭 버튼 컴포넌트는 Contact에 마우스를 올려도 hover UI를 보여주지 않는 문제가 있습니다.
또, Contact 버튼을 연타할 동안 아무 반응이 없다가 Posts렌더링이 끝나야 Contact 버튼 클릭이 작동된다는 문제를 가지고 있습니다.
이러한 반응성 중단현상을 해결하려면 어떻게 해야할까요?

이 페이지의 구현코드는 아래와 같습니다.

// App.js
import { useState } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";

export default function TabContainer() {
  const [tab, setTab] = useState("about");

  const selectTab = (nextTab) => {
    setTab(nextTab);
  };

  return (
    <>
      <TabButton
        isActive={tab === "about"}
        onClick={() => selectTab("about")}
      >
        About
      </TabButton>
      <TabButton
        isActive={tab === "posts"}
        onClick={() => selectTab("posts")}
      >
        Posts (slow)
      </TabButton>
      <TabButton
        isActive={tab === "contact"}
        onClick={() => selectTab("contact")}
      >
        Contact
      </TabButton>
      <hr />
      {tab === "about" && <AboutTab />}
      {tab === "posts" && <PostsTab />}
      {tab === "contact" && <ContactTab />}
    </>
  );
}
// TabButton.js
import { useTransition } from "react";

export default function TabButton({ children, isActive, onClick }) {
  if (isActive) {
    return <b>{children}</b>;
  }
  return (
    <button
      onClick={() => {
        onClick();
      }}
    >
      {children}
    </button>
  );
}
export default AboutTab = () => <p>Welcome to my profile!</p>;
export default ContactTab = () => <p>You can find me online here: abcd@gmail.com</p>;
// PostsTab.js
import { memo } from "react";

const PostsTab = memo(function PostsTab() {
  console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />");

  let items = [];
  for (let i = 0; i < 500; i++) {
    items.push(
      <SlowPost
        key={i}
        index={i}
      />
    );
  }
  return <ul className="items">{items}</ul>;
});

function SlowPost({ index }) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {
    // 항목당 1 ms 동안 아무것도 하지 않음으로써 매우 느린 코드를 대리 실행합니다.
  }

  return <li className="item">Post #{index + 1}</li>;
}

export default PostsTab;

<PostsTab>은 500개의 자식컴포넌트를 갖고있는 무겁고 느린 컴포넌트입니다.
이 탭이 선택될 경우 오랜시간동안 지연이 발생합니다.

사용자가 PostsTab을 한번 클릭할 시 500개의 컴포넌트를 렌더하며 리액트의 반응성이 한동안 멈춥니다.
그 동안은 사용자가 다른 탭을 누르더라도, 렌더작업이 종료될때까지 탭버튼은 전혀 동작하지 않습니다.
신규입력이 한동안 무시 되는 현상이 발생한 것입니다.

만약 마음이 바뀌어 다른 탭이 보고싶어져도 볼 수 없으며, 실제로 내 클릭이 반영되었는지도 알 수 없습니다.
사이트가 responsive 특성을 잃어버리면 그 동안 사용자는 답답함과 불안함을 느낍니다.

QnA

위와 관련된 내용으로 제가 처음 공부할 당시 궁금했던 점을 QnA식으로 설명해드려 볼게요!

왜 반응성이 중단되는 현상(responsive block)이 발생할까요?

React의 렌더링 업데이트는 동기적이며, 도중에 중지될 수 없기 때문입니다.
한 번 렌더하기 시작한 SlowList가 완료될 때 까지 React는 새로운 작업을 시작할 수 없습니다.

Responsive Block과 동시성 렌더링은 무슨 관계인 거죠?

Responsive Block을 해결하기 위해 React 18에서 새로 등장한 것이 동시성 렌더링(Concurrent Rendering) 입니다.

Responsive Block 현상을 일으킬 수 있는 작업들은 뭐가 있을까요?

무거운(heavy) 작업들이 이에 해당합니다.
단순히 SlowList와 같이 컴포넌트의 양(count)이 많은 경우나, 서버의 응답을 받아오는 네트워크요청이 발생하는 경우에도 서버의 응답을 받아오는 동안 React가 기다려야합니다.

React의 동시성렌더링 사용하기

우선, 동시성렌더링의 핵심은 컴포넌트를 비동기적으로 렌더링 시킬 수 있다는 것입니다.
컴포넌트를 비동기적으로 렌더링하기 위해서는 startTransitionuseDeferredValue 두 가지 방법이 존재합니다.
무겁지만 급하지않은 작업을 transition으로 지정해주면 반응성 중단을 피할 수 있습니다.
useDeferredValue는 애초부터 '지연된 상태'를 만들어, 지연된 상태의 업데이트는 천천히 반영하여 반응성 중단을 피할 수 있습니다.

startTransition

위에서 보여드린 예시에서 단 한줄만 수정하면 startTransition을 이용할 수 있습니다.
아래와 같이 setTab과 같은 setter를 담고있는 함수를 startTransition으로 감싸주기만 하면 됩니다.
startTransition으로 감쌀지 말지는 어떻게 판단할까요?
이 setTab으로 인한 리렌더링이

  1. 오래 걸릴 것이며 (다른 render를 블로킹)
  2. 다른 상호작용을 막으면서 보여줄 만큼 중요하지는 않다.

1과 2 기준을 충족시킨다면, setter 함수를 startTransition으로 감싸줄 수 있습니다.

before

const selectTab = (nextTab) => {
  setTab(nextTab);
};

after

import { startTransition } from "react";

const selectTab = (nextTab) => {
  startTransition(() => {
    setTab(nextTab);
  });
};

로 바꿔 주면 됩니다.

이와같이 바꿔준다면, 아래와 같이 동작하게 됩니다.

[영상] 버전0. 최적화 적용 전

Contact를 누르자마자 Posts를 누르고 잠시뒤 About을 연타한 모습입니다.

Posts 탭 클릭 후에
About에 마우스를 올려도 hover UI가 표시되지않으며,
실제로는 계속 About 버튼 클릭을 하고 있지만 반응이 없습니다.

[영상] 버전 1. 반응성 중단 해결. startTransition 사용.

Posts 탭 클릭 후에도 탭이 반응성을 잃지 않습니다.
Posts 탭 클릭 이후 About에 마우스를 올렸을 때,
hover가 즉각적인 반응성을 보이며,
About버튼을 클릭 시 Posts 렌더를 중지하고 About 컴포넌트를 보여줍니다.
도중에 마음이바뀌어 Posts말고 About을 보고싶어서 About을 클릭해도 탭은 즉각반응합니다.

설명

startTransition은 상태가 변경되거나 리렌더를 일으킬만한 무거운 작업에 감싸주는 것만으로도 사용할 수 있습니다.

그 다음으로는 useTransition 훅을 알아보겠습니다.

useTransition 훅

앞서 보았던 startTransition은 훅이 아니라 함수(API) 입니다.
useTransition은 이것의 훅 버전입니다.
useTransition은 startTransition과 isPending을 반환합니다.
따라서 isPending (과거데이터를 보여주고 있는상태인가) 상태에 따라 다른 UI를 보여주고싶다면 useTransition훅을 사용할 수 있습니다.

startTransition 과 사용법이 거의 동일합니다.
startTransition 사용법이

import { startTransition } from "react";

const selectTab = (nextTab) => {
  startTransition(() => {
    setTab(nextTab);
  });
};

이와 같을 경우

useTransition도 동일하게

import { useTransition } from "react";

const [isPending, startTransition] = useTransition();
const selectTab = (nextTab) => {
  startTransition(() => {
    setTab(nextTab);
  });
};

if (isPending) return <div>Pending...</div>;

와 같이 사용할 수 있습니다.
startTransition외에 지금 Transition에 의한 지연렌더링 중인지(isPending)를 추가로 사용할 수 있다는 장점입니다.

useDefferedValue 훅

startTransition의 경우, 호출되면 리렌더를 일으키는 함수를 startTransition으로 감싸서 지연시켰습니다.
리렌더를 일으킬 수 있는 조건은 기본적으로 상태의 변경, prop의 변경, 부모의 리렌더입니다.
startTransition은 setter 함수(상태의 변경)의 반영을 지연시키는 효과인 것입니다.
useDeferredValue는 어떤 것일까요?
startTransition은 setter 함수에만 지연효과를 준 것임에 반해,
useDeferredValue는 지연된 상태를 하나 더 만들어냅니다. 이 상태가 업데이트 될 때에는 항상 지연 업데이트됩니다.
예를들면 App.js를 아래와 같이 바꾸어 사용할 수 있습니다.

import { useState, useDeferredValue } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";

export default function TabContainer() {
  const [tab, setTab] = useState("about");
  const deferredTab = useDeferredValue(tab);
  function selectTab(nextTab) {
    setTab(nextTab);
  }

  return (
    <>
      <TabButton
        isActive={deferredTab === "about"}
        onClick={() => selectTab("about")}
      >
        About
      </TabButton>
      <TabButton
        isActive={deferredTab === "posts"}
        onClick={() => selectTab("posts")}
      >
        Posts (slow)
      </TabButton>
      <TabButton
        isActive={deferredTab === "contact"}
        onClick={() => selectTab("contact")}
      >
        Contact
      </TabButton>
      <hr />
      {deferredTab === "about" && <AboutTab />}
      {deferredTab === "posts" && <PostsTab />}
      {deferredTab === "contact" && <ContactTab />}
    </>
  );
}

이렇게 사용한다면 startTransition으로 지연시켰던 상황과 동일하게 작동합니다.

deferredTab이라고하는 상태가 하나 더 생겼는데요.
이 deferredTab이라는 지연된 상태는 기본적으로 원래상태인 tab을 추종합니다.
하지만 deferredTab의 값에 따라 AboutTab, PostsTab, ContactTab등 다른 탭들을 보여주고 있는데요.
이 deferredTab 값이 바뀌어 다른 탭을 보여주는 동안 렌더링이 오래걸린다면, 이 렌더링은 반영하지않고 다른 렌더링이 다 되면 즉각적으로 화면을 보여줍니다.

[영상] 버전 1. 반응성 중단 해결. useDeferredValue 사용.

Posts 탭 클릭 후에
About에 마우스를 올렸을 때 hover UI로 즉각적인 반응성을 보이며,
About을 클릭시 Posts 렌더를 중지하고 About 컴포넌트를 보여줍니다.

하지만 여기서 useDeferredValue에서만 해줄 수 있는 기능이 하나 더 있습니다.
사용자가 Posts(slow) 버튼을 누르고나서,
Posts(slow) 버튼이 잘 동작하고 있지 않은 것인지,
오래걸리는 렌더를 열심히 그리는 중인지 알 수 있도록 상호작용을 제공하지 않고 있습니다.

만약 현재탭을 보여주는 동작이 Posts (slow)가 다 그려진 후가아니라
현재탭은 클릭시 바로 적용되고,
그 탭 내용의 업데이트는 지연된 렌더를 하려면 어떻게할까요?
아래와 같이 Tab의 Active판단(빠름)은 실제 상태인 tab 변수를 사용하고,
탭 컨텐츠 리렌더는 지연된 상태인 deferredTab을 이용하면 됩니다.

import { useState, useDeferredValue } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";

export default function TabContainer() {
  const [tab, setTab] = useState("about");
  const deferredTab = useDeferredValue(tab);
  function selectTab(nextTab) {
    setTab(nextTab);
  }

  return (
    <>
      <TabButton isActive={tab === "about"} onClick={() => selectTab("about")}> // 바뀐 부분 : deferredTab -> tab으로 변경됨.
        About
      </TabButton>
      <TabButton isActive={tab === "posts"} onClick={() => selectTab("posts")}>
        Posts (slow)
      </TabButton>
      <TabButton
        isActive={tab === "contact"}
        onClick={() => selectTab("contact")}
      >
        Contact
      </TabButton>
      <hr />
      {deferredTab === "about" && <AboutTab />}
      {deferredTab === "posts" && <PostsTab />}
      {deferredTab === "contact" && <ContactTab />}
    </>
  );
}

[영상] 버전 2. 반응성 중단 해결 + 버튼 클릭 UI도 즉각 보여주기. useDeferredValue와 기존상태 함께 사용.

이와같이 Posts를 클릭하면 컨텐츠가 렌더링되는 중에도 탭은 즉각적인 업데이트를 할 수 있게 됩니다.

useDeferredValue 버전1과 버전2의 비교

버전1은 탭 컨텐츠를 지연렌더하는 것은 성공하였지만, 탭버튼이 클릭되었다는 UI 피드백을 즉각적으로 주지 않고있습니다.
반면 버전 2는 탭버튼의 UI피드백까지 즉각적으로 제공합니다.

두 버전의 코드상의 차이만을 간결하게 비교해보겠습니다.

버전1

<TabButton isActive={deferredTab === "about"} onClick={() => selectTab("about")}> // 바뀐 부분 : deferredTab -> tab으로 변경됨.
        About
</TabButton>
      {/*...생략...*/}
{deferredTab === "about" && <AboutTab />}

버전2

<TabButton isActive={tab === "about"} onClick={() => selectTab("about")}> // 바뀐 부분 : deferredTab -> tab으로 변경됨.
        About
</TabButton>
      {/*...생략...*/}
{deferredTab === "about" && <AboutTab />}

바로 TabButton에 들어가는 상태가 deferredTab -> tab으로 바뀐 것입니다.
Tab버튼 컴포넌트의 isActive를 판단할 때 deferredTab을 사용하고있었지만
deferredTab은 지연되는 상태이기때문에 즉각적으로 반영되지 않습니다.

하지만 TabButton의 렌더링은 지연렌더링 2개의 기준

    1. 렌더링이 오래걸리는가?
    1. 중요하지 않은 렌더링인가?

중 1번을 충족하지 못합니다.

따라서 지연렌더링을 하지 않는 게 좋겠죠?
그래서 지연되지 않은 상태인 tab을 사용해주면 버전 2가 됩니다.

더 관심이 있으시다면

(deferredTab !== tab) 일 경우 화면이 변경중이라는 작은 안내문구를 탭 옆에 표시하여 더 멋진 UI를 만들 수도 있습니다.

마무리

startTransition과 useDeferredValue의 차이는?

startTransition은 상태를 업데이트할때 논블로킹 방식을 사용합니다.
useDeferredValue는 논블로킹방식만을 사용하는 상태를 만듭니다.

useTransition과 useDeferredValue를 각각 어떤상황에서 쓰면 좋을 까요?

동시성 렌더링은 useTransition과 useDeferredValue를 통해 달성할 수 있음을 보여드렸습니다.
원리적으로는
둘은 모두 값의 변화를 지연시켜서 렌더링을 지연시키는 방법으로 동일하지만, 작은 차이가 있습니다.
useTransition은 인자로 넘어온 ‘함수’ 내에서만 지연된 상태업데이트를 사용하지만,
useDeferredValue로 만든 상태는 '항상' 지연 업데이트를 합니다.

사용상에서의 차이는 어떤 게 있을까요?
useDeferredValue를 사용하는 경우, 기존의 tab 값과 deferredTab으로 지연된 상태값 모두를 가지고 있게됩니다. 따라서 특정 자식컴포넌트는 tab로 실제상태값으로 업데이트시키고,
느릴 것으로 예상되는 컴포넌트에만 deferredTab를 주어 지연시킬 수 있습니다.

반면 useTransition은 기본 상태와 지연된 상태를 가진 게 아니라, tab이라는 상태를 지연 업데이트 시킬 뿐이기 때문에 그러한 분리된 동작까지는 할 수 없습니다.

즉, useDeferredValue의 예시로 보여드렸던 경우에 useTransition을 사용하기는 어렵습니다.
왜냐하면 useTransition으로 변경되는 tab자체를 지연시킬 경우
이에 따라 탭의 렌더링자체도 지연되어버리기때문입니다.

이에따라 지연되는 상태값과, 기존의 상태값 둘을 분리하여 각각 사용하고싶은 경우
useDeferredValue를 사용하고, 그러한 세밀한 조정이 필요 없을 경우엔 useTransition 또는 startTransition을 사용할 수 있습니다.

지연되는 상태값이 꼭 필요한 다른 사례로는 input 제어컴포넌트에따라 fetch 요청을 발생시켜` 느린컴포넌트를 업데이트하는 경우가 있습니다.
제어컴포넌트인 input 컴포넌트의 상태업데이트를 단순히 지연시켜버리면 사용자의 입력상호작용마저 지연되기 때문입니다.
이런 경우에도 useDeferredValue를 사용하여 기존상태와 지연된 상태를 각각 가지고 있어야 할 것입니다. 기존 상태로 사용자의 상호작용을 즉시업데이트하고, 지연된 상태를 사용하여 느린 컴포넌트를 업데이트 할 수 있습니다.

동시성 렌더링은 어떤 원리로 작동하는 걸까요?

동시성 렌더링 지원과 관련된 기능은 startTransition, useTransition, useDeferredValue 등이 있습니다.

responsive 중단현상의 핵심원인은 Render과정이 동기적이라는 점입니다.
따라서, 비쌀 것으로 예상되지만 상대적으로 덜 중요한 기능을 미리 개발자가 지정해주면,
해당 기능은 render과정에서 잠시 스킵하고 화면을 띄운 후, 추후 유휴시간(idle time)에 작업하여 업데이트 합니다.

transition으로 지정해준다고 리액트의 동작을 막지않는 이유가 뭐죠?

'그 함수자체가 오래걸리면 결국 그동안은 main 스레드를 블로킹하는 것 아닐까요? 어떻게 리액트의 동작을 막지 않을까요?'

'JS엔진은 싱글스레드이니까 유휴시간에 작동을 시작하더라도 main 스레드에서 동작하므로 함수자체의 동작이 오래걸리면 JS를 블로킹하는 게 아닌가요?' 와 같은 의문을 가지실 수 있습니다.

시작은 유휴시간에 하더라도 만약 그 작업이 100초가 걸린다면 그 동안은 responsive block이 발생할 것입니다. render block이 발생하는 걸 막기 위해 리액트는 아래의 3가지 동작을 수행합니다.

  • 멈춤(yield): 매 5ms마다 리액트는 작업을 멈추고 브라우저에 양보합니다. 덕분에 브라우저는 Promise나 Fire된 event를 처리할 수 있습니다. 5ms마다 리액트가 멈추기위해 큰 작업들을 작은 chunk로 쪼갭니다.
  • 중단(interrupt): 리액트는 우선순위가 더 높은 렌더링 작업이 들어오면 하던 작업을 중단하고, 중요도가 더 높은 렌더링을 처리 후 복귀합니다. 구체적으로, useDeferredValue나 startTransition으로 발생한 덜 중요한 업데이트가 우선순위가 더 낮습니다.
  • 과거 결과 스킵 (skipping old results): 중단 후 돌아왔을 때, 리액트는 가장 최신 결과(prop, state)를 기준으로만 렌더합니다. 과거의 이력들은 모두 무시합니다.

참고

profile
가치를 빠르고 안전하게 전달하는 개발을 하고 싶습니다.

0개의 댓글

관련 채용 정보