티스토리 뷰

WEB/React.js

React 18 주요 변경점

이지홍 2023. 1. 17. 17:52
반응형

 

1. 자동배치 (Automatic Batching)

2.Transitions(전환)

3.Suspense 와 lazy

4. 새로운 Hook
    1) useId
    2) useTransition, startTransition
    3) useDeferredValue
    4) useSyncExternalStore
    5) useInsertionEffect
    
4. 새로운 CSR API
    # React DOM Client
        1) CreateRoot
        2) hydrateRoot
    # React DOM Server
        1) renderToPipableStream
        2) renderToPipableStream​

1. 자동배치 (Automatic Batching)

React-18v 부터 상태 업데이트(setState)를 하나로 통합해서 배치 처리를 한 후 리렌더링을 진행한다.
배치란, 리액트가 더 나은 성능을 위해 여러 개의 상태 업데이트를 한 번의 리렌더링(re-render)으로 묶는 작업이다.

과거 이전 React-17v 에서는 이벤트 핸들러 내부에서 발생하는 상태 업데이트만 배치처리를 지원했다. 
하지만 이벤트 핸들러 내부에 fetch() 등과 같은 콜백을 받아 처리하는 메소드가 존재할 경우 내부의 콜백이 모두 완료된 후에 Automatic Batching 이 처리되지 않았다.

# React-17v

1) 이벤트 핸들러 내부에서 상태 업데이트가 여러번 발생한다.

import React, { useState } from "react";
import "./App.css";

function App() {
  // 2가지의 상태 존재
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  // 하나의 핸들러에 2가지 상태를 업데이트
  const onClickCreateNumber = () => {
    setNumber((prev) => prev + 1);
    setBoolean((prev) => !prev);
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;

위와 같이 하나의 핸들러에서 2개의 상태 업데이트가 이루어졌음에도 불구하고 리렌더링은 1번만 발생한다. 


2) 이벤트 핸들러 내부에서 fetch()함수를 활용하여 상태 업데이트 여러번 발생

import React, { useState } from "react";
import "./App.css";

function App() {
  const [number, setNumber] = useState(0);
  const [boolean, setBoolean] = useState(false);

  const onClickCreateNumber = () => {
    // fetch()를 활용해서 콜백함수 내부에서 여러개의 상태 업데이트
    fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => {
      setNumber((prev) => prev + 1);
      setBoolean((prev) => !prev);
    });
  };

  console.log("리렌더링");
  return (
    <>
      <div>{number}</div>
      <button onClick={onClickCreateNumber}>button</button>
    </>
  );
}

export default App;

위와 같이 콜백함수 내부에서 여러개의 상태를 업데이트 할때, 클릭 이벤트가 발생하면 리렌더링이 2번 발생한다. 
따라서 자동배치(Automatic Batching) 가 동작하지 않는다.

# React-18v

1) 이벤트 핸들러 내부에서 상태 업데이트 한 번 발생

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>
  );
}

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


2)  이벤트 핸들러 내부에서 fetch()함수를 활용하여 상태 업데이트 여러번 발생

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>
  );
}

자동 배치 작업은 리액트 이벤트 핸들러 내에서만 수행되므로, 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);
  // 리액트는 오직 마지막에만 리렌더링을 한 번 수행한다. (배치 적용)
});

이렇게 위의 경우처럼 React18 버전에서는 자동배치가 적용되어, 일반적인 React 이벤트 핸들러 함수 스코프에서 상태 업데이트가 일어나지 않더라도 자동으로 배치를 적용해준다.

자동 배치를 사용하기 위해서는 컴포넌트 트리를 기존의 React.DOM.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) : 직접적인 상호 작용 반영(타이핑, 오버, 스크롤링 등)
  • 전환 업데이트(Trangition updates) : 하나의 뷰에서 다른 뷰로의 전환

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

React18 이전까지는 상태 업데이트를 긴급과 전환 업데이트로 명시하는 방법이 없었다. 모든 상태는 긴급 업데이트로 적용하기 때문에, setTimeout이나 throttle, debounce 등의 테크닉을 사용해 긴급 업데이트 방해를 우회하는 것이 최선이었다. 
하지만, React18 부터 startTransitionAPI를 제공함으로써 전환 업데이트를 명시적으로 구분하여 상태 업데이트를 진행할 수 있게 되었다.

📍startTransition이란? 
- startTransition : 클릭이나 키 입력에 의해 우선순위가 높은 상태 업데이트가 발생할 경우 내부에 선언한 상태 업데이트는 중단되고 클릭이나 키 입력이 끝난 후에 해당 상태 업데이트가 발생한다.
- isPending: state 변경 직후에도 UI를 리렌더링 하지 않고 UI를 잠시 유지하는 상태각 상태 업데이트에 대한 우선순위를 설정할 수 있는 Hook 입니다.
import { useTransition } from 'react';

