React Suspense와 lazy Loading

이수빈·2023년 3월 8일
10
post-thumbnail
  • 컴포넌트 또는 비동기상태의 로딩상태를 선언적으로 처리 할 Suspense와

  • Suspense와 함께 컴포넌트를 동적으로 import하는 방식으로 번들링 사이즈를 줄여 초기 페이지 로드 감소시킨 lazy loading을 프로젝트에 적용한 부분을 작성하려고 한다.

Suspense란?

  • Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을때 로딩 화면을 보여주고 로딩이 완료되면 해당 컴포넌트를 보여주는 React에 내장되어 있는 기능이다.

  • Suspense를 사용하는 이유는 다음과 같다. 먼저 code spliting을 통해 필요할때 동적으로 컴포넌트를 import하는 lazy loading 방식을 Suspense와 함께 구현 할 수 있다.

  • 또한 Suspense를 통해 컴포넌트나 비동기 관련 로직상태를 명령형 방식이 아닌 선언적인 방식으로 처리 할 수 있다.


Suspense의 동작원리?

  • 비동기 관련 로딩상태를 처리할때, Suspense는 프로미스 기반으로 작동한다.

  • Suspense가 비동기를 처리하는 동작원리를 자세히 보기 위해 React 팀에서 발표한 예시코드인 wrapPromise.js를 확인해보자.


//wrapPromise.js
function wrapPromise(promise) {
  let status = 'pending'
  let response

  const suspender = promise.then(
    (res) => {
      status = 'success'
      response = res
    },
    (err) => {
      status = 'error'
      response = err
    },
  )

  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender
      case 'error':
        throw response
      default:
        return response
    }
  }

  return { read }
}

export default wrapPromise
  • wrapPromise는 promise를 파라미터로 받는다. 또한 Promise에서 반환되는 데이터를 읽을 준비가 되었는지 확인할 수 있는 read라는 메서드를 제공한다.

  • 기본적으로 promise는 pending(이행대기) 상태이다. promise는 pending, fullfilled(success) , rejected(error) 3가지 상태를 갖는다.

  • suspender라는 변수는 파라미터로 받은 promise 결과에 따라 stats와 response를 바꾼다.

  • read함수는 promise의 상태가 pending이나 error라면, promise를 상위로 throw하고, 던진 promise를 받아 fallback UI를 보여준다.

  • 만약 promise가 success 상태라면 성공한 response(data)를 return 한다.

  • 실제 wrapPromise를 사용한 코드 예시를 보자.


//fetchData.js

import wrapPromise from './wrapPromise'

function fetchData(url) {
  const promise = fetch(url)
    .then((res) => res.json())
    .then((res) => res.data)

  return wrapPromise(promise)
}

export default fetchData


//UserWelcome.jsx

import React from 'react'
import fetchData from '../api/fetchData'

const resource = fetchData(
  'https://run.mocky.io/v3/d6ac91ac-6dab-4ff0-a08e-9348d7deed51'
)

const UserWelcome = () => {
  const userDetails = resource.read()

  return (
    <div>
      <p>
        Welcome <span className="user-name">{userDetails.name}</span>, here are
        your Todos for today
      </p>
      <small>Completed todos have a line through them</small>
    </div>
  )
}

export default UserWelcome
  • fetchData라는 컴포넌트는 data를 fetching 하는 proise를 wrapPromise로 감싼 형태이다.

  • 기본적으로 컴포넌트가 mount될때 fetchData의 read를 호출하게 된다.

  • 비동기 로직이기 때문에, 이때 promise가 정확히 어떤 상태인지 확신 할 수 없다.

  • 만약 promise가 success 상태라면 응답된 데이터를 return받고, promise가 pending이나 rejected된 상태라면 상위 컴포넌트로 promise를 던진다.

  • 이후 promise가 fullfilled되는 로직은 Suspense 코드를 보면 알 수 있다.


import React from "react";

export interface SuspenseProps {
  fallback: React.ReactNode;
}

interface SuspenseState {
  pending: boolean;
  error?: any;
}

function isPromise(i: any): i is Promise<any> {
  return i && typeof i.then === "function";
}

export default class Suspense extends React.Component<
  SuspenseProps,
  SuspenseState
> {
  public state: SuspenseState = {
    pending: false
  };

  public componentDidCatch(catchedPromise: any) {
    if (isPromise(catchedPromise)) {
      this.setState({ pending: true });

      catchedPromise
        .then(() => {
          this.setState({ pending: false });
        })
        .catch((err) => {
          this.setState({ error: err || new Error("Suspense Error") });
        });
    } else {
      throw catchedPromise;
    }
  }

  public componentDidUpdate() {
    if (this.state.pending && this.state.error) {
      throw this.state.error;
    }
  }

  public render() {
    return this.state.pending ? this.props.fallback : this.props.children;
  }
}
  • componentDidCatch 메소드에서 Suspense로 감싼 하위 컴포넌트에서 던진 promise를 catch한다.

  • 던저진 초기 promise는 pending중인 상태로 간주한다.

  • promise 체이닝을 통해 만약 프로미스가 이행되었다면, Suspense 컴포넌트의 pending 상태를 false로 변환한다.

  • 만약 Error가 발생했다면, Suspense Error을 상위로 던진다. 이는 Error Boundary를 통해 처리해야한다.

  • promise가 pending 상태라면 fallback으로 받은 컴포넌트를 render하고 fullfilled 라면 하위 컴포넌트(children)을 렌더한다.

