Javascript 동작 원리 (2) - 실행순서

Roy Jung·2023년 7월 19일
0

이번에는 Event loop에서 javascript를 특히 비동기 함수들을 어떤 순서로 실행 순서로 실행시키는지 예시 위주로 알아보자.

동기 & SetTimeout

1

첫번째로 기본적인 동기 함수와 SetTimeout의 콜백 함수가 어떤 방식으로 실행되는지 보자.

const foo = () => console.log('First');
const bar = () => setTimeout(() => console.log('Second'), 500);
const baz = () => console.log('Third');

bar();
foo();
baz();

정답 : Frist -> Third -> Second

  1. bar 함수를 Call Stack에 넣었다.
  2. setTimeout이 있으므로 Web API에 집어넣고 bar 함수는 Call Stack에서 제거된다.
  3. Web API에서 타이머가 실행되는 동안 foo가 호출되어 Call Stack에 쌓였다. console을 찍는다.
  4. baz가 호출되었고 console을 찍고 제거된다.
  5. 그 동안 Web API에서는 Task Queue에 setTimeout에 있던 콜백을 집어넣는다.
  6. Event Loop는 Call Stack에서 아무것도 없음을 확인한 후 Task Queue에 있던 콜백을 Call Stack에 넣는다. 그리고 실행.

이런 식으로 Event Loop는 실행할 함수를 관리하는 역할로 Call Stack과 Task Queue의 함수를 계속 확인한다. 이렇게 반복되는 매 순회(iteration)를 tick 이라고 부른다.

SetTimeout같은 콜백 함수는 Task Queue로 들어가게 되고 Task Queue에 있는 task들은 Call Stack이 비어 있어야지만 실행된다.

2

function delay() {
  for (var i = 0; i < 100000; i++);
}
function foo() {
  delay();
  bar();
  console.log('foo!'); // (3)
}
function bar() {
  delay();
  console.log('bar!'); // (2)
}
function baz() {
  console.log('baz!'); // (4)
}

setTimeout(baz, 10); // (1)
foo();

출력은 어떤 순서로 될까? delay 함수는 10만의 연산을 해야하므로, 꽤 올래 걸리기 때문에 baz가 가장 먼저 찍힐까? 아니다. setTimout이 Task Queue에 넣은 후, Call Stack이 비어있을 경우 Event Loop가 Task Queue에 있는 baz를 Call Stack으로 넘겨줄 것이기 때문에 baz가 가장 나중에 찍힌다. setTimeout의 두번째 인자인 10 은 10ms 라는 의미를 가진다. 즉, 0.01초다. 그럼에도 불구하고 10ms 보다 더 늦게 실행될 것이다. 즉, 자바스크립트의 타이머는 정확한 타이밍을 보장해주지 않는다.

Promise

1

setTimeout(function () {
  // (A)
  console.log('A');
}, 0);
Promise.resolve()
  .then(function () {
    // (B)
    console.log('B');
  })
  .then(function () {
    // (C)
    console.log('C');
  });

Promise도 비동기로 실행되니까 Task Queue에 추가되어 순서대로 A -> B -> C로 찍힐까? 아니다 답은, B -> C -> A다. 이유는 바로 Promise가 MicroTask Queue를 사용하기 때문이다.

MicroTask Queue는 일반 Task Queue보다 더 높은 우선순위를 갖는 태스크다. Task Queue에 대기중인 태스크가 있더라도 MicroTask Queue가 먼저 실행된다. setTimeout은 콜백 A를 Task Queue에 추가하고 Promise의 then() 메서드는 콜백 B를 Task Queue가 아닌 MicroTask Queue에 추가한다. 콜백 B가 실행되고 나면 두번째 then() 메서드가 콜백 C를 MicroTask Queue에 추가한다. Event Loop는 다시 MicroTask Queue를 확인하고, 큐에 있는 콜백 C를 실행한다.

이후에 MicroTask Queuerk 비었음을 확인한 다음 Task Queue에서 콜백 A를 꺼내와 실행한다. 즉, MicroTask Queue에는 Promise가 담기며 Event Loop가 Task Queue 보다 먼저 실행한 후, 다시 then 절이 있는지 확인하고 다시 MicroTask Queue에 집어넣었다.

MicroTask Queue에는 Promise뿐 아니라, Observer API, Node.js의 process.nextTick 등이 그 대상이 된다.

📍 마이크로 태스크 vs 매크로 태스크

  • Queue는 Macro Queue와 MicroQueue가 있다. Micro Queue가 우선순위가 더 높다.

