React 18 (RC in now)

cleopatra·2022년 2월 9일
2

와 벨로그 오랜만,,

개요

딱히 할 것도 없는 차에 React 18 버전 업데이트 소식을 뒤늦게 접하였고, 몇 가지 흥미로운 기능들이 보여 공식 discussion의 몇가지 내용을 번역/정리합니다.

링크의 내용을 참조했습니다.


아직 18 버전은 Alpha, Beta 를 거쳐 현재 RC 상태. 따라서 아래에서 소개하는 기능은 최종 릴리즈 당시 바뀔 수 있음을 알립니다.

많은 블로그 포스트에서 18버전을 아우르는 키워드는 동시성이라고들 해요.

아래에서 다루겠지만 렌더링 순서에 우선순위를 부여(startTransition)한다던가, SSR (Server-Side Rendering) 구조를 개선했다던가 하는 내용이 주를 이룹니다. 싱글 스레드 JS로 개발하는 웹 어플리케이션에 동시성을 부여함으로써, 개발 방식(workflow)과 성능을 개선한다는 것이죠.

미리 써볼 수 있나요?

다음의 방식으로 RC 버전을 설치해 사용할 수 있습니다.

npm install next@latest react@rc react-dom@rc

추가(변경) 되었어요

  • (out-of-the-box) Automatic batching
  • (out-of-the-box) SSR support for Suspense
  • (out-of-the-box) Fixes for Suspense behavior quirks (제외)
  • startTransition
  • useDeferredValue
  • <SuspenseList>
  • Streaming SSR with selective hydration

현재 React 18 Discussion 에서 명시한 변경사항들입니다. 가장 메인은 역시 Automatic batching 이 아닐까..

어쨌든 본 문서에서는

  • Automatic batching
  • startTransition
  • useDeferredValue

이 세 가지에 대해 알아보기로 합니다.

SSR (Server-Side-Rendering) 관련 내용은 기회가 된다면 다음에...




1. Automatic batching

Batching 이 뭔데요?

Batching is when React groups multiple state updates into a single re-render for better performance.

다시 말해 여러 개의 state 업데이트 상황을 하나의 re-render로 묶어 더 빨리 처리하자! 하는 것입니다.


useState 를 사용하신 분들이라면 이 구문이 비동기로 동작한다는 사실을 이미 알고 계실거에요.

한 번의 클릭 이벤트로 2개의 state 가 변경된다고 칩시다. count 와 flag 가 변경되면 <h1> 태그가 변경될테고, 그럼 화면을 다시 렌더링 해야겠죠. React 는 항상 이 변경 사항(update)들을 하나의 리랜더링(re-render)에 모아서 처리합니다.


아래의 코드를 보세요.
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
 
  function handleClick() {
    setCount(c => c + 1); // 아직 렌더링 안했어요!
    setFlag(f => !f); // 아직 렌더링 안해요!!
    // React는 이 끝에서 리-렌더링을 한번만 합니다. (that's batching!)
  }
 
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

그러므로 Batching 은 성능에 어마어마한 영향을 줍니다. 그렇지 않나요?

당장 위의 20줄 짜리 코드에서도 2번 렌더링 할 것을 단 한 번으로 줄여주었으니까요.


🤚 또 있습니다.
batching 을 하지 않으면 count 가 바뀌었을 때 <h1>을 새로 렌더링 될 거고, 동 순간 개발자가 의도한 대로 color는 변경되지 않을 겁니다.

이건 버그에요. Batching 은 이런 버그를 예방하는 효과도 있습니다.



그런데 말입니다. 🤔


그동안 React 가 update를 모아서 한 번에 처리하는 Batching에는 일관성이 없었습니다.

예를 들어 데이터를 fetch한 다음 state를 업데이트 해야하는 상황에서는 Batching 이 적용되지 않아요. 두 번의 독립적인 update 가 실행되는 거죠.



왜 그럴까요?

👉 보통 리액트는 브라우저의 이벤트가 일어나는 동안에만 batching을 하기 때문입니다.


