본문 바로가기

JavaScript/지식 정리

[JavaScript] JavaScript 파헤치기 - 동작 원리

트리스티가 JavaScript를 공부하며 남긴 기록입니다. 틀린 내용은 언제든지 말씀해주세요 ~!

 

자바스크립트를 사용하다 보면 이 언어가 도대체 어떻게 동작하는지 궁금할 때가 있습니다. 도대체가 싱글 스레드인지 멀티 스레드인지 헷갈리게 동작하는 이상한 언어입니다. 그래서 이번에는 한번 자바스크립트의 동작 원리를 알아보려고 합니다.

 

 

 

 

 

📣 자바스크립트 동작 구조  

 

자바스크립트의 동작 원리를 보기 전에 먼저 전체적인 동작 구조를 살펴보도록 하겠습니다. 자바스크립트를 실행하기 위해서는 자바스크립트 엔진이 필요합니다. 자바스크립트는 엔진은 V8, Rhino, SpiderMonkey 등 다양하게 있지만 이 중에서도 가장 대표적인 예는 Google에서 만든 V8 엔진일 것입니다. 아래는 자바스크립트를 실행할 수 있게 해 주는 V8 엔진을 간략하게 표현한 그림입니다. 

출처: https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

 

엔진은 보시다시피 두 가지 구성요소로 구성됩니다. 하나는 Memory Heap이고, 다른 하나는 Call Stack이죠. Memory Heap(메모리 힙)은 메모리 할당이 발생하고, Call Stack(호출 스택)은 코드 실행에 따라 스택이 하나씩 쌓이는 곳입니다.

 

자바스크립트를 사용하다 보면 setTimeOut() 같은 수많은 API를 사용하고는 합니다. 이런 API들은 자바스크립트 엔진에서 제공해주는 요소일까요? 이러한 API들은 Web API라고 해서 웹 브라우저 혹은 node js 같은 자바스크립트 런타임에서 지원해주는 API입니다. 그래서 어떤 브라우저에서는 지원을 해줄 수도 있고, 안 해 줄 수도 있는 것이죠.

 

 

출처: https://developer.mozilla.org/ko/docs/Web/API

 

 

그런데 자바스크립트는 아까 위에서 나온 setTimeOut()과 같은 비동기 코드 작성이 가능함에도 불구하고, 자바스크립트 자체에는 비동기 코드를 처리하기 위한 개념을 갖고 있지 않았습니다. 그런데 어떻게 비동기 코드를 처리할 수 있었냐고요? 바로 Event loop & Callback Queue의 환상의 콜라보레이션으로 가능하게 된 것입니다.

 

위에서 설명한 Web API와 더불어 웹 브라우저나 런타임에서는 Event loop와 Callback Queue라는 것도 지원해줍니다. 

즉, 밑의 그림과 같은 동작 구조가 탄생할 수 있게 된 것입니다. (짜잔!)

 

 

출처:https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

 

 

 

🤔 자바스크립트는 무슨 스레드에요 그럼? 

 

그렇다면 자바스크립트는 멀티 스레드일까요 단일 스레드일까요? 제대로 알아보기 위해 좀 더 파보도록 하겠습니다. 아까 위에서 V8 엔진의 구조를 간단하게 봤을 때 하나의 Call Stack(호출 스택)하나의 Memory Heap(메모리 힙)으로 이루어진 것을 확인할 수 있었습니다. Call Stack은 기본적으로 자바스크립트를 한 줄씩 읽어가며 우리의 코드가 순서대로 돌 수 있도록 보장해주는 데이터 구조입니다. 스택을 사용하기 때문에 후입 선출(LIFO, Last-In-First-Out)의 구조를 갖습니다.

 

Call Stack에 쌓이는 하나의 사각형을 스택 프레임(Stack Frame)이라고 하는데, 만약 함수가 실행이 된다면은 스택의 맨 위에 있던 스택 프레임을 가리키는 중이며, 함수의 실행이 끝날 때 해당 스택 프레임을 Call Stack에서 제거하게 됩니다.  말로만 하면 이해가 안 되니까 대표적인 예를 가지고 어떻게 작동하는지 보겠습니다. 다음은 사각형의 넓이를 구하는 예제입니다.

 

 

function multiply(x, y) {
    return x * y;
}
function printSquare(x) {
    var s = multiply(x, x);
    console.log(s);
}
printSquare(5);

 

 

 

 

 

맨 처음 엔진이 자바스크립트를 실행할 때는 아무 함수도 만나지 않은 상태라서 비어있겠지만, 코드를 실행하며 가장 먼저 printSquare를 호출하기 때문에 printSquare를 Push 해서 스택에 쌓고 읽어가다 그다음에 만난 multiply를 Push 해서 스택에 쌓습니다. 더 이상 쌓을 함수가 존재하지 않는다면 맨 위의 스택 프레임부터 하나씩 처리하며 출력하게 됩니다.

