런타임을 직관적인 말로 바꾸어 표현하면, "실행기" 라고 할 수 있습니다.
우리가 일상에서 사용하는 크롬 등의 브라우저는 대표적인 자바스크립트 런타임입니다.
즉, 브라우저는 자바스크립트의 실행기인 셈입니다.
Node.js역시 자바스크립트 런타임입니다.
브라우저와는 또 다른 자바스크립트 실행기인 것이죠.
가끔, Node.js를 서버 프레임워크라고 말하는 사람들이 있습니다.
하지만 노드js는 라이브러리도, 프레임워크도 아닙니다.
정확히 표현하면, Node.js는 그저 자바스크립트를 실행해주는 실행기일 뿐이고,
브라우저 런타임에선 불가능하던 서버 단의 코드도 실행해줄 수 있는 실행기인 셈입니다.
노드JS는 브라우저와 완전히 다른 별개의 실행기라는 사실을 미리 정리해두고 넘어가겠습니다.
자바스크립트는 싱글 스레드 언어입니다.
이는 곧 오직 하나의 호출 스택만을 갖는 언어라는 의미기도 합니다.
조금 간단하게 얘기해보면, 자바스크립트는 언어 본질상으론 한 번에 하나의 작업만을 수행할 수 있습니다.
그런데, 이러한 자바스크립트의 특성은 실제 서비스에서 엄청난 문제를 발생시킬 수 있습니다.
간단한 예를 들어볼까요?
//예제 코드 제공 - chat gpt
import React, { useEffect } from 'react';
function App() {
const blockingFetch = () => {
// 아주 긴 동기적 작업 시뮬레이션
const start = Date.now();
while (Date.now() - start < 5000) {
// 5초 동안 CPU를 점유
}
// 실제 fetch 호출
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => response.json())
.then(data => {
console.log(data);
alert('Fetch finished!');
});
};
useEffect(() => {
// 컴포넌트가 마운트될 때 블로킹 fetch 호출
blockingFetch();
}, []);
const handleClick = () => {
alert('Button clicked!');
};
return (
<div>
<h1>JavaScript Single Thread Example</h1>
<button onClick={handleClick}>Click Me!</button>
</div>
);
}
export default App;
위의 코드에서 while문을 의도적으로 사용하여 자바스크립트의 비동기 처리를 막아보았습니다. 대표적인 비동기 API인 fetch도, while문 안에선 동기적으로 동작합니다.
다시 코드로 돌아와, 데이터를 불러오는 시간이 아주 길게 걸린다고 가정해보겠습니다. 또한, 현재 while문의 존재로 인해 코드는 동기적으로 동작합니다.
문제는 버튼에서 발생합니다.
가령 데이터를 불러오는데 시간이 4초 걸린다고 가정하면,
이론상 자바스크립트는 싱글 스레드 언어이기에 데이터를 불러오기 시작한 시점부터 4초동안 다른 작업을 수행할 수 없습니다.
그렇다면, 4초동안 유저는 버튼을 클릭하는 등의 그 어떤 동작도 하지 못하며 그저 가만히 기다려야 하죠.
이런 말도 안 되는 상황이 발생한다면, 성질 급한 한국인들은 물론 전 세계의 서비스 유저들이 이탈할 것입니다.
이상적으론, 데이터를 불러오는 동안에도 버튼을 누르거나 검색을 하는 등, 사용자는 다른 작업들을 문제 없이 수행할 수 있어야 합니다.
즉, 여러 동작을 동시에 처리할 수 있어야 합니다.
싱글 스레드 언어인 자바스크립트로 어떻게 동시 처리 기능을 구현할 수 있을까요?
정답은 자바스크립트의 런타임인 브라우저와 노드 JS 각각이 가진 추가적인 기능을 활용하는 것입니다.
결론적으로, 시간이 오래 걸리는 fetch, axios등의 AJAX작업, 이벤트 핸들링 작업, 타이머, Promise 등의 코드들은 일반적인 코드가 수행되는 호출스택과 다른 장소에서 처리됩니다.
조금 더 자세히, 브라우저와 노드JS각각이 어떻게 싱글 스레드 언어인 JS의 한계를 극복하여 동시성 처리를 성공적으로 해낼 수 있는 것인지, 내부 동작 원리를 살펴보겠습니다.
JS의 런타임 중, 먼저 브라우저를 살펴보겠습니다.
브라우저 환경에서 자바스크립트를 실행할 경우 시스템의 구조는 아래와 같습니다.
주요 구성 요소로는, Heap
, Call Stack
, Web APIs
, Task Queue
, Event Loop
가 있습니다.
이중 Web APIs
는 자바스크립트의 내장 기능이 아닌, 브라우저가 제공하는 기능입니다.
각각의 기능에 대해 자세히 살펴본 후, 위의 요소들이 어떻게 유기적으로 동작하는지 최종 정리하겠습니다.
챕터 3-1-3
에서 완벽하게 깔끔정리 하였으니,
우선은 많은 내용이 마구 쏟아져도, 포기하지 말고 각각을 이해하려 노력해주세요.
힙 메모리에는 함수나 변수와 관련된 정보들이 저장됩니다.
function foo() {
console.log("hi");
}
위와 같이 foo라는 함수가 정의되면,
힙 메모리에 foo함수에 관한 내용들이 저장됩니다.
호출 스택은, 실행해야 하는 코드가 쌓이는 공간입니다.
foo();
위와 같이 함수를 호출하는 코드를 만나면,
호출 스택에 foo()
가 들어옵니다.
참고로, 특정 파일이 실행되면 호출스택엔 가장 먼저
anonymous(main함수)
가 들어오고, 파일의 모든 코드가 종료되면 호출 스택에서 빠져나갑니다.
자바스크립트 엔진은 호출 스택에 있는 코드를 하나씩 꺼내어 수행합니다.
이때, 호출 스택에 있는 코드를 수행하기 위해 필요한 함수나 변수 등의 정보는
힙 메모리에서 찾아 참조합니다.
자바스크립트 언어 자체는 싱글 스레드이지만, 자바스크립트 엔진이 브라우저의 Web APIs로 특정 코드를 보내 별도의 스레드를 사용하기에, 동시 작업 처리가 가능합니다.
여기서 Web APIs는, 자바스크립트 엔진인 V8자체의 기능이 아닌, 브라우저가 제공하는 기능입니다.
Web APIs는 C++로 작성되어 있기에, 멀티스레딩이 가능합니다.
호출스택에서 Web APIs로 보내지는 코드들은 다음과 같습니다.
- 타이머 API: setTimeout, setInterval, clearTimeout, clearInterval
- 네트워크 API: fetch, XMLHttpRequest
- DOM API: addEventListener, querySelector등등
- DB요청
- Promise의 then, catch, finally절
이 외에도 많은 것들이 Web APIs로 보내져 V8엔진의 호출 스택과 병렬로 별도의 공간에서 처리됩니다.
이해를 돕기 위해 간단한 예제 코드를 보겠습니다.
창의력이 부족한 저는 이번에도 예제 코드를 지피티 스승님께 받아오겠습니다.
지피티 선생님께서 주신 코드를 살펴보며, 직접 브라우저와 엔진이 되어 처리 순서를 익혀보겠습니다.
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
}, 0);
console.log('End');
자, 우선 가장 먼저 아래의 코드가 실행되어야 합니다.
console.log('Start');
이는 백그라운드로 넘어가지 않는 일반 코드이므로,
V8엔진의 호출 스택에서 바로 수행됩니다.
파일이 실행되었으니 main함수인 anonymous
가 호출스택에 가장 먼저 쌓이고,
그 위에 console.log('Start');
가 들어옵니다.
이후 콘솔창에 "start"
라는 문자열이 찍히고,
해당 코드는 모두 실행되었으니 호출 스택에서 console.log('Start');
는 사라집니다.
해당 코드가 수행된 이후 각각의 공간은 아래와 같은 상태가 됩니다.
이제, 아래의 코드를 실행할 차례입니다.
setTimeout(() => {
console.log('Timeout 1');
}, 0);
이번엔 타이머와 관련된 코드가 들어왔습니다.
우선 호출 스택에 setTimeout()
이 잠깐 들어옵니다.
그랬더니, V8엔진이 말합니다
"타이머 고객님께서는 옆동네 Web APIs를 이용해주세요."
이를 현학적으로 "자바스크립트 엔진이 작업을 브라우저에 위임했다" 고 표현하기도 합니다.
어쨌든 타이머는 WebAPIs
로 이동합니다.
타이머의 시간이 100초든 0초든 타이머 코드는 V8엔진에서 무조건 Web APIs
로 보내버립니다.
백그라운드에 머무는 동안, 타이머에 지정된 시간을 흘려보냅니다.
호출 스택엔 console.log("End");
가 들어오고, 바로 실행됩니다.
다만, console.log("End");
가 0초짜리 타이머가 Web APIs에 있는 시점에 실행될지, 태스크 큐로 이동한 후에 실행될지 여부는 브라우저의 종류와 환경(브라우저 버전, 사양 등)에 따라 다를 수 있습니다.
확실한 것은, 타이머의 시간이 0초더라도 Web APIs로 한 번 이동한 코드들은 호출 스택이 모두 비워진 후에야 실행될 수 있기에, console.log("End");
는 console.log("Timeout1");
보다 무조건 먼저 실행된다는 것입니다.
위의 코드에서는 백그라운드에서 0초의 시간을 대기한 후, 타이머의 콜백함수가 Web APIs에서 태스크 큐로 이동합니다.
브라우저의 Web APIs가 열일하는 동안, V8엔진도 열심히 호출 스택의 코드들을 실행시킵니다. 호출 스택의 console.log("End");
도 실행되어 호출 스택에서 제거됩니다.
위의 사진에선 console.log("End");
를 호출 스택에 남겨두었는데, 위에서 언급하였듯 console.log("End");
가 0초짜리 타이머가 Web APIs에 있는 시점에 실행될지, 태스크 큐로 이동한 후에 실행될지 여부는 브라우저의 종류와 환경(브라우저 버전, 사양 등)에 따라 다를 수 있는데, 위의 경우엔 후자의 상황을 가정한 것이라고 생각해주시면 됩니다.
아무튼 현재 논의의 핵심은, Web APIs에서 타이머에 지정된 시간이 모두 흐르면, 타이머의 콜백함수가 태스크 큐로 이동한다는 사실입니다.
사실 타이머의 시간을 0초로 지정한 예시는, 이 내용을 처음 접하는 사람들에게 꽤나 어렵게 다가갑니다.
조금 더 쉬운 예시로서 만일 타이머에 지정된 시간이 5초였다면, Web APIs에서 5초를 대기한 후 타이머의 콜백이 태스크 큐로 전달됩니다.
지금까지의 논의를 통해 정리할 수 있는 사실은, 태스크 큐는 백그라운드(Web APIs)의 콜백함수가 보관되는 공간 이라는 것입니다.
타이머의 시간이 모두 흐른 뒤 각각의 공간의 상태는 아래와 같습니다.
타이머의 콜백인 console.log("Timeout1");
이 백그라운드에서 태스크 큐로 이동하였습니다.
브라우저가 열심히 작업하는 동안, V8엔진도 열심히 일을 합니다. 타이머를 애초에 Web APIs로 위임해버렸으니, 백그라운드에서 타이머 코드가 돌아가는 동안 V8엔진은 console.log("End");
를 수행한 것입니다.
콘솔창에 End를 찍고, 호출 스택에서 console.log("End");
가 사라집니다.
이벤트 루프는 호출스택을 계속 주시하다가, anonymous
를 제외하고는 호출 스택이 비어있는지 확인합니다.
anonymous
외에 아무런 요소도 호출 스택에 남아있지 않다면, 이벤트 루프는 태스크 큐의 요소들을 V8의 호출 스택으로 올려보냅니다.
console.log("End");
를 수행한 이후엔, 호출 스택이 비어있으니 이를 이벤트 루프가 감지하여 태스크 큐의 console.log("Timeout1");
을 V8의 호출스택으로 보내줍니다.
현 시점에서의 각 공간의 상태는 아래와 같습니다.
이후 console.log("Timeout1");
이 실행되어 콘솔창에 "Timeout1"
이 출력되고, 해당 파일의 모든 코드가 실행되었으니 anonymous
도 사라집니다.
결론적으로
console.log('Start');
setTimeout(() => {
console.log('Timeout 1');
}, 0);
console.log('End');
이 코드의 실행 결과는
Start, End, Timeout1
순으로 출력됩니다.
타이머가 0초임에도, Web APIs로 한 번 보내진 코드들은
호출스택이 비어야 이벤트루프가 호출스택으로 보내주기 때문에
End보다 늦게 출력된다는 사실을 기억해야 합니다.
일반적인 코드들은 호출 스택에서 처리된다
타이머, AJAX, 이벤트, 프로미스 등의 코드가 호출스택에 들어오면 V8엔진은 이를 직접 처리하지 않고 브라우저의 Web APIs로 보내버린다(위임한다). 이는 자바스크립트 엔진 바깥의 공간이기에, 자바스크립트가 싱글스레드 언어임에도 동시 처리가 가능한 비결이다
Web APIs에서 지정된 타이머 시간이 전부 흐르거나, AJAX요청시 데이터를 다 받았거나, 이벤트가 실제로 발생한 경우, 콜백함수가 태스크 큐로 전달된다
이벤트 루프는 호출스택이 비었는지 주시하다가, 호출스택이 빈 경우 태스크 큐의 코드들을 내부적 우선순위에 맞추어 호출스택으로 올려보낸다.
위의 3-1-3챕터의 4번 항목에서, 이벤트 루프는 호출스택이 비었는지 주시하다가, 호출스택이 빈 경우 태스크 큐의 코드들을 내부적 우선순위에 맞추어 호출스택으로 올려보낸다고 하였습니다.
그런데, 가만 생각해보면 태스크 큐는 FIFO자료구조인 큐(QUEUE)
인데,
먼저 들어온 것이 최상의 우선순위를 갖는 것이 아니라면, 뭔가 이상합니다.
사실, Web APIs의 콜백은, 하나의 태스크 큐로 전달되는 것이 아니라,
(macro)태스크 큐
, 마이크로 태스크 큐
, 애니메이션 프레임 큐
이렇게 3개의 큐로 전달됩니다.
이들 큐들 사이에 우선순위가 존재하고, 각 큐에선 당연히 FIFO방식으로 동작합니다.
이들 각 큐에 대해 정리해보겠습니다.
아래와 같이 세 개의 큐는 고유한 우선 순위를 부여받습니다.
Promise의 핸들러인 then, catch, finally와, DOM요소의 변화를 감지하는 MutationObserver가 호출스택이 비워진 후 최고의 우선순위를 갖습니다.
이후, 애니메이션 프레임 큐가 호출 스택으로 올라갈 기회를 얻습니다.
마지막으로 타이머나 AJAX, 클릭 이벤트의 콜백함수 등이 담길 (Macro)태스크 큐가 이벤트 루프의 조사를 받습니다.
깔끔하게 정리하면,
브라우저 상에서 자바스크립트를 실행할 경우 코드의 실행 순서는 아래와 같습니다.
- 호출 스택
- 마이크로 태스크 큐: Promise 핸들러, MutationObserver
- 애니메이션프레임 큐
- (매크로)태스크 큐: AJAX(fetch등..), 이벤트 발생처리, 타이머
그렇다면, 이를 통해 얻을 수 있는 교훈이 하나 있습니다.
바로, 브라우저 상에선 타이머가 우리가 지정한 시간보다 더 오랜 시간을 기다려야 할 수도 있다는 것입니다.
타이머 코드가 실행되기 위해선 최종적으로 타이머의 콜백이 호출스택으로 올라가야 하는데,
타이머의 콜백이 있는 매크로 태스크 큐보다 높은 우선순위를 갖는 큐들에서 오랜 시간이 소요된다면, 타이머에 지정한 시간 후에 콜백이 동작하지 않을 수 있겠습니다.
이 글의 1장에서 굳이 브라우저와 노드js에 관한 설명을 했는데,
바로 지금 활용하기 위함입니다.
노드JS는 브라우저와 독립된, 하나의 실행기(런타임)이라고 하였습니다
즉, 노드 JS로 자바스크립트를 구동시킨다면, 브라우저의 기능인 Web APIs가 존재하지 않습니다.
자바스크립트 자체는 싱글 스레드 언어인데, 노드JS는 논블로킹I/O를 어떻게 구현시킬 수 있는 것일까요?
정답은 libuv
라이브러리입니다.
브라우저가 아니기에 Web APIs는 없지만, 멀티스레딩을 지원하는 C언어로 작성된 libuv라이브러리가 노드js환경에서 Web APIs의 역할을 대체합니다.
노드JS자체적으로 멀티스레딩의 구현이 불가능하냐고 묻는다면, 그렇진 않습니다.
사실 노드JS 런타임 자체는 워커스레드의 존재로 멀티스레딩이 가능합니다.
다만, 코드가 복잡해지기에 워커스레드는 잘 사용하지 않습니다.
아무쪼록, 멀티스레딩은 노드 자체의 기능을 활용하지 않고, C언어로 구현된 별도의 외부 라이브러이에 위임하는 구조라는 것을 기억해야 합니다.
브라우저와의 또 다른 차이점이라면, Node.js는 서버측 프로그래밍을 위해 개발되었기에, 브라우저 환경과 달리 자바스크립트 엔진이 애니메이션프레임큐를 사용하지 않습니다.
노드JS의 내부 구조를 다이어그램으로 시각화하면 아래와 같습니다.
자바스크립트는 싱글 스레드 언어지만,
브라우저 런타임에선 Web APIs,
노드 JS 런타임에선 libuv의 도움을 얻어
자바스크립트 V8엔진 외부의 공간에서도 일부 코드를 수행하며
동시성 처리를 구현할 수 있습니다.
감사합니다.