문서와 리소스 로딩

woolee의 기록보관소·2023년 2월 25일
0

FE개념정리

목록 보기
35/35

참고
DOMContentLoaded, load, beforeunload, unload 이벤트
defer, async 스크립트

1. DOMContentLoaded, load, beforeunload, unload 이벤트

HTML 문서의 생명주기엔 다음과 같은 3가지 주요 이벤트가 관여한다.

  • DOMContentLoaded : 브라우저가 HTML을 전부 읽고 DOM 트리를 완성하는 즉시 발생한다. 이미지 파일이나 스타일시트 등의 기타 리소스들은 기다리지 않는다.
  • load : HTML로 DOM 트리를 만들고 나서 이미지, 스타일시트 등 외부 리소스도 모두 불러온 뒤에야 발생한다.
  • beforeunload/unload : 사용자가 페이지를 떠날 때 발생한다.

DOMContentLoaded 이벤트는 document 객체에서 발생한다. 그러므로 이 이벤트를 다루려면 addEventListener를 사용해야 한다.

document.addEventListener("DOMContentLoaded", func)

DOMContentLoaded에는 몇가지 특이사항이 존재한다.

DOMContentLoaded와 scripts

브라우저는 HTML 문서를 처리하는 도중에 <script> 태그를 만나면, DOM 트리 구성을 멈추고 <script> 를 실행한다. 스크립트 실행이 끝난 후에야 나머지 HTML 문서를 처리한다. <script>에 있는 스크립트 코드들이 DOM 조작과 관련된 로직을 담당하고 있기 때문에 이런 프로세스를 지니는 것이다. 따라서 DOMContentLoaded 이벤트 역시 <script> 안에 있는 스크립트 코드가 처리되고 난 후에 발생한다.

스크립트가 모두 실행되고 난 후에야 DOMContentLoaded 이벤트가 발생한다.

예외(DOMContentLoaded를 막지 않는 스크립트)는 존재한다.

  • async 속성이 있는 스크립트는 DOMContentLoaded 를 막지 않는다.
  • document.createElement('script')로 동적으로 생성되고 웹페이지에 추가된 스크립트는 DOMContentLoaded를 막지 않는다.

DOMContentLoaded와 styles

외부 스타일시트는 DOM에 영향을 주지 않기 때문에 DOMContentLoaded는 외부 스타일시트가 로드되는 걸 기다리지 않는다.

다만 한 가지 예외가 있다. 스타일시트를 불러오는 태그 바로 다음에 스크립트가 위치하면 이 스크립트는 스타일시트가 로드되기 전까지 실행되지 않는다.

<link type="text/css" rel="stylesheet" href="style.css">
<script>
  // 이 스크립트는 위 스타일시트가 로드될 때까지 실행되지 않습니다.
  alert(getComputedStyle(document.body).marginTop);
</script>

스크립트에서 스타일에 영향을 받는 요소의 프로퍼티를 사용할 가능성이 있기 때문이다.

기본적으로 DOMContentLoaded는 스크립트가 로드되기를 기다린다. 위 경우라면 당연히 스타일시트를 기다린다.

브라우저 내장 자동완성

Firefox와 Chrome, Opera의 폼 자동완성(form autofill)은 DOMContentLoaded에서 일어난다.

실행해야 할 스크립트가 길어서 DOMContentLoaded 이벤트가 지연된다면 자동완성 역시 뒤늦게 처리된다.

window.onload

window 객체의 load 이벤트는 스타일, 이미지 등의 리소스들이 모두 로드되었을 때 실행된다. load 이벤트는 onload 프로퍼티를 통해서도 사용할 수 있다.

window.onunload

window 객체의 unload 이벤트는 사용자가 페이지를 떠날 때, 즉 문서를 완전히 닫을 때 실행된다. unload 이벤트에선 팝업창을 닫는 것과 같은 딜레이가 없는 작업을 수행할 수 있다.

unload 이벤트는 사용자가 페이지를 떠날 때 발생하기 때문에
navigator.sendBeacon(url, data)와 같은 메서드를 사용하기에 좋다.

let analyticsData = { /* 분석 정보가 담긴 객체 */ };