출처: https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

 

 

 

 

 

만약 스택을 초과하게 된다면 어떻게 될까요? 무한 루프를 도는 코드를 돌리면 Maximum call stack size 에러가 발생하게 됩니다.

 

 

 

 

 

 

 

무한 루프가 될 때를 그림으로 표현하면 밑의 그림처럼 표현할 수 있습니다. Call Stack은 정해진 스택 사이즈가 존재하고, 하나씩 쌓이기 때문에 정해진 용량을 초과하게 되면 에러가 발생하게 됩니다. 흔히들 이것을 Stack Overflow라고 말합니다.

 

출처: https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

 

 

 

이처럼 자바스크립트는 하나의 Call Stack을 가지고 코드를 순차적으로 처리하기 때문에 한 번에 하나의 명령어만 실행될 수밖에 없습니다. 한 번에 여러 가지를 할 수 없고 하나씩만 처리할 수 있어요! 때문에 자바스크립트는 단일 스레드이며 동기식 언어라고 할 수 있습니다. 자바스크립트는 이러한 특성 때문에 무한 루프는 발생할 수 있어도 동기화 문제인 교착상태(DeadLock)는 발생할 수 없습니다. (으썸!)

 

 

 

 

 

 

🤔 자바스크립트는 어떻게 비동기 작업을 수행하나요? 

 

 

그런데 자바스크립트가 어디에 사용되죠? 웹에서도 사용하고 node js나 deno js 같은 각종 런타임에서도 자바스크립트를 사용해 여러 가지 작업을 할 수 있습니다. 만약 자바스크립트의 단일 스레드 + 동기식 특성을 가진 Call Stack만을 사용해서 웹 페이지를 만들었다면 지금처럼 네이버처럼 많은 기능을 제공하는 사이트가 나올 수 없었을 것입니다. 하나의 Call Stack에서 전부 처리하려고 하면, 어떤 작업의 경우에는 처리하는데 오랜 시간이 걸릴 수 있어서 웹 페이지가 멈추거나 제대로 동작하지 않을 것입니다. 우리 사용자들은 웹페이지가 2초만 늦어도 느리다고 생각한다고요. 그럼 안돼잖아요?

 

 

출처: https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

 

 

 

 

 

이럴 때 바로 자바스크립트에게 날개 같은 존재인 Event loop & Callback Queue가 필요합니다! 한번 코드와 그림을 통해서 알아보도록 하겠습니다. 밑의 코드는 과연 어떤 순서대로 실행될까요? 자바스크립트는 하나의 Call Stack만 사용하니까 콘솔에는 1, 3, 2로 출력되지 않을까요?

 

function foo() {
  console.log("1");
}

function foo2() {
  console.log("2");
}

foo();
setTimeout(function () {
  console.log("3");
}, 2000);
foo2();

 

 

 

 

 

틀렸습니다! 정답은 1, 2, 3이었습니다. 분명히 하나의 Call Stack을 사용하기 때문에 순서대로 스택에 들어가면 1, 3, 2가 맞을 텐데 왜 이런 결과가 나온 것일까요? 이 부분을 알기 위해서는 Event loop & Callback Queue의 동작 방식을 이해해야 합니다. 지금부터 한번 위의 코드를 토대로 자바스크립트의 전체적인 동작 방식을 한번 보도록 하겠습니다.

 

 

 

아쉽네요. 1, 2, 3이었습니다!

 

 

 

 

 

 

1.  제일 먼저 foo()라는 함수가 Call Stack에 쌓이게 됩니다. 

 

 

 

 

 

 

 

2.  그다음 foo() 함수 안에 있는 console.log()가 Call Stack에 쌓이게 됩니다.

 

 

 

 

 

 

 

3.  콘솔 창에 1을 출력합니다.

 

 

 

 

 

 

 

 

 

3.  foo() 함수는 종료되었으니 Call Stack에서 빠지게 되고, setTimeOut()이 Call Stack으로 들어옵니다.

 

 

 

 

 

 

 

4.  setTimeOut()을 보니까 비동기 함수네요. 그렇다면 이걸 Web API에서 처리하도록 보냅니다. 만약 node js나 deno js 같은 경우에는 백그라운드에서 처리하도록 보냅니다. 그리고 그다음 함수인 foo2() 함수를 불러옵니다.

 

 

 

 

 

 

 

5. foo2() 함수 안에 있는 console.log()가 Call Stack에 쌓이게 됩니다.

 

 

 

 

 

 

 

 

 

6.  콘솔 창에 2를 출력합니다.

 

 

 

 

 

 

 

 

