Front End/React

Concurrent Feature (`React.lazy()`, `Suspense`)

React는 현재도 계속 업데이트가 되고 있는 오픈소스 라이브러리이며, 다양한 기능을 추가적으로 지원하고 있다. Hooks 역시 함수형 컴포넌트를 지원하면서 클래스형 컴포넌트에서 사용 가능했던 기능을 추가한 사례라 볼 수 있다.

// React 18 버전부터는 경고문을 출력하는 기존 방식
const rootElement = document.getElementById("root");
ReactDOM.render(<AppTest />, rootElement);

// React 18 버전부터는 createRoot API를 사용한다
import { createRoot } from "react-dom/client";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
    <App />
);

React 18 버전부터 추가된 부분 중 주목할만한 기능은 Concurrent Feature이다. 지금까지는 Concurrent Mode라고 명명되었지만 이번에 Concurrent Feature로 바뀌면서 하나의 기능으로 들어오게 되었다.

코드 분할 (Code Spliting)

대부분의 React 앱들은 Webpack, Rollup 과 같은 툴을 사용해 번들링한다. 이렇게 하면 HTML 웹페이지에 JavaScript를 쉽게 추가할 수 있기 때문이다. 번들된 앱은 모든 JavaScript가 한 곳에 있기 때문에 페이지를 설정하는 데 필요한 호출 수가 적은 링크 태그 하나만 필요로 하게 된다.

하지만 모던 웹으로 발전하면서 점점 DOM을 다루는 정도가 정교해지며 JavaScript 코드 자체가 방대해지고 무거워졌고, 이는 번들링을 하게 되면 특정 지점에서 코드를 해석하고 실행하는 것이 느려지는 결과를 가져오게 되었다. 이를 해결하기 위한 방법으로 제시된 것이 코드 분할이다.

코드 분할의 핵심 아이디어는 ‘어떤 페이지에서 코드를 해석하고 실행하는 정도가 느려졌는지 파악해서 번들을 나눈 후 지금 필요한 코드만 불러오고 나중에 필요한 코드는 나중에 불러올 수 있지 않을까?’ 에서 시작되었다. 즉, 번들이 거대해 지는 것을 막기 위해 물리적으로 나누는 것이다.

코드 분할은 런타임 시 여러 번들을 동적으로 만들고 불러오는 것으로, Webpack이나 Rollup과 같은 번들러가 지원하는 기능이다. 따라서 코드 분할을 하게 되면 지금 당장 필요한 코드가 아니면 따로 분리시키고 나중에 필요할 때 불러와서 사용할 수 있다. 이를 통해 대규모 프로젝트의 앱인 경우에도 페이지 로딩 속도를 개선할 수 있게 된다.

번들 분할 혹은 줄이는 법

서드파티 라이브러리는 기본적으로 사용자에게 다양한 메서드를 제공하기 때문에 코드의 양이 많고, 번들링 시 많은 공간을 차지한다. 따라서 사용중인 라이브러리를 전부 불러와서 사용하는 것 보다 필요한 코드만 따로 따로 불러와서 사용하는 것이 좋다.

// 이렇게 lodash 라이브러리를 전체를 불러와서 
// 그 안에 들은 메소드를 꺼내 쓰는 것은 비효율적이다.
import _ from 'lodash';

_.find([]);

// 이렇게 lodash의 메소드 중 하나를 불러와 쓰는 것이 성능에 더 도움이 된다.
import find from 'lodash/find';

find([]);

React에서의 코드 분할

React는 SPA인데, 사용하지 않는 모든 컴포넌트까지 한 번에 불러오기 때문에 첫 화면이 렌더링 될 때까지의 시간이 오래 걸린다. 따라서 사용하지 않는 컴포넌트는 나중에 불러오기 위해 코드 분할 개념을 도입했다. React에서 코드 분할을 하는 방법은 dynamic import(동적 불러오기)를 사용하는 것이다.

Static Import

