[es2015+] 잡스(Jobs)

잡스와 잡큐의 개요.

스티브 잡스(Jobs)는 es2015+부터 도입된 기능으로 내부적으로 개별 스크립트가 어떤 방식으로 적재되고 실행되는지를 정의하는 새로운 방법입니다.

이걸 공부해야 하는 이유는 잡큐가 어떤 식으로 동작하는지 이해해야만 스크립트의 실행순서를 이해하고 통제할 수 있기 때문입니다. 우리는 정말 잡큐를 모르고 실행순서를 맞출 수 있는지 실험해볼까요?

<html>
<head></head>
<body>
<!-- 로그출력 -->
<section id="log"></section>

<!-- 테스트용 함수를 먼저 정의해준다 -->
<script>
const log = document.getElementById('log');
const test =t=>{
  log.innerHTML += `<div>${t} step1</div>`;
  //두 개의 Promise가 두 번의 then으로 비동기분리
  Promise.resolve()
    .then(_=>log.innerHTML += `<div>${t} promise1 step1</div>`)
    .then(_=>log.innerHTML += `<div>${t} promise1 step2</div>`);
  Promise.resolve()
    .then(_=>log.innerHTML += `<div>${t} promise2 step1</div>`)
    .then(_=>log.innerHTML += `<div>${t} promise2 step2</div>`);
  log.innerHTML += `<div>${t} step2</div>`;
};

//async false발동
'asyncFalse1,asyncFalse2'.split(',').forEach(v=>{
  const s = document.createElement('script');
  s.src = v + '.js';
  s.async = false;
  document.body.append(s);
});

//직접 생성한 스크립트
test('mix1');
{
  const s = document.createElement('script');
  s.text = "test('dynamic')";
  document.body.append(s);
}
test('mix2');
</script>

<!-- async가 true인 것과 false인 것이 섞이면? -->
<script src="async1.js" async></script>
<script src="async2.js" async></script>

<!-- 그 와중에 defer가 끼어들면? -->
<script src="defer1.js" defer></script>
<script src="defer2.js" defer></script>

<!-- 첫번째 스크립트 블럭과 그 안의 프라미스-->
<script>
test('main1');
</script>

<!-- 두번째 스크립트 블럭과 그 안의 프라미스-->
<script>
test('main2');
</script>

</body>
</html>

각각 로딩되는 js의 내용도 main쪽과 대동소이하게 작성하면 됩니다. 예를 들어 async1.js라면 아래와 같이 main측의 스크립트를 흉내내서 작성하면 되죠.

test('async1');

어떤가요. 콘솔 호출이 어떤 순서로 되는지 설명하기 쉽지 않습니다. 게다가 이 순서는 브라우저마다 다르기도 합니다(심지어 같은 모양으로 동작하는 브라우저가 없..)
그저 멀쩡히 원하는 순서대로 스크립트를 실행하는 정도를 실현하기 위해서라도 표준적인 스크립트의 실행 순서가 어떤 스펙으로 규정되어있는지 이해하고 브라우저마다의 구현 차이를 이해할 필요가 있습니다.

스크립트를 실행하려면 잡큐에 등록

es6가 스크립트 실행 방식을 간단히 설명하면

  1. 하나의 프레임 내에서도 다시 스크립트를 실행하는 큐를 갖게 하여
  2. 실행할 스크립트를 순차적으로 큐에 적재한 뒤
  3. 큐를 비워가며 하나씩 실행해간다는 점입니다.

이렇듯 개별적으로 실행되는 스크립트 블럭을 하나의 잡(Job)이라고 하고 이러한 잡들(Jobs)를 적재하는 FIFO큐가 바로 잡큐(JobQueue)인 셈입니다.
위에 등장했던 html에서 두 개의 script블럭은 각각의 잡을 만들어내고 이를 큐에 등록하게 됩니다.

<script>
//여기 전체가 첫번째 잡으로 잡큐에 등록됨
</script>

<script>
//여기 전체가 두번째 잡으로 잡큐에 등록됨
</script>

즉 두 개의 script는 각각 잡큐에 등록되는 잡들인 셈입니다. 하지만 스펙을 더 참고해보면 더 깊은 사항을 파악할 수 있습니다.

  • Jobs and Job Queues섹션을 찬찬히 보다보면
  • 현재 진행 중이 EC가 없는데 잡이 큐에 적재되면 RunJobs()에 의해 즉시 실행되는데
  • 이 코드의 실행결과로 또 다른 잡이 등록될 수 있습니다.

따라서 엄밀하게는 아래와 같은 상황입니다.

<script>
//만약 여기서 추가적인 잡을 등록하면
//등록된 잡이 다 해소된 후
</script>

<script>
//그 다음에 이 잡이 실행된다.
</script>