마이크로 태스크들은 실행하면서 새로운 마이크로 태스크를 큐에 추가할 수도 있다. 새롭게 추가된 마이크로 태스크도 큐가 빌 때까지 계속해서 실행된다.

반대로, 이벤트 루프는 매크로 태스크 큐에 있는 것을 실행시키기 시작할 때 있는 매크로 태스크만 실행시킨다. 매크로 태스크가 추가한 매크로 태스크는 다음 이벤트 루프가 실행될 때까지 실행되지 않는다.

2

console.log('Start!');

setTimeout(() => {
  console.log('Timeout!');
}, 0);

Promise.resolve('Promise!').then(res => console.log(res));

console.log('End!');

정답 : Start => End => Promise! => TimeOut!

Promise는 Macro Queue, Timeout!은 Micro Queue에 들어가고 Call stack이 빌때까지 기다렸다 Micro Queue부터 실행되기 때문이다.

Async/Await

비동기 함수가 Promise를 반환하는데 await 키워드를 비동기 함수 앞에 붙여주면 비동기 함수가 Promise를 반환할 때까지 코드를 일시 중지 할 수 있다. 다음 코드가 어떻게 실행되는지 살펴보자.

const one = () => Promise.resolve('One!');

async function myFunc() {
  console.log('In function!');
  const res = await one();
  console.log(res);
}

console.log('Before function!');
myFunc();
console.log('After function!');
  • Before function!
  • In function!
  • After function!
  • One!

Before function!이 실행되었고, myFunc 함수 내부의 In function!이 먼저 찍혔다.

이 과정으로 Promise.then 과 async 함수의 차이점을 알 수 있다.

async 함수에서는 await 를 만나면 함수가 중단되고 MicroTask Queue로 들어간다.
Promise는 곧바로 MicroTask Queue에 들어간다.

마지막 문제

function a() {
  console.log('a1');
  b();
  console.log('a2');
}

function b() {
  console.log('b1');
  c();
  console.log('b2');
}

async function c() {
  console.log('c1');
  setTimeout(() => console.log('setTimeout'), 0);
  await d();
  console.log('c2');
}

function d() {
  return new Promise(resolve => {
    console.log('d1');
    resolve();
    console.log('d2');
  }).then(() => console.log('then!'));
}

a();
  1. a 함수 호출, console.log 실행, 출력 → a1
  2. b 함수 호출, console.log 실행, 출력 → b1
  3. c 함수 호출, console.log 실행, 출력 → c1
  4. setTimeout이 Task Queue에 쌓임.
  5. d 함수 호출, 첫 번째 console.log 실행, 출력 → d1 (비동기X)
  6. 두 번째 console.log 실행, 출력 → d2 (비동기X)
  7. .then 콜백은 백그라운드를 거쳐 마이크로 태스크 큐에 쌓임
  8. d 함수 호출 완료 후 await를 만나고 async 함수 c는 중단
    async 함수의 나머지는 마이크로 태스크 큐에 쌓임
  9. c 함수를 호출한 실행 컨텍스트(b함수)로 돌아가서 console.log 실행, 출력 → b2
  10. b 함수를 호출한 실행 컨텍스트(a함수)로 돌아가서 console.log 실행, 출력 → a2
  11. Call Stack이 모두 비워지고, Event Loop가 MicroTask Queue를 확인. then 콜백, async 함수가 쌓여있음.
  12. .then 콜백 실행, console.log 출력 → then!
  13. async 함수 중단된 곳부터 이후로 실행, console.log 출력 → c2
  14. 또다시 Event Loop가 Task Queue를 확인, setTimeout의 콜백이 쌓여있음. setTimeout의 콜백을 Call Stack으로 옮겨 실행 및 출력 → setTimeout

정답

a1
b1
c1
d1
d2
b2
a2
then!
c2
setTimeout

참고
https://joshua1988.github.io/web-development/translation/javascript/how-js-works-inside-engine/
https://hmk1022.tistory.com/entry/task-queue-micro-task-queue
https://pozafly.github.io/javascript/event-loop-and-async/
https://velog.io/@devstone/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84-%EA%B8%B0%EB%B0%98%EC%9C%BC%EB%A1%9C-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC-%EB%9C%AF%EC%96%B4%EB%B3%B4%EA%B8%B0

profile
내가 보려고 쓰는 글

1개의 댓글

comment-user-thumbnail
2023년 7월 19일

너무 좋은 글이네요. 공유해주셔서 감사합니다.

답글 달기