FrontEnd/React.js

[React.js] React 18의 새로운 기능

푸고배 2022. 5. 18. 21:21

React 18의 새로운 기능

1. 자동 배치(Automatic Batching)

배치란, 리액트가 더 나은 성능을 위해 여러 개의 상태 업데이트를 한 번의 리렌더링(re-render)으로 묶는 작업이다.

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    setCount(c => c + 1); // 리렌더링 전
    setFlag(f => !f); // 리렌더링 전
    // 리액트는 오직 마지막에만 리렌더링을 한 번 수행한다. (배치 적용)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

위의 handleClick 이벤트 핸들러 함수에서는 상태 업데이트를 두 번(setCount, setFlag) 수행했지만, 리액트는 배치를 통해 두 번의 업데이트를 한 번의 리렌더링으로 처리한다. 이를 통해 불필요한 렌더링을 방지하고 의도치 않은 버그를 예방할 수 있다.

 

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // 리액트 17 및 그 이전 버전에서는 배치가 수행되지 않는다. 왜냐하면
      // 이 코드들은 이벤트 이후의 콜백에서 실행되기 때문이다.
      setCount(c => c + 1); // 리렌더링 
      setFlag(f => !f); // 리렌더링
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}

배치 작업은 React 이벤트 핸들러 내에서만 수행되므로, Promise 내부의 업데이트, setTimeout, 기본 이벤트 핸들러 또는 기타 이벤트에서는 처리되지 않았다. 

 

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 리액트는 오직 마지막에만 리렌더링을 한 번 수행한다. (배치 적용)
}, 1000);

fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 리액트는 오직 마지막에만 리렌더링을 한 번 수행한다. (배치 적용)
})

elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // 리액트는 오직 마지막에만 리렌더링을 한 번 수행한다. (배치 적용)
});

 

하지만, React 18부터 자동 배치(Automatic Batching)라는 것이 추가되었다. 자동 배치는 위와 같이 일반적인 React 이벤트 핸들러 함수 스코프에서 상태 업데이트가 일어나지 않더라도 자동으로 배치를 적용해준다.

 

자동 배치를 사용하기 위해서는 컴포넌트 트리를 기존의 ReactDOM.render 함수 대신 새로운 ReactDOM.createRoot 함수를 사용해야 한다.

 

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });  // 리액트는 즉시 DOM을 업데이트 한다.

  flushSync(() => {
    setFlag(f => !f);
  });  // 리액트는 즉시 DOM을 업데이트 한다.
}

그리고 상태 업데이트에 자동 배치가 적용되지 않았으면 하는 경우에는 새롭게 추가된 ReactDOM.flushSync 함수를 사용할 수 있다.

 

2. Transitions(전환)

전환은 긴급 업데이트와 긴급하지 않은 업데이트를 구분하기 위한 React의 새로운 개념이다.

  • 긴급 업데이트(Urgent updates) : 직접적인 상호 작용 반영(타이핑, 오버, 스크롤링 등)
  • 전환 업데이트(Transition updates) : 하나의 뷰에서 다른 뷰로의 UI 전환

긴급 업데이트는 사용자의 입력에 따라 즉각적으로 업데이트되지 않으면 문제(화면 멈춤, 렉 등)가 있다고 느끼는 영역이다. 반면 전환 업데이트는 화면에 즉시 나타나는 걸 기대하지 않는 영역이다.

 

 

사용자가 검색하기 위해 직접 '리액트'를 입력하는 상황이라고 가정해본다.

'ㄹ', 'ㅣ', 'ㅇ', 'ㅐ', 'ㄱ', ... 각각의 키를 입력할 때, 키 입력이 올바르게 되었다는 결과는 즉각적으로 보여줘야한다. 다시 말해 입력되는 값을 검색창에 바로바로 업데이트 해야한다. 그렇지 않으면, 사용자는 렉이나 화면이 멈췄다고 생각할 것이기 때문이다.

 

반면, 하단의 자동 완성의 영역의 경우 그렇지 않다. 만약 디바이스의 성능이 권장 사양보다 낮아 렌더링 성능이 낮거나, 네트워크 속도가 느린 상황 등의 이유로 업데이트가 늦어질 수도 있다. 중요한 건 전환 업데이트 때문에 긴급 업데이트가 방해되어서는 안된다.

 

