[모던JS: 브라우저] 문서와 리소스 로딩

KG·2021년 6월 22일
0

모던JS

목록 보기
36/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

DOMContentLoaded, load, beforeunload, unload 이벤트

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

  1. DOMContentLoaded : 브라우저가 HTML을 전부 읽고 DOM 트리를 완성하는 즉시 발생한다. 이때 이미지 태그 img의 파일(리소스)이나 스타일시트 등의 기타 자원은 기다리지 않는다.

  2. load : HTML로 DOM 트리를 만드는 게 완성되었을 뿐만 아니라 이미지, 스타일시트 같은 외부 자원도 모두 불러오는 것이 완료되었을 때 발생한다.

  3. beforeunload/unload : 사용자가 페이지를 떠나려할 때/떠날 때 발생한다.

위 세가지 이벤트는 다음과 같은 상황에서 유용하게 사용할 수 있다.

  1. DOMContentLoaded : DOM이 준비된 것을 확인 후 원하는 DOM 노드를 찾아 핸들러를 등록하는 등의 인터페이스 초기화 작업

  2. load : 이미지 사이즈를 확인하는 등 외부 자원이 모두 로드된 이후에 적용할 수 있는 작업

  3. beforeunload : 사용자가 사이트를 떠나려 할 때, 변경되지 않은 사항들을 저장했는지 확인시켜 주는 등의 작업

  4. unload : 사용자가 진짜 떠나기 전 사용자 분석 정보를 담은 통계/분석 정보를 서버에 전송하고자 하는 등의 작업

생명주기와 관련된 이벤트들에 대해 조금 더 자세히 살펴보도록 하자.

React 역시 이와 유사한 생명주기(Life Cycle)이 있다. 다만 리액트는 순수 HTML 생명주기와 달리 JSX 문법 내에서 사이클을 관리하기 때문에 기본 HTML 생명주기 이벤트와 유사한 것도 있고, 그 보다 더 구체화 된 이벤트가 존재한다.
리액트 훅(HOOK)이 등장하기 이전에는 주로 클래스형 컴포넌트 디자인으로 리액트를 사용했는데, 이 경우엔 각각의 라이프사이클 관련 메서드를 직접 사용할 수 있었다. (Ex. componentDidMount())
훅이 도입된 후엔 useEffect 훅을 사용해서 생명주기를 관리할 수는 있지만, 이는 클래스형에서 관리하던 것과 완벽하게 동일하지는 않다. 리액트의 생명주기와 관련된 설명은 추후 다른 포스팅에서 조금 더 자세히 다루어보도록 하자.

1) DOMContentLoaded

이전 챕터에서도 드문드문 볼 수 있었던 이벤트다. 해당 이벤트는 document 객체에서 발생하기 때문에, 항상 addEventListener로 등록해야 한다. onDOMContentLoaded와 같이 핸들러를 등록하는 방식으로는 정상 동작하지 않는다.

<script>
  function ready() {
    alert('DOM 준비 완료');
  
    // 이 시점에서 이미지는 로드되지 않은 상태
    // 따라서 이미지 사이즈는 모두 0으로 계산된다
    alert(`image size: ${img.offsetWidth} x ${img.offsetHeight}`);
  }
  
  // document.onDOMContentLoaded = ready; 방식은 동작하지 않는다
  document.addEventListener('DOMContentLoaded', ready);
</script>

<img id="img" src="https://en.js.cx/clipart/train.gif?speed=1&cache=0">

위 예시에서 DOMContentLoaded 핸들러는 문서가 로드되었을 때 실행된다. 따라서 핸들러 아래에 선언된 <img> 요소뿐만 아니라 그 외 다른 모든 요소에도 접근이 가능하다.

그렇지만 이미지의 자원(= 파일)이 로드되는 시점은 DOMContentLoaded와는 상관이 없다. 즉 해당 이벤트는 외부 자원이 로드되는 것은 기다리지 않기 때문에, alert 창이 뜨는 시점에서는 외부 이미지를 아직 불러오지 못한 관계로 모든 사이즈는 0으로 계산된다.

DOMContentLoaded는 기본적으로 DOM 트리가 완성되면 발생하는 이벤트로 생각할 수 있다. 다만 몇 가지 주의해야할 특수사항이 있는데 이를 살펴보도록 하자.

DOMContentLoaded와 scripts

브라우저는 HTML 문서를 처리하는 도중에 <script> 태그를 만나면, DOM 트리 구성을 멈추고 바로 <script>를 실행한다. 그리고 스크립트의 실행이 끝나고 나서야 이어서 나머지 HTML 문서를 처리한다. 즉 위에서 아래로 동기적으로 파싱이 수행된다. 이는 <script>에서 DOM 조작 관련 로직을 담고 있을 수 있기 때문에 이를 모두 정상적으로 처리하기 위해 이와 같은 방지책이 만들어졌다. 따라서 DOMContentLoaded 이벤트 역시 <script> 안에 있는 스크립트가 처리되고 난 후에 발생한다.

<script>
  document.addEventListener('DOMContentLoaded', () => {
  alert('DOM 준비 완료');
  });
</script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.3.0/lodash.js" />

