SPA 초기 로딩 속도 개선하기

Code Splitting & Chunking

  • 초기 로딩 시간 개선을 위해 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 는 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 를 통해서 모듈을 불러올 수 있게 해준다

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

기본적인 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 로 이를 구현했다)

단순히 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 을 하면 단순히 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++ 등에 관심이 많습니다.

--

--

Little big things about programming. Inspired by http://littlebigdetails.com/

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
홍철주 (angdev)

홍철주 (angdev)

175 Followers

Indecisive Programmer, Software Engineer. Ruby, Elixir, Javascript, ... http://angdev.space