React17과 React18은 뭐가 다를까? 업데이트를 통해 얻은 몇가지 인사이트

생성일
2023/08/08 02:15
태그
테크리포트
기술블로그
kt NexR
React18
글쓴이
en.Kwon
속성
속성 1

서론

2022년 3월 React는 18.0.0 버전이라는 메이저 업데이트를 진행하였습니다.
기존 NDC를 포함한 다양한 React 프로젝트에서는 아직까지 React 17 버전을 채택해 사용하고 있습니다. 하지만 React 18 업데이트를 통해 React 17을 사용하면서도 인사이트를 얻을 수 있겠다고 생각했습니다. 무엇을 얻을 수 있을지에 대해 찾고자 해당 리서치를 진행해 보았습니다.
공식 문서에서는 React 18 업데이트에서 기존에 없던 새로운 기능을 제공한다고 말합니다.
Automatic Batching, startTransition과 같은 새로운 API, Suspense를 지원하는 SSR 등 React 18의 새로운 기능들에 대해 알아보았습니다.

필요 조건

먼저 React 18의 새로운 기능을 사용하기 위해서는 createRoot API를 사용해야 합니다.
// 이전 버전 import React from 'react'; import ReactDOM from 'react-dom'; // render 사용 ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') );
Plain Text
복사
// 리액트18 버전 import React from 'react'; import ReactDOM from 'react-dom/client'; const rootNode = document.getElementById('root'); // createRoot 사용 ReactDOM.createRoot(rootNode).render( <React.StrictMode> <App /> </React.StrictMode>, );
Plain Text
복사
기존 버전에서는 render만을 사용해 root를 생성했는데 React 18버전에서는 createRoot라는 새로운 Root API를 통해 root를 생성할 수 있게 되었습니다. createRoot API를 사용해 React 18버전의 새로운 기능과 API를 사용할 수 있게 됩니다.
ReactDOM을 불러오는 경로 또한 react-dom에서 react-dom/client로 변경된 것을 볼 수 있습니다.

관련 기술

React 18의 새로운 기능 중 주요 기능 키워드는 아래와 같습니다.
Automatic Batching
Concurrent Feature
New Hooks

1. Automatic Batching

공식문서에서 설명하는 Automatic Batching
Automatic Batching은 여러 상태 업데이트(setState)를 통합해서 단일 리렌더링으로 처리해 리렌더링의 성능을 개선한 기능입니다.
React 18 이전엔 useState, setState를 통해 상태를 업데이트하고 업데이트된 결과를 바탕으로 리렌더링을 진행했습니다. 이때, state의 개수가 많아진다면 그만큼 리렌더링도 많이 발생하기 때문에 불필요한 리렌더링이 많아지고 성능 이슈가 발생할 수 있었습니다. 그래서 도입된 기능이 배치처리(batch updating)입니다. 배치처리는 여러 상태 업데이트(setState)가 발생해도 상태를 하나로 통합해 리렌더링을 진행하는 방식입니다. React 18 이전 버전에서는 이벤트 핸들러와 생명주기 메소드를 사용할 땐 배치처리를 지원하지만 콜백 함수가 포함된 경우 배치처리를 지원하지 않았습니다.

2. Concurrent Feature

Concurrent Feature을 알아보기 전, Concurrent Mode라는 개념에 대해 먼저 알아야 합니다. Concurrent Mode는 자바스크립트가 싱글 스레드 기반의 언어이기 때문에 여러 작업을 동시에 처리할 수 없는 문제점을 보완하기 위해 나왔습니다.
React 또한 JavaScript 기반이기 때문에 동일한 문제점이 발생했고 Concurrent Mode(동시성)을 통해 이를 해결하고자 했습니다. Concurrent Mode는 여러 작업을 작은 단위로 나눠 우선순위를 정해 그에 따라 작업을 번갈아 수행하는 것을 말합니다. 동시에 수행되는 것은 아니지만 작업 간 전환이 빠르기 때문에 동시에 수행되는 것처럼 보여 사용자 경험을 UX 적으로 향상시킵니다.
공식문서에서 설명하는 Concurrent Feature
이러한 배경에서 탄생한 Concurrent Feature은 Concurrent Mode에서 용어가 바뀐 것으로 이번 React 18 업데이트에서 가장 중요한 추가 기능입니다. 동시성을 효율적으로 진행할 수 있는 기능인 Suspense와 Transitions를 지원합니다. 해당 기능들은 한 번에 여러 동작을 수행해 사용자 경험을 향상시킬 수 있습니다.

