리액트에서 코드 스플리팅 해보기

고기호·2024년 8월 21일
1

같이듣자

목록 보기
4/5

코드 스플리팅이란?


코드 스플리팅

코드 스플리팅이란 전체 애플리케이션을 더 작은 청크(파일) 단위로 나누고, 특정 시점에 필요한 청크들만 동적으로 불러와서 사용하는 방식으로 애플리케이션을 최적화 하는 방법이다. 이를 통해 초기 로딩 시간을 줄이고, 사용자가 더 빠르게 콘텐츠에 접근할 수 있도록 한다.

SPA에서 왜 코드 스플리팅이 필요한가?

SPA(Single Page Application)의 경우, 사용자가 애플리케이션에 처음 진입할 때 진입점이 되는 HTML 파일을 불러오고 위에서부터 코드를 실행하며 resource를 불러와 페이지를 렌더링한다.따라서 초기 렌더링시 굳이 바로 사용하지 않을 resource도 불러오기 때문에 초기 렌더링이 사용자 입장에서 불필요하게 늘어날 수 있다. 따라서 필요할 때마다 resource를 불러오는 방식으로 청크를 나누면 초기 렌더링 시간이 줄어들어 사용자 경험이 개선이 된다.

코드 스플리팅할 파일을 정하는 기준

모든 파일을 코드 스플리팅을 한다면 초기 로딩 속도는 많이 줄어들겠지만 이후에 애플리케이션을 사용할 때마다 resource를 불러와 사용자 경험이 오히려 떨어질 수 있다. 따라서 여러가지 기준을 세워서 코드 스플리팅을 해야한다.

  1. 사용 빈도가 낮은 컴포넌트 혹은 페이지
  2. 비동기 데이터 패칭 컴포넌트
    • 코드 스플리팅으로 번들 사이즈가 줄어들 뿐만 아니라 데이터를 불러오는 로직과 에러 처리 로직을 바깥으로 꺼내어 비동기 데이터 패칭 컴포넌트임을 나타내고, 내부의 로직은 깔끔해진다.
    • 예를들어 isLoading의 경우 Suspense로 처리하고, isError의 경우 ErrorBoundary로 처리한다.
      • 리액트의 ErrorBoundary는 함수형 컴포넌트가 아닌 클래스형 컴포넌트로 사용해야 하기 때문에 react-errror-boundary 라이브러리를 사용한다

코드 스플리팅하는 방법

  • webpack 설정
    const path = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const CopyWebpackPlugin = require('copy-webpack-plugin');
    const Dotenv = require('dotenv-webpack');
    const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
    const progressPlugin = require('progress-webpack-plugin');
    
    module.exports = {
    	...
      // 설정 부분
      optimization: {
        splitChunks: {
          chunks: 'all',
        },
      },
    };
    
  • Suspense와 lazy사용
    // components
    import { lazy, Suspense } from 'react';
    import { Route, Routes } from 'react-router-dom';
    import LandingPage from '@/pages/Landing/LandingPage';
    import MainPage from '@/pages/Main/MainPage';
    import ChannelContainer from '@/pages/Main/_compoenets/Content/ChannelContainer';
    import Channel from '@/pages/Main/Channel/Channel';
    import FallBack from '@/components/Molecules/FallBack';
    import SignInPage from '@/pages/Signin/SignInPage';
    
    const Profile = lazy(() => import('@/pages/Main/Profile/Profile'));
    const EditChannels = lazy(() => import('@/pages/Main/EditChannels/EditChannels'));
    const NotFoundPage = lazy(() => import('@/pages/NotFound/NotFoundPage'));
    
    export default function Router() {
      // view
      return (
        <Suspense fallback={<FallBack />}>
          <Routes>
            <Route path='/' element={<LandingPage />} />
            <Route path='/signIn' element={<SignInPage />} />
            <Route path='/main' element={<MainPage />}>
              <Route path='/main' element={<ChannelContainer />} />
              <Route path='/main/channel/:channelId' element={<Channel />} />
              <Route path='/main/Profile' element={<Profile />} />
              <Route path='/main/EditChannels' element={<EditChannels />} />
            </Route>
            <Route path='*' element={<NotFoundPage />} />
          </Routes>
        </Suspense>
      );
    }
    

