자바스크립트 이벤트 루프와 비동기 통신의 이해

Date
Created
Apr 27, 2024 12:10 PM
Tags
1장

싱글 스레드 자바스크립트

프로세스

이전에는 프로그램을 실행하는 단위가 프로세스뿐이였음.
프로세스는 프로그램을 구동해 프로그램의 상태가 메모리상에서 실행되는 작업 단위.
즉 하나의 프로그램은 하나의 프로세스를 가지고 그 프로세스 내부에서 모든 작업이 처리되는 것.
소프트웨어가 복잡해지며 프로그램이 동시에 여러 작업을 수행해야 했는데 그래서 나온 더 작은 작업 단위가 스레드.

스래드

하나의 프로세스에서 여러개의 스레드를 만들 수 있고, 스레드 끼리는 메모리를 공유할 수 있어서 여러가지 작업을 동시에 수행이 가능하다.

자바스크립트는 왜 싱글 스레드?

멀티 스레드는 여러가지 장점이 있지만 내부적인 처리가 복잡하다는 단점.
각각 격리된 프로세스와 달리 같은 자원을 공유하는 스레드는 동시에 문제가 생길 수 있다.
이 관점에서 자바스크립트의 역할을 바라보면 최초의 자바스크립트는 브라우저에 html을 그리는데 한정적인 보조적 역할로 만들어졌다.
1995년에 처음 나왔고 그때는 멀티 스레드에 대한 개념이 대중화 되던 시기가 아니였다. 브렌던 아이크가 브라우저의 간단한 스크립트를 지원할 목적으로 LiveScript를 만든게 자바스크립트의 시작.
자바스크립트로 DOM을 조작하는걸 생각한다면 여러 스레드로 동시에 DOM을 조작한다면 같은 자원에 접근하는 스레드의 타이밍 이슈가 발생할 수 있고, 이는 브라우저의 DOM표시에 큰 문제를 야기할 수 있음.

자바스크립트의 싱글스레드는 무엇을 의미하나

자바스크립트 코드의 실행이 하나의 스레드에서 순차적으로 이루어짐을 의미함.
하나의 작업이 끝나기 전까지 뒤이은 작업이 실행되지 않는다는 것.
C언어나 다른 언어에서는 스레드의 실행중인 함수를 중단하고 다른 스레드를 먼저 수행할 수 있는 기능을 지원하지만 자바스크립트는 존재하지 않는다.
Node.js에 새로 추가된 Worker나 WebWorker가 있지만 매우 최근에 나왔고 자바스크립트의 환경과는 별개로 돌아가 워커와 따로 postmessage를 통해 소통해야 한다.
이런 특징을 Run-to-completion이라고 하고 이는 동시성의 고민이 필요 없지만 반대로 떄에 따라서 웹 상에서는 단점이 될 수 있다.
특정 작업이 오래 걸린다면 웹이 멈춘듯한 느낌을 주기 때문.

비동기란?

자바스크립트에서 비동기 함수를 선언할 때는 async를 사용한다. 영단어로는 동시에 일어나지 않는 것을 의미한다.
동기식과 다르게 요청한 즉시 결과가 주어지지 않을 수 있고 언제 올지도 모른다.
그치만 동기식과 다르게 여러 작업을 동시에 수행할 수 있다.
console.log(1) setTimeout(() => { console.log(2) }, 0); setTimeout(() => { console.log(3) }, 100); console.log(4)
출력 순서는 1, 4, 2, 3이 되는데 싱글스레드인 자바스크립트는 Run-to-completion으로 작동해야 하니 1, 2, 3, 4가 되어야 정상이 아닌가?
이에대한 답으로 이벤트루프를 살펴보겠다.

이벤트 루프

이벤트 루프란 자바스크립트 런타임 외부에서 자바스크립트의 비동기 실행을 돕기 위해 만들어진 장치.
V8, Spider Monkey같은 현대의 자바스크립트 런타임 엔진에는 자바스크립트를 효과적으로 실행하기 위한 장치가 여럿 마련되어있다.

호출 스택과 이벤트 루프