<script>
  alert('라이브러리 로딩이 끝나고 인라인 스크립트 실행');
</script>

위 예시를 실행하면 다음 순서로 스크립트가 실행된다.

  1. <script> 실행 : document 객체에 DOMContentLoaded 이벤트 핸들러 등록

  2. 두 번째 <script> 실행 :lodash 라이브러리 로딩

  3. 세 번째 <script> 실행 : "라이브러리 로딩..." 메시지 출력

  4. 이 시점에서 DOM 트리 구성이 완료 : 따라서 DOMContentLoaded 이벤트 발생

  5. document 객체에 등록한 이벤트 핸들러 동작 : "DOM 준비 완료" 메시지 출력

이처럼 같은 <script> 요소이기에 순서대로 위에서부터 아래로 실행되고 있지만, DOMContentLoaded 이벤트의 발생 시점은 모든 스크립트가 실행되고 난 후에 발생하는 것을 볼 수 있다.

이를 DOMContentLoaded 이벤트를 블록킹(Blocking)한다라고 표현하는데, 이는 스크립트의 실행이 동기적으로 일어나기 때문이다. 그러나 위 규칙에는 두 가지 예외사항이 있다.

  1. async 속성이 있는 스크립트는 DOMContentLoaded 이벤트를 블록킹하지 않는다.
  2. document.createElement('script') 방식으로 동적 생성되어 웹페이지에 추가된 스크립트는 DOMContentLoaded 이벤트를 블록킹하지 않는다.

1번과 관련해서는 이후 챕터에서 조금 더 자세히 살펴볼 예정이다.

DOMContentLoaded와 style

외부 스타일시트는 CSSOM 이라는 객체 모델로 따로 생성되어 관리되기 때문에 DOM 객체에 영향을 주지 않는다. 그 때문에 DOMContentLoaded는 외부 스타일시트가 로드되는 것을 기다리지 않는다.

그러나 여기에는 한 가지 예외가 있는데, 스타일시트를 불러오는 태그 바로 다음에 스크립트가 위치한 경우이다. 이때는 스크립트가 스타일시트가 로드되기 전까지 실행되지 않는다.

<!-- 외부 스타일시트 로드 -->
<link type="text/css" rel="stylesheet" href="style.css" />

<!-- 이 스크립트는 위 스타일시트가 로드될 때까지 대기 -->
<script>
  alert(getComputedStyle(document.body).marginTop);
</script>

이러한 예외는 스크립트에서 스타일이 영향을 받는 요소의 프로퍼티를 사용할 가능성이 있기 때문에 만들어졌다. 위의 경우가 바로 그러한 경우인데, 출력 정보에서 특정 요소의 마진(margin)값에 접근하고 있기 때문이다. 이는 스타일이 로드되고 난 후 확정되는 값이기 때문에 이러한 제약이 존재한다. DOMContentLoaded 이벤트는 스크립트가 로드되기를 기다리는 것을 위에서 살펴보았다. 따라서 위의 경우에선 당연히 스타일시트까지 기다리기 된다.

브라우저 내장 자동완성

파이어폭스와 크롬, 오페라 등의 브라우저에서 제공하는 폼(form) 요소의 자동완성(autofill) 기능은 보통 DOMContentLoaded 이벤트에서 발생한다.

페이지에 아이디와 비밀번호를 기입하는 폼이 있고, 브라우저에 해당 아이디/비밀번호가 저장되어 있다면 DOMContentLoaded 이벤트가 발생할 때 해당 인증정보가 자동으로 채워진다. 물론 사전에 사용자가 자동완성 기능을 허용하는 과정이 요구된다.

만약 실행해야할 스크립트의 양이 매우 많다면 DOMContentLoaded 이벤트는 지연될 것이다. 해당 이벤트가 지연된다면 자동완성 역시 뒤늦게 처리될 것이다. 브라우저에서 자동 완성이 간혹 느리게 지원되는 이유는 바로 이러한 동작원리 때문이다. 이는 UX에 심각한 해가 되는 요소가 될 수 있기 때문에 적절한 스크립트 로딩 처리가 필요한 이유이다.

2) window.onload

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

아래 예시에서는 window.onload가 이미지가 모두 로드되고 난 후 실행되기 때문에 해당 이미지의 사이즈를 정상적으로 출력함을 확인할 수 있다.

<script>
  window.onload = function() {
    alert('페이지 전체 로드 완료');
  
    alert(`img size: ${img.offsetWidth} x ${img.offsetHeight}`); 
</script>

<img id="img" src="https://en.js.cx/clipart/train.gif?speed=1&cache=0">

해당 이벤트는 모든 자원이 로드되는 것을 기다리기에 많은 시간을 잡아먹을 수 있기 때문에 보통 잘 사용되지 않는다.

3) window.onunload

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

그러나 어떤 작업에 딜레이가 발생하는 경우에는 unload 이벤트 핸들러를 통해 처리하는 것은 부적절하다. 대표적인 예로 사용자 분석정보를 서버로 전송하는 작업이 이에 해당한다.

