React 18을 거쳐 React19의 출시로 인해 웹 개발의 방식이 획기적으로 변화하고 있습니다. React19를 알아보기에 앞서 React 18의 동시성 렌더링과 동시성 렌더링에 있어서 Suspense가 가지는 의미에 초점을 맞춰 아티클을 작성했습니다. 복잡한 어플리케이션에서도 일관되고 유동적인 사용자 경험을 제공할 수 있는 비동기 처리 방식인 Suspense에 대해 알아봅시다.
동시성 렌더링과 Suspense의 통합
React 18 이전에는 렌더링을 시작하게 되면 작업이 완료될 때까지 멈출 수 없었습니다. 그에 따라 만약 어떤 컴포넌트의 렌더링이 오래 걸린다면 다음에 수행할 작업들에게 영향이 가면서 어플리케이션이 버벅이는 현상이 발생했으나, React 18의 동시성 렌더링을 통해 이를 해결하게 되었습니다.
자바스크립트가 싱글 스레드이기 때문에 동시성이 불가능하지 않을까 생각할수도 있지만, React에서 도입하게 된 동시성의 의미는 조금 다릅니다.
React의 동시성은 다음 뷰를 렌더링 하는 동안 현재 뷰의 반응성을 유지하도록 렌더링 프로세스를 재작업 하는 걸 의미합니다.
즉, 우선순위
를 두고 업데이트를 함으로써 여러 개의 작업을 동시에 처리하는 것처럼 보이게 하는 것이라고 할 수 있습니다. Concurrent 렌더링을 통해 어플리케이션이 더 빨라지진 않지만 빠르게 보이도록 하는 것이죠. React 18에서는 긴급한 업데이트(urgent update)
와 전환 업데이트(transition update)
로 나누어 우선순위가 급한 렌더링을 트리거 하도록 개선되었습니다.
출처 https://vercel.com/blog/how-react-18-improves-application-performance
동시성 렌더링의 등장과 Suspense 사이에 어떤 관련성이 있을까요?
React 16에 등장한 Suspense는 React 18, 동시성 기능과 통합되면서 데이터 패칭
에도 Suspense가 적용 가능하도록 발전했습니다.
컴포넌트가 데이터 로딩을 기다리고 있을 때, 리액트는 중지된 컴포넌트를 우선순위가 낮은 업데이트로 취급하고, 다른 작업을 우선적으로 실행하게 됩니다. 그동안 Suspense의 fallback 속성을 통해 로딩 상태 UI를 렌더링하도록 합니다. 그리고 데이터 로드가 완료됐을 때, 앞서 중단된 컴포넌트의 렌더링을 재개합니다.
Suspense와 동시성 기능의 조합을 통해 높은 우선순위가 높은 컴포넌트가 사용자에게 먼저 전달되면서, 이전보다 유동적인 사용자 경험을 제공할 수 있게 되었다는 점에서 그 의미가 있습니다.
선언형 컴포넌트
Suspense 컴포넌트를 사용하지 않고 구현하게 되면 로딩 컴포넌트를 어떻게(HOW) 띄워줄 것이냐에 초점을 맞춰 코드를 작성하게 됩니다.
pending
일 때 Suspense의 fallback
옵션을 통해 선언적
으로 코드를 작성해줄 수 있습니다.const { data, isLoading } = useGetGroupContent(moimId || '');
if (isLoading) return <div>isLoading...</div>
또한, 이전에는 개별 컴포넌트에서 데이터 로딩을 위한 상태관리를 일일이 지정해줘야 했으나, Suspense의 fallback 속성을 사용하여 이를 해결할 수 있습니다. 그에 따라 컴포넌트가 각각의 역할에 집중할 수 있게 해주어 코드의 복잡성을 줄이고 결합도를 낮추는 장점이 있습니다.
아래 코드는 React Core 팀에서 Suspense로 비동기를 감지하는 과정 설명을 위해 작성한 pseudo code 입니다.
function wrapPromise(promise) {
let status = "pending";
let result;
let suspender = promise.then(
(r) => {
status = "success";
result = r;
},
(e) => {
status = "error";
result = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw result;
} else if (status === "success") {
return result;
}
}
};
}
코드를 보면 알 수 있듯이, 하위 컴포넌트에서 상위 컴포넌트로 Promise를 던져줌으로써 비동기를 감지할 수 있음을 파악할 수 있습니다.
여기선 다루지 않겠지만 ErrorBoundary도 마찬가지로 하위 컴포넌트에서 Error객체와 같이 상위 컴포넌트로 throw 해줌으로써 상위 컴포넌트가 에러를 인식하고 처리를 해주게 됩니다.
- Suspense mount
- MainComponent mount
- MainComponent에서 useSuspenseQuery 훅을 사용하여 비동기 데이터 요청
- MainComponent unmount, fallback UI인 Loader mount
- 비동기 데이터 요청이 완료되면 fallback UI인 Loader unmount
- MainComponent mount
Suspense 컴포넌트는 Component Lazy Loading이나 데이터 패칭과 같이 비동기 처리시 Fallback UI를 보여주는 기능을 합니다.
Suspense를 검색하다보면 심심치 않게 React.lazy를 사용하여 Lazy Component를 만들어 Suspense와 함께 사용하는 예제를 발견할 수 있습니다.
그렇다면 React.lazy를 사용하는 이유가 뭘까요 ?
성능과 관련지어 사용하는 이유를 설명할 수 있습니다.
React는 SPA(Single Page Application) 이기 때문에 처음 렌더링 할 때 모든 파일이 하나로 번들링되어 빌드됩니다. 따라서, 최초 진입시 모든 페이지에 대한 정보를 가져오게 되면서 초기 로딩 속도가 느려지는 문제가 있습니다. 이때 lazy 컴포넌트를 사용하면 사용자 접근하는 페이지에 대한 정보만 동적으로 불러올 수 있어서 렌더링 최적화
측면에서도 좋은 효과를 얻을 수 있습니다.
const LazyComponent = React.lazy(() => import('./SomeComponent'));
React.lazy는 import를 통한 콜백 함수를 인자로 전달 받아서 사용합니다.
lazy 컴포넌트는 다른 컴포넌트 내부에서 선언하게 될 경우, 리렌더링 할 때 모든 상태가 재설정되는 문제가 있으므로 모듈의 최상위 수준(컴포넌트 외부)에서 선언해야 합니다.
lazy와 Suspense를 함께 사용하게 되면, Lazy Component가 로딩될 때, Suspense의 fallback 속성을 사용해서 로딩 화면을 보여주도록 할 수 있습니다.
Suspense는 React.lazy 없이도 비동기 데이터 로딩 상태를 처리할 때 단독으로 사용 가능하나, React.lazy의 경우, 비동기 함수이기 때문에 컴포넌트의 로딩이 완료될 때까지 대체할 UI를 불러와줘야 한다는 점에서 Suspense를 필수로 같이 사용해야 합니다.
Options
→ useQueries
와 동일하나, 다음의 옵션들은 갖지 못합니다.
❗ useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries를 사용하면 data가 undefined 상태가 되지 않습니다.
Suspense를 좀 더 타입 세이프 하게 사용하기 위해 useSuspenseQuery, useSuspenseInfiniteQuery, useSuspenseQueries를 제공합니다.
React 18의 동시성 렌더링과 Suspense의 통합으로 인해 개발자는 복잡한 비동기 로직을 더 직관적이고 선언적으로 처리할 수 있게 되었으며, 사용자 경험도 크게 개선되었습니다. 이러한 변화는 단순 성능 향상을 넘어, 더 유연한 관리가 가능하게 하여 개발자와 사용자 모두에게 이점을 가져다줄 것입니다. 하지만 무조건적으로 fallback을 띄워주는 것이 사용자 경험 입장에서 오히려 좋지 않을 수 있으니 적절한 곳에 fallback UI를 띄워주는 것 또한 개발자가 생각해봐야 할 과제인 것 같습니다.