2-1. Suspense

Suspense는 사용자에게 보여주고 싶은 컴포넌트를 먼저 렌더링 할 수 있게 하는 기능입니다.
공식문서에서 설명하는 Suspense
Suspense로 렌더링을 원하는 컴포넌트를 감싸줍니다. 해당 컴포넌트가 렌더링이 완료되기 전까지 fallback 내부의 컴포넌트를 사용자에게 보여줍니다.
리액트18 개발자들이 강조하는 Suspense의 사용 환경
또한, React 개발자들은 아직 완벽하진 않지만 React 18에서 프레임워크의 데이터 호출 기능을 Suspense를 통해 구현 가능할 것이라고 기대하고 있습니다. Suspense는 서버 렌더링 환경(SSR)에서 가장 잘 작동하기 때문입니다.

2-2. Transitions

공식 문서에서 설명하는 Transitions
Transition은 setState(상태 업데이트)의 우선순위를 구분합니다. Urgent updates는 입력, 클릭, 누르기와 같은 사용자가 직접적인 상호 작용을 하는 기능을 말하고 Transition updates(non-urgent)는 UI 전환과 같은 기능을 말합니다.

3. New Hooks

이번 React 18 업데이트에서는 기존에 없던 새로운 Hook들이 제공됩니다.
(Hook의 예시들은 IV에서 다뤄보도록 하겠습니다.)

적용 방법

1. Automatic Batching

1-1. React 18 업그레이드 전

일반 이벤트 핸들러 내에서 발생하는 배치처리 예시입니다.
import React, { useState } from "react"; const Automatic = () => { const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); // 하나의 핸들러 내에서 두 개의 상태 업데이트 진행 const onClick = () => { setOne(one + 1); setTwo(two + 1); }; console.log("리렌더링"); return ( <> <div>{one}</div> <button onClick={onClick}>button</button> </> ); }; export default Automatic;
Plain Text
복사
상태 one과 two의 상태 업데이트가 하나의 리렌더링으로 처리됩니다.
→ Automatic Batching 적용
콜백 함수를 사용했을 때 예시입니다.
import React, { useState } from "react"; const Automatic = () => { const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); // 비동기 처리 방식 fetch 활용// 콜백 함수 존재 const onClick = () => { fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => { setOne(one + 1); setTwo(two + 1); }); };
Plain Text
복사
콜백 함수가 존재하는 fetch 등 비동기 함수에서 상태 업데이트를 할 때는 배치처리가 안 되는 문제점이 존재합니다.
상태 one과 two의 상태 업데이트가 각각 리렌더링 되는 것을 볼 수 있습니다. 상태의 개수만큼 리렌더링이 발생하기 때문에 성능 저하의 원인이 됩니다.
→ Automatic Batching 적용 안 됨

1-2. React 18 업그레이드 후