만약 사용자가 웹 페이지에서 어떤 행동을 하고, 어디에 얼마만큼 머무르는 지 등의 대한 분석 정보를 모으고 있다고 가정해보자. unload 이벤트는 사용자가 페이지를 떠날 때 실행되는 이벤트라고 했으므로, 해당 이벤트가 발생하는 순간 관련 정보를 서버에 전송하는 것을 떠올릴 수 있다. 그러나 이처럼 서버로 데이터를 전송하는 것은 전송에 필요한 사전 작업 및 전송 과정에 요구되는 시간 등 딜레이가 발생하는 작업이다. 그러나 unload 이벤트는 그 페이지가 닫는 그 순간에 실행되고, 페이지가 종료되면 내부에서 처리하고 있던 동작들은 모두 갑자기 종료되게 된다. 때문에 그 시간 내에 모든 작업을 처리하지 못했다면 비정상적인 처리로 남게되는 것이다.

이를 보완하기 위해 navigator.sendBeacon(url, data)라는 메서드가 만들어졌다. 해당 메서드는 데이터를 백그라운드에서 전송한다. 즉 다른 페이지로 전환/종료 시에 분석 정보를 서버에 계속 전송하도록 처리한다. 쉽게 말하면 일종의 비동기 처리와도 유사하다. 분석 정보가 제대로 서버에 전송되지만, 딜레이가 없는 것은 바로 이 때문이다.

let analyticsData = ...;

window.addEventListener('unload', function() {
  navigator.sendBeacon("/analytics", JSON.stringify(analyticsData));
};

sendBeacon 메서드는 다음과 같은 특징을 가진다.

  • 서버로 전송되는 요청은 POST 메서드로 전송된다.
  • 요청 시 문자열뿐만 아니라 폼이나 fetch에서 설명하는 기타 포맷 역시 전송 가능하다. 대개는 문자열 형태의 객체가 전송된다.
  • 전송 데이터는 64kb를 초과할 수 없다.

해당 메서드가 등장하기 전에는 unload 이벤트가 실행되는 시점을 억지로 지연시켜, 관련 작업을 모두 처리한 후에 다시 해당 이벤트를 진행시키는 방식을 사용했다. 그러나 이와 같은 방식은 사용자 경험을 해치는 주 요소가 될 수 있다. 처리에 걸리는 작업 소요시간 만큼 페이지 전환에 더 많은 시간이 걸리기 때문이다.

sendBeacon 요청이 종료된 시점엔 브라우저가 다른 페이지로 전환을 마친 상태일 확률이 높다. 즉 해당 메서드는 페이지 전환을 블록킹하지 않는다. 따라서 서버 응답을 받을 수 있는 방법이 없다. 사용자 분석 정보에 관한 서버의 응답은 그러한 이유로 보통 빈 상태를 갖는다.

fetch 메서드는 페이지를 떠난 후에도 요청이 가능하도록 해주는 플래그 keepalive를 지원하는데, 이와 관련된 자세한 내용은 fetch 챕터에서 자세히 다루어보도록 하자.

한편 다른 페이지로 전환 중에 이를 취소하고자 하는 경우엔 unload 이벤트를 사용할 수 없다. unload 이벤트는 이미 페이지를 떠날 때 발생하는 이벤트이기 때문이다. 따라서 이 경우에는 beforeunload 이벤트를 사용해야 한다.

4) window.onbeforeunload

사용자가 현재 페이지를 떠나 다른 페이지로 이동하려 할 때, 혹은 창을 닫으려고 할 때 beforeunload 핸들러에서 추가적인 확인 요청이 가능하다. 해당 이벤트 역시 DOMContentLoaded 이벤트와 달리 프로퍼티를 통해 할당이 가능하다.

beforeunload 이벤트를 취소하려고 하는 경우 브라우저는 사용자에게 해당 사실 확인여부를 요청한다.

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

false를 반환하는 경우 외에도, 빈 문자열이 아닌 문자열을 반환하면 기본 동작을 막아 이벤트를 취소한 것과 같은 효과를 볼 수 있다. 이는 역사적인 이유 때문에 아직 남아있는 기능인데 근래의 명세서에서는 이와 같은 작동 방식을 권장하고 있지 않다.

window.onbeforeunload = function() {
  return "저장되지 않은 임시사항이 있습니다. 그래도 떠나시겠습니까?";
};

구식 브라우저의 경우에는 위와 같이 처리하는 경우엔 해당 문자열이 그대로 출력됨을 볼 수 있다. 그러나 오늘날 대부분의 모던 브라우저에서는 위와 같이 문자열을 입력하더라도 해당 문자열을 출력하지 않는다. 보통 브라우저에 디폴트로 설정된 문구만 출력될 뿐이다. 이는 몇몇 사이트 관리자들이 해당 문구를 사용해 의도적으로 악용하는 사례가 많았기 때문이다. 보안과 사용자 경험을 위해서 이러한 기능은 모던 브라우저에서는 모두 차단되었다.

5) readyState

문서가 완전히 로드된 후에 DOMContentLoaded 핸들러를 설정하면 보통의 경우 절대 실행되지 않을 것이다. 문서가 완전히 로드된 시점에서는 이미 DOM 트리 구축이 끝나있기 때문이다. 때문에 대부분 현재 보이는 페이지에서 개발자도구를 통해 콘솔창에서 해당 이벤트 핸들러를 동작하더라도 관련 처리를 할 수 없음을 직접 확인해 볼 수 있다.

