React는 현재도 계속해서 업데이트가 되고 있는 오픈소스 라이브러리입니다. 이번에 React가 버전 18로 업데이트가 되면서 많은 부분이 바뀌었는데, 가장 빠르게 알아볼 수 있는 변화는 콘솔 창에 이전에 보이지 않던 경고문이 보인다는 점입니다.
해당 경고문은 이제 React 18 버전은 더 이상 ReactDOM.render를 지원하지 않는다는 내용입니다. 물론 버전이 18보다 낮은 앱에서는 해당 경고문은 보이지 않습니다. 그러나 계속해서 업데이트가 되고 있는 라이브러리이니만큼 해당 이슈는 알아둬야 할 것입니다. 언제까지 18 버전 이하만 사용할 일은 없을 것이고, 18 버전을 쓰는데도 지원하지 않는 이전의 문법을 사용해서는 안 되기 때문입니다.
const rootElement = document.getElementById("root");
ReactDOM.render(<AppTest />, rootElement);
저런 경고문이 보이는 이유는 React 18에서는 이제 createRoot API를 사용하기 때문이기 때문입니다. React 18에서 생긴 자동 배칭 또한 이 createRoot API를 사용해야 합니다.
import { createRoot } from "react-dom/client";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(
<App />
);
그중 주목할 부분은 Concurrent Feature입니다. 지금까지는 Concurrent Mode라고 명명되었지만 이번에 Concurrent Feature로 바뀌면서 하나의 기능으로 들어오게 되었습니다. 그중에는 Suspense 기능이 있는데, React 18 버전부터는 이 Suspense 기능을 이용해 독립적으로 렌더링을 할 수 있도록 했습니다.
대부분 React 앱들은 Webpack, Rollup과 같은 툴을 사용해 번들링(Bundling)합니다. 이렇게 하면 HTML 웹 페이지에 JavaScript를 쉽게 추가할 수 있기 때문입니다. 번들된 앱은 모든 JavaScript가 한 곳에 있기 때문에 페이지를 설정하는 데 필요한 호출 수가 적은 링크 태그 하나만 필요하게 됩니다. 과거에는 이렇게 해도 무리가 없었습니다. 모던 웹 이전의 웹 JavaScript 코드는 최소한의 수준으로 작성되었기 때문입니다.
그러나 이제는 번들링하게 되면 특정 지점에서 코드를 해석하고 실행하는 정도가 느려지게 되었습니다. 모던 웹으로 발전하면서 점점 DOM을 다루는 정도가 정교해지며 JavaScript 코드 자체가 방대해지고 무거워졌기 때문입니다.
“그렇다면 어느 페이지에서 코드를 해석하고 실행하는 정도가 느려졌는지 파악해서 번들을 나눈 뒤에 지금 필요한 코드만 불러오고 나중에 필요한 코드는 나중에 불러올 수 있지 않을까??”
이것이 코드 분할의 핵심 아이디어입니다. 번들이 거대해지는 것을 방지하기 위한 좋은 해결 방법은 번들을 물리적으로 나누는 것입니다. 코드 분할은 런타임 시 여러 번들을 동적으로 만들고 불러오는 것으로, Webpack, Rollup과 같은 번들러가 지원하는 기능입니다.
따라서 코드 분할을 하게 되면 지금 당장 필요한 코드가 아니라면 따로 분리를 시키고, 나중에 필요할 때 불러와서 사용할 수 있습니다. 이를 통하여 대규모 프로젝트의 앱인 경우에도 페이지의 로딩 속도를 개선할 수 있게 됩니다.
번들링 되는 파일에는 여러분들이 앱을 만들면서 npm을 통해 다운로드하는 서드파티(Third Party) 라이브러리도 포함이 됩니다. 서드파티 라이브러리는 개인 개발자나 프로젝트 팀, 혹은 업체 등에서 개발하는 라이브러리로, 즉 제3자 라이브러리입니다. 서드파티 라이브러리는 플러그인이나 라이브러리 또는 프레임워크 등이 존재하며, 이 라이브러리를 잘 사용하면 편하고 효율적인 개발을 할 수 있습니다.
그러나 서드파티 라이브러리는 사용자에게 다양한 메서드를 제공하기 때문에 코드의 양이 많고, 번들링 시 많은 공간을 차지합니다. 따라서 사용 중인 라이브러리의 전부를 불러와서 사용하는 것보다 따로따로 불러와서 사용할 수 있다면 많은 공간을 차지하지 않을 수 있게 됩니다.
/* 이렇게 lodash 라이브러리를 전체를 불러와서 그 안에 들은 메서드를 꺼내 쓰는 것은 비효율적입니다.*/
import _ from 'lodash';
...
_.find([]);
/* 이렇게 lodash의 메서드 중 하나를 불러와 쓰는 것이 앱의 성능에 더 좋습니다.*/
import find from 'lodash/find';
find([]);
해당 코드는 lodash라는 라이브러리를 예시로 하고 있습니다. 섹션2에서 underbar 과제를 했던 것 기억나시나요? 그 과제는 lodash 라이브러리와 비슷하게 구현하는 과제였습니다. lodash는 배열, 숫자, 객체, 문자열을 사용할 때 반복적인 작업 같은 것을 할 시 사용하기에 좋은 라이브러리입니다.
lodash라는 라이브러리는 하나의 폴더와 같고, 그 폴더 안에는 개발 시 다양한 상황에 쓰기 좋은 메서드들, 즉 함수 코드들이 들어 있습니다. 이 함수 코드들의 양이 상당하기 때문에 전부 가져올 시, 정말로 필요한 것 한두 개만 쓰인다면 나머지는 그냥 쓰이지 않는 코드 뭉치로 앱 내부에 남게 됩니다. 이는 앱의 성능을 저하시킬 요지가 있기 때문에, 필요한 것 한두 개만 가져다 쓰는 식으로 개발하는 것이 훨씬 좋습니다.
React는 SPA(Single-Page-Application)인데, 사용하지 않는 모든 컴포넌트까지 한 번에 불러오기 때문에 첫 화면이 렌더링 될 때까지의 시간이 오래 걸립니다. 그래서 사용하지 않는 컴포넌트는 나중에 불러오기 위해 코드 분할 개념을 도입했습니다.
React에서 코드 분할하는 방법은 dynamic import(동적 불러오기)를 사용하는 것입니다. 그전까지는 코드 파일의 가장 최상위에서 import 지시자를 사용해 사용하고자 하는 라이브러리 및 파일을 불러오는 방법을 사용했었습니다. 이를 static import(정적 불러오기)라고 합니다.
/* 기존에는 파일의 최상위에서 import 지시자를 이용해 라이브러리 및 파일을 불러왔습니다. */
import moduleA from "library";
form.addEventListener("submit", e => {
e.preventDefault();
someFunction();
});
const someFunction = () => {
/* 그리고 코드 중간에서 불러온 파일을 사용했습니다. */
}
기존에는 항상 import 구문은 문서의 상위에 위치해야 했고, 블록문 안에서는 위치할 수 없는 제약 사항이 있었습니다. 왜냐하면 번들링 시 코드 구조를 분석해 모듈을 한 데 모으고 사용하지 않는 모듈은 제거하는 등의 작업을 하는데, 코드 구조가 간단하고 고정이 되어 있을 때에야만 이 작업이 가능해지기 때문이었습니다.
그러나 이제는 구문 분석 및 컴파일해야 하는 스크립트의 양을 최소화하기 위해 dynamic import 구문을 지원합니다.
form.addEventListener("submit", e => {
e.preventDefault();
/* 동적 불러오기는 이런 식으로 코드의 중간에 불러올 수 있게 됩니다. */
import('library.moduleA')
.then(module => module.default)
.then(someFunction())
.catch(handleError());
});
const someFunction = () => {
/* moduleA를 여기서 사용합니다. */
}
이런 식으로 dynamic import를 사용하게 되면 불러온 moduleA 가 다른 곳에서 사용되지 않는 경우, 사용자가 form을 통해 양식을 제출한 경우에만 가져오도록 할 수 있습니다.
dynamic import는 then 함수를 사용해 필요한 코드만 가져옵니다. 가져온 코드에 대한 모든 호출은 해당 함수 내부에 있어야 합니다. 이 방식을 사용하면 번들링 시 분할된 코드(청크)를 지연 로딩시키거나 요청 시에 로딩할 수 있습니다.
이 dynamic import는 React.lazy 와 함께 사용할 수 있습니다.
React.lazy 함수를 사용하면 dynamic import를 사용해 컴포넌트를 렌더링할 수 있습니다. React는 SPA(Single-Page-Application)이므로 한 번에 사용하지 않는 컴포넌트까지 불러오는 단점이 있다고 앞의 챕터에서 학습했습니다. React는 React.lazy를 통해 컴포넌트를 동적으로 import를 할 수 있기 때문에 이를 사용하면 초기 렌더링 지연시간을 어느 정도 줄일 수 있게 됩니다.
import Component from './Component';
/* React.lazy로 dynamic import를 감쌉니다. */
const Component = React.lazy(() => import('./Component'));
이 React.lazy로 감싼 컴포넌트는 단독으로 쓰일 수는 없고, React.suspense 컴포넌트의 하위에서 렌더링을 해야 합니다.
Router로 분기가 나누어진 컴포넌트들을 위 코드처럼 lazy를 통해 import하면 해당 path로 이동할 때 컴포넌트를 불러오게 되는데 이 과정에서 로딩하는 시간이 생기게 됩니다. Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 화면을 보여주고, 로딩이 완료되면 렌더링이 준비된 컴포넌트를 보여주는 기능입니다.
/* suspense 기능을 사용하기 위해서는 import 해와야 합니다. */
import { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
function MyComponent() {
return (
<div>
{/* 이런 식으로 React.lazy로 감싼 컴포넌트를 Suspense 컴포넌트의 하위에 렌더링합니다. */}
<Suspense fallback={<div>Loading...</div>}>
{/* Suspense 컴포넌트 하위에 여러 개의 lazy 컴포넌트를 렌더링시킬 수 있습니다. */}
<OtherComponent />
<AnotherComponent />
</Suspense>
</div>
);
}
Supense 컴포넌트의 fallback prop은 컴포넌트가 로드될 때까지 기다리는 동안 로딩 화면으로 보여줄 React 엘리먼트를 받아들입니다. Suspense 컴포넌트 하나로 여러 개의 lazy 컴포넌트를 보여줄 수도 있습니다.
앱에 코드 분할을 도입할 곳을 결정하는 것은 사실 까다롭기 때문에, 중간에 적용시키는 것보다는 웹 페이지를 불러오고 진입하는 단계인 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 속성으로 설정해 주면 됩니다. 초기 렌더링 시간이 줄어드는 분명한 장점이 있으나 페이지를 이동하는 과정마다 로딩 화면이 보이기 때문에 서비스에 따라서 적용 여부를 결정해야 합니다.