Suspense 컴포넌트는 lazy로 감싸진 import문으로 해당 컴포넌트, 페이지가 불러오기 전까지 보여줄 컴포넌트이다.

  • Suspense와 ErrorBoundary로 isLoading과 isError 밖으로 빼기
    1. 코드 스플리팅 전

      import React, { useEffect, useState } from 'react';
      
      function UserProfile() {
        const [isLoading, setIsLoading] = useState(true);
        const [isError, setIsError] = useState(false);
        const [data, setData] = useState(null);
      
        useEffect(() => {
          async function fetchData() {
            try {
              const response = await fetch('/api/user');
              const result = await response.json();
              setData(result);
            } catch (error) {
              setIsError(true);
            } finally {
              setIsLoading(false);
            }
          }
      
          fetchData();
        }, []);
      
        if (isLoading) {
          return <div>Loading...</div>;
        }
      
        if (isError) {
          return <div>Error loading data</div>;
        }
      
        return (
          <div>
            <h1>{data.name}</h1>
            <p>{data.email}</p>
          </div>
        );
      }
      
      export default UserProfile;
    • 컴포넌트 내부 코드가 복잡하다.


    1. 코드 스플리팅 이후

      import React, { useEffect, useState } from 'react';
      
      function UserProfile() {
        const [data, setData] = useState(null);
      
        useEffect(() => {
          async function fetchData() {
            const response = await fetch('/api/user');
            const result = await response.json();
            setData(result);
          }
      
          fetchData();
        }, []);
      
        if (!data) {
          throw new Promise((resolve) => setTimeout(resolve, 1000)); // 임시 로딩 처리
        }
      
        return (
          <div>
            <h1>{data.name}</h1>
            <p>{data.email}</p>
          </div>
        );
      }
      
      export default UserProfile;
      import React, { lazy, Suspense } from 'react';
      import { ErrorBoundary } from 'react-error-boundary';
      
      const UserProfile = lazy(() => import('./UserProfile'));
      
      function LoadingFallback() {
        return <div>Loading...</div>;
      }
      
      function ErrorFallback({ error, resetErrorBoundary }: { error: Error, resetErrorBoundary: () => void }) {
        return (
          <div role="alert">
            <p>Something went wrong:</p>
            <pre>{error.message}</pre>
            <button onClick={resetErrorBoundary}>Try again</button>
          </div>
        );
      }
      
      function App() {
        return (
          <ErrorBoundary FallbackComponent={ErrorFallback}>
            <Suspense fallback={<LoadingFallback />}>
              <UserProfile />
            </Suspense>
          </ErrorBoundary>
        );
      }
      
      export default App;
    • 코드가 길어 보일 수 있는데 Fallback과 ErrorFallback, fetch 함수를 분리시키면 훨씬 가독성이 좋아진다.
    • 또한 ErrorBoundary와 Suspense로 컴포넌트를 감싸주면서 비동기 데이터 패칭 컴포넌트임을 바로 알 수 있게 된다.
    • 또한 resetErrorBoundary 를 이용하면 에러가 나기 전으로 되돌려 다시 컴포넌트를 렌더링해 데이터 패칭을 다시 하게 끔 만들 수 있다.

같이 듣자 프로젝트에 적용해보기


사용 빈도가 낮은 컴포넌트 혹은 페이지에 적용하기

  • 라우팅 파일에서 적용하기
    // components
    import { lazy, Suspense } from 'react';
    import { Route, Routes } from 'react-router-dom';
    import LandingPage from '@/pages/Landing/LandingPage';
    import MainPage from '@/pages/Main/MainPage';
    import ChannelContainer from '@/pages/Main/_compoenets/Content/ChannelContainer';
    import Channel from '@/pages/Main/Channel/Channel';
    import FallBack from '@/components/Molecules/FallBack';
    import SignInPage from '@/pages/Signin/SignInPage';
    
    const Profile = lazy(() => import('@/pages/Main/Profile/Profile'));
    const EditChannels = lazy(() => import('@/pages/Main/EditChannels/EditChannels'));
    const NotFoundPage = lazy(() => import('@/pages/NotFound/NotFoundPage'));
    
    export default function Router() {
      // view
      return (
        <Suspense fallback={<FallBack />}>
          <Routes>
            <Route path='/' element={<LandingPage />} />
            <Route path='/signIn' element={<SignInPage />} />
            <Route path='/main' element={<MainPage />}>
              <Route path='/main' element={<ChannelContainer />} />
              <Route path='/main/channel/:channelId' element={<Channel />} />
              <Route path='/main/Profile' element={<Profile />} />
              <Route path='/main/EditChannels' element={<EditChannels />} />
            </Route>
            <Route path='*' element={<NotFoundPage />} />
          </Routes>
        </Suspense>
      );
    }
    
    위에서 본 코드인데 Profile, EditChannels, NotFoundPage의 경우 유저가 자주 들어갈 페이지가 아니기 때문에 코드 스플리팅으로 지연로딩이 되게 끔 했다.
  • 나중에 보여질 컴포넌트 코드 스플리팅
    // libraries
    import styled from 'styled-components';
    import { lazy } from 'react';
    
    // components
    import Header from './_compoenets/Header/Header';
    import SideBar from './_compoenets/SiderBar/SideBar';
    import { Outlet } from 'react-router-dom';
    import YoutubeIframePlayer from './_compoenets/YoutubeIframePlayer/YoutubeIframePlayer';
    
    const MusicBar = lazy(() => import('./_compoenets/MusicBar/MusicBar'));
    
    export default function MainPage() {
      // view
      return (
        <Wrapper>
          <Header />
          <Main>
            <SideBar />
            <Right>
              <Outlet />
            </Right>
          </Main>
          <MusicBar />
          <YoutubeIframePlayer />
        </Wrapper>
      );
    }
    
    ...Styled-components
    MusicBar의 경우 음악이 재생 될 때만 보여지기 때문에 적용했다.

