본문 바로가기
Web development/Node.js & Typescript

[Javascript] 이벤트루프와 호출 스택(call stack), 비동기성

by 자몬다 2020. 4. 3.

자바스크립트의 큰 특징으로 비동기성과 싱글스레드, Non-blocking IO가 있다. 비동기성은 알겠고, 논 블로킹 IO도 비동기성에서 오는 특징이라는건 알겠는데, 비동기성은 어떻게 동작하는 것일까? 그것도 싱글스레드라면, 한번에 한 동작밖에 못하는게 아닌지?

 

우선 동기성과 비동기성은 간단히 그림으로 살펴보자.

한 동작을 수행하다가 다른 동작이 필요한 경우, 

동기 : 응답이 반환될때까지 하던 일을 멈추고 기다린다.

비동기 : 응답을 기다리느라 하던 일을 멈추지 않는다.

 

여기서 언뜻 들었던 의문이 있다.

하던 일을 멈추지 않는다 = 여러 일을 동시에 한다 => 싱글스레드인데?

 

이 부분은 자바스크립트의 동작 원리, 특히 이벤트루프와 호출스택을 이해해야 한다.

 

호출 스택(call stack)

자바스크립트는 딱 하나의 호출 스택(call stack)을 가지고 있다. 다시말해 하나의 호출 스택을 처리할 하나의 스레드를 가지고 있고, 한번에 한 가지의 일만 할 수 있다는 의미이기도 하다.

 

아래의 코드를 예로 한번 살펴보자.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  var squared = square(n);
  console.log(squared);
}

printSquare(4);

단계별로 호출 스택의 상태를 보자.

 

[ main() ]

1. 이 코드를 실행하면, 호출 스택에는 실행되는 코드 자체를 의미하는 main()함수(=anonymous function)를 스택에 넣게 된다.

 

[ multiply(n, n) ]

[ square(n) ]

[ printSquare(4) ]

[ main() ]

2. 그 후 printSquare(4), square(n), multiply(n, n)을 차례로 넣는다.

 

[ multiply(n, n) ] -> 16

[ square(n) ]

[ printSquare(4) ]

[ main() ]

3. 스택이므로, 값이 리턴될때마다 가장 위의 함수를 꺼내서 실행한다.

multiply(n, n)에서 16이 리턴되면, multiply함수는 스택에서 사라진다.

 

[ square(n) ] -> 16

[ printSquare(4) ]

[ main() ]

4. square는 16을 리턴하고 사라진다.

 

[ console.log(squared) ]

[ printSquare(4) ]

[ main() ]

5. printSquare에서 squared변수에 16이 할당되고, console.log()가 스택에 추가된다.

 

[ console.log(squared) ] -> 16

[ printSquare(4) ]

[ main() ]

6. 콘솔에 16이 찍히고 사라진다.

 

[ printSquare(4) ]

[ main() ]

7. printSquare의 코드가 종료되었기 때문에 사라진다.

 

[ main() ]

8. 모든 코드가 실행되었으므로 main()이 사라진다.

 

위 코드를 실제로 실행해보면, 순식간에 끝날 것이다. 하지만 네트워크 통신처럼, 오래 걸리는 작업도 있다.

하나의 호출스택밖에 없는데 오래 걸리는 작업을 하면, 그 동안엔 다른 작업을 할 수 없다. 다시말해 브라우저는 그동안 동작을 멈출 수도 있고, 클라이언트 입장에서는 사이트가 멈춘 것처럼 보일 것이다. 그래서 비동기 함수가 필요한 것이다.

 

비동기 함수는, 함수의 동작이 끝날때까지(응답할 때까지) 기다리지 않고 다음 코드를 실행시키는 것이다. setTimeout함수나 ajax가 대표적이다. (그런데, 이 함수들은 자바스크립트 엔진에 포함된 함수가 아니다. 브라우저의 web api나, node api의 함수다. 그래서 별도의 )

 

아래 코드는 어떤 결과가 출력될까?

console.log('hello');

setTimeout(function late() {
  console.log('sorry I\m late!');
}, 3000);

console.log('world');

결과는 hello > world > ...5초 후... > sorry I'm late! 순으로 출력된다. 쉽게 짐작할 수 있으리라 생각한다.

