script의 async를 둘러싼 스펙 탐험

bsidesoft 블로그에 Jobs에 대한 내용이 올라왔다.

여튼, Jobs에 대한 글 중간에

<!--소용없음. 그냥 async 선언과 동일-->
<script async="false" src="1.js"></script>

“안타깝게도 이 옵션은 직접 태그에 쓰면 소용없고 동적으로 스크립트를 만든 경우에만 적용할 수 있습니다.”라고 나와있어서,

스펙에서는 어디에 나와있는가 질문을 드렸더니 다음과 같은 답변이 달렸다.

www.w3.org/TR/html51/semantics-scripting.html

여기에 가시면 script엘리먼트객체의 속성인 async IDL attribute에 대한 작동에 대해 자세히 기술하고 있습니다.

The async IDL attribute controls whether the element will execute in parallel or not. If the element’s “non-blocking” flag is set, then, on getting, the async IDL attribute must return true, and on setting, the “non-blocking” flag must first be unset, and then the content attribute must be removed if the IDL attribute’s new value is false, and must be set to the empty string if the IDL attribute’s new value is true. If the element’s “non-blocking” flag is not set, the IDL attribute must reflect the async content attribute.

분명 훑어보았던 부분인데 IDL attribute를 이해 못해서 그냥 넘어갔던 부분이다.

반성의 의미로 스펙을 좀 더 읽고 내 질문에 대한 답을 정리해본다.

async attribute

우선 <script async="false" src="1.js"></script>에서 async가 false로 설정 안 되는 이유.

script element 스펙에는 “The async and defer attributes are boolean attributes“라는 말이 나오는데,

boolean-attribute란 attribute로 명시하는 것 만으로 true가 된다.

즉, async=”true”든 async=”false”든 모두 async가 설정된 것으로 본다.

checked, disabled 같은 애들이 다 boolean-attribute다.

따라서 스크립트를 추가된 순서대로 실행하려면 attribute에서 async를 생략해야 하지만,

그렇게 하면 또 병렬(parallel) 로 로드가 안 되는 문제가 생기게 된다.

그럼 동적으로 삽입된 스크립트에서 가능한 이유는 뭘까?

//js에서 동적로딩하는 경우
const s = document.createElement('script');
s.src = '1.js';
s.async = false; //효과 있음
document.body.appendChild(s);

script element 스펙에 나오는 문장을 이해하기 위해 IDL attribute부터 알아보자.

content attribute와 IDL attribute

스택오버플로우에서 답을 찾아봤다.


IDL이란 말은 Web IDL spec에서 왔습니다.

이 문서는 Web IDL이라는 인터페이스 정의 언어를 정의하고, 이는 웹 브라우저 내에서 구현하려는 인터페이스를 설명하는데 사용될 수 있습니다.
Web IDL은 IDL의 일종으로 웹 플랫폼에서의 공통적인 스크립트 객체의 동작을 보다 쉽게 정의될 수 있게 해주는 많은 기능을 갖추고 있습니다.
Web IDL로 설명된 인터페이스가 ECMAScript 실행 환경 내의 구조에 어떻게 대응되는지 또한 이 문서에서 자세히 설명합니다.

Contents attibute란 마크업에서 나타나는 attribute입니다.

<div id="mydiv" class="example"></div>

여기서 id와 class는 attribute죠. 일반적으로 content attribute는 이에 상응하는 IDL attribute를 갖고 있습니다.

…생략

JavaScript 안에서 IDL attribute는 종종 property라고 불리는데, JavaScript에게는 DOM 객체의 property로써 노출되기 때문입니다.

보통 content attribute와 IDL attribute는 짝을 이루지만, 반드시 둘이 호환되는 건 아닙니다.

…생략 (<option> element의 초기값을 예로 든다)


결국 우리가 JavaScript 개발자로서 인식하는 property가 바로 IDL attribute란 개념을 가리키고 있다.
contents attribute라 함은 일반적으로 attribute라고 생각하는 그 마크업 상에 노출되는 바로 그것.

script element

이제 async에 관한 HTML5.1의 script쪽 명세를 다시 보자.

The async IDL attribute controls whether the element will execute in parallel or not.
Async IDL attribute는 element가 병렬로 실행될 지를 결정한다.