Lazy Loading이란?

  • 초기에 페이지에 접속하면 웹팩으로부터 번들링 된 js 파일을 받는다.

  • 이때 번들링에는 모든 컴포넌트가 포함되있다. 여기에 데이터 fetching까지 이뤄진다면, 사용자가 초기에 웹페이지를 보는 TTV의 시점이 매우 늦어지게 된다.

  • lazy loading은 코드를 분할해서 모든 컴포넌트가 번들링 된 파일을 받는것이 아니라 필요할때 동적으로 컴포넌트를 불러오는 기법이다.

  • 컴포넌트를 동적으로 import 하려면 promise의 상태를 처리해야하기 때문에 Suspense 내부에서 사용해야한다.

  • CSR의 고질적인 문제점인 TTV가 일어나는 시점이 오래걸리는 문제를 해결해준다.

  • 페이지를 동적으로 import 할때, Suspense가 이를 감지해 fallback 컴포넌트를 대신 보여준다.

Suspense, lazy loading 적용 코드

  • 코드를 적용하는 부분은 간단하지만, 어떻게 동작하고 왜 사용하는지를 이해하는 것이 어려웠던 것 같다.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import ErrorBoundary from './components/ErrorBoundary';
import Spinner from './components/Spinner';
import NotFound from './pages/NotFound/NotFound';

const Admin = lazy(() => import('./pages/Admin/Admin'));
const FoodDetail = lazy(() => import('./pages/FoodDetail/FoodDetailMain'));
const FoodList = lazy(() => import('./pages/FoodList/FoodList'));
const Login = lazy(() => import('./pages/Login/Login'));
const MainPage = lazy(() => import('./pages/MainPage/MainPage'));
const MyPage = lazy(() => import('./pages/MyPage/MyPage'));
const Register = lazy(() => import('./pages/Register/Register'));

const Router = () => {
  return (
    <BrowserRouter>
      <ErrorBoundary>
        <Suspense fallback={<Spinner />}>
          <Routes>
            <Route path="/" element={<MainPage />} />
            <Route path="/login" element={<Login />} />
            <Route path="/register" element={<Register />} />
            <Route path="/foodList" element={<FoodList />} />
            <Route path="/foodList/:id" element={<FoodDetail />} />
            <Route path="/admin" element={<Admin />} />
            <Route path="/mypage" element={<MyPage />} />
            <Route path="*" element={<NotFound />} />
          </Routes>
        </Suspense>
      </ErrorBoundary>
    </BrowserRouter>
  );
};

export default Router;

lazy loading 이전 이후 페이지 로드시간 비교

lazy loading 이전

  • lazy loading 적용 이전 데이터들을 분석해보면 다음과 같다.

  • 번들링사이즈(Resource)는 8.7MB이고, transfer Size는 1.5 MB로 측정되었다.

  • resource size와 transfer size는 웹의 성능에 직접적으로 영향을 준다.

  • 여기서 resource size는 네트워크와 관계없이 리소스를 구성하는 총 용량을 나타낸다.

  • transfer Size는 네트워크를 통한 전송에 필요한 총 바이트 수를 나타낸다. 일반적으로 transfer Size가 클수록 네트워크 전송에 더 오랜 시간이 걸린다.

  • 네트워크 탭에서 밑에 DomContentLoaded, Load, Finish 라는 시간이 존재한다.

  • DomContentLoaded는 HTML을 파싱하여 DOM Tree 구조를 그리는데까지 걸리는 시간이다.

  • Load는 Dom Tree구조를 포함, 이미지까지 화면에 로드되는 시간이다. 즉 사용자가 처음 화면을 보는 시간이라고 할 수 있다.

  • 로드까지 걸린 시간은 848ms이다.

lazy loading 이후

  • 번들링 사이즈는 4.0MB transfer Size는 797kB로 측정되었다.

  • 로드까지 걸린시간은 626ms이다.

  • lazy loading 적용전과 비교해봤을때, 번들링사이즈는 약 절반가까이 줄었고(8.7 => 4.0MB), 페이지 로드 시간이 약 27% 감소했다.

  • 웹 어플리케이션의 규모가 크면 클수록, lazy loading을 적용하면 초기 페이지 로드속도를 크게 항샹 시킬 수 있을 것이다.

ref)
React 공식문서: https://ko.reactjs.org/docs/react-api.html#reactsuspense
Suspense 동작원리 :
https://blog.logrocket.com/react-suspense-data-fetching/
https://velog.io/@seeh_h/suspense%EC%9D%98-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC#suspense-%EC%9B%90%EB%A6%AC%EC%99%80-%EC%A7%81%EC%A0%91-%EA%B5%AC%ED%98%84%ED%95%B4%EB%B3%B4%EA%B8%B0

Transfer, Resource Size : https://www.webperf.tips/tip/resource-size-vs-transfer-size/

네트워크 : https://velog.io/@te-ing/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%8F%84%EA%B5%AC-Network%ED%83%AD-%EC%B4%9D%EC%A0%95%EB%A6%AC

profile
응애 나 애기 개발자

2개의 댓글

comment-user-thumbnail
2024년 8월 23일

안녕하세요. 글 잘 읽었습니다. 캡쳐해주신 네트워크 화면에서 번들링 사이즈는 어디서 확인할 수 있나요?

1개의 답글