SPA 초기 로딩 속도 개선하기

Code Splitting & Chunking

TL;DR

  • 초기 로딩 시간 개선을 위해 Chunking 을 해보자
  • Webpack 을 활용하면 Code Split 을 쉽게 할 수 있다
  • Chunk Optimization 을 통해서 효과를 극대화시키자

이 글은 이전 글인 SPA 스크립트 용량 어디까지 줄일 수 있을까 의 내용 중

초기 로딩 시간 (하얀 화면 보는 시간) 을 줄이기 위해서 스크립트 쪼개기 (Chunking) 등의 방법은 향후에 다시 논의하려고 한다.

라는 이야기로부터 비롯된 글입니다. 그리고 글 작성 편의상 경어를 생략한 점 알려드립니다.


스크립트 용량을 많이 줄였더라도 직접 작성한 코드 용량은 운영하면서 날로 커져만 갈 것이다. 그와 함께 초기 로딩 시간 역시 점점 길어질 것이다. 그렇다면 초기 로딩 시간을 줄이기 위해서는 화면 별로 필요한 모듈만 담아서 서빙하는 것은 어떨까 하는 생각에 도달하게 된다. 이 방법은 어찌보면 최종 스크립트 용량은 커질 수 있다. 왜냐하면 나눠진 모듈끼리 서로 중복된 모듈이 있을 수 있기 때문이다. 하지만 단일 스크립트 용량은 현저히 줄어들게 될 것이다.

모듈을 어떤 기준 (여기서는 화면) 으로 나누는 것을 Chunking 이라고 부르는데 이는 많은 곳에서 이미 사용되고 있다. 가까이서 볼 수 있는 예시 중 하나로 Instagram 이 있다.

Landing Page 소스 중 일부

Commons.js 에 공통 모듈 및 패키지를 담고 LandingPage.js 에는 Landing Page 와 관련된 React Component 들이 있음을 짐작해볼 수 있다.

이 글에서는 React Router + Webpack 을 사용한 케이스에 대해 코드 나누기에 대해서 다뤄보려한다. 그 전에 Static import 와 Dynamic import 에 대해서 살펴볼 필요가 있다.

Static Import & Dynamic Import

Static Import 는 import name from “module-name”; 과 같이 import 로 시작하는 구문이며, 모두들 잘 사용하고 있는 그것이다. 이는 빌드 타임에 정적으로 의존성이 분석되며, 만들어진 번들에 관련 모듈이 포함된다. 특히나 ES6 Native Module 을 지원하지 못하는 Webpack 1 에서는 주로 commonjs 방식에 따라 스크립트 파일 전체가 번들에 포함되게 된다. (관련 내용을 논의한 적이 있으니 참고)

Dynamic Import 의 경우는 이름 그대로 동적으로 모듈을 불러오는 것이라 생각해볼 수 있다. 이는 ES6 Native 로는 사용할 수 없고, Webpack 과 같은 모듈 로더에서 정의된 방식을 따르는 것으로 사용할 수 있다.

AMD (Asynchronous Module Definition) 같은 경우는 그 이름에서도 드러나듯이 비동기적으로 모듈을 불러올 수 있다. Webpack 을 통하면 commonjs 를 따르면서도 비동기적으로 모듈을 불러올 수 있게 해주니, 여기서는 commonjs 를 따르도록 하겠다.

사실 Dynamic Import 표준에 대한 Draft 가 Stage 3 에 있으며 tc39/proposal-dynamic-import 에서 관련 내용을 살펴볼 수 있다. Static Import 와 다르게 Dynamic Import 는 import 라는 이름의 메서드를 호출하는 것으로 사용할 수 있게 될 전망이다. Webpack 2 에서는 System.import 구문을 사용하는 것으로 이를 사용할 수 있게 해두었지만 아직 Webpack 2 는 RC 단계에 있다.
Native <Static Import & Dynamic Import>

Webpack 에서는 어떻게 Dynamic Import 를 할 수 있게 해주는가? 간단히 설명하면 아래와 같다.

commonjs manner 에서는 require.ensure 의 첫 번째 인자로 모듈 리스트를 넣으면 이들 모듈 리스트가 빌드 타임에 정적으로 분석되고, 이들 모듈이 묶여서 Entrypoint 와 다른 Chunk 로 분리되어 만들어진다.

commonjs code splitting

