SPA 스크립트 용량 어디까지 줄일 수 있을까

최대한 줄여보고자 하는 당신을 위한 안내서

TL;DR

  • Kill Your Dependencies
  • 닭 잡는데 소 잡는 칼을 쓰지 말자
  • 동적 Require 가 사용되는 경우에는 힌트를 주자
  • ES2015+ Native Import/Export 에 대해 제대로 알고 사용하면 공짜 점심이 기다린다

글 작성 편의상 경어를 생략하였음을 미리 알려드립니다.

요즘에 React니 Angular니 하며 SPA (Single Page Application) 가 점점 늘어가는 추세인데, 그와 함께 많은 사이트들의 스크립트 용량 역시 늘어가고 있다.

2014.08 ~ 2016.08 JS Transfer Size & JS Requests (link)

기존에 html 파일로 서빙되던 마크업 조차도 스크립트에 포함되어 마크업 용량을 흡수한 데에 이어, SPA의 특성상 웹앱 전체가 한 스크립트를 통해서 서빙되는 경우도 부지기수이다. 주기적으로든 서비스 오픈 전이든 클라이언트 성능 테스트를 거치게 되면서 “코드 사이즈가 이렇게 컸었나?” 하는 점을 초기 로딩 속도 등을 통해서 경험하는 경우가 많은데, 그럴 때 적용해볼 수 있는 방법과 그와 관련된 주변 지식에 대해서 다뤄보려 한다.

이 글에서는 스크립트 자체의 용량을 줄이는 것에 대해 먼저 중점적으로 다룰 것이다. 초기 로딩 시간 (하얀 화면 보는 시간) 을 줄이기 위해서 스크립트 쪼개기 (Chunking) 등의 방법은 향후에 다시 논의하려고 한다. ES2015+를 사용하면서 Transpiler로 Babel, Bundler로 Webpack, Library로 React를 쓴 상황을 예시로 들어 설명할 것이지만 다른 케이스에도 유사하게 적용할 수 있는 방법이 반드시 있을 것이라 생각한다.


상황 진단하기

똑같은 단일 스크립트 361, 4.44s (!)

위 스크린샷은 no throttling 인 경우의 상황이고 아래 상황은 3G Regular (750kb/s ~ 250kb/s) 로 속도 제한을 로딩을 한 것이다. 비교적 극단적으로 상황이 꾸며졌지만 스크립트 로딩 시간 동안에 유저는 빈 화면을 보게 된다. (Server Rendering 에 대한 이야기는 잠시 접어두고 스크립트 감량에 무게를 두도록 하자)

약 4초 동안 idle 상태가 유지되고 있다

참고로 위 스크린샷에서 361 KB 라고 나오긴 했지만 gzipped(!) 라는 점을 일러둔다. gzipped 전에는 1.3MB 정도 된다. 당신도 이런 상황을 맞이하였다면 이 글이 도움이 되길 바란다.

다른 SPA 사이트들의 상황이 궁금할 지도 모르겠다. 예시로 인스타그램은 이 정도 된다.

인스타그램 Landing Page
인스타그램 소스 중 일부

여기는 스크립트가 화면별로 쪼개져있을 것으로 예상할 수 있다. React 를 포함한 각종 공통 패키지가 Commons.js 아래에, Landing Page 와 관련된 컴포넌트가 담긴 스크립트가 LandingPage.js 아래에 있는 것으로 예상된다. 둘만의 용량만 따진다면 넉넉잡아 200KB 정도 된다.

다른 사이트의 예시도 보았고 다시 자신의 상황을 살펴보자. 내가 작성한 스크립트가 저렇게 커질리가 없는데, 그 원인이 어디 있을까? 라고 생각했다면 첫 번째로는 무분별한 패키지 사용이 있을 수 있다. 두 번째는 잘못된 module import (require) 가 있을 수 있다. 그리고 이 글에서 중점적으로 다룰 것은 아니나 Webpack 빌드 설정 등이 잘못되었을 수도 있다. 먼저 무분별하게 사용한 패키지를 손보자.

무분별하게 사용한 패키지 제거하기 혹은 대체하기

가장 먼저 이 방법을 적용해보려면 하는 일에 비해 용량을 크게 잡아먹고 있는 패키지를 발견해내는 것이 중요하다. 다행히도 Webpack 에서는 이를 쉽게 발견할 수 있도록 분석 도구를 제공하고 있다.

