JS 비동기의 핵심 Event Loop

조 은길·2021년 12월 30일
13
post-thumbnail
post-custom-banner

지난 편에서 Node.js의 구조에 관해 간단하게 언급했다. 특히, libuv가 Node.js와 JS에서 굉장히 중요한 파트인데, 바로 "비동기"를 담당하고 있기 때문이다.

위 그림에서 언급하듯이 libuv는 이벤트 기반과 논 블로킹 담당하고 있는데, 전부 비동기와 밀접한 관련이 있는 개념들이다.

논 블로킹 : 이전 작업이 완료될 때까지 대기하지 않고, 다음 작업을 수행하는 방식

I/O : Input/Output => 입출력을 의미한다.

이벤트 기반(event-driven) : 이벤트가 발생할 때 미리 지정해둔 작업을 수행하는 방식

ex) 클릭, 키보드, 네트워크 요청 등등

그리고 이 Node.js가 유명해진 이유 역시 "싱글 스레드 + 비동기 모델"이기 때문이다.
=> 싱글 스레드라서, 멀티 스레드에 비해 초보자들이 접근하기 쉽고!!
=> 비동기 모델이기 때문에, 모든 작업들이 동시에 처리될 수 있는 상황에서는 동기적인 방식보다 훨씬 더 짧은 시간에 작업들을 완료할 수 있다.

그렇다면, JS와 JS의 실행기 Node.js는 어떻게 비동기 방식으로 작동할 수있는가??

  • 브라우저 작동 구조
  • Node.js 작동 구조

위의 그림들에서 공통적으로 등장하는 단어가 하나 있다. 마치, 톱니바퀴처럼 모든 것들이 잘 돌아가도록 돌려주는 듯한 모양을 하고 있는 Event Loop이다.

그림과 같이, Event Loop는 특정한 함수들을 일정한 규칙에 맞게 다른 곳에 보내주는 역할을 한다. 그렇기 때문에, Event Loop가 보내주는 곳들이 어떻게 이뤄져있는지부터 알아야 Event Loop를 이해할 수 있으니, 각 파트의 명칭과 역할에 대해서 알아보자!

  • 필자는 크롬 브라우저를 기준으로 설명하겠다.

🙋 JS V8 엔진

자바스크립트 엔진에서 가장 유명한 것이 구글 크롬의 V8 Engine이다.

그리고 JS 엔진은 Memory Heap 과 Call Stack 으로 구성되어 있다.

JS 는 원래 동기적으로 작동하는데 ( = 코드를 위에서 아래로 한 줄씩 순서대로 실행한다 ) ,
바로 JS가 단일 스레드 (single thread)이기 때문이다. 그리고 이 single thread라는 말은 Call Stack이 딱 "하나"라는 의미이다.
( = 멀티가 되지 않고, 하나씩 하나씩 처리한다는 의미! )

콜 스택에 대해서 더 알아보기 전에, 힙에 대해서 간단하게 짚고 넘어가자!!

Memory Heap : 메모리 할당이 일어나는 곳

Heap : 구조화되지 않은 넓은 메모리 영역이 할당되는 곳이다.
ex) 객체 ( 변수, 함수 등 ) 들이 담긴다. => 원시 타입이 아닌 것들은 전부 여기에 할당 된다.

🙋 콜 스택 ( Call Stack or Stack )

Call Stack은 코드가 동기적으로 실행될 때 쌓이는 곳이다. 자료 구조 중에 하나인 stack 형태로 쌓이는 게 특징이다.
Stack : 자료구조 중 하나, 선입후출(LIFO, Last In First Out)의 룰을 따른다.

파일(혹은 페이지) 하나가 실행하게 되면, 이 콜 스택에서 맨처음 크롬에서는 anonymous 가 Node.js에서는 main이 제일 먼저 콜 스택에 쌓인다(실행된다). 이 둘은 해당 파일의 모든 코드를 가지고 있는 함수라고 생각하면 된다. 그리고 콜 스택의 맨 마지막까지 남아있다가 사라진다. 즉, anonymous가 종료되면, 콜 스택이 비어진 거다.

근데, 가끔 가다 JS는 비동기적은 작업도 할 수 있다. ex) setTimeout, 이벤트리스너, ajax 함수, fs 명령어 등등
코드를 위에서부터 한 줄씩 처리하는게 아니라, 오래 걸리는 코드는 제껴두고 빨리 실행 되는 코드부터 실행시킨다.