비동기 데이터 패칭 컴포넌트에 적용하기

먼저 리액트 쿼리를 사용하는 경우 Suspense와 ErrorBoundary를 사용할때는 useSuspenseQueryQueryClientthrowOnError: true 옵션을 사용해야한다. (자세한건 공식 문서 보기)

  • QueryErrorBoundary
    import styled from 'styled-components';
    import { useQueryErrorResetBoundary } from '@tanstack/react-query';
    import { ErrorBoundary } from 'react-error-boundary';
    
    interface QueryErrorBoundaryProps {
      children: React.ReactNode
    }
    
    export default function QueryErrorBoundary({ children }: QueryErrorBoundaryProps) {
      const { reset } = useQueryErrorResetBoundary();
    
      return (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ error, resetErrorBoundary }) => (
            <Wrapper role='alert'>
              <ErrorMessage>{error.message}</ErrorMessage>
              <ResetComponentButton onClick={resetErrorBoundary}>재시도</ResetComponentButton>
            </Wrapper>
          )}
        >
          {children}
        </ErrorBoundary>
      );
    }
    
  • 비동기 데이터 패칭 컴포넌트 코드 스플리팅
// libraries
import { lazy, Suspense } from 'react';
import styled from 'styled-components';

// components

import SubscribeButton from './SubscribeButton/SubscribeButton';
import EditButton from './EditButton/EditButton';
import FallBack from '@/components/Molecules/ComponentFallBack';
import QueryErrorBoundary from '@/components/Molecules/QueryErrorBoundary';

const MusicContainer = lazy(() => import('./MusicContainer'));

// images
import addSquareSvg from '@/images/svg/add-square.svg';

// hooks
import useMusicList from './MusicList.hook';
import AddMusicGuide from './AddMusicGuide';

export default function MusicList() {
  // logics
  const { isEditMode, channelId, setIsEditMode, handleCreateMusicButtonButtonClick, handleEditConfirmButtonClick } =
    useMusicList();

  // view
  return (
    <Wrapper>
      <Header>
        <Left>
          <SubscribeButton channelId={channelId} />
          <EditButton
            isEditMode={isEditMode}
            handleEditConfirmButtonClick={handleEditConfirmButtonClick}
            setIsEditMode={setIsEditMode}
          />
        </Left>
        <CreateMusicButton onClick={handleCreateMusicButtonButtonClick}>
          <img src={addSquareSvg} alt='음악 생성 버튼 이미지' />
        </CreateMusicButton>
        <AddMusicGuide />
      </Header>

      <QueryErrorBoundary>
        <Suspense fallback={<FallBack />}>
          <MusicContainer isEditMode={isEditMode} />
        </Suspense>
      </QueryErrorBoundary>
    </Wrapper>
  );
}

...
  • useSuspenseQuery사용
import { useSuspenseQuery } from '@tanstack/react-query';
import { Music } from '../../types/music';
import { getMusicsByChannelId } from '../services/channel';
import queryKeys from '../queryKey';

export default function useGetMusicsByChannelId(channelId: string | undefined) {
  const queryKey = queryKeys.musicList.allMusicList;

  return useSuspenseQuery<Music[], Error>({
    queryKey: queryKey,
    queryFn: () => getMusicsByChannelId(channelId),
  });
}
  • 작동

결론

  • 코드 스플리팅은 SPA에서 초기 로딩 시간을 줄이고, 사용자 경험을 향상시키기 위해 매우 중요하다.
  • SuspenseErrorBoundary 를 사용해 코드 스플리팅을 하면서도 로딩 상태와 에러 처리를 고급지게(?) 처리할 수 있다. 단 몇가지 세팅이 필요하다.
  • 아직 프로젝트 전체를 최적화한 것이 아니라 유의미한 결과로 나오지는 않았지만 이번주는 진득하게 코드 스플리팅 위주로 결과를 뽑아볼 듯 하다.

reference


https://velog.io/@k_ddaddi/코드-스플리팅-Code-Splitting

https://velog.io/@wildcatco/비동기-데이터-패칭의-로딩-에러-상태-분리하기-with-Tanstack-Query-Suspense-Error-Boundary

https://maxkim-j.github.io/posts/suspense-argibraic-effect/

profile
웹 개발자 고기호입니다.

0개의 댓글