코드는 무조건 순서대로 실행되어야....지!....?
일반적으로 자바스크립트에서는
작성된 코드를 위에서부터 아래방향으로, 순서대로 실행합니다.
자바스크립트가 오로지 순차적으로만 실행된다면 어떤 일이 발생할 지 알아봅시다.
최초 코드 상태
전역 코드 (main()) 실행
"task start" 출력 코드 실행
외부 데이터 요청 코드 실행
N시간 동안 아무런 응답이 없다면..
위 예시 처럼 모든 코드가 위에서 아래로 순차적으로만 동작한다면,
그 중 하나의 작업이 너무 오래 걸리거나 평생 응답이 오지 않는다면,
자바스크립트는 다음 코드를 실행하지 못하고 영원히 멈춰 있게 될 겁니다.
이러한 이유로 자바스크립트에서는 시간이 오래 걸리거나, 바로 응답을 받을 수 없는 요청에 대해서
비동기 작업으로 처리할 수 있도록 여러 문법을 제공하고 있습니다.
우선 비동기가 무엇인지 알아봅시다.
동기, 비동기란?
동기 (Synchronous)
- 직렬적으로 태스크를 수행하는 방식
- 요청을 보낸 후 응답을 받아야만 다음 동작이 이루어지는 방식
- 태스크를 처리하는 동안 나머지 태스크는 대기합니다.
console.log('task 1');
console.log('task 2');
console.log('task 3');
/*
task 1
task 2
task 3
*/
비동기 (Asynchronous)
- 병렬적으로 태스크를 수행하는 방식
- 요청을 보낸 후, 응답의 수락 여부와는 관계없이 다음 태스크가 동작하는 방식
- 비동기 요청 시, 응답 후 처리할 '콜백 함수'를 함께 알려주어, 태스크가 완료되었을 때 '콜백 함수'가 호출되어 그 다음 태스크를 처리 할 수 있습니다.
console.log('task 1');
setTimeout(() => console.log('task 2'), 0);
console.log('task 3');
/*
task 1
task 3
task 2
*/
비동기는 어떤 작업이 실행하거나 요청을 보내고 나서 그 응답을 기다리지 않고
그 다음 작업을 바로 실행하는 것이라고 할 수 있습니다.
자바스크립트에서는 어떤 문법으로 이 비동기 작업을 실행할 수 있는지 알아봅시다.
자바스크립트에서 비동기 처리를 위해 제공하는 문법
콜백 함수
콜백 함수는 어떤 함수의 매개 변수로써 사용되는 함수입니다.
인자로써 함수를 받은 함수는 그 함수를 원하는 시점에서 실행 시킬 수 있어
어떤 작업 내에서 순서를 가져야 한다면 유용하게 활용할 수 있습니다.
아래 예시는 비동기 함수 중 하나인 setTimeout과 콜백 함수를 활용한 예시입니다.
setTimeout(() => {
console.log('1');
setTimeout(() => {
console.log('2');
setTimeout(() => {
console.log('3')
}, 3000);
}, 2000);
}, 1000);
/*
1
2
3
*/
Promise
프로미스는 프로미스 객체를 생성할 때, 매개 변수로 넣게 되는 콜백함수에서
파라미터로 넘어오는 resolve, reject 함수의 매개변수로 값을 반환함으로써 비동기 실행을 위한 코드를 작성할 수 있습니다.
then, catch, finally 메서드의 매개변수로 콜백함수를 넣음으로써 그 다음 실행할 작업을 구현할 수 있습니다.
(then, catch, finally 메서드의 콜백함수에서 반환하는 값도 프로미스 객체입니다.)
const delay = function (delay) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`resolve ${delay}`);
resolve();
}, delay);
});
};
delay(1000)
.then(() => delay(2000))
.then(() => delay(3000));
/*
resolve 1000
resolve 2000
resolve 3000
*/
async/await
async를 선언한 함수 내에서만 await 키워드를 함께 사용하여 비동기 함수의 실행이 완료되도록 기다리게 할 수 있습니다.
async 키워드와 함께 사용한 함수는 항상 Promise 객체를 반환합니다.
기존의 콜백함수, Promise와 다르게 동기적으로 코드를 작성할 수 있습니다.
(동기적으로 작성할 수 있으므로 가독성이 좋아지고 사용성도 좋습니다.)
const delay = function (delay) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`resolve ${delay}`);
resolve();
}, delay);
});
};
(async function () {
await delay(1000);
await delay(2000);
await delay(3000);
})();
/*
resolve 1000
resolve 2000
resolve 3000
*/
자바스크립트의 비동기는 누가, 어떻게 제어할까?
자바스크립트를 제어하는 주체
'자바스크립트는 누가 제어할까?' 라는 질문의 답을 찾기 위해서는
우선 스레드라는 개념을 알아야 합니다.
스레드
- 한 프로세스 내에서 동작되는 여러 실행 흐름으로 프로세스 내의 Heap, Data, Code 영역을 공유합니다.
- 하나의 프로세스 안에서 다양한 작업을 담당하는 최소 실행 단위를 스레드라고 합니다.
- ex) 크롬 브라우저(=프로세스)안에서 유튜브 보기(=스레드1) 및 블로그 포스트 작성하기 (스레드2)
- 각각의 스레드는 독립적인 작업을 수행해야 하기 때문에, 고유한 스레드 ID, 프로그램 커운터, 레지스터 집합, 스택을 가지고 있습니다.
→ 스레드에 더 자세히 알고 싶다면, 이 블로그를 참고해봅시다! (링크)
이러한 스레드는 싱글스레드와 멀티스레드로 나눌 수 있습니다.
그 이름에서 유추할 수 있듯이 하나의 프로그램(=프로세스)에서
하나의 스레드만 사용한는지, 여러 개의 스레드를 동시에 사용하는지에 따릅니다.
싱글 스레드와 멀티스레드는 각각의 장단점이 있지만,
사용자에게 있어서 웹 브라우저, 화면이라는 자원에 여러 스레드가 동시에 접근하게 된다면
문제가 발생할 수 있기 때문에 자바스크립트는 싱글스레드로 동작합니다.
브라우저는 멀티 스레드 방식으로 동작하여
하나의 브라우저(=프로세스)에서 다양한 작업(=스레드)을 실행할 수 있습니다.
그 중 하나의 스레드로 자바스크립트 엔진을 동작시함으로써
브라우저 내부에서 자바스크립트 코드를 해석 및 실행 할 수 있게 됩니다.
결국 웹 환경에서 자바스크립트를 제어하는 주체는
브라우저라는 프로세스 안에서 사용되는 많은 스레드 중 하나,
싱글스레드 방식으로 자바스크립트 엔진을 실행하여 자바스크립트 코드를 동작시킨다고 할 수 있겠습니다.
자바스크립트는 어떻게 비동기를 제어할까?
그렇다면 의문점이 생깁니다.
비동기 코드가 포함된 자바스크립트 코드가 실행되려면
동기적인 코드를 순차적으로 실행시키는 스레드와
그것과 별개로 비동기적인 코드를 실행시킬 스레드,
최소 두 개 이상의 스레드를 사용해야 하는게 아닌가 하는 점입니다.
바로 여기서 자바스크립트가 비동기 코드를 어떻게 제어하는지 알 수 있습니다.
앞서 살펴봤듯이 브라우저에서 자바스크립트 엔진이라는 하나의 스레드 안에서
자바스크립트를 해석하며 코드를 실행합니다.
이 자바스크립트 엔진은 내부의 이벤트 루프라는 것을 통해서
멀티 스레드처럼 동작할 수 있도록 하고 있습니다.
먼저 이벤트 루프를 통해 비동기가 동작하기 위한 구조에 대해서 살펴봅시다.
위 이미지를 보면 아래와 같은 요소들이 있습니다.
- 콜 스택 (Call Stack)
- 이벤트 루프 (Event Loop)
- Web API
- 마이크로 태스크 큐 (Micro Task Queue)
- Animation Frame 큐
- 매크로 태스크 큐 (Macro Task Queue)
이 많은 요소들이 어떻게 동작하는지, 또 왜 필요한지 한번 알아보겠습니다.
우선 중요하게 짚고 넘어가야 할 부분부터 먼저 확인하고 가겠습니다.
이벤트 루프는 항상 콜 스택이 비어있는지 확인을 하고 나서야
각각의 태스크 큐(마이크로 태스크 큐, Animation Frame 큐, 매크로 태스크 큐)의 태스크를 콜 스택에 푸시합니다.
(여기서 태스크는 비동기 함수의 인자로 넣은 콜백 함수입니다.)
어떤 얘기인지 예시 코드를 실행하는 과정을 그림으로 보면서 이해해봅시다.
console.log("task start");
setTimeout(() => console.log("setTimeout task1"), 0);
Promise.resolve()
.then(() => console.log("Promise task 1"))
.then(() => console.log("Promise task 2"));
requestAnimationFrame(() => console.log("rAF tasks"));
console.log("task end");
먼저 예시 코드에 대해서 보면,
단순히 생각했을 때에는 아래와 같은 순서로 콘솔에 출력이 될 것 같습니다.
"1. task start"
"2. setTimeout task1"
"3. Promise task 1"
"4. Promise task 2"
"5. rAF tasks"
"6. task end"
하지만 실제로는 이렇게 출력 되는데요.
"1. task start"
"6. task end"
"3. Promise task 1"
"4. Promise task 2"
"2. setTimeout task1"
"5. rAF tasks"
// 또는
"1. task start"
"6. task end"
"3. Promise task 1"
"4. Promise task 2"
"5. rAF tasks"
"2. setTimeout task1"
이벤트 루프가 어떻게 동작하길래 이렇게 출력이 되는 것인지,
그리고 2번과 5번의 순서는 왜 바뀌어서 출력될 수 있는지 알아보겠습니다.
(본 예시에서는 main() 함수라고 임시로 이름 지었습니다.)
비동기 함수 실행
자바스크립트는 setTimeout의 반환 값을 기다리지 않고,
바로 다음 코드인 Promise.resolve() 함수를 호출합니다.
브라우저는 멀티 스레드 방식으로 동작한다고 했습니다.
자바스크립트를 실행하는 스레드와는 별개로,
Web API(브라우저)는 별도의 스레드에서
사전에 정의된 0초의 시간이 지난 것을 확인한 뒤, 매크로 태스크 큐에 그 콜백 함수를 태스크로써 푸시합니다.
Promise.resolve 함수는 비동기 함수로써 호출되면,
setTimeout 함수와 다르게 Web API를 거치지 않고
마이크로 태스크 큐의 태스크로써 그 콜백함수를 푸시하게 됩니다.
그 이유는 나중에 다시 확인하겠습니다.
requestAnimationFrame 함수는 역시 비동기 함수로써 Web API에게 그 실행을 위임합니다.
"6. task end" 출력
마이크로 태스크 큐의 태스크 처리
자, 이제 main() 함수를 제외하면 콜 스택이 비어있는 상태입니다.
이벤트 루프가 작동하면서 각 태스크 큐들의 태스크들을 처리하기 시작합니다.
가장 먼저 마이크로 태스크 큐에 있는 태스크부터 가져옵니다.
여기서 특이한 점은 "3. Promise task 1"을 콘솔에 출력하고 난 뒤,
then 메서드 체이닝을 통해, then 메서드의 콜백 함수가 다시 마이크로 태스크 큐로 푸시된다는 점입니다.
뒤이어 바로 이벤트 루프에 의해 콜 스택으로 전달되어 "4. Promise task 2"가 콘솔에 출력됩니다.
마이크로 태스크 큐는 콜 스택이 비어있는 상태일 때,
이벤트 루프에 의해 큐에 있는 모든 태스크를 한 번에 비우게 됩니다.
또, 그 과정에서 발생한 다른 마이크로 태스크도 함께 연속적으로 비우게 됩니다.
이것이 바로 다른 비동기 함수들과 다르게 Promise가 곧바로 마이크로 태스크 큐로 푸시되는 이유입니다.
Promise는 Web API를 거치지 않고 바로 마이크로 태스크 큐로 푸시하여
1. 비동기 작업의 상태 변화와 관련된 로직을 쉽게 구성할 수 있습니다.
- Promise의 pending, fulfilled, rejected와 같은 상태를 일관성 있게 표현할 수 있습니다.
- 일관성 있게 표현 한다는 것은 그 만큼 코드의 예측 가능성을 높일 수 있다는 의미가 될 수 있습니다.
2. 다른 태스크 큐들보다 우선적으로 실행되도록 보장할 수 있습니다.
- Promise는 다른 태스크보다 우선순위가 높은 마이크로 태스크로 분류됩니다.
- 이는 다른 태스크들보다 우선적으로 실행되어야함을 뜻하며, 이를 위해 마이크로 태스크 큐라는 별도의 태스크 큐를 사용합니다.
3. then, catch, finally와 같은 메서드 체이닝을 가능하게 합니다.
- Promise 체인을 구성할 때, 각 비동기 작업의 결과는 메서드를 통해 다음 작업으로 넘어갑니다.
- 이런 체이닝을 가능하게 하기 위해서는 비동기 작업의 결과를 가능한 빠르게 처리해야 합니다.
- 마이크로 태스크 큐로 바로 태스크가 푸시되어 처리되기 때문에 빠르게 처리가 가능하며, 순차적으로 실행할 수 있게 합니다.
이어서 살펴봅시다.
매크로 태스크 큐의 태스크 처리
마이크로 태스크 큐로부터 가져왔던 태스크를 모두 처리했으니 다시 콜 스택이 비었습니다.
마이크로 태스크 큐는 비어있으니, 다음에는 매크로 태스크 큐의 태스크를 처리합니다.
마이크로 태스크 큐와 매크로 태스크 큐 간 태스크 처리 방식의 차이점은
마이크로 태스크 큐는 이벤트 루프에 의해 비워질 때,
큐 안에 있는 모든 태스크를 한 번에 비우지만,
매크로 태스크 큐는 한 번에 하나의 태스크 만을 처리합니다.
이것의 의미는
이벤트 루프가 매크로 태스크 큐에서 하나의 태스크를 가져와 콜 스택에서 실행하는 도중에
만약 마이크로 태스크 큐에 태스크가 하나라도 푸시 되어 있다면, 다시 마이크로 태스크 큐를 비웁니다.
그 다음에 다시 매크로 태스크 큐를 처리한다는 것입니다.
Animation Frame 큐의 태스크 처리
마지막 Animation frame 큐를 비울 차례입니다.
Animation Frame 큐도 매크로 태스크와 동일하게 작동합니다.
이벤트 루프가 콜 스택 → 마이크로 태스크 큐를 차례로 비었는지 확인 한 뒤,
매크로 태스크 큐 또는 Animation Frame 큐의 태스크를 비웁니다.
다만, requestAnimationFrame 함수의 경우에는
코드를 실행하는 로컬 머신의 화면을 렌더링 하는 모니터의 주사율에 따라 그 실행이 반복되므로,
그 주사율 주기에 따른 함수 호출 시점에 의해
매크로 태스크 큐보다 먼저 콜 스택으로 전달될 수 있어,
항상 매크로 태스크 큐가 Animation Frame 큐 보다 먼저 비워지는 것은 아닙니다.
자바스크립트에서는 비동기 함수마다 어느 태스크 큐로 푸시될 지 정해져 있습니다.
- 마이크로 태스크 큐: Promise 등
- 매크로 태스크 큐: setTimeout, setInterval 등
- Animation frame 큐: requestAnimationFrame
정리
이번 포스팅에서는 자바스크립트에서 다룰 수 있는 비동기부터 이벤트 루프에 대해서까지 알아보았습니다.
원활한 코드의 실행을 위해서 비동기적인 코드는 반드시 필요합니다.
자바스크립트에서는 이 비동기 코드를 어떻게 사용할 수 있는지,
또, 싱글 스레드로 동작하는 자바스크립트가 어떻게 비동기 코드를 실행 시킬 수 있는지
나름대로 정리해보았습니다.
포스팅을 정리하면서
자바스크립트가 어떻게 비동기를 다루는지 정리할 수 있어서
많은 학습이 되었던 것 같습니다.
잘못된 내용이나 피드백을 주실 부분이 있다면,
언제든지 알려주시면 확인 후, 반영하도록 하겠습니다.
Reference
- 동기, 비동기 처리
https://velog.io/@daybreak/%EB%8F%99%EA%B8%B0-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC
- 싱글 스레드 vs 멀티 스레드
- 자바스크립트는 어떻게 약속을 지킬까?
https://ui.toast.com/posts/ko_20220725
- Javascript 비동기 함수의 동작원리 (feat. EventLoop)
https://gruuuuu.github.io/javascript/async-js/
- 웹 애니메이션 최적화 requestAnimationFrame 가이드
https://inpa.tistory.com/entry/%F0%9F%8C%90-requestAnimationFrame-%EA%B0%80%EC%9D%B4%EB%93%9C