21년 6월 8일, 리엑트 코어 개발자들이 리엑트 공식 홈페이지에
리엑트 18에 새롭게 추가되는 기능들을 소개했습니다.
리엑트의 서버사이드렌더링은 다음의 스텝으로 이루어집니다.
각 단계들은 synchronous하게 진행되며 전체 과정은 Top-Bottom / WaterFall모델의 형태를 띄고 있습니다.
전체 컴포넌트 중 특정 부분만 느리게 처리가 되어도 앱 전체가 제일 느린 부분이 완전히 준비될 때까지 기다려야 하므로 비효율적인 시간소모가 발생합니다.
서버사이드에서 html을 클라이언트로 보내주기 전에 먼저 렌더링에 필요한 데이터들을 api서버에 호출해야 하는 상황이 있습니다.
api응답이 느리다면 해당 api를 사용하는 컴포넌트로 인해 사용자는 첫 페이지가 뜨기 전까지 오랜 시간을 기다려야 합니다.
data fetching은 클라이언트단의 코드를 개선한다고 완전히 해결할 수 없는 상황이기에 초기 페이지를 빠르게 보내기 위해서 아래의 선택지에서 취사선택을 해야합니다.
1. SSR에서 해당 API fetch를 제외시키고 CSR단계에서 useEffect에서 호출하는것으로 대신합니다.
2. 페이지를 구성하는 10개의 컴포넌트 중 9개의 컴포넌트가 해당 api응답과 무관하더라도 딜레이를 감수합니다.
초기페이지가 브라우저에서 그려지기 시작하더라도 아직 딜레마가 하나 더 남아있습니다.
브라우저는 리엑트와 같은 싱글페이지 웹 어플리케이션을 실행할 때 자바스크립트를 다음과 같은 과정으로 처리하는데요,
1. Fetch JS
2. Load JS
3. Hydrate
페이지가 버튼클릭과 같은 유저 인터렉션에 반응하기 위해서는 이벤트핸들러와 같은 JS 코드들이 html에 동화되어야 합니다.
이것을 수화(hydration)과정이라고 하는데 자바스크립트 파일 번들용량이 크거나 복잡하다면 위의 과정이 오래걸릴 수밖에 없습니다.
또한 모든 컴포넌트가 완전히 수화과정을 완료하기 전까지는 페이지 기능을 정상적으로 사용할 수 없습니다.
고사양의 컴퓨터에서 이런 딜레이는 큰 문제가 되지 않을 수 있습니다.
하지만 사양이 낮은 디바이스 환경에서 hydration딜레이는 충분히 발생할 수 있으며 나쁜UX를 젝오하는 원인이 될 수 있기 때문에 개발자가 고려해야할 사항입니다.
1. 용량이 크거나 복잡한 로직이 담긴 컴포넌트를 제외시킨다.
2. JS 코드들이 완전히 수화될때까지 유저를 기다리게 한다.
결국 리엑트를 사용하는 모던 웹에서 interaction blocking과 slow FTP(Frist time to Paint)두가지의 UX문제는 서로 트레이드 오프적인 관계를 지닐 수 밖에 없습니다.
서버에서 각 컴포넌트 렌더링에 필요한 데이터를 먼저 처리하고 전체 페이지를 렌더링해 클라이언트에 내려줍니다.
자바스크립트 번들을 다운받고 브라우저가 불러올때까지 기다립니다.
그 이후 hydration을 진행합니다.
18버전 부터는 유저에게 처음 보여지는 페이지 전체를 그려 내려주는것이 아니라 빠르게 준비되는 부분부터 렌더링/수화 시켜주며 이 기능을 HTML Streaming
과 Selective Hydration
으로 명명하고 있습니다.
위의 두가지 기능이 어떻게 사용되어지는지 react18저장소 에서 친절하게 설명해주고 있는데,
아래에서 하나씩 살펴보겠습니다.
서버에서 html을 보내주는것을 html streaming이라고 하는데, renderToString을 사용한 전통적인 방식으로 SSR을 구현하면 브라우저에서는 서버에서 보내주는 html페이지를 하나의 파일로 통째로 받았었습니다.
새로운 버전에서 서버는 pipeToNodeWritable을 사용해 html코드를 작은 청크로 나누어 보내줄 수 있습니다.
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
전체 앱 중 첫 페이지를 렌더링하는데 시간이 오래걸리는 <Commnets />
컴포넌트를 <Suspnese >
로 감쌋습니다.
리엑트에게 해당 컴포넌트를 렌더링할 준비가 되기 전까지는 fallback props로 넘긴 <Spinner />
를 대신 보여달라고 말한것인데요,
실제로 사용자는 아래와 같은 페이지를 보게됩니다.
화면 아래에서는 이런 일들이 일어나고 있습니다.
<Comments />
와 관련된 태그가 전ㄹ혀 보이지를 않습니다.
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
서버에서 <Comments/>
컴포넌트를 렌더링할 준비가 모두 끝나면 리엑트는 추가적인 html코드를 스트리밍하는데요, 앞서 대신 보여줬던 fallback엘리먼트와 대체해줍니다.
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
이전 버전까지는 전체 페이지가 준비되기까지 사용자가 페이지의 다른부분을 전혀 볼 수 없었습니다.
준비된 부분부터 보는것이 가능하다는 것은 렌더링 전체 과정에 있어서
FTTB(First Time To Byte)시간이 줄어든다는 뜻이며,
이것은 단순한 사용자 경험을 떠나 정량적인 수치로도 렌더링 퍼포먼스의 향상이 있다는 것을 의미합니다.
위의 예제코드에서 등장한 <Suspense>
는 전체 페이지를 각각의 작은 청크로 나누어 렌더링 할 수 있게 도와줍니다.
응답을 받는데 오래 걸리는 컴포넌트에는 <Suspence>
를 사용해 나머지 영역의 초기 렌더링 속도에 영향을 미치지 않게 할 수 있습니다.
<Suspence>
태그는 리엑트18 에서 처음 소개된 것이 아닙니다.
이 태그는 2018년도 처음 소개되어 클라이언트 사이드 렌더링 단계에서 큰 번들의 자바스크립트 코드들을 청크들로 나누어 로드될 수 있게 해주는 역할을 React.Lazy와 함께 수행했습니다. 이 기능을 코드 스플리팅 이라 합니다.
React18이전에도 코드 스플리팅을 구현해 큰 번들의 자바스크립트 코드를 청크로 잘게 나누어 로드되는 시간들을 분산시킬 수 있었습니다.
하지만 서버사이드 렌더링을 구현할 때 사용되는 renderToString
과 함께 사용할 수 없었고, 정상적인 SSR환경을 구축하기 위해서는 loadable-component와 같은 서드파티 라이브러리를 함께 사용해야 했습니다.
React18부터는 서드파티 라이브러리를 활용하지 않고도 <Suspense>
를 SSR환경에서도 정상적으로 이용할 수 있게 되었습니다.
이제 최소한 복잡하고 용량이 큰 <Comments/>
컴포넌트 때문에 페이지 전체가
FCP(First Content Paint)에서 손해를 보는 상황에서는 벗어날 수 있게 되었습니다.
하지만 극단적인 예시를 들어 일부 컴포넌트의 용량이 너무나 크고 복잡하다고 가정해볼까요?
다른 엘리먼트들이 자바스크립트를 다운받고 hydration할 준비를 다 마쳤더라도 언제 풀릴지 모르는, <Spinner />
만 계속 바라보고 있어야 할지도 모릅니다.
이렇게 렌더링하는데 비용이 큰 컴포넌트들을 <Suspence>
로 감쌈으로 인해 해당 부분이 여전히 fallback엘리먼트는 내보내고 있어도 그와 상관없이 페이지의 다른 부분은 hydrating을 시작할 수 있게 되었습니다.
이전 버전의 Top-Down방식과 다르게 JS번들이 로드된 컴포넌트들은 먼저 hydration을 시작할 수 있습니다.
기존에는 번들 로드가 느린 <Comments/>
때문에 모든 페이지가 완전하게 인터렉션하게 될때까지 기다려야 했지만 이제는 사이드바나 네비게이션바가 본인들의 역할을 보다 빨리 할 수 있게 되었습니다.
유저가 해당 페이지가 완전히 동작하기 이전에 다른곳으로 이동하고 싶다면 이미 hydration이 완료된 네비게이션 바를 이용할 수 있습니다.
빠른 인터렉션을 제공해 더 나은 UX를 제공할 수 있게 되는 것입니다.
또한 사용자의 인터렉션에 따라 어떤 것을 먼저 hydration시킬지에 대한 우선순위를 정할 수 있게 되었습니다.
아래와 같이 <SideBar/>
와 <Comments/>
두 태그가 <Suspense>
로 둘러싸여 있다고 할 때 일반적으로 hydration은 돔트리에 배치된 순서에 따라 순차적으로 진행이 됩니다.
다음과 같은 상황에서는<SideBar/>
가 <Comments/>
보다 먼저 hydration이 진행됩니다.
<Layout>
<NavBar />
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
사용자가 <SideBar/>
가 hydration되기 이전에 <Commnets/>
에 대해 관심을 가지고 버튼 클릭을 했다면
리엑트는 해당 클릭 이벤트를 기록하고 <Comments/>
부분에 대한 hydration의 우선순위를 높여서 진행합니다.
<Comments/>
에 대한 hydration이 완료되면 앞서서 기록 놓았던 클릭 이벤트를 실행하고 남은 <Sidebar/>
의 처리도 마저 진행합니다.
selected hydrating 로 인해 항상 정해진 순서를 따르지 않고 사용자가 관심 있는 부분부터 인터렉션 가능한 컨텐츠를 제공할 수 있게 되었습니다.
<Suspense/>
를 조금 더 세분화 해서 여러 부모 자식 관계를 가진 컴포넌트를 대상으로 적용시킨다면 해당 기능의 장점이 좀 더 극명하게 나타납니다.
<Layout>
<NavBar />
<Suspense fallback={<BigSpinner />}>
<Suspense fallback={<SidebarGlimmer />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<CommentsGlimmer />}>
<Comments />
</Suspense>
</RightPane>
</Suspense>
</Layout>
아래의 그림에서 사용자가 <Comments/>
컴포넌트 안에 있는 <Comment/>
들 중 첫번째 엘리먼트를 먼저 클릭했다고 가정하겠습니다.
클릭한 요소를 둘러싸고 있는<Suspense/>
중 최상위 부모 엘리먼트 부터 hydration을 시작하게 됩니다.
인터렉션과 관계없는 <Suspense/>
로 둘러싸인 형제 엘리먼트는 일단 hydration을 스킵하고 인터렉션이 발생한 요소부터 실행하기 때문에
hydration이 즉시 일어나는 것 같은 느낌을 줄 수 있습니다.
배칭(batching)은 업데이트 대상이 되는 상태값들을 하나의 그룹으로 묶어서 한번의 리렌더링에 업데이트가 모두 진행될 수 있게 해주는 것을 의미합니다.
한 함수 안에서 setState를 아무리 많이 호출시키더라도 리렌더링은 단 한번만 발생합니다.
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!)
}
batch update를 사용함으로 불필요한 리렌더링을 줄일 수 있어서 퍼포먼스 적으로 큰 이점을 얻을 수 있는데요,
이전 버전에서도 이런 batch update가 지원되었지만 클릭과 같은 브라우저 이벤트에서만 적용이 가능하고 api호출에 콜백으로 넣은 함수나 timeouts함수에서는 작동하지 않았습니다.
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
});
}
setTimeout(() => {
setCount(c => c + 1); // re-render occurs
setFlag(f => !f); // re-render occurs again!
}, 1000);
18버전 부터는 React.createRoot
를 이용해 브라우저 이벤트 뿐만 아니라
timeouts,promises를 비롯한 모든 이벤트에서 batching이 자동으로 적용되게 할 수 있습니다.
리엑트 팀은 여러 상황에서 발생할 수 있는 렌더링 횟수를 줄임으로 퍼포먼스 개선을 기대할 수 있다고 하는데요 , 이 기능을automatic batching이라 합니다.
앞선 예제코드에서는 작동하지 않았던 배칭 업데이트가 리엑트 18버전의 createRoot
아래에서는 자동으로 적용되는 것을 확인할 수 있습니다.
// onClick 핸들러
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}
// setTimeout
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}, 1000);
// fetch API
fetch(/*...*/).then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
})
// addEventListener callback
elm.addEventListener('click', () => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
});
만약 automatic batching을 사용하고 싶지 않다면, ReactDom.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
}
해당 기능은 상태 업데이트를 함에 있어서 우선순위를 정하는데 도움을 줍니다.
리엑트 팀은 상태 업데이트 대상을 두가지로 나우었으며 이를 통해 transition이 의미하는 바가 무엇인지를 파악할 수 있습니다.
타이핑과 같이 빈번하게 일어나는 이벤트에 따라 큰 화면이 업데이트 되어야 한다면 각 이벤트마다 일어나느 리렌더링이 해당 화면에 렉을 일으키거나 스무스한 UI를 제공하지 못하는 요인으로 작용할 수 있습니다.
검색 사이트에서 auto complete 기능이나 검색 필터링 기능을 사용한다고 했을때 결과값을 이용해 상태를 업데이트 하기 위해 보통 이러한 코드를 작성하게 됩니다.
// show what was typed
setInputValue(input);
// show results
setSearchQuery(input);
검색 결과 리스트가 매우 길거나 많지 않더라도 사이트에서 검색 결과 값을 가지고 내부적으로 복잡한 작업을 진행할 수 있으며 유저의 이벤트 값이 약간이라도 달라지더라도 페이지 UI에 큰 변화를 불러 일으키기에 이 때 발생하는 렉을 최적화할 수 있는 명확한 방법을 제시하기가 매우 어렵습니다.
페이지에서 사용자의 타이핑에 따라 화면이 달라지는 부분은 크게 입력 폼 과 결과 창 두가지 입니다.
입력 창은 네이티브 이벤트를 발생하는 UI이므로 유저는 타이핑이 입력창에 즉각적으로 반영되기를 기대할 것입니다.
결과 창은 직관적으로 어디에서 검색결과를 가져오는 작업을 하는 공간으로 느껴지기에 입력창 보다 UI업데이트가 느린것에 대해 자연스럽게 받아 들여집니다.
입력 창과 결과 창에 사용하는 상태 값은 항상 동일한 시간에 업데이트되기 때문에 이로인해 결과 값에 따라 입력 창의 업데이트가 지연될 가능성이 발생합니다.
지금까지는 리엑트가 모든 상태 업데이트의 우선순위를 동일하게 처리하였기 때문에
사람이 기대하는 것에 맞춰서 뷰의 각 부분에 세밀하게 우선순위를 주어 렌더링하는것이 매우 어려웠습니다.
리엑트 18에서 소개된 startTransition
을 이용해 각 상태 업데이트에 대한 우선순위를 정해줄 수 있게 되었습니다.
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으로 둘러싸인 부분은 클릭이나 키 입력에 의해 우선순위 높은 상태 업데이트가 발생하게 되면 렌더링 업데이트가 중단되고 키 입력이 다 끝난 이후의 업데이트만 발생하게 됩니다.
transition을 이용해 UI가 크게 달라지는 부분이 빈번하게 발생하더라도 사용자와 페이지간의 상호작용을 신속하고 원활하게 유지할 수 있습니다.
또한 더이상 사용자에게 보여지는 부분과 관련이 없는 컨텐츠를 렌더링하는데 있어서 시간을 낭비하지 않아도 됩니다.
유저에게 transition업데이트가 백그라운드에서 진행됨을 알려주고 싶을 수 있습니다.
이 때는 useTransition
훅을 이용해 <Spinner/>
와 같은 UI를 표시해줄 수 있습니다.
import { useTransition } from 'react';
const [isPending, startTransition] = useTransition();
...
{isPending && <Spinner />}
...
보통 auto complete 구현을 위해 debounce기능을 사용합니다.
debounce에는 setTimeout을 사용하는데, startTransition은 setTimeout과는 다르게 함수 호출 스케쥴을 뒤로 미루는 것이 아닙니다.
startTransition에 넘겨지는 콜백함수는 동기적으로 호출되며 콜백함수 안에서 일어나는 상태 업데이트는 transition
으로 마킹되어 리엑트가 업데이트를 처리할 때 어떤 우선순위로 처리해야 할지를 알려줍니다.
이 말은 즉, timeout 함수와 같이 macroTask에 의해 둘러싸인 상태 업데이트보다 먼저 처리됨을 의미합니다.
고사양의 디바이스에서는 이러한 차이가 미세할 수 있지만, 최적화가 필수적인 디바이스 환경에서 이러한 차이는 큰 영향을 미칠 수 있습니다.
startTransition은 크게 리엑트가 UI업데이트를 위해 크고 복잡한 일을 함으로 써 대기 시간이 발생하거나 느린 네트워크 환경에서 데이터를 받아오기 위해 기다리는 상황에서 사용한다고 합니다.