본문 바로가기

리액트 심화 스터디

🏄‍♀️ 리심스 7주차 - Concurrent Mode

Concurrent Mode와 Suspense는 데이터 로딩과 UI 렌더링의 효율을 높이고 사용자 경험을 개선하기 위해 리액트 최신 버전에 새롭게 도입된 기능이다.

Concurrent Mode

동시성(Concurrency)이란?
여러 작업을 작은 단위로 나눈 뒤 그 작업들 간의 우선순위를 정하고 작업을 번갈아 수행하는 것.

👉 실질적으로는 하나에 한 번의 작업을 처리하는 것이나, 빠른 작업 간의 전환으로 인해 하나의 시스템이 여러 작업을 동시에 처리하는 것처럼 보이게 한다.

 

리액트에서의 동시성 (Concurent Mode)

 

 

자바스크립트는 싱글 스레드 언어로, 하나의 작업이 실행되는 동안 다른 작업은 block된다. 

기존 렌더링은 동기적 처리로 이루어졌으며, 리액트가 렌더링 작업을 시작하면 중간에 중단할 수 없고 다른 작업을 진행하지 못하는 blocking되는 현상이 존재한다. 이것을 Blocking rendering이라고 한다.

 

구체적인 사례를 예시로 들어보겠다.

현재 진행중인 무거운 작업 때문에 다음 작업이 늦어지는 경우를 생각해보자. 예시로 사용자가 input을 입력할 때마다 무거운 작업을 수행하는 경우에 입력이 버벅이는 경우가 있다. (검색을 통한 목록 필터링 등)

 

다음은 사용자 입력마다 10000개의 DOM element를 생성하는 예제를 실행한 모습이다.

 

 

텍스트를 input창에 아주 빠르게 입력하고 있지만, 화면 상으로 버벅임이 있으며 느리게 입력되는 것처럼 보이는 것을 확인할 수 있다.

거대한 dom 렌더링이 발생하여 렌더링하는 동안 텍스트 입력을 업데이트 하는것이 차단되는 것이다.

 

https://playcode.io/1523275

 

이러한 문제를 Debounce와 Throttle로 해결할 수 있으나, 한계가 존재한다.

 

Debounce
사용자의 마지막 입력이 끝나고 일정 시간이 지나서 무거운 작업을 수행하게 된다.
다음 무거운 작업을 위해서 일정 시간을 낭비한다는 단점이 있다.

Throttle
입력 중에 주기적으로 무거운 작업을 수행하는 방식으로, 디바운스에서 사용자 입력 중에 무거운 처리가 이뤄지지 않는 단점을 해결한다.
하지만 이 또한 쓰로틀 주기를 짧게 가져갈수록 성능이 나쁜 기기에서는 버벅거리는 문제를 야기할 수 있다.

👉 두 방법 모두 사용자 경험을 완전히 개선하진 못함..

 

 

이는 사용자 경험을 저해하는 문제를 일으키며 동시성을 지원하는 Concurrent Mode의 도입을 통해 여러 작업을 동시에 처리할 수 있게 하여 해결할 수 있다. 

 

Concurrent Mode

최근 리액트는 Fiber 아키텍처가 도입되면서, 렌더링 작업을 더 작은 단위로 나누고 스케줄러를 통하여 각 작업들에 중요도에 따른 우선 순위를 부여할 수 있게 되었다. 렌더링 작업을 더 작은 단위로 나누고 작업 간 우선순위를 조정함으로써, 사용자가 느끼는 반응성을 최적화하는 것을 목표로 한다.

Concurrent Mode 활성화 시

1. 메인 스레드를 차단하지 않고
2. 한 번에 여러 작업을 수행하고 우선순위에 따라 작업을 전환하고
3. 결과를 확정하지 않고 부분적으로 렌더링할 수 있게 된다.


 

 

동시성이 줄 수 있는 이점을 나열해보자면 다음과 같다.

  • 동시에 여러 작업들을 처리하고, 우선 순위에 따라 각 작업들을 빠르게 전환
  • 애니메이션의 여러 프레임을 동시에 렌더링 가능 -> 더 매끄러운 애니메이션 구현
  •  단일 작업이 전체 애플리케이션의 렌더링을 차단하는 것을 방지하여 무거운 작업이나 네트워크 요청이 있어도 UI가 반응성을 유지하도록 보장
  • 입력 처리와 같은 사용자와 상호작용을 우선 순위로 지정하여, 무거운 연산이나 네트워크 요청이 발생하더라도 사용자 인터페이스가 반응성을 유지하도록 함