7.  foo() 함수는 종료되었으니 Call Stack에서 빠지게 됩니다. 이제 들어올 함수가 없네요. 그런데 아직 비동기 함수를 처리하고 있었죠? setTimeOut()의 시간을 2초로 설정해 두었으니 2초간은 Wab API에서 처리하게 됩니다. 2초가 지난 후에는 setTimeOut()의 콜백 함수를 Callback Queue로 보내게 됩니다.

 

여기서 헷갈리시면 안 되는 게 Call Stack에 있던 foo2() 함수가 종료되고 나서야 Web API를 처리하는 게 아니고 setTimeOut()이 Web API로 넘어간 시점부터 처리되고 있는 것입니다. 즉, Call Stack에서는 foo2() 함수를 처리하고 있었고, Web API에서는 setTimeOut()을 2초 동안 돌리고 있었다는 것이죠. 

 

 

 

 

 

 

 

 

 

8. 이제 Event Loop가 등장합니다! Event LoopCallback Queue에 있는 콜백 함수를 Call Stack으로 보내서 처리하기 위해 Call Stack이 비어있는지를 검사합니다. 만약 Call Stack이 비어 있다면 Callback Queue에 있던 함수를 Call Stack으로 보내서 처리하게 됩니다.

 

 

 

 

 

 

 

 

9. Call Stack에 있던 console.log()를 콘솔에 출력하는 것으로 프로그램이 종료됩니다.

 

 

 

 

 

 

 

🤔 왜 Call Stack이 비어있어야 하는 것인가요? 

 

여기서 드는 궁금증! 그렇다면 왜 Event Loop는 Call Stack이 비어져있는지 확인하고 Callback Queue의 함수를 처리하는 것일까요? Call Stack이 비어져 있던 말던 상관없이 그냥 넣어버리면 훨씬 더 프로그램이 빨라지고 좋을 텐데 말이죠. 저도 이런 궁금증이 있어서 Stack Overflow 사이트를 뒤져보게 되었고, 마침 V8 엔진을 개발하셨던 분이 답변을 써주신 글을 찾을 수 있었습니다.

 

 

출처: https://stackoverflow.com/questions/52906975/call-stack-event-loop-why-waiting-for-empty-stack

 

 

예를 들어 Call Stack에 정상적으로 처리되고 있는 함수들이 있다고 가정을 해보겠습니다. 그런데 어떤 함수를 잘 처리하고 있던 와중에 갑자기 Event Loop에서 Callback Queue의 내용을 Call Stack으로 Push 해서 처리해야 한다고 합니다. 그래서 어쩔 수 없이 잘 처리하고 있던 함수를 중단하고 Event Loop가 보낸 함수를 처리해야 합니다. 그런데 또 갑자기 Event Loop에서 Callback Queue의 내용을 Call Stack으로 Push 해서 처리해야 한다고 합니다. 과연 이 경우 실행의 결괏값을 어떻게 될까요? 과연 내가 처리하려고 했던 함수는 내가 예상했던 시간에 끝나고 값을 제대로 도출할 수 있을까요? 

 

간단히 말하자면 이벤트 루프가 반드시 Call Stack이 비어져있는 상태에서만 Call Stack으로 Push 하는 이유는 자바스크립트라는 언어가 동기화 문제를 안는 것을 피하고 단일 스레드 언어라는 것을 보장해주기 위함입니다. 만약 단일 스레드 환경에서 위에서 예를 들었던 것 같은 상황이 발생한다면, 그것은 멀티 스레드에서 발생하는 문제 상황을 그대로 단일 스레드 언어가 안게 되어버립니다. 따라서 단일 스레드 언어에서 "해당 함수를 중단하지 말고 실행이 끝난 뒤에 Event Loop가 보내준 함수를 처리해야 해!"같은 동기화 문제를 해결할 수 있는 요소가 추가로 필요해지게 되는 것이죠.

 

결국에는 Event Loop가 Call Stack이 비어있는지를 확인하게 함으로써 프로그래머는 동기화 문제에 대해 골머리를 앓을 필요가 없어지는 것이랍니다.

 

 

 

 

📣 간단 정리 

 

결론은 자바스크립트는 기본적으로는 단일 스레드에 동기식 언어이지만, 웹 브라우저나 런타임들의 도움을 받아서 비동기 작업들을 처리할 수 있게 되었고, 마치 멀티 스레드인 것처럼 동작할 수 있는 짱짱 신기한 언어 라는 것입니다. 동작원리도 좀 헷갈렸었는데 저도 요번에 정리하면서 다시 하나씩 알아갈 수 있었고, 궁금했던 것도 해결할 수 있었습니다. 역시 사람은 정리를 해야 기억에 남나봐요!

 

 

 

 

 

📣 참고 링크