본문 바로가기

Frontend/성능 최적화

[블로그 코드 분할 & 지연 로딩] 웹 성능 최적화까지 해보자-4

번들 파일 분석

이번에는 webpack을 통해 번들링된 파일을 분석하고 최적화해보자.

 

이전에 Performance 패널 분석할 때 0.chunk.js 파일이 다운로드도 오래 걸렸었다.

화면을 그리는데 필요한 리소스인 리액트 코드의 다운로드가 늦어지면 다운로드가 완료된 후에나 화면을 그릴 수 있어 다운로드가 오래 걸린 만큼 화면도 늦게 뜬다.

그래서 이 자바스크립트 파일을 최적화해야 한다.

그 전에 해당 자바스크립트 파일이 어떤 코드로 이루어져 있는지 알아야 한다.

 

이 구성을 상세히 보기 위해서 Webpack Bundle Analyzer라는 툴을 이용해보자.

이 툴은 webpack을 통해 번들링된 번들 파일이 어떤 코드로 이루어져 있는지 트리맵으로 시각화해서 보여준다.

하지만 이 툴을 사용하려면 webpack 설정을 직접 수정해야 한다는 단점이 있다.

이 프로젝트는 cra를 통해 생성되었기 때문에 webpack에 대한 설정이 숨겨져 있다.

이를 직접 변경하려면 npm run eject 스크립트를 통해서 cra의 설정 파일들을 추출해야 한다.

 

여기서는 cra-bundle-analyzer를 사용해볼 것이다.

cra-bundle-analyzer는 내부적으로 webpack-bundle-analyzer를 사용하는 툴로, 결과물은 동일하지만 cra 프로젝트에서 eject 없이 사용할 수 있다.

 

npm을 통해 설치하고 실행해보면 브라우저가 하나 뜬다.

위 브라우저는 이 서비스의 번들 파일과 그 안에 있는 모든 패키지이다.

파일의 실제 크기에 따라 비율로 보여주기 때문에 어떤 패키지가 어느 정도의 용량을 차지하고 있는지도 쉽게 알 수 있다.

 

가장 많은 부분을 차지하고 있는 건 2.bebf38f6.chunk.js라는 파일인데, Performance 패널에서 0.chunk.js 파일과 동일한 번들 파일인 것을 유추해볼 수 있다.

그리고 그 하위에 있는 요소의 이름이 node_modules이기 때문에 이 번들 파일이 담고 있는 코드가 npm을 통해 설치한 외부 라이브러리라는 것을 알 수 있다.

 

이제 어떤 패키지 때문에 2.chunk.js 파일이 큰 것인지 확인해보자.

2.chunk.js 내부를 살펴보면, 크게 refractorreact-dom이 큰 비중을 차지한다.

 

react-dom은 리액트를 위한 코드라 생략하고 refractor 패키지의 출처를 확인해보자.

이는 package-lock.json을 통해 확인해볼 수 있다.

확인해보니 react-syntax-highlighter라는 패키지에서 refractor를 참조하고 있다.

이는 마크다운의 코드 블록에 스타일을 입히는데 사용하는 라이브러리이다.

"node_modules/react-syntax-highlighter": {
      "version": "12.2.1",
      "resolved": "<https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz>",
      "integrity": "sha512-CTsp0ZWijwKRYFg9xhkWD4DSpQqE4vb2NKVMdPAkomnILSmsNBHE0n5GuI5zB+PU3ySVvXvdt9jo+ViD9XibCA==",
      "dependencies": {
        "@babel/runtime": "^7.3.1",
        "highlight.js": "~9.15.1",
        "lowlight": "1.12.1",
        "prismjs": "^1.8.4",
        "refractor": "^2.4.1"
      },
      "peerDependencies": {
        "react": ">= 0.14.0"
      }
    },

이 라이브러리는 src/components/markdowns/CodeBlock.js에서 사용하고 있다.

그런데, CodeBlock 컴포넌트는 마크다운을 표시하는데 필요하니 블로그 글 상세 페이지에서만 필요할 뿐 글 목록 페이지에서는 필요가 없다.

즉 크기가 너무 큰 react-syntax-highlighter 모듈사용자가 처음 진입하는 목록 페이지에서는 굳이 다운로드할 필요가 없다는 것이다.

지금은 하나로 합쳐져 있는 이 번들 파일을 페이지별로 필요한 내용만 분리해서 필요할 때만 따로따로 로드하면 좋을 것 같다.

 

코드 분할

이제 코드 분할 기법을 이용해서 페이지별로 코드를 분리해보자.

 

코드 분할 기법은 말 그대로 코드를 분할하는 기법으로 하나의 번들 파일을 여러 개의 파일로 쪼개는 방법이다.

분할된 코드는 사용자가 서비스를 이용하는 중 해당 코드가 필요해지는 시점에 로드돼서 실행된다.

이를 지연 로딩이라고 한다.

 

