웹 어플리케이션을 개발할때, 작업의 복장성을 가장 올리는 부분은 비동기적인 데이터 페칭을 핸들링하는 부분이다.
데이터가 없는 경우, 데이터가 로딩 중일 경우, 작업이 완료되었을 경우 등 여러가지 상황에 따라 다른 화면을 노출시켜야 하는데, 비동기 요청이 하나일 경우는 큰 문제가 안되지만, 한 페이지에서 여러가지 비동기 요청이 일어날 경우 문제는 더욱 복잡해진다.
따라서, View단의 컴포넌트들이 error 및 loading처리에서 벗어나 무엇을 보여줄지에만 집중할수 있게 하고자 하였고, 이에 대한 해결책으로 suspense와 error boundary를 통한 선언적인 데이터 페칭 패턴을 적용하기로 하였다.
Suspense는 React 18 즉, Concurrent react의 주요 기능 중 하나이다. Suspense에 대해 알아보기에 앞서 Concurrent React가 무엇인지 잠깐 살펴보자.
React는 18버전부터 기본적으로 Concurrency를 지원한다.
Concurrency란 둘 이상의 작업이 동시에 진행됨을 의미하는데
이전 버전에서는 하나의 동기 작업이던 렌더링 작업이
이제는 스케줄링을 통해 우선순위 따라 처리되게 된 것이다.
시간이 오래걸리는 작업도중이더라도 다음 작업이 블로킹 되지 않게, 즉 유저 입력같은 우선순위가 높은 작업은 먼저 실행되게 된다.
However, long term, we expect the main way you’ll add concurrency to your app is by using a concurrent-enabled library or framework.
또한 주의할 점은, Suspense, StartTransition hook등 concurrency feature는 현재 react 어플리케이션에서 바로 사용이 가능하지만, React 개발진은 여러 이유들 때문에 장기적인 관점에서 Concurrency 기능을 순수 react 아닌 Next.js 같은 프레임워크에서 사용할 것을 권장하고 있다.
이러한 Concurrenct features의 도입으로
데이터가 준비되는대로 유저에게 자연스럽게 보여줌으로써 사용자 경험 향상을 도모할 수 있게 되었으며, Suspense와 같은 기능들이 도입되어 DX도 향상시킬 수 있는 기반이 되었다.
React 18을 사용하면 Suspense와 Error boundary를 프로젝트에 바로 적용할 수 있다.
그렇다면 Suspense와 Errorboundary를 적용하기에 앞서 이들의 개념과 작동원리에 대해 짚고 넘어가보자.
Suspense는 Concurrent React의 주요 기능 중 하나이다.
'Suspense' lets you display a fallback until its children have finished loading.
....
Suspense works best when it’s deeply integrated into your application’s architecture: your router, your data layer, and your server rendering environment. So even long term, we expect that libraries and frameworks will play a crucial role in the React ecosystem.
Suspense를 사용하면 데이터를 불러오는 동안 Fallback UI를 띄울 수 있다.
현재 이 기능은 데이터가'Only Suspense-enabled data sources'일때만 사용할 수 있는데, 이는 Suspense의 작동원리가 데이터,즉 Primise가 fulfill되지 않는 경우 Throw를 던지고 상위컴포넌트에서 이를 catch하는 방식으로 작동하기 때문이다.
React query, SWR 등에서 suspense: true를 설정하면, 데이터가 준비되지 않았을 때 Error를 직접 Throw하는 식으로 작동하게 된다.
따라서 Next.js같은 프레임워크를 사용하거나, React-query같은 라이브러리를 사용하는 것이 권장된다.
Error boundary는 react 16부터 도입된 기능이다.
Error boundary를 사용하면 렌더링 도중에 일어나는 에러를 감지하고 fallback UI를 표시할 수 있다.
Suspense내 코드에서 promise를 만나면 thorw하는 것과 Error boundary가 error를 만나면 error를 throw하는 것이 매우 흡사하다.
Suspens와 Error boundary를 사용하면 error나 loading을 만날 경우 이를 throw하게 되고 이것이 상위 컴포넌트로 전파되어 그에 대한 작업을 위임할 수 있을 것으로 예상된다.
그렇다면 기존의 React는 비동기 데이터를 어떤 식으로 렌더링 하였을까?
크게 다음과 같은 2가지로 정리할 수 있었다.
useEffect안에서 데이터를 페칭하는 경우.
최초 렌더링으로 빈 화면을 그리고 데이터를 불러온 이후 다시 렌더링을 한다.
비동기 작업들이 동시에 수행되지 않고 순차적으로 수행되어 Waterfall 문제가 발생한다.
데이터 페칭 이후 컴포넌트를 렌더링 하기 때문에, 컴포넌트 트리의 depth가 깊어질 경우 모든 트리의 렌더링이 순차적으로 진행되기 때문에 매우 비효율적인 렌더링이 발생된다.
데이터가 모두 준비된 이후 화면을 렌더링한다
모든 데이터가 준비되기 전까지 렌더링이 일어나지 않는다.
Promise.all등을 통해 비동게 데이터 페칭의 동시성을 보장할수는 있으나, 관심사 분리가 되지 않으며 가장 느린 작업이 완성되기 전까지는 렌더링이 일어나지 않는 단점이 있다.
앞서 나온 데이터페칭-렌더링 패턴에서의 문제점들을 해결할수 있는 것이 바로 Suspense를 활용한 Render-as-you-fetch 방법이다.
해당 방식에서는 Suspense를 사용하여 컴포넌트 별로 데이터가 준비되는대로 렌더링을 시작한다.
이를 통해.
상기의 효과를 얻을 수 있다.
또한 Error boundary도 함께 사용하여, 에러 처리에 대한 부분도 외부에 위임하게 되면 더 이상 컴포넌트에서 loading과 Error에 대한 로직을 따로 작성하지 않아도 되게 된다.
일반적으로 AXIOS interceptor - reject callback을 사용해
에러를 전역적으로 관리한다.
에러 컴포넌트를 활용하면 선언적으로 하위 컴포넌트 트리의 자바스크립트 에러를 포착하고 Fallback UI를 보여줄 수 있다.
// API에서 발생하는 에러와 그 외 클라이언트에서 발생하는 에러 분리
<RootErrorBoundary>
<ApiErrorBoundary>
</ApiErrorBoundary>
</RootErrorBoundary>
// Local Error boundary를 사용하며 레이아웃 내부에 에러 UI를 띄울수 있다.
<RootErrorBoundary>
<ApiErrorBoundary>
<LocalApiErrorBoundary>
</LocalApiErrorBoundary>
</ApiErrorBoundary>
</RootErrorBoundary>
또한 이렇게 에러를 세분화하여
Axios에서는 NetworkError
RootErrorBoundary에선 Front-end에서 발생하는 에러
ApiErrorBoundary에선 api에서 발생하는 에러를 처리하게 함으로써
관심사를 분리시킬 수 있다.
비즈니스 로직에 집중한 에러 처리 가능
UI 일부에서 발생하는 에러를 전역으로 전파시키지 않고 처리할 수 있다.
전역에서 처리되어야 하는 에러를 구분해서
Fallback에서 다시 직접 Throw 해줘야 한다.
페이지를 업데이트하면 필연적으로 Transition이 발생한다.
페이지가 업데이트 될 때, 보통 loading 컴포넌트나 skeleton ui 등을 배치하여 더 나은 사용자 경험을 제공하곤 한다.
하지만 이는 layout shift를 수반하기도 하고, 짧은 시간안에 loading state와 페이지를 왔다갔다 하는 것은 오히려 더 나쁜 사용자 경험으로 이어질 수도 있다.
따라서 사용자에게 시각적 피드백을 주는 것이 중요하지 않은 업데이트, 즉 시급하지 않은 업데이트는 굳이 이러한 작업을 할 필요가 없다.
이 경우에 새로운 훅인 useTransition을 사용할 수 있다.
useTransition을 사용하면 페이지 업데이트 일어날 경우, fallback UI를 거치지 않고 새로운 데이터가 준비되기 전까지 이전의 데이터를 표시하게 된다.
페이지 전환 텀이 매우 짧은 경우에 유용하다.
보통 프로젝트에서는 react-query나 swr같은 데이터 페칭 라이브러리를 사용하곤 한다.
이런 라이브러리를 서버사이드 렌더링 환경에서 사용하기 위해선 추가적인 설정이 필요하다.
기본적으로 위의 라이브러리들은 클라이어언트 데이터 페칭 라이브러리로 서버사이드에서 작동하지 않기 때문에, suspense 옵션을 활성화 하지않으면 서버사이드에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않아 Hydration 과정에서 에러가 발생할 수 있다.
따라서 이를 방지하기 위해선, suspense옵션을 활성화하고 fallbackData를 미리 제공하거나(공통), react-query의 경우 내장된 함수를 통한 dehydration을 통해 이 문제를 해결할 수 있다.
우선 Recoil은 React에서 개발하였기 때문에, 기본적으로 Concurrent mode를 지원한다.
redux, zustand와 같은 라이브러리가 useSyncExternalStore 훅을 통해 concurrent mode를 지원하지만, transition은 지원하지 않는 등 태생적으로 external 즉 외부에 존재하는 스토어에 기반한 라이브러리는 완벽한 싱크를 맞추는 것이 한계가 있다고 보여진다.
따라서 React 내부 상태(Context)에 의존하는 Recoil이나 Jotail이 더 적절한 것으로 판단된다.
Recoil은 최근 meta 사정에 따라 메인 컨트리뷰터들이 laid-off 되고, 유지보수가 안되고 있어 여전히 베타버전에 머물고 있는 한계가 존재한다.
따라서 최근에는 Jotai 쪽에 좀 더 힘이 실리고 있는 상황이라 Jotai도 좋은 옵션으로 보인다.
외부 저장소 상태를 리액트 라이프사이클과 일치시켜주기 위한 훅.
이전까지 useMutableSource 라는 훅이 있었으나, tearing 문제가 생겨 이 훅이 고안되었다고 한다.
최근 react-redux 및 zustand도 내부적으로 이 hook을 사용하고 있다고 한다.
react에서 에러 핸들링에는 Axios-interceptor가 많이 사용된다.
하지만 Next.js 13부터 fetch api 사용을 권고하고 있기 때문에, axios를 사용하지 않기 위해선 fetch api를 감싸는 고차 함수를 직접 정의해서 에러가 발생할 경우 로직을 처리해 주어야 한다.
또는
https://return-fetch.myeongjae.kim/#2-throw-an-error-if-a-response-status-is-more-than-or-equal-to-400
다음과 같은 라이브러리를 사용하는 방법도 있다.