React Suspense 란 무엇일까?

React Suspense 란 무엇일까? 왜 그리고 어떻게 사용해야 할까?

 

Suspense 는 무엇일까?

Suspense 는 React에서 비동기 작업을 관리하기 위한 기능이다. Suspense 는 컴포넌트가 일부 데이터를 기다리고 있음을 나타낼 수 있다.
 
어떻게 이러한 기능을 구현했을까?
SuspensePromisethrow 한다. Promiseresolve 되거나 reject 될 때 까지 컴포넌트의 트리의 생성을 연기한다. 컴포넌트 트리의 생성을 연기하는 동안 해당 컴포넌트는 DOM에 존재하지 않게 된다. 컴포넌트 트리 생성이 완료되지 않은 컴포넌트는 커밋되지 않는다. 따라서 컴포넌트 트리 생성이 완료되고 DOM 트리에 배치되며 브라우저 화면에 업데이트 되기 때문에 라이프사이클 이벤트가 불일치 하는 일이 발생하지 않는다.
 
중요한 사실은 Suspense 가 데이터를 페칭하는 라이브러리도 아니고, 상태를 관리하는 라이브러리도 아니다. 컴포넌트 일부 데이터의 비동기 작업(예를들어 데이터 페칭)을 기다리는 동안 단순한 fallback 컴포넌트를 선언적으로 렌더할 수 있다.
 

Suspense 는 왜 사용할까?

Suspense 출시 문서에 따르면 Suspense를 다음과 같이 설명하고 있다.
Suspense lets you declaratively specify the loading state for a part of the component tree if it's not yet ready to be displayed
해석을 해보면 Suspense를 사용하면 아직 표시할 준비가 되지 않은 경우 컴포넌트의 일부 데이터에 대한 로드 상태를 선언적으로 지정할 수 있다.
여기서 왜 선언적이라는 키워드가 들어가 있을까? Suspense에 대한 설명을 하기전에 왜 선언적으로 해야하며 Suspense가 생기게 된 이유를 알아보려고 한다.
 

선언형 프로그래밍