위의 예시에서는.... 그냥 한 번 더 코드로 다시 보겠습니다.

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
 
  function handleClick() {
    fetchSomething().then(() => {
      // 그동안의 React 는 여기서 batch 하지 않아요.
      // 이유는.. 위에 적어뒀습니다.
      setCount(c => c + 1); // re-render 를 유발하는 구문
      setFlag(f => !f); // 역시 re-render를 유발하는 구문
    });
  }
 
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

요약하자면, 그동안의 React (17) 는 이벤트 핸들러의 밖에서는 Batch 처리를 하지 않았습니다. 이벤트를 핸들링하는 동안에만 일괄 업데이트(batching)를 했어요.

따라서 기본적으로 Promise 구문, setTimeout, native event handlers, 또는 등등의 모든 이벤트 내에서 발생하는 변경사항(update)는 Batch 처리 되지 않았습니다.


지금까지는요.
😉



진짜 진짜 Automatic batching

이제 18이 나옵니다!

React 18의 createRoot 를 사용하면, 모든 변경사항(update)은 자동으로 batch 처리 될 거에요! 변경사항이 어디에서 수행되든지 상관 없어요.

즉, promise건 timeout 이건 native 이벤트이건 모든 이벤트가 React 자체 이벤트와 같은 방식으로 변경사항을 일괄 처리(batch)한다는 거죠. 일관성이 생긴 겁니다.

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
 
  function handleClick() {
    fetchSomething().then(() => {
      // 이제 여기서 batching 이 일어납니다.
      setCount(c => c + 1);
      setFlag(f => !f);
      // 이 끝에서 배치 처리 되어 한 번에 업데이트 될거에요!
    });
  }
 
  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

이제 React 는 자동으로 모든 변경사항을 Batch 처리 할거에요. 변경(update)가 어디서 일어나든 상관 없이 말이죠.

그래서!

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}

이것도

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
}, 1000);

이것도

fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
})

이것도

elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React will only re-render once at the end (that's batching!)
});

그리고 이것도!
모두 동일하게 atching 됩니다.


물론 이런 상황을 의심 많은 사람들을 위한 데모 코드도 있습니다. 아래 샌드박스로 들어가 Console log 를 확인해보세요.

React 17 : 배치 처리 되지 않고 2번 렌더링 되는 예제
React 18 : event handler 밖에서도 완벽하게 배치 처리되는 예제!



Automatic batching 적용하는 법 : createRoot 사용하기

일단 "root"가 뭔지부터 짚고 갈까요.

React에서 root는 rendering Tree(다들 아는 그것)를 트래킹 하기 위해 사용하는 데이터 구조의 최상위를 가리킵니다.


일반적으로 사용하는 index.js 의 root 코드를 보겠습니다.

import * as ReactDOM from 'react-dom';
import App from 'App';
 
const container = document.getElementById('app');
 
// 최초의 렌더링
ReactDOM.render(<App tab="home" />, container);
 
// 변경사항이 발생하면, React 는 DOM 객체의 root 에 접근합니다.
ReactDOM.render(<App tab="profile" />, container);

렌더링 할 때 ReactDom.render() 라는 API 를 사용하는 것을 볼 수 있을텐데요, 공식 문서에서는 이것을 Legacy root API 라고 부릅니다.

ReactDom.render() 에서 "root"는 DOM 객체에 이어붙이는(attached) 용도이고 여기에 접근하기 위해서 역시 DOM 노드를 사용하기 때문에 "root"라는게 뭔지 알 수 없습니다.



지금부터 우리가 사용하려는 New Root API, 즉 createRoot() 에서는 이렇게 바뀔거에요.

import * as ReactDOM from 'react-dom';
import App from 'App';
 
const container = document.getElementById('app');
 
// root 를 명시적으로 생성합니다.
const root = ReactDOM.createRoot(container);
 
// 최초의 렌더링: 객체를 root 에 렌더링하기
root.render(<App tab="home" />);
 
// 변경 사항이 발생하면 container 에 root 를 다시 전달 할 필요가 없습니다.
root.render(<App tab="profile" />);

이해가 되시나요?