React 18 이전까지는 상태 업데이트를 긴급과 전환 업데이트로 명시하는 방법이 없었다. 모든 상태는 긴급 업데이트로 적용하기 때문에, setTimeout이나 throttle, debounce 등의 테크닉을 사용해 긴급 업데이트 방해를 우회하는 것이 최선이었다. 

 

하지만, React 18부터는 startTransitionAPI를 제공함으로써 전환 업데이트를 명시적으로 구분하여 상태 업데이트를 진행할 수 있게 되었다.

import { useTransition } from 'react';

function SearchBar() {
	const [isPending, startTransition] = useTransition();

  // ...

	function handleChange(e) {
		const input = e.target.value;

		// 긴급 업데이트: 타이핑 결과를 보여준다.
		setInputValue(input);

		// 이 안의 모든 상태 업데이트는 전환 업데이트가 된다.
		startTransition(() => {
		  // 전환 업데이트: 결과를 보여준다.
		  setSearchQuery(input);
		});
	}

  // ...
}

 

startTransition의 경우 크게 두 가지 Use Cases가 있다.

  • 느린 렌더링 : 작업량이 많아 결과를 보여주기 위한 UI 전환까지 시간이 걸린다.
  • 느린 네트워크 : 네크워크로부터 데이터를 기다리기 위한 시간이 걸린다.(Suspense와 연계)

 

3. 서스펜스(Suspense)를 지원하는 새로운 서버 사이드 렌더링 아키텍처

React18에서는 새로운 서버 사이드 렌더링(이하 SSR) 아키텍처가 적용되었다. 새롭게 pipeToNodeWritable API가 추가 되었고, 이 API를 사용하면 SSR을 통해 <Suspense>를 사용할 수 있게 되었다.

 

즉, React.lazy를 서버 사이드 렌더링에서 사용할 수 있게 되었다.

 

// lazy 컴포넌트
const OtherComponent = React.lazy(() => import('./OtherComponent'));

React.lazy는 동적 import를 사용하여 컴포넌트를 렌더링할 수 있게 해주는 함수이다.

이러한 컴포넌트를 lazy 컴포넌트라고 하는데, 이 컴포넌트는 반드시 <Suspense>컴포넌트 하위에서 렌더링되어야 한다. 지금까지는 이 lazy 컴포넌트와 <Suspense>를 서버 사이드 렌더링에서 사용할 수 없었다는 것이 문제였다.

 

하지만 React 18부터는 새로운 렌더링 API인 pipeToNodeWritable 덕분에, <Suspense>와 함께 lazy 컴포넌트를 사욯할 수 있게 되어 앱을 더 작은 독립적인 유닛으로 만들 수 있다.

 

현재 리액트 생태계의 주류 환경인 웹팩 기반의 애플리케이션에서, lazy 컴포넌트를 사용하면 코드 스플리팅(Code Splitting)이 적용되어 별도의 자바스크립트 Chunk 파일로 분리된다. 그리고 이 <Suspense> 컴포넌트 하위 트리의 렌더링 외부 트리의 렌더링 과정을 막지 않고 별도의 과정이 진행된다.

 

새로운 Hook

useId

클라이언트와 서버간의 hydration의 mismatch를 피하면서 유니크 아이디를 생성할 수 있는 새로운 훅이다. 이는 주로 고유한 'id'가 필요한 접근성 API와 사용되는 컴포넌트에 유용할 것으로 기대된다. React18에서 새로운 스트리밍 렌더러가 HTML을 순서에 어긋나지 않게 전달해 줄 수 있다.

 

아이디 생성 알고리즘은 여기에서 살펴볼 수 있다. 아이디는 기본적으로 트리 내부의 노드의 위치를 나타내는 base 32 문자열이다. 트리가 여러 children으로 분기될 때마다, 현재 레벨에서 자식 수준을 나타내는 비트를 시퀀스 왼쪽에 추가하게 된다. 

 

useTransition, startTransition