React는 16.8 버전을 기점으로 클래스형 컴포넌트에서 함수형 컴포넌트를 사용하고 라이프사이클 대신에 여러가지 훅을 사용하는 개발방식으로 큰 변화가 있었다.
useState 는 상태의 사용을 선언한다. useMemo 는 메모제이션의 사용을 선언한다. useCallback 은 콜백 레퍼런스의 사용을 선언한다. useEffect 는 부수적인 효과의 발생을 선언한다. 우리는 함수형 컴포넌트로 React를 개발할때 모두 선언형으로 개발을 하고있었고, 이렇게 효과의 사용을 선언하기만 하면 나머지는 React 내부에서 동작을 보장해준다.
하지만 정작 UI를 렌더하는 부분에서 로딩상태를 관리할 때는 어떨까?
const Component = () => { return ( {loding && <Loading />} {data && !loading && <AnotherComponent /> ) }
와 같이 어떤식으로 렌더해야 할지 명령형으로 작성하고 있다. 개인적으로는 명령형으로 작성하는것이 더 많은 자유도가 있다고 생각하지만 여러개의 상태를 동시에 다뤄야 할 경우에는 어떨까?
일반적으로 화면을 렌더하는 조건을 data , loading , error 이 세가지가 있다고 생각해보자. 한개의 데이터를 처리하는데 3개의 경우의 수가 있다. 하지만 두개의 데이터를 처리한다고 가정하면 이것의 제곱인 9개의 경우의 수가 생긴다. 이렇게 중첩되는 데이터의 모든 경우의 수를 따져 명령형으로 컴포넌트를 잘 렌더할 수 있을지 걱정이 되기 시작한다.
 

로딩, 에러 처리의 분리

두번째로 로딩화면과 에러화면의 처리 분리의 장점이 있다. 이러한 예를 들기 전에 가장 기본적인 예시를 생각해보면 callbackasync await 이 있다.
 
function foo () { anyPromise((res) => { if (res.status === 200 && !res.err) { anotherPromise(res.data); } if (res.err) { // 에러 처리 } }) }
async function foo () { const res = await anyPromise(); const res2 = await anotherPromise(res.data); }
이 두가지 코드중 어떤 코드가 더 나은 코드라고 할 수 있을까 고민해보면 당연 후자를 선택한다. 왜 그렇게 생각할까?
첫번째의 예시는 ‘성공하는 경우’와 ‘실패하는 경우’ 가 공존하고 있다. 매번 성공과 실패를 구분하여 코드를 작성해야 한다.
반면에 두번째 예시는 ‘성공하는 경우’ 만 작성한다. 그리고 ‘실패하는 경우’ 는 try catch 를 이용하여 따로 분리하여 작성할 수 있다. 이러한 분리는 코드를 작성하는 입장에서 더 명확하고 편하게 느껴진다. 우리는 항상 에러를 분리하지 않고 한 곳으로 위임하여 처리하곤 한다. 필요하다면 함수 내부에서 try catch 를 사용하여 직접 에러처리를 할 수도 있다.
 
이러한 논리는 컴포넌트를 렌더하는 상황에서도 똑같이 적용할 수 있다. 위에서 적었던 예시를 다시한번 보자
const Component = () => { return ( {loding && <Loading />} {data && !loading && <AnotherComponent /> ) }
이제는 왜인지 모르게 불편하게 느껴진다. ‘성공하는 경우’와 ‘실패하는 경우’ 가 공존하고 있다. 불편한 콜백은 지양하고 있으면서 그동안 불편한 렌더를 하고 있었던것은 아닐까?
 

Suspense 를 이용한 Render-as-You-Fetch

간단한 예시를 보자. User 컴포넌트는 3초의 딜레이를 가지고 있다. Post 컴포넌트는 5초의 딜레이를 가지고 있다. 이러한 페이지에 진입했을때 일반적으로 유저들은 User - Post 순서로 렌더되는 것을 예상한다. 하지만 위 예시는 반대로 Post - User 순서로 렌더 된다.
 
Suspense 없이도 waterfall 패턴을 이용하여 문제를 해결할 수 있다.
컴포넌트의 위치를 UserPost 를 포함하도록 변경하는것 만으로도 해결이 되었다. 혹은 Promise.all 같은 방법으로 두개의 데이터페칭을 묶을수 있다. 하지만 시간은 3초 + 5초 로 총 8초가 걸렸다. 이러한 시간은 또 다른 문제이다. 이런 경우에 깔끔하게 해결할 수 있는 방법이 바로 Suspense 이다. (사실 조금 귀찮은 작업을 거치면 Suspense 를 이용하지 않고 구현할 수 있다.)
 
이 예시를 보면 Suspense 를 이용해서 시간의 이슈도 없고 동시에 깔끔하게 렌더된다.
<Suspense fallback={<p>Loading application...</p>}> <User id="1" delay={5000} /> <Post id="1" delay={3000} /> </Suspense>
useSWR 를 이용하여 suspense 옵션을 활성화 하고, Suspense 내부에 컴포넌트를 배치하는것으로 간단하게 구현된다.
 
그렇다면 또 다른 컴포넌트 구성은 어떨지 보자.
<Suspense fallback={<p>Loading application...</p>}> <UserWrapper> <User id="2" delay={5000}/> </UserWrapper> <div> <PostContainer /> </div> </Suspense>
위 예시에서 보이듯이 Suspense 자식 트리에 컴포넌트중 하나라도 susepnd 상태가 되면 Suspense 는 fallback 을 렌더한다.
 

그렇다면 Suspense 를 이용하여 얻는 이점은 무엇일까?

  1. 가장 첫번째로 비동기적인 코드를 동기적인 방식(async await 과 유사)으로 작성할 수 있다는 것이다. 리액트는 이를 위해서 마이크로태스크 를 이용한다.(사실 마이크로태스크에 대해서 잘 모른다. 다음에 포스트를 써봐야겠다.)
  1. 중첩된 여러개의 컴포넌트 중 특정 컴포넌트에 대한 로딩상태 관리에 대해 걱정하지 않고 쉽게 코드를 작성할 수 있게 된다.
  1. 중첩된 여러개의 컴포넌트에서 데이터 표시의 순서에 상관없이 병렬적으로 데이터를 페칭할 수 있다.
 

Suspense Boundary

지금까지 공부한 내용을 바탕으로 Suspense 를 사용할때 생각이 드는점이 있다.
Suspense 내부에 시간이 오래걸리는 페칭이 중요한 컴포넌트를 차단할 가능성이 있으면 어떡하지?
이 의문은 의외로 간단하게 해소된다. 이 때 나오는 용어가 Suspense Boundary 이다. 어떤 컴포넌트에서 suspending 되었을때 컴포넌트의 부모 트리에서 가장 가까운 Suspense 컴포넌트를 Suspense Boundary 라고 한다. 이 개념을 이해하기전에 try catch 를 먼저 생각해보자.
const a = () => {throw new Error('Oops!')}; const b = () => a(); const c = () => b(); try { try { c(); console.log('Child worked.'); } catch (e) { console.log("Error inside child", e?.message); } console.log('Parent worked.'); } catch (e) { console.log("Error inside parent", e?.message); } // Error inside child Oops! // Parent worked.
try catch 문이 두개로 중첩되어있다. 이때 에러는 상위 catch 까지 전달되지 않는다. “Error inside parent” 는 표시되지 않는다는 것이다. Suspense 도 이와 마찬가지이다 Suspense Boundarytry catch 의 개념과 유사하다.
 
<Suspense fallback={<p>Loading application...</p>}> <User id="1" delay={5000} /> <Post id="1" delay={3000} /> <Suspense fallback={<p>Loading photo...</p>}> <Photo id="1" delay={10000} /> </Suspense> </Suspense>
이 예시와 같이 원한다면 Suspense Boundary 를 이용하여 로딩 구간을 나눌수 있다.
 

마무리

이번에 Suspense 에 대해 공부를 해보았는데 꽤 유용한 기능이고 많은 부분에서 사용자 경험을 개선할 수 있을것 같다. 당장 리액트 18버전을 사용하는 이 블로그에 적용해봐야겠다.
 

레퍼런스