React 18, 달라진 점들 (React 18, breaking points)
이번 포스팅에서는 최근에 페이스북에서 발표한 React 18 알파 버전에 대해서 바뀐 점들을 분석해보고 리액트 팀이 어느 방향으로 리액트를 만들어 나가는지 동향을 분석해 보려고 한다.
2021년 6월 8일 리액트 팀은 리액트 18 버전의 주요 변경 사항들을 발표했다. 리액트 18을 다음 메이저 버전으로 가져갈 것이며 리액트 커뮤니티에서의 다양한 의견들을 수용하여 리액트 18의 주요 기능들로 가져간다고 한다. 그리고 여러 사람들로부터 피드백을 듣기 위해 리액트 18 알파 버전을 발표했다.
리액트 18은 다음과 같은 개선사항들이 포함될 예정이다.
- 즉시 사용 가능한 개선(out-of-the-box improvement)
- automatic batching (적은 렌더링을 위함)
- suspense를 위한 SSR 지원
- new APIs (동시성 특성, concurrent features)
- startTransition
- useDeferredValue
- SuspenseList
- Streaming SSR (선택적 hydration)
이와 같은 특징들은 공통적으로 동시적 렌더링(concurrent rendering)을 지원하기 위함이며 이는 리액트가 여러 버전의 UI를 동시간에 보여줄 수 있게 가능하게 해준다. 이러한 변화는 겉으로 직접적으로 보이지는 않지만(behind-the-scenes), 더욱더 실제적이고 인지적인 퍼포먼스를 어플리케이션이 향상시킬 수 있도록 도와줄 것이다.
그러면 지금부터 주요 변경점을 하나씩 딥 다이브(deep dive) 해보도록 하자.
Automatic Batching
React 18에서는 바로 사용할 수 있는 성능적인 개선점을 보여줬는데 이것은 기본적으로 더 많은 배칭(batching)을 하는 것이다. 이로 인해 수동적으로 배치를 해 주어야 할 일이 줄어들었다.
배칭은 리액트에서 여러개의 상태를 업데이트 할 때 그것들을 묶어서 하나의 리렌더링으로 묶는 과정으로, 더 나은 성능을 제공한다.
예를 들어 아래 예제와 같이 하나의 클릭 이벤트로 두 개의 상태를 동시에 바꿔줄 때, 리액트에서는 두 개의 상태변화를 하나의 리렌더링을 통해서 처리한다. 매번 클릭할 때마다 한 번의 리렌더링을 통해 두 개의 상태를 동시에 바꾸어주는 것이다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // Does not re-render yet
setFlag(f => !f); // Does not re-render yet
// React will only re-render once at the end (that's batching!)
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
이러한 배칭은 성능적인 면에서 엄청나게 유리하다. 하지만, 여기에도 한계가 있는데 예를 들면 데이터 패칭한 후 상태를 변화시키려 할 때 리액트는 이러한 두 가지 업데이트를 배칭하지 않고 각각 두 개의 업데이트로 처리한다.
다음과 같이 처리하는 이유는, 리액트는 배치 업데이트를 브라우저 이벤트(ex. 클릭 이벤트) 동안에만 진행하기 때문이다. 하지만 아래와 같은 코드에는 이미 이벤트가 핸들링 된 후(fetch callback) 상태를 업데이트 하기 때문에 배칭이 일어나지 않는다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 17 and earlier does NOT batch these because
// they run *after* the event in a callback, not *during* it
setCount(c => c + 1); // Causes a re-render
setFlag(f => !f); // Causes a re-render
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
리액트 18이 나오기 전까지, 리액트는 이벤트 핸들러 동안에만 배칭을 수행했다. Promise, setTimeout, native Event Handler 등과 같은 함수들 안에서 업데이트는 배치가 이루어지지 않았다.
그렇다면 리액트 18에서 추가된 자동 배칭(automatic batching)은 무엇일까?
리액트 18에서 createRoot 를 시작으로, 모든 업데이트는 어디에서 기원이 있는 업데이트이든지 자동적으로 배칭이 이루어진다. 이 말의 의미는 Timeout, Promise, 네이티브 이벤트 핸들러 등 어던 이벤트 안에서든 업데이트가 마치 기존의 리액트 이벤트처럼 항상 배칭이 이루어진다는 의미이다. 우리는 이러한 변화로 인해 렌더링 작업이 줄어들고 어플리케이션의 높은 성능을 기대할 수 있다.
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 18 and later DOES batch these:
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
따라서 아래의 모든 예제에 대해서 렌더링은 한 번씩만 일어나게 된다.
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!)
});
만약 자동 배칭을 원하지 않는다면 flushSync를 사용하여 컴포넌트를 직접 리렌더링 해주면 된다.
import { flushSync } from 'react-dom'; // Note: react-dom, not react
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag(f => !f);
});
// React has updated the DOM by now
}
SSR Support for Suspense
이 부분은 기본적으로 서버 사이드 렌더링(Server Side Rendering, SSR) 로직의 연장선이다. 전형적인 리액트 SSR 어플리케이션의 로직은 다음과 같다.
- 서버가 UI를 그리기 위해 필요한 데이터를 패칭한다.
- 서버가 전체 앱을 HTML으로 렌더링을 하고, 클라이언트에 응답을 보낸다.
- 클라이언트는 자바스크립트 번들을 다운로드 한다.(HTML과 별개)
- 마지막으로, 클라이언트는 자바스크립트 로직을 HTML으로 연결한다.
전통적인 SSR 어플리케이션의 문제는, 각각의 단계가 전체 어플리케이션에서 끝나야 다음 단계가 시작할 수 있다는 점이다. 이 부분은 어플리케이션이 복잡해 질수록 느려질 수 있고, 반응형이 되기 어렵다. 어떤 것이든 보여주기 위해서는 모든 데이터를 패칭 해야 하며, 어플리케이션이 무거워질수록 사용자는 데이터를 오랜 시간 기다려야 한다.
예를 들어 현재 SSR은 "all or nothing" 이다.
클라이언트가 처음에 받는 서버로부터 만들어진 HTML은 다음과 같고,
모든 코드(자바스크립트 청크)가 로드되고 전체 앱이 hydrated 되면 다음과 같을 것이다.
리액트 18의 <Suspense>를 쓰면 다음과 같이 페이지의 부분부분을 <Suspense>로 묶어서 따로 처리할 수 있다. <Suspense> 통해서 해결을 하려고 하는데, 이 컴포넌트를 통해 전체 어플리케이션을 좀 더 작은 독립적인 유닛으로 쪼개고 각각이 위에 언급한 단계들을 지나가게 된다. 그래서 유저가 컨텐츠를 보게되면, 바로 인터렉티브하게 된다.
예를 들어, 다음과 같이 코드를 작성한다.
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
<Comment> 부분을 <Suspense>로 감쌌기 때문에, 여기서 리액트는 HTML을 스트리밍하기 위해 코멘트를 기다릴 필요가 전혀 없다. 따라서 리액트는 코멘트 부분을 spinner로 나타난 placeholder를 두고 나머지 영역을 먼저 처리한다.
이제 서버에서 코멘트 부분의 데이터가 준비가 되면 리액트는 추가적인 HTML을 보내서 기존의 HTML에서 적절한 부분에 끼워 넣는 작업을 진행한다. 그러면 다음과 같이 모든 페이지가 잘 HTML이 불러와졌다.
이 이후에 자바스크립트를 불러올 때도, 코멘트 부분은 다음과 같이 코드 스플리팅으로 처리해 줄 수 있다.
import { lazy } from 'react';
const Comments = lazy(() => import('./Comments.js'));
// ...
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
리액트 18에서 <Suspense>는 코멘트 위젯이 로드되기 전에 앱을 hydrate 해준다. 그러면 다음과 같은 순서로 페이지가 그려질 것이다.
만약 자바스크립트 코드가 모든 HTML 코드보다 일찍 로드가 된다면 기다리지 않고 나머지 부분을 먼저 hydrate 한다. 다음과 같은 순서로 앱이 그려질 것이다.
이렇게 되면 코멘트 영역이 hydrate 되기 전에, 페이지의 다른 영역들이 먼저 hydrate 되었으므로 자유롭게 사용할 수 있다. 클릭을 한다든지 하는 인터렉션이 가능하다는 의미이다.
<Suspense>의 새로운 특징들은 다음과 같다. startTransition은 숨어있는 존재하는 컨텐츠를 (심지어 그것이 다시 유예되더라도) 피할 수 있게 해준다. 이는 리패칭되는 동안 이전 데이터를 보여주는 부분을 구현할 때 유용하다.
또한 주요 바뀐점 중 하나는 <Suspense>와 lazy를 서버에서 쓸 수 있다는 것이다. pipeToNodeWritable 메서드와 같이, 이는 서버 렌더링 과정에서 long standing pain point와 성능 이슈를 해결할 수 있다.
Transition
리액트 팀은 상태 업데이트를 하는 대상을 두 가지로 나누었다.
- urgent updates : 사용자가 직관적으로 보았을 때 업데이트가 즉각 일어나는 것들을 대상으로 함.
- transition updates : 상태 값의 변화에 따른 모든 업데이트가 뷰에 즉각적으로 반영되는 것을 기대하지 않음.
리액트 18에서는 이 transition 부분에서 불필요하게 많은 업데이트가 일어나는 부분을 개선하였다. 예를 들면, 인풋 필드에 타이핑을 하고, 이로 인해 데이터가 필터링이 되는 기능을 생각해보자. 필드에 글자를 하나씩 입력할 때마다 값을 저장해야하고 그 때마다 검색 쿼리를 보내주어야 한다.
// Update the input value and search results
setSearchQuery(input);
타이핑을 하나 할 때마다, 인풋 값을 업데이트하고 새로운 값으로 쿼리를 날려 리스트를 검색해야 한다. 기존 리액트에서 이 업데이트는 항상 동일한 시간에 이루어진다. 데이터가 많으면 이 작업을 매번 할 때마다 랙이 발생할 수 있을 것이다. 만약 타이핑을 하나 할 때마다 페이지를 모두 다시 그리고, 그렇게 되면 어플리케이션이 느려지고 사용자 경험이 나빠질 것이다. 따라서 이 부분에 최적화가 필요하게 되었고 이 부분이 리액트 18에서 반영이 되었다.
위의 작업에서 urgent updates와 not urgent updates로 굳이 나누어 본다면 다음과 같이 나누어 볼 수 있을 것이다. 인풋 값 자체에 대한 변화는 urgent 하지만, 그 결과를 필터링해서 리스트로 보여주는 부분은 상대적으로 not urgent 하다.
// Urgent: Show what was typed
setInputValue(input);
// Not urgent: Show the results
setSearchQuery(input);
리액트 18에서 startTransition API는 이 이슈를 해결할 수 있게 도와준다. 작업의 우선순위를 정해줄 수 있게 된 것이다.
import { startTransition } from 'react';
// Urgent: Show what was typed
setInputValue(input);
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results
setSearchQuery(input);
});
이를 사용하여 사용자와 페이지간의 상호작용을 신속하고 원활하게 유지할 수 있다. 그리고 더 이상 사용자에게 보여지는 부분과 관련이 없는 부분을 렌더링하는 과정에서 시간낭비를 하지 않아도 된다. startTransition을 사용할 수 있는 곳은 리액트가 UI를 업데이트하면서 크고 무겁고 복잡한 작업을 처리해야 하거나 이로 인해 대기시간이 발생할 수 있고, 또는 네트워크 환경이 좋지 않는 상황에서의 대응을 할 때라고 한다.
참고자료
https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html
https://velog.io/@daadaadaah/%EB%B2%88%EC%97%AD-Introducing-React-18
https://github.com/reactwg/react-18/discussions/21
https://javascript.plainenglish.io/what-you-need-to-know-about-react-18-54070f6bc4a1
https://github.com/reactwg/react-18/discussions/47#discussioncomment-847004