호출 스택(call stack)은 자바스크립트에서 수행해야할. 코드나 함수를 순차적으로 담아두는 스택.
function bar(){ console.log('bar') } function baz(){ console.log('baz') } function foo(){ console.log('foo') bar() baz() } foo()
  1. foo가 호출스택에 들어감
  1. foo 내부의 console.log가 호출스택에 들어감
  1. 2의 실행이 완료되고 다음 코드로 진행
  1. bar가 호출 스택에 들어감
  1. bar 내부의 console.log가 호출스택에 들어감
  1. 5의 실행이 완료되고 다음 코드로 진행
  1. bar에 남은 코드가 없으니 호출스택에서 bar가 제거
  1. baz가 호출 스택에 들어감
  1. baz내부에 console.log가 호출스택에 들어감
  1. 9의 실행이 완료되고 다음 코드로 진행
  1. baz에 남은 코드가 없으니 스택에서 제거
  1. foo에 남은 코드가 없으니 스택에서 제거
  1. 호출 스택이 완전히 비었다.
여기서 호출 스택이 비어 있는지 여부를 확인하는 것이 이벤트 루프.
이벤트 루프는 단순히 이벤트 루프만의 단일 스래드 내부에서 호출 스택 내부에 수행해야 할 작업이 있는지 확인하고, 수행해야 할 코드가 있다면 자바스크립트 엔진으로 실행함.
‘코드의 실행’과 ‘호출 스택이 비어있는지 확인’은 모두 싱글 스레드에서 일어난다. 두 작업은 동시에 일어날 수 없고 순차적으로 일어난다.
function bar(){ console.log('bar') } function baz(){ console.log('baz') } function foo(){ console.log('foo') setTimeout(bar(), 0) baz() } foo()
  1. foo가 호출스택에 들어감
  1. foo 내부의 console.log가 호출스택에 들어감
  1. 2의 실행이 완료되고 다음 코드로 진행
  1. setTimeout이 호출 스택에 들어감
  1. 4번에 대한 타이머 이벤트가 실행되어 태스크 큐로 들어가고 바로 호출 스택에서 제거된다.
  1. baz가 호출 스택에 들어감
  1. baz내부에 console.log가 호출스택에 들어감
  1. 9의 실행이 완료되고 다음 코드로 진행
  1. baz에 남은 코드가 없으니 호출 스택에서 제거
  1. foo에 남은 코드가 없으니 호출 스택에서 제거
  1. 호출 스택이 비어짐
  1. 이벤트 루프가 호출 스택이 빈것을 확인 태스크 큐를 확인하니 4번에서 들어온 내용이 있어 bar를 호출스택에 넣음
  1. bar내부의 console.log가 호출 스택에 들어감
  1. 13의 실행이 끝나고 다음으로 넘어감
  1. 더이상 bar에 남은 코드가 없어 호출 스택에서 제거
위 코드를 살펴보면 setTimeout이 정확히 0초 뒤에 실행됨을 보장할 수 없음.
여기서 태스크 큐 라는 새로운 개념이 등장

태스크 큐

태크스크 큐는 실행할 태스크의 집합을 의미.
이벤트 루프는 태스크 큐를 한 개 이상 가지고 있고 닉값을 못해 태스크 큐는 자료구조의 큐가 아닌 set 형태를 가짐.
이유는 선택된 큐 중 실행 가능한 가장 오래된 태스크를 가져와야 하기 때문.
자료구조 큐는 FIFO 선입 선출의 형태로 꺼내와야 하지만 태스크 큐는 그렇지 않다.
태스크 큐에서 의미하는 ‘실행할 태스크’는 비동기 함수의 콜백 함수나 이벤트 핸들러를 의미함.
 
이벤트 루프의 역할은 호출 스택에 실행 중인 코드가 있는지, 태스크 큐에 대기 중인 함수가 있는지 반복해 확인하는 역할.
호출 스택이 비어있다면 태스크 큐에 대기 중인 작업이 있는지 확인하고, 이 작업을 실행 가능한 오래된 것부터 순차적으로 꺼내와 실행함.
이 작업도 태스크 큐가 빌 때까지 이루어짐.

비동기 함수의 수행 주체

누가 저 비동기 함수를 수행할까? n초 뒤 setTimeout을 요청하는 작업을 누가 처리하고 fetch 기반의 네트워크 요청은 누가 보내고 응답을 받을까?
이런 작업들은 모두 자바스크립트 코드가 동기식으로 실행되는 메인 스레드가 아닌 태스크 큐가 할당되는 별도의 스레드에서 수행됨.
이 별도의 스레드에서 태스크 큐에 작업을 할당해 처리하는 것은 브라우저나 node.js의 역할.
자바스크립트 코드 실행은 싱글 스레드에서 이루어 지지만 외부 web api는 모두 자바스크립트 외부에서 실행되고 콜백이 태스크 큐로 들어가는것.
이벤트 루프는 호출 스택이 비고 태스크 큐의 콜백이 실행 가능한 때가 오면 이것을 꺼낸 수행하는 역할.
이런 작업이 메인 스레드에서만 이루어지면 절대 비동기 작업을 수행할 수 없음.
태스크 큐는 어떤 방식으로 수행될까?

