React - Code Splitting, Suspense, Lazy

이소라·2022년 8월 8일
0

React

목록 보기
5/23

Code Splitting

  • 애플리케이션이 커질 수록 번들도 커짐

    • 특히, 큰 규모의 3rd party 라이브러리 추가시 실수로 앱이 커져서 로드 시간이 길어질 수 있음
    • 번들이 거대해지는 것을 방지하기 위해서 번들을 나누어야 함
  • 코드 분할(Code-Splitting)

    • 런타임에 여러 번들을 동적으로 만들고 불러오는 것
    • Webpack, Rollup, Browserify와 같은 번들러가 지원하는 기능
    • 애플리케이션을 지연 로딩(laze-loading)하게 도와줌
    • 애플리케이션의 코드 양을 줄이지 않고도 사용자가 필요하지 않은 코드를 불러오지 않게 함
    • 애플리케이션의 초기화 로딩에 필요한 비용을 줄여줌



import()

  • 동적 import() 문법
    • 애플리케이션에 코드 분할을 도입하는 가장 좋은 방법
    • Webpack이 이 구문을 만나면 애플리케이션의 코드를 분할함
    • Babel을 사용할 때는 Babel이 동적 import를 인식하지만 변환하지 않게 하기 위해서 @babel/plugin-syntax-dynamic-import를 사용해야 함
      • @babel/plugin-syntax-dynamic-import : import()의 parse를 허락하는 플러그인
      • ES2020에서는 @babel/preset-env에 이 플러그인이 포함되어 있음
// before
import { add } from './math';

consol.log(add(16, 26));

// after
import("./math").then(math => {
  console.log(math.add(16, 26));
});
  • import() 호출은 promise를 내부적으로 사용함
    • 오래된 브라우저(IE11 등)에서 import()를 사용할 경우, es6-promise나 promise-polyfill과 같은 polyfill을 사용해서 Promise를 지원해야 함
// index.js
function getComponent() {
  return import('lodash')
    .then(({default: _ }) => {
    const element = document.createElement('div');
    element.innerHTML = _.join(['Hello', 'webpack']);
    return element;
  })
    .catch((error) => 'An error occurred when loading the component');
}

getComponent().then((component) => {
  document.body.appendChild(comonent);
});
  • default가 필요한 이유 : webpack 버전 4부터 CommonJS module을 import할 때 더 이상 module.exports의 값을 resolve하지 않고, 그 대신 Common JS module에 대한 인공적인 namespace 객체를 만들기 때문임

  • import()가 promise를 반환하므로 async 함수를 사용할 수 있음

// index.js
async function getComponent() {
  const element = document.createElement('div');
  const { default: _ } = await import('lodash');
  element.innerHTML = _.join(['Hello', 'webpack']);
  return element;
}

getComponent().then((component) => {
  document.body.appendChild(comonent);
});



Suspense & Lazy