블로그 서비스를 예로 들면, 블로그 글 목록 페이지와 상세 페이지 코드가 모두 하나의 파일로 만들어진다.

페이지 컴포넌트 각각은 페이지에서 사용하는 패키지도 포함하고 있다.

이렇게 모든 코드가 하나로 합쳐져 있으면 목록 페이지에 접근했을 때 당장 사용하지 않는 ViewPage에 있는 코드까지 함께 다운로드된다.

그래서 페이지 로드 속도가 느려지는 것이다.

 

코드 분할 기법을 사용해, 목록 페이지에 접근하면 목록 페이지와 관련된 코드인 ListPage.chunk.js만 로드하고 상세 페이지에 접근하면 ViewPage.chunk.js만 로드하는 식으로 변경하면 된다.

코드 분할 기법에는 여러 가지 패턴이 있는데, 페이지별로 코드를 분할할 수도 있고, 각 페이지가 공통으로 사용하는 모듈이 많고 그 사이즈가 큰 경우에는 페이지별로 분할하지 않고 모듈별로 분할할 수도 있다.

 

코드 분할 적용하기

코드 분할을 하는 가장 좋은 방법은 동적 import를 사용하는 방법이다.

아래와 같이 import 문을 작성하면 해당 모듈은 빌드 시 함께 번들링이 된다.

import { add } from "./math";

 

아래와 같이 import 문을 사용하면 빌드할 때가 아닌 런타임에 해당 모듈을 로드한다.

이를 동적 import라고 한다.

import('add').then((module) => {
	const { add } = module;
});

 

webpack은 이 동적 import 구문을 만나면 코드를 분할해서 번들링한다.

하지만 동적 import 구문은 Promise 형태로 모듈을 반환해주기 때문에 문제가 있다.

여기서 import하려는 모듈은 컴포넌트이기 때문에 Promise 내부에서 로드된 컴포넌트를 Promise 밖으로 빼내야 한다.

 

리액트의 경우 이런 문제를 해결하기 위해 lazy와 Suspense라는 것을 제공한다.

이 함수를 사용하면 비동기 문제를 신경 쓰지 않고 동적 import를 할 수 있다.

import React, { Suspense } from 'react';

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

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

 

lazy 함수는 동적 import를 호출해서 그 결과인 Promise를 반환하는 함수를 인자로 받는다.

lazy 함수가 반환한 값, 즉 import한 컴포넌트는 Suspense 안에서 렌더링해야 한다.

 

그러면 동적 import를 하는 동안 SomeComponent가 아직 값을 갖지 못할 때는 Suspense의 fallback prop에 정의된 내용으로 렌더링되고 이후 SomeComponent가 온전히 로드됐을 때 fallback 값으로 렌더링된 Suspense가 정상적으로 SomeComponent를 렌더링한다.

 

이 예에서는 페이지별로 코드를 분할할 예정이기 때문에 Router 쪽에 이 코드를 적용해야 한다.

import React, { Suspense, lazy } from "react";
import { Switch, Route } from "react-router-dom";
import "./App.css";

const ListPage = lazy(() => import("./pages/ListPage/index"));
const ViewPage = lazy(() => import("./pages/ViewPage/index"));

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>로딩 중...</div>}>
        <Switch>
          <Route path="/" component={ListPage} exact />
          <Route path="/view/:id" component={ViewPage} exact />
        </Switch>
      </Suspense>
    </div>
  );
}

export default App;

위 코드는 블로그 서비스의 App.js의 Router에 lazy 함수를 적용한 것이다.

기존 정적 import 문은 지워줬고, 동적 import 문으로 변경해주었다.

 

이렇게 하면 각 페이지 컴포넌트 코드가 분할되고 사용자가 목록 페이지에 접근했을 때 전체 코드가 아닌 ListPage 컴포넌트의 코드만 동적으로 import해서 화면을 띄운다.

 

이제 다시 서비스의 번들 구조를 한 번 살펴보자.

0.chunk.js 파일은 ListPage에서 사용하는 외부 패키지를 모아 둔 번들 파일(axios)이다.

3.chunk.js 파일은 ViewPage에서 사용하는 외부 패키지를 모다 둔 번들 파일(react-syntax-highlighter)이다.

4.chunk.js 파일은 리액트 공통 패키지를 모아 둔 번들 파일(react-dom 등)이다.

5.chunk.js 파일은 ListPage 컴포넌트 번들 파일이다.

6.chunk.js 파일은 ViewPage 컴포넌트 번들 파일이다.

 

이제 Performance 패널도 확인해보자.

약 472ms 정도 걸렸던 0.chunk.js 파일이 코드 분할 후에는 40ms 정도로 줄어든 것을 볼 수 있다.

아마 production 빌드를 통해 번들링하게 되면 더 줄일 수 있을 것이다.

 

마지막으로 Lighthouse 검사 결과도 한 번 확인해보자.

이전보다 점수가 많이 오른 것을 확인할 수 있었다.