일단 잡큐의 실행에 대해서는 이 정도로 살펴보고 실제 잡큐에 등록되는 잡의 종류를 알아보죠.

두 가지 잡스

실제 잡큐에 등록되는 잡의 종류는 딱 두 가지만 정의되어있습니다.

  • ScriptJobs – 일반적인 스크립트 코드를 실행하는 잡
  • PromiseJobs – 프라미스의 해소나 예외로 분기되어 실행될 함수가 적재되는 잡

두 개 다 잡큐를 사용하게 됩니다. 즉 프라미스도 현재 프레임에서 then으로 분기되는 경우 다음 프레임으로 넘어가는 건 아니지만 잡큐로 then 이후가 PromiseJobs로 등록되므로 현재 EC의 실행은 쭉 진행된 이후에 잡큐에서 꺼내 실행하게 되는 것입니다.

ScriptJobs

머 프라미스를 통해서 PromiseJobs를 생성한다고 치고 ScriptJobs는 어떻게 생성하는 걸까요?

실제 js를 구현하는 측의 스펙을 정의하는 w3c의 잡큐 병합 항목을 보면 ScriptJobs는 코드로는 생성하지 못하게 못 박고 있습니다. 하지만 다음과 같은 코드로 손쉽게 ScriptJobs도 생성할 수 있습니다.

const s = document.createElement('script');
s.text = "//스크립트내용";
document.body.append(s);

PromiseJobs

프라미스의 then은 즉시 잡큐에 넣도록 스펙에서 규정하고 있습니다. 따라서 즉시 해소된다 하더라도 현재 실행 중인 EC가 완료된 후 다음 잡으로 실행되게 됩니다. 이는 다음과 같은 코드로 확인할 수 있습니다.

Promise.resolve()//즉시 해소되는 프라미스
  .then(_=>console.log('promise step1'));
console.log('ec running');

//ec running
//promise step1

분명 resolve로 즉시 해소되어있을텐데도 then의 함수는 바로 호출되지 않고 우선 EC가 다 해소된 이후 잡큐에서 꺼내 실행되는 것을 볼 수 있습니다. 이는 보다 복잡한 다중 프라미스 상황으로 번지는데 다음과 같이 두 개의 프라미스가 두 번 then을 하는 상황을 가정해보죠.

Promise.resolve()//즉시 해소되는 프라미스1
  .then(_=>console.log('promise1 step1')) //첫번째 then
  .then(_=>console.log('promise1 step2'));//두번째 then

Promise.resolve()//즉시 해소되는 프라미스2
  .then(_=>console.log('promise2 step1')) //첫번째 then
  .then(_=>console.log('promise2 step2'));//두번째 then

console.log('ec running');

그럼 차근차근 순서대로 보면

  1. 처음 등장한 프라미스1의 첫 번째 then이 잡큐에 등록됩니다.
  2. 이 시점에서 then은 실행되지 않으므로 두 번째 then은 무시해두죠.
  3. 이어 두 번째 등장한 프라미스2의 첫 번째 then이 잡큐에 등록됩니다. 이제 잡큐에는 두 개의 PromiseJobs가 등록되었습니다.
  4. 이제 마지막 줄의 console.log가 실행되어 현재의 ScriptJobs가 완료됩니다.
  5. 잡큐에 있는 다음 잡인 1번에서 등록한 첫 번째 프라미스의 첫 번째 then이 실행됩니다. 그 결과 다시 then이 호출되어 이를 잡큐에 등록하게 됩니다.
  6. 두 번째 잡큐에 들어있는 잡은 3번에서 등록한 두 번째 프라미스의 첫 번째 then입니다. 이것도 실행하면 그 결과 잡큐에 두 번째 then의 내용을 등록하게됩니다.
  7. 이제 5번에서 등록한 첫 번째 프라미스의 두 번째 then이 실행됩니다.
  8. 이어서 6번에서 등록한 두 번째 프라미스의 두 번째 then이 실행됩니다.

결과적으로 다음과 같은 로그를 볼 수 있습니다.

//ec running
//promise1 step1
//promise2 step1
//promise1 step2
//promise2 step2

같은 프레임에서 즉시 해소되는 프라미스라 할지라도 해당 프레임의 잡큐에 등록되는 순서에 따라 실행되므로 위와 같은 결과가 되는 것입니다.
이를 통해 여러 프라미스 간의 then사이의 잡큐 등록절차 및 순서에 따른 문제원인을 파악할 수 있습니다.

ScriptJobs와 PromiseJobs를 연결지어 이해하기

이제 두 개를 동시에 연결지어 생각해볼 수 있습니다. 다음과 같은 스크립트 블럭 두 개가 각각의 프라미스를 갖고 있다고 생각해보죠.