이 두 메소드를 사용하면 일부 상태 업데이트를 긴급하지 않은 것(not urgent)로 표시할 수 있다. 이 것으로 표시되지 않은 상태 업데이트는 긴급 업데이트로 간주한다. 긴급한 상태 업데이트(ex. input text 등)가 긴급하지 않은 상태 업데이트(검색 결과 목록 렌더링)을 중단시킬 수 있다.

function App() {
  const [resource, setResource] = useState(initialResource)
  const [startTransition, isPending] = useTransition({ timeoutMs: 3000 })
  return (
    <>
      <button
        disabled={isPending}
        onClick={() => {
          startTransition(() => {
            const nextUserId = getNextId(resource.userId)
            setResource(fetchProfileData(nextUserId))
          })
        }}
      >
        Next
      </button>
      {isPending ? 'Loading...' : null} <ProfilePage resource={resource} />
    </>
  )
}

'startTransition'는 함수로, 리액트에 어떤 상태변화를 지연하고 싶은지 지정할 수 있다.

'isPending'은 진행 여부로, 트랜지션이 진행중인지 알 수 있다.

'timeoutMs'로 최대 3초 간 이전 화면을 유지한다.

 

useDeferredValue

'useDeferredValue'를 사용하면, 트리에서 급하지 않은 부분의 재렌더링을 지연할 수 있다. 이는 'debounce'와 비슷하지만, 몇 가지 더 장점이 있다. 고정된 지연 시간이 없으므로, 리액트는 첫 번째 렌더링이 반영되는 즉시 지연 렌더링을 시도한다. 이 지연된 렌더링은 인터럽트가 가능하며, 사용자 입력을 차단하지 않는다.

import { useDeferredValue } from 'react'

const deferredValue = useDeferredValue(value, {
  timeoutMs: 5000,
})

'value'의 값이 바뀌어도, 다른 렌더링이 발생하는 동안에는 최대 5000ms가 지연된다. 시간이 다되거나, 렌더링이 완료된다면 'deferredValue'가 변경되면서 상태값이 변하게 될 것이다. 

 

useSyncExternalStore

'useSyncExternalStore'는 스토어에 대한 업데이트를 강제로 동기화하여 외부 스토어가 concurrent read를 지원할 수 있도록하는 새로운 훅이다. 외부 데이터의 원본에 대한 subscript를 필요로 할 때 더 이상 'useEffet'를 사용하지 않고, 이는 리액트 외부 상태와 통합되는 모든 라이브러리에 권장된다.

  • 'External Store' : 외부 스토어라는 것은 우리가 subscribe하는 무언가를 의미한다. 예를 들어 리덕스 스토어, 글로벌 변수, dom 상태 등이 될 수 있다.
  • 'Internal Store' : 'props', 'context', 'useState', 'useReducer' 등 리액트가 관리하는 상태를 의미한다.
  • 'Tearing' : 시각적인 비일치를 의미한다. 예를 들어, 하나의 상태에 대해 UI가 여러 상태로 보여지고 있는(=각 컴포넌트 별로 업데이트 속도가 달라서 발생하는) 상태를 말한다.

React 18이전에는 이러한 문제가 없었지만, concurrent 랜더링이 등장하며 렌더링이 렌더링을 일시중지할 수 있게 되면서 문제가 발생하기 시작했다. 일시중지가 발생하는 사이에 업데이트는 렌더링에 사용되는 데이터와 이와 관련된 변경사항을 가져올 수 있어 UI는 동일한 데이터에 다른 값을 표시할 수 있게 되버렸다. 

 

useInsertionEffect

'useInsertionEffect'는 css-in-js 라이브러리가 렌더링 도중에 스타일을 삽입할 때 성능 문제를 해결할 수 있는 새로운 훅이다. css-in-js 라이브러리를 사용하지 않는다면 사용할 필요가 없다. 이 훅은 DOM이 한번 mutate된 이후에 실행되지만, layout effect가 일어나기 전에 새 레이아웃을 한 번 읽는다. 이는 React 18 이전의 문제를 해결할 수 있으며, React 18에서는 나아가 concurrent 렌더링 중에 브라우저에 리액트가 값을 반환하므로, 레이아웃을 한번 더 계산할 수 있는 기회가 생겨 매우 중요하다. 

 