그런데 가끔은 문서가 로드되었는지 아닌지를 판단할 수 없는 경우가 있다. DOM이 완전히 구성된 후에 특정 함수를 실행해야 할 때는 DOM 트리 완성 여부를 확인하고 해당 함수를 실행시켜야 할 것이다. 이럴 때 현재 로딩 상태를 알려주는 document.readyState 프로퍼티를 이용할 수 있다. 해당 프로퍼티는 다음과 같이 3가지 값을 가진다.

  • loading : 문서를 불러오는 중
  • interactive : 문서가 완전히 불러와졌을 때
  • complete : 문서를 비롯한 이미지 등의 리소스들도 모두 불러와졌을 때

따라서 document.readyState의 값을 확인해 값에 따라 핸들러를 설정하거나 코드 실행을 한다면 적절한 시점에 원하는 작업을 처리할 수 있다.

function work() { ... }

if (document.readyState === 'loading') {
  // 문서를 불러오는 중이므로 DOMContentLoaded 이벤트 등록
  document.addEventListener('DOMContentLoaded', work);
} else {
  // DOM tree는 구축된 상태
  work();
}

이 외에도 상태가 변경되었을 때 실행되는 이벤트인 readystatechange를 사용해 현재 상태에 맞게 원하는 작업 처리도 가능하다.

console.log(document.readyState);

document.addEventListener('readystatechange', () => {
  console.log(document.readyState);
});

readystatechange 이벤트는 아주 오래전부터 존재한 이벤트인데, 해당 이벤트를 사용해서도 문서 로딩 상태를 파악하는데엔 문제가 없지만 요즈음엔 잘 사용하지 않는다.

다음 스크립트를 보고 발생하는 이벤트들의 순서를 살펴보도록 하자.

<script>
  log('initial readyState: ' + document.readyState);
  
  document.addEventListener('readystatechange', () => log('readyState: ' + document.readyState));
  document.addEventListener('DOMContentLoaded', () => log('DOMContentLoaded'));
  
  window.onload = () => log('window onload');
</script>

<iframe src="iframe.html" onload="log('iframe onload')"></iframe>

<img src="http://en.js.cx/clipart/train.gif" id="img">

<script>
  img.onload = () => log('img onload');
</script>

실행결과는 다음과 같다.

  1. [1] initial readyState: loading
  2. [2] readyState: interactive
  3. [2] DOMContentLoaded
  4. [3] iframe onload
  5. [4] img onload
  6. [4] readyState: complete
  7. [4] window onload

대괄호 안에 있는 숫자는 실제 해당 로그가 출력되기까지의 시간을 상대적으로 표현한 것이다. 같은 숫자라면 1ms 오차 범위 내에서 동시에 실행된 이벤트라고 가정한다.

  • document.readyStateDOMContentLoaded가 실행되기 바로 직전에 interactive로 변화한다. 따라서 DOMContentLoadedinteractive는 거의 동일한 시점이라고 볼 수 있다.

  • document.readyStateiframe, img를 비롯한 리소스 전부가 로드되었을 때에 complete로 변화한다. iframeimg는 각각 외부 리소스를 통해 로드되기 때문에 다른 시점에서 순차적으로 실행된다. 이때 img에서 로드를 마치면 모든 리소스가 로드된 것을 의미하고 때문에 complete 상태로 변하게 되고, 동시에 windowonload 이벤트 핸들러가 동작한다.

  • readyState의 값이 complete로 바뀐다는 것은 결국 window.onload가 실행되는 것과 동일한 의미라고 볼 수 있다. 차이점이라고 한다면, window.onload는 다른 load 핸들러가 전부 실행된 후 마지막에 동작한다는 점이다.

defer, async 스크립트

모던 웹 브라우저에서 돌아가는 스크립트들은 대부분 HTML 문서보다 무거운 경우가 많다. 외부에서 가져오는 스크립트는 대부분 라이브러리의 형태를 갖고 있는데, 범용적으로 쓰이는 다양한 기능을 구현해 놓았기 때문에 그 크기가 HTML 보다 큰 것이다. 용량이 커서 스크립트의 다운로드가 오래 걸리고 처리하는데 역시 오랜 시간이 요구될 수 있다.

앞서 살펴보았듯이 브라우저는 HTML을 읽다가 <script>를 만나면 파싱을 멈추고, 스크립트를 먼저 실행하고 다시 나머지 요소의 파싱을 진행한다. 이는 src 속성이 있는 <script> 역시 마찬가지이다. 때문에 다운로드가 모두 끝나야 나머지 HTML 요소를 파싱하고 DOM 객체를 생성할 수 있다. 이러한 동작 방식은 두 가지 중요한 이슈를 만든다.

  1. 스크립트는 스크립트 아래에 있는 DOM 요소에 접근할 수 없다. 따라서 DOM 요소에 핸들러를 추가하는 것과 같은 여러 행위가 불가능하다. 다만 document 객체에 DOMContentLoaded와 같인 이벤트 핸들러를 추가하는 것은 예외이다.

  2. 페이지 위쪽에 용량이 큰 스크립트가 있는 경우엔 스크립트가 페이지를 블록킹한다. 페이지에 접속하는 사용자들은 스크립트를 다운받고 실행하기 까지 스크립트 아래쪽에 있는 페이지는 확인할 수 없다.