아까 설명한 input창 버벅임 예시는 동시성 도입을 통해 해결할 수 있다. 

 

Concurrent Mode 동작 원리

State 변경과 UI 준비 : 특정 state가 변경되면 React는 기존 UI를 유지하면서, 동시에 새 UI를 준비한다. 즉, 새 UI를 백그라운드에서 비동기로 계산하며, 준비가 완료되면 조건에 따라 DOM에 반영한다.

중단 가능성과 우선순위 기반 렌더링 : 이전에는 렌더링이 블로킹 작업이었다면, Concurrent Mode는 작업이 중단 가능하도록 설계되었다. 긴 작업이라도 중간에 끼어들거나, 우선순위가 높은 작업을 먼저 처리할 수 있다.

준비 중인 UI의 렌더링 단계특정 조건에 부합하면 실제 DOM에 반영한다.

 

📌 렌더링 단계

UI 업데이트는 state의 변경에 의해 발생하므로 각 단계는 특정 state 변경의 관점에서 보는 렌더링 단계이다.

UI 렌더링 단계

1. Transition 단계

Transition 단계는 useTransition 훅을 활용해 비동기 UI 전환을 설정할 때 주로 나타난다.

state 변경 직후에 일어날 수 있는 단계로, pending / receded 두 가지 상태가 있다.

  • pending
    • UI를 즉시 변경하지 않고, 현재 화면을 유지하며 새 UI를 준비하는 단계
    • useTransition의 startTransition 함수와 함께 사용되며, timeoutMs를 통해 Pending 상태의 유지 시간을 설정할 수 있다.
    • ex) 사용자가 검색어를 입력했을 때, 이전 검색 결과를 유지하며 새로운 검색 결과를 준비.
  • receded
    • Transition을 사용하지 않은 기본 상태, state 변경과 함께 UI가 즉시 업데이트
    • 예: 전체 페이지가 로딩 화면으로 전환.

Pending 상태에서도 Receded 상태로 넘어갈 수 있는데 Pending 상태의 시간이 useTransition 옵션으로 지정된 timeoutMs을 넘으면 강제로 Receded 상태로 넘어간다.

 

 

2. Loading 단계

  • UI의 일부만 로딩되거나, Skeleton UI(뼈대 로딩 화면)를 보여주는 상태
  • Suspense 컴포넌트를 활용해 특정 컴포넌트나 데이터가 로드되지 않은 상태를 처리할 수 있다.

 

3. Done 단계

  • Done 단계는 모든 데이터가 준비되고, 사용자에게 완전한 UI를 보여주는 최종 상태
  • Complete
    • 로딩이 완료되어 데이터와 UI가 완전히 렌더링된 상태 (DOM에 최종 결과를 반영)

 

📌 특정 조건

 

React에서 UI 업데이트를 실제 DOM에 반영하려면:

  1. 현재 화면의 UI 상태보다 더 최신 상태로 업데이트가 완료되어야 함
  2. Complete 상태로 넘어간 새 UI만 실제 DOM에 반영됨

 

더보기

예시

Pending 상태에서 Skeleton으로 넘어갈 때:

  • 버튼 클릭으로 state 변경 → React는 Pending 상태로 전환.
  • Skeleton 상태로 렌더링 준비가 완료되면 Skeleton이 DOM에 반영됨.
  • 이유: Skeleton은 Pending보다 더 최신 단계.

Complete 상태로 바로 넘어갈 때:

  • 이미 Complete 상태에서 같은 state가 업데이트되면 Pending이나 Skeleton은 건너뛰고 바로 새로운 Complete 상태가 반영됨.
  • 현재 UI 상태(Complete)가 Pending이나 Skeleton보다 더 최신 상태이기 때문

 

 

React의 Concurrent Mode는 최신 상태가 더 나은 사용자 경험을 제공하는가?라는 원칙에 따라 DOM에 반영 여부를 결정한다.

  • 더 최신 상태가 준비되기 전에는 기존 UI를 유지
  • 최신 상태가 Complete에 도달하면 DOM을 업데이트

 

Concurrent Mode 관련 주요 훅

Transitions

