Suspense와 Concurrent Mode로 DX와 UX 둘 다 챙기기

Doeunnkimm·2023년 9월 3일
6

React

목록 보기
4/5
post-thumbnail

비동기 프로그래밍

📌 비동기
   앞에서 행하여진 사상(事象)이나 연산이 완료되었다는 신호를 받고 비로소 특정한 사상이나 연산이 시작되는 방식
   - [네이버 국어사전]

즉, 비동기 프로그래밍이란 특정 코드의 처리가 완료되기 전, 처리하는 도중에도 아래로 계속 내려가면 수행을 하는 것을 말합니다.

비동기 프로그래밍이 필요한 이유

예를 들어, 서버에게 요청을 보내고 응답만을 기다리고 유저의 다른 인터렉션은 반응하지 않는다면 그냥 멈춰있게 됩니다. 여기서 비동기 프로그래밍이 있다면, 서버에 요청을 보내고, 기다리는 게 아니라 다른 작업을 하면서 사용자에게 좋은 경험을 보여주다가, 서버 응답이 돌아오면 이어서 할 일을 할 수 있습니다.

⭐️ 비동기 프로그래밍은 좋은 사용자 경험을 위해 필수!

React 컴포넌트에서의 비동기 처리

😰 컴포넌트에서 로딩과 에러 처리 수행

function App() {
  return (
    <>
      <Foo />
    </>
  )
}

function Foo() {
  const foo = useFooQuery()
  
  if (foo.error) return <div>로딩에 실패했습니다.</div>
  if (!foo.data) return <div>로딩중..</div>
  return <div>{foo.data.name}님 안녕하세요!</div>
}

위 코드는 비동기인 foo를 가져오는데, foo가 에러이면 실패 메시지를 보여주고, foo가 없으면 로딩 중이라고 보여주고, foo가 있으면 안녕하세요라는 메시지를 보여주고, 위와 같이 코드를 많이 작성합니다. (저도 그랬는데요.)

😣 이렇게 컴포넌트에서 로딩과 에러 처리를 수행하면 좋지 못해요

  1. 핵심 로직에 집중하기 어려워요
    한 컴포넌트에서 성공하는 경우와 실패하는 경우가 섞여서 처리되고 있어, 핵심 로직에 집중하기 어려워집니다.
  1. 여러 개의 비동기 작업이 실행된다면, 점점 더 복잡해져요

    function Foo() {
      const foo = useFooQuery()
      const bar = useBarQuery(foo)
    
      if (foo.error || bar.error) return <div>로딩에 실패했습니다.</div>
      if (!foo.data || !bar.data) return <div>로딩 중입니다...</div>
      return /* foo와 bar로 적합한 처리하기 */
    }

    위 코드는 foo와 bar라는 하는 값을 비동기로 가져오는 상황입니다. bar를 가져오기 위해서는 foo가 있어야 하는 상황인데요. foo를 가져오고, bar는 foo가 로드될 때까지 기다리고, if문을 복잡해지고... 복잡합니다.

React의 비동기 처리가 어려운 이유

위 코드에서 볼 수 있었듯이, 성공하는 경우에만 집중해 컴포넌트를 구성하기 어려웠습니다. 특히 2개 이상의 비동기 로직이 개입해 복잡해질 때 비즈니스 로직을 파악하기 점점 어려워졌습니다.

🥳 Suspense & ErrorBoundary로 위임하여 처리 수행

좀 전에 있었던 비동기 처리의 어려움과 컴포넌트 내부에서 처리했을 때의 문제점들을 Suspense와 ErrorBoudnary를 통해 해결할 수 있게 되었습니다.

function App () {
  return (
    <ErrorBoundary fallback={<MyErrorPage />}>
      <Suspense fallback={<Loader />}>
        <FooBar />
      </Suspense>
    </ErrorBoundary>
  )
}

function FooBar() {
  return (
    /* 성공했을 때의 비즈니스 로직 */
  )
}

위와 같이 Suspense와 ErrorBoundary를 이용해 에러 상태와 로딩 상태 처리를 외부로 위임하여 비동기를 처리하면서 간단하고 읽기 편한 React 컴포넌트 만들기가 가능해지게 되었습니다.

🤩 Suspense와 ErrorBoudnary로 위임함으로써 얻게 되는 이점은요

  1. 로딩 상태와 에러 상태 로직이 분리되어, 성공한 경우에만 집중할 수 있게 되었어요.
    FooBar 컴포넌트는 성공한 경우의 로직만을 작성할 수 있게 되어 핵심 기능이 드러나게 됩니다.

😦 Suspense의 문제점

Suspense로 로딩 상태를 분리함으로써 컴포넌트는 핵심 로직에 집중할 수 있게 되었으나, 만약 응답 속도가 매우 빠르게 이루어지는 비동기 요청에 대해서는 Loader로 인해 오히려 깜빡임으로 보여질 수 있습니다. 너무 빨라서 로더도 잠깐 뜨게 되는 것이죠.