<p> 스크립트 앞 부분 </p>

<script src="https://javascript.info/article/script-async-defer/long.js?speed=1" />

<p> 스크립트 뒷 부분 </p>

위 HTML에서 만약 스크립트가 외부 자원을 다운로드하고 실행을 완료하기까지 스크립트 뒷 부분에 해당하는 내용은 사용자가 확인할 수 없다.

이러한 동작은 사용자 경험을 해치는 주 요소 중에 하나이다. 이 같은 부작용을 피하기 위한 몇 가지 방법이 있다. 가장 단순하고 직관적인 방법은 스크립트를 가장 아래쪽에 배치하는 것이다. 이 경우 스크립트에서는 자신보다 위에 존재하는 모든 요소에는 스크립트 호출 시점에서 모두 정상적으로 접근이 가능하다. 또한 페이티 콘텐츠의 출력 역시 블록킹하지 않는다.

<body>
  ... 스크립트 위 콘텐츠들 ...
  
  <script src="https://javascript.info/article/script-async-defer/long.js?speed=1" />
</body>

그러나 이러한 방법은 완벽한 해결책이 아니다. HTML 문서 역시 대규모 페이지 또는 웹 애플리케이션을 구현하는 경우에는 크기가 매우 커질 수 있다. 브라우저가 HTML 문서 전체를 다운로드 한 다음에 스크립트를 다운로드 받게 하는 동작 방식은 완전한 페이지를 사용자가 받아볼 때까지 굉장히 오랜 시간이 소요되게 할 수 있다.

사실상 오늘날 대부분 인터넷 환경이 구축되어 있는 곳에서는 개인 PC 사양 및 인터넷 속도 발전으로 인해 이러한 지연이 크게 체감되지 않을 수는 있다. 그렇지만 여전히 네트워크 환경이 미비한 곳에서는 이러한 지연이 크게 작용할 수 있으며, 모바일 네트워크에서 접속이 PC 보다 느린 경우가 많기 때문에 지연에 대해서는 항상 고민해야 할 필요가 있다.

이런 문제를 보다 적극적으로 해결하기 위한 <script> 속성이 존재한다. 이들에 대해 살펴보도록 하자.

1) defer

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

<p> 스크립트 앞 부분 </p>

<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<p> 스크립트 뒷 부분 </p>

위에서 살펴본 것과 동일한 코드이지만 defer 속성만 추가되었다. 그러나 아까와는 달리 스크립트 뒷 부분에 해당하는 콘텐츠를 즉각 받아볼 수 있다.

지연 스크립트는 다음과 같은 특징을 가진다.

  • 페이지 생성을 절대 블록킹하지 않는다.
  • DOM이 준비된 후에 실행되기는 하지만, DOMContentLoaded 이벤트 발생 전에 실행된다.

두 번째 특징에 조금 유의할 필요가 있다. 지연 스크립트는 DOMContentLoaded 이벤트 전에 발생하므로 DOM 트리를 구축하는데 있어 제 역할을 수행할 수 있다.

<p> 스크립트 앞 부분 </p>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    alert('defer 스크립트 실행 후 발생');
  });
</script>

<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>

<p> 스크립트 뒷 부분 </p>

위 코드를 실행하면 페이지 콘텐츠가 먼저 전부 출력되고 나서 스크립트 다운로드를 시작하게 된다. 해당 코드에서는 다운되는 스크립트가 DOM 트리 구축에 영향을 주지는 않으므로 DOM 트리 생성 > 스크립트 다운로드 > DOMConentLoaded 이벤트 순으로 동작한다.

지연 스크립트는 일반 스크립트와 마찬가지로 동기적으로 동작한다. 즉 HTML에 추가된 순서로 실행된다. 만약 길이가 긴 스크립트가 앞에 있고, 길이가 짧은 스크립트가 뒤에 있다면 짧은 스크립트는 용량이 아무리 작고 더 빠르게 다운로드가 가능하더라도 앞에 있는 요소가 다운로드 후 실행을 완료할 때까지 대기한다.

조금 더 엄밀하게 말하자면 작은 스크립트는 먼저 다운로드된다. 그러나 실행만 나중에 될 뿐이다. 이는 브라우저가 성능을 위해 페이지에 어떤 스크립트가 있는지 사전에 미리 살펴보고, 그 후에 병렬적으로 스크립트를 다운로드를 하도록 동작이 정의되어 있기 때문이다. 따라서 다운로드 자체는 용량이 작을수록 먼저 완료된다. 하지만 지연 스크립트에서는 다운이 완료되었더라도 문서에 추가한 순서대로 실행하는 것이 보장된다.

2) async