<script>
Promise.resolve()
 .then(_=>console.log('block1 step1'))
 .then(_=>console.log('block1 step2'));
console.log('block1');
</script>

<script>
Promise.resolve()
 .then(_=>console.log('block2 step1'))
 .then(_=>console.log('block2 step2'));
console.log('block2');
</script>

위의 코드에서

  1. 우선 ScriptJobs에 첫 번째 script태그 내용이 등록됩니다.
  2. 등록되었으므로 무조건 실행됩니다.
  3. 실행하니 프라미스의 then이 나와 다음 잡큐에 등록됩니다.
  4. 3번에서 등록된 PromiseJobs가 실행됩니다. 그 결과 다시 then이 나와 다음 잡큐에 등록됩니다.
  5. 4번에서 등록된 PrmoiseJobs가 실행되며 잡큐는 비어있는 상태가 됩니다.
  6. 이어서 브라우저가 다음 script태그 내용을 ScriptJobs로 잡큐에 등록합니다.
  7. 6에서 등록한 스크립트가 실행됩니다.
  8. 스크립트가 실행되며 3번과 마찬가지 형태로 프라미스와 then 두 번이 잡큐에 등록되며 실행됩니다.

결과적으로 로그는 다음과 같을 것입니다.

//block1
//block1 step1
//block1 step2
//block2
//block2 step1
//block2 step2

이 번엔 앞에서 언급했던 script엘리먼트를 만들어서 실행하는 방식으로 직접 ScriptJobs를 생성하는 코드를 보죠.

<script>
{
  const s = document.createElement('script');
  //동적 스크립트를 생성하고 이 안에도 프라미스를 넣자
  s.text = `
    Promise.resolve()
     .then(_=>console.log('dynamic step1'))
     .then(_=>console.log('dynamic step2'));
    console.log('dynamic');
  `;
  document.body.appendChild(s);
}

//이쪽이 현재 ScriptJobs에서 실행하고 있는 흐름 쪽의 코드
Promise.resolve()
   .then(_=>console.log('base step1'))
   .then(_=>console.log('base step2'));
console.log('base');
</script>

이러면 굉장히 재밌는 일이 일어납니다. 아마도 여기까지의 흐름을 쫓아오셨다면 다음과 같은 결과를 기대하셨을 겁니다.

//base
//base step1
//base step2
//dynamic
//dynamic step1
//dynamic step2

위처럼 예상하신 분들은

  1. 우선 현재 스크립트가 실행된 후
  2. appendChild한 스크립트가 실행될 것이다.

라고 생각하신 분들입니다. 이론상 맞는 생각입니다. 반대로 아래와 같이 예상한 분도 있을 겁니다.

//dynamic
//dynamic step1
//dynamic step2
//base
//base step1
//base step2

이 분들은 보다 브라우저 작동에 대해 해박하신 편인데, script태그는 html구조에 편입되는 순간 곧장 실행된다라는 것을 알고 계시는 분들입니다.
하지만 실제 결과는 의외로 다음과 같습니다.

//dynamic
//base
//dynamic step1
//base step1
//dynamic step2
//base step2

이는 마치 동적으로 생성한 스크립트와 현재 실행 중인 스크립트가 하나의 ScriptJobs가 되어 실행되는 결과와 같습니다.
사실 그렇습니다. script태그를 생성하든 innerHTML로 넣어주던 자바스크립트의 DOM제어가 또 다른 스크립트를 만들어내는 경우 새로운 잡으로 등록되지 않고 현재 잡에서 연속적으로 실행되는 결과를 낳게 됩니다. 그 결과 새로 생성한 동적 스크립트는 현재의 ScriptJobs에서 실행되게 되어 먼저 EC의 콘솔이 해소되고 이어서 순차적으로 프라미스가 잡큐에 등록되며 해소됩니다.

async defer에 따른 차이

새로운 표준으로 async 옵션이 생겼지만 대부분의 브라우저는 여전히 defer를 광범위하게 지원하고 있습니다.

  1. defer속성은 DOMLoaded 이후에 로딩된 스크립트가 순서에 맞게 실행되는 것을 보장합니다.
  2. 이에 비해 async는 html상에 등장한 순서에 상관없이 먼저 로딩된 녀석이 먼저 실행됩니다.

async의 경우

async는 상대적으로 순서보장이 안되므로 순서가 필요한 경우 불편하게 전용로직을 구현해야하므로 HTML5.1에서는 async의 false옵션이 생겼는데 이는

  1. async하지 않게 로딩한다는 뜻이 아니라 로딩은 async하게 하지만
  2. 실행은 순서에 맞게 해준다라는 옵션입니다.