리액트는 비동기 작업을 효율적으로 처리하기 위한 여러 훅을 제공하는데, 이 중 useTransition과 useDeferredValue는 UI 업데이트의 우선 순위를 조정하거나 지연시키는 데 유용하다.

 

📍useTransition

  • 높은 우선순위 작업(ex 클릭, 입력 등)과 낮은 우선순위 작업(ex 검색 결과 렌더링)을 분리, 긴급하지 않은 작업에 startTransition를 사용하여 우선순위를 낮추어 UI 업데이트를 의도적으로 지연할 수 있다.
  • 사용자 입력에 즉각적인 반응을 제공하면서, 작업을 백그라운드에서 실행
  • 추가적으로 지연된 Transition이 있는지 여부를 알려주는 isPending 상태를 제공
import { useState, useTransition } from "react";

// 1. 입력창에 입력을 업데이트 합니다
setInputValue(input);

// Transitions안에 두어 우선순위를 낮춥니다. 
startTransition(() => {
  // 2. 입력된 데이터에 대한 검색을 진행합니다.
  setSearchQuery(input);
});

 

useTransition을 사용하여 본문 초반의 예제에서 버벅임 문제를 개선해보겠다.

const handleChange = (event) => {
    setInput(event.target.value);
    startTransition(() => {
      setArray(
        array.map((_, index) => ({
          text: texts[getRandomInt(0, 3)] + getRandomInt(0, 100),
          key: getRandomInt(0, 100),
        }))
      );
    });
  };

 

무거운 작업을 startTransition() 안에 위치하도록 하여 사용자 입력이 완료된 후 처리되도록 낮은 우선순위로 지정했다.

 

다음과 같이 입력창 업데이트가 화면에 바로 반영되어 버벅임이 많이 개선된 모습을 확인할 수 있다.

 

 

📍useDeferredValue

  • 특정 상태를 지연 렌더링하도록 설정
  • 값 자체를 지연시켜, 복잡한 작업이 사용자 경험에 영향을 주지 않게 함
  • 주어진 값의 변화가 렌더링에 영향을 미칠 때, 이 값을 지연된 값으로 참조
  • 입력 등 사용자 작업의 즉각성을 유지하면서, 지연된 값으로 컴포넌트를 렌더링
  • 입력 필드의 즉각적인 반응과 배경 업데이트를 분리

다음 코드는 입력값 변경에 따른 대규모 리스트 렌더링의 예제이다. useDeferredValue를 사용하여 입력값과 관련된 렌더링(대규모 리스트 렌더링)은 지연시키고, 입력창 업데이트는 즉각 실행되는 것으로 구현되어있다.

const handleChange = (event) => {
    setInput(event.target.value);
  };

  const deferredInput = useDeferredValue(input);
  const array = Array.from({ length: 5000 }, (_, index) => ({
    text: texts[getRandomInt(0, texts.length)] + getRandomInt(0, 100),
    key: getRandomInt(0, 100),
  }));

  return (
    <div>
      <input type="text" value={input} onChange={handleChange} />
      <ul>
        {deferredInput &&
          array.map((item, index) => (
            <li key={item.key || index}>{item.text}</li>
          ))}
      </ul>
    </div>
  );

 

입력값 처리와 관련된 대규모 작업은 백그라운드에서 진행되어 성능에 미치는 영향을 줄인다.

 

요약하자면, useTransition은 특정 작업을 나중에 실행, useDeferredValue은 상태 변경에 대한 반영을 지연하는 것이다.

 

대규모 상태 업데이트(ex 배열이나 데이터 처리)에 대한 최적화를 원한다면 startTransition, 입력값 기반 렌더링(ex 검색 필터나 조건부 목록 표시)에서는 useDeferredValue을 사용하는 것이 좋다.

 

 

Concurrent Mode 실제 도입 방법

1. Concurrent 모드 활성화

  • createRoot()를 사용
//index.js
import React from 'react';
import ReactDOM from 'react-DOM';
import App from './App';

const rootElement = document.getElementById('root');
ReactDOM.createRoot(rootElement).render(<App />);

 

 

2. state 반영 우선순위 지정하기

  • 위에서 소개한 useTransition 등을 이용하여 작업의 우선 순위를 지정한다.

 

Suspense 란?

리액트 18에서 새롭게 제공하는 컴포넌트로, 컴포넌트의 렌더링을 어떤 작업이 끝날 때까지 잠시 중단시키고 다른 컴포넌트를 먼저 렌더링할 수 있다.