기존에는 항상 import구문을 문서의 상위에 위치해야 했고, 블록문 안에서는 위치할 수 없는 제약 사항이 있었다. 번들링 시 코드 구조를 분석해 모듈을 한 데 모으고 사용하지 않는 모듈은 제거하는 등의 작업을 해야 하는데, 코드 구조가 간단하고 고정이 되어 있을 때만 이 작업이 가능해지기 때문이다.

// 기존에는 파일의 최상위에서 import 지시자를 이용해 라이브러리 및 파일을 불러오고
import moduleA from "library";

form.addEventListener("submit", e => {
  e.preventDefault();
  someFunction();
});

const someFunction = () => {
	// 코드 중간에서 불러온 파일을 사용했다.
  moduleA();
}

Dynamic Import

하지만 이제는 구문 분석 및 컴파일해야 하는 스크립트의 양을 최소화 시키기 위해 dynamic import 구문을 지원한다. then 함수를 이용해 필요한 코드를 가져오는 방식이며, React.lazy와 함께 사용할 수 있다.

form.addEventListener("submit", e => {
  e.preventDefault();
	// 동적 불러오기는 이런 형태로 코드의 중간에 불러올 수 있게 된다.
  import('library.moduleA')
    .then(module => module.default)
    .then(someFunction())
    .catch(handleError());
});

const someFunction = () => {
  // moduleA를 여기서 사용한다.
}

이런 식으로 사용하게 되면 불러온 moduleA가 다른 곳에서 사용되지 않는 경우, 사용자가 form을 통해 양식을 제출한 경우에만 가져오도록 할 수 있다.

React.lazy()

React.lazy 함수를 사용하면 dynamic import를 사용해 컴포넌트를 렌더링 할 수 있다. 컴포넌트를 동적으로 import 할 수 있기 때문에 SPA의 단점인 사용하지 않는 컴포넌트까지 불러와서 초기 렌더링 지연시간이 긴 것을 어느정도 해결할 수 있다. 단, React.lazy로 감싼 컴포넌트는 단독으로 쓰일 수 없고 React.suspense 컴포넌트의 하위에서 렌더링 해야한다.

// 기존의 static import
import Component from './Component';

// React.lazy로 dynamin import를 감싼다.
const Component = React.lazy(() => import('./Component'));

React.Suspense

Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 화면을 보여주고, 로딩이 완료되면 렌더링이 준비된 컴포넌트를 보여주는 기능이다. 보통 Router로 분기가 나누어진 컴포넌트에 적용할때 많이 사용한다.

// suspense 기능을 사용하기 위해서는 import 해야한다.
import { Suspense } from 'react';

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

function MyComponent() {
  return (
    <div>
			{// 이런 식으로 React.lazy로 감싼 컴포넌트를 Suspense 컴포넌트의 하위에 렌더링한다.}
			{// 이 때, fallback 속성에는 로딩 시 보여질 컴포넌트가 들어간다.}
      <Suspense fallback={<div>Loading...</div>}>
				{// Suspense 컴포넌트 하위에 여러 개의 lazy 컴포넌트를 렌더링시킬 수 있다.}
        <OtherComponent />
				<AnotherComponent />
      </Suspense>
    </div>
  );
}

React.lazySuspense의 적용

앱에 코드 분할을 도입할 곳을 결정하는 것은 사실 까다롭기 때문에, 중간에 적용시키는 것 보다 웹페이지를 불러오고 진입 단계인 Route에 이 두 기능을 적용시키는 것이 좋다.

import { 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>
);

라우터에 Suspense를 적용하는 것은 비교적 간단하다. 라우터가 분기되는 컴포넌트에서 각 컴포넌트에 React.lazy를 사용해 import하고, Route 컴포넌트를 Suspense로 감싼 후 로딩 화면으로 사용 할 컴포넌트를 fallback 속성으로 설정하면 된다. 이러한 방식은 초기 렌더링 시간이 줄어드는 분명한 장점이 있지만 페이지를 이동하는 과정마다 로딩 화면이 보여지기 때문에 서비스에 따라 적용 여부를 결정하는 것이 좋다.


Uploaded by N2T