async 속성이 붙은 스크립트는 모듈 챕터에서 살짝 살펴본 바 있다. async 속성이 붙은 스크립트(= 비동기 스크립트)는 async의 비동기 의미에 걸맞게 페이지와는 완전히 독립적으로 동작한다.

  • 비동기 스크립트는 지연 스크립트와 마찬가지로 백그라운드에서 다운로드가 이루어진다. 따라서 HTML 페이지는 비동기 스크립트의 다운이 완료되기까지 기다리지 않고 페이지 내 콘텐츠를 처리하고 출력한다. 하지만 비동기 스크립트를 실행하는 도중에는 HTML 파싱이 멈춘다.

  • DOMContentLoaded 이벤트와 비동기 스크립트는 서로 기다리지 않는다. 페이지 구성이 끝난 후에 비동기 스크립트 다운이 끝난 경우는 DOMContentLoaded 이벤트가 비동기 스크립트 이전에 발생할 수 있고, 반대로 비동기 스크립트의 용량이 작아 먼저 다운이 되는 경우엔 DOMContentLoaded 이벤트가 비동기 스크립트 실행 후에 발생할 수 있다.

  • 다른 스크립트들은 비동기 스크립트를 기다리지 않는다. 비동기 스크립트 역시 다른 스크립트를 기다리지 않는다. 이는 지연 스크립트는 서로 문서 순서대로 실행되는 것과 달리 이례적이다.

다음과 같은 특징을 통해 비동기 스크립트는 프라미스 챕터에서 다루었던 비동기 동작과 일맥상통 하고 있음을 알 수 있다. 이러한 특징 때문에 페이지에 비동기 스크립트가 여러 개 있는 경우, 그 실행 순서는 용량에 따라 제각각일 수 있다. 실행은 다운로드가 끝나는 순서로 진행된다.

<p> 스크립트 앞 부분 </p>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    alert('DOM 준비 완료');
  });
</script>

<script async src="https://javascript.info/article/script-async-defer/long.js"></script>
<script async src="https://javascript.info/article/script-async-defer/small.js"></script>

<p> 스크립트 뒷 부분 </p>
  1. 비동기 스크립트 다운로드는 페이지 로딩을 막지 않는다. 따라서 페이지 콘텐츠는 다운로드 여부와 관계없이 바로 출력된다.

  2. DOMContentLoaded 이벤트는 상황에 따라 비동기 스크립터 전/후에 발생한다. 정확한 순서는 스크립트가 다운로드 받는 용량과 네트워크 속도에 따라 결정되기 때문에 예측할 수 없다.

  3. 비동기 스크립트는 서로 기다리지 않는다. 위치 상으로는 small.js가 아래에 있지만, 크기가 더 작기 때문에 먼저 실행된다. 이렇게 먼저 로드가 된 순서로 실행되는 것을 load-first order라고 부른다.

비동기 스크립트는 주로 방문자 수 카운터나 광고 관련 스크립트처럼 각각 독립적인 역할을 하는 서드 파티 스크립트를 현재 개발 중인 스크립트에 통합하려고 할 때 유용하다. 현재 개발 중엔 스크립트에 의존하지 않고 그 반대도 마찬가지이기 때문이다. 다음과 같이 구글 애널리틱스는 비동기 스크립트로 추가할 수 있다.

<script async src="https://google-analytics.com/analytics.js"></script>

3) 동적 스크립트

자바스크립트를 사용하면 문서에 스크립트를 동적으로 추가하는 것도 가능하다. 이렇게 추가한 스크립트를 보통 동적 스크립트(dynamic script)라고 부른다.

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

위 예시에서 외부 스크립트는 관련 요소가 문서에 추가되는 시점에서 다운로드를 시작한다. 이때 동적 스크립트는 아무런 설정을 하지 않는다면 기본적으로 비동기 스크립트처럼 동작한다. 때문의 다음의 특징을 가진다.

  • 동적 스크립트는 그 어떤 것도 기다리지 않는다. 그리고 그 어떤 것도 동적 스크립트를 기다리지 않는다.

  • 먼저 다운로드 된 스크립트가 먼저 실행되는 load-first order를 가진다.

만약 동적 스크립트를 일반 스크립트 처럼 문서에 선언된 순서대로 동작시키고자 한다면 script.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");

스크립트 다운로드가 끝나지 않았더라도 페이지는 동작해야 한다. defer를 사용하는 경우엔 스크립트가 실행되기 전에 페이지가 화면에 출력된다는 점을 항상 유의하자. 때문에 사용자는 그래픽 관련 컴포넌트들이 미처 준비를 다 하지 못한 상태에서 화면을 볼 가능성이 있다. 따라서 지연 스크립트가 영향을 주는 영역에는 반드시 로딩 인디케이터와 같은 처리가 필요하다.

보통 모던 브라우저와 자바스크립트에서는 스크립트를 모듈 형식으로 많이 사용하지만 그 이외에 defer 또는 async 속성을 사용해야 한다면 다음을 기점으로 구분할 수 있다.

  • defer : DOM 전체가 필요한 스크립트나 실행 순서가 중요한 경우

  • async : 방문자 수 카운터나 광고 관련 스크립트 같이 독립적인 스크립트 또는 실행 순서가 중요하지 않은 경우