위 명령으로 생성된 stat.json 파일을 https://webpack.github.io/analyse/ 에 업로드하면 모듈 의존성, 청크, 빌드 경고, 빌드 에러, 모듈 리팩토링과 관련된 힌트 등에 해당하는 많은 정보를 얻을 수 있다. 여기서 먼저 주목할 부분은 Modules 에 해당하는 부분이다. 그러면 먼저 모듈 의존성 그래프와 함께 각 모듈 파일 정보에 대한 정보가 아래에 나열된 화면을 볼 수 있을 것이다.

거대한 모듈 의존성 그래프

이 화면에서 먼저 큰 파일을 찾기 위해서 ‘Kib’ 같은 키워드로 검색을 하면서 노다지 찾는 기분으로 하나씩 찾아보면 노다지를 진짜 발견할 수도 있다. 아래처럼 말이다.

Bingo!

이런 모듈을 찾았을 때의 대처는 순전히 개발자의 몫이다. 예를 들어 “jquery 를 더 이상 쓰고 있지 않은데 아직 있네?” 같은 상황이면 바로 제거하면 되는 것이고, “jquery 에서 쓰는 부분이 selector 뿐이었다" 같은 상황이면 jquery 와 호환성이 있는 다른 훨씬 가벼운 selector library로 대체하면 될 일이다. 혹은 라이브러리가 하는 일이 크지 않고 작성할만한 정도라면 패키지를 제거하고 그 일을 하는 코드를 직접 작성하는 것도 좋다. (Kill Your Dependencies)

동적 Require 에 힌트 주기

moment.js locales

위 스크린샷은 moment.js 내부의 모듈 중 일부이다. 그런데 보다시피 사용하지 않은 Locale에 대한 모듈을 잔뜩 불러오고 있음을 알 수 있는데 (관련 모듈만 약 100개에 육박한다) moment.js 의 locale 모듈은 아래와 같이 require 된다.

8번째 줄에 있는 require(‘./locale/’ + name) 에서 동적으로 모듈을 require 하는 것을 할 수 있다. 하지만 실제로 어플리케이션 코드에서 로케일을 불러올만한 일은 없었다. 그런데 왜 모든 locale 모듈이 번들링되었는지는 Webpack 이 동적 경로에 대한 require 를 어떻게 처리하는지 알게 되면 이해를 할 수 있다. (링크)

간단하게 설명하면 require(‘./locale/’ + name) 에서 name 부분에 어떤 값이 올지 알 수 없으므로 webpack 에서는 locale 이라는 디렉토리에서 /^.*$/ 에 매칭되는 파일을 모두 require 될 수 있는 후보로 생각하기 때문에 이를 모두 번들에 넣을 수밖에 없다는 것이다. 여기서 디렉토리 정보와 정규표현식 정보를 연관지어 하나의 Context 라고 지칭한다.

한국어 로케일만 필요한 경우라면 ContextReplacementPlugin 을 통해서 locale 이라는 디렉토리에서 매칭할 파일에 대한 정규표현식을 아래와 같이 지정해줄 수도 있다. (원래 생성되려고 했던 Context 대신에 직접 생성한 Context 로 대체한다는 의미로 Context Replacement 라고 함)

한국어 로케일만 불러오도록 힌트를 주는 예시

혹은 로케일이 필요하지 않은 경우는 IgnorePlugin 을 통해서 특정 디렉토리에 대한 require 를 무시할 수도 있다.

혹시나 moment.js 와 같이 locale 정보가 포함된 라이브러리를 사용하고 있다면 locale 과 관련된 스크립트 파일을 위와 같은 방법으로 번들에 포함시키지 않을 수 있다. 단, 바로 잘라도 되는지 판단하는 것은 패키지 소스 파일을 보고 난 당신의 몫이다.

잘못된 module import 바로잡기

그리고 찬찬히 더 보다보면 의외의 장면을 목격하게 될 수도 있다. 아래와 같이.

import { assign } from ‘lodash’;

“왜 이런 일이 일어났을까? 나는 최대한 가볍게 쓰려고 모듈 단위로 불러와서 썼는데..” 라는 분들은 아래와 같이 썼을 것이다.

그런데 왜 assign 함수만 따로 가져오지 않고 전체 모듈을 다 불러왔을지 궁금하다면 babel 과 webpack 이 module resolve 를 어떻게 하는지 살펴볼 필요가 있다. 그 전에 몇 가지 배경을 알고 넘어가면 좋을 것 같다.

ES2015(ES6) 와 commonjs

자바스크립트의 모듈 시스템이 발달하게 된 것은 node.js 의 등장 이후라고 여겨진다. node.js 에서는 require 함수를 통해서 다른 파일의 모듈을 불러오게 되는데, 이를테면 아래와 같이 말이다.

