트랜지션, 서스펜스, 리액트 서버 컴포넌트와 같은 동시성 기능이 어떻게 애플리케이션 성능을 개선했는지 알아보겠습니다.
원문 : https://vercel.com/blog/how-react-18-improves-application-performance
리액트 18은 동시성 기능을 도입하여 리액트 애플리케이션의 렌더링 방식에 근본적인 변화를 가져왔습니다. 우리는 이러한 최신 기능들이 애플리케이션 성능에 어떠한 영향을 미치고 개선하는지 알아볼 것입니다.
우선, 잠시 뒤로 물러나서 long task에 대한 기본 지식과, 이에 따른 성능 측정에 대해 이해해보겠습니다.
브라우저에서 자바스크립트를 실행할 때, 자바스크립트 엔진은 주로 메인 스레드라고 불리는 싱글 스레드 환경에서 코드를 실행합니다. 메인 스레드는 자바스크립트 코드 실행과 더불어, 클릭과 키보드 입력 같은 사용자 상호작용, 네트워크 이벤트 처리, 타이머, 애니메이션 업데이트 그리고 브라우저의 리플로우와 리페인트 관리를 포함한 여러 종류의 작업을 처리합니다.
메인 스레드는 작업을 하나씩 처리합니다.
한 작업이 처리 중이면, 다른 모든 작업들은 반드시 기다려야 합니다. small task는 브라우저에서 매끄럽게 실행되어 원활한 사용자 경험을 제공할 수 있지만, long task는 다른 작업을 차단하기 때문에 문제가 될 수 있습니다.
50ms 이상 소요되는 작업은 "long task"로 간주됩니다.
이 50ms의 기준은 부드러운 시각 경험을 유지하기 위해 디바이스가 반드시 매 16ms(60fps)마다 새로운 프레임을 생성해야 한다는 사실에 기반합니다. 그러나, 디바이스는 사용자 입력에 응답하고 자바스크립트를 실행하는 등의 다른 작업들도 반드시 수행해야 합니다.
50ms 기준은 디바이스가 렌더링 프레임과 다른 작업을 수행하는 데 자원을 할당할 수 있도록 하며, 부드러운 시각 경험을 유지하면서 다른 작업을 수행할 수 있도록 추가적인 ~33.33ms를 제공합니다. RAIL 모델에 대해 다루는 이 블로그 글에서 50ms 기준에 대해 더 자세히 알아보실 수 있습니다.
최적의 성능을 유지하기 위해 long task의 개수를 최소화하는 것이 중요합니다. 웹사이트의 성능을 측정하기 위해 long task가 애플리케이션에 어떤 영향을 미치는지 측정할 수 있는 두 가지 지표가 있습니다. 총 차단 시간(Total Blocking Time, TBT)과 다음 페인트에 대한 상호작용(Interaction to Next Paint, INP)입니다.
총 차단 시간(TBT)은 최초 콘텐츠풀 페인트(First Contentful Paint, FCP)와 상호작용까지의 시간(Time To Interactive, TTI)사이의 시간을 측정할 수 있는 중요한 지표입니다. TBT는 작업을 실행하는 데 50ms 이상 걸린 시간의 합으로, 사용자 경험에 큰 영향을 미칩니다.
TTI이전에 50ms 임계값을 각각 30ms와 15ms만큼 초과하는 작업이 두 개가 있기 때문에 TBT는 45ms입니다. 총 차단 시간은 이러한 시간을 모두 더한 값입니다. 30ms + 15ms = 45ms
다음 페인트에 대한 상호작용(INP)는 새로운 코어 웹 바이탈 지표로서 버튼 클릭과 같은 사용자의 첫번째 상호작용으로 부터 실제 상호작용이 화면에 표시(다음 페인트)되기 까지의 시간을 측정합니다. 이 지표는 이커머스 사이트나 소셜 미디어 플랫폼과 같이 사용자의 상호작용이 많은 페이지일수록 특히 중요합니다. 사용자의 현재 방문 기간 동안의 모든 INP값을 축적하여 가장 나쁜 점수를 반환하는 식으로 측정됩니다.
가장 높게 측정된 시각 지연은 250ms이기 때문에, 이것이 다음 페인트에 대한 상호작용 값입니다.
새로운 리액트 업데이트가 이러한 지표를 어떻게 최적화하고 사용자 경험을 개선했는지 이해하기 위해서 우선 기존의 리액트 동작 방식을 알아보는 것이 중요합니다.
리액트의 시각적 업데이트는 렌더 단계 그리고 커밋 단계의 두 단계로 나뉩니다. 리액트에서 렌더 단계는 리액트 요소들이 기존 DOM과 조정되는 (즉, 비교되는) 순수한 계산 단계입니다. 이 단계에서는 새로운 리액트 요소 트리가 생성되고, 또한 실제 DOM에 대한 가벼운 인메모리 표현인 "가상 돔"도 생성됩니다.
렌더링 단계에서 리액트는 현재의 DOM과 새로운 리액트 컴포넌트 트리 간의 차이점을 계산하고, 필요한 업데이트를 준비합니다.
렌더링 단계 다음에 커밋 단계가 있습니다. 이 단계에서 리액트는 렌더링 단계에서 계산된 업데이트를 실제 DOM에 적용합니다. 여기에는 새로운 리액트 컴포넌트 트리를 미러링하기 위해 DOM 노드를 생성, 업데이트, 삭제하는 작업이 포함됩니다.
기존의 동기 렌더링 방식에서 리액트는 컴포넌트 트리 내의 모든 요소에 동일한 우선순위를 부여했습니다. 컴포넌트 트리가 렌더링 될 때, 초기 렌더링이나 상태 업데이트시, 리액트는 중단하지 않는 단일 작업으로 트리를 렌더링하고, 그 후 DOM에 커밋하여 화면의 컴포넌트를 시각적으로 업데이트합니다.
동기 렌더링 방식은 렌더링을 시작한 컴포넌트가 항상 완료될 것이 보장된 "모-아니면-도(all-or-nothing)" 작업입니다. 컴포넌트의 복잡성에 따라 렌더링 단계를 완료하는 데 시간이 걸릴 수 있습니다. 이 시간 동안 메인 스레드는 차단되며 애플리케이션과 상호작용을 시도하는 사용자들은 리액트가 렌더링을 종료하고 결과를 DOM에 반영하기 전까지 반응이 없는 UI를 경험하게 됩니다.
아래의 데모를 통해 위 내용을 확인할 수 있습니다. 데모에는 텍스트 입력 필드와 입력값에 따라 필터링된 결과를 보여주는 도시 목록이 있습니다. 동기 렌더링에서는 키 입력마다 리액트는 CitiesList
를 다시 렌더링합니다. 목록이 수만 개의 도시로 구성되어 있기 때문에 계산 비용은 상당히 많이 듭니다. 따라서 키 입력과 이를 텍스트 입력에 반영하는 데까지 분명한 시각적 피드백 지연이 존재합니다.
성능 탭을 보면, 키 입력마다 long task가 발생하는 것을 볼 수 있는데, 이는 최적화가 필요합니다.
빨간색 모서리로 마킹된 작업은 "long task"입니다. 총 차단 시간은 4425.40ms입니다.
이러한 시나리오에서, 리액트 개발자들은 종종 렌더링을 지연시키기 위해 debounce
와 같은 서드파티 라이브러리를 사용하지만, 이는 내장된 해결책이 아닙니다.
리액트 18은 화면 밖에서 동작하는 새로운 동시성(Concurrent) 렌더러를 도입했습니다. 이 렌더러는 특정 렌더링이 급하지 않다는 것을 표시할 수 있는 수단을 제공합니다.
낮은 우선순위의 컴포넌트(핑크색)을 렌더링할 때, 리액트는 더 중요한 작업을 확인하기 위해 메인 스레드에게 양보합니다.
이 경우, 리액트는 5ms마다 더 중요한 작업이 있는지 확인하기 위해 메인 스레드에 양보합니다. 예시로는 사용자 입력이나, 그 순간 사용자 경험에 훨씬 중요한 다른 리액트 컴포넌트의 상태 업데이트를 렌더링하는 것 등이 있습니다. 계속 메인 스레드에게 양보하면서, 리액트는 렌더링을 차단하지 않고 더 중요한 작업의 우선순위를 정할 수 있습니다.
모든 렌더링마다 하나의 중단없는 작업을 실행하는 대신, 동시성 렌더러는 낮은 우선순위의 컴포넌트를 (리)렌더링하는 동안 5ms의 간격으로 메인 스레드에 제어권을 넘겨줍니다.
추가로, 동시성 렌더러는 결과를 즉시 커밋하지 않고 백그라운드에서 여러 버전의 컴포넌트 트리를 "동시에" 렌더링할 수 있습니다.
동기식 렌더러가 모-아니면-도의 계산을 했다면, 동시성 렌더러는 가장 최적의 사용자 경험을 달성하기 위해 리액트가 하나 또는 여러 개 컴포넌트 트리 렌더링을 중단하고 재개하도록 합니다.
리액트는 다른 업데이트를 우선하여 렌더링하도록 하는 사용자 상호작용에 근거하여 현재의 렌더링 작업을 일시적으로 중단합니다.
동시성 기능을 사용하여, 리액트는 사용자 상호작용과 같은 외부 이벤트에 근거하여 컴포넌트 렌더링을 일시 중지하고 재개할 수 있습니다. 사용자가 ComponentTwo
와 상호작용하기 시작하면, 리액트는 현재의 렌더링을 일시 중지하고 ComponentTwo
를 우선으로 렌더링합니다. 그 후 ComponentOne
의 렌더링 작업을 재개합니다. 뒤에 등장하는 Suspense
섹션에서 이에 대해 더 알아보겠습니다.
useTransition
훅에서 제공하는 startTransition
함수를 사용하여 업데이트가 긴급하지 않다는 것을 표시할 수 있습니다. 이는 특정 상태 업데이트를 "트랜지션"으로 표시할 수 있는 강력한 신규 기능이며, 만약 해당 업데이트가 동기적으로 렌더링 될 경우 잠재적으로 사용자 경험을 방해하는 시각적 변화로 이어질 수 있음을 나타냅니다.
상태 업데이트를 startTransition
로 감싸서 현재 사용자의 인터페이스를 인터랙티브하게 유지하도록 렌더링을 연기하거나 중단해도 괜찮다고 리액트에게 알릴 수 있습니다.
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}
트랜지션이 시작될 때, 동시성 렌더러는 백그라운드에 새로운 트리를 마련합니다. 렌더링이 완료되면 리액트 스케줄러가 새로운 상태를 반영하도록 DOM을 고성능으로 업데이트할 수 있을 때까지 결과를 메모리에 보관합니다. 이 순간은 브라우저가 유휴 상태이고 사용자 상호작용과 같은 높은 우선순위의 작업이 없을 때가 될 수 있습니다.
트랜지션을 사용하는 것은 CitiesList
데모에 아주 적합합니다. 동기 렌더링을 유발하는 키 입력마다 searchQuery
로 전달되는 값을 즉시 업데이트하는 방식 대신, 상태를 두 가지 값으로 나누고 searchQuery
의 상태 업데이트를 startTransition
으로 감쌀 수 있습니다.
이는 리액트에게 상태 업데이트가 사용자들에게 방해되는 시각적 변화를 발생시킬 수 있다는 것을 알려줍니다. 따라서 리액트는 업데이트를 즉시 반영하는 대신 백그라운드에서 새로운 상태를 준비하면서 현재의 UI를 인터랙티브하게 유지하도록 시도해야 합니다.
로 사용하는text
상태는 여전히 동기적으로 업데이트되기 때문입니다.
리액트는 키 입력마다 백그라운드에서 새로운 트리를 렌더링합니다. 그러나 이것은 모-아니면-도의 동기식 작업이 아닙니다. 리액트는 "오래된" 상태를 보여주는 현재의 UI를 추가적인 사용자 입력에 반응하도록 남겨두면서 메모리 내에서 새로운 버전의 컴포넌트 트리를 준비합니다.
성능 탭을 보면, startTransition
으로 상태 업데이트를 감싸는 경우, 트랜지션을 사용하지 않은 성능 그래프와 비교했을 때 long task의 개수와 전체 차단 시간이 상당히 줄어든 것을 확인할 수 있습니다.
성능 탭에서 long task의 개수와 전체 차단 시간이 확연히 줄어든 모습을 볼 수 있습니다.
트랜지션은 리액트 렌더링 모델의 근본적인 변화의 일부로서, 리액트가 여러 버전의 UI를 동시에 렌더링하고, 여러 작업 간의 우선순위를 조정할 수 있도록 합니다. 이는 더 부드럽고 반응적인 사용자 경험을 제공하며, 특히 높은 빈도의 업데이트나 CPU 집약적인 렌더링 작업을 할 때 더욱 빛을 발합니다.
리액트 서버 컴포넌트는 리액트 18의 실험 기능이지만, 프레임워크에서 사용 가능한 상태입니다. Next.js를 살펴보기 전에 알아두어야 할 중요한 사항입니다.
전통적으로, 리액트는 앱을 렌더링하는 몇 가지 주요 방법을 제공했습니다. 모든 것을 완전히 클라이언트가 렌더링하거나 (클라이언트 사이드 렌더링), 또는 컴포넌트 트리를 서버에서 HTML로 렌더링하고 정적 HTML과 컴포넌트 하이드레이트에 사용될 자바스크립트 번들을 클라이언트 측에 전송할 수도 있습니다. (서버 사이드 렌더링)
두 접근법 모두 컴포넌트 트리가 이미 서버에서 사용할 수 있더라도 동기식 리액트 렌더러는 제공된 자바스크립트 번들을 사용하는 컴포넌트 트리를 클라이언트 측에서 다시 빌드해야 한다는 사실에 기반합니다.
리액트 서버 컴포넌트를 사용하면 리액트가 실제 직렬화된 컴포넌트 트리를 클라이언트에 전송할 수 있습니다. 클라이언트 측 리액트 렌더러는 이 형식을 이해하고, HTML 파일이나 자바스크립트 번들을 전송할 필요 없이 이 형식을 사용하여 리액트 컴포넌트 트리를 고성능으로 재구성합니다.
react-server-dom-webpack/server
의 renderToPipeableStream
메서드와 react-dom/client
의 createRoot
메서드를 결합하여 이 새로운 렌더링 패턴을 사용할 수 있습니다.
// server/index.js
import App from '../src/App.js'
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(React.createElement(App));
return pipe(res);
});
---
// src/index.js
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';
export function Index() {
...
return createFromFetch(fetch('/rsc'));
}
const root = createRoot(document.getElementById('root'));
root.render(<Index />);
⚠️ 위 예제는 매우 단순화된 형태입니다 (!) CodeSandbox 데모 예제는 아래에 있습니다.
전체 CodeSandbox 예제를 보려면 이 곳을 클릭하세요. 다음 섹션에서, 더 정교한 예제를 살펴볼 것입니다.
기본적으로 리액트는 리액트 서버 컴포넌트를 하이드레이션하지 않습니다. 해당 컴포넌트들이 window
객체에 접근하거나 useState
, useEffect
등의 훅을 사용하는 것과 같이 클라이언트 측 상호작용을 이용할 거라고 기대하지 않습니다.
제공되는 자바스크립트 번들에 컴포넌트와 import를 추가하여 인터랙티브하게 만들기 위해, 파일 상단에 "use client" 디렉티브를 사용할 수 있습니다. 이는 번들러에게 이 컴포넌트와 import를 클라이언트 번들에 추가하고, 리액트에게 상호작용을 추가하기 위해 클라이언트 측에서 트리를 하이드레이트 하라고 알립니다. 이러한 컴포넌트를 클라이언트 컴포넌트라고 합니다.
참고 : 프레임워크마다 구현이 다를 수 있습니다. 예를 들어, Next.js는 기존의 SSR 접근 방식과 유사하게 클라이언트 컴포넌트를 서버의 HTML로 미리 렌더링합니다. 그러나, 기본적으로 클라이언트 컴포넌트는 CSR 접근 방식과 비슷하게 렌더링됩니다.
클라이언트 컴포넌트로 개발할 때 번들 크기를 최적화하는 것은 개발자에게 달려있습니다. 개발자들은 아래 작업을 통해 최적화를 수행할 수 있습니다.
"use client"
디렉티브를 정의하도록 보장합니다. 이를 위해서 몇몇 컴포넌트의 분리가 필요할 수도 있습니다.children
을 클라이언트 번들에 추가하지 않고도 리액트 서버 컴포넌트로 렌더링할 수 있습니다. 또 다른 중요한 동시성 기능은 Suspense
입니다. Suspense
는 리액트 16에서 React.lazy
와 함께 코드 스플리팅을 위해 출시되었기 때문에 완전히 새로운 기능까지는 아니지만, 리액트 18에 등장한 새로운 기능은 Suspense
를 데이터 페칭까지 확장합니다.
Suspense
를 사용하면, 원격 소스에서 데이터가 로드되는 등의 특정 조건을 만족시킬 때까지 컴포넌트 렌더링을 지연시킬 수 있습니다. 그동안, 이 컴포넌트가 로딩 중임을 알릴 수 있도록 폴백 컴포넌트를 렌더링할 수 있습니다.
명시적으로 로딩 상태를 정의함으로써, 조건부 렌더링 로직의 필요성이 줄어듭니다. Suspense
를 리액트 서버 컴포넌트와 함께 사용하면 데이터베이스나 파일 시스템과 같이 별도의 API 엔드포인트 없이도 서버 측 데이터 소스에 직접 접근할 수 있습니다.
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
리액트 서버 컴포넌트는 서스펜스와 원활히 동작하며, 컴포넌트가 로딩될 동안 로딩 상태를 정의할 수 있습니다.
Suspense
의 진가는 리액트 동시성 기능과의 긴밀한 통합에서 발휘됩니다. 예를 들어 컴포넌트가 데이터 로드를 기다리고 있어서 일시 중단된 경우, 리액트는 컴포넌트가 데이터를 받을 때까지 유휴 상태로 대기 하고 있지 않습니다. 대신에 중지된 컴포넌트의 렌더링을 멈추고 다른 작업을 진행합니다.
그동안, 이 컴포넌트가 여전히 로딩 중임을 나타내는 폴백 UI를 렌더링하도록 리액트에 지시할 수 있습니다. 기다린 데이터를 사용할 수 있게 되었을 때, 앞서 트랜지션 섹션에서 본 것처럼 리액트는 중단 가능한 방식으로 이전에 중단된 컴포넌트의 렌더링을 원활히 재개할 수 있습니다.
리액트는 또한 사용자 상호작용에 따라 컴포넌트의 우선순위를 조정할 수 있습니다. 예를 들어, 사용자가 현재 렌더링 되지 않은 중단된 컴포넌트와 상호작용할 때, 리액트는 현재 진행 중인 렌더링을 일지 중지하고 사용자가 상호작용한 컴포넌트의 우선순위를 높여 진행합니다.
준비가 완료되면, 리액트는 DOM에 커밋하고 이전 렌더링을 재개합니다. 이렇게 하면 사용자 상호작용을 우선시하고, UI가 사용자 입력에 따라 반응하고 최신 상태를 유지하는 것을 보장할 수 있습니다.
Suspense
와 리액트 컴포넌트의 스트림 가능한 형태의 조합을 통해 낮은 우선순위의 렌더링 작업이 끝나길 기다릴 필요 없이, 높은 우선순위의 업데이트는 준비되는 즉시 클라이언트에 전송될 수 있습니다. 이를 통해 클라이언트는 데이터 처리를 더 빨리 시작할 수 있으며, 콘텐츠가 도착하면 차단하지 않는 방식으로 점진적으로 노출하여 보다 유동적인 사용자 경험을 제공할 수 있습니다.
Suspense
의 비동기 작업 처리 능력과 결합된 이 중단 가능한 렌더링 메커니즘은 중요한 데이터 페칭을 필요로 하는 복잡한 애플리케이션에서 특히 더 부드럽고 사용자 중심적인 경험을 제공합니다.
렌더링 업데이트 외에도, 리액트 18은 데이터를 페치하고 그 결과를 효율적으로 기억하는 새로운 API를 도입했습니다.
이제 리액트 18에는 래핑된 함수 호출의 결과를 기억할 수 있는 캐시 함수가 있습니다. 만약 동일한 렌더링 단계에서 같은 함수를 같은 인자를 이용하여 호출한다면, 리액트는 해당 함수를 다시 호출하는 대신 기억한 값을 사용할 것입니다.
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // 같은 렌더링 단계에서 호출: 기억된 결과를 반환
fetch
호출에서, 리액트 18은 이제 기본적으로 cache
의 사용 없이 유사한 캐싱 메커니즘을 포함합니다. 이는 같은 렌더링 단계 내에서 네트워크 요청의 수를 줄여주며, 애플리케이션의 성능이 향상하고, API 비용을 낮출 수 있습니다.
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // 같은 렌더링 단계에서 호출: 기억된 결과를 반환
이러한 기능은 Context API에 접근할 수 없는 리액트 서버 컴포넌트와 함께 사용하면 유용합니다. cache와 fetch의 자동 캐싱 동작을 통해 전역 모듈에서 단일 함수를 내보내고 이를 애플리케이션 전체적으로 재사용할 수 있습니다.
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}
async function BlogPostContent() {
const post = await fetchBlogPost('123'); // 기억된 결과를 반환
return '...'
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}
요약하자면 리액트 18의 최신 기능들은 다양한 방식으로 성능을 개선했습니다.
Next.js의 앱 라우터를 사용하는 개발자는 이 블로그 글에서 언급한 cache
및 서버 컴포넌트와 같이 프레임워크에 사용할 수 있는 기능을 바로 활용할 수 있습니다. 다음 글에서는 Next.js 앱 라우터가 이러한 기능을 활용하여 애플리케이션을 더욱 향상시키는 방법에 대해 다룰 예정입니다.
At Cyber Security Dubai, we specialize in providing tailored cybersecurity solutions for businesses of all sizes. Our experienced team of security professionals is dedicated to helping our clients protect their businesses from cyber threats. We offer a wide range of services, from security assessments and risk management to security training and incident response. https://cybersecuritydubai.ae/
잘 읽었습니다. 좋은 정보 감사드립니다.