즉 먼저 나온 스크립트가 먼저 로딩되면 즉시 실행하지만 두번째로 나온 스크립트가 먼저 로딩되면 첫번째 스크립트가 로딩되어 실행된 이후에 실행되는 식입니다.
안타깝게도 이 옵션은 직접 태그에 쓰면 소용없고 동적으로 스크립트를 만든 경우에만 적용할 수 있습니다.

<!--소용없음. 그냥 async 선언과 동일-->
<script async="false" src="1.js"></script>
//js에서 동적로딩하는 경우
const s = document.createElement('script');
s.src = '1.js';
s.async = false; //효과 있음
document.body.appendChild(s);

이렇게 async를 적용한 script의 경우 스크립트 하나당 무조건 하나의 ScriptJobs를 생성하므로 프라미스의 경우도 별도의 잡큐 순서를 갖게 됩니다.
예를 들어 다음의 두 개 스크립트를 로딩하면

<script async src="1.js"></script>
<script async src="2.js"></script>
//1.js-------------------
Promise.resolve()
   .then(_=>console.log('1 step1'))
   .then(_=>console.log('1 step2'));
console.log('1');
//2.js-------------------
Promise.resolve()
   .then(_=>console.log('2 step1'))
   .then(_=>console.log('2 step2'));
console.log('2');

1이 먼저 로딩되면 1부터, 2가 먼저 로딩되면 2부터 다음과 같은 형태로 출력됩니다.

//1
//1 step1
//1 step2
//2
//2 step1
//2 step2

//또는

//2
//2 step1
//2 step2
//1
//1 step1
//1 step2

즉 1과 2가 섞이지 않고 자신의 잡큐관리를 하게 됩니다. 이는 async=false를 줘도 마찬가지로 보장됩니다.

…보장되어야 하는데 말이죠..

파이어폭스는 async가 false인 경우 어떤 경우는 async가 false인 애들을 묶어서 하나의 ScriptJobs로 등록하기도 하고 어떤 경우는 분리해서 등록하기도 합니다….젠장.
그래서 파이어폭스는 다음과 같은 두 가지 결과가 나올 수 있습니다.

//1. 각각 ScriptJobs로 등록된 경우

//1
//1 step1
//1 step2
//2
//2 step1
//2 step2

//2. async false인 스크립트 두 개가 하나의 ScriptJobs로 등록된 경우
//1
//2
//1 step1
//2 step2
//1 step2
//2 step2

즉 async false상황 하에서의 변태는 파이어폭스입니다. 하지만 async false상황 하에서 한 프레임에서 둘 다 로딩되었을 때 어떻게 잡스를 나누라는 표준스펙은 없습니다. 따라서 구현이 틀렸다고는 할 수 없습니다(오히려 파이어폭스만 한 프레임 안에서 여러 개의 스크립트로딩완료 발생되는 진정한 병렬로더일 수 있습니다)

defer의 경우

이에 비해 defer는 여러 개의 스크립트를 로딩해도 반드시 순서를 보장하는 형태입니다.

<script defer src="1.js"></script>
<script defer src="2.js"></script>

아래 태그에서 반드시 1 다음에 2가 실행될 것을 보장합니다. 그러면 예상하건데 다음과 같은 출력을 기대해볼 수 있을 것입니다.

//1
//1 step1
//1 step2
//2
//2 step1
//2 step2

하지만 여기서 재밌는 일이 발생합니다. 크롬과 파이어폭스는 개별 defer를 각각 ScriptJobs로 등록하기 때문에 위와 같이 출력됩니다.
엣지는 마치 동적 스크립트를 삽입했을 때처럼 defer로 선언된 스크립트를 묶어서 하나의 ScriptJobs로 등록하기 때문에 아래와 같이 출력됩니다.

//1
//2
//1 step1
//2 step1
//1 step2
//2 step2

이쪽도 어느 쪽이 맞다고는 할 수 없습니다. defer의 결과가 취합되는데 있어서 표준은 없기 때문에 제각각 구현 방식을 따릅니다. 여튼 defer의 x맨은 엣지네요.

결론

es2015+에 도입되어있는 잡스와 잡큐의 개념은 HTML5에도 유기적으로 반영되어있는 표준입니다. 언제 자바스크립트코드가 활성화되고 실행되는가에 대한 원리라고 할 수 있죠.
현상보다는 스펙을 이해하고 브라우저 간의 차이점을 인식하면 혼동스러울 것은 없는 정도입니다 ^^;
script태그의 async와 defer의 관계를 보면 결국 크롬, 파폭, 엣지는 다른 정책으로 ScriptJobs를 생성합니다. 이를 잘 이해하고 미묘한 문제를 피해야겠습니다.
위에서 다뤘던 코드의 예제는 다음과 같습니다.

예제