일반 이벤트 핸들러의 배치처리 방식은 React 18 업그레이드 전과 동일합니다.
반면, 콜백 함수를 사용했을 때는 React 18 업그레이드 전과 다른 결과를 볼 수 있었습니다.
import React, { useState } from "react"; const Automatic = () => { const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); const onClick = () => { fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => { setOne(one + 1); setTwo(two + 1); }); };
Plain Text
복사
React 18 업그레이드 전의 코드와 달리 콜백 함수에서도 리렌더링이 한 번만 발생합니다.
→ Automatic Batching 적용
일반 이벤트 핸들러와 콜백 함수에서는 Automatic Batching이 적용되지만 아쉽게도 예외 상황이 존재합니다.
아래의 코드는 하나의 함수 안에 일반 상태 업데이트와 콜백 함수 내에서 상태 업데이트가 동시에 존재하는 상황에서의 예시 코드입니다.
import React, { useState } from "react"; const Automatic = () => { const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); const onClick = () => { setOne(one + 1);// 일반 상태 업데이트 fetch("https://jsonplaceholder.typicode.com/posts/1").then((response) => { setTwo(two + 1);// 콜백 함수 내에서 상태 업데이트 }); };
Plain Text
복사
이벤트 핸들러 내에서 일반적으로 상태 업데이트를 하는 것과 콜백 함수에서 상태 업데이트를 하는 것이 중복되면 리렌더링이 상태의 개수만큼 발생하는 것을 볼 수 있습니다.
→ Automatic Batching이 적용 안 됨

1-3. React 18의 배치처리 방지

기본적으로 일반 이벤트 핸들러에서는 Automatic Batching이 발생합니다. 이때 Automatic Batching을 방지하고자 한다면 react-dom의 내장 함수인 flushSync()를 사용할 수 있습니다. flushSync()를 사용하면 React 18에서도 배치처리를 사용하지 않을 수 있습니다.
flushSync() 함수 내부에 Automatic Batching을 적용하고 싶지 않은 상태 업데이트를 넣어 Automatic Batching을 발생시키지 않을 수 있습니다. 단, 하나의 flushSync() 함수에 2개 이상의 상태 업데이트를 넣게 되면 정상적으로 작동이 되지 않습니다.
import React, { useState } from "react"; import { flushSync } from "react-dom"; const Automatic = () => { const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); const onClick = () => { flushSync(() => { setOne(one + 1); }); flushSync(() => { setTwo(two + 1); }); }; /* 단, 하나의 flushSync()에 두 개의 상태 업데이트를 넣게 되면 효과가 없다! flushSync(() => { setOne(one + 1); setTwo(two + 1); }); */
Plain Text
복사
flushSync()로 상태 업데이트를 방지한 결과, 리렌더링이 상태의 개수만큼 발생한 것을 볼 수 있습니다.
→ Automatic Batching이 적용 안 됨

2. Concurrent Feature - Suspense

2-1. Suspense를 사용하지 않은 경우

기본적으로 Suspense를 사용하지 않은 경우의 예시입니다.
// First.tsx <Second />
Plain Text
복사
// Second.tsx function Second() { const [isLoading, setIsLoading] = useState<boolean>(true);// isLoading state 생성 if(isLoading) {// isLoading의 값에 대한 조건문 return <Loading /> } return <h1>This is Suspense</h1>; }
Plain Text
복사
Second 컴포넌트에서 조건문에 따라 Loading 컴포넌트를 보여주는 waterfall 방식으로 병목현상이 발생할 위험성이 있어 성능 이슈의 위험이 있습니다.

2-2. Suspense를 사용한 경우

렌더링 할 컴포넌트를 Suspense로 감싼 후, 컴포넌트가 렌더링 되기 전에 보여주길 원하는 컴포넌트를 fallback으로 지정합니다.
// First.tsx <Suspense fallback={<Loading />}/> <Second /> </Suspense>
Plain Text
복사
Second 컴포넌트가 렌더링 되는 동안 Loading 컴포넌트를 통해 사용자에게 로딩 상태임을 알리고 UX 적으로 사용자 경험을 향상시킬 수 있습니다.
// Second.tsx function Second() { return <h1>This is Suspense</h1>; }
Plain Text
복사
사용자에게 초기 렌더링 페이지를 전체가 아닌 빠르게 준비되는 부분부터 보여주기 때문에 초기 렌더링 속도가 감소합니다. 또한, 코드를 간결하게 만들어주고 데이터 플로우를 직관적으로 확인할 수 있다는 장점이 있습니다.

2-3. SSR 렌더링 방식에서 Suspense를 사용하지 않은 경우

서버에서 Header와 Post 컴포넌트가 모두 렌더링 되어야 하는 상황을 가정해 보겠습니다.
<Header /> <Post />// 로딩이 오래 걸리는 컴포넌트일 때
Plain Text
복사
Post 컴포넌트의 로딩이 오래 걸리면 사용자는 빈 화면만 보는 상황이 발생합니다. Post 컴포넌트의 로딩이 되지 않았기 때문에 사용자는 Header 컴포넌트 또한 볼 수 없습니다.
빈 화면만 보게 되는 사용자

2-4. SSR 렌더링 방식에서 Suspense를 사용한 경우

사용자는 순서에 따라 Header 컴포넌트를 볼 수 있는 상태입니다. Post 컴포넌트가 로딩이 느린 컴포넌트라고 할 때, Suspense로 감싸 fallback으로 Post 컴포넌트가 로딩되기 전 보여줄 컴포넌트를 지정할 수 있습니다. fallback에 Loading를 넣어주면 Post 컴포넌트가 로딩되는 동안 Loading 컴포넌트가 보입니다.
<Header /> <Suspense fallback={<Loading />}/> <Post />// 로딩이 오래 걸리는 컴포넌트일 때 </Suspense>
Plain Text
복사
Post 컴포넌트의 로딩이 되기 전까지 Loading 컴포넌트가 먼저 보이기 때문에 한 번에 hydration을 하지 않을 수 있습니다.
Post 컴포넌트가 로딩되기 전, Loading 컴포넌트가 먼저 보여지는 상황입니다.

2-5. HTTP Streeming

Suspense가 서버에서 HTML을 보내주는 HTTP Streem API와 만나게 되면 Post 컴포넌트와 Loading 컴포넌트가 HTML로 교체될 수 있습니다.
// Post 컴포넌트 로딩 전 <header>헤더</header> <img src="로딩중.gif" />
Plain Text
복사
// Post 컴포넌트 로딩 후 <header>헤더</header> <ul>포스트...</ul>
Plain Text
복사

3. Concurrent Feature - Transitions

3-1. useTransition

새롭게 나온 리액트 Hook으로 상태 업데이트에 대한 우선순위를 설정합니다.
isPending 상태 값을 가져와 렌더링 결과를 분기 처리하는 기능입니다. 여기서 isPending은 state를 업데이트했음에도 리렌더링 하지 않고 UI를 잠시 유지하는 상태를 말합니다. isPending은 boolean 값으로 transition 완료를 알려주게 됩니다.

3-2. startTransition

내부에 존재하는 상태 업데이트는 모두 전환 업데이트일때 우선순위가 높은 상태 업데이트가 발생할 경우 내부에 선언한 상태 업데이트는 중단됩니다.
const [isPending, startTransition] = useTransition(); const [one, setOne] = useState<number>(0); const [two, setTwo] = useState<number>(0); const onClick = () => { setOne(one + 1);// 긴급 업데이트// startTransition으로 전환 업데이트 선언// 내부에 있는 상태 업데이트는 모두 전환 업데이트 startTransition(() => { setTwo(two + 2);// 전환 업데이트로 setOne보다 우선순위가 낮음 }); }; return ( <> <button onClick={onClick} /> <button disabled={isPending} /> <> );
Plain Text
복사
isPending의 값이 true일 경우 startTransition 내부에 있는 setTwo가 우선순위에 밀리게 됩니다. setOne의 상태 업데이트 진행시 isPending의 값은 true입니다. setOne의 상태 업데이트가 완료되면 isPending의 값이 false가 되며 setTwo의 상태 업데이트를 시작합니다.

3-3. Deboune, Throttle과 비교

Transitions은 기존에 사용자 경험을 개선하기 위해 사용되었던 debounce, throttle, setTimeout 등을 대체할 수 있는 기능입니다. 기존의 debounce, throttle은 setTimeout을 활용해 특정 시간을 무조건 대기해야 한다는 단점과 적절한 대기 시간을 선택하는 어려움 존재했습니다.
Debounce
인터랙션이 발생하면 대기 시간만큼 딜레이 되고 다시 인터랙션이 발생하면 대기 시간이 처음부터 시작되어 가장 최신의 값을 출력
인터랙션이 계속되면 화면 업데이트가 계속해서 지연
Ex) 검색어가 변경될 때마다 검색을 한다면, onChange 이벤트가 계속해서 발생하고 마지막 입력이 끝난 후 검색 API를 호출, 입력이 계속된다면 검색 API 호출 지연
Trottle
인터랙션이 발생하면 대기 시간 후에 가장 최신의 값을 출력하고 초기화하며 인터랙션이 끝날 때까지 반복
중간에 인터랙션이 끊겨도 무의미한 대기 시간이 발생
Ex) 검색어가 변경될 때마다 검색을 한다면, onChange 이벤트가 계속해서 호출되고 대기 시간이 지난 후 다시 호출되고를 반복
주기가 짧은 경우 trottle의 사용 의미가 없어지고 주기가 긴 경우 UI 정체 현상
setTransition
startTransition의 경우 화면을 업데이트하는 중에도 사용자의 인터랙션에 상호작용이 됨
중간에 우선순위가 높은 사용자 인터랙션이 발생하면 긴급 업데이트 실행
Ex) 검색어가 변경될 때마다(긴급 업데이트) 검색을 한다면, onChange 이벤트가 완료되기 전까지 전환 업데이트(ex. 로딩 스피너)를 수행
사용자의 검색어 입력이 완료되면 검색어를 보여주고, 다시 사용자가 검색어를 입력해서 onChange가 발생하면 다시 로딩 스피너 호출

