React Suspense에 대해 공부하던 중 Dan Abramov의 좋은 글이 있어 그 글을 정리해서 이번 프론트파트 WIL(Weekly I Learned) 주제로 발표하고 블로그에도 정리해보고자 한다.
Suspense는 리액트 공식문서에도 동작원리나 정확한 사용방식에 대해 정확하게 나와있지 않아 제대로 공부해보기 쉽지 않았는데 Dan Abramov이 정리한 글이 동작원리부터 브라우저 상호작용 방식까지 잘 설명해주고 있어 이 내용을 정리하고 팀원들에게도 공유하고 싶었다.
이후 실제 Suspense관련하여 내가 리팩토링하고 있던 Next.js 페이지에서 발생한 문제를 해결한 방법을 설명한다.
리액트 18에는 리액트의 서버사이드 렌더링을 위한 성능적인 구조개선이 많습니다.(renderToPipeableStream, Suspense 등등…)
그 중 우리는 Suspense에 대해 알아봅시다.
자, SSR을 사용하면 서버의 React 컴포넌트에서 HTML을 생성하고 해당 HTML을 사용자에게 보여줍니다.
💡 SSR을 사용하면 JS번들이 로드되고 실행되기 전 사용자가 페이지의 컨텐츠를 볼 수 있습니다.
이런,,,이런 방식이라면 앱의 일부 부분이 다른 부분에 비해 느리다면 효율적이지 않겠습니다.
이러한 부분을 해결하기 위해서 React18의 Suspense 를 사용하여 앱의 이런 단계들의 부분을 독립적으로 진행할 수 있습니다. 그렇게되면 사용자는 사용가능한 컨텐츠를 더 빠르게 확인하고 소비할 수 있겠군요!
<Layout> <NavBar /> <Sidebar /> <RightPanel> <Post /> <Suspense fallback={<Spinner />}> <Comments /> </Suspense> </RightPanel> </Layout>
이와 같은 구조의 트리가 있다고 가정할 때, Comments
는 Suspense
에 감싸져 있고 이를 통해 React는 Comments
를 기다리지 않고 다른 HTML을 먼저 스트리밍해도 되겠다고 판단합니다.
<main> <nav> <!--NavBar --> <a href="/">Home</a> </nav> <aside> <!-- Sidebar --> <a href="/profile">Profile</a> </aside> <article> <!-- Post --> <p>Hello world</p> </article> // Suspended@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ <section id="comments-spinner"> <!-- Spinner --> <img width=400 src="spinner.gif" alt="Loading..." /> </section> </main>
따라서, 클라이언트는 이처럼 Spinner
로 대체된 HTML을 받게 됩니다.
<div hidden id="comments"> <!-- Comments --> <p>First comment</p> <p>Second comment</p> </div> <script> // 실제 구현은 이거보다 좀 더 복잡함 document.getElementById('sections-spinner').replaceChildren( document.getElementById('comments') ); </script>
이러다 만약 데이터가 준비되면, React는 같은 스트림으로 추가적인 HTML을 보냅니다. 이때 인라인으로 JS코드가 들어가는데, 해당 코드에는 Suspend된 컴포넌트가 들어갈 자리에 새로운 컴포넌트를 갈아 끼우는 코드가 들어가게 됩니다.
💡 스트림이란?
실제의 입력이나 출력이 표현된 데이터의 이상화된 흐름
즉, 운영체제에 의해 생성되는 가상의 연결 고리!
React ←(스트림)→ 클라이언트 과 같은 느낌일지?
이후 모든 HTML이 렌더링이 된 모습은 다음과 같을 것 입니다.
하지만 아직 유저 인터랙션을 위한 Hydration작업이 끝나지 않았습니다. 유저는 아직 화면만 볼 수 있는 상황이겠죠?
이벤트 핸들러와 같은 유저 인터랙션을 수행할 수 없는 상태에서 기존 SSR과 달리, Suspense를 사용하면 Hydration 역시 컴포넌트 단위로 작업이 가능합니다.
다음과 같은 구조를 봅시다.
<Layout> <NavBar /> <Suspense fallback={<Spinner />}> <Sidebar /> </Suspense> <RightPanel> <Post /> <Suspense fallback={<Spinner />}> <Comments /> </Suspense> </RightPanel> </Layout>
Suspense로 감싸진 컴포넌트가 두 개가 있기 때문에, 이 두 컴포넌트를 제외한 나머지 컴포넌트는 HTML 렌더링과 Hydration이 먼저 일어나게 됩니다. 이처럼 말이죠.
초록색 부분이 이미 Hydration이 마무리되어 인터랙션이 가능한 상태입니다.
React는 Suspense에 감싸진 두 컴포넌트 중 트리에서 더 빨리 찾아지는 Suspense 바운더리를 먼저 Hydration을 시도합니다.
가령, 위처럼 사이드바가 트리에서 먼저 찾아졌다고 가정해봅시다. 그럼 사이드바의 Hydration이 진행되고 있을겁니다.
Comments
컴포넌트와 인터랙션을 시도하면 어떻게 될까요?OMG;; 놀랍게도, React는 클릭 이벤트가 발생한 컴포넌트를 먼저 ‘동기적’으로 Hydrating합니다.
이처럼 React의 Suspense는 Hydration도 컴포넌트 단위로 가능하게 합니다.
자, 그럼 이렇게 좋은 React Suspense는 어떻게 이 비동기를 처리하고 있을까요?
자식 컴포넌트의 Promise 상태를 감지하고 fallback UI를 보여준다? 저는 React 공식문서를 보고 완전히 혼자 다 알아서 처리해주는 줄 알았습니다. React가 정말 이렇게 똑똑하고 마술처럼 이 모든 것을 처리해주고 있을까요?
React Suspense는 Error Boundary와 유사하게 작동합니다. 사실 둘 모두 try…catch 문이 본질이라고 할 수 있습니다.
이 둘의 다른 점이라면, Error Boundary는 throw된 Error를 캐치하고, Suspense는 throw된 Promise를 캐치한다는 점입니다.
사용자의 예외 처리를 위한 throw를 통해 pending상태의 Promise가 던져지면 해당 값은 콜 스택의 첫 번째 catch 블록으로 전달되고 바깥 컴포넌트 Suspense가 이를 캐치할 수 있게됩니다.
Suspense는 던져진 Promise를 캐치하는 역할만 할 뿐, Promise를 던지는 건 자식 컴포넌트가 해야한다는 점입니다.
요 놈은 Suspense에 대해 검색하면 쉽게 볼 수 있는 예제 코드입니다.
function suspensify(promise) { // 프라미스의 상태 추적 let status = "pending"; let result; let suspender = promise.then( (res) => { // 프라미스 성공 시 상태: success, result: 해당 데이터로 업데이트 status = "success"; result = res; }, (error) => { // 프라미스 에러 시 상태: error, result: 해당 에러 객체로 업데이트 status = "error"; result = error; } ); // 위에서 구현한 suspender를 throw할 수 있는 read() 메서드를 담은 객체를 리턴 return { read() { if (status === "pending") { // !! pending 상태일 때 체이닝 된 프라미스를 Suspense에 throw throw suspender; } else if (status === "error") { // Suspense에 의해 처리된 결과 에러라면, 해당 결과를 다시 던져 Error Boundary에 전한다. throw result; } else if (status === "success") { // Suspense에 의해 성공적으로 처리되었다면, 해당 결과를 리턴한다. return result; } }, }; }
이 코드를 사용하는 컴포넌트 구현부는 다음과 같습니다.
function AsyncChildComponent() { const [promise, setPromise] = useState() const onClick = () => { setPromise(suspensify(fetchData(...))) } const data = promise ? promise.read() : null return <div>{data}</div> }
호출된 read
메서드는 초기 상태(pending
)에 따라 suspender
를 던지게 된다. 그러면 Suspense가 이를 처리하는 동안 fallback UI를 보여주다 처리가 완료되면 상태와 result를 업데이트시키고, 변경된 값은 컴포넌트에 반영된다.
promise
객체의 read메서드가 호출되고 객체의 내부 상태가 여전히 (status === pending)인 경우에 suspender(새로 만든 Promise)가 던져집니다. 이 행위가 React의 정상적인 렌더링 흐름을 중단시킵니다.read()
가 호출되면 이미 resolve되면서 내부 상태는 success가 되었을 것이고, 더 이상 만들어둔 suspender
(Promise)를 던지지 않고 결과를 리턴합니다. (혹은 error를 throw하고 ErrorBoundary가 이를 처리하겠죠?)React와 JS의 Promise 동작 방식과 알고리즘을 활용하여 처리됩니다. 사실 생각해보면 React의 렌더링 프로세스에서 자연스럽게 상태를 통해 관리하고 있는 것 입니다.
그래서 Suspense 비동기 처리가 useEffect에서 상태로 관리하는 것보다 나은 점이 무엇일까요?
useEffect
는 UI waterfall이 발생합니다. 상위 컴포넌트의 데이터 로딩이 끝나면, 하위 컴포넌트의 로딩이 시작되기 때문에 UI가 순차적으로 나타납니다.
useEffect
는 초기 렌더링 이후에 발생하는 사이드 이펙트를 처리하는 훅으로, 데이터 로딩이 끝나면 리렌더링을 수행하기 때문에, Race Condition에 취약합니다.
💡 Race Condition이란?
공유 자원에 대해 여러 프로세스가 동시에 접근을 시도할 때, 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태
useEffect
는 다소 명령적입니다. 선언적인 코드를 지향하는 React와는 선언적인 처리가 가능한 Suspense가 더 어울린다고 볼 수 있습니다. + 컴포넌트 내에서 데이터 로딩과 렌더링을 동시에 처리한다는 점이 React 스럽지 못하다고 볼 수 있겠군요.
<Suspense fallback={<Loading />}> <Albums /> </Suspense>
모두가 알고 있듯, 가장 대표적인 예시로 Albums
컴포넌트 내부에서 비동기적으로 데이터를 가져오고 있을 때, 가장 가까운 Suspense 바운더리에서 캐치하고 fallback을 표시해줍니다.
Suspense의 children은 suspense-enabled data여야 한다는 점입니다. 컴포넌트가 useEffect
를 사용하거나 이벤트 핸들러를 통해 데이터를 가져온다면 는 이를 감지하지 못합니다.
<Suspense fallback={<Loading />}> <Biography /> <Panel> <Albums /> </Panel> </Suspense>
이처럼 Suspense안에 있는 모든 트리는 단일 트리로 취급됩니다. Biography
, Albums
컴포넌트의 데이터 페칭이 &&
로 마무리되어야 상위 컨텐츠 내부가 보이게 됩니다.
<Suspense fallback={<BigSpinner />}> <Biography /> <Suspense fallback={<AlbumsGlimmer />}> <Panel> <Albums /> </Panel> </Suspense> </Suspense>
Suspense 컴포넌트는 이처럼 겹쳐서 사용할 수도 있습니다. 위 컴포넌트에서 Biography
컴포넌트는 Albums
컴포넌트를 기다리지 않아도 됩니다.
이렇게되면 둘이 동시에 보이거나 Biography
가 보인 후 Albums
이 로딩중이다가 보이겠군요.
현재 저희는 withAuthGuard
라는 HOC 를 통해 유저 인증 확인을 하고 있습니다.
withAuthGuard
는 유저 인증이 완료된 사용자만 보여줘야하는 페이지를 감싸 해당 페이지의 코드를 선언적으로 관리하기 위해 사용하고 있습니다.
위와 같은 문제가 발생한 이유는 withAuthGuard
내부에서 유저의 인증정보를 확인하는 동안 null
을 리턴하고 있기 때문입니다.
Content
의 Suspense
가 시작되어 fallback
이 보여지기 직전 해당 페이지의 접근권한이 있는지를 체크하는 동안은 해당 페이지의 Content
가 null
이기 때문에 헤더와 푸터는 이미 표시되었고 이후 권한 체크가 끝나고 해당 Content
에 필요한 데이터들을 불러오기위한 Suspense
가 실행되어 fallback
인 스켈레톤UI가 뒤늦게 표시되어 발생한 문제였습니다.
null
이 아닌 Suspense
를 일으키도록!유저 권한 체크를 하는동안 null
을 리턴하는 기존 로직과의 차이점으로 선언부의 옵션의 따라 null
이 아닌 Suspense
를 일으키도록 하는 것이 목표였다.
HOC
의 이름은 선언적으로 페이지의 Suspense
를 적용한다고 해서 withPageSuspense
라고 네이밍 지었다.
withPageSuspense
의 코드 처리 흐름은 다음과 같다.
withPageSuspense
// 역할 위임을 통해 기존 메인컨텐츠를 감싸고 있던 SSRSafeSuspense를 사용하지 않아도됨. // 페이지는 이제 온전하게 페이지 ui에 집중 const SpacePage: NextPage = () => { return <MainSpaces />; }; // withAuthGuard의 suspense옵션을 true로 줬다면, // withPageSuspense를 활용하여 페이지 상위의 SSRSafeSuspense를 만들고, // 유저 인증 체크를 담당하는 withAuthGuard의 역할이 끝날 때까지 표시할 fallback ui 전달. export default withPageSuspense( withAuthGuard(SpacePage, { suspense: true, }), { fallback: ( <div> <div className={S.banners_wrapper}> <BannerSliderSkeleton /> </div> <SpaceListSkeleton /> </div> ), }, );
(gif 화질 지못미..😂)
해당 스터디를 통해 Suspense는 마법과 같지 않다는 것을 깨닳았다. 특히 막연하게 그럴 것이다 생각했던 Suspense가 주기적으로 polling하며 리렌더링을 시도하고 있지 않다는 점을 알게되어 마음이 좀 편해졌고 그에 따른 성능 문제도 당연히 존재하지 않을 것이다라는 것을 배우게되어 의미있었던 스터디였다.
아직 모든 부분을 알진 못하지만 앞으로 배울 것들을 좀 더 효과적으로 습득할 수 있을 것 같다는 생각과 함께 기존보다 선언적으로 처리된 모습이 좀 더 리액트스러워진 것 같다고 생각이 들어 조금은 기분이 좋았다. 😂 (그래도 좀 지나면 미래의 나에게 미안한 코드가 되어있겠지…)