If the element’s “non-blocking” flag is set, then, on getting, the async IDL attribute must return true, and on setting, the “non-blocking” flag must first be unset, and then the content attribute must be removed if the IDL attribute’s new value is false, and must be set to the empty string if the IDL attribute’s new value is true.
Element의 “non-blocking” 플래그가 설정되면, 값을 가져올 때는 async IDL attribute가 true를 반환해야만 하고, 설정할 때는 “non-blocking” 플래그는 일단 해제한다. 이후 IDL attribute의 새로운 값이 false이면 content attribute는 제거, true이면 빈 값을 설정한다.


If the element’s “non-blocking” flag is not set, the IDL attribute must reflect the async content attribute.
Element의 “non-blocking” 플래그가 설정되지 않았다면, IDL attribute는 반드시 async content attribute를 반영(연동)해야 한다.

참고로 값을 가져올 때 현재 content attribute 값을 리턴해야 하는 경우 reflect란 용어를 쓰고 있는데 나는 이를 ‘반영(연동)’이라고 번역했다.

간단히 console 창에서 테스트해보자.

var s = document.createElement('script');를 통해 생성한 후 바로 s.async를 보면 true가 나온다.

처음 스크립트가 생성될 때는 “non-blocking” 플래그가 설정되기 때문이다.

Element의 “non-blocking” 플래그가 설정되면, 값을 가져올 때는 async IDL attribute가 true를 반환해야만…

이제 한 문장은 이해한 것 같은데, 어려운 용어가 더 생겼다.

“non-blocking” 플래그라는 게 뭘까?

parsing script tag

“non-blocking” 상태를 이해하기 위해 우선 HTML 도큐먼트의 파싱과정을 보자.

parsing-model-overview

“non-blocking”에서 block하는 대상은 HTML의 파싱이다.

보통 네트웍 계층에서 input stream으로 데이터를 넘겨주지만, 실행중인 스크립트로부터 받아들이기도 한다.

document.write가 대표적인데, 여기서 쓰는 데이터는 input stream에 섞여 들어간다.

<ul>
<script>document.write('<li>hello</li>');</script>
</ul>

ul 사이에 li가 제대로 자릴 잡기 위해선 input stream으로부터 가 나타나기 전까지만 데이터를 읽어들이고,

document.write가 포함된 스크립트 구문 실행하면 <li>hello</li> 부분이 input stream에 추가되고,

파서가 non-block 상태가 되면 다시 input stream으로부터 문자를 받아오는데,

<li>hello</li>\n</ul> 순으로 가져오게 된다.

이런 이유로 script 태그는 기본적으로 HTML 파싱을 막는다.

외부 스크립트도 마찬가지인데,

<script src="external.js"></script>

파서는 를 만나면 src에 명시된 파일을 다운로드하고, 실행하기를 기다린 후에야 파싱을 재개할 수 있다.

“non-blocking” flag

그럼 “non-blocking” 상태는 스펙에서 어떻게 설명하는지 보자.

Initially, script elements must have this flag set.
Script element는 초기 플래그 값으로 non-block으로 설정된다.


It is unset by the HTML parser and the XML parser on script elements they insert.
HTML/XML 파서가 script element를 삽입하면서 이 플래그가 해제된다.

그러니까 HTML 파서에 의해 생성된 스크립트 태그는 기본적으로 fetch/실행하는 동안 HTML 파서를 block 블럭하게 된다.

In addition, whenever a script element whose “non-blocking” flag is set has an async content attribute added, the element’s “non-blocking” flag must be unset.
추가적으로, “non-blocking” 플래그가 설정된 script element에 async content attribute가 추가되면, element의 “non-blocking” 플래그는 해제된다.

이 부분은 script element 쪽에서도 언급이 되니 같이 살펴보자.

다시 script element

If the element’s “non-blocking” flag is set, then, on getting, the async IDL attribute must return true, and on setting, the “non-blocking” flag must first be unset, and then the content attribute must be removed if the IDL attribute’s new value is false, and must be set to the empty string if the IDL attribute’s new value is true.
Element의 “non-blocking” 플래그가 설정되면, 값을 가져올 때는 async IDL attribute가 true를 반환해야만 하고, 설정할 때는 “non-blocking” 플래그는 일단 해제한다. 이후 IDL attribute의 새로운 값이 false이면 content attribute는 제거, true이면 빈 값을 설정한다.

최초 script element를 생성할 때는 “non-blocking” 플래그가 설정되기 때문에 async가 true를 반환하는 것은 이미 확인했다.