4. New Hooks

4-1. useId()

서버와 클라이언트에서 사용될 고유 ID 생성하며 key를 생성하는 것은 아닙니다.
id를 하나만 전달할 때 사용 방법입니다.
function CreateID() { const id = useId(); return <input id={id} type="text" />; };
Plain Text
복사
id를 두 개 이상 전달할 때는 접미사를 추가해 아이디를 구분합니다.
function CreateID() { const id = useId(); return ( <input id={id + '-firstID'} type="text" /> <input id={id + '-secondID'} type="text" /> ); };
Plain Text
복사

4-2. useDeferredValue()

트리에서 긴급하지 않은 부분의 리렌더링을 연기하는 기능을 제공합니다. 고정된 지연 시간이 없고 긴급한 작업이 완료된 후 즉시 실행하며 사용자 입력을 차단하지 않습니다.
useTransition과 같은 역할? useTransition은 함수 실행의 우선순위, useDeferredValue는 값의 업데이트 우선순위 지정
아래의 예시 코드를 보면 value가 변경되더라도 deferredValue에는 변경된 값이 아닌 이전 값을 리턴합니다. 사용자의 인터랙션이 종료되면 그때 새로운 값을 리턴하게 됩니다.
import { useDeferredValue, useState } from "react"; const SlowUI = () => ( <> {Array(50000) .fill(1) .map((_, index) => ( <span key={index}>{100000} </span> ))} </> ); function App() { const [value, setValue] = useState<string>(""); const deferredValue = useDeferredValue(value);// 긴급하지 않은 값으로 지정 const handleClick = (e: any) => { setValue(e.target.value); }; return ( <> <input onChange={handleClick} /> <div>DeferredValue: {deferredValue}</div> <div> <SlowUI /> </div> </> ); } export default App;
Plain Text
복사

분석결과

1. Automatic Batching 속도 비교

Automatic Batching

Automatic Batching

간단한 예시 코드로 작업을 했기 때문에 유의미한 결과는 나오지 않았지만 최초 렌더링을 제외하면 Automatic Batching이 적용된 코드가 평균적으로 미세하게 빠른 것을 볼 수 있습니다.

2. Suspense - Selective Hydration

Suspense를 사용하면 hydration을 비동기적으로 실행할 수 있게 됩니다. 일부 컴포넌트가 아직 렌더링 되지 않은 상황에서도 다른 컴포넌트에 hydration을 시작할 수 있습니다.
<Suspense fallback={<Loading />}> <Sidebar /> </Suspense> <Suspense fallback={<Loading />}> <Comments /> </Suspense>
Plain Text
복사
로딩이 끝난 후, Sidebar 컴포넌트와 Comments 컴포넌트가 hydrate할 준비를 할 때 비동기로 이루어지기 때문에 Sidebar부터 hydration이 진행됩니다.
이때 사용자가 Comments 컴포넌트의 영역을 인터랙션 하게 되면 리액트는 Sidebar 컴포넌트의 hydration을 멈추고 Comments 컴포넌트의 hydration을 진행합니다.
사용자가 hydrate 중인 컴포넌트가 아닌 다른 컴포넌트에 인터랙션을 하면 React는 우선순위를 높여 사용자가 인터랙션 한 컴포넌트를 우선적으로 hydration 하게 됩니다. 사용자의 관심도에 따라 인터랙션이 가능한 컴포넌트부터 기능을 제공하기 때문에 사용자 경험이 향상된다는 장점이 있습니다.

결론

React 18 버전업 전에도 이벤트 핸들러와 생명주기 메소드를 사용하면 배치처리가 가능했습니다. 하지만 콜백 함수 내에서 배치처리가 되지 않는 문제점이 있었습니다. React 18 업데이트를 통해 Automatic Batching이 가능하게 되며 배치처리가 가능해지게 되었습니다. 이러한 Automatic Batching을 적용하고 싶지 않다면 flushSync() 함수로 상태 업데이트를 감싸 사용할 수 있습니다. 이때 flushSync() 내부에 하나의 상태 업데이트가 아닌 두 개 이상의 상태 업데이트가 들어가게 되면 정상적으로 작동하지 않습니다.
Concurrent Mode는 Suspense, Transition 두 기능을 제공합니다. Suspense는 로딩이 오래 걸리는 컴포넌트를 렌더링 할 때 사용자에게 빈 화면이 아닌 임시의 컴포넌트를 보여주기 위해 사용합니다. fallback으로 임시 컴포넌트를 먼저 보여주면 사용자는 빈 화면이 아닌 로딩과 같은 화면을 보기 때문에 사용자 경험이 향상될 수 있습니다. 또, Selective Hydration으로 일부 컴포넌트가 렌더링 되지 않은 상태에서 다른 컴포넌트의 hydration을 진행할 수 있습니다. 사용자는 먼저 보고 싶은 화면을 선택적으로 볼 수 있습니다. Transition은 우선순위에 따라 상태 업데이트를 진행할 수 있는 기능을 제공합니다. 기존에 사용하던 Debounce와 Throttle을 대체할 수 있습니다.
전체적으로 React 18 업데이트에서는 사용자 경험을 향상시키는데 초점을 맞춘듯싶습니다. 렌더링 속도와 사용자에게 보여줄 화면에 대한 업그레이드를 통해 사용자에게 보다 나은 서비스를 제공하려는 기능들이 많았습니다.
본 기술블로그에 게재되는 모든 컨텐츠의 저작권은 케이티넥스알(kt NexR)에서 가지고 있으며, 동의 없는 컨텐츠 수정 및 무단 복제는 금하고 있습니다. 컨텐츠(글/사진/영상 등)를 공유하실 경우 반드시 출처를 밝혀주시기 바랍니다. Copyright(c) kt NexR, Inc. All Rights Reserved.