[REAL Deep Dive into JS] 42. 비동기 프로그래밍

young_pallete·2022년 10월 27일
0

REAL JavaScript Deep Dive

목록 보기
43/46

본론

🚦 동기 처리와 비동기 처리

간단명료하게 말하자면, 동기 처리와 비동기 처리는 다음과 같은 차이가 있습니다.

동기처리는 순서를 지향해요. 어떤 일이 끝난 다음에, 다음 일이 실행되어야 합니다.
하지만 비동기는 순서가 아닌, 처리했다는 사실을 중시해요. 따라서 순서를 보장하지 않고 일단 실행해버려요.

비동기 처리의 필요성

우리, 왜 시험을 동시에 볼까요?
사실 그냥 한명 한명 1:1 면접 보듯이 보면 되는데 말이죠.
그렇다면 컨닝같은 부정도 일어나지 않을텐데 말이죠.

이러한 질문에 당장 대답할 수 있는 건,
1. 너무 일일이 보는 것은 시간이 너무 오래 걸립니다.
2. 일일이 보았을 때 드는 인적 자원에 대한 리소스가 너무 아깝습니다. 여러 명을 대응할 수 있는데 말이죠.
3. 순서라는 게 크게 중요하지 않습니다. 동시에 보고, 동시에 끝나도 이상하지 않죠.
4. 돌발사항에 있어 유연합니다. 시험을 일부 보지 못하는 상황에서도 다른 사람들이 시험을 볼 수 있기에, 유연하게 대처할 수 있죠.

따라서 시험은 비동기에요.
순서를 굳이 보장하지 않고 병렬적으로 실행함으로써, 빠르게 시간을 절약할 수 있는 거죠.

특히 이러한 과정은 브라우저에서 정말 중요해요.
만약 데이터 통신에 순서를 보장하게 된다고 가정해봅시다. 그런데 서버에서 응답하지 않아요.
그러면 나머지 데이터들이 모두 전달받지 못하여 블락당하는 상황이 존재하게 되는 겁니다.

그렇기 때문에 비동기가 중요합니다. 동기 처리 대비 통신에 있어 엄청난 성능 향상이 있기 때문이죠.

자바스크립트는 싱글 스레드이다.

보통 스레드라 함은 프로세스의 흐름을 처리하는 작은 단위를 일컬어요.
우리는 자바스크립트를 통해 브라우저를 동적으로 제어하는데, 실은 자바스크립트는 처리할 수 있는 스레드가 오직 하나입니다.

그렇다면 무엇이 이상한 건가요?

바로 여기서 비동기에 대한 한계가 존재한다는 것이죠.
자바스크립트는 엔진 하나로 주어진 연산을 순서대로 처리하고 실행해야 합니다.

그런데... 우리 API를 어떻게 통신하는 걸까요?
혹은, 타이머는 어떻게 설정할 수 있는 걸까요?

순서를 보장해야 한다면, 타이머가 끝나야 다음 일들이 처리되어야 하는데, 실제로는 그렇지 않습니다.

function test() {
    setTimeout(() => {
        console.log('Timeout !')
    }, 0)
    console.log('Console !')
}

test();
// Console !
// Timeout !

따라서 우린, 자바스크립트의 내부 동작 과정을 좀 더 이해할 필요가 있습니다.

생각보다 거대했던 자바스크립트의 내부

자바스크립트에는 분명, 싱글 스레드 방식 이상의 무언가가 깊숙이 있다는 것을 알 수 있게 됐습니다.

그렇다면, 이러한 비동기를 가능하게 한 것은 무엇일까요?
우리는 먼저 이분법적으로 사고를 할 필요가 있어요.

  • 코드를 순서대로 동작하고 실행하는 자바스크립트 엔진과
  • 이 엔진을 움직이는 런타임 환경(브라우저, 혹은 Node.js)으로 말이죠.

자바스크립트 엔진

이 친구는 우리가 쭉 공부하며 알던 그 친구에요.
코드를 평가하고, 순차적으로 실행하는 친구이죠.