태스크 큐와 마이크로 태스크 큐

태스크 큐와 다르게, 마이크로 태스크 큐가 있음.
기존의 태스크 큐와 다른 태스크를 처리하는데 대표적으로 Promise가 있음.
이 마이크로 태스크 큐는 기존 태스크보다 우선권을 가짐.
setTimeout과 setInterval은 Promise보다 늦게 실행됨.
명세에 따르면 마이크로 태스크 큐가 빌 때까지는 기존 태스크 큐의 실행은 미뤄짐
function bar(){ console.log('bar') } function baz(){ console.log('baz') } function foo(){ console.log('foo') } setTimeout(foo, 0) Promise.resolve().then(bar).then(baz)
위 코드를 실행하면 bar, baz, foo 순으로 실행됨.
태스크 큐: setTimeout, setInterval, setImmediate
마이크로 태크스: process.nextTick, Promises.queueMicroTask, MutationObserver
그렇다면 렌더링은 언제 실행될까? 태스크 일까 마이크로 태스크 일까?
태스크 큐를 실행하기 앞서 마이크로 태스크 큐를 실행하고 그 다음에 렌더링이 일어난다.
각 마이크로 큐 작업이 끝날 때마다 한 번씩 렌더링 할 기회를 얻음.
console.log('a') setTimeout(() => { console.log('b') }, 0) Promise.resolve().then(() => { console.log('c') }) window.requestAnimationFrame(() => { console.log('d') })
requestAnimationFrame는 브라우저의 다음 리페인트 전에 콜백 함수 호출을 가능하게 하는 함.
위 코드를 실행하면 a, c, d, b 순서로 출력됨.
즉 브라우저의 렌더링은 마이크로 태스크와 태크스 큐 사이에서 일어남
결론적으로 동기 코드는 물론 마이크로 태스크 또한 렌더링에 영향을 미칠 수 있다.
따라서 만약 특정 렌더링이 자바스크립트 내 무거운 작업과 연관이 있다면 분리해 사용자에게 좋은 경험을 제공할 . 지고민해야 한다.

이벤트 루프 자세히 살펴보기

책에는 없지만 이벤트 루프를 이해하기 위해 적는다.
마이크로 태스트 큐와 태스크 큐를 묶어서 콜백 큐라고 한다.
웹브라우저와 Node.js의 Web API 차이웹브라우저의 Web APIs 와 Node.js 의 Node.js APIs 들은 구성은 비슷하지만 동작 측면에서 약간 차이가 있다. 웹브라우저의 Web APIs는 비동기 작업이 끝나면 스스로 callback queue에 적재하지만, Node.js API들은 이벤트 루프가 직접 옮겨준다. 예를들어 Timer Web API에서 타이머가 모두 지나가면, 자바스크립트 환경이 웹브라우저냐 Node.js 냐에 따라 차이가 갈린다.Node.js : Timer API가 타이머 완료 이벤트를 발생시키고, 이벤트 루프가 이를 감지하여 Task Queue에 콜백 함수를 추가한다.웹브라우저 : Timer API가 스스로 Task Queue에 콜백 함수를 추가한다. 출처: https://inpa.tistory.com/entry/🔄-자바스크립트-이벤트-루프-구조-동작-원리 [Inpa Dev 👨‍💻:티스토리]
 

Async/Await 오해와 진짜 동작

let x = await bar(); // bar() 함수 정의는 생략 console.log(x); console.log('Done');
bar().then(x => { console.log(x); console.log('Done'); });
const one = () => Promise.resolve('One!'); async function myFunc(){ console.log('In function!'); const res = await one(); console.log(res); } console.log('Before Function!'); await myFunc(); console.log('After Function!');
const one = () => Promise.resolve('One!'); async function myFunc(){ console.log('In function!'); const res = await one(); console.log(res); } console.log('Before Function!'); await myFunc(); console.log('After Function!'); /* ---------------- ↓↓↓ 변환 ↓↓↓ ---------------- */ const one = () => Promise.resolve('One!'); function myFunc(){ console.log('In function!'); return one().then(res => { console.log(res); }); } console.log('Before Function!'); myFunc().then(() => { console.log('After Function!'); });