공식문서 - React Suspense
<Suspense>
는 child element의 loading이 완료될 때까지 대체 항목을 표시하게 해주는 역할을 한다.\
위 코드를 예시로 들면 <SomeComponent />
가 완료될 때까지 <Loading />
컴포넌트를 보여주고 <SomeComponent />
완료되면 <SomeComponent />
을 보여주는 것이다.
const App = () => {
const [userDetails, setUserDetails] = useState({});
useEffect(() => {
fetchUserDetails().then(setUserDetails);
}, []);
if (!userDetails.id) return <p>Fetching user details...</p>;
return (
<div className="app">
<h2>Simple Todo</h2>
<UserWelcome user={userDetails} />
<Todos />
</div>
);
};
우리가 흔히 보는 로직이다. 최초 useEffect
를 이용하여 데이터를 fetch
해오고 이후 useState
를 이용하여 데이터를 담은 다음 re-rendering
을 통해 갖고온 데이터를 실제 컴포넌트에 적용시키는 것이다. 하지만 이 방식(Fetch-On-Render)은 큰 문제가 존재한다. 만약 <Todos>
에 data fetch function이 존재한다고 가정하면 이 data fetching은 같이 진행되는 게 아닌 render가 된 이후 fetch가 진행된다. 즉 병렬적으로 data fetch를 처리해야하지만, 현재는 data fetching이 일어나지 않으면, 렌더가 되지 않을 것이고 렌더가 되지 않으면 <Todos>
에 존재하는 data fetch을 처리하지 못하게 된다. -> 이를 waterfall 문제
라고 한다.
다음 접근법은 컴포넌트가 렌더링 되기 전에 fetch
를 실행시키는 것이다.
const fetchDataPromise = fetchUserDetailsAndTodos(); // We start fetching here
const App = () => {
const [userDetails, setUserDetails] = useState({});
const [todos, setTodos] = useState([]);
useEffect(() => {
fetchDataPromise.then((data) => {
setUserDetails(data.userDetails);
setTodos(data.todos);
});
}, []);
return (
<div className="app">
<h2>Simple Todo</h2>
<UserWelcome user={userDetails} />
<Todos todos={todos} />
</div>
);
};
전역에서 data fetching이 일어나게 되면 render 이전에 data fetch을 실행시킬 수 있게 된다. 위에서 발생한 문제를 해결할 수 있는 방법이다. 위에서는 병렬적으로 data fetching을 처리하지 못해 waterfall
문제가 발생한다. 하지만 위 코드는 render 이전에 data fetching을 발생시키기 때문에 병렬적으로 데이터를 불러올 수 있을 것이다. 하지만 이 방식도 한 가지 문제가 존재한다. fetchUserDetails과 fetchTodos가 병렬적으로 시작했지만, 이 상황에서 우리는 두 요청중에 더 느린 요청이 완료될 때 까지 기다려야지 데이터를 렌더링 할 수 있게 된다. 예를 들어서 fetchTodos()가 100ms
걸리고, fetchUserDetails()가 1000ms
가 걸린다면, 우리는 900ms동안 fetchTodos()
가 완료되었음에도 불구하고, 기다려야한다. 이는 두 fetching 함수의 완료 return 시간에 따라 성능이 달라지겠지만, 완료 간격이 커지면 커질 수록 문제가 발생할 것이다. 그래서 react 18에서는 <Suspense>
라는 것을 도입하여 위 두 문제를 완벽하게 해결하였다.
이 방식은 fetch 요청이 발생한 직후에 바로 컴포넌트를 렌더링한다.
const data = fetchData(); // Promise x
const App = () => (
<>
<Suspense fallback={<p>Fetching user details...</p>}>
<UserWelcome />
</Suspense>
<Suspense fallback={<p>Loading todos...</p>}>
<Todos />
</Suspense>
</>
);
const UserWelcome = () => {
const userDetails = data.userDetails.read();
};
const Todos = () => {
const todos = data.todos.read();
};
Suspense는 시범적으로 react v16.6으로 실행되었고, 현재 react v18버전부터 SSR(server side rendering) 과 Data Fetching을 지원하는 중요한 기능으로 확장되었다. react v18의 가장 핵심적인 기능은 concurrent rendering(동시성 렌더링)이다. 이 때 주 핵심 개념으로서 Suspense가 등장하였는데 data fetching을 위한 비동기 UI 처리에 사용할 수 있게 되었다.
promise객체의 status가 fulfilled이 될 때까지 fallback에 존재하는 컴포넌트를 실행하고 fulfilled이 되면 실제 UI를 보여준다. 그러면서 모든 fetching을 동시에 일어나면서도 render를 실행시키게 해준다.
동시에 데이터를 불러오면서도 리렌더링도 안 시키고 렌더 문제도 없이 if 검사를 제거했다. 얼마나 깔끔하고 아름다운 코드인가?
다음은 문제가 발생할 수 있는 코드입니다. 확인하실 때 참고만 하시는 걸 추천드립니다.
// Board/[BoardId].tsx
import { Suspense } from 'react';
import BoardFooter from './_components/BoardFooter';
import BoardContainer from './_components/BoardContainer';
import getBoard from '@/app/apis/boards/getBoard';
import { IDeatilPageProps } from '@/models/detailPageProps';
import BoardLikeHate from './_components/BoardLikeHate';
import getReaction from '@/app/apis/boards/reaction/getReaction';
import BoardSkeleton from './_components/BoardSkeleton';
import BoardSkeletonReaction from './_components/BoardSkeletonReaction';
export default function Detail({ params }: IDeatilPageProps) {
const param = params.boardId;
return (
<div className="flex flex-col gap-4">
<Suspense fallback={<BoardSkeleton />}>
<BoardContainer resource={getBoard(+param)} />
</Suspense>
<Suspense fallback={<BoardSkeletonReaction />}>
<BoardLikeHate resource={getReaction(+param)} />
</Suspense>
<BoardFooter />
</div>
);
}
Next로 코드를 구현하였고, SSR을 최대한 많이 써보고 싶어서 Suspense를 사용해보았다. 덕지 덕지 "use client"를 사용하고 싶지 않았다. 제대로 next를 하는 건 처음인데 이번에 공부하면서 새로운 기능들을 많이 보게 되었다. 특히 page.tsx
에서 매개변수로 값을 받을 수 있는데 params(동적 경로에서 전달받은 값) / searchParams(query string) 를 바로 불러올 수 있다. 위에 코드를 예시로 들면 params를 매개변수에서 들고오고 해당 키(boardId)를 불러오면 현재 동적으로 전달받은 params를 받을 수 있다는 것이다.
다음은 Suspense 구현부이다. data가 fetching(즉, promise.status => pending) 되는 동안 fallback에 존재하는 Component를 불러오고 data fetching이 끝나면 (Promise.status => fulfilled)면 Suspense 내에 존재하는 컴포넌트를 실행하는 것이다.
현재 Suspense props로 resource를 보내는데 저 보내는 함수 getBoard() & getReaction()
가 데이터 요청 함수이다. 두 함수가 데이터 요청을 보내고 보내는 와중에는 fallback에 존재하는 component를 호출하고 데이터가 들어오면 Suspense 내에 존재하는 BoardContainer & BoardLikeHate
를 호출한다. 그럼 getBoard() & getReaction()
어떤 식으로 호출되는 지 확인해보자.
// getBoard.ts
import { IApiResponseData } from '@/models/apiResponse';
import { api } from '../config';
import { IBoardReader } from '@/models/boardReaderResponse';
export default function getBoard(boardId: number): IBoardReader {
let status = 'pending';
let board: IApiResponseData | Promise<IApiResponseData>;
const response = api
.get(`/board/${boardId}`)
.then((response) => {
setTimeout(() => {
board = response.data.data;
status = 'fulfilled';
}, 2000);
})
.catch((e) => {
status = 'reject';
board = e;
});
return {
read() {
if (status === 'pending') {
throw response;
} else if (status === 'reject') throw board;
else if (status === 'fulfilled') return board;
},
};
}
가장 문제였던 건 Promise vs async & await이다 이것에 대해서는 위 코드 설명한 이후에 이것에 대해서 따로 설명하겠다. getBoard()
는 board에 존재하는 boardId를 불러오는 함수이다. 나는 평상시에 axios + async await로 데이터를 불러오는데, Suspense를 사용하기 위해서 우리는 Promise 객체가 필요하다. 즉 async & await를 사용할 수 없다는 얘기다. 그래서 .then()을 사용하여 promise 객체를 반환했다.
여기서 setTimeOut 함수는 일부러 dramatic하게 패칭을 하는과정을 보고싶어서 일부러 사용하였다. 실제로 setTimeout을 사용하면 Suspense를 사용하는 이유가 없다.
return을 read 함수라는 것을 만들어서 성공하면 board 리턴하고 실패하거나 현재 진행중이면 error를 반환시키는 로직을 구현하였다.
위에서 말했다시피 나는 평상시에 axois / async + await 조합을 사용한다. 일단 간결하고 편하기 때문이다. 사실 이렇게 하면 바로 데이터를 불러올 수 있기 때문에 api 호출 로직은 당연하게도 저걸 사용했다. 여기서 문제가 발생한게 그냥 api를 호출하면 async await를 먼저 박아버리니깐 promise 객체를 못 받는 것이다. Suspense를 사용하려면 반드시 Promise 객체가 필요하다.
React Suspense는 렌더링 중에 비동기 작업이 완료되지 않으면, 해당 Promise를 throw해서 React가 이를 감지하고 fallback UI를 표시한 후, Promise가 해결되었을 때 다시 렌더링하는 방식으로 동작한다.
즉, React Suspense는 컴포넌트가 비동기적으로 처리 중일 때 Promise를 throw하는 것을 기반으로 작동하므로, Promise 자체가 없으면 Suspense의 동작을 제대로 활용할 수 없다. await 키워드를 사용하면, 해당 비동기 작업이 완료될 때까지 기다린 후 결과를 반환한다. 이 과정에서 Suspense의 로딩 상태 관리는 동작하지 않습니다.
이것때문에 무한루프,, 호출값 에러, fallback 이상해짐... 다음부턴 반드시 필히 공식문서를 읽고 제대로 써보자 .
/** @format */
import { IBoardReaderResource, IBoardResponse } from '@/models/boardReaderResponse';
import BoardHeader from './BoardHeader';
import BoardIcon from './BoardIcon';
import BoardImages from './BoardImages';
export default function BoardContainer({ resource }: IBoardReaderResource) {
const boardInfor: IBoardResponse = resource.read();
return (
<>
{boardInfor && typeof boardInfor === 'object' && 'boardId' in boardInfor && (
<>
<BoardHeader infor={boardInfor} />
<BoardIcon />
<BoardImages infor={boardInfor} />
<div className="prose-r_16_24">{boardInfor.content}</div>
<div className="w-full flex justify-center items-center bg-gray-100 py-4">
<iframe
width="560"
height="315"
src={`https://www.youtube.com/embed/${boardInfor.youtubeUrl}`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
</>
)}
</>
);
}
다음은 BoardContainer
이다. 뭐 이제 크게 문제가 없다. 다만 먼저 boardInfor의 type + value를 확실하게 체크하고 잘 들어왔다면 밑에 컴포넌트들을 호출하는 것이다. 크크
data fetching을 동시성 렌더링으로 구현한 게 신기하다.. ㅋㅋㅋㅋㅋㅋㅋ 이번 Next를 공부하면서 최대한 신기능들을 경험해보자
긴 글 읽어주셔서 감사합니다.
출처 - https://blog.logrocket.com/data-fetching-react-suspense/