그 과정에서 자바스크립트 엔진은 다음과 같은 자료구조를 토대로 실행하는데요.

  • 콜 스택

이 바로 그 주인공입니다.

자바스크립트는 메모리에 값을 동적으로 결정하게 됩니다. 이유는 정적으로 컴파일하기보다는, 기본적으로 동적으로 컴파일하는 언어이기 때문이죠.

따라서 이러한 메모리에 값을 런타임에 동적으로 처리하기 위해서 힙을 사용합니다.

콜 스택

실행 컨텍스트를 떠올리면 쉬울 것 같아요.
전역 실행 컨텍스트부터 해서, 많은 실행 컨텍스트들이 존재했는데요. 이러한 실행 컨텍스트들이 쌓이는 스택 자료구조를 콜 스택이라 합니다.

이러한 스택을 통해 위의 컨텍스트가 끝날 때까지 아래의 컨텍스트는 제거되지 않도록 함으로써, 실행 컨텍스트의 동기성을 유지할 수 있는 것입니다.

"자바스크립트는 싱글 스레드니, 동시에 처리할 수 없다"는 말은 거짓말이 아닙니다.
다만 이를 둘러싼 환경이 자바스크립트의 약점을 보완해준다고 하는 것이 맞아요!

런타임 환경

런타임 환경에서도 이를 처리할 수 있도록 돕는 친구들이 2개가 존재합니다.

  • 태스크 큐(마이크로태스크 큐)와
  • Web API인데요.

이 둘까지 이해해야, 비로소 우리는 비동기를 처리하는 이벤트 루프의 사이클을 이해할 수 있게 돼요.

태스크 큐

큐는 기본적으로 먼저 들어오면 먼저 나갈 수 있도록 한 선입선출의 자료구조의 구조를 갖고 있어요. 따라서 타이머가 실행하는 콜백함수가 여기에 들어가게 되고, 오래된 태스크 큐가 빠져나와 다시 콜스택에 들어가게 되는 거에요!

잠깐만! 그러면 타이머가 만약 2초, 1초 순으로 들어가게 되면 문제 생기는 거 아닌가요? 태스크 큐에 콜백함수가 순차적으로 들어가는 게 아닌가요?

만약 그렇다면 당연히 맞는 말이에요! 잘 파악하신 거에요. 👏🏻
그런데, 여기는 태스크 큐에서는 순전히 타이머가 완료된 것들만 콜백함수로 들어오게 돼요. 즉, 태스크 큐에서 바로 콜백에 넣어서 스케줄링을 하지는 않는다는 이야기입니다.

그렇다면 어디서 관리할까요? 이를 위해 Node.js가 말하는 이벤트 루프를 참고한다면 답을 알 수 있는데요.

결국 타이머의 시간 예약을 관리하는 것은 바로 커널입니다. 커널은 멀티 스레드로 이루어져 있기 때문에 커널에서 적절한 시간을 완수했다!하면 큐로 넘겨주는데요. 그 큐가 바로 태스크 큐인 거죠.

timers 단계에서 확인 후 queue에 넣어주게 되면, 그 다음에는 연결에서 지연이 된 것들에 대한 콜백 함수를 폴 큐에 넣게 됩니다.
그 다음에는 poll 단계에서 setImmediate가 있는 경우 바로 check 단계로 넘기는데요.
그게 아니라면 큐가 빌 때까지 처리해줍니다.

이때 특이한 게 있습니다.
결국 콜백의 제어권은 poll단계에 있는데요. 이때 타이머의 실행은 콜백 함수가 얼마나 오래 걸리느냐에 따라 영향을 받습니다.

참고로 timer의 구현 스펙 자체도 딱 시간에 맞지 않아요.
표준을 살펴보면 4ms 이하거나 5번 중첩 시에는 간격이 4ms로 된다고 하죠.

아래를 실제로 실행하면, 3초 뒤에 모든 콜백 함수가 나오는 것을 알 수 있습니다.

setTimeout(() => {
    const prev = Date.now();
    while (Date.now() - prev < 3000) {} // 3초가 걸리도록 설정
    console.log('hi')
}, 0)

setTimeout(() => {
    console.log('bye')
}, 100);

