이번 글에서는 회사에서 React Suspense와 ErrorBoundary를 도입 및 적용해 보면서 배운 학습 내용과 어려웠던 경험을 공유하고자 합니다.
제목은 “Suspense를 사용해야 하는 이유?”라고 적었지만 제목을 조금 더 풀어서 설명하고 하려고 합니다. 그 이유에 대해 살펴보기 위해서는 먼저 Concurrent UI Pattern에 대해 간략히 소개해드리겠습니다.
Concurrent UI Pattern이란
이 글을 쓰는 시점에 일반적인 서비스의 프론트엔드 개발은 성능에서의 중대한 이슈가 자주 발생하지는 않습니다. 이것이 가능한 이유는 리액트를 비롯한 다양한 UI 프레임워크가 내부적으로 성능을 효율적으로 관리해주기 때문에 성능 최적화보다는 사용자 경험(UX)을 향상시키기 위해 에러 처리, 로딩 등의 다양한 고민들을 하고 있습니다.
React에서는 이러한 고민을 해소해주기 위해 React 18에서 Concurrent Mode
가 릴리즈 되었습니다.
Concurrent Mode는 업데이트(렌더링)의 중요도를 기반으로 우선 순위를 지정할 수 있게 해주고 컴포넌트의 지연 렌더링, 로딩 화면의 유연한 구성 등을 쉽게 구성할 수 있도록 도와줍니다. (Automatic Batching, Transition, Suspense 등)
이러한 Concurrent Mode를 이용해서 사용자의 기기 및 네트워크 환경에 맞게 유연하게 화면을 보여주어 사용자 경험을 향상 시켜 줄 수 있으며 이러한 기능들을 사용한 UI 개발 패턴을 React 팀에서는 Concurrent UI Pattern이라고 합니다.
지금부터는 Concurrent UI Pattern을 Concurrent UI로 칭하여 부르겠습니다.
Concurrent UI에 대해서 알아보았으니 다시 제목으로 돌아가보면 Suspense를 사용하는건 Concurrent UI를 구현하기 위해 Suspense라는 하나의 도구를 이용했다 라는 뜻으로도 이해할 수 있을 것 같습니다.
Suspense에 대해 알아보기 전에 추가로 한 가지 개념을 살펴보면 좋을 것 같습니다.
프로그래밍 패러다임과 Suspense의 선언적 이점
여기서는 다양한 프로그래밍 패러다임 중 명령형과 선언형 프로그래밍에 대해 설명합니다. 예시 코드를 보면서 이해하는 것이 좋을 것 같습니다.
1import React from "react"; 2import useGetUserInfo from "./useGetUserInfo"; 3 4const LoadingIndicator = () => <div>로딩중...</div>; 5const ErrorNotification = ({ error }) => <div>오류 발생: {error.message}</div>; 6const UserInfoDisplay = ({ userInfo }) => <div>이름: {userInfo}</div>; 7 8const UserInfoFetcher = () => { 9 const { data: userInfo, isLoading, isError } = useGetUserInfo(); 10 11 if (isLoading) { 12 return <LoadingIndicator />; 13 } 14 15 if (isError) { 16 return <ErrorNotification error={isError} />; 17 } 18 19 return <div>{userInfo}</div>; 20}; 21 22export default UserInfoFetcher;
위의 코드는 useQuery의 커스텀 훅인 useGetUserInfo를 이용해 사용자 정보를 화면에 보여주는 기능을 하는 컴포넌트로 로딩 중일때(isLoading) <LoadingIndicator /> 컴포넌트를 보여주고 에러가 발생하면(isError) <ErrorNotification /> 컴포넌트를 보여주며 성공적으로 데이터를 받아왔다면 <UserInfoDisplay /> 컴포넌트를 보여줍니다.
위의 코드에서는 isLoading, isError 등의 상태(state)에 따라 화면을 어떻게 보여주어야 할 지를 고민하고 이러한 고민을 분기 처리를 통해 화면에 보여주는 코드를 작성하였습니다.
우리는 이러한 코드 흐름을 명령형 프로그래밍이라고 합니다. 하지만 명령형 프로그래밍은 우리가 사용하는 리액트의 철학과는 맞지 않는 코드 흐름입니다.
리액트의 공식 문서에서는 아래와 같이 “선언형” 라이브러리로 소개하고 있으며 “각 상태에 대한 간단한 뷰만 설계” 라는 의미가 선언형을 정의한다고 볼 수 있습니다.
이러한 명령형과 선언형 프로그래밍에 대해 간단히 정리해보면 아래와 같습니다.
- 명령형 프로그래밍(Imperative Programming): 어떻게(How) 해야 하는지 초점을 맞춤
- 선언형 프로그래밍(Declarative Programming): 무엇을(What) 해야 하는지 초점을 맞춤
정리한 개념을 토대로 다시 위의 예시 코드에 적용해보면 “로딩중..”이라는 텍스트와 “이름 {data}” 데이터 및 텍스트는 data와 isLoading의 상태(state)에 따라서 화면에 보여지거나 보여지지 않습니다. → 즉 상태에 따라 UI를 어떻게 보여줄 것인지 고민하고, 그 결과를 분기 처리를 통해 구현한 것입니다.
그렇다면 리액트의 철학인 선언형으로 예시 코드를 바꿔보기 위해 드디어 Suspense를 사용해보면 좋을 것 같습니다.
1import React from "react"; 2import useGetUserInfo from "./useGetUserInfo"; 3 4const UserInfoFetcher = () => { 5 const { data: userInfo } = useGetUserInfo(); 6 7 return <div>{userInfo}</div>; 8}; 9 10export default UserInfoFetcher;
명령형 예시 코드와 달리 isLoading, isError의 상태를 사용하지 않고 데이터를 받아 화면에 보여주는 것만 집중하도록 코드를 수정하였습니다.
그렇다면 로딩 처리는 어디에서 하는걸까요? 아래의 UserInfoFetcher의 부모 컴포넌트에서 처리하도록 수정하였습니다. (에러 처리는 아래에서 추가로 설명할 예정입니다)
1import { Suspense } from "react"; 2import UserInfoFetcher from "./UserInfoFetcher.js"; 3 4const LoadingIndicator = () => <div>로딩중...</div>; 5 6const UserPage = () => { 7 return ( 8 <Suspense fallback={<LoadingIndicator />}> 9 <UserInfoFetcher /> 10 </Suspense> 11 ); 12}; 13 14export default UserPage;
우리는 이전의 코드와 달리 UserInfoFetcher 컴포넌트에서 로딩을 처리하지 않고 부모 컴포넌트인 UserPage에서 로딩을 처리하도록 코드를 수정하였습니다.
이렇게 코드를 수정할 수 있는 이유는 Suspense를 사용했기 때문입니다. 그러면 코드를 통해 Suspense가 무엇을 해주는지 유추해볼 수 있을것 같습니다. UserInfoFetcher 컴포넌트가 로딩 중이라면? fallback props에 들어가 있는 컴포넌트를 대신 보여주고 로딩이 끝나면 UserInfoFetcher 컴포넌트를 보여주는게 아닐까요?
공식문서를 통해 확인해보면 Suspense를 아래와 같이 설명합니다.
lets you display a fallback until its children have finished loading.
즉 “Suspense는 자식(children) 컴포넌트의 로딩이 완료될 때까지 fallback props에 지정된 값(컴포넌트)을 표시할 수 있다”라고 해석할 수 있습니다.
코드를 통해 유추한 기능과 동일합니다. 이렇게 우리는 비동기 요청을 명령형으로 작성했던 코드를 Suspense를 이용해서 리액트답게 선언적으로 바꾸었습니다.
→ 우리는 상태에 따라 어떤 컴포넌트를(WHAT) 보여줄 지 집중할 수 있게 되었습니다.
추가로 위에서 설명을 하지 않았던 에러 처리도 Error Boundary를 이용해서 적용해보겠습니다.
이 글에서는 Error Boundary의 클래스형 컴포넌트 구조 및 생명주기 설명은 생략하며 react-error-boundary 라이브러리를 사용합니다.
1import { Suspense } from "react"; 2import UserInfoFetcher from "./UserInfoFetcher.js"; 3import { ErrorBoundary } from "react-error-boundary"; 4 5const LoadingIndicator = () => <div>로딩중...</div>; 6 7const UserPage = () => { 8 return ( 9 <ErrorBoundary fallback={<ErrorNotification />}> 10 <Suspense fallback={<LoadingIndicator />}> 11 <UserInfoFetcher /> 12 </Suspense> 13 </ErrorBoundary> 14 ); 15}; 16 17export default UserPage;
ErrorBoundary를 적용한 코드를 보면 에러가 발생하면 <ErrorNotification /> 컴포넌트가 보여지게 됩니다. 이렇게 Suspense + ErrorBoundary까지 적용하면 에러 처리와 로딩 처리를 UserPage 컴포넌트에 위임하게 할 수 있게 되었고 <UserInfoFetcher /> 컴포넌트에서는 데이터 요청에 대해 성공한 경우만 집중할 수 있게 되었습니다.
이러한 Suspense를 이용해서 선언적으로 코드를 작성하면서 얻는 이점들에 대해 정리하면 좋을 것 같습니다.
- 핵심은 로딩 처리와 에러 처리를 외부에 위임, 즉 Suspense를 사용하는 곳에서 처리를 해준다는 점입니다.
- 이로인해 함수가 하는 역할이 명확히 드러나면서, 성공하는 경우에만 집중해 복잡도를 낮춰줍니다.(비즈니스 로직을 한눈에 파악할 수 있음)
Suspense의 선언적 이점은 화면에 많은 비동기 데이터를 보여줄 때 더더욱 이점을 얻을 수 있습니다. 명령형 코드였다면 하나의 비동기 작업에 대해 로딩 중, 에러, 완료됨 3가지의 상태를 가지고 있으며 개수가 늘어날수록 관리해야 할 상태가 기하급수적으로 증가합니다.
이러한 비동기 처리가 많은 경우를 간단한 예시와 함께 Suspense로 처리해보면 좋을 것 같습니다.
아래 이미지처럼 하나의 페이지에 A,B,C,D,E 총 5개의 서로 다른 API를 요청해서 받아온 데이터를 화면에 보여주어야 하는 상황입니다.
이러한 상황에서 몇가지 요구사항이 존재합니다.
- A,B,C 데이터가 모두 다 불러와져야 A,B,C 데이터를 화면에 보여준다.
- A,B,C 데이터 중 하나라도 실패하면 하나의 에러 화면을 보여준다.
- D영역과 E영역은 다른 영역과 상관없이 데이터를 불러오면 화면에 바로 보여준다.
요구사항을 Suspense를 이용해서 처리하는 코드를 살펴보면 아래와 같습니다.
1// 1번 요구사항 -> A,B,C 데이터가 모두 다 불러와져야 A,B,C 데이터를 화면에 보여준다 2 3import React from 'react'; 4import useGetA from './useGetA'; 5import useGetB from './useGetB'; 6import useGetC from './useGetC'; 7 8const Header = () => { 9 const { data: aData } = useGetA(); 10 const { data: bData } = useGetB(); 11 const { data: cData } = useGetC(); 12 13 return ( 14 <div> 15 <div>{aData.data}></div> 16 <div>{aData.data}></div> 17 <div>{aData.data}></div> 18 </div> 19}; 20 21export default Header;
<Header /> 컴포넌트에는 react-query의 커스텀훅을 이용해 각각 A,B,C 비동기 요청을 처리해서 데이터를 화면에 보여주는 컴포넌트입니다.(간단한 예시이므로 A,B,C를 따로 컴포넌트로 분리하지 않았습니다) 그리고 아래 코드의 부모 컴포넌트인 <MainPage /> 컴포넌트에는 Suspense로 감싸고 에러와 로딩 처리를 다른 컴포넌트를 이용하여 처리하였습니다.
1import { Suspense } from "react"; 2import Header from "./Header.js"; 3import { ErrorBoundary } from "react-error-boundary"; 4 5const LoadingIndicator = () => <div>로딩중...</div>; 6const ErrorNotification = () => <div>에러 발생...</div>; 7 8const MainPage = () => { 9 return ( 10 <ErrorBoundary fallback={<ErrorNotification />}> 11 <Suspense fallback={<LoadingIndicator />}> 12 <Header /> 13 </Suspense> 14 </ErrorBoundary> 15 ); 16}; 17 18export default MainPage;
<Header /> 컴포넌트 내에 존재하는 요청중 하나라도 로딩 상태라면 Suspense는 fallback를 화면에 노출시킵니다. 이러한 기능을 통해 우리는 1번 요구사항을 충족시켰습니다.
2번 요구사항 또한 <MainPage /> 컴포넌트에서 충족시켰습니다. <Header /> 컴포넌트 내에 하나의 비동기 요청이라도 실패한다면 에러는 위로 거슬로 올라가 가장 가까운 ErrorBoundary에서 처리가 되는데 가장 가까운 ErrorBoundary는 <Header />의 Suspense 바로 위의 ErrorBoundary이기 때문입니다.
마지막으로 3번 요구사항을 충족시키기 위해 <MainPage />컴포넌트에 코드를 추가해보면 될 것 같습니다.
1import { Suspense } from "react"; 2import Header from "./Header.js"; 3import Article from "./Article.js"; 4import Footer from "./Footer.js"; 5import { ErrorBoundary } from "react-error-boundary"; 6 7const LoadingIndicator = () => <div>로딩중...</div>; 8const ErrorNotification = () => <div>에러 발생...</div>; 9 10const MainPage = () => { 11 return ( 12 <ErrorBoundary fallback={<ErrorNotification />}> 13 <Suspense fallback={<LoadingIndicator />}> 14 <Header /> 15 </Suspense> 16 <Suspense fallback={<LoadingIndicator />}> 17 <Article /> 18 </Suspense> 19 <Suspense fallback={<LoadingIndicator />}> 20 <Footer /> 21 </Suspense> 22 </ErrorBoundary> 23 ); 24}; 25 26export default MainPage;
Suspense를 각각 따로 감싸주어 데이터가 로딩이 완료되면 독립적으로 화면에 보여줄 수 있도록 처리하여 3번의 요구사항까지 충족하였습니다.
우리는 1,2,3번의 요구사항을 포함하기 위해 Suspense 사용하여 훨씬 편하고 선언적으로 해결할 수 있었으며 Concurrent Mode의 로딩 및 에러 화면의 유연한 구성을 통해 Concurrent UI Pattern을 구현하여 사용자 경험을 향상시킬 수 있었습니다.
적용하면서 어려웠던 점
이번에는 Suspense를 사내에 도입하고 실제로 사용하면서 직면했던 어려움을 공유하고자 합니다.
1. Suspense는 SSR에 대한 지원이 미흡하다.
사내에서 사용하는 기술 스택은 Next.js(v13) Page router, React-Query(v5)의 useSuspenseQuery + suspense를 사용하고 있었으며 SSG 또는 SSR의 방식 즉 서버에서 HTML을 만들때 useSuspenseQuery를 이용해서 Suspense 사용하면 HTML을 생성할 때 로딩이 전체적으로 발생하는 문제가 있었습니다.
API를 요청하는 axios 함수에 5초 후에 데이터를 반환하도록 아래와 같이 코드를 수정 후에 테스트를 진행했습니다.
1import { api } from "."; 2 3export const getHealthCheck = async () => { 4 try { 5 console.log("hey api space"); 6 const response = await api.get("/v1/health_check"); 7 8 return new Promise((resolve) => { 9 setTimeout(() => resolve(response.data), 5000); 10 }); 11 } catch (error) { 12 console.error("Health check failed:", error); 13 throw error; 14 } 15};
우선 SSG 빌드 시에 getStaticProps 함수를 사용하지 않고 정적 빌드(SSG)를 실행하면 빌드 시간 또한 5초가 추가로 더 걸리며 빌드를 실행한 터미널에서도 API 호출 함수에 작성한 console.log를 출력하는 것을 볼 수 있었습니다.
또한 getServerSideProps를 사용하지 않고 SSR로 실행하면 아래와 같이 페이지 전체가 생성되는 시간이 5초가 추가적으로 더 딜레이 되는 것을 볼 수 있었습니다.
Next.js와 react-query(tanstack-query)의 공식 문서에서도 뚜렷하게 이러한 원인을 설명할 수 있는 문서를 찾을 수가 없었기에 직접 react-query의 github discussions으로 질문을 올리고 답변을 통해 Next.js의 페이지 라우터에서는 SSR을 지원하지 않는다는 것을 알게 되었습니다. (App router의 Server Component에서는 Streaming을 통해 사용이 가능합니다.)
그렇다면 Next.js의 페이지 라우터 기준으로 CSR에서 Suspense를 사용할 수 있다는 것이며 이러한 방법으로는 크게 2가지 방법을 찾았습니다.
- Dynamic import를 이용하여 ssr: false 옵션을 통해 런타임에서 컴포넌트를 불러와서 CSR에서 사용하는 방법
1const Test = dynamic(() => import("./Test"), { 2 ssr: false, 3});
- @suspensive/react 패키지의 Suspense 컴포넌트의 clientOnly 옵션을 이용한 방법
1import { Suspense } from "@suspensive/react"; 2 3const Example = () => ( 4 <Suspense clientOnly fallback={<Loading />}> 5 <Children /> 6 </Suspense> 7);
사내에서는 모든 Suspense를 사용하는 곳에서 Dynamic import를 사용하기 보다는 suspensive 패키지를 이용해서 유연하게 상황에 따라 사용할 수 있도록 처리하였습니다.
2. 페이지네이션이 필요한 컴포넌트에서 Suspense를 사용시 페이지를 변경할 때마다 fallback 컴포넌트 노출 또는 깜빡임 문제 발생
Suspense를 사용하지 않았을 때는 useQuery와 placeholderData 옵션을 함수로 이용하여 페이지가 변경될 때 이전에 성공한 데이터를 이용해서 화면이 끊김 없이 보이도록 처리하였습니다.
아래는 적용했던 일부 코드와 영상입니다.
1export const useGetTest = () => { 2 const [pageNumber, pageSize] = useTestStore((state) => [ 3 state.pageNumber, 4 state.pageSize, 5 ]); 6 7 return useQuery({ 8 placeholderData: (previousValue) => previousValue, 9 throwOnError: true, 10 queryKey: testKeys.list(pageNumber, pageSize), 11 queryFn: () => getTest({ pageNumber, pageSize }), 12 }); 13};
위와 같이 부드럽게 페이지 이동을 Suspesne를 사용해서도 구현해보고 싶었기여 몇가지 방법을 시도하였는데 우선 사내에서 Suspense를 사용하면서 <DeferredComponent />라는 컴포넌트를 로딩 컴포넌트를 사용할 때 감싸서 사용합니다.
<DeferredComponent />는 자식 컴포넌트를 200ms 지연 후 렌더링합니다. 데이터 로딩 시간이 200ms 미만이면 로딩 컴포넌트가 표시되지 않습니다. 이를 통해 데이터 로딩이 빠를 때 잠깐 나타났다 사라지는 로딩 컴포넌트로 인한 화면 깜빡임을 방지하여 사용자 경험을 개선하였습니다. (좋은 예시를 공유드립니다. https://tech.kakaopay.com/post/skeleton-ui-idea/)
그렇기에 사내에서는 페이지네이션에 Suspense를 사용하면 페이지가 바뀔 경우 빠르게 데이터를 불러오기 때문에 로딩 컴포넌트는 보여지지 않지만 화면이 깜빡이는 증상이 발생합니다.
아래의 영상을 통해 확인할 수 있습니다.
이러한 현상을 해결하기 위해 2가지 방법을 시도해보았습니다.
- react-query의 initialData를 사용하여 초기 데이터를 이용하여 페이지가 바뀔 때 초기 데이터를 보여주도록 처리
- react18의 useDefferedValue를 사용하여 렌더링 지연을 통해 처리
initialData 활용
먼저 react-query의 initialData를 사용해보았습니다. 아래와 같이 코드를 적용해보았습니다. (사내 코드를 조금 수정하여 보여드립니다.)
1export const useGeTest = (TestId: string) => { 2 const [pageSize, pageNumber] = useTestStore((state) => [ 3 state.testPageSize, 4 state.testPageNumber, 5 ]); 6 7 return useSuspenseQuery({ 8 queryKey: testKeys.useGetTest("desc", testId, testNumber, testSize), 9 queryFn: () => getTest({ sort: "desc", testId, testNumber, testSize }), 10 initialData: { 11 payload: { 12 items: [ 13 { 14 testId: "loading", 15 name: "loading", 16 test: "loading", 17 isTest: false, 18 createdAt: "loading", 19 }, 20 { 21 testId: "loading", 22 name: "loading", 23 test: "loading", 24 isTest: false, 25 createdAt: "loading", 26 }, 27 { 28 testId: "loading", 29 name: "loading", 30 test: "loading", 31 isTest: false, 32 createdAt: "loading", 33 }, 34 { 35 testId: "loading", 36 name: "loading", 37 test: "loading", 38 isTest: false, 39 createdAt: "loading", 40 }, 41 { 42 testId: "loading", 43 name: "loading", 44 test: "loading", 45 isTest: false, 46 createdAt: "loading", 47 }, 48 ], 49 totalTestCount: 1, 50 }, 51 } as testResponse<ITestResponses>, 52 select: (res) => res.payload, 53 }); 54};
기존 쿼리에 initialData를 사용하면 설정한 초기 데이터만 화면에 보여주며 데이터를 패칭하지 않습니다. react-query는 initialData로 설정한 초기 데이터를 쿼리에서 성공 상태로 간주하여 페이지가 로딩되었을 때 새롭게 데이터를 패칭하지 않습니다. 이러한 동작으로 인해 아래의 코드 처럼 useEffect 내에 refect 함수를 이용해서 컴포넌트가 마운트시에 새롭게 쿼리를 패칭하도록 수정하였습니다.
1useEffect(() => { 2 refetch(); 3}, [pageNumber]);
이렇게 수정하여도 기존에 원하는 동작처럼 화면이 부드럽게 동작하지는 않았으며, 동작하더라고 useEffect 내에 refetch 함수를 작성해주어야 하는 것도 좋은 코드 흐름이라고 생각하지 않아 다른 방법을 시도하였습니다.
useDefferedValue 활용
이번에는 useDefferedValue 사용해서 새롭게 받아오는 데이터로 인한 리렌더링을 지연시켜 해결하려고 했습니다.
아래 코드와 같이 useSuspenseQuery를 통해 받아오는 data를 useDefferedValue 함수안에 넣어서 사용해서 적용해보아도 원하는 동작처럼 부드럽게 동작하지는 않았습니다.
1function UsersTestContainer() { 2 const { 3 push, 4 query: { id: testId }, 5 } = useRouter(); 6 7 const { data } = useGetTest(roleId as string); 8 const delayRenderData = useDeferredValue(data); 9 10 return ( 11 <Table.Body 12 hasBorder={false} 13 data={delayRenderData?.items} 14 onClickRow={(row) => {}} 15 /> 16 ); 17}
화면의 깜빡임 없이 부드럽게 페이지네이션이 되도록 처리하려고 시도했지만 좋은 방법을 찾지 못해서 페이지네이션을 사용하는 곳은 기존에 사용했던 방법인 useQuery + placeholderData 조합으로 구성하였습니다.
혹시라도 해결 방법을 아신다면 댓글로 공유해주시면 감사합니다!
페이지네이션과 같은 부분에서 문제가 발생하면서 과연 Suspense의 장점을 모든 곳에서 무조건 적용하여 사용해야할까? 라는 생각이 들었습니다.
사내 서비스를 기준으로 고민해보고 여러 글을 참고하면서 저는 아래와 같은 기준을 통해 Suspense 적용을 고민하기로 했습니다.
- 비동기 로직이 복잡한지?(하나의 컴포넌트에서 여러 API를 호출하고 데이터를 뿌려주는 형태일 때)
- 로딩이 길거나(오래걸리는 API) layout shift가 발생하는 곳인지?
- 기획의 요구사항으로 인해 Suspense를 사용하는 것이 효과적일 때?
- Suspense의 특정으로 인해 오히려 사용자 경험 또는 DX가 좋지 않아지는지?
모든 기술과 기능은 처한 상황에 맞게 사용하는 것이 맞다고 생각하기 때문에 Suspense 또한 예외가 아니라고 생각하였으며 위의 4가지 기준을 가지고 Suspense를 사용할까?에 대한 고민을 조금은 줄일 수 있었습니다.
이렇게 우리는 Suspense에 대해 깊지는 않지만 왜 사용해야 하며, 사용하면 어떤 이점을 얻을 수 있는지에 대해 알아보았습니다.
저의 경험이 다른분에게도 조금이라도 도움이 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다!
출처
React Query와 함께 Concurrent UI Pattern을 도입하는 방법