각각 스크립트의 다운로드와 실행 그리고 HTML 파싱에 대한 관계를 그림으로 나타내면 다음과 같다.

리소스 로딩: onload와 onerror

브라우저는 script, iframe, img 태그 등과 같이 외부 자원을 불러오는 요소에서 로딩 과정을 추적할 수 있는 이벤트를 제공한다. 해당 이벤트는 다음과 같이 두 가지 프로퍼티로 감지할 수 있다.

  • onload : 로드가 성공적으로 이루어졌을 때
  • onerror : 로드과정에서 에러가 발생한 경우

1) 스크립트 로딩

서드 파티 라이브러리를 불러와 거기에 존재하는 어떤 함수를 호출해야 하는 경우를 생각해보자. 만약 동적 스크립트를 사용하여 구현한다면 다음과 같이 작성할 수 있을 것이다.

let script = document.createElement('script');
script.src = "my.js";

document.head.append(script);

my.js의 다운로드는 비동기 스크립트 방식으로 진행될 것 이다. 따라서 스크립트 로드는 HTML 파싱과 병렬적으로 이루어진다. 하지만 어떤 시점에서 my.js 내부에 존재하는 함수를 호출할 수 있을까? 당연히 my.js의 다운로드가 완료된 이후 시점이 되어야 할 것 이다. 이 시점을 onload 핸들러로 잡아낼 수 있다.

script.onload

앞서 window 객체에서도 load 이벤트를 살펴보았다. window.onload의 경우엔 스타일, 이미지 등의 리소스가 모두 로드되었을 때 발생하는 이벤트였다. 그러나 외부로 부터 자원을 가져올 수 있는 요소들에도 load 이벤트를 적용할 수 있다. 이 경우에는 해당 요소가 자원을 모두 로드한 시점에 해당 이벤트가 발생한다. 따라서 이 시점에서 다운로드 받은 스크립트 내부에 선언된 특정 함수를 호출할 수 있다.

let script = document.createElement('script');

// lodash 라이브러리 로드
script.src = "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.3.0/lodash.js"
document.head.append(script);

script.onload = function() {
  // 해당 시점은 lodash 라이브러리의 다운이 완료된 상태
  // 때문에 스크립트에 선언된 다양한 함수 호출이 가능
  alert(_.VERSION);
};

script.onerror

네트워크 환경, 또는 잘못된 스크립트 경로 사용에 따라 다운로드가 실패하는 경우가 발생할 수 있다. 이 경우에는 로드 과정에서 에러가 발생하는데 이때 error 이벤트가 발생한다. 해당 이벤트는 onerror 핸들러로 잡아낼 수 있다. 아래와 같이 존재하지 않는 스크립트를 로드하는 경우 발생하는 에러를 처리할 수 있다.

let script = document.createElement('script');
script.src = "https://example.com/404.js"; 
document.head.append(script);

script.onerror = function() {
  alert("Error 발생: " + this.src);
};

이때 발생하는 에러는 단순히 다운로드가 실패했음을 알려주는 이벤트이다. HTTP 통신으로 넘어가 그 곳에서 발생하는 에러 관련 정보는 onerror 핸들러에서 취급할 수 없다. 즉 404 또는 500과 같은 통신 관련 Internal Error에 대한 정보는 획득할 수 없다.

onload/onerror 이벤트 핸들러는 오직 요소의 다운로드에만 관여하고 추적하는데, 이는 스크립트를 다운로드하고 실행하는 시점에서 발생하는 오류는 해당 이벤트로 잡아낼 수 없다는 것을 의미한다. 만약 스크립트 실행 도중에 에러가 발생하더라도 실행 시점에서는 이미 다운로드가 성공적으로 이루어졌기 때문에 onload 이벤트가 트리거된다. 만약 스크립트 자체에서 어떤 에러가 발생하는지 추적하고 싶다면 그 보다 상위 객체인 window.onerror로 관리해야 한다.

2) 다른 리소스 로딩 요소

loaderror 이벤트는 기본적으로 src 속성을 사용해 외부 자원을 로드할 수 있는 HTML 태그라면 모두 발생한다. 예를 들면 <img>, <iframe> 태그 같은 것들이 있다.

let img = document.createElement('img');
img.src = "https://js.cx/clipart/train.gif";

img.onload = function() {
  alert(`img size: ${img.width} x ${img.height}`);
};

img.onerror = function() {
  alert('이미지 로딩 과정에서 에러 발생');
};

해당 이벤트를 사용할 때 다음과 같은 주의사항이 있다.

  • 대부분의 리소스는 문서에 추가된 시점에서 다운로드를 시작한다. 그러나 <img> 태그는 예외에 해당하는데, 해당 요소는 src 속성을 지정받을 때 로드를 시작한다.

  • <iframe> 태그는 다운로드가 성공했든 실패했든 상관없이 로딩이 끝나는 시점에서 onload가 트리거 된다.

이와 같은 예외 사항은 대개 역사적인 이유로 하위 호환성을 위해 아직까지 남아있는 구식 기능이라고 보면 된다.

3) Cross-Origin 정책