node.js 의 라이브러리 제작자들은 (특히 lodash 와 같은 유틸리티성 라이브러리들) 사용 편의성과 단일 모듈 로딩 등을 위해 각 메서드는 여러 파일로 쪼개서 작성하되, 최종적인 메서드들은 하나의 객체로 제공하는 방식을 선호하였다. 그래야 한 번 모듈을 로딩해서 편하게 쓸 수 있을 뿐만 아니라 chaining 과 같은 테크닉을 제공하기 쉽기 때문이다.

그리고 node.js 에서는 각 모듈에서 export 된 것들을 한 번에 불러왔기에 아래와 같은 사용을 허용하였다.

즉 모듈 단위로 export 하는 기능이 언어 레벨에서 제공되지 않았기에 Object에 담아 모듈들을 export 하는 식으로 구현이 되어 있고, 사용하는 측에서도 단순히 Object 에 접근하는 것으로 모듈을 꺼내 쓸 수 있었다. 이런 구현이니 반드시 사용하는 쪽에서는 Object 전체를 불러올 수밖에 없는 구조이다. 이렇게 구현된 모듈 시스템을 흔히 commonjs 라고 부른다. (CJS require)

다행히도 (?) commonjs 시스템을 사용하는 스크립트들은 주로 서버사이드에서만 사용되었기에 용량과 같은 문제는 크게 대두되지 않았다.

비교적 최근에 와서 ES2015 (ES6) 표준에 module import 와 export 에 대한 명세가 추가되었으나 이를 이용해서 구현한 라이브러리 코드도 적거니와 이를 각종 자바스크립트 엔진에서 곧바로 지원하지는 못했기에 babel 과 같은 transpiler 에서는 이를 기본적으로 commonjs 시스템에 따라 ES5 코드 등으로 변환해주었다. 그래서 모듈을 어떻게 resolve를 해주었는지 살펴보면-

위와 같이 해석되기 때문에 메서드 단위로 import를 해도 전체 모듈이 딸려오는 경우가 생기는 것이다. 그렇다고 하면 어떻게 해야 하는가?

lodash 의 경우는 단일 메서드를 사용하는 경우가 많아서 클라이언트에서 사용할 때는 각 메서드를 직접 import 해서 사용할 수 있도록 해두었다.

이외에도 공식 홈페이지에서 es module로 제공되는 lodash-es, lodash 전체 import를 사용한 메서드들의 import로 변경해주는 babel-plugin-lodash 등을 제공하고 있다. 이와 같이 커다란 패키지 전체를 import 하고 있는 경우는 없는지 다시 한 번 살펴볼 필요가 있다.

Native Import와 Tree Shaking

lodash를 lodash-es 로 대체했는데 상황이 변하지 않았다는 이야기를 하러 왔다면 이 주제에 대한 논의가 필요하다.

앞에서 babel이 기본적으로 module import 부분을 commonjs require로 바꾼다는 이야기를 했었는데 사실은 babel preset으로 es2015 등을 썼을 경우에 그렇게 된다. es2015 preset에 babel-plugin-transform-es2015-modules-commonjs 가 포함되어 있기 때문인데 아래와 같이 설정하거나 babel-preset-es2015-native-modules 를 사용하면 module import를 require로 변환하지 않는다.

이와 함께 ES2015+ Native Import 를 지원하는 bundler를 사용해야 (!) 메서드 단위로 import 를 할 수 있게 된다. Native Import/Export 를 Harmony Import/Export 라고도 부르는데 이는 Webpack 2 부터 지원하고 있다. 그렇기 때문에 현재 Stable 버전인 Webpack 1 에서는 이 혜택을 누릴 수가 없다고 보면 된다. 한 번 효과를 확인하고 싶으면 Webpack 2.1.0-beta.21 (2016년 8월 21일 현재 기준) 을 설치하여 번들링을 해보자. 번들링을 시도한 예제 코드는 아래와 같다.

require 를 통해서 모듈을 불러오게 되면 cjs require 라는 표시와 함께 번들링이 되고, import 를 통해서 모듈을 불러오게 되면 harmony import 라는 표시와 함께 모듈을 불러오는 것을 볼 수 있다. 그와 더불어 [only some exports used: foo] 라는 문구도 확인할 수 있는데 common.js 에 대해서는 hello 만 사용되었다는 문구가 없다. 아래는 번들링된 스크립트이다.