…설정할 때는 “non-blocking” 플래그는 일단 해제한다.

이 글을 한참이나 쓰고 있는 이유가 이 때문인데, async를 추가하는데 왜 “non-blocking”을 해제하는 걸까? 이런 의문으로 너무 혼란스러웠다.

그러니까, async가 “non-blocking” 상태 아님?하는 의문이다.

나만 빼고 다들 잘 이해를 하고 있는지, “non-blocking” 플래그에 대한 질문도 찾기 쉽지 않다.

여러가지 가정을 하고 스펙을 여러번 읽어본 결과,

브라우저는 “non-blocking” 플래그가 없어도 async로 병렬처리 여부를 확인할 수 있다라고 나름의 결론을 냈다.

왜 병렬로 다운로드 받는가에 대해선 스펙상에서 찾을 수는 없었는데, 어쩌면 다운로드를 병렬로 받는 건 중요하지 않겠다는 생각도 든다.

다운로드는 왜 병렬로 처리되는 걸까?

스펙에서 찾은 건 아니지만, 원래 다운로드는 그냥 알아서 병렬로 다운받는 거 아닌가?

브라우저 별로 동시 다운로드 받을 수 있는 개수가 다르긴 한데, 어쨌든 아래와 같은 일반적인 script 태그를 써도 동시에 다운로드 받는 걸 볼 수 있다.

<script src="small.js"></script>
<script src="small2.js"></script>
<script src="small3.js"></script>
<script src="small4.js"></script>

현대 브라우저에서 외부 리소스를 다운로드할 때는 fetcher라는 놈에게 위임하고,
(아마 이런 놈들)

Fetcher는 캐시를 확인하거나 각종 다운로드 받을 파일을 나름의 우선순위로 다운로드 한다.

그럼 fetch는 어차피 병렬이 기본인 거고(혹은 브라우저 마음일테고),

async도 “non-blocking” 플래그와 마찬가지로 HTML 파서가 스크립트를 해석하는 순서, 즉 실행 순서를 결정 짓는 것 뿐이란 말인가.

아직 자신은 없다.

차근 차근 각 상황을 되짚어 보자.

우선 async가 없는 일반적인 동적 스크립트 생성 시

  1. 스크립트에서 동적으로 script element를 생성하면 기본적으로 “non-blocking” 플래그 설정됨
  2. xxx.async를 찍어보면 true를 리턴하는 상태
  3. 다운로드 자체는 fetcher가 알아서 동적으로 다운로드 받겠지
  4. “non-blocking” 상태라서 HTML 파서는 나중에 추가된 스크립트의 존재를 알고 있겠지
  5. 그럼 준비된(먼저 들어온) 놈부터 실행하라고 JS 엔진에게 던질 수 있겠지.

다음으로 async가 true로 설정되는 경우

  1. 스크립트에서 동적으로 script element를 생성하면 기본적으로 “non-blocking” 플래그 설정됨
  2. xxx.async = true하면 “non-blocking” 플래그 해제
  3. HTML 내에 async attribute를 넣어 정적으로 삽입된 것 혹은 동적 스크립트 생성의 경우과 동일하게 동작함
  4. 그래서 다운로드 되는 놈부터 실행

다음으로 스크립트로 xxx.async = false로 처리하면 왜 병렬로 fetch하고 실행은 순서대로 하는지 살펴보자.

스펙에 의하면 async는 병렬로 ‘실행’할지를 제어한다.

bigScript.async = false;
document.body.appendChild(bigScript);
smallScript.async = false;
document.body.appendChild(smallScript);
  1. 그럼 script는 bigScript, smallScript 순으로 추가가 될 것이고
  2. xxx.async = false;로 했으니 “non-blocking” 플래그 해제된 상태
  3. smallScript가 먼저 로드가 되거나 말거나 blocking 상태에선 현재 script 블럭을 처리하기 전까지는 파서는 다음 script를 읽어들이지 않는다
  4. 그러니 bigScript의 소스를 js 엔진이 지지고 볶은 후, 파서는 다음 스크립트인 smallScript를 해치운다

OK. 실행이 순서대로 되는 건 알겠다.

결국 async는 비동기 다운로드와는 관계가 없고, 애초에 실행 순서만 관련있었던 것 같다.

async - Execute script in parallel

스펙에 써있는 그대로다.

참고