이를 다르게 표현해서 Code Split Point 를 정의한다고도 한다. 여기서부터 코드 찢어주세요 같은 느낌으로 받아들일 수 있을 것 같다. 그 후에 require.ensure 부분은 런타임에 다음과 같은 일을 하게 된다.

  • 이 Split Point 에서 생성된 스크립트 (Chunk) 를 불러온다 (동적으로 스크립트 태그 등을 추가해서 불러오는 방식을 주로 사용)
  • 스크립트 로딩이 끝나면 콜백을 호출해서 런타임에 require 를 통해서 모듈을 불러올 수 있게 해준다

설명한 내용을 그림으로 요약하면 아래와 같다.

React Router 와 모듈 로딩

기본적인 Router 의 형태

대체적으로 React Router 를 쓰면 위와 같은 형태로 사용하게 된다. 그럼 여기서 component 는 어디서 왔는가라고 묻는다면 당연히 static import 를 통해 온 것이다. Route 의 Component 중에서 특별히 커다란 컴포넌트가 있다면 이 지점은 훌륭한 Split Point 가 될 것이다. 그런데 동적으로 모듈을 불러온다는 것은 비동기적으로 컴포넌트가 주입되어야 하는데 React Router 는 이를 허용하는가? 라고 한다면 다행히도 허용하고 있다.

getComponent 라는 props 가 있고, cb 라는 이름의 콜백에 불러온 컴포넌트를 넘겨줄 수 있도록 되어있다. 이제 일은 간단하다. 위의 getComponent 부분에 require.ensure 를 통해 불러온 모듈을 가져갈 수 있게 해주면 될 일이다.

결과적으로 아래와 같이 코드를 수정하게 된다. 이렇게 해주면 Webpack 에서 Chunk 를 알아서 만들어주고, 생성되는 번들을 같이 CDN 등에 올려주면 추가적인 작업 없이도 잘 작동한다. (필자는 Webpack 2 를 beta 때부터 쓰면서 System.import 로 이를 구현했다)

Chunk Optimization

단순히 Code Split Point 를 정의하는 것으로 끝나면 좋겠지만, 앞으로 더 나아가기 위해서는 Chunk 를 최적화할 필요가 있다. 다양한 최적화 요인이 있지만 몇 가지만 살펴보면 아래와 같을 것이다.

  • 각 모듈에서 자주 쓰이는 모듈들은 각 Chunk 별로 중복되어 들어있을 것이다
  • 너무 작은 Chunk 는 쓸데없는 HTTP 요청에 따른 Overhead 를 발생시킨다
  • 너무 큰 Chunk 는 긴 로딩 시간을 가진다

Webpack 은 Chunk 최적화를 시도할 수 있도록 플러그인을 제공하고 있다. CommonsChunkPlugin, MinChunkSizePlugin, LimitChunkCountPlugin, AggressiveMergingPlugin 등이 그것이다.

이 중에서 가장 사용하기 좋으며 간편한 것은 CommonsChunkPlugin 이다. Code Split 으로부터 생긴 Chunk 들에서 공통으로 참조하는 모듈들을 Parent Chunk 로 밀어넣는 방식이나, 이를 Parent Chunk 에 밀어넣지 않고 따로 분리하되 Children Chunk 와 동시에 로딩하도록 할 수 있다. (Example 3, Example 4)

Code Split 에 의해 만들어진 Chunk 들에서 자주 쓰는 라이브러리들은 명시적으로 분리해주는 것도 좋다. 예를 들어 ‘react’, ‘lodash’ 등이 이에 해당할 수 있다. (Example 2) 특히 Vendor Chunk는 거의 변하지 않을 것이기 때문에 새 버전을 배포하더라도 클라이언트에서는 이 Chunk에 대한 캐시를 활용할 수 있어 빠른 로딩이 가능할 수 있다.

그 다음에 해볼 수 있는 것으로는 minChunks 값을 설정해서 N개 이상의 모듈에서 공유된 모듈을 chunk 로 만드는 것이다. (Example 1) 이는 커다란 Entry 스크립트의 크기를 줄이는 데에 용이하다. minChunks 값은 여러 값을 테스트해보면서 생성되는 chunk 를 Webpack analyzer 등을 통해서 살펴보는 것이 좋다. webpack-bundle-analyzer 가 살펴보기는 더 편한 것 같다.

webpack-bundle-analyzer

이외에도 MinChunkSizePlugin, LimitChunkCountPlugin 등은 사용법도 명백하고, 필자는 사용하지 않았기에 따로 언급하지는 않겠다.