흔히 CORS(Cross Origin Resource Sharing)이라고 불리는 브라우저 보안 정책이 있다. 교차 출처 리소스 공유라고 불리는 이 정책은 HTTP 헤더를 사용해, 하나의 출처에서 실행 중인 웹 애플리케이션이 다른 출처 자원에 접근할 수 있는 권한을 부여하도록 브라우저에서 알려주는 시스템이다. 만약 출처가 서로 다른 경우 권한 부여가 되어있지 않다면 각 출처간 자원 공유는 불가하다. 예를 들어 https://facebook.com에서 https://gmail.com으로 어떤 자원을 가져오는 것은 권한에 따라 금지되어 있을 수 있다. 이는 브라우저를 사용하는 사용자의 안전을 보장하기 위해 제정된 정책이다. 해당 정책을 통해 우리는 다른 외부 출처에서 가져오는 다양한 리소스가 안전하다는 최소한의 보장을 브라우저로부터 받을 수 있다.

이때 출처(origin)은 보통 도메인/포트/프로토콜을 비교해 동일한 출처인지 아닌지를 구분한다. 아래 그림을 예로 들면 Protocol, Host 그리고 Port 번호가 일치하면 동일 출처, 아니면 보통 다른 출처로 인식한다. 포트 번호는 대개 생략되는 경우가 많다.

때문에 외부 리소스를 가져오는 경우에 CORS 정책이 동일하게 적용된다. CORS 정책에 대한 표준이 아주 명확하게 제시되어 있지는 않기 때문에 (RFC 6454에서 정의하고 있지만 전제를 사용하기 때문에 어떻게 해석하느냐에 따라 구현이 달라질 수 있다) 각 브라우저들은 독자적으로 출처 비교 로직을 구현한다. 때문에 브라우저별로 CORS를 처리하는 방식이 미세하게 다를 수 있다. 예를 들어 특정 브라우저에서는 다른 도메인으로부터 어떤 스크립트를 가져오는 경우 다운로드는 정상적으로 가능하지만, 만약 에러가 발생하는 경우엔 이를 제대로 처리할 수 없는 처리가 되어있을 수 있다.

// error.js : ReferenceError가 발생하는 코드
noFunction();
<!-- 동일 출처에서 error.js를 가져오는 경우 -->
<script>
window.onerror = function(message, url, line, col, errorObj) {
  alert(`${message}\n${url}, ${line}:${col}`);
};
</script>
<script src="/article/onload-onerror/crossorigin/error.js"></script>

해당 코드는 동일 출처에서 실행되었다고 가정하면 다음과 같이 정상적으로 에러 메시지를 출력한다.

Uncaught ReferenceError: noSuchFunction is not defined
https://javascript.info/article/onload-onerror/crossorigin/error.js, 1:1

하지만 다른 출처인 경우에는 다음과 같이 에러를 제대로 처리하지 못한다.

Script error.
, 0:0

앞서 말한것과 같이 브라우저 별로 출력되는 에러메시지가 다를수도 있고, 또는 아예 다운로드조차 불가능한 경우가 있을 수 있다. 그러나 CORS 정책의 핵심은 모두 동일하다. 서로 다른 출처에서 가져오는 자원에 대해서는 해당 출처에 대한 인증 또는 권한 검증이 필요하다는 것이다.

스크립트의 경우 교차 출처 공유를 허용하기 위해 crossorigin 속성을 제공한다. 하지만 클라이언트 단인 HTML에서 해당 속성을 지정하는 것 외에도 서버에서 추가적인 작업을 해주어야 한다. 서버를 통해 자원이 공유될텐데 이때 사용하는 HTTP(S) 통신 헤더에 권한을 부여해 주어야 한다. 이 과정을 몇 가지 단계로 구분할 수 있다.

  1. crossorigin 속성을 사용하지 않는 경우 : 접근 불허

  2. crossorigin="anonymous" : 서버에서 HTTP 헤더에 Access-Control-Allow-Origin 속성에 대해 모든 도메인을 허용(*)하거나 현재 출처에 대해 허용되어 있는 경우에 한해서 접근 허용. 그러나 쿠키나 인가(Authorization)관련 정보는 서버로 전송되지 않음

  3. crossorigin="use-credentials" : 서버에서 Access-Control-Allow-Origin 속성이 설정되어 있고 동시에 Access-Control-Allow-Credentials: true로 설정되어 있는 경우 접근 허용 및 쿠기와 인가 관련 정보 전송 가능

CORS 정책과 관련된 더 자세한 정보는 추후에 다룰 fetch 챕터에서 상세히 다루어볼 것이다. 또는 해당 포스트에서 관련내용을 확인해 볼 수 있다.

Webpack을 이용하는 프레임워크에서는 웹팩에서 제공하는 개발서버를 이용하는 경우 별도로 proxy 서버를 뚫어 CORS 정책을 우회하는 방법도 있다. 관련 정보 역시 위의 포스트에서 확인할 수 있다.

References

  1. https://ko.javascript.info/loading
  2. https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
  3. https://developers.google.com/web/fundamentals/performance/critical-rendering-path/page-speed-rules-and-recommendations?hl=ko
  4. https://evan-moon.github.io/2020/05/21/about-cors/
profile
개발잘하고싶다

0개의 댓글