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

이 글은 원문을 바탕으로 쓴 글입니다.

이 글의 목적은 브라우저에서 자바스크립트가 어떻게 동작하는지 알려주는 것입니다.

자바스크립의 브라우저 동작 과정

각 토픽으로 들어가기 전에, 대략적인 개요를 보여주겠습니다. 이 개요엔 자바스크립트가 어떻게 브라우저와 동작하는 지 보여주고 있습니다.


JS 경계선 안의 힙과 콜스택 화살표가 Web APIs를 향해 있다. Web APIs 안에 DOM, AJAX, Timeout이 있다. 콜백 큐로 향하는 화살표가 있고 이 안에는 콜백 함수들(onClick, onDone, onLoad)존재, 이벤트 루프 화살표가 이 세 영역 사이를 돌고 있고 콜스택 큐에서 JS 경계선으로 화살표가 이어져있음.


콜 스택

자바스크립트가 싱글 스레드라는 말은 들어봤을 수 있습니다. 하지만 싱글 스레드의 진짜 의미는 무엇일까요?

자바스크립트에는 콜스택이 하나만 존재하기 때문에 한번에 하나씩 할 수 있습니다.

콜 스택은 자바스크립트가 함수의 호출지를 찾아갈 수 있도록 하는 매커니즘입니다.

스크립트나 함수가 함수를 호출하면, 이 함수가 콜 스택의 가장 위에 쌓입니다. 함수가 종료되면 인터프리터가 콜스택에서 이를 다시 제거합니다.

함수가 종료되는 경우는 return문을 만나거나 스코프의 끝에 다다랐을 경우입니다.

콜스택은 가장 상단에 쌓이기 때문에 LIFO(Last In, First Out)특성을 가집니다.

1
2
3
4
5
6
7
const addOne = (value) => value + 1;
const addTwo = (value) => addOne(value + 1);
const addThree = (value) => addTwo(value + 1);
const calculation = () => {
return addThree(1) + addTwo(2);
};
calculation();

만약 위와 같은 코드를 실행시킨다고 하면 다음과 같은 과정을 따릅니다.

  1. 파일이 로드되고 main함수가 실행됩니다. 이 main함수는 전체 파일을 실행시킨다는 것을 의미합니다. 이 함수가 콜 스택에 추가됩니다.
  2. maincalculation()을 호출하고 콜 스택에 쌓입니다.
  3. calculation()addThree()를 호출합니다. 그리고 이 함수는 콜스택에 쌓입니다.
  4. addThreeaddTwo를 호출합니다. 이 함수도 콜스택에 쌓입니다.
  5. addOne도 위와 동일한 과정을 거칩니다.
  6. addOne이 다른 함수를 호출하지 않기 때문에 함수가 종료되면 콜 스택에서 제거됩니다.
  7. addOne이 종료됨에 따라 addTwo도 종료되고 콜스택에서 사라집니다.
  8. 마찬가지로 addThree도 종료됩니다.
  9. calculation에서 addTwo를 부르게 되고 콜스택에 추가됩니다.
  10. addTwoaddOne을 호출하고 콜 스택에 쌓입니다.
  11. addOne이 콜 스택에서 제거됩니다.
  12. addTwo가 콜 스택에서 제거됩니다.
  13. addThreeaddTwo가 모두 평가되었으므로 calculation은 결과값을 계산할 수 있다. calculation이 콜 스택에서 제거됩니다.
  14. 더 이상 존재하는 코드가 없기 때문에 main도 콜 스택에서 제거됩니다.

이 글에서 main이라 작성하긴 했지만 공식적으로 이에 대한 이름은 존재하지 않습니다. 브라우저 콘솔에서는 이 함수를 anonymous라 부릅니다.

Uncaught RangeError: Maximum call stack size exceeded


Uncaught RangeError: Maximum call stack size, exceeded at b (<anonymous>:6:5) exceeded at a (<anonymous>:2:5)


이 에러 메시지에 따르면, 서로 순환 호출을 하기 때문에 콜스택의 적재 횟수를 초과하여서 생긴 에러입니다. 최대 콜 스택의 범위는 최소 만 부터 5만까지이다. 따라서 이 에러가 발생한 경우에는 코드가 무한 루프에 빠지고 있습니다.

