본문 바로가기

웹 개발/프론트엔드 - JavaScript

[JavaScript] 한 달 동안 자바스크립트 박살내기 (11) - event loop

「한 달 동안 자바스크립트 박살내기」는, ChatGPT쨩이 선생님이 되어, 매일 수업해주는 느낌으로 진행합니다.

GPT쨩의 말은 이렇게 작성합니다.

 

 

 

오늘 공부 내용 및 목표

 

오늘 학습 목표

오늘은 JavaScript에서 비동기 코드를 읽을 때 가장 중요한 기반인 event loop를 다룰 거야. setTimeout(..., 0)이 왜 즉시 실행되지 않는지, Promise의 후속 처리가 왜 timer보다 먼저 보이는지를 call stack → task → microtask → event loop 순서로 추적해보자.

오늘은 Promise 자체의 사용법을 깊게 배우는 날은 아니야. Promise는 실행 순서를 설명하는 데 필요한 최소 범위만 먼저 정의하고, chaining이나 async/await, fetch는 내일 09/02에 본격적으로 다룰게.

놀랍게도 9/1에 했어야 할 내용이었다.

12일 뒤에야 열어본게 레전드.

 

오늘의 위치

단계: JavaScript 실행 모델 → 비동기
주제: event loop — call stack, task/macrotask, microtask, Promise 실행 순서, setTimeout
필수 예상시간: 90분
선택 심화: 15~20분

 

 

 

어제 내용 복습

 

복습 1

다음 코드에서 finally는 언제 실행될까?
try {  
 throw new Error("boom");  
} catch (error) {  
 console.log("caught");  
} finally {  
 console.log("cleanup");  
}

이 코드에선, caught가 출력된 뒤 실행된다.

finally는 try 블록의 처리가 끝났을 때나, catch블록의 처리가 끝났을 때나 항상 실행된다.

정답.

 

 

복습 2

다음 두 호출에서 this가 같은지 설명해봐. (strict mode라고 가정)
const character = {  
 name: "Lyria",  
 normal() {  
   console.log(this.name);  
 },  
};  
  
character.normal();  
  
const fn = character.normal;  
fn();

음.. 똑같지않나?

오답. character.normal()에선 일반 함수가 method 형태로 호출되어 this가 character.

fn()의 경우엔, 일반 함수의 단독 호출이어서 this가 undefined.

 

복습 3

let으로 선언한 변수가 block scope를 가진다는 말은 무슨 뜻일까?
if (true) {  
 let hp = 100;  
}  
  
console.log(hp);

이 코드에서 hp가 선언된 곳은 if 블럭 안으로, 참조될 수 있는 위치도 if 블록 안에서만 가능하다.

정답.

 

 

 

핵심 수업

 

setTimeout - 일정 시간이 지난 뒤 callback 실행을 예약하는 브라우저 API

setTimeout은 지정한 시간이 지난 뒤 callback이 실행될 수 있도록 예약하는 기능이야.
setTimeout(() => {  
 console.log("timer");  
}, 1000);
두 번째 값 1000은 약 1000ms, 즉 1초 뒤부터 callback이 실행될 자격이 생긴다는 뜻이야.

이러면, 1초 뒤에 실행되게 되는데, 무조건 1초 뒤는 아니고, 다른 코드가 실행중이라면 그걸 기다려야 한다.

 

특히 이것도 즉시 실행되지 않아.
console.log("A");  
  
setTimeout(() => {  
 console.log("B");  
}, 0);  
  
console.log("C");

이러면 A-C-B 순으로 출력된다.

 

 

task - event loop가 한 번에 하나씩 꺼내 실행하는 작업 단위

task(태스크)는 브라우저가 나중에 JavaScript가 처리하도록 준비해 두는 작업 단위야. timer callback이나 일부 사용자 이벤트 callback 등이 task로 실행될 수 있어.

macrotask라는 표현도 자주 보게 될 텐데, 오늘 범위에서는 실무 설명이나 자료에서 task를 microtask와 구분하려고 macrotask라고 부르는 경우가 많다 정도로 이해하면 충분해. 브라우저 표준 문맥에서는 보통 task라는 표현을 사용해.

아까 코드를 다시 보자.
console.log("A");  
  
setTimeout(() => {  
 console.log("B");  
}, 0);  
  
console.log("C");
대략 이렇게 생각하면 돼.

1. console.log("A")
2. timer 예약
3. console.log("C")
4. 현재 코드 종료

나중에 실행할 task - timer callback → console.log("B")

setTimeout같이, 나중에 처리할 task들을 옆에 치워놓고, 현재 코드가 끝나는 대로 처리해주는 느낌이라고 생각하면 될 것 같다.

 

 

event loop - 실행할 다음 작업을 고르는 반복 메커니즘

