Compressing JavaScript
최적의 성능을 위해 JavaScript를 압축하고 청크의 크기를 관리해야 한다. 지나친 JavaScript 번들 세분화는 중복 제거과 캐싱에 도움이 될 수 있지만, 브라우저 프로세스나 캐시 검사 등으로 인해 50-100 청크 범위에서는 압축과 영향 로드가 저하될 수 있기 때문에 가장 적합한 압축 전략을 선택해야 한다.
JavaScript는 페이지 크기에 두 번째로 큰 영향을 주며 인터넷에서 이미지 다음으로 많이 요청되는 웹 리소스이다. 웹사이트 성능을 개선하기 위해 JavaScript의 전송, 로드 및 실행 시간을 줄이는 패턴을 사용해야 한다. 압축은 네트워크를 통해 스크립트를 전송하는 데 필요한 시간을 줄이는 데 도움이 될 수 있다.
압축, 축소, 코드 분할, 번들링, 캐싱, 레이지로딩과 같은 다른 기술과 결합하여 대량의 JavaScript가 성능에 미치는 영향을 줄일 수 있다. 그러나 이러한 작업들은 서로 상충할 때가 있다. 이 페이지에서는 JavaScript 압축 기술을 살펴보고 코드 분할 및 압축 전략을 결정할 때 고려해야 할 미묘한 차이에 대해 알아볼 것이다.
요약
- Gzip 및 Brotli는 JavaScript를 압축하는 가장 일반적인 방법이며 많은 최신 브라우저에서 지원한다.
- Brotli는 유사한 압축 수준에서 더 나은 압축 비율을 제공한다.
- Next.js는 기본적으로 Gzip 압축을 제공하지만 Nginx와 같은 HTTP 프록시에서 사용하는 것이 좋다.
- Webpack을 사용하여 코드를 번들링하는 경우 Gzip 압축에 CompressionPlugin을 사용하거나 Brotli 압축에 BrotliWebpackPlugin을 사용할 수 있다.
- Oyo는 Gzip 대신 Brotli 압축으로 전환한 후 파일 크기가 15-20%, Wix는 21-25% 감소했다.
- compress(a + b) <= compress(a) + compress(b) - 여러 개의 작은 번들보다 하나의 큰 번들의 압축률이 더 낫다. 이로 인해 중복 제거와 캐싱이 브라우저 성능 및 압축과 상충되는 세분화된 트레이드오프가 발생한다. 세분화된 청킹은 이러한 트레이드오프를 처리하는 데 도움이 될 수 있다.
HTTP 압축
압축을 하면 문서와 파일의 크기가 줄어들어 원본보다 디스크 공간을 덜 차지한다. 문서가 작을수록 더 낮은 대역폭을 사용하며 네트워크를 통해 빠르게 전송할 수 있다. HTTP 압축은 이 간단한 개념을 사용하여 웹 사이트 콘텐츠를 압축하고 페이지 가중치를 줄이며 대역폭 요구 사항을 낮추고 성능을 향상시킨다.
HTTP 데이터 압축은 다양한 방식으로 분류될 수 있다. 그 중 하나는 손실 대 무손실이다.
손실 압축은 압축-압축 해제 주기가 사용성을 유지하면서 문서를 약간 변경하는 것을 의미한다. 최종 사용자는 변경 사항을 대부분 감지할 수 없다. 손실 압축의 가장 일반적인 예시는 이미지에 대한 JPEG 압축이다.
무손실 압축을 사용하면 압축 및 압축 해제 후 복구된 데이터가 원본과 정확하게 일치한다. 무손실 압축의 예시로는 PNG 이미지가 있다. 무손실 압축은 텍스트 전송과 관련이 있으며 HTML, CSS, JavaScript와 같은 텍스트 기반 형식에 적용해야 한다.
브라우저에서 전부 유효한 JavaScript 코드를 위해 무손실 압축 알고리즘을 사용해야 한다. JavaScript를 압축하기 전에 축소화를 통해 불필요한 구문을 제거하고 실행에 필요한 코드로만 줄일 수 있다.
Minification
페이로드 크기를 줄이기 위해 JavaScript를 최소화할 수 있다. 최소화는 공백과 불필요한 코드를 제거함으로써 더 작지만 완벽하게 유효한 코드 파일을 생성하여 압축을 보완한다. 코드를 작성할 때 줄 바꿈, 들여쓰기, 공백, 잘 지은 변수명 및 주석을 사용하여 코드 가독성과 유지 관리성을 향상시킨다. 이러한 것들은 전체 JavaScript 크기에 기여하지만 브라우저에서 실행하는 데 필요하지 않다. 최소화는 JavaScript 코드를 성공적인 실행에 필요한 최소 수준으로 줄여준다.
최소화는 JavaScript 및 CSS 최적화를 위한 표준 관행이다. JavaScript 라이브러리 개발자는 일반적으로 프로덕션 배포를 위해 min.js 확장자로 표시되는 축소된 파일을 제공한다. (예: jquery.js
및 jquery.min.js
)
HTML, CSS 및 JavaScript 리소스 최소화를 위해 다양한 툴이 제공된다. Terser는 널리 사용되는 ES6+용 JavaScript 압축 도구이며 Webpack v4에는 최소화된 빌드 파일을 만들기 위한 라이브러리용 플러그인이 기본으로 제공된다. TerserWebpackPlugin과 오래된 버전의 웹팩을 사용하거나 모듈 번들러 없이 Terser를 CLI 도구로 사용할 수도 있다.
정적 압축 vs 동적 압축
최소화는 파일 크기를 크게 줄이는 데 도움이 되지만 JavaScript 압축에는 더 큰 이점이 있다. 두가지 방법으로 서버사이드 압축을 할 수 있다.
정적 압축: 정적 압축을 사용하여 리소스를 미리 압축하고 빌드 프로세스의 일부로 미리 저장해둘 수 있다. 이 경우 더 수준 높은 압축을 사용하여 코드 다운로드 시간을 단축할 수 있다. 빠른 빌드 시간은 웹 사이트 성능에 영향을 주지 않기 때문에 자주 변경되지 않는 파일에 정적 압축을 사용하는 것이 가장 좋다.
동적 압축: 동적 압축을 사용하면 브라우저가 리소스를 요청할 때 즉시 압축이 수행된다. 동적 압축은 구현하기 더 쉽지만 낮은 수준의 압축을 사용하게 된다. 압축 수준이 높을수록 더 많은 시간이 필요하며, 콘텐츠 크기가 작을수록 얻는 이점은 작아진다. 자주 변경되는 콘텐츠나 애플리케이션에서 생성되는 콘텐츠와 함께 동적 압축을 사용하는 것이 좋다.
애플리케이션 콘텐츠 유형에 따라 정적 압축이나 동적 압축을 사용할 수 있다. 일반적인 압축 알고리즘을 사용하여 정적 압축과 동적 압축 모두 사용할 수 있지만 권장되는 압축수준은 각각의 케이스마다 다르기 때문에 압축 알고리즘에 대해 더 알아볼 필요가 있다.
압축 알고리즘
Gzip과 Brotli는 HTTP 데이터를 압축하기위해 쓰이는 요즘 가장 인기있는 알고리즘이다.
Gzip
Gzip 압축 형식은 거의 30년 동안 사용되어 왔으며 Deflate 알고리즘에 기반한 무손실 알고리즘이다. Deflate 알고리즘 자체는 입력 데이터 스트림의 데이터 블록에서 LZ77 알고리즘과 허프먼 코딩의 조합을 사용한다.
LZ77 알고리즘은 중복된 문자열을 식별하고 이전에 표시된 위치에 대한 포인터인 역참조로 대체하며 문자열의 길이가 이어진다. 이후 허프먼 코딩은 일반적으로 사용되는 참조를 식별하고 이를 더 짧은 비트 시퀀스의 참조로 대체한다. 더 긴 비트 시퀀스는 자주 사용되지 않는 참조를 나타내기 위해 사용된다.
이미지 출처: https://www.youtube.com/watch?v=whGwm0Lky2s&t=851s
모든 메이저 브라우저들은 Gzip을 지원한다. Zipfli 알고리즘은 속도가 느리지만 Gzip과 호환되며 더 작은 용량의 파일을 만들어내는 Deflate/Gzip의 향상된 버전이다. Zipfli는 정적 압축에 적합하며 꽤 괜찮은 성능을 낸다.
Brotli
2015년에 구글은 Brotli 알고리즘과 Brotli 압축 포멧을 공개했다. Gzip처럼 Brotli 역시 LZ77 알고리즘과 허프만 코딩을 기반으로 한 무손실 압축 알고리즘에 2nd order context modeling 기법을 추가 사용하여 동일한 속도에 더 뛰어난 압축 성능을 제공한다. context modeling이란 동일 블록의 동일 알파벳에 여러 허프만 트리를 사용할 수 있게 하는 기능이다. Brotli은 백레퍼런스를 위해 더 큰 window 크기와 Static Dictionary를 지원하여 압축 알고리즘의 효율을 높혔다.
Brotli은 거의 모든 주요 브라우저에서 지원하며 많이 사용되고 있다. Brotli은 Netlify, AWS, Vercel을 포함한 호스팅 프로바이더나 미들웨어에서도 쉽게 이용할 수 있다.
대량의 트래픽을 처리해야 하는 OYO, Wix와 같은 웹 사이트들은 Gzip을 Brotli로 변경하여 성능을 향상시킬 수 있다.
Gzip 과 Brotli의 비교
아래의 표는 Brotli과 Gzip에서 각각의 압축 레벨에 따른 압축비와 속도를 벤치마킹한 표이다.
출처: Brotli Compression: How Much Will It Reduce Your Content?
추가로. 아래 내용은 크롬이 연구했던 Gzip과 Brotli을 이용해 JS를 압축하는 것에 대한 인사이트들이다.
- Gzip 9 레벨은 가장 좋은 압축 비율과 속도를 제공한다. 9 레벨을 사용할 것을 추천한다.
- Brotli을 사용할 때에는 6-11 레벨을 고려하자. Gzip과 비교하여 조금 더 압축되며 빠르다.
- 전반적인 크기는 Brotli 9-11 레벨이 Gzip보다 훨씬 좋으나, 속도가 느리다.
- 번들이 클 수록 더 나은 압축 비율과 속도가 나온다.
- 알고리즘간의 비교 결과는 모든 번들 크기에서 유사하게 나왔다 (예를 들어 Brotli 7 은 모든 번들 크기에서 Gzip 9 보다 나았고. Gzip 9는 모든 번들 크기에서 Brotli 5 보다 빨랐다)
이제 서버와 브라우저 간에 선택한 압축 형식에 대한 통신을 살펴보자.
압축 활성화하기
빌드 과정에서 정적 압축을 적용할 수 있다. 만약 Webpack을 사용하고 있다면 Gzip을 위해 CompressionPlugin을 쓸 수 있고. Brotli 압축을 위해 BrotliWebpackPlugin을 사용할 수 있다. 해당 플러그인들은 아래처럼 Webpack 설정에 포함할 수 있다.
module.exports = {
//...
plugins: [
//...
new CompressionPlugin()
]
}
Next.js는 기본적으로 Gzip압축을 지원하지만. HTTP 프록시에서 따로 활성화해주는 것을 추천하고 있다. Vercel 플랫폼에서는 proxy 레벨에서 Gzip, Brotli모두 지원하고 있다.
또 Node.js를 포함한 웹 서버에서도 동적 무손실 압축을 적용할 수 있다. 브라우저는 요청 헤더의 Accept-Encoding 을 통해 지원하는 알고리즘 정보를 서버에 보낸다.
Accept-Encoding: gzip, br
위는 브라우저가 Gzip과 Brotli을 지원한다는 뜻이다. 개발자는 가이드 문서를 참고하여 웹서버에 다양한 압축을 지원하도록 할 수 있다. 예를 들어 Apache 서버에 Brotli을 적용하는 방법, Express에는 compression 미들웨어를 적용하는 등으로 말이다. 해당 문서들을 참고하여 요청되는 리소스들을 압축하도록 하자.
Brotli은 다른 알고리즘에 비해 더 많이 압축되기때문에 사용하길 추천한다. 브라우저가 Brotli을 지원하지 않는 경우를 위해 Gzip을 폴백으로 사용할 수 있다. 서버에 정상적으로 세팅되어 있다면 서버는 Content-Encoding 응답 헤더를 통해 리소스가 어떤 알고리즘으로 압축되었는지를 알려준다.
Content-Encoding: br
압축 감사
크롬 브라우저의 개발자 도구 > 네트워크 > 헤더 탭에서 서버가 리소스를 압축했는지 여부를 확인할 수 있다. 개발자 도구는 아래 스크린샷과 같이 content-encoding 응답 헤더를 보여준다.
Lighthouse에서는 “Enable Text Compression” 항목에서 텍스트 기반 리소스들이 content-encoding 헤더 없이 응답된 경우 경고를 보여준다. 또 Gzip을 사용할 경우 트래픽을 얼마나 절약할 수 있는지도 알려준다.
출처: https://web.dev/uses-text-compression/#how-to-enable-text-compression-on-your-server
JavaScript 압축 및 로드 세분성
JavaScript의 압축 효과를 완전히 이해하고 극대화하기 위해서는 라우팅 기반 코드 분할, 일반 코드 분할, 번들링과 같은 다른 JavaScript의 최적화들에 대해서도 고민해야 한다.
모던 웹 앱들은 대부분 많은 양의 JavaScript를 포함하고 있고. 이 코드들을 효율적으로 로드하기 위해 다양한 코드 분할 및 번들링 기술을 사용하는 경우가 많다. 앱은 SPA 기반에서 라우팅 경로와 같이 논리적인 경계를 사용하거나, 인터렉션 혹은 뷰포트에 노출됨에 따라 점진적으로 JavaScript코드를 다운로드 받도록 하는 등으로 코드를 분할한다. 이런 경계를 인식하도록 번들러를 구성할 수 있다.
이런것들이 압축에 미치는 영향을 살피기 전에, 코드 분할 및 번들링과 관련된 몇가지 기본 정의들을 집고 넘어가도록 하자.
번들링 용어들
아래는 이야기하고자 하는 내용과 관련된 주요 용어들을 정리한 것이다.
- 모듈: 모듈은 견고한 추상화 및 캡슐화를 제공하도록 설계된 개별 기능이다. 자세한 내용은 모듈 패턴을 참고하자.
- 번들: 번들러에서 로드 및 컴파일 프로세스를 거친 최종 소스파일을 포함하는 개별 모듈 그룹.
- 번들 분리: 각 번들이 독립적으로 게시되고 다운로드되거나 캐시될 수 있도록 번들러에서 수행하는 처리 과정.
- 청크: 번들링과 코드 분리의 최종 결과물을 웹팩에서 지칭하는 용어임. 웹팩은 번들들을 entry설정, SplitChunkPlugin, 동적 import기준으로 청크로 변환한다.
소스 파일에 모듈이 포함된 경우. 코드 혹은 번들 분리 처리가 끝난 후 최종 결과물을 청크라고 한다. 참고로 소스 파일과 청크는 서로 종속될 수 있다.
출처: https://www.youtube.com/watch?v=ImjzA7EMI6I&list=PLyspMSh4XhLP-mqulUMcaqTbLo-ZJxSX5&index=29
JavaScript 빌드 결과물의 크기는 청크의 크기 혹은 JavaScript 번들러 혹은 컴파일러의 최적화를 거친 소스의 크기에 달려 있다. 크기가 큰 앱은 독립적으로 로드 가능한 JavaScript파일의 청크로 구성된다. 로드할 JavaScript를 세분화 한다는 것은 청크의 수를 의미한다. 청크가 많을수록 각 청크의 크기가 작아지며 더 자주 로드하게 된다.
어떤 청크는 더 자주 로드되거나 앱의 주요 로직을 담고 있기 때문에 (예를 들어 결제 위젯) 다른 청크보다 더 중요할 수 있다. 어떤 청크가 중요한지 알기 위해서는 앱에 대해 이해하고 있어야 하지만. 기본적인 기능을 담고 있는 청크가 중요하다고 생각하는 것도 안전한 방법이다.
페이지에서 필요로 하는 청크들은 사용자의 단말에서 다운로드 후 구문 분석과 실행되어야 한다. 이는 앱의 성능에 직접적인 영향을 끼친다. 청크들은 앱의 런타임 동안 결국 다운로드 될 것이므로 다운로드 속도를 위해 미리 압축해두자.
지금까지 다룬 배경들을 토대로 청크를 잘게 나누는 것과 압축 사이의 관계에 대해서 알아보자.
청크 세분화의 트레이드오프
이상적으로. 청크 세분화는 서로 상충되는 다음의 목표들을 달성하는 것을 목표로 해야 한다.
- 다운로드 속도 향상: 이전 섹션에서 보았듯이 압축을 통해 다운로드 속도를 개선할 수 있다. 다만 여러 작은 청크를 압축하는 것 보다 하나의 큰 코드를 압축하는 것이 더 좋은 결과를 보인다.
compress(a + b) <= compress(a) + compress(b)
“조사에 따르면 작은 청크는 5%에서 10% 정도의 효율 손해를 볼 수 있다고 한다. 번들되지 않은 청크는 최대 20%까지 크기가 증가할 수 있다. 또 큰 청크에 대해서는 공유되는 청크에 추가적인 IPC, I/O 및 처리 비용이 추가된다. v8 엔진에는 30K의 스트리밍 및 구문 분석 임계값이 존재한다. 이것은 30K보다 작은 용량의 청크는 중요도에 관계 없이 Critical Loading Path 과정에서 구문분석을 하게 된다.”
“위의 이유로 인해 크기가 큰 청크가 작은 청크보다 다운로드 및 브라우저 성능의 최적화 관점에서 더 효율적일 수 있다”
- 캐시 히트 및 캐시 효율 향상: 작은 크기의 의 번들은 JS를 점진적으로 로드하는 앱에서 더 나은 캐시 효율을 보인다.
- 코드 변경 사항들은 작은 청크들로 분리될 수 있다. 코드 변경 시 변경이 이루어진 청크만 다시 다운로드 받으면 되며 이런 코드들의 크기는 작을 가능성이 높다. 나머지 코드들은 캐시 히트가 되므로 히트 수가 증가한다.
- 청크 크기가 클수록 코드 변경 시 다운로드 해야 하는 경우가 자주 생긴다.
따라서 캐시 효율성을 고려하면 더 작은 청크를 만드는 것이 좋다.
- 빠른 실행 속도: 코드가 빨리 실행되려면 다음의 요구사항을 만족해야 한다.
- 모든 종속 모듈이 빠르게 사용가능해야 한다 - 해당 종속 모듈들은 함께 묶여 다운로드되거나 캐시히트된다. 이는 관련된 코드를 모두 하나의 큰 청크로 묶어야 함을 의미한다.
- 페이지 혹은 라우팅 경로상에서 필요로 하는 코드만을 실행해야 한다. 불필요 추가 코드가 다운로드되거나 실행되어선 안된다.
Common
청크는 보통 대부분의 페이지에서 필요로 하지만 ‘모든’ 페이지에서 필요로 하진 않다. 따라서 작은 청크 크기를 위해 코드 중복을 최소화 해야 한다. - 메인 스레드에서 롱 태스크로 오랫동안 블록하는 경우 작은 청크로 나누어야 한다.
출처: https://www.youtube.com/watch?v=ImjzA7EMI6I&list=PLyspMSh4XhLP-mqulUMcaqTbLo-ZJxSX5&index=29
위의 삼각형이 표현하듯 너무 한쪽으로 치우칠 경우 다른 쪽을 놓치게 될 수 있다. 이 것이 청크 세분화에 따른 트레이드오프 문제이다.
De-duplication and caching are at odds with browser performance and compression.
이런 고려사항들 때문에 대부분 프로덕션 앱에서 사용하는 청크의 수는 약 10개 정도이다. 많은 양의 JavaScript를 필요로 하는 앱에 더 나은 캐싱 및 중복 제거를 지원하기 위해 이 숫자는 더 늘어날 수 있다.
SplitChunksPlugin과 세분화 된 청크 만들기
아래 요구사항들을 만족 시키는 것은 청크 세분화에 대한 트레이드오프를 해결할 수 있는 방법이 될 수도 있다.
- 성능에 영향을 주지 않는 선에서 더 나은 캐싱과 중복 제거를 위해 작은 크기의 여러 청크(40개 ~ 100개)를 사용한다.
- 여러 스크립트 태그를 처리할 때 드는 IPC, I/O 및 처리 비용에 대한 성능 오버헤드를 해결한다.
- 여러 작은 청크로 나눌 때 발생하는 압축률 손실을 해결한다.
이런 요구 사항들을 만족하는 솔루션은 아직 작업 중에 있다. 그러나 Webpack v4의 SplitChunksPlugin 및 세분화 된 청킹 전략은 청크 로드 세분성을 어느정도 증가시키는 데 도움이 될 수 있다.
웹팩 초기 버전은 공통 의존 모듈을 단일 청크로 묶기 위해 CommonsChunkPlugin
을 사용했다. 때문에 공통 모듈을 쓰지 않는 페이지의 다운로드 및 실행 시간이 불필요하게 증가할 수 있었다. 이를 개선하기 위해 웹팩 4 버전에서는 SplitChunksPlugin
을 도입했다. 이제 기본 설정 혹은 수동 설정 기반으로 분리된 여러 청크들은 다양한 라우팅 경로에서 중복으로 로드되지 않도록 되었다.
Nextjs는 SplitChunksPlugin
을 채택하여 다음과 같은 청크 세분화 전략을 사용해 위에서 언급했던 세분성 트레이드오프를 해결하는 청크들을 생성한다.
- 160KB 보다 큰 서드 파티 모듈은 개별 청크로 분리함
- react, react-dom과 같은 프레임워크 의존 모듈을 위한 별도의 청크 생성
- 최대 25개 까지의 공유 청크가 생성됨
- 청크로 만들어지는 모듈의 최소 크기가 20KB로 변경됨
단일 청크 대신 여러 공유 청크를 사용하면 관련이 없는 페이지에서 다운로드되거나 실행되는 불필요 혹은 중복 코드의 양이 최소화된다. 크기가 큰 서드파티 라이브러리에 대한 독립적인 청크를 생성하면 캐시 효율을 높일 수 있다. 압축 효율 최대화를 위해 20KB의 최소 청크 크기를 사용한다.
이러한 청크 세분화 전략은 여러 Nextjs웹 사이트가 JavaScript의 크기를 줄이는 데 큰 도움이 되었다.
웹사이트 | 최종 JS 크기 변화량 | 변화량 % |
---|---|---|
https://www.barnebys.com/ | -238 KB | -23% |
https://sumup.com/ | -220 KB | -30% |
https://www.hashicorp.com/ | -11MB | -71% |
Gatsby 에서도 청크 세분화를 구현하여 비슷한 정도의 효과를 나타내고 있다.
결론
리소스 압축만으로 모든 JavaScript 성능 문제를 해결할 수는 없다. 브라우저와 번들러가 백그라운드에서 동작하는 방식을 이해하면 더 나은 리소스 압축을 위한 번들링 전략을 만드는 데 도움이 될 수 있다. 청크 로딩 세분화 문제는 하나의 부분에서 해결하려 하기 보단 여러 관점에서 해결해야 한다. 청크 세분화는 그 방향으로 나아가는 한 단계이지만. 아직 갈 길이 멀다.