요약하자면 콜 스택은 함수의 호출지를 추적할 수 있도록 하고 LIFO(Last In, First Out) 특성을 가지고 있습니다. 다시 말해서 스택의 가장 상단에 있는 부분이 먼저 실행됩니다.

Heap

자바스크립트의 힙은 함수나 변수를 정의할 때 객체가 정의되는 곳입니다. 이 주제는 콜 스택이나 이벤트 루프와 관련은 없지만, 자바스크립트의 메모리 할당과 관련하여 알고 싶은 사람은 이 링크를 참고하길 바랍니다.

Web APIs

자바스크립트는 싱글 스레드 기반이지만 브라우저와 동시에 실행될 수 있습니다. 이유는 브라우저가 제공하는 Web API가 있기 때문입니다.

예를 들어, 자바스크립트 인터프리터를 통해 코드를 실행시키기 위해서는 서버에서 이에 대한 응답을 받아야만 합니다. 이 때문에 웹 애플리케이션의 사용이 불가능할 수 있습니다.

이에 대한 해결책으로 웹 브라우저가 자바사크립트 코드에서 실행시킬 수 있는 API를 제공합니다. 이를 실행하는 건 브라우저가 실행합니다. 이 때문에 콜스택을 막지 않습니다.

web APIs의 또다른 장점으로는 C와 같은 낮은 레벨의 코드로 쓰여진 점입니다. 따라서 자바스크립트만으로 하기 어려운 일들을 해줄 수 있습니다.

이들은 AJAX 요청이나 DOM관리를 할 수 있습니다. 이뿐만 아니라, geo-tracking, 로컬 스토리지 접근 등이 가능합니다.

콜백 큐

web APIs의 특성 덕분에 자바스크립트 인터프리터 밖에서도 무언가를 할 수 있습니다. 하지만 예를 들어 Web API나 AJAX 요청에 대한 응답에 대해 처리를 자바스크립트로 하고 싶다면 어떻게 해야할까요?

이 때문에 콜백이 등장합니다. 이들을 활용해서, web API가 API 실행 뒤에 코드를 실행시킬 수 있습니다.

콜백이 무엇인가요? 콜백이란 다른 함수에 전해지는 함수를 말합니다. 콜백은 보통 어떤 코드가 실행된 이후에 실행됩니다. 함수를 매개변수로 해서 쉽게 콜백 함수를 만들 수 있습니다. 이들은 고차함수라고 불리기도 합니다. 콜백은 기본적으로 비동기가 아닌걸 기억하세요!

예제를 살펴봅시다.

1
2
3
4
5
6
7
const a = () => console.log('a');
const b = () => setTimeout(() => console.log('b'), 100);
const c = () => console.log('c');

a();
b();
c();

이미 나올 값을 알고 있는 사람도 있을 겁니다.

setTimeout이 실행될 동안 JS 인터프리터도 다음 구문을 해석해 나갑니다. 만약 setTimeout에서 설정된 시간이 지나고 콜 스택이 전부 비게 되면 setTimeout의 인자로 넘긴 콜백 함수가 실행됩니다.

따라서 결과는 a c b가 됩니다.

setTimeout가 종료되더라도 콜백 함수는 바로 실행되지 않습니다. 이 이유는 자바스크립트가 한번에 하나의 일만 하기 때문입니다.

자바스크립트로 작성된 콜백함수는 setTimeout의 인수로 전해집니다. 그러므로 자바스크립트 인터프리터가 이를 해석(=콜스택에 추가)해야 합니다. 이를 다시 말하면, 콜백을 실행하기 위해서는 콜 스택이 전부 빌 때까지 기다려야 합니다.

setTimeout이 web APIs를 부릅니다. API는 콜백을 콜백 큐로 이동시켜줍니다. 그리고 이벤트 루프가 콜 스택이 빌 때 콜백 큐에서 콜 스택으로 콜백 함수를 추가합니다.

콜 스택과 달리 콜백 큐는 FIFO(First In, First Out)를 따릅니다. 이는 큐에 쌓인 순서대로 실행되는 것을 의미합니다.