주목할 점은 1번 모듈의 bar 부분인데, 코드 상으로는 export 했으나 여기서는 export 조차 시켜주지 않았다는 점에 주목할 필요가 있다. 이와 같이 실제로 사용되지 않은 모듈의 import, export 구문을 없애버리는 것을 Tree Shaking 이라고 부른다. 위 코드에는 남아있지만 번들링 시에 minify 플러그인을 넣으면 모두 없어지게 된다. cjs require 를 사용한 부분은 이후에도 어떤 property를 참조할지 정적 분석이 힘들기 때문에 기본적으로 Tree Shaking 되지 않는다.

마찬가지로 bar를 import 시키지도 않았다

아직 많은 라이브러리들이 ES2015+에 맞춰 제공하고 있지 않다. (일단 React.js 부터..) 그래서 아직까지는 lodash 와 같이 전체 import 를 피해갈 수 있다면 괜찮은 케이스인 것이고, 그렇지 않다면 안고 가거나 다른 가벼운 라이브러리를 찾는 것이 고작이다. 그래도 시간이 지나면 우리는 공짜 점심 (Yeah! Free lunch!) 을 먹을 수 있을 것이라 기대가 된다. (약 4개월 전에는 Webpack 2 beta 버전을 production 에 썼었으나 큰 효과를 보지는 못했다)

혹시 이런 실수를?

아직 용량이 많이 줄지 않았다면, 가벼운 실수를 하지 않았는지 확인해볼 필요도 있다. 예를 들어 SourceMap을 소스 파일에 포함시키지는 않았는지 혹은 NODE_ENV 를 production 으로 주지 않았을 수도 있다.

Webpack을 이용한 기본적인 용량 최적화에 대해서는 https://github.com/webpack/docs/wiki/optimization 에 간략하게 설명이 되어있다. Minimize, Deduplication, … 등의 기법을 적용하는 것이다. 이는 적용하기가 간단한 편에 속하니 해볼만 하다. 이 중에서 Minimize (Uglify) 같은 경우는 Dead Code Elimination 을 해주기 때문에 반드시 해야한다. 향후에 Tree Shaking 을 하더라도 Minimize 를 해주지 않으면 export 되지 않은 부분이 날아가지 않기 때문에 실제 효과를 볼 수 없기 때문이다. 그리고 NODE_ENV 를 production 으로 주더라도 Minimize 를 하지 않으면 역시 감량 효과를 볼 수 없다.


결과와 느낀 점

이 글은 실제 작업을 하면서 배우고, 적용해보았던 내용을 토대로 쓰여졌다. 적용을 하고 나서는 아래와 같은 결과를 얻게 되었다. (스크립트 쪼개기도 포함, Chunking 을 좀 더 잘하면 좋지 않았을까 하는 아쉬움과 함께)

작업 이후 + Chunking

사이즈를 줄이는 데 사실 가장 큰 도움이 되었던 것은 기존에 있던 패키지를 대체할 수 있는 스크립트 작성하기와 훨씬 가벼운 대체 패키지를 찾는 것이었다. 스크립트 쪼개기를 하면 외부 패키지 코드 사이즈와 실제 작성한 코드의 사이즈를 명확히 알 수 있게 되는데 외부 패키지 코드가 일반적으로 실제 작성한 코드에 비해 월등히 크다는 점을 알게 된다. (다시 한 번: Kill Your Dependencies) 정말로 닭 잡는데 소 잡는 칼을 쓰지 말자.

그리고 얼른 많은 패키지들이 ES2015+ 버전을 지원하고 Webpack 2도 얼른 beta 딱지를 떼면 좋겠다는 바람도 있다. CPU 클럭 수가 더 오르지 않게 되면서 프로그래머들이 공짜 점심을 못 먹나 했는데, 우리는 다른 곳 (..) 에서 공짜 점심을 먹을 수 있지 않을까 기대가 된다. (얼른 먹고 싶어서 두 번 말했다)


이 글이 많은 분들께 도움이 됐으면 좋겠습니다. 추가적으로 더 적용할 수 있는 좋은 방법을 알고 계신 분께서는 @angdev 로 알려주세요. 제가 좋아합니다. 긴 글 읽어주셔서 감사합니다.

글쓴이에 대해서

맨날 블로그에 글 쓴다고 하다가 안 쓰다가 드디어 쓰게 되어 좋아하고 있습니다. Github의 Contributions 부분이 초록초록 했는데 요즘에는 Private 하게 코딩하다보니 노는 것처럼 보이는데 맞습니다. 이번에 Frontend 에 대한 글을 쓰긴 했지만 원래는 Backend에 더 관심이 많은 사람입니다. RoR을 주로 쓰지만 요즘에는 Elixir, Clojure 공부도 겸하고 있습니다.