마이크로태스크 큐

그런데 말이죠. 태스크 큐와 비슷한 친구가 있어요.
Promise를 통해 API를 받아오면 이를 실행하는 콜백 함수를 처리하는 태스크 큐도 존재하는데요.

이 친구를 마이크로태스크 큐라 해요.
똑같이 비동기에서 스케줄링이 끝나고 콜백 함수들을 콜 스택으로 옮겨주는 친구에요.

이때, 마이크로태스크 큐가 제어권을 가진 콜백 함수는 태스크 큐보다 우선순위를 가집니다.

setTimeout(() => console.log('bye'))
setTimeout(() => console.log('bye'))
setTimeout(() => console.log('bye'))
setTimeout(() => console.log('bye'))
setTimeout(() => console.log('bye'))
setTimeout(() => console.log('bye'))
new Promise((resolve) => resolve()).then(() => console.log('hi')));
// hi
// bye
// bye
// bye
// bye
// bye
// bye

Web API

이렇게 비동기를 유발하는 함수들이 몇 가지가 있어요.

  • Timer Function
  • DOM API
  • HTTP Request 등이 있는데요.

이러한 친구들이 바로 Web API입니다. 이 친구들이 호출되면 자연스럽게 콜백을 처리하도록 커널이나 태스크 큐로 넘어가는 거죠.

이벤트 루프

결과적으로 제어권을 가진 태스크 큐는 폴링(주기적으로 검사하는 단계)을 하면서 콜 스택이 모두 빠지게 되면 쌓여있던 콜백들을 처리합니다.

이때, 모든 콜백들을 처리하지는 않아요.
분명 임계값은 존재하며, 최대한 처리하려고 노력할 뿐이라고 합니다. (예컨대 pending 되는 친구들은 존재하기 때문이죠)

이후 다시 콜스택이 빠지거나, 스케줄링이 끝난 콜백이 추가될 때까지 태스크 큐는 계속해서 호출할 준비를 하게 되죠. 이러한 전 과정을 이벤트 루프라고 합니다.

결과적으로 자바스크립트에서 우리가 말하는 비동기 프로그래밍은 이러한 이벤트 루프를 기반으로 동작하는데요.
이벤트 루프를 이해하게 되면, 비로소 이제 다음 코드를 이해할 수 있게 된답니다!

setTimeout(() => {
    const prev = Date.now();
    while (Date.now() - prev < 3000) {}
    console.log('SetTimeout1')
}, 0) // 4. 우선적으로 스케줄링이 끝난 콜백을 실행한다. 총 8초가 걸렸다.

new Promise((resolve) => resolve()).then(() => console.log('Promise')); // 3. 마이크로 태스크 큐부터 먼저 스케줄링이 끝난 콜백 함수가 있는지 탐색, 해당 콜백 함수 실행.

setTimeout(() => {
    console.log('SetTimeout2')
}, 100); // 5. `hi`가 처리된 후 폴링을 한 결과 이미 쌓인 큐가 존재했다. 따라서 이까지 처리한다. 8초에 `hi` 직후 바로 `bye`가 출력된다.

const prev = Date.now();
while (Date.now() - prev < 5000) {}; // 1. 5초간 대기한다.
console.log('end'); // 2. 콜스택에 추가, end 출력, 전역 실행 컨텍스트 스택에서 제거

🎉 마치며

후, 생각보다 꽤 시간이 걸렸어요.
저 역시 이렇게까지 태스크 큐를 들여본 적은 없었기 때문인데요.
덕분에 몰랐던 부분을 많이 알아가서 기분은 좋네요.
특히 커널에서 스케줄링에 힌트를 준다는 부분과,
폴링을 통해 큐의 특성을 보장한다는 점은 정말 제게 좋은 깨달음을 주었어요.

이벤트 루프, 브라우저에서의 동작들을 이해하는 데 있어 정말 중요한 개념인데요!
이 글이 도움이 됐으면 좋겠네요. 이상 🌈

Node.js 이벤트 루프, 타이머, process.nextTick()
Node.js phase 간단 정리

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글