개발자의 입장에서 대체 뭔 소리야 싶었던 root 가 짜잔🎉, 하고 명시적으로 등장했습니다. 덕분에 렌더링 할 때 마다 container 를 매번 render()함수에 전달 할 필요가 없어졌어요.

Batch 처리하기 싫어요!

가끔은 state 가 변경되는 즉시 DOM 에 반영해야 할 상황이 생깁니다. Batching 하기엔 빠른 반영이 필요한 것들.

그런 상황을 위해 Batch에서 제외할 수 있는 API 를 제공합니다. 바로 flushSync 에요.

import { flushSync } from 'react-dom'; // Note: react-dom, not react
 
function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // 여기서 바로 DOM을 업데이트 합니다.
  flushSync(() => {
    setFlag(f => !f);
  });
  // 여기서도 마찬가지.
}

여기까지 batching 관련한 내용이었습니다.

정리하느라 생략된 내용들이 많습니다. 공식 Github 문서에서 나머지 내용을 살펴보세요.



2. startTransition

React 18 에서 소개하는 새로운 API 입니다. 이 API는 대량의 update 를 처리하는 와중에도 우리의 어플리케이션이 응답성을 유지할 수 있게 도와줍니다.


startTransition()을 사용하면 특정 update를 'transitions'로 표시해서 UI를 개선할 수 있습니다. 따라서 이를 이용하명 state 가 변경되는 중에 시각적 피드백을 제공하게 하고 브라우저의 응답성을 유지할 수 있습니다.

무슨 말인지는, 아래의 동영상을 보면 확실히 알 수 있습니다.
(출처 : 공식 discussion)

클릭해서 보기

코드로도 볼까요.

function FastSlider({defaultValue, onChange}) {
  const [value, setValue] = useState(defaultValue);
  const timeout = useRef(null);
   
  return (
    <Slider
      value={value}
      onChange={(e, nextValue) => {
        clearTimeout(timeout.current);
 
        // Update the slider.
        setValue(nextValue);
 
        // 슬라이더가 100ms 이상 움직임이 없으면
        // 값이 변경된 걸로 칩니다.
        timeout.current = setTimeout(() => {
          onChange(nextValue);
        }, 100);
      }}
    />
  );
}

상단의 슬라이더로 value를 변경할 수 있으며, 변경된 value를 참조하는 하단의 그래프가 re-rendering되는 UI입니다.