🙋 Web API ( Node.js에서는 백그라운드 )

Web API는 자바스크립트 엔진이 아니다. 브라우저에서 제공하는 API 로, DOM, Ajax, Timeout 등이 있다.

위에서 언급한 비동기적으로 작업하는 함수들은 콜 스택에서 처리되는 것이 아니라 바로 Web API로 보내진다.
( Node.js에서는 "백그라운드"가 동일한 작업을 수행한다!! )
이곳에 올 수 있는 함수들은 ( = 동시에 실행될 수 있는 애들은 ) 제한적이고, 나머지는 모두다 동기적으로 돌아간다.

이러한 비동기 함수들은 각 함수마다 걸려진 조건들을 충족시키면, 콜백 큐로 이동한다.

좀 더 자세히 말하자면, Call Stack에서 실행된 비동기 함수는 Web API로 이동하고, Web API는 비동기 함수의 콜백함수를 Callback Queue에 밀어 넣는다.
ex) addEventListener( 'click' , function() {...})
addEventListener는 Web API로 들어가지만, 2nd 인자의 익명함수는 callback queue로 보내진다.

🙋 콜백 큐 ( callback queue )

비동기적으로 실행된 콜백함수가 보관 되는 영역이다. 자료구조 Queue 형태로 쌓이는 게 특징이다.
Queue(큐) : 자료 구조 중 하나, 선입선출(FIFO, Frist In Frist OUT)의 룰을 따른다.

콜백 큐는 아래와 같은 구조로 이뤄져 있다.

이 구조가 중요한 이유는 각각 들어가는 콜백 함수들의 종류가 다르고, 들어가는 큐에 따라서 이벤트 루프가 내보내는 우선 순위 역시 달라지기 때문이다.

Task Queue : 대표적으로 SetTImeout() 같은 타이머들이 여기에 들어간다.

Microtask Queue : 대표적으로 Promise의 then / catch , process.nextTick 가 여기에 들어간다.

Animation Frames : requestAnimationFrame API가 실행되면 콜백이 Animation Frames으로 담긴다.

ex)

requestAnimationFrame(function() {
    console.log("requestAnimationFrame");
})

브라우져마다 이벤트 루프의 탐색 순서가 다를 수 있다고는 하는데, 크롬 기준은 다음과 같다.
Microtask Queue => Animation Frames => Task Queue 순으로 실행된다.

그렇다면, 이 우선 순위를 기준으로 아래의 코드를 본다면, 어떤 순서로 실행 될까??

Q1)

console.log("start");

setTimeout(function() {
  console.log("this is from setTimeout");
}, 0);

Promise.resolve().then(function() {
  console.log("promise1");
}).then(function() {
  console.log("promise2");
});

requestAnimationFrame(function() {
    console.log(" this is from requestAnimationFrame");
})
console.log("end");
결과 값 :
start
end
promise1
promise2
this is from requestAnimationFrame
this is from setTimeout

Q2)

function oneMore(){
    console.log('one more');
}
function run(){
    console.log('func run is running');
    setTimeout(()=> {
        console.log('setTimeout is running');
    }, 0);
    new Promise((resolve) => {
      // 여기까지는 동기이다.
        resolve('hi');
      // 그러나 then을 만나는 순간 비동기가 되는 거다.
    } )
    .then( console.log );
  
    oneMore();
}
setTimeout( run, 5000 );
결과 값 :
func run is running
one more
hi
setTimeout is running

위의 예제들만으로, 100% 이해가 안 된다면, 이벤트 루프의 작동원리를 시각적인 요소로 볼 수 있게 만들어준 예시 코드를 확인해보면 도움이 될 것이다.

🙋 Event Loop

Event Loop는 Call Stack과 Callback Queue의 상태를 체크하여,
Call Stack이 빈 상태가 되면, Callback Queue의 첫번째 콜백 함수 하나를 Call Stack으로 밀어넣는다.
이러한 반복적인 행동을 틱(tick) 이라 부른다.