(사실 정확히 5초는 아니고, 최소 5초 후에 실행됨을 보장할 뿐이다.)

 

실제로 어떻게 동작할까?

 

stack


main()

web api





task queue []

1. main()함수가 호출 스택에 추가된다.

 

stack

console.log('hello') --console--> hello

main()

web api






task queue []

2. console.log('hello')가 스택에 추가된다. 추가되자마자 콘솔에 hello를 출력하고 사라진다.

 

stack


setTimeout(late)

main()

web api







task queue []

3. setTimeout(late)가 스택에 추가된다.

 

stack


setTimeout(late) ------------------------------->

main()

web api



Timer [late] ...

task queue []

4. setTimeout(late)가 web api 또는 node api영역으로 이동해 타이머가 시작된다. 호출 스택에서는 사라진다.

 

stack


console.log('world') --console--> world

main()

web api



Timer [late] ...



task queue []

5. console.log('world')가 스택에 추가된다. 추가되자마자 콘솔에 world를 출력하고 사라진다. 타이머는 돌고있다.

 

stack

 

[ main() ]

web api


Timer [late] ...


task queue []

6. main()함수가 사라진다. 타이머는 아직도 돌고있다...

 

stack

 

web api

Timer [late] 
 |
 v
task queue [ late ]

7. timer가 종료된다. web api의 작동이 완료되면 late()가 task queue로 옮겨진다.

 

stack


late()

web api




task queue [ late ]

8. 이벤트루프가 late()를 스택으로 옮긴다.

 

stack


console.log('sorry I'm late!') --> sorry I'm late!

late()

web api






task queue []

9. console.log('sorry I'm late!')가 스택에 추가된다. 추가되자마자 콘솔에 출력하고 사라진다.

 

stack

late()

web api




task queue []

 

10. late()가 사라진다.

 

 

결국 이벤트 루프가 하는 일은 간단하다.

호출 스택과 태스크 큐를 보고 있다가, 호출 스택이 비어 있고, 태스크 큐에 할일이 있으면 -> 태스크 큐의 첫번째 콜백을 꺼내 호출 스택에 담는다.

이게 전부다.

 

그렇다면 아래 코드는 어떨까?

console.log('hello');

setTimeout(function late() {
  console.log('sorry I\'m late!');
}, 0); // 0초로 지정했다!

console.log('world');

결론부터 말하자면 출력되는 순서는 위의 코드와 똑같다.

위의 4번째 단계에서, 타이머가 시작하자마자 끝나기 때문에, 태스크 큐로 이동할 것이다. 하지만 호출 스택에 할일이 있기 때문에, 이벤트루프는 태스크를 옮기지 않는다. console.log('world')와 main()까지 사라지고 나면 그제서야 옮겨지고, 실행되게 된다. 

 

따라서 위 코드에서 setTimeout은 코드 실행을 스택의 마지막 순서로 미루는 용도로 사용되었다고 볼 수 있다.

 

 

정리하자면,

 

  • 자바스크립트는 하나의 호출 스택(call stack)을 가지고 있고 모든 함수들은 호출 스택을 거쳐야 한다.
  • 우리가 흔히 사용하는 ajax, setTimeout같은 비동기 함수들은 호출 스택에 담긴 즉시 콜백 함수가 실행되지 않는다.
  • web 또는 node api에서 어떤 동작을 수행하다가, 완료되면 (콜백)태스크 큐에 담기게 된다. 
  • 이벤트 루프는 호출 스택이 비어있으면 태스크 큐에서 하나씩 꺼내 호출 스택에 담는다.
  • 그러므로, 비동기 코드가 있으면, 코드의 실행 순서가 (위에서 아래로)보장되지 않는다.

 

 

참고자료

글을 정리하면서 좋은 자료들을 많이 참고하였다. 

 

https://meetup.toast.com/posts/89

 

자바스크립트와 이벤트 루프 : TOAST Meetup

자바스크립트와 이벤트 루프

meetup.toast.com

 

 

 

https://www.youtube.com/watch?v=8aGhZQkoFbQ

첫번째 이미지 출처 : WTF is Synchronous and Asynchronous!(Skrew Everything)

 

두번째 이미지 출처 : How JavaScript works: an overview of the engine, the runtime, and the call stack(Alexander Zlatkov)

 

 

댓글