function SearchBar() {
	const [isPending, startTransition] = useTransition();
    const [inputValue, setInputValue] = useState();
    const [searchQuery, setSearchQuery] = useState();

  // ...

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

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

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

  console.log("리렌더링", isPending, inputValue, searchQuery);
  return (
    <>
      <input onChange={onClickCreateNumber} />
      // isPending값이 true일 경우 searchQuery 상태가 우선순위에 밀려 pending 상태임으로 버튼 클릭 불가
      // searchQuery 상태 업데이트가 완료되면 버튼 클릭 가능 => 이것을 활용해 로딩 기능 구현 가능
      <button disabled={isPending}>button</button>
    </>
  );
}

위의 소스를 살펴보면 searchQuery 상태 업데이트 진행중 inputValue의 상태 업데이트가 발생하게 되면 잠시 중단하고 inputValue 상태 업데이트가 완료 후, searchQuery 상태 업데이트가 진행된다.

이로인해, 디바운스 / 쓰로틀링을 활용하지 않고 기기 성능에 따라 최적화가 가능해진다.

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

위처럼 useTransition()에서 timeout을주어 최대 얼마까지 기다릴 것인지 시간 설정을 할 수 있다. 5초동안 렌더링을 기다리다가 5초가 지나도 pending 값이 변하지 않으면 강제로 렌더링 된다. 

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

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

3. Suspense와 SSR

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

기존의 React SSR방식은 waterfall 방식을 사용했었다.

1. 서버에서 전체 앱의 데이터를 받는다.
2. 그 후, 서버에서 전체 앱을 HTML로 렌더링한 후 Response로 전송
3. 클라이언트에서 전체 앱의 자바스크립트 코드를 로드
4. 클라이언트에서 서버에서 생성된 전체 앱의 HTML과 자바스크립트 로직을 연결

해당 방식으로 진행하면서 병목현상이 발생할 경우 성능 이슈가 발생하게 되는데 React18부터는 suspense를 사용해 앱을 더 작은 독립적인 단위로 바꿀 수 있고 이를 통해 병목 현상을 막을 수 있다. 

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

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

React.lazy는 동적 import를 사용해 컴포넌트를 렌더링할 수 있게 해주는 함수이다. 
이러한 컴포넌트를 lazy컴포넌트라고 하는데, 이 컴포넌트는 반드시 <suspense> 컴포넌트 하위에서 렌더링 되어야 한다. 지금까지는 이 lazy 컴포넌트와 <suspense>를 SSR에서 사용할 수 없었다는 것이 문제였다. 

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

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


4. 새로운 Hook

1) useId

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

2) useTransition, startTransition

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

해당 훅은 위의 transition 에서 설명했기때문에 넘어가도록 하겠다.

3) useDeferredValue

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

import { useDeferredValue } from 'react'

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

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

4) useSyncExternalStore

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

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

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

React18 이전

Tearing이 발생하는 과정

초기 그림처럼 초기 데이터는 파랑색이지만 중간에 external store가 update되면서 data가 빨간색으로 바뀌게된다. 리액트는 이를 반영하려고 할 것이고 이 과정에서 Tearing이 발생하게 된다.

이를 해결하기 위해 도입된 hook이 useSyncExternalStore 이라고 한다.

5) useInsertionEffect

useInsertionEffect는 css-in-js 라이브러리가 렌더링 도중에 스타일을 삽입할 때 성능 문제를 해결할 수 있는 새로운 훅이다. css-in-js 라이브러리를 사용하지 않는다면 사용할 필요가 없다. 

이 훅은 DOM이 한 번 mutate된 이후에 실행되지만, layout effect가 일어나기 전 새 레이아웃을 한 번 읽는다. 이는  React 18이전의 문제를 해결할 수 있으며, React 18에서는 나아가 concurrent 렌더링 중에 브라우저에 리액트가 값을 반환하므로, 레이아웃을 한 번 더 계산할 수 있는 기회가 생겨 매우 중요하다. 

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

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

이를 피할 수 있는 방법은 타이밍이다. 리액트가 DOM을 변환한 경우, 레이아웃에서 무언가를 읽기 전, 또는 페인트를 위해 브라우저에 값을 전달하기 전에 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'와 마찬가지로 서버에서 실행되지는 않는다.


5. 새로운 CSR API

# React DOM Client

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

1) 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)JAVASCRIPT

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" />)JAVASCRIPT

2) hydrateRoot

SSR 애플리케이션에서 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)JAVASCRIPT

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를 완벽하게 지원한다.

1) renderToPipableStream

node 환경에서 스트리밍 지원

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

2) renderToPipableStream

Cloudflare, deno와 같이 모던 엣지 런타임 환경에서 스트리밍 지원
'renderToString'는 여전히 존재하지만, 사용하는 것이 권장되지는 않는다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함