event loop(이벤트 루프)는 현재 JavaScript 실행이 끝난 뒤, 준비된 다음 작업을 골라 실행하도록 이어주는 메커니즘이야.
단순화하면 다음 흐름으로 보면 돼.

현재 JavaScript 실행
↓
call stack이 비었는지 확인
↓
microtask 처리
↓
다음 task 실행
↓
다시 반복

실행할 우선순위가 정해지는 느낌이다. call stack으로부터 task를 하나씩 꺼내서 실행하는 느낌.

 

 

Promise - 미래에 완료되거나 실패할 비동기 작업의 결과를 나타내는 객체

Promise(프로미스)는 지금 당장 결과가 없을 수 있는 작업의 미래 결과를 나타내는 객체야.

왜 필요하냐면 네트워크 요청처럼 결과가 나중에 도착하는 작업을 다룰 때, “완료되면 이 함수를 실행해”라는 흐름을 표현할 필요가 있기 때문이야.

API 호출 결과를 보통 Promise 객체로 받아서, 성공/실패 여부를 처리하는 식으로 했던거같다.

 

console.log("A");  
  
Promise.resolve().then(() => {  
 console.log("B");  
});  
  
console.log("C");
Promise.resolve("done")은 이미 성공한 Promise를 만들고, .then(...)은 성공 결과를 받은 뒤 실행할 callback을 등록해.

이러면, then callback은 바로 실행되는게 아니라 call stack에 들어간다.

그래서 현재 스크립트인 A, C가 출력된 뒤에 B가 출력되게 된다.

 

 

microtask - 현재 JavaScript 실행이 끝난 직후, 다음 task보다 먼저 처리되는 작은 예약 작업

microtask(마이크로태스크)는 현재 실행 중인 JavaScript가 끝난 뒤 처리되지만, 다음 task를 실행하기 전 먼저 처리되는 작업이야.
Promise의 .then에 등록한 callback은 대표적으로 microtask를 통해 실행돼.
console.log("A");  
  
setTimeout(() => {  
 console.log("timer");  
}, 0);  
  
Promise.resolve().then(() => {  
 console.log("promise");  
});  
  
console.log("B");
출력은:
A B promise timer

순서를 단계별로 추적해보자.
1. 현재 script 실행  
  A 출력  
  timer task 예약  
  Promise의 then → microtask 예약  
  B 출력

2. 현재 script 종료

3. microtask 처리  
  promise 출력

4. 다음 task 처리  
  timer 출력

task라고 해서 다 같은게 아니라, microtask가 먼저 처리된다고 한다.

promise는 microtask이기 때문에, setTimeout으로 설정된 callback보다 먼저 처리된다.

 

현재 동기 코드
→ 준비된 microtask 전부 처리
→ 다음 task 하나 실행
→ 그 뒤 다시 microtask 확인

일단 현재 스크립트 다 실행하고, 그 후엔 「microtask 전부 처리 → task 전부 처리」를 반복한다.

이 때, microtask 안에서 microtask가 또 생기는 경우엔 이렇게 처리된다.

 

Promise.resolve().then(() => {  
 console.log("A");  
  
 Promise.resolve().then(() => {  
   console.log("B");  
 });  
});  
  
setTimeout(() => {  
 console.log("C");  
}, 0);
출력은:
A B C

A를 출력한 뒤에, B를 출력하는게 microtask 리스트에 추가된다. 그러면 event loop는 다음 task인 timer로 넘어가기 전에, microtask가 남아있는지 확인한다.

이 경우엔 B를 출력하는 처리가 남아있으니, B를 처리한 뒤에 C를 출력하게 된다.

항상 microtask가 task보다 먼저 비워진다는 것, 그리고 microtask를 끝없이 만들어내면 다음 task로 넘어가지 못할 수 있다는 것도 기억해두자.

 

 

 

코드 보고 동작 예측하기

 

예측 1

console.log("A");  
  
setTimeout(() => {  
 console.log("B");  
}, 0);  
  
console.log("C");

A, C, B 순으로 출력된다.

정답.

 

 

예측 2

console.log("A");  
  
Promise.resolve().then(() => {  
 console.log("B");  
});  
  
console.log("C");

A, C, B 순으로 출력된다.

정답.

 

 

예측 3

setTimeout(() => {  
 console.log("timer");  
}, 0);  
  
Promise.resolve().then(() => {  
 console.log("micro 1");  
});  
  
Promise.resolve().then(() => {  
 console.log("micro 2");  
});  
  
console.log("sync");

sync, micro 1, micro 2, timer 순으로 출력된다.

Promise는 microtask라 timer보다 먼저 처리되기 때문.

정답.

 

 

예측 4

console.log("start");  
  
setTimeout(() => {  
 console.log("timer 1");  
  
 Promise.resolve().then(() => {  
   console.log("micro in timer");  
 });  
}, 0);  
  