컴포넌트를 아래와 같이 Suspense로 감싸주면 컴포넌트의 랜더링을 특정 작업 이후로 미루고, 그 작업이 끝날 때 까지는 fallback 속성으로 넘긴 컴포넌트를 대신 보여줄 수 있다.

// App.tsx
<ErrorBoundary fallback={<MyErrorPage />}> // 에러 처리
  <Suspense fallback={<Loader />}> // 로딩 처리
    <MyPage />
  </Suspense>
</ErrorBoundary>

 

주로 비동기로 데이터를 가져올 때 데이터를 기다리는 사용자의 경험을 개선하기 위해 사용하고, 데이터가 로드되는 동안 fallback 속성에 지정된 UI를 표시하며, 데이터 로딩이 완료되면 자동으로 자식 컴포넌트를 렌더링한다.

 

위의 예시처럼, Suspense와 ErrorBoundary를 사용하면 MyPage에서는 컴포넌트의 주된 목적인 사용자가 필요한 정보를 보여주기만 하면 된다. 즉, 데이터 요청 상태에 따른 처리를 관심사의 분리와 함께 선언적으로 처리가 가능하다.

 

Concurrent Mode와 Suspense

비동기 호출이 이뤄질 때 어느정도 로딩이 발생한다면 Suspense를 통해 폴백 UI를 보여주어 로딩상태임을 나타내는 것이 자연스러운 과정으로 보이지만, 오히려 사용자 경험 저해로 이어지는 경우도 있다.

 

다음은 Suspense를 통해 posts 탭을 클릭하면 구성 요소가 일시 중단되고 로딩 폴백이 나타나도록 구현한 코드와 코드의 실행 모습이다.

import { Suspense, 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');
  return (
    <Suspense fallback={<h1>🌀 Loading...</h1>}>
      <TabButton
        isActive={tab === 'about'}
        action={() => setTab('about')}
      >
        About
      </TabButton>
      <TabButton
        isActive={tab === 'posts'}
        action={() => setTab('posts')}
      >
        Posts
      </TabButton>
      <TabButton
        isActive={tab === 'contact'}
        action={() => setTab('contact')}
      >
        Contact
      </TabButton>
      <hr />
      {tab === 'about' && <AboutTab />}
      {tab === 'posts' && <PostsTab />}
      {tab === 'contact' && <ContactTab />}
    </Suspense>
  );
}

 

로딩 표시기를 표시하기 위해 탭 컨테이너 전체를 숨기면 사용자 경험을 저해할 수 있는데(로딩 중에는 탭 버튼이 사라져서 탭 전환할 수 있는 선택권이 사라져버림), useTransition을 활용하면 이를 개선할 수 있다. 

 

import { useTransition } from 'react';

export default function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();
  if (isActive) {
    return <b>{children}</b>
  }
  if (isPending) {
    return <b className="pending">{children}</b>;
  }
  return (
    <button onClick={() => {
      startTransition(() => {
        action();
      });
    }}>
      {children}
    </button>
  );
}

 

useTransition을 활용하여 버튼을 클릭할 때의 비동기 상태 업데이트를 우선순위 낮은 작업으로 처리하고, isPending 상태를 사용해 비동기 작업이 진행 중임을 나타내는 pending 상태를 UI에 표시하도록 했다.

 

-> 사용자 상호작용의 응답성을 유지하면서도, 비동기 상태 변화를 효율적으로 지연

 

 

 

Suspense는 데이터 로드와 같은 UI 컴포넌트 로드 과정을 선언적으로 관리하고, useTransition은 사용자 상호작용 중 발생할 수 있는 깜빡임과 성능 저하 문제를 해결한다.

 

Suspense와 useTransition을 상호보완적으로 사용하여 Suspense로는 데이터 로드 상태를 다루고, useTransition으로는 UI 업데이트의 렌더링 시점을 미세 조정할 수 있다.

 

결론

Concurrent Mode을 잘 이용하면 사용자와의 상호작용을 최적화할 수 있다.

startTransition, useTransition, useDeferredValue와 같은 훅을 활용하면 비동기적 작업과 렌더링을 조화롭게 처리할 수 있으며, Suspense와 결합해 로딩 상태를 자연스럽게 관리하면 대규모 애플리케이션에서 렌더링 성능과 사용자 인터페이스 응답성을 크게 향상시킬 수 있다.