window.addEventListener("unload", function() {
  navigator.sendBeacon("/analytics", JSON.stringify(analyticsData));
};
                        
/*
1. 요청은 POST 메서드로 전송된다. 
2. 요청 시 문자열 뿐만 아니라 폼이나 fetch에서 설명하는 기타 포맷들도 보낼 수 있다. 대개는 문자열 형태의 객체가 전송된다. 
3. 전송 데이터는 64kb를 넘을 수 없다. 
*/

sendBeacon 요청이 종료된 시점엔 브라우저가 다른 페이지로 전환을 마친 상태일 확률이 높다. 따라서 서버 응답을 받을 수 있는 방법이 없다. 사용자 분석 정보에 관한 응답은 대개 빈 상태이다.

fetch 메서드는 '페이지를 떠난 후’에도 요청이 가능하도록 해주는 플래그 keepalive를 지원한다.

한편, 다른 페이지로의 전환 중에 이를 취소하고 싶을 때는 onbeforeunload 이벤트를 사용하면 된다. unload 이벤트에서는 불가능하다.

window.onbeforeunload

사용자가 현재 페이지를 떠나 다른 페이지로 이동하려 할 때나 창을 닫으려고 할 때, beforeunload 핸들러에서 추가 확인을 요청할 수 있다.

예를 들어 아래 코드를 실행하고 새로고침을 하면 사이트 새로고침을 정말하겠냐는 alert 창이 뜬다.

window.onbeforeunload = function() {
  return false;
};

readyState

문서가 완전히 로드된 후에 DOMContentLoaded 핸들러를 설정하면 절대 실행되지 않는다.

그런데 가끔은 문서가 로드되었는지 아닌지 판단할 수 없는 경우가 있다. DOM이 완전히 구성된 후에 특정 함수를 실행해야 할 때는 DOM 트리 완성 여부를 아는 게 필요하다.

이때 현재 로딩 상태를 알려주는 document.readyState 프로퍼티를 사용할 수 있다.

프로퍼티의 값은 다음과 같다.

  • "loading" – 문서를 불러오는 중일 때
  • "interactive" – 문서가 완전히 불러와졌을 때
  • "complete" – 문서를 비롯한 이미지 등의 리소스들도 모두 불러와졌을 때
function work() { /*...*/ }

if (document.readyState == 'loading') {
  // 아직 로딩 중이므로 이벤트를 기다립니다.
  document.addEventListener('DOMContentLoaded', work);
} else {
  // DOM이 완성되었습니다!
  work();
}

readystatechange 이벤트를 사용해서 상태 변화시에 동작을 넣을 수도 있다.

// 현재 상태
console.log(document.readyState);

// 상태 변경 출력
document.addEventListener('readystatechange', () => console.log(document.readyState));

document.readyState는 DOMContentLoaded가 실행되기 바로 직전에 interactive가 됩니다. 따라서 DOMContentLoadedinteractive는 같은 상태를 나타낸다고 볼 수 있다.

document.readyState는 iframe, img를 비롯한 리소스 전부가 로드되었을 때 complete가 됩니다. readyState의 값이 complete로 바뀐다는 것은 window.onload가 실행된다는 것과 동일한 의미입니다. 이 둘의 차이점은 window.onload는 다른 load 핸들러가 전부 실행된 후에야 동작한다는 것에 있습니다.

2. defer, async 스크립트

모던 웹브라우저에서 돌아가는 스크립트들은 대부분 HTML보다 ‘무겁다’. 용량이 커서 다운로드받는 데 오랜 시간이 걸리고 처리하는 것 역시 마찬가지이다.

브라우저는 HTML을 읽다가 <script>...</script> 태그를 만나면 스크립트를 먼저 실행해야 하므로 DOM 생성을 멈춘다. 이는 src 속성이 있는 외부 스크립트 를 만났을 때도 마찬가지이다. 외부에서 스크립트를 다운받고 실행한 후에야 남은 페이지를 처리할 수 있다.

이런 브라우저의 동작 방식은 두 가지 중요한 이슈를 만든다.

  • 스크립트에서는 스크립트 아래에 있는 DOM 요소에 접근할 수 없다. 따라서 DOM 요소에 핸들러를 추가하는 것과 같은 여러 행위가 불가능해진다.
  • 페이지 위쪽에 용량이 큰 스크립트가 있는 경우 스크립트가 페이지를 ‘막아버린다’. 페이지에 접속하는 사용자들은 스크립트를 다운받고 실행할 때까지 스크립트 아래쪽 페이지를 볼 수 없게 된다.

이런 부작용들을 피할 수 있는 몇 가지 방법이 있다. 스크립트를 페이지 맨 아래 놓는 것이 하나의 방법이 될 수 있다. 이렇게 하면 스크립트 위에 있는 요소에 접근할 수 있다. 또한, 페이지 콘텐츠 출력을 막지 않게 된다.

그런데 이 방법은 완벽한 해결책이 아니다. HTML 문서 자체가 아주 큰 경우를 가정해보면, 브라우저가 HTML 문서 전체를 다운로드 한 다음에 스크립트를 다운받게 하면 페이지가 정말 느려질 것이다.

네트워크 속도가 빠른 곳에서 페이지에 접속하고 있다면 이런 지연은 눈에 잘 띄지 않는다. 하지만 아직도 네트워크 환경이 열악한 곳이 많다. 모바일 네트워크 접속이 느린 곳도 많다.

다행히도 이런 문제를 해결할 수 있는 <script> 속성이 있습니다. 바로 defer와 async이다.

defer

브라우저는 defer 속성이 있는 스크립트(이하 defer 스크립트 또는 지연 스크립트)를 '백그라운드’에서 다운로드 한다. 따라서 지연 스크립트를 다운로드 하는 도중에도 HTML 파싱이 멈추지 않는다. 그리고 defer 스크립트 실행은 페이지 구성이 끝날 때까지 지연 된다.

  • 지연 스크립트는 페이지 생성을 절대 막지 않는다.
  • 지연 스크립트는 DOM이 준비된 후에 실행되긴 하지만 DOMContentLoaded 이벤트 발생 전에 실행된다. 즉, DOMContentLoaded 이벤트는 지연 스크립트의 실행을 기다린다.

지연 스크립트는 일반 스크립트와 마찬가지로 HTML에 추가된 순(상대순, 요소순)으로 실행된다. 따라서 길이가 긴 스크립트가 앞에, 길이가 짧은 스크립트가 뒤에 있어도 짧은 스크립트는 긴 스크립트가 실행될 때까지 기다린다.

<script defer src="https://javascript.info/article/script-async-defer/long.js"></script>
<script defer src="https://javascript.info/article/script-async-defer/small.js"></script>
  1. 참고로, 작은 스크립트는 먼저 다운되지만, 실행은 나중에 된다.
    브라우저는 성능을 위해 페이지에 어떤 스크립트들이 있는지 쭉 살펴본 후에야 스크립트를 병렬적으로 다운로드한다. 위 예시에서도 스크립트 다운로드가 병렬적으로 진행되었다. 그런데 이 때 크기가 작은 small.js이 long.js보다 먼저 다운로드 될 수 있다.
    하지만 명세서에서 스크립트를 문서에 추가한 순서대로 실행하라고 정의했기 때문에 small.js는 long.js 다음에 실행된다.
  2. defer 속성은 외부 스크립트에만 유효합니다.
    <script>에 src가 없으면 defer 속성은 무시됩니다.

async

async 속성이 붙은 스크립트(이하 async 스크립트 또는 비동기 스크립트)는 페이지와 완전히 독립적으로 동작한다.

  • sync 스크립트는 defer 스크립트와 마찬가지로 백그라운드에서 다운로드된다. 따라서 HTML 페이지는 async 스크립트 다운이 완료되길 기다리지 않고 페이지 내 콘텐츠를 처리, 출력한다. 하지만 async 스크립트 실행중에는 HTML 파싱이 멈춘다.
  • DOMContentLoaded 이벤트와 async 스크립트는 서로를 기다리지 않는다.즉, 페이지 구성이 끝난 후에 async 스크립트 다운로딩이 끝난 경우, DOMContentLoaded는 async 스크립트 실행 전에 발생할 수 있다. 혹은 async 스크립트가 짧아서 페이지 구성이 끝나기 전에 다운로드 되거나 스크립트가 캐싱처리 된 경우, DOMContentLoaded는 async 스크립트 실행 후에 발생할 수도 있다.
  • 다른 스크립트들은 async 스크립트를 기다리지 않는다. async 스크립트 역시 다른 스크립트들을 기다리지 않는다.

이런 특징 때문에 페이지에 async 스크립트가 여러 개 있는 경우, 그 실행 순서가 제각각이 된다. 실행은 다운로드가 끝난 스크립트 순으로 진행된다.

  • 비동기 스크립트 다운로드는 페이지 로딩을 막지 않기 때문에 페이지 콘텐츠가 바로 출력된다.
  • DOMContentLoaded 이벤트는 상황에 따라 비동기 스크립트 전이나 후에 실행된다. 정확한 순서를 예측할 수 없다.
  • 비동기 스크립트는 서로를 기다리지 않는다. 위치상으론 small.js가 아래이긴 하지만 long.js보다 먼저 다운로드되었기 때문에 먼저 실행된다. 이렇게 먼저 로드가 된 스크립트가 먼저 실행되는 것을 'load-first order’라고 부른다.

비동기 스크립트는 방문자 수 카운터나 광고 관련 스크립트처럼 각각 독립적인 역할을 하는 서드 파티 스크립트를 현재 개발 중인 스크립트에 통합하려 할 때 아주 유용하다. async 스크립트는 개발 중인 스크립트에 의존하지 않고, 그 반대도 마찬가지이기 때문이다.

<!-- Google Analytics는 일반적으로 다음과 같이 삽입합니다. -->
<script async src="https://google-analytics.com/analytics.js"></script>

동적 스크립트

자바스크립트를 사용하면 문서에 스크립트를 동적으로 추가할 수 있습니다. 이렇게 추가한 스크립트를 동적 스크립트(dynamic script)라고 부른다.

let script = document.createElement('script');
script.src = "/article/script-async-defer/long.js";
document.body.append(script); // (*)

위 예시에서 외부 스크립트는 관련 요소가 문서에 추가되자 마자((*)로 표시한 줄) 다운로드가 시작된다.

그런데 동적 스크립트는 기본적으로 ‘async’ 스크립트처럼 행동한다.

그러므로 다음과 같은 특징을 지닌다.

  • 동적 스크립트는 그 어떤 것도 기다리지 않는다. 그리고 그 어떤 것도 동적 스크립트를 기다리지 않는다.
  • 먼저 다운로드된 스크립트가 먼저 실행된다(‘load-first’ order).

동적 스크립트가 async 스크립트처럼 동작하기 싫다면 false를 주면 된다.

function loadScript(src) {
  let script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.body.append(script);
}

// async=false이기 때문에 long.js가 먼저 실행됩니다.
loadScript("/article/script-async-defer/long.js");
loadScript("/article/script-async-defer/small.js");

요약하자면 다음과 같다.

async와 defer 스크립트는 다운로드 시 페이지 렌더링을 막지 않는다는 공통점이 있다다. 따라서 async와 defer를 적절히 사용하면 사용자가 오래 기다리지 않고 페이지 콘텐츠를 볼 수 있게 할 수 있다.

두 스크립트의 차이점은 다음과 같다.

async

  • 순서 : load-first order. 문서 내 순서와 상관없이 먼저 다운로드된 스크립트가 먼저 실행된다.
  • DOMContentLoaded : 비동기 스크립트는 HTML 문서가 완전히 다운로드되지 않은 상태라도 로드 및 실행될 수 있다. 스크립트 크기가 작거나 캐싱 처리 되어있을 때 혹은 HTML 문서 길이가 아주 길 때 이런 일이 발생한다.

defer

  • 순서 : 문서에 추가된 순
  • DOMContentLoaded : 지연 스크립트는 문서 다운로드와 파싱이 완료된 후에, DOMContentLoaded 이벤트 발생 전에 실행된다.

스크립트 다운로드가 끝나지 않았어도 페이지는 동작해야 한다.
defer를 사용하게 되면 스크립트가 실행되기 전 에 페이지가 화면에 출력된다는 점에 항상 유의해야 한다. 사용자는 그래픽 관련 컴포넌트들이 준비되지 않은 상태에서 화면을 보게 될 수 있다.
따라서 지연 스크립트가 영향을 주는 영역엔 반드시 '로딩 인디케이터’가 있어야 한다. 관련 버튼도 사용 불가(disabled) 처리를 해줘야 한다. 이렇게 해야 사용자에게 현재 어떤 것은 사용할 수 있는지, 어떤 것은 사용할 수 없는지를 알려줄 수 있다.
실무에선 defer를 DOM 전체가 필요한 스크립트나 실행 순서가 중요한 경우에 적용한다. async는 방문자 수 카운터나 광고 관련 스크립트같이 독립적인 스크립트에 혹은 실행 순서가 중요하지 않은 경우에 적용한다.

3. async vs defer attributes

defer나 async를 붙이지 않은 일반적인 <script> 태그의 경우 아래와 같은 프로세스를 거친다.

<script>
브라우저가 HTML 파일을 파싱하다가, 멈추고 스크립트를 다운로드한다. 그리고 실행까지 끝낸 뒤에야 다시 HTML 파싱을 재개한다.

<script async>
async 키워드를 붙일 경우 아래와 같다. HTML 문서를 파싱하는 동시에 백그라운드에서 스크립트를 다운로드한다. 다운로드가 끝나면 실행하는 동안은 HTML 파싱을 멈춘다.

<script defer>
defer 키워드를 사용하면 아래와 같다. 마찬가지로 HTML 문서를 파싱하는 동안 백그라운드에서 스크립트를 다운로드한다. 다만 파싱이 끝난 뒤에 실행한다. 정확히는 태그를 만났을 때 실행한다. 그리고 DOMContentLoaded 이벤트 발생 전에 실행된다.

용도 정리

  • 만약 스크립트가 모듈 형태이고, 다른 어떤 스크립트에도 의존하지 않는다면 async를 사용하는 게 좋다. (++ 실행 순서가 중요하지 않은 경우)
  • 만약 스크립트가 다른 스크립트에 의존하고 있다면 defer를 쓰는 게 더 적절하다. (++ 실행 순서가 중요한 경우)
  • 만약 스크립트가 작고 async 스크립트에 의존하고 있다면 그때는 인라인 스크립트를 사용하는 게 좋다. (실행 순서가 중요한 경우)

참고

async vs defer attributes
Async & Defer — How to Load JavaScript Properly

profile
https://medium.com/@wooleejaan

0개의 댓글