빠른 웹사이트 로딩을 위한 최적화 기술
웹 서핑을 하면서 이 사이트 괜찮은데? 하고 느껴지는 여러 웹 사이트들은 어떻게 해서 사용자들을 만족시킬 수 있었을까?
빠르고, 반응성 있고, 사용자 친화적인 웹사이트는 사용자들을 끌어들이고 유지하는 데에도 큰 도움이 되지만, 검색 엔진 랭킹과, 높은 전환율, 높은 사용자 경험을 이끌어낼 수 있다.
때문에 FE 작업을 하면서 기획 구현도 구현이지만, 엔지니어로써 가장 필요한 부분은 최적화에 관련된 부분을 빼놓고 가기엔 섭섭할 것 같다.
빠른 웹 사이트 로딩을 위해선 무엇이 필요할까?
- 빠른 BE API
- 빠른 네트워크 인프라
모두 맞는 말이지만 FE 개발을 하는 입장에서는 사실상 out of bound이기에, FE 개발의 입장으로써 최적화할 수 있는 방안을 최대한 고민해야 한다.
파일 최소화
웹 사이트의 로드 타임을 줄이는 데에 가장 큰 역할을 하는 것은 사용자에게 제공되는 파일들의 크기이다. 큰 파일들은 다운로드하는데 더 많은 시간이 소요되고 사용자 경험을 저해시킨다. 자바스크립트 파일들 또한 예외는 아니다.
그렇기 때문에 자바스크립트 파일들의 크기를 줄이는 것은 웹 사이트의 성능을 증대시키는 가장 기본적인 단계이다.
최소화 (Minification)은 불필요한 문자들을 제거하는 절차이다. 제거의 대상이 되는 문자들은
- 공백
- 주석
- 개행 문자(
\n
등)
최소화는 문자를 제거하는 것 뿐만 아니라 대치하는 것도 포함되는데, 긴 변수의 이름을 짧게 만들어서 파일 자체의 크기를 줄이되 자바스크립트 코드의 기능성 자체에는 문제가 없이 작동되도록 하는 과정도 포함되어 있다.
파일 압축
파일 압축은 파일의 크기를 줄이기 위한 또 다른 기법이다.
파일에 포함된 데이터들을 알고리즘을 적용해 압축함으로써, 최소화와 마찬가지로 파일들이 똑같이 기능하는 것을 전제하에 파일의 크기를 줄이는 기법이다.
압축된 파일들이 브라우저에 의해 요청될 때 압축이 해제가 되고, 컨텐츠가 렌더되고 실행되는데 아무 이상이 없는 것처럼 느끼게 한다.
가장 널리 사용되는 압축 알고리즘에는 두 가지 종류가 있다.
- Gzip: 오랜 기간 동안 사실 상 표준 압축 알고리즘이었다.
- Brotli: 구글이 개발한 새로운 압축 알고리즘으로, Gzip에 비해 뛰어난 압축 비율과 속도 때문에 빠르게 도입되고 있는 추세이다.
압축 알고리즘에 대해 조금 더 다뤄보자면..
Gzip
Gzip은 자바스크립트 파일들의 크기를 줄이기 위해 널리 사용되고 있는 압축 알고리즘이다. Gzip은 내부적으로 LZ77과 Huffman 인코딩을 사용해서 데이터를 보다 효율적으로 압축할 수 있다고 알려진 Deflate 알고리즘을 사용한다.
Brotli
Brotli 알고리즘은 구글에 의해 개발된 새로운 버전의 압축 알고리즘이다. Gzip 보다 더 뛰어난 압축 비율을 제공한다.
Brotli 또한 LZ77과 Huffman 인코딩을 사용하지만, novel context modeling 기법을 더해 더 높은 압축율을 달성할 수 있었다고 한다.
번들링
각각의 네트워크 요청들은 latency를 더하고, 네트워크 bandwidth를 소모하므로 네트워크 요청 횟수 자체를 줄이는 것 또한 웹 사이트 경험을 향상시키는 중요한 요소 중 하나이다.
번들링이란?
번들링은 다수의 자바스크립트 파일들을 하나의 파일로 묶는 것이다. 이는 브라우저가 페이지를 렌더하기 위해 필요한 네트워크 요청 횟수를 줄이는데 큰 도움이 되기 때문에, 로딩 속도를 빠르게 한다. 작은 크기의 여러 자바스크립트 파일이 있다면 그 효과는 더 극대화될 것이다.
번들링하려면
자바스크립트 파일들을 번들링할 수 있는 유명한 툴들이 몇 가지 있다.
- Webpack : 웹팩은 자바스크립트 파일 뿐만 아니라 스타일 시트와 이미지 같은 다른 에셋들도 다룰 수 있는강력하고 유연한 모듈 번들러이다. robust plugin 에코 시스템을 가지고 있기 때문에 원하는 대로 확장성을 늘릴 수 있다는 것이 장점이다. 웹팩 플러그인을 사용해서 js 파일과 css 파일을 HTML에 포함시킨 예시
- Rollup : 롤업은 또 다른 유명한 자바스크립트 모듈 번들러이다. 롤업은 간단함과 성능에 중점을 두고 있기 때문에 라이브러리들을 번들링하는데 부분적으로 최적화되어 있고, 다른 여러 포맷들도 다룰 수 있다.
요청 자체의 횟수를 최소화하여 브라우저가 필요한 리소스들을 다운로드하고 처리하는 시간을 줄일 수 있다. 그리고 이는 더 빠른 로드 타임과 반응형 유저 경험을 가져다 준다.
이미지와 아이콘에 스프라이트 사용하기
스프라이트들은 아이콘이나 UI 요소처럼 보다 작은 다수의 이미지들을 포함하고 있는 단일 이미지 파일이다. 그리고 이미지 스프라이트를 사용하는 것은 네트워크 요청 횟수를 줄이는 또 다른 기법이다.
이미지 스프라이트란
이미지 스프라이트는 보다 작은 이미지 여러 개를 그리드 패턴처럼 나열하여 포함하고 있는 큰 이미지이다. 그리고 사용할 때에는 CSS나 JS 코드 상에서 개별의 이미지를 스프라이트 상의 위치를 통해 참조할 수 있다.
이런 기법은 여러 개의 이미지를 하나의 HTTP 요청을 통해 불러올 수 있기 때문에 latency를 개선하고 로드 타임을 향상시킨다.
이미지 스프라이트 사용 예시
.icon {
width: 32px;
height: 32px;
background-image: url('icons.png');
}
.icon-search {
background-position: 0 0;
}
.icon-settings {
background-position: -32px 0;
}
.icon-user {
background-position: -64px 0;
}
상기 예제에서 각각의 아이콘 클래스는 스프라이트 이미지 내부에서 상응하는 아이콘의 위치를 기술한다. 이런 방법을 통해 CSS를 통해 원하는 이미지를 추가적인 HTTP 요청 없이 표시할 수 있다.
리소스의 지연 로딩
지연 로딩 (lazy loading)은 별로 중요하지 않은 리소스들에 대해 실제로 필요하기 전까지 로딩하지 않는 기법을 말한다.
- 모든 리소스들을 한꺼번에 로딩하는 것보다
- 당장 표시해줘야 할 뷰에 대한 리소스들만 로딩하고
- 나머지 리소스들은 쓸 일이 생기면 fetch하여 사용한다.
지연 로딩은 초기 로드 타임을 굉장히 크게 개선할 수 있고, 특히 이미지들과 긴 스크립트와 같이 큰 에셋들이 포함되어 있는 경우에 유효하다.
지연 로딩 사용 예시
지연 로딩을 사용하기 위해 이미지들을 뷰 포트 내부에 보이게 되었을 때에만 로딩하는 예시를 살펴보자.
IntersectionObserver
API를 통해서 이런 종류의 지연 로딩을 구현할 수 있다.
1. 이미지 요소들에 대해
<img data-src="image.jpg" class="lazy-load" alt="example">
지연 로딩을 적용할 이미지 태그에 data-src
어트리뷰트를 추가해서 실제 이미지 소스를 연결시킨다.
2. 스크립트 작성
document.addEventListener('DOMContentLoaded', function () {
const lazyImages = [].slice.call(document.querySelectorAll('.lazy-load'));
if ('IntersectionObserver' in window) {
const lazyImageObserver = new IntersectionObserver(function (entries, observer) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove('lazy-load');
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function (lazyImage) {
lazyImageObserver.observe(lazyImage);
});
}
});
상기 스크립트에서 IntersectionObserver
는 .lazy-load
클래스를 가진 모든 이미지들이 뷰포트에 진입하는지를 감시한다.
이미지가 감지되면 data-src
어트리뷰트가 src
어트리뷰트로 할당이 되고 실제 이미지의 다운로드를 유발시킨다.
이미지가 한번 로드된 이후에는 lazy-load
클래스는 제거되고, 이미지는 더 이상 옵저버의 관찰 대상이 아니게 된다.
간단한 지연 로딩 기법을 사용해서 실제 화면에 보여지는 이미지들만 로딩되는 것을 보장할 수 있고, 네트워크 요청 횟수를 줄이고 웹 사이트의 초기 로드 타임을 줄일 수 있다.
캐싱 사용하기
성능을 증대시키기 위해 가장 중요한 기법은 캐싱이다. 캐싱은 반복되는 요소들에 대한 다운로드를 하지 않아도 되기 때문에 로드 타임을 빠르게 한다.
브라우저 캐싱
브라우저 캐싱은 브라우저들이 웹 사이트의 이미지, 스타일 시트, 스크립트와 같은 리소스들의 복사본을 저장하도록 허용하는 것이다.
사용자들이 웹 사이트를 다시 방문할 때, 브라우저는 이런 리소스들을 다시 다운로드 하지 않고 캐시로부터 가져오기 때문에 로드 타임을 더 빠르게 하고 서버의 부하를 감소시킨다.
서버는 응답에 적절한 캐싱 헤더를 내려줌으로써 어떤 리소스가 캐싱 대상이 되고 또 얼마나 오랜 기간 캐싱될 지도 컨트롤 할 수 있다. 그리고 캐싱을 컨트롤 하기 위한 두 가지 중요한 헤더는 Cache-Control
과 ETag
이다.
Cache-Control 헤더
Cache-Control
헤더는 아래와 같은 캐시 디렉티브들을 설정하도록 허용한다.
- 캐시 안의 리소스의 최대 수명 (maximum age)
- 다시 유효화될 것인지에 대한 여부 (revalidate)
예를 들어 Cache-Control: public, max-age=3600
으로 설정한다면 리소스가 한 시간 동안 브라우저의 캐싱 대상이 되도록 설정하는 것이다.
ETag 헤더
ETag
헤더는 특정 버전의 리소스에 대해 유니크한 식별자를 제공한다.
브라우저가 리소스를 요청할 때에는 캐시 안의 ETag
값을 같이 보내게 되는데,
- 만약 서버의
ETag
값이 브라우저가 보낸ETag
값과 동일하다면304 Not Modified
상태를 보낸다. 이 때 브라우저는 캐시된 버전을 사용한다. - 서버의
ETag
값이 브라우저가 보낸ETag
값과 상이하다면 최신 버전의 리소스를 다운로드하게 한다.
비동기 로딩 활용하기
브라우저는 기본적으로 스크립트들을 동기적으로 로드하는데, 이는 스크립트가 온전히 로드되고 실행될 때까지 렌더링 과정을 블락킹한다.
비동기 로딩은 스크립트가 다른 리소스들과 병렬적으로 로드될 수 있도록 허용하여 렌더링을 블락하는 것을 방지하고 전체적인 로드 타임을 향상 시킨다.
Async 어트리뷰트 활용하기
async
어트리뷰트는 브라우저에게 렌더링 블락킹 없이 스크립트를 다운로드하도록 한다.
그리고 스크립트가 먼저 다 다운로드 된다면 브라우저는 스크립트를 실행하기 위해 렌더링을 일시 정지 시킨다.
async
어트리뷰트는 다른 스크립트에 의존성이 없거나 DOM이 다 로딩되지 않아도 되는 스크립트들에 유용하다.
Defer 어트리뷰트 활용하기
defer
어트리뷰트는 브라우저에게 렌더링 블락킹 없이 스크립트를 다운로드하도록 하지만 DOM이 전부 파싱된 이후에 실행하도록 한다.
defer
어트리뷰트는 스크립트가 다른 스크립트와 의존 관계를 가지고 있거나 DOM에 의존적인 스크립트에 유용하다.
DOM 최적화
효율적인 DOM 조정은 웹사이트 성능을 향상시키기 위해서는 필수적이다.
DOM은 웹 페이지의 구조를 표현하기 때문에 이를 조정하는 것은 리소스를 많이 소모한다. 그렇기 때문에 DOM을 직접적으로 최적화하기 보다는 이를 다루는 자바스크립트 코드를 최적화하여 성능에 미치는 영향을 최소화하고 부드러운 유저 경험을 만들 수 있다.
DOM 최적화 예시
// Inefficient DOM manipulation
const list = document.querySelector('#list');
for (let i = 0; i < 1000; i++) {
const item = document.createElement('li');
item.textContent = `Item ${i}`;
list.appendChild(item);
}
// Efficient DOM manipulation
const list = document.querySelector('#list');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const item = document.createElement('li');
item.textContent = `Item ${i}`;
fragment.appendChild(item);
}
list.appendChild(fragment);
상기 예시에서는 DocumentFragment
를 통해 DOM 연산들을 배치하도록 함으로써 reflow와 repaint 횟수를 줄일 수 있었다.
브라우저의 개발자 도구를 통한다면 병목 지점을 확인하고 코드의 어떤 부분이 향상될 수 있는지를 쉽게 확인할 수 있다. 꼭 활용해보자.