리액트로 프론트엔드 개발을 하다보면 웹 어플리케이션의 크기가 커지게 되는데, 그만큼 번들 크기가 커지고 초기 로딩 시간이 길어져 사용자 경험(UX)가 저해된다.
이런 상황에서 React의 Suspense와 동적 import를 활용하면 사용자에게 필요한 코드만 그때그때 가져와 초기 렌더링 비용을 감소시킬 수 있다. 이번 글에서는 내가 진행했던 프로젝트에서 Suspense와 동적 import를 활용하여 성능을 개선한 과정을 이야기하고자 한다.
성능 최적화 전 코드는 다음과 같다.
import {
createBrowserRouter,
createRoutesFromElements,
Route,
} from 'react-router-dom';
import Layout from './components/layout/Layout';
import LayoutWithSidebar from './components/layout/LayoutWithSidebar';
import MainPage from './pages/main/Page';
import ProfilePage from './pages/profile/Page';
import ResumeListPage from './pages/profile/resume/Page';
import CreateResumePage from './pages/profile/resume/create/Page';
import ApplicationsPage from './pages/profile/applications/Page';
import CommunityPage from './pages/community/Page';
import CompaniesPage from './pages/companies/Page';
import DetailCompanyPage from './pages/companies/DetailPage';
import NotFoundPage from './pages/notFound/Page';
import RegisterPage from './pages/register/Page';
import LoginPage from './pages/login/Page';
import { userSidebarNavItems } from './lib/constants/navItems';
export const router = createBrowserRouter(
createRoutesFromElements(
<>
{/* Layout이 적용되는 라우트들 */}
<Route path='/' element={<Layout />}>
<Route index element={<MainPage />} />
<Route path='community' element={<CommunityPage />} />
<Route path='companies' element={<CompaniesPage />} />
<Route path='companies/:id' element={<DetailCompanyPage />} />
<Route path='register' element={<RegisterPage />} />
<Route path='login' element={<LoginPage />} />
</Route>
{/* Sidebar가 포함된 라우트 */}
<Route
path='/'
element={<LayoutWithSidebar navItems={userSidebarNavItems} />}
>
<Route path='profile' element={<ProfilePage />} />
<Route path='profile/resume' element={<ResumeListPage />} />
<Route path='profile/resume/create' element={<CreateResumePage />} />
<Route path='profile/applications' element={<ApplicationsPage />} />
</Route>
{/* Layout이 적용되지 않는 라우트 */}
<Route path='*' element={<NotFoundPage />} />
</>,
),
);
동적 import
동적 import는 코드 스플리팅 기법 중 하나이다. 리액트 웹 어플리케이션은 SPA(Single Page Application)의 특성으로 인해 한번에 사용하지 않는 모든 코드까지 불러오기 때문에 초기 렌더링 시간이 오래 걸리게 된다. 리액트에서는 React.lazy()를 통해 컴포넌트를 동적으로 import하여 초기 렌더링 시간을 단축시킬 수 있다.
코드 스플리팅이란?
자바스크립트 파일을 필요한 부분만 나누어 로드하는 기법으로, 모든 코드를 한번에 불러오는 것이 아닌 필요한 부분만 먼저 불러오는 것이다.
파일 내에서 여러 모듈을 import하게 되면 번들 과정에서 import문에 포함된 모든 파일들을 한번에 불러오게 된다. 네트워크 탭을 통하여 메인페이지에 접속하면 다음과 같은 결과가 나온다.
하단에 요약을 보면, 메인 페이지와 관련 없는 다른 페이지에 포함되어 있는 모듈을 한번에 로드하여 총 228개의 요청을 보내는 모습을 볼 수 있다. 그로인해 초기 렌더링 비용이 증가하게 되어 로딩 시간이 130ms가 나온 것을 확인할 수 있다.
이런 상황에서 React.lazy()를 사용하여 동적 import를 적용하면 해당 모듈이 실제로 사용될 때 해당 코드를 내려받게 된다.
사용법
import { lazy } from 'react';
const MainPage = lazy(() => import('./pages/main/Page'));
메인 페이지를 포함하여 다른 페이지들도 동적 import를 적용하고 다시 메인페이지에 접속한 결과 다음과 같은 결과가 나온다.
메인 페이지에 포함되어 있는 모듈만 불러오게 되어 요청 횟수도 71로 줄었으며, 로딩 시간은 82ms로 줄어든 모습을 볼 수 있다. 예시로 보여주는 프로젝트의 규모가 아직 작아서 그런지 눈에 띄는 효과가 없지만, 성능이 개선된 모습을 수치를 통해 확인하니 매우 뿌듯했다.
Suspense를 통한 UX개선
Suspense는 컴포넌트가 아직 렌더링 준비가 되지 않았을 때 로딩 화면을 보여주고 로딩이 완료되면 해당 컴포넌트를 보여주는 용도로 사용한다. 주로 React.lazy를 통해 페이지 이동 과정에서 생기는 로딩시간이나 데이터 페칭으로 인하여 생기는 로딩시간 중 로딩 화면을 보여주도록 해주는 역할을 한다.
사용법
import { Suspense, lazy } from 'react';
const MainPage = lazy(() => import('./pages/main/Page'));
export const router = createBrowserRouter(
createRoutesFromElements(
<Suspense fallback={<LoadingSpinner />>
<Route path='/' element={<Layout />}>
<Route index element={<SuspensedMainPage />} />
</Route>
</Suspense>
),
);
라우터에 Suspense를 적용하려면 각 페이지를 동적 imoprt를 한 다음 로딩 UI를 적용할 부분을 감싸주면 된다. 그러면 페이지가 렌더링 준비가 되지 않았을 경우 아무것도 없는 흰 바탕을 보여주는 대신 로딩 페이지를 렌더링하여 사용자 경험을 개선할 수 있다.
고차 컴포넌트(HOC)로 Suspense가 적용된 컴포넌트 만들기
컴포넌트에 Suspense를 사용하다가 문득 이런 생각이 들었다. Suspense를 사용할 때마다 일일이 감싸주는건 좀 귀찮지 않을까?
그래서 나는 Suspense를 감쌀 컴포넌트와 fallback 컴포넌트를 props로 받아 특정 컴포넌트에 Suspense를 감싸주는 고차 컴포넌트를 구현하였다.
// withSuspense
import { Suspense } from 'react';
/**
* withSuspense HOC
*
* @param { React.ComponentType } WrappedComponent - 감쌀 대상 컴포넌트
* @param { React.ReactNode } [fallback=<div>Loading...</div>] - 로딩될 때 보여줄 UI
* @return Suspense를 감싼 컴포넌트를 반환
*/
function withSuspense<P extends object>(WrappedComponent: React.CompontType<P>, fallback = <div>Loading...</div>) {
return function SuspenseHOC(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
);
);
)
export default withSuspense;
위에서 작성된 코드처럼 감쌀 대상인 컴포넌트와 fallback 컴포넌트를 props로 받아 Suspense로 감싸진 컴포넌트를 return하는 함수를 반환하도록 하였다.
그러면 여러 개의 Suspense가 적용된 컴포넌트를 적용하기 위해서는 다음과 같이 활용할 수 있다.
import withSuspense from '@/pages/withSuspense';
const MainPage = lazy(() => import('./pages/main/Page'));
const ProfilePage = lazy(() => import('./pages/profile/Page'));
const ResumeListPage = lazy(() => import('./page/profile/resume/Page'));
const CreateResumePage = lazy(() => import('./page/profile/resume/create/Page'));
export const router = createBrowserRouter(
createRoutesFormElements(
<Route path='/'>
<Route index element={withSuspense(MainPage)} />
<Route path='profile' element={withSuspense(ProfilePage)} />
<Route path='profile/resume' element={withSuspense(ResumeListPage)} />
<Route path='profile/resume/create' element={withSuspense(CreateResumePage)} />
</Route>
),
);
일일이 Suspense를 감싸주는 일 없이 고차 컴포넌트를 활용하여 간단히 Suspense를 적용한 모습을 볼 수 있다.
에러 바운더리(Error Boundary)
에러 바운더리는 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신 fallback UI를 보여주는 React 컴포넌트로 렌더링 도중 생명주기 메서드 및 그 아래에 있는 전체 트리에서 에러를 잡아낸다.
그러나 아래의 경우에는 에러를 잡아낼 수 없다.
- 이벤트 핸들러
- 비동기적 코드
- SSR
- 에러 바운더리 컴포넌트 내에서 발생한 에러
나같은 경우에는 레이아웃 컴포넌트부터 에러를 포착하기 위헤 레이아웃 컴포넌트 내에 에러 바운더리를 적용하였다.
사용법
// RootErrorBoundary.tsx
import React from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { useNavigate } from 'react-router-dom';
function RootErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const navigate = useNavigate();
const handleReset = () => {
// 에러 복구 시 해야 할 로직이 있다면 이곳에서 진행
resetErrorBoundary();
// 홈으로 이동하거나 다른 페이지로 이동이 필요한 경우
navigate('/');
};
return (
<div>
<h1>에러가 발생했습니다.</h1>
<p>{error.message}</p>
<button onClick={handleReset}>홈으로 이동</button>
</div>
);
}
interface RootErrorBoundaryProps {
children: React.ReactNode;
}
function RootErrorBoundary({ children }: RootErrorBoundaryProps) {
return (
<ErrorBoundary
FallbackComponent={RootErrorFallback}
onReset={() => {
// ErrorBoundary가 reset될 때 실행할 추가 작업이 있으면 여기에 작성
}}
>
{children}
</ErrorBoundary>
);
}
export default RootErrorBoundary;
// Layout.tsx
import { Outlet, ScrollRestoration } from 'react-router-dom';
import Header from '../common/header/Header';
import Footer from '../common/footer/Footer';
import RootErrorBoundary from '@/components/error/RootErrorBoundary';
export default function Layout() {
return (
<RootErrorBoundary>
<Header />
<Outlet />
<Footer />
<ScrollRestoration />
</RootErrorBoundary>
);
}
'Frontend > React.js' 카테고리의 다른 글
[React] 이벤트 전파 방지 (0) | 2025.05.11 |
---|---|
[React] react-i18next 다국어를 위한 JSON 파일 한번에 생성하기 (0) | 2025.04.08 |
[React] 파일 입력 박스 (0) | 2025.01.18 |
[React]useImmer 라이브러리 사용법 (0) | 2025.01.04 |
[React]state를 업데이트를 위한 두 가지 방법 (1) | 2024.12.29 |