Chunk 최적화에 대해서 더 잘할 수 있는 방법을 알고 계신 분은 제게도 꼭 알려주시면 감사하겠습니다 (_ _)

Chunking 효과 측정

Chunking 을 하면 단순히 entry 스크립트의 용량이 줄어드는 효과도 있지만, [entry, vendor, chunk] 를 동시에 요청할 수 있게 (load chunks parallel) 되는 것도 큰 장점이다. 모든 방면에서 최적으로 하기는 쉽지 않지만 사용자 경험 측면에 있어서는 충분히 개선을 할 수 있을 것이라 생각한다.

결과를 측정할 수 있는 방법에는 여러 가지가 있겠으나, 간단히는 Chrome Inspector 에서 Network throttling 을 주고 로딩을 해볼 수 있다. 이 때 Timeline 부분도 함께 보면 좋다. 아마 긴 로딩 막대 하나가 몇 개로 나뉘어져 동시에 로딩되는 모습을 볼 수 있을 것이다.

그리고 실제 화면이 언제부터 그려지기 시작했나 등은 Network 탭 옆에 있는 Timeline 탭을 살펴보면 된다. Screenshots 부분을 체크하고 리로드해보면 된다. 하얀 화면이 나오다가 언제부터 그려지기 시작하는지에 유의해서 살펴보자. 그런데 아래 스크린샷을 무조건 신뢰하기는 힘들 것 같다. 제대로 원인 파악을 해보지 못했으나 캐싱이 되어있는지 실제와 다르게 나오는 경우도 있었다. 스크린샷과 함께 그래프도 유의해서 살펴볼 필요가 있다.

이와 더불어 실제 사용자가 봤을 때는 어떻게 보일지가 궁금한 경우에 WebPageTest 를 활용하면 좋다. 여러 번 사이트에 접속해서 렌더링을 수행한 후에 이에 대한 보고서 및 스크린샷, 비디오 등을 만들어준다. 아래 표의 경우에서는 Start Render 를 살펴보자. 그 이전까지는 스크립트 로딩 시간이 큰 영향을 줬을 것이다. (Parsing, Styling 등에 대한 시간인지는 타임라인 그래프가 따로 제공되니 확인할 수 있다)

WebPageTest — Performance Results

이 뿐만 아니라 Visual Progress, 각종 시간 지표 측정, 페이지에서 일어나는 요청 수, 요청하는 바이트 수 등까지 정리해주고 있으니 이번 기회가 아니라도 페이지 최적화에 많은 도움을 줄 것이다. 이런 정말 좋은 툴이 공짜로 사용할 수 있도록 열려있다는게 놀랍다.


마무리

초기 로딩 속도를 잡아먹는 요인으로는 여러 가지가 있을 수 있습니다.. 비단 스크립트 크기 뿐만 아니라, 실제 렌더링이 이루어지기 전에 foo sdk, bar sdk 초기화 라거나, 데이터가 있어야 렌더링이 되도록 코드가 작성되었다거나 등의 요인이 있을 수 있죠. 이번에는 이들 사례를 제외하고 공통적으로 적용할 수 있을 것 같은 방법에 대해 이야기를 해보았습니다. (앞의 사례는 필자가 이상하게 짜서 겪었던..) 이번 글에서 제시한 방법과 위에서 언급된 각종 멋진 성능 측정, 시각화 도구 등을 잘 활용해서 기존보다 더 나은 사용자 경험을 제공할 수 있으리라 생각합니다. 필자의 경우도 많은 실험과 삽질을 통해서 이를 개선하려고 노력해보았고, 그 때 시도해보았던 것들을 정리한 것입니다.

이 글이 많은 분들께 도움이 됐으면 좋겠습니다. 피드백은 언제나 환영합니다. @angdev 로 멘션 주시면 감사하겠습니다 (_ _) 궁금하시거나 이야기하고 싶은 점이 있으신 분은 Little Big Programming Gitter 에서 이야기 나눠요!

글쓴이에 대해서

이번에도 Frontend 에 대한 글을 쓰긴 했지만 원래는 Backend 에 더 힘을 쏟고 싶은 사람입니다. 생업으로 Ruby on Rails 를 하고 있습니다. Docker 를 열심히 굴리는 중이고, AWS로 인프라 구축하는 것에 재미를 붙였습니다. Kubernetes 와 같은 Orchestration Tool 과 Ruby, Elixir, Javascript, C++ 등에 관심이 많습니다.