위 이미지에서 보이는 것처럼 비동기 호출이 이루어질 때 어느정도 로딩이 발생한다면 Loader를 보여주는 UI는 필요합니다.

하지만 두 번째 이미지처럼 비동기 처리가 매우 빠르게 처리된다면 Loader를 띄우는 과정 때문에 오히려 깜빡임처럼 보이게 됩니다. 이는 사용자 경험 측면에서 화면 깜빡거림으로 인식되어 좋지 못한 UI가 되어 버립니다.

위와 같은 상황을 충분히 렌더링이 빠름에도 의미 없는 로딩을 보여주는 경우라고 말할 수 있겠습니다.

✨ useTransition으로 의미없는 로딩(깜빡임) 해결

위와 같은 현상을 해결할 수 있는 방법으로 React 18에서는 useTransition이라는 훅을 제공합니다.

const [isPending, startTransition] = useTransition()
  • isPending : 작업이 지연되고 있음을 알리는 boolean
  • startTransition : 낮은 우선순위로 실행할 함수를 인자로 받는다.
function App() {
  const [isPending, startTransition] = useTransition()
  const [page, setPage] = useState(1)
  
  const onNextPage = () => {
    startTransition(() => {
      setPage(prev => prev + 1)
    })
  }
  
  return (
    <Suspense fallback={<Loader />}>
      <Foo page={page} onNextPage={onNextPage} />
    </Suspense>
  )
}

function Foo({ page, onNextPage }) {
  return /* 성공했을 경우만 고려하여 작성 가능 */
}

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

제가 써봐도 사용자 경험이 이전보다 훨씬 좋아진 걸 느낄 수 있었습니다 👍🏻

그런데, 위와 같은 동작은 어떤 원리일까요? 그걸 알아보기 위해서는 Concurrent Mode를 알아봐야 합니다.

Concurrent Mode

JavaScript는 싱글 스레드 언어입니다. 이는 하나의 작업을 수행할 때 다른 작업을 동시에 수행할 수 없음을 의미합니다. 하지만 React에서 concurrent mode를 사용하면 여러 작업을 동시에 처리할 수 있습니다.

📌 concurrent mode를 사용하면 여러 작업을 동시에 처리 가능

React가 여러 작업을 동시에 처리하는 방식

React는 여러 작업을 작은 단위로 나눈 뒤, 그들 간의 우선순위를 정하고 그에 따라 작업을 번갈아 수행합니다. 서로 다른 작업들이 실제로 동시에 수행되는 것은 아니지만, 작업 간의 전환이 매우 빠르게 이루어지면서 동시에 수행되는 것처럼 보이게 되는 것입니다. 이를 동시성이라고 합니다.

이렇게 React는 동시성 개념을 도입해 싱글 스레드 환경에서 여러 작업을 동시에 할 수 있게 되었습니다.

Concurrent mode의 동작 원리

특정 state가 변경되었을 때 현 UI를 유지하고 해당 변경에 따른 UI 업데이트를 동시에 준비합니다. 준비 중인 UI의 렌더링 단계가 특정 조건에 부합하게 되면 실제 DOM에 반영하는 것이죠.

그래서 fallback으로 지정했던 컴포넌트를 띄우는 것이 아니라 현 UI를 유지하면서 변경을 준비합니다.

렌더링 단계

state 변경의 관점에서 보는 렌더링 관계는 위와 같이 3단계가 있습니다.

1. Transition 단계

Transition는 state 변경 직후에 일어날 수 있는 UI 렌더링 단계입니다.

  • Pending : useTransition 훅을 사용하면 state 변경 직후에 UI를 업데이트하지 않고 현 UI를 잠시 유지할 수 있는데 이를 Pending 상태라고 합니다.
  • Receded : useTransition 훅을 사용하지 않은 기본 상태. state 변경 직후 UI(Loader를 생각)가 변경됩니다.

🤔 그럼 Pending으로 걸어놨는데, 로딩 시간이 길어지면 UI에 안 좋은 거 아닌가?

Pending 상태에서도 Receded 상태로 넘어갈 수 있습니다! Pending 상태의 시간이 useTransition 옵션으로 지정된 timeoutMs를 넘으면 강제로 Receded 상태로 넘어갑니다 🙌

const [isPending, startTransition] = useTransition({
    timeoutMs: 3000
  });

2. Loading 단계

현재 컴포넌트의 자식 요소에서 발생되는 비동기 처리하는 과정을 처리 중인 단계입니다. 위에서 봤던 <h1>로딩중입니다~</h1>가 화면에 나왔었죠? 그걸 말합니다.

3. Done 단계

비동기 처리가 완료됨에 따라 완성된 UI를 보여줍니다.

참고

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

1개의 댓글

comment-user-thumbnail
2023년 9월 10일

오, 좋은 글 인정합니다.~.! 지금 하고 있는 프로젝트가 있는데, 팀원들한테 다 한번씩 읽어보라고 해야겠네요. bb:)

답글 달기