일반적으로는 문제가 없을 수 있지만, 그래프가 대량의 update를 처리하는 경우라면 동영상에서 처럼 화면 자체가 멈춰버리는 현상이 생깁니다.

  ![](https://velog.velcdn.com/images%2Fcindy-choi%2Fpost%2Fcdfedd6c-c559-4113-b407-f40f655c6c0a%2Fimage.png)
  

최악이야.

그러면 위의 코드를 개선해보겠습니다.

참고로 startTransition을 사용하기 위해서는 섹션 1에 언급 된 New Root API 를 사용해야 하기 때문에, index.js 파일의 root 를 아래와 같이 바꿔주세요.

// 이거를
ReactDOM.render(<App />, document.getElementById('root'));
 
// 이렇게
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

이제 코드에 직접 startTransition()을 사용해보겠습니다.

import { startTransition } from 'react';
 
// ...그 외 생략
 
function FastSlider({defaultValue, onChange}) {
  const [value, setValue] = useState(defaultValue);
 
  return (
    <Slider
      value={value}
      onChange={(e, nextValue) => {
        {/* 여기에서 slider의 값(value)이 변경됩니다. */}
        setValue(nextValue);
        onChange(nextValue);
      }}
    />
  );
}
 
// 버블 차트 부분
function ClassicAssociationsBubbles({associations}) {
  const [minScore, setMinScore] = useState(0.1);
 
  const data = computeData(minScore);
 
  return (
    <>
      <FastSlider defaultValue={0.1} onChange={val => {
        {/* Update the results, in a transition. */}
        startTransition(() => {
          setMinScore(val);
        });
      }/>
      <ExpensiveChart data={data}/>
    </>
  );
}

여기까지만 해도 적용 되지만, 하나만 더 개선해볼게요.

사용자에게 현재 차트가 그려지는 중에는 차트의 투명도를 낮추어 펜딩 상태를 가시적으로 표현해보겠습니다.

import { useTransition } from 'react';
 
// FastSlider 부분은 놔두고 아래의 버블 차트 부분만 변경합니다.
 
function ClassicAssociationsBubbles({associations}) {
  // New hook in React 18
  const [isPending, startTransition] = useTransition();
   
  const [minScore, setMinScore] = useState(0.1);
  const data = computeData(minScore);
 
  return (
    <>
      <FastSlider defaultValue={0.1} onChange={val => {
        /* transition 내부에서 값을 변경합니다. */}
        startTransition(() => {
          setMinScore(val);
        });
      }/>
      <ExpensiveChart
        // pending class 적용하기.
        className={isPending ? 'pending' : 'done'}
        data={data}
      />
    </>
  );
}
 
// index.css
.pending {
  opacity: 0.7;
   
  // 너무 빨리 렌더링되면
  // 보이지 않을 수 있으므로 0.4s 지연을 줍니다.
  transition: opacity 0.2s 0.4s linear;
}
 
.done {
  opacity: 1;
  transition: opacity 0s 0s linear;
}

여기까지!
수정된 코드의 결과 역시 아래의 동영상으로 확인하세요.

그래프가 렌더링 되는 동안에도 슬라이더는 사용자의 움직임에 반응하며, 차트가 렌더링 되는 동안 투명도 값이 조절되므로 사용자는 그래프의 로딩 상황을 알 수 있습니다. 🧐

startTransition에 대해 더 알고 싶다면 공식 discussion을 참조하세요.



3. useDeferredValue

'use'로 시작하는 걸로 봐서는 hook이에요.
이쪽은 정보가 많이 없었습니다. 많이 주목받지 못하는 자그마한 어쩌고,,,

useDeferredValue 는 말 그대로 어떤 변수의 deferred 된 값을 반환하는 hook입니다.


react 가 deferred 개념을 처음 사용한 것은 아닙니다.

기존에도 HTML 의 <script> 태그에 defer 옵션이 있었고, jQuery의 promise를 공부할 때면 근근히 따라 다니는 것이 deferred 개념이었죠.

가장 쉬운 서치바를 만들 때에도 이 개념은 유효합니다. 저는 직접 구현하기도 했지만 주로 lodash 의 debounce() API 를 사용했었습니다.

아래는 간단한 예시입니다.
input 에 100ms 동안 변경사항이 없으면 searchQuery에 검색어를 저장하여 MyList에서 필터링합니다.

function FastSlider() {
  const [value, setValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const timeout = useRef(null);
   
  return (
    <input
      value={value}
      onChange={(e, nextValue) => {
        clearTimeout(timeout.current);
        setValue(nextValue);
 
        {/* 100ms 이상 값이 변하지 않으면 검색어를 업데이트합니다. */}
        timeout.current = setTimeout(() => {
          setSearchQuery(nextValue);
        }, 100);
      }}
    />

    <MyList text={searchQuery} />
  );
}

그러나!
이제 React에서 제공하는 hook을 사용해서 아주 짧은 코드로 대체가 가능합니다.

function App() {
  const [text, setText] = useState("hello");
  const deferredText = useDeferredValue(text, { timeoutMs: 2000 });
 
  return (
    <div className="App">
      {/* input에 현재 텍스트를 계속 전달합니다. */}
      <input value={text} onChange={handleChange} />
      ...
      {/* 하지만 이 목록은 필요한 경우 "뒤처질" 수 있습니다. */}
      <MySlowList text={deferredText} />
    </div>
  );
 }

짠.
코드가 매우 심플해졌죠. 또 기존에 lodash 의 debounce()를 사용하시던 분들이라면 외부 라이브러리 의존성을 하나 덜었습니다.

물론 useDeferredValue()의 두번째 매개변수 timeoutMs 는 ms 단위로 사용자가 얼마든지 조절이 가능합니다.




여기까지 RC 단계에 있는 React 18의 일부 기능에 대해 알아봤습니다.
그럼 이만.

profile
안녕나는클레오파트라세상에서제일가는포테이토칩

0개의 댓글