setTimeout(() => {  
 console.log("timer 2");  
}, 0);  
  
console.log("end");

start, end, timer 1, timer 2, micro in timer가 출력될거같다.

timer 1 출력하고 있는 시점에서 이미 그냥 task를 비우려고 하고 있어서?

오답. timer 1 다음에 micro in timer가 출력된다. task 하나를 처리할 때마다 또 microtask가 비어있는지 확인한다고 한다.

 

 

 

직접 코딩

 

작은 전투 로그 실행 순서 추적기 만들기

오늘은 작은 전투 로그 실행 순서 추적기를 만들어보자. 새로운 API는 쓰지 않고 오늘 배운 console.log, setTimeout, Promise.resolve().then만 사용해.
function runBattleTurn() {  
 console.log("1. 공격 입력");  
  
 // TODO 1:  
 // Promise.resolve().then(...)을 사용해서  
 // "3. 상태 반영"을 microtask로 예약  
  
 // TODO 2:  
 // setTimeout(..., 0)을 사용해서  
 // "4. 다음 연출"을 다음 task로 예약  
  
 console.log("2. 입력 처리 종료");  
}  
  
runBattleTurn();
목표 출력:
1. 공격 입력
2. 입력 처리 종료
3. 상태 반영
4. 다음 연출

아래와 같이 작성해주었다.

 

function runBattleTurn() {
  console.log("1. 공격 입력");

  Promise.resolve().then(() => {
    console.log("3. 상태 반영");
  })

  setTimeout(() => {
    console.log("4. 다음 연출")
  }, 0);


  console.log("2. 입력 처리 종료");
}

runBattleTurn();

 

 

추가 과제

그다음 한 단계 변형해봐.
"4. 다음 연출"을 출력하는 timer callback 안에서 Promise microtask를 하나 더 예약해서:
  5. 연출 후 상태 확인
이 출력이 어디에 들어가는지 직접 확인해.

아래와 같이 작성했다. "4. 다음 연출" 다음으로 바로 출력된다.

 

function runBattleTurn() {
  console.log("1. 공격 입력");

  Promise.resolve().then(() => {
    console.log("3. 상태 반영");
  })

  setTimeout(() => {
    console.log("4. 다음 연출")
    Promise.resolve().then(() => {
      console.log("5. 연출 후 상태 확인");
    })
  }, 0);


  console.log("2. 입력 처리 종료");
}

runBattleTurn();

 

 

코드 읽기 / 디버깅 드릴

어떤 개발자가 공격 버튼 처리 로그가 start → end → request complete → animation 순서로 나올 거라고 생각했어.
function attack() {  
 console.log("start");  
  
 setTimeout(() => {  
   console.log("animation");  
 }, 0);  
  
 Promise.resolve().then(() => {  
   console.log("request complete");  
 });  
  
 console.log("end");  
}  
  
attack();
질문:
1. 실제 출력 순서는?
2. request complete가 animation보다 먼저 나오는 이유는?
3. setTimeout(..., 0)을 setTimeout(..., 1)로 바꾸는 것이 이 코드의 실행 원리를 이해하는 올바른 해결책일까?
4. 이유도 설명해봐.
  1. start, end, request complete, animation 순으로 출력된다.
  2. Promise는 microtask로 처리되어, timer보다 먼저 실행되기 때문이다.
  3. 해결되지 않는다. 시간을 조절한다고 해서 event loop상의 처리 순서가 달라지는 것이 아니기 때문이다.

정답.

 

 

 

종료 테스트

 

1번 문제

다음 출력 순서를 적어봐.
console.log("1");  
  
Promise.resolve().then(() => {  
 console.log("2");  
});  
  
setTimeout(() => {  
 console.log("3");  
}, 0);  
  
console.log("4");

1, 4, 2, 3 순서로 출력된다.

정답.

 

 

2번 문제

다음 출력 순서를 적고, 특히 C와 D 중 무엇이 먼저인지 이유를 설명해봐.
setTimeout(() => {  
 console.log("A");  
  
 Promise.resolve().then(() => {  
   console.log("C");  
 });  
}, 0);  
  
setTimeout(() => {  
 console.log("D");  
}, 0);  
  
Promise.resolve().then(() => {  
 console.log("B");  
});

B, A, C, D 순으로 출력된다.

C가 먼저 출력되는데, 그 이유는 A 출력을 처리하는 task 내에서 C를 출력하는 microtask가 추가되어, D를 출력하는 task보다 우선적으로 처리되기 때문이다.

정답.

 

 

 

감상

 

조금씩 이해의 빵꾸가 메워지고 있는 느낌이 든다.

사실 지금 프로젝트에서 Typescript를 쓰는데도, 제대로 이해하지 않고 쓰고있던 개념들이 많다보니 답답했는데, 그런 것들이 메워지니 기분이 좋다.