어떻게 보면 'useLayoutEffect'와 비슷한데, 차이가 있다면 DOM 노드에 대한 참조에 엑세스 할 수 있다는 것이다.

 

클라이언트 사이드에서 '<style>' 태그를 생성해서 삽입할 때는 성능 이슈에 대해 민감하게 살펴보아야 한다. CSS 규칙을 추가하고 삭제한다면 이미 존재하는 모든 노드에 새로운 규칙을 적용하는 것이다. 이는 최적의 방법이 아니므로 많은 문제가 존재한다.

 

이를 피할 수 있는 방법은 타이밍이다. 리액트가 DOM을 변환한 경우, 레이아웃에서 무언가를 읽기 전 ('clientWidth'와 같이) 또는 페인트를 위해 브라우저에 값을 전달하기 전에 DOM에 대한 다른 변경과 동일한 타이밍에 작업을 하면 된다.

function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted.has(rule)) {
      isInserted.add(rule)
      document.head.appendChild(getStyleForRule(rule))
    }
  })
  return rule
}
function Component() {
  let className = useCSS(rule)
  return <div className={className} />
}

이는 'useLayoutEffect'와 마찬가지로 서버에서 실행되지는 않는다.

 

새로운 Client 및 Server Rendering API

React DOM Client

'react-dom/client'에 새로운 API가 추가되었다.

createRoot

렌더링 또는 언마운트할 루트를 만드는 새로운 메소드다. 'ReactDOM.render' 대신 사용하며, React18의 새로운 기능동작을 위해 필수로 사용해야한다.

 

before

import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

ReactDOM.render(<App name="yceffort blog" />, container)

ReactDOM.render(<App name="yceffort post" />, container)

 

after

import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

// 루트 생성
const root = ReactDOM.createRoot(container)

// 최초 렌더링
root.render(<App name="yceffort blog" />) // During an update, there is no need to pass the container again
// 업데이트 시에는, container를 다시 넘길 필요가 없다.
root.render(<App name="yceffort post" />)

 

hydrateRoot

서버사이드 렌더링 애플리케이션에서 hydrate하기 위한 새로운 메소드이다. 새로운 React DOM Server API와 함께 'ReactDOM.hydrate' 대신 사용하면 된다. React18의 새로운 기능동작을 위해 필수로 사용해야한다. 

 

before

import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

ReactDOM.hydrate(<App name="yceffort blog" />, container)

 

after

import ReactDOM from 'react-dom'
import App from 'App'

const container = document.getElementById('root')

const root = ReactDOM.hydrateRoot(container, <App name="yceffort blog" />)

 

위 두 메소드 모드 'onRecoverableError'를 옵션으로 받을 수 있는데, 리액트가 렌더링이나 hydration시 에러가 발생하여 리커버리를 시도할 때 logging을 할 수 있는 목적으로 제공된다. 기본값으로 reportError나 구형 브라우저에서는 'console.error'를 쓴다.

 

React DOM Server

'react-dom/server'에 새로운 API가 추가되었으며, 이는 서버에서 steaming Suspense를 완벽하게 지원한다.

renderToPipableStream

node 환경에서 스트리밍 지원

  • <Suspense>와 함께 사용 가능
  • 콘텐츠가 잠시 사라지는 문제없이 'lazy'와 함께 코드 스플리팅 가능
  • 지연된 콘텐츠 블록이 있는 HTML 스트리밍이 나중에 뜰 수 있음

renderToReadableStream

Cloudflare, deno와 같이 모던 엣지 런타임 환경에서 스트리밍 지원

'renderToString'는 여전히 존재하지만, 사용하는 것이 권장되지는 않는다.

 


Reference

 

React v18.0 – React Blog

React 18 is now available on npm! In our last post, we shared step-by-step instructions for upgrading your app to React 18. In this post, we’ll give an overview of what’s new in React 18, and what it means for the future. Our latest major version inclu

reactjs.org

 

React 18을 준비하세요.

요약

medium.com

 

Home

yceffort

yceffort.kr

 

 

반응형