본문 바로가기
Language/React

[React 공식 Advanced Doc] Code-Splitting

by ocwokocw 2021. 2. 11.

- 이 글은 React 공식 홈페이지 Docs v16.8.3 (https://reactjs.org/docs) 에 기반하여 작성한 글입니다.

- Bundling

대부분의 React app들은 Webpack 이나 Browserify 같은 tool 을 이용한 bundling된 파일들로 구성되어 있다. 여기서 bundling 이란 import 된 파일들을 1개의 파일로 합치는 프로세스를 말한다. 그리고 이때 생성된 1개의 파일을 "bundle" 이라 한다. 이렇게 "bundle" 이 만들어 지게 되면 webpage 에서 전체 app을 한번에 로딩될 수 있다.

 

import { add } from './math.js';

console.log(add(16, 26));
export function add(a, b) {
  return a + b;
}

 

위와 같이 1개의 export 된 함수와 이를 import 해서 log를 찍는 App이 있다고 할 때 bundle 을 만들면 아래와 같은 형태가 된다.

 

function add(a, b) {
  return a + b;
}

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

 

만약 Create React App이나 Next.js, Gatsby 혹은 이와 비슷한 tool을 사용했다면, 만든 app을 bundle로 만들기 위해서 Webpack 이 설치되어 있을 것이다. 이와 같은 tool 을 이용하지 않았다면 Webpack 의 가이드 및 설치 문서를 읽고 직접 bundling을 구성해야 한다. 


- Code Splitting

Bundling 을 하는건 이점이 많지만 조금만 생각해보면 의문이 생긴다. 대규모 Project 라면 app의 크기가 상당할 것이고, 그렇다면 만들어 지는 bundle의 크기는 1번에 import 하기 너무 크지 않을까라는 의문이 자연스럽게 들 것이다. 특히 만약 다른 라이브러리들까지 이용한다면 app이 생성된 bundle을 load할 때 너무 많은 시간이 걸리지 않는지 주의할 필요가 있다.

 

bundle이 너무 커지는 현상을 막기 위해서 문제가 발생하기 전에 분할하는 것이 좋다. Code-Splitting은 이미 Webpack 이나 Browserify 같은 bundler 들에서 제공하는 기능이다. Code-Splitting을 이용하면 여러개의 bundle 파일들을 실행시점에 로딩할 수 있다.

 

우리가 만든 app 이 한꺼번에 loading 되는게 아니라 "lazy-load"(이 챕터에서 뿐만 아니라 프로그래밍에 있어 일반적인 용어이다.)되어 유저가 현재 필요한 부분들만 로딩하므로 상당한 속도개선이 될 수 있다.

 

여러개로 분할된 파일들의 loading 시간의 총합이 1개의 파일을 로딩한 시간보다 적어지는건 아니다. 하지만 User가 사이트에 접속할 때 초기 로딩시간을 줄여주고, 만약 유저가 필요한 부분의 code가 아니라면 해당 부분의 파일을 로딩하지 않음으로써 속도 향상을 꾀할 수 있다.


- import()

code-splitting을 쓰기 가장 좋은 방법은 dynamic import() 구문을 사용하는 것이다.

Before:

 

import { add } from './math.js';

clickButton() {
	let addResult = add(16, 26);
	this.setState({test: addResult});
}

After:

 

clickButton() {
	import('./math.js').then((math) => {
		let addResult = math.add(16, 26);
		this.setState({test: addResult});
	});
}

위의 화면을 보려면 개발자도구를 키고 네트워크 탭을 클릭하면 된다. Before 에서는 2.chunk.js 하나만 다운로드를 받는데, dynamic import() 구문으로 변경하고 나서는 0.chunk.js를 다운받고 버튼을 클릭할 때 1.chunk.js를 한번 더 다운받는다.

 

이로써 dynamic import() 구문을 사용하면 "bundle"로써 1개의 파일을 한꺼번에 다운받는게 아니라 필요한 시점에 일부 파일을 나눠서 다운받는걸 확인할 수 있다. (주의: dynamic import() 구문은 ECMAScript 이긴 하지만 언어 표준은 아니다.)

 

Webpack 이 해당 구문을 만나면 code-splitting을 자동으로 시작한다. 만약 Create React App을 사용해서 React를 시작했다면 이미 설정이 되어 있을 것이다. (Next.js 에서도 지원한다.)

 

Webpack 을 수동설정 할 거라면 Webpack에 가이드가 나와있다.  Babel 은 dynamic import 구문을 파싱할 수는 있지만 변형시키지는 않으므로 플러그인을 설치해 줘야 한다.


- React.lazy

React.lazy 와 Suspense 는 아직 server-side rendering을 지원하지 않는다. 만약 server rendered app 에서 code-splitting을 사용하고 싶다면 Loadable Component를 추천한다.  React.lazy 함수를 사용하면 Component를 동적으로 import 하여 rendering 할 수 있다.

 

Before:

 

import OtherComponent from './OtherComponent';

class TestRender extends React.Component{

	constructor(props){
		super(props);
		
		this.state = {
			isShowOtherComponent: false
		};

		this.showOtherComponent = this.showOtherComponent.bind(this);
		this.loadOtherComponent = this.loadOtherComponent.bind(this);
	}

	showOtherComponent() {
		this.setState({
			isShowOtherComponent: true
		});
	}

	loadOtherComponent(){
		if(this.state.isShowOtherComponent){
			return <OtherComponent />
		}

		return null;
	}

	render() {
		return (
			<div>
				<div>
					<button onClick={this.showOtherComponent}>add other component</button>
				</div>
				{this.loadOtherComponent()}
			</div>
		);
	}
}

 

After:

 

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

loadOtherComponent(){
	if(this.state.isShowOtherComponent){
		return (
			<React.Suspense fallback={<div>Loading ..</div>}>
				<OtherComponent />
			</React.Suspense>
		);
	}

	return null;
}

Suspense는 OtherComponent가 rendering 되는 중간에 대체 콘텐츠를 보여주기 위한 태그이다. fallback attribute에 보여줄 대체 React element를 주면된다. Suspense를 구현하지 않으면 콘솔 에러가 날 것이다. 다음절에서 설명하니 모르겠다면 지금은 그냥 개념만 알아도 괜찮다.

 

코드를 After: 처럼 바꾸고 개발자도구에서 네트워크 탭을 킨 후 비교를 해보면 dynamic import 예제에서와 같이 Component도 lazy loading 할 수 있다는 사실을 알 수 있다.

 

React.lazy 는 반드시 동적으로 import() 를 호출해야 한다. 그리고 호출된 import는 default export 되는 React component 모듈의 Promise 를 반환해야 한다. 아래에서도 나오겠지만 현재 React.lazy는 default export만 지원하기 때문에 export {} 형태의 모듈은 import 할 수 없다. 주의하자.

 

Promise를 반환하는지 눈으로 확인해 보자.

여러 속성중 의미를 알 수 없는 프로퍼티가 있는데 _ctor() 이라는 함수를 호출하면 Promise를 반환하고, __proto__를 펼치면 then 함수가 보일 것이다. 공식문서의 설명이 맞는 것 같다.


- Suspense

만약 OtherComponent를 포함하는 모듈이 아직 MyComponent 가 rendering 될 때까지 로딩되지 않았다면, 로딩중과 같이 로딩되는 동안 보여줄 화면을 마련해야 한다. 이때 Suspense 라는 Component 를 사용함으로써 구현할 수 있다.

구현할 수 있다고 했지만 사실은 구현하지 않으면 console에 에러를 뿜는다. 구현 해야 한다.

 

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

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

 

fallback prop은 Component가 로딩중일 때 rendering할 React element를 인자로 받는다. Suspense component 의 위치는 lazy 를 지정한 Component 상위라면 어디든지 선언가능하다. 그리고 1개의 Suspense component 안에서 여러개의 lazy component를 선언할 수 있다.

 

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

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

- Error boundaries

만약 네트워크 이슈와 같은 이유로 다른 모듈이 로딩되는데 실패하면, error가 발생한다. 이런 상황에서 좋은 UX를 위해 이런 error들을 다룰 수 있고, Error boundaries 를 이용하면 복구하는 기능을 구현할 수 있다.

 

일단 Error Boundary를 만들어 놓으면 네트워크 에러 같은 에러의 상태를 보여주기 위해 lazy component들 상위 어느곳에든 Component를 위치시킬 수 있다. 자세한 설명은 Error boundaries 섹션이 따로 있으니 개념만 알도록 하자.

 

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

- Route-based code splitting

code splitting 코드를 app 어디에 둬야할지 결정하는 건 약간 애매할 수 있다. 당연히 bundle 들을 균등하게 분할 할 수 있는 위치에 두는 것이 좋지만 그렇다고 해서 UX를 방해할 수준이어서는 안된다.

 

이렇게 애매할 때는 route를 쓰는것이 해결책이 될 수 있다. 일반적으로 web에서 페이지 전환이 일어날 때 많은 시간이 걸린다. 보통 App은 한번에 전체 페이지를 re-rendering 하려는 경향이 있어서 사용자는 페이지안에서 UX적으로 다른 element와 상호작용 한다고 느끼지 못할 것이다.

 

아래 코드는 React Router와 React.lazy를 이용하여 route 기반으로 코드를 작성한 것이다.

 

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import React, { Suspense, lazy } from 'react';

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

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Switch>
        <Route exact path="/" component={Home}/>
        <Route path="/about" component={About}/>
      </Switch>
    </Suspense>
  </Router>
);

- Named Exports

React.lazy는 현재 default exports 만 지원한다. 만약 named export 형태로 모듈을 사용하고 싶다면, 모듈을 새롭게 하나 만들어서 그 모듈안에서 사용할 component를 default로 다시 export를 하는 형식으로 사용해야 한다.

 

이런 특징은 사용하지 않는 component들을 가져오지 않는 사상에 기반한다.(이를 treeshaking 이라고 한다. 나무를 흔들어서 필요 없는 것들을 떨어뜨리는, 코딩에서는 필요없는 코드를 없애는 작업)

 

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"));

 

MyComponent.js 를 intermediate module 이라고 표현하고, 이런 트릭으로 export default만 import 가능한 React.lazy 문제를 해결하였다.

댓글