들어가며
성능 최적화에 대해서 이야기할 때, 백엔드와 프론트엔드에서 접근하는 관점이 꽤 다릅니다.
예를 들어 백엔드에서는 데이터베이스 쿼리를 최적화나 서버 리소스 관리에 대해 주요하게 고려한다면, 프론트엔드에서는 페이지의 로딩 시간을 단축하여 사용자 경험을 향상 시키는 것에 초점을 맞추곤 하죠.
이처럼 프론트엔드 성능에 영향을 미치는 여러 요소 중에서도, 자바스크립트가 DOM 구성을 차단하는 현상은 특히 중요합니다.
구글의 연구에 따르면 페이지 로딩 시간이 3초 이상 지연되면, 절반 이상의 사용자가 페이지를 이탈한다고 해요.
이번 글에서는 자바스크립트가 DOM 구성을 어떻게 차단하게 되는지, 이를 어떤 기법을 사용해서 최적화할 수 있는 지에 대해서 다루어보려고 합니다.
자바스크립트와 DOM 렌더링의 관계
Critical Rendering Path
먼저 Critical Rendering Path(CRP)의 개념에 대해서 짚고 넘어가겠습니다.
CRP는 브라우저가 HTML, CSS, JavaScript를 처리해서 화면에 렌더링을 하기까지의 과정을 의미하는데요.
렌더링을 하는 일련의 단계이기에 웹 페이지의 초기 로딩이 얼마나 빠르게 되는지를 결정짓는 핵심적인 과정이라고 할 수 있습니다.
CRP의 단계는 다음과 같이 구성됩니다.
- HTML 파싱, DOM 트리 구축
- CSS 파싱, CSSOM 트리 구축
- JavaScript 실행
- 렌더 트리 생성
- 레이아웃 계산
- 페인팅 (실제 렌더링에 해당)
여기서 파싱(Parsing)이란, 텍스트 형태의 코드(HTML, CSS, JavaScript)를 브라우저가 처리할 수 있도록 데이터 구조로 변환하는 과정을 의미합니다.
<div>
<p>Hello World</p>
</div>
위와 같은 텍스트 형태의 코드를 브라우저는 파싱을 통해 트리 구조의 DOM으로 변환합니다.
이로써 구조화된 데이터를 이용해 브라우저는 웹 페이지를 렌더링 할 수 있게 되는 것이죠.
자바스크립트가 DOM 구성을 어떻게 차단할까
브라우저가 코드를 파싱 하는 과정에서 script 태그를 만나면, 파싱이 중단되게 됩니다.
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div>첫 번째 콘텐츠</div>
<script src="script.js"></script> <!-- 여기서 파싱 중단 -->
<div>두 번째 콘텐츠</div>
</body>
</html>
위는 HTML 코드 사이에 script 태그가 껴있는 모습인데요.
HTML 파싱이 위에서 아래로 순차적으로 이루어지는데, script 태그를 만나면 진행 중이던 파싱은 중단이 됩니다.
그리고 JavaScript 실행에 필요한 파일을 다운로드하고, 실행까지 하게 되죠.
실행이 모두 완료된 이후에야 아까 진행 중이었던 파싱이 마저 이어지게 됩니다.
이러한 동작이 필요하지 않은 것은 아닙니다.
JavaScript는 DOM을 조작할 수 있기 때문에, 파싱을 중단하지 않으면 정작 JavaScript가 DOM 요소를 참조하려 할 때 아직 원하는 DOM 요소가 생성되어있지 않아서 오류가 발생할 수도 있어요.
결국 JavaScript 는 파싱을 중단시키는 요소이기 때문에, 파서 차단 리소스라고 불립니다.
이러한 차단은 JavaScript 파일이나 스크립트가 많아질수록 페이지 로딩 시간을 크게 증가시킬 수 있어요.
사용자는 스크립트의 실행이 완료될 때까지 페이지의 일부 콘텐츠를 보지 못할 수도 있습니다.
따라서 CRP를 최적화하기 위해서 JavaScript의 타이밍을 적절하게 제어하는 것이 중요합니다.
로딩 최적화 전략
위에서 살펴본 것처럼, JavaScript는 DOM을 구성하는 과정을 중간에 중지시킬 수 있어서, 페이지 로딩 시간에 직접적인 영향을 끼치게 됩니다.
현대 웹 개발에서는 이러한 문제를 해결하기 위해서 다양한 로딩 최적화 전략을 사용하는데요.
그중에서도 이 글에서는 비동기 로딩, 지연 로딩, 동적 임포트, 프리로딩에 대해서 살펴보려 합니다.
비동기 로딩(Async Loading)
비동기 로딩은 JavaScript 파일을 비동기적으로 로드해서 DOM 파싱 과정을 중간에 차단하지 않도록 하는 전략입니다.
script 태그 안에 async 속성을 추가함으로써 구현할 수 있어요.
<script async src="analytics.js"></script>
일반적으로 script 태그는 DOM 파싱을 차단하지만, async 속성이 추가되면 스크립트가 별도의 스레드에서 병렬적으로 다운로드됩니다.
다운로드가 다 되어서 실행할 준비가 되었다면, 그제야 DOM 파싱을 중단하고 실행되게 되죠.
다운로드 시간이 길게 걸리는 동안 DOM 파싱까지 중지시키며 시간을 낭비할 필요 없이, 스크립트를 다운로드하는 동안에는 파싱을 진행할 수 있습니다.
이러한 비동기 로딩의 특징 중 하나는 실행 순서가 보장되지 않는다는 것입니다.
예를 들어서 async 속성을 추가한 script 태그가 여러 개라면, 이 script 태그들은 완료되는 순서대로 실행되게 됩니다.
원하는 특정 순서가 있었다면, 지켜지지 않을 수 있기 때문에 실행 순서가 중요한 경우에는 비동기 로딩이 적합하지 않을 수 있어요.
하지만 비동기 로딩은 DOM에 의존성이 없는 독립적인 기능을 구현할 때, 페이지 초기 로딩에 굳이 없어도 되는 기능을 구현할 때에는 유용할 수 있겠습니다.
지연 로딩(Defer Loading)
지연 로딩은 비동기 로딩처럼 스크립트 다운로드는 병렬적으로 별도로 진행하되, 실행은 DOM 파싱이 모두 완료된 이후에 수행하는 전략입니다.
만약 여러 개의 지연 로딩 스크립트가 있다면, HTML 문서에 작성된 순서대로 진행이 돼요.
script 태그 안에 defer 속성을 추가함으로써 구현할 수 있어요.
<script defer src="main.js"></script>
<script defer src="component.js"></script>
예를 들어 위와 같은 코드를 보겠습니다.
비동기 로딩이었다면 두 스크립트 중에서 먼저 완료되는 것이 먼저 실행이 되었겠지만, defer 속성이 적용된 지연 로딩에서는 위쪽부터 순서대로 main.js, component.js 순으로 실행되게 됩니다.
이러한 지연 로딩은 DOM이 완전히 구성된 이후에야 스크립트를 실행하기 때문에, 필요한 스크립트로 DOM을 조작할 때에 유용할 수 있습니다.
그리고 실행 순서도 보장되기 때문에 스크립트와 스크립트 간의 의존성이 있는 경우에도, 안전하게 실행할 수 있을 것입니다.
하지만 페이지 로딩 완료 전에 실행을 해놓아야 하는 스크립트의 경우라면 적합하지 않을 수 있어요.
예를 들어 다크모드에 지연 로딩을 적용했다면, DOM을 완전히 구성한 이후에 스크립트가 그제야 실행되어서 사용자는 기본테마를 보게 되었다가 이후에 다크모드 테마로 바뀌는 경험을 하게 될 것입니다.
또한 보안적으로 중요한 페이지의 경우, 지연 로딩을 적용했다면 일반 사용자에게 보호된 콘텐츠가 노출될 수도 있을 것입니다.
장단점이 존재하지만, SPA(싱글 페이지 애플리케이션)에서 초기 로딩 성능을 개선하는 데에 효과적이어서 널리 사용됩니다.
왜냐하면 SPA는 처음부터 많은 양의 JavaScript 코드를 로드해야 하는 특징이 있어서 초기 로딩이 느리다는 단점이 있는데, 지연 로딩을 적용하여 사용자가 페이지 레이아웃을 더 빠르게 볼 수 있도록 해서 단점을 보완할 수 있기 때문이에요.
동적 임포트(Dynamic Import)
동적 임포트는 JavaScript 코드를 필요한 시점에 로드하게 하는 전략입니다.
예를 들어 사용자가 특정 인터랙션을 시도했을 때에나, 특정 조건을 만족했을 때에만 필요한 코드를 로드할 수 있는데요.
Promise를 반환하는 import() 함수를 사용하여 구현할 수 있습니다.
button.addEventListener('click', async () => {
const { drawChart } = await import('./chart.js');
drawChart(data);
});
위 예시를 보면, 이 버튼을 클릭했을 때에 차트와 관련된 라이브러리를 import 하도록 하고 있습니다.
이러한 동적 임포트의 장점은 초기 페이지를 로드할 때 다운로드해야 하는 JavaScript 코드를 줄이고, 필요한 시점에 나누어서 다운로드할 수 있다는 것입니다.
현대의 빌드 도구들은 이러한 동적 임포트를 자동으로 감지해서 분할된 코드 조각인 청크(chunk)로 분류하고, 필요한 시점에서 자동으로 로드하도록 처리합니다.
이렇게 애플리케이션의 코드를 여러 개의 작은 번들로 나누는 것을 코드 분할(Code Splitting)이라고 합니다.
번들링과 번들
하나의 웹 서비스에는 수많은 개별 파일들이 존재합니다.
이 파일들을 그대로 웹 브라우저에 전송하면 네트워크의 부하가 커지고 로딩 순서를 보장하기 어려워집니다.
따라서 이러한 문제를 해결하기 위해 여러개의 자바스크립트 파일을 하나 또는 적은 수의 파일로 합치는데, 이 과정을 번들링이라고 합니다.
그리고 이렇게 생성된 결과물을 번들이라고 합니다.
마치 여러 개의 파일이 종이 레시피라면, 그 레시피를 다 모아서 하나의 요리책으로 만들어 낸 게 번들이라고 비유할 수 있을 것 같아요.
번들링 과정에서 코드 최적화, 중복 제거, 의존성 관리 등은 자동으로 수행됩니다.
하지만 너무 빈번한 동적 임포트는 오히려 성능 저하를 일으킬 수 있으니, 적절한 적용이 필요합니다.
프리로딩
일반적으로 브라우저가 HTML을 파싱 하다가 스크립트를 발견했을 때 다운로드를 시작하는데, 프리로딩을 사용하면 이러한 과정 없이 바로 다운로드를 시작할 수 있습니다.
프리로딩으로 JavaScript 파일을 미리 다운로드해 두면, 실제 스크립트가 필요한 시점에서 다운로드를 할 필요 없이 바로 실행을 시킬 수 있습니다.
프리로딩은 link 태그에서 rel="preload" 속성을 사용하여 구현할 수 있습니다.
<link rel="preload" as="script" href="critical.js">
하지만 모든 리소스를 프리로딩하면 오히려 성능을 떨어뜨릴 수 있으니 조심해야 해요.
정말 중요해서, 곧 필요할 것 같은 리소스에 대해서만 프리로딩을 적용하는 게 좋습니다.
예를 들어 SPA의 경우, 다른 페이지에 접속하기 위해 특정 링크에 마우스를 올려놓았을 때 해당 페이지에 필요한 JavaScript를 미리 로드해서 페이지 전환 시 발생하는 지연 시간을 줄일 수 있을 것입니다.
맺음말
이번 글에서는 비동기 로딩, 지연 로딩, 동적 임포트, 프리로딩과 같은 다양한 최적화 전략을 살펴보았는데요.
각각 장단점이 있고, 필요한 상황이 있어서 적절한 전략을 적용하는 게 중요할 것 같습니다.
개인적으로 지금까지 각각의 전략들을 모두 따로 이해하고 적용해 왔었는데, 이렇게 모아놓고 비교를 해보니 각각의 장단점들을 뚜렷하게 확인할 수 있었던 기회가 된 것 같습니다.
ref:
https://developer.mozilla.org/ko/docs/Web/HTML/Attributes/rel/preload
https://developer.mozilla.org/ko/docs/Web/Performance/Critical_rendering_path