비동기 데이터 처리에서의 문제점

  1. 한 페이지의 여러 컴포넌트에서 동시에 비동기 데이터를 읽어오는 경우, UI가 마치 폭포(waterfall)처럼 순차적으로 나타나는 현상이 발생할 수 있음

    • 상위 컴포넌트의 데이터 로딩이 끝난 이후에 하위 컴포넌트의 데이터 로딩이 시작될 수 있기 때문에 이러한 현상이 발생함
  2. 초기 렌더링 이후에 데이터 로딩 후 리렌더링하는 방법은 경쟁 상태에도 취약함

    • 비동기 데이터 통신은 데이터가 반드시 요청한 순서대로 응답된다는 보장이 없기 때문에 의도치 않게 싱크(sync)가 맞지 않는 데이터를 제공할 수도 있음
  • 경쟁 상태 : 공유 자원에 대해 여러 개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태
  1. if 조건문을 사용하여 어떤 컴포넌트를 보여줄지를 결정하는 것은 명령형(imperative) 코드에 가까움
    • 선언형(declarative) 코드를 지향하는 React의 기본 방향성과 맞지 않음
    • 한 컴포넌트 안에 데이터 로딩과 UI 렌더링의 두 로직들이 커플링(coupling)되므로, 코드가 읽기 어려워지고 테스트를 작성하기 힘들어짐

  • 이러한 문제들을 해결하기 위해 React.Suspense를 사용함
    1. Suspense는 모든 요청을 기다리지 않고 화면을 렌더링할 수 있음
      • before : data fetching 요청 -> 로딩중 UI 렌더링 -> data 응답 -> 컴포넌트에 응답 반영
      • after : data fetching 요청 -> Suspense 하위의 컴포넌트에 요청 리소스를 반영 -> Suspense에 의해 로딩 UI 렌더링 -> data 응답 -> 컴포넌트에 응답 반영
      • Suspense는 요청 직후 요청 리소스를 컴포넌트에 직접 주입함
      • 그렇기 때문에 data fetching 요청 직후 응답 도착 여부와는 상관없이 렌더링을 수행함
  • 요청 리소스는 Promise가 아니라 일반 객체임
  1. Suspense는 state 설정 시기를 바꿔서 경쟁 상태 발생을 방지함
    • before : A 프로필 요청 -> 로딩 UI 렌더링 -> A 프로필 응답 -> <Profile />에 응답
    • after : A 프로필 요청 -> <Profile />에 A 프로필 요청 리소스 반영 -> Suspense에 의해 A 프로필 요청에 대한 로딩 UI 렌더링 -> 요청 리소스로 A 프로필 응답 들어옴 -> <Profile />에 응답 반영
    • Suspense가 응답이 언제 오는지 시간에 대한 것을 고려하지 않아도 되므로, 경쟁 상태를 해결할 수 있음
    • data fetching 요청 직후 컴포넌트에 해당 리소스를 반영하므로, 이전에 수행하고 있던 요청이 있더라도 해당 요청은 무시하고 새로운 요청으로 대체됨

React.lazy

  • React.lazy 함수를 사용하면 동적으로 불러오는 컴포넌트를 정의할 수 있음
    • 그러면 번들 크기를 줄이고, 초기 렌더링에서 사용되지 않는 컴포넌트를 불러오는 작업을 지연시킬 수 있음
  • React.lazy 함수는 동적 import()를 호출하는 함수를 인자로 가짐
    • 이 함수는 default export로 가진 모듈 객체가 이행되는 Promise를 반환해야 함
// before
import OtherComponent from './OtherCompoent';

// after
const OtherComponent = React.lazy(() => import('./OtherComponent'));
  • MyComponent가 처음 렌더링될 때, OtherComponent를 포함한 번들이 자동으로 불러와짐
  • lazy한 컴포넌트를 렌더링하려면 렌더링 트리 상위에 <React.Suspense> 컴포넌트가 존재해야 함

React.Suspense

  • React.Suspense를 사용하면 트리 상에 아직 렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 지시기(Loading indicator)를 나타낼 수 있음
import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    // Displays <Spinner> untill OtherComponent loads
    <Suspense fallback={<Spinner />}>
      <div>
        <OtherComponent />
      </div>
    </Suspense>
  );
}
  • lazy한 컴포넌트는 Suspense 트리 내의 깊숙한 곳에 위치할 수 있음
    • Suspense가 모든 컴포넌트를 감쌀 필요가 없음
    • 로딩 지시기를 보여주고 싶은 지점에 <Suspense>를 작성하는 것이 가장 좋음
      • Suspense 트리 내에서 code-splitting을 하고 싶은 어디 곳에서나 lazy()를 사용해도 됨
  • Suspense는 element들이 모두 지연로드(laze-loading)된 경우에도 로드에서 element들을 일시 중단할 수 있음
    • 이는 단일 로드 상태만 표시하면서 여러 element들의 렌더링을 지연시키는 유용한 방법임
    • 모든 element들을 가져온 이후, 사용자에게 동시에 모든 element를 보여줌
    • 이 방법을 통해 UI의 element들이 각각 다른 로딩 지시기를 갖고 있을 때 차례대로 렌더링될 경우 발생하는 경쟁 상태를 방지함