이벤트 루프

자바스크립트 이벤트 루프는 콜 스택이 빌 때, 콜백 큐에 있는 가장 첫번째 일을 콜 스택에 추가합니다.

다르게 말하자면 콜 스택이 비지 않는 한, 콜백 큐에 있는 어떠한 일도 실행되지 않습니다.

따라서 너무 많은 코드를 실행시키거나 콜백 큐를 가로막으면, 새로운 자바스크립트 코드가 실행되지 못하기 때문에 웹 사이트가 반응하지 않을 수 있습니다.

onscroll과 같은 이벤트 헨들러들은 이벤트가 실행될 때, 콜백 큐에 일들을 추가합니다. 이 때문에 이러한 콜백들엔 디바운스가 필요합니다. 디바운스란 매 x밀리초마다 실행되도록 하는 것입니다.

실제로 해보세요 이 코드를 브라우저 콘솔에 입력해보세요. 스크롤시 얼마나 많은 콜백이 나오는지 관찰해보세요.

1
window.onscroll = () => console.log('scroll');

setTimeout(fn, 0)

위처럼 코드를 작성하는 것의 이점은 메인 스레드를 오랫동안 막아두지 않으면서 어떠한 일을 할 수 있는 것입니다.

비동기 코드를 콜백에 넣고 setTimeout을 0ms로 지정하면 브라우저가 콜백 실행을 하기 전에 DOM업데이트와 같은 작업을 수행할 수 있습니다.

잡(job) 큐와 비동기 코드

콜백 큐에 더불어 프로미스들을 독립적으로 관리하는 큐가 있는데, 그것이 바로 잡 큐입니다.

프로미스

프로미스는 EcmaScript 2015 (or ES6)에서 처음 소개되었습니다. 바벨을 통해 사양을 낮출 수도 있습니다.

프로미스는 콜백 대신 비동기 코드를 관리할 수 있는 방법입니다. 프로미스를 통해 비동기 함수를 체이닝 함으로써 콜백헬이나 운명의 피라미드에 빠지는 것을 방지할 수 있습니다.

1
2
3
4
5
6
7
8
9
setTimeout(() => {
console.log('Print this and wait');
setTimeout(() => {
console.log('Do something else and wait');
setTimeout(() => {
// ...
}, 100);
}, 100);
}, 100)

이런식으로 코드를 작성하게 되면 옆구리가 점점 더 들어가버릴거에요😞

프로미스를 활용하면 가독성을 더 높일 수 있습니다.

1
2
3
4
5
6
7
8
9
10
// A promise wrapper for setTimeout
const timeout = (time) => new Promise(resolve => setTimeout(resolve, time));
timeout(1000)
.then(() => {
console.log('Hi after 1 second');
return timeout(1000);
})
.then(() => {
console.log('Hi after 2 seconds');
});

여기서 async/await를 활용하면 더 가독성이 좋아집니다.

1
2
3
4
5
6
7
8
const logDelayedMessages = async () => {
await timeout(1000);
console.log('Hi after 1 second');
await timeout(1000);
console.log('Hi after 2 seconds');
};

logDelayedMessages();

여기서는 프로미스의 대략적인 부분만 다뤘기 때문에 더 세부적인 내용이 궁금하신 분은 MDN을 참고하세요

프로미스의 올바른 사용법

프로미스는 콜백과 다르게 그들만의 큐를 가지고 있습니다. 잡 큐는 프로미스 큐라고도 알려져있습니다. 그리고 이 큐는 콜백 큐보다 더 높은 우선 순위를 가지고 있습니다. 따라서 콜백 큐보다 우선적으로 콜 스택에 쌓습니다.

예시를 보겠습니다.

1
2
3
4
5
6
7
8
9
console.log('a');
setTimeout(() => console.log('b'), 0);
new Promise((resolve, reject) => {
resolve();
})
.then(() => {
console.log('c');
});
console.log('d');

이 예제를 보면 프로미스 큐가 콜백 큐보다 더 높은 우선순위를 가지기 때문에 출력은 a d c b로 출력됩니다.