
토이프로젝트2에서 다른 팀원이 React Suspense로 로딩 컴포넌트 구현하기를 맡았는데, 해당 코드에 대해 자세히 살펴보고 싶어 글을 작성하게 되었다.
먼저 gif를 이용한 로딩 컴포넌트를 만든다.
import styled from '@emotion/styled';
import LoadingDuck from './LoadingDuck';
const Loading = () => {
return (
<Container>
<LoadingDuck />
<Message>로딩중 ...</Message>
</Container>
);
};
export default Loading;
import styled from '@emotion/styled';
import loadingDuckGIF from '@/assets/loading_duck.gif';
const LoadingDuck = () => {
return (
<ImgContainer>
<img src={loadingDuckGIF} alt="Loading..." />
</ImgContainer>
);
};
export default LoadingDuck;
요렇게 생긴 귀여운 오리다.

fallback으로 Loading 컴포넌트를 불러온다. Suspense를 통해 자식 컴포넌트의 패치가 끝날때까지 Loading 컴포넌트를 보여준다.
import Title from '@/components/common/Title';
import Loading from '@/components/Loading/Loading';
import styled from '@emotion/styled';
import { Suspense } from 'react';
const Schedule = () => {
return (
<Suspense fallback={<Loading />}>
<Container>
<Title title="개인근무 일정표" className="title" />
<Calendar isOfficial={false} />
</Container>
</Suspense>
);
};
export default Schedule;
이제 팀원이 만든 로딩 컴포넌트를 내가 작업한 페이지에 적용해보려고 한다.
기존에 이런식으로 작업해놨는데 서스펜스를 적용하려니 약간의 충돌이 생겼다.
SummaryInfoCard 컴포넌트에서 useState와 useEffect를 사용하여 데이터를 패칭하고 있는데, 이 방식은 Suspense의 데이터 로딩 모델과 맞지 않는다.
기존코드
import SummaryInfoCard from '@/components/Home/SummaryInfoCard';
import Calendar from '@/components/common/Calendar/Calendar';
import Title from '@/components/common/Title';
import styled from '@emotion/styled';
const Home = () => {
return (
<Container>
<SummaryInfoCard />
<Title title="공식 근무 스케줄" className="title" />
<Calendar isOfficial={true} />
</Container>
);
};
export default Home;
import { useState, useEffect } from 'react';
import getOfficialWage from '@/api/work/getOfficialWage';
import { colors } from '@/constants/colors';
import { fontSize, fontWeight } from '@/constants/font';
import characterCheese from '@/assets/character_cheese.svg';
import styled from '@emotion/styled';
interface IWageDataProps {
totalWorkHour: number;
totalWage: number;
}
const SummaryInfoCard = () => {
const [wageData, setWageData] = useState<IWageDataProps | null>(null);
const [error, setError] = useState<string | null>(null);
const nowDate = new Date();
const year = nowDate.getFullYear();
const month = nowDate.getMonth() + 1;
const monthString = month.toString().padStart(2, '0');
useEffect(() => {
const fetchWageData = async () => {
setError(null);
try {
const data = await getOfficialWage(year, month);
setWageData(data);
} catch (error) {
setError('급여 정보를 불러오는 데 실패했습니다');
}
};
fetchWageData();
}, [year, month]);
if (error) return <p>{error}</p>;
return (
<SummaryCard>
<SummaryCardContainer>
<FirstSection>
<p>
공식 근무 스케줄 | {year}년 {monthString}월
</p>
<p>근무 시간 | {wageData ? wageData.totalWorkHour : '-'}시간</p>
</FirstSection>
<SecondSection>
<p>예상 급여액</p>
<p>{wageData ? wageData.totalWage.toLocaleString() : '-'}원</p>
</SecondSection>
</SummaryCardContainer>
<img src={characterCheese} alt="치즈캐릭터" />
</SummaryCard>
);
};
export default SummaryInfoCard;
Suspense를 효과적으로 활용하기 위해 다음과 같이 코드를 수정했다. 우선 Home에서 Suspense를 불러온다. 팀원이 만들어둔 useWageCheck가 있길래 얘를 활용해서 필요한 유저의 급여 정보를 불러왔다. 나는 공식스케줄의 급여정보만 필요해서 officialWageData와 officialWageError 정보, year,month, 에러처리시 필요한 getErrorMessage를 불러왔다.
수정된 코드
import SummaryInfoCard from '@/components/Home/SummaryInfoCard';
import Loading from '@/components/Loading/Loading';
import Title from '@/components/common/Title';
import styled from '@emotion/styled';
import useWageCheck from '@/hooks/useWageCheck';
import { Suspense } from 'react';
const Home = () => {
const { year, month, officialWageData, officialWageError, getErrorMessage } = useWageCheck();
if (officialWageError) {
return <div>Error: {getErrorMessage(officialWageError)}</div>;
}
return (
<Suspense fallback={<Loading />}>
<Container>
<SummaryInfoCard
year={year}
month={month}
wagecount={officialWageData?.totalWage || 0}
workinghours={officialWageData?.totalWorkHour || 0}
/>
<Title title="공식 근무 스케줄" className="title" />
<Calendar isOfficial={true} />
</Container>
</Suspense>
);
};
export default Home;
import getOfficialWage from '@/api/work/getOfficialWage';
import getPersonalWage from '@/api/work/getPersonalWage';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from 'react-query';
import { IOfficialWageItem } from '@/components/Wage/WorkHistory';
const useWageCheck = () => {
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const navigate = useNavigate();
const { data: personalWageData, error: personalWageError } = useQuery(
['personalWage', year, month],
() => getPersonalWage(year, month),
);
const { data: officialWageData, error: officialWageError } = useQuery(
['officialWage', year, month],
() => getOfficialWage(year, month),
);
const handleMonthChange = (newYear: number, newMonth: number) => {
setYear(newYear);
setMonth(newMonth);
};
const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
return 'An unknown error occurred';
};
const handleItemClick = (item: IOfficialWageItem) => {
navigate(`/wage/check/detail`, { state: { item } });
};
return {
year,
month,
personalWageData,
personalWageError,
officialWageData,
officialWageError,
handleMonthChange,
getErrorMessage,
handleItemClick,
};
};
export default useWageCheck;
SummaryInfoCard에서는 useEffect로 패치해오던 코드를 삭제하고 부모 컴포넌트에서 props로 정보를 받아왔다.
import { colors } from '@/constants/colors';
import { fontSize, fontWeight } from '@/constants/font';
import characterCheese from '@/assets/character_cheese.svg';
import styled from '@emotion/styled';
interface IWageDataProps {
wagecount: number;
workinghours: number;
}
const SummaryInfoCard = ({ year, month, wagecount, workinghours }: IWageDataProps) => {
return (
<SummaryCard>
<SummaryCardContainer>
<FirstSection>
<p>
공식 근무 스케줄 | {year}년 {month.toString().padStart(2, '0')}월
</p>
<p>근무 시간 | {workinghours}시간</p>
</FirstSection>
<SecondSection>
<p>예상 급여액</p>
<p>{wagecount.toLocaleString()}원</p>
</SecondSection>
</SummaryCardContainer>
<img src={characterCheese} alt="치즈캐릭터" />
</SummaryCard>
);
};
export default SummaryInfoCard;
Home에서 SummaryInfoCard와 Calendar를 감싸는 Suspense를 적용했다. SummaryInfoCard와Calendar에 각각 Suspense를 적용할수있지만 최상위에 적용하는 이유는 다음과 같다.
일관된 로딩 경험을 제공
서로 다른 로딩 컴포넌트라면 쪼개도 되지만, 어차피 같은 로딩하면을 각각 쪼개서 보여줄 이유가 없다고 판단했다.
성능 최적화
여러 컴포넌트가 동시에 로드될 때, 개별적인 로딩 상태 대신 하나의 로딩 상태만 관리한다. 서스펜스는 각 자식 컴포넌트가 패치되면 리렌더링이 일어난다. 각각 사용하면 2번인데 한번만 사용함으로써 불필요한 리렌더링을 줄였다.
그리고 SummaryInfoCard 컴포넌트에서 기존 패치를 지우고 props로 데이터를 받아오는 이유는 다음과 같다.
코드 재사용성 높이기
이미 팀원이 만들어 놓은 코드가 있어 재사용성을 높이고자 했다. 그리고 보통 패치는 컴포넌트마다 하는게 아니라, 패치 파일을 다 만들어두고 그것을 import해서 사용한다고 들었다.
코드 양 줄이기
props로 데이터를 받아옴으로써 SummaryInfoCard의 코드가 깔끔해졌다.
팀원들과 서스펜스 스터디를 하면서 서스펜스를 각 컴포넌트에 적용하는게 아니라 라우터에만 적용하면 된다는 말을 들었다. 이렇게하면 페이지가 이동할때마다 로딩컴포넌트를 보여준다.
초기 렌더링 시간이 줄어든다는 장점이 있으나 페이지를 이동할때마다 로딩컴포넌트가 보여지기 때문에 서비스에 따라서 적용 여부를 결정해야 한다.(만약 특정페이지들만 보여줘야한다면 다른 방식을 고려해야한다)
아래처럼 코드를 작성하면, "/" 경로로 리퀘스트가 들어오면 React앱은 Layout 컴포넌트 외에 다른 컴포넌트도 그 즉시 불러오게 된다.
아직 "/schedule" 같은 경로로 가지 않았는데도 Schedule 컴포넌트가 로딩되는 것은 불필요한 작업이다.
이럴 때 쓰이는 게 React.lazy이다. React.lazy는 해당 lazy 컴포넌트가 있는 컴포넌트가 리퀘스트(방문)될 때 비동기식으로 import 해서 필요할 때 불러오게 된다.
lazy 동작원리
초기 렌더링 시, lazy로 임포트된 컴포넌트는 로드되지 않는다.
해당 컴포넌트가 처음 렌더링될 때, React는 컴포넌트 로딩을 시작한다.
로딩 중에는 Suspense의 fallback UI가 표시된다.
로딩이 완료되면, 실제 컴포넌트가 렌더링된다.
const App = () => (
<BrowserRouter>
<GlobalStyles />
<QueryClientProvider client={queryClient}>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Layout />}>
<Route element={<PrivateRoute />}>
<Route index element={<Home />} />
<Route path="schedule" element={<Schedule />} />
<Route path="schedule/:date" element={<ScheduleDetail />} />
{/* ... 다른 라우트들 */}
</Route>
<Route path="login" element={<Login />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</QueryClientProvider>
</BrowserRouter>
);
export default App;
즉 아래처럼 코드를 작성하면 해당 페이지로 이동할때만 import된 컴포넌트들이 보여진다. lazy 사용으로 초기 번들을 줄여 코드를 최적화할 수 있다.
const App = () => (
const Layout = React.lazy(() => import('@/Layout'))
const Home = React.lazy(() => import('@/Home'))
const Schedule = React.lazy(() => import('@/Schedule'))
const ScheduleDetail = React.lazy(() => import('@/ScheduleDetail'))
...등등
<BrowserRouter>
<GlobalStyles />
<QueryClientProvider client={queryClient}>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Layout />}>
<Route element={<PrivateRoute />}>
<Route index element={<Home />} />
<Route path="schedule" element={<Schedule />} />
<Route path="schedule/:date" element={<ScheduleDetail />} />
{/* ... 다른 라우트들 */}
</Route>
<Route path="login" element={<Login />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</QueryClientProvider>
</BrowserRouter>
);
export default App;
https://www.elancer.co.kr/blog/view?seq=267
https://mycodings.fly.dev/blog/2022-08-29-react-lazy-react-suspense-guide