import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<Spinner />}>
      <section>
        <OtherComponent />
        <AnotherComponent />
      </section>
    </Suspense>
  );
}

주의

  • React.lazyReact.Suspense는 아직 ReactDOMServer에서 지원하지 않고, 아직 서버 사이드 렌더링을 할 수 없음
    • 서버에서 렌더링하는 애플리케이션의 코드를 분할 하고 싶다면 Loadable Components를 사용하길 추천함

Error Boundaries

  • 네트워크 장애 같은 이유로 모듈 로드에 실패할 경우 에러를 처리하기 위해 Error BoundarySuspense를 사용함
    • 에러 발생을 감지하고자 하는 컴포넌트들을 Error BoundarySuspense로 래핑함
import React, { Suspense } from 'react';
import MyErrorBoundary from './MyErrorBoundary';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);
// fetch API를 모방한 예시
export function fetchProfileData() {
  let userPromise = fetchUser(); // 프로미스를 리턴
  let postsPromise = fetchPosts();
  return {
    user: wrapPromise(userPromise),
    posts: wrapPromise(postsPromise)
  };
}

function wrapPromise(promise) {
  let status = "pending"; // 최초의 상태
  let result;
  
  // 프로미스 객체 자체
  let suspender = promise.then(
    (r) => {
      status = "success"; // 성공으로 완결시 success로
      result = r;
    },
    (e) => {
      status = "error"; // 실패로 완결시 error로
      result = e;
    }
  );
  // 위 함수의 로직을 클로저 삼아, 함수 밖에서 프로미스의 진행 상황을 읽는 인터페이스가 됨
  return {
    read() {
      if (status === "pending") {
          throw suspender; // 펜딩 프로미스를 throw 하면 Suspense의 Fallback UI를 보여줌
        } else if (status === "error") {
          throw result; // Error을 throw하는 경우 ErrorBoundary의 Fallback UI를 보여줌
        } else if (status === "success") {
          return result; // 결과값을 리턴하는 경우 성공 UI를 보여줌
      }
    }
  };
}

Route-based code splitting

  1. Route를 기준으로 코드 분할하기
    • 애플리케이션에서 코드 분할을 도입하기 좋은 곳은 Route임
    • 웹 페이지를 불러오는 시간은 페이지 전환에 어느 정도 발생하고 대부분 페이지를 한번에 렌더링하기 때문에, 사용자가 페이지를 렌더링하는 동안 다른 요소와 상호 작용하지 않음
import React, { Suspense, lazy } from 'react';
import {BrowserRouter, as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => {
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
} 
  1. 특정 사용자 상호작용(예: button click)에서만 렌더링되는 페이지의 큰 구성요소를 식별하여 분할하기
    • 이렇게 할 경우, JavaScript payload(전송되는 데이터)가 최소화됨
  2. 화면 밖에 있고 사용자에게 중요하지 않은 것을 모두 분할하는 것을 고려하기

Named Exports

  • React.lazy는 현재 default exports만 지원함
  • named exports를 사용하고자 한다면 default로 이름을 재정의한 중간 모듈을 생성할 수 있음
    • 이렇게 할 경우 tree shaking이 계속 동작하고 사용하지 않는 컴포넌트를 가져오지 않음
// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;

// MyComponent.js
export { MyComponent as default } from './ManyComponents.js';

// MyApp.js
import React, { lazy } from 'react';
const MyComponent = lazy(() => import('./MyComponent.js'));

Suspense와 함께 사용되는 Data Fetching 라이브러리

  • Relay : graphQL에 의존적인 data fetching 라이브러리 (실무에서 Suspense와 같이 사용됨)
  • SWR : data fetching을 위한 React 훅 라이브러리
  • React Query : React Application에서 서버 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트하는 작업을 도와주는 라이브러리
  • Recoil : React를 위한 상태관리 라이브러리

참고

0개의 댓글