정리하면,

  • V8 엔진에서 코드가 실행되면 Call Stack에 쌓이고, Stack에서는 선입후출 룰 대로 실행된다.
  • 비동기 함수가 실행된다면, Web API가 호출된다.
  • Web API는 비동기함수의 콜백함수를 Callback Queue에 밀어넣는다.
    • Promise는 Microtask Queue로, Timeout은 Task Queue로,
    • RequestAnimationFrame은 Animation Frame으로 콜백함수를 밀어넣는다.
  • Event Loop는 Call Stack이 빈 상태가 되면 콜백을 Call Stack으로 이동시킨다.
    콜백함수 이동 우선순위는 Microtask Queue => Animation Frames => Task Queue 이다.

⚠ 콜 스택에서 발생할 수 있는 에러

1. Max call stack size exceeded

콜 스택에서 발생할 수 있는 잠재적인 에러로는 " Max call stack size exceeded " 가 있다.

대부분 재귀함수를 잘못 만들어서 콜스택이 터졌을 때, 보게 되는 에러이다.

이 에러에 대해서 간략하게 설명하자면,
브라우저마다 콜 스택의 한계점이 다르다. 일반적으로 1만개를 가지고 있고, 크롬은 약 12만개를 가지고 있다고 한다. 그리고 이 제공된 숫자만큼의 콜 스택을 초과해서 콜 스택이 쌓일 때, 발생하는 에러이다.

2. browser freezing

이벤트 루프는 처음에 비동기 함수를 백 그라운드로 보냈다가 => 태스크 큐로 옮겨 간다.

단!!!! => 콜 스택이 비었을 때만, 이벤트 루프가 콜 스택으로 올려보낸다.

즉, stack을 너무 바쁘게 만들면, 비동기 함수가 제때 들어가지를 못하고, 브라우져 프리징 현상이 발생한다.

ex)
1. 클릭 이벤트가 일어났는데, 해당 콜백함수가 큐에서 콜스택으로 들어가지 못하고 콜스택의 모든 함수들의 실행이 종료될 때까지 기다리고 있는 상황이다. 이때, 위의 이미지와 같은 "응답 대기 중"현상이 발생한다.

2. 마찬가지로, 버튼 하나에 이벤트 리스너를 100개씩 달아놓으면, 큐에 콜백함수가 100개 생기고 이에 따라 웹싸이트가 버벅일 수 있다.

=> 즉, 우리는 콜 스택을 너무 바쁘게 만들지 말고, 하나의 이벤트에 너무 많은 이벤트 리스너를 달아놓는 방식을 지양해야 한다.

✨글을 마치며

내용이 엉성해질까봐 미쳐 언급하지 못했던 부분을 첨부하고자 한다.

  1. 호출 스택(얘만 JS) , 백 그라운드 ( C++ or 운영체제로 운영 됨 ), 태스크 큐( 다른 언어로 만들어짐 ) 각각 스레드를 하나씩 차지 한다.

  2. promise 함수 자체는 동기적 함수이다. 그러나, .then을 만나는 순간 비동기적으로 작동한다.

  3. task 큐와 event 큐는 같은 거다. => 타이머 함수들이 담긴다.

  4. 마이크로 큐와 잡 큐는 같은 거다. => 둘다 promise가 담긴다.
    => 어떤 부분에서는 차이가 있다는 자료도 종종 보이는데, 교육 엔지니어님께서 같은 개념이라고 생각해도 무방하다고 하신다.

  5. Node.js의 libuv는 HTML 스펙을 완벽히 따르지는 않기 때문에, 브라우저 환경의 이벤트 루프와 상세 구현이 조금씩 다르다.

  6. 심지어는, 브라우저마다 이벤트 루프 구현이 조금씩 다르다.
    => 그래서, 설명을 시작할 때, 크롬 기준으로 설명한다고 언급했다.

  7. 백그라운드나 Web API에 남아있는 함수들은 누가 먼저 실행 될지 모른다.
    => 그냥 백그라운드에서 먼저 끝나는 함수들이 콜백 큐로 먼저 간다.
    => .then 이 먼저 끝날수도 있고, setTimeout이 먼저 끝날 수도 있다.


자료 출처 및 참고 자료

profile
좋은 길로만 가는 "조은길"입니다😁
post-custom-banner

4개의 댓글

comment-user-thumbnail
2023년 3월 16일

좋은 글 정말 감사합니다!! 지난 글을 포함해서 이렇게 이해가 쉽게 작성된 글을 오랜만에 보는 것 같습니다.

1개의 답글
comment-user-thumbnail
2024년 7월 6일

Microtask Queue 실행 순서에 대해 멋진 글 남겨주셔서 감사드립니다!

1개의 답글