1.5 이벤트 루프와 비동기 통신

gonn-i·2025년 3월 10일
post-thumbnail

1.5.1 싱글 스레드 자바스크립트

자바스크립트는 싱글 스레드 언어이기에, 동기 방식으로 한번에 단 하나의 작업만을 처리한다. 이러한 처리는 직렬 방식이라고 하기도 하는데,
특정 요청이 시작된 이후에는 무조건 응답을 받은 이후에야 비로소 다음 작업을 처리할 수 있다.

과거에는, 실행 단위가 오직 프로세스 뿐이었다.
(프로그램을 구동해, 프로그램 상태가 메모리상에 실행되는 작업 단위 )
이때, 하나의 프로그램에는 - 오직 하나의 프로세스만이 할당되므로, 동시에 여러개의 복잡한 작업을 수행하기 어려웠다.

이에 따라, 더 작은 실행 단위인 스레드가 만들어졌고, 하나의 프로세스에서 여러개의 스레드를 만들어 스레드끼리 메모리를 공유하며 여러 작업을 동시에 수행할 수 있게 되었다.

근데 자바스크립트는 왜 하필 싱글 스레드일까?

자바스크립트의 시작은 비교적 간단한 작동 범위에서 '간단한 경고창을 띄우거나, 버튼 위에 이미지를 띄우는 것'과 같은 수행에 초점을 두었다.
한편, 여러 스레드에서 DOM 을 조작한다면, 메모리가 공유된채 동시에 같은 자원에 접근하게 되어 타이밍 이슈가 발생되었을 수도 있을 것이다.
이러한 상황에 빗어대 보면, 자바스크립트를 싱글 스레드로 구현한 것은 굉장히 합리적인 결정이라 볼 수 있다.

asynchronous 비동기성 이라는 뜻은, 동시에 일어나지 않는것을 의미하는데
동기식과 다르게 즉시 결과가 주어지지 않을수도 있고, 따라서 응답이 언제 올지도 알 수 없다.

그렇기 때문에 자바스크립트는 싱글 스레드 기반으로 동작하지만, 비동기 처리를 위해 브라우저(Web API)와 함께 이벤트 루프를 활용한다.


1.5.2 이벤트 루프란

이벤트 루프는 브라우저의 동작 타이밍을 관리하는 시스템이다.

  • ECMAScript에 포함된 내용은 아니고, 런타임 환경에서 제공하는 기능인데,
  • 하는 역할은 자바스크립트 엔진과 브라우저(Web API)가 협력하여 비동기 함수의 실행 시점을 조절한다고 보면된다.

🔗 호출 스택과 이벤트 루프

🔹 주요 개념 정리

  • 콜 스택
    - 자바스크립트에서 수행해야 할 코드나 함수를 순차적으로 담아둔 스택
    - 호출 되면 순차적으로 코드를 넣고, 실행이 끝나면 호출 스택에서 제거된다.
  • 이벤트 루프
    - 콜 스택이 비었는지 확인하고, 실행할 작업(태스크 큐 또는 마이크로태스크 큐)이 있으면 가져오는 역할
  • 태스크 큐
    - setTimeout, setInterval 같은 비동기 작업이 실행을 기다리는 공간
    • 테스크 큐란, 실행해야 할 테스크의 집합을 의미한다.
    • 이때, 호출 스택이 비었다면 선택된 큐 중에서 실행 가능한 가장 오래된 테스크를 "이벤트 루프"가 꺼내와 호출 스택에 넣는다
  • 마이크로태스크 큐
    - Promise.then(), MutationObserver 같은 높은 우선순위 작업이 대기하는 공간
    • 테스크 큐보다 더 높은 우선권을 갖는다! (마이크로 테스크 큐가 빌 때까지 기존 테스크 큐는 대기)

자바스크립트가 구동되는 환경인, Node.js와 브라우저에서는 여러 스레드가 사용되기 때문에, 이벤트 루프로 여러 스레드가 사용되는 환경과 - 자바스크립트 엔진을 연동한다

근데 싱글 스레드라 순차적으로 코드를 처리한다면, 비동기 함수는 누가 수행하는데?

이는 자바스크립트 코드가 동기식으로 실행되는 메인 스레드가 아닌
테스크 큐가 할당하는 별도의 스레드에서 처리된다.
그래서 Web API || Browser API 에서 비동기 로직이 실행되고, 그 콜백이 테스크 큐에 들어가는 것이다.


예제 코드로 본 동작 과정을 아래와 같다.

function bar() {
  console.log("bar");
}

function baz() {
  console.log("baz");
}

function foo() {
  console.log("foo");
  setTimeout(bar, 0)
  baz();
}

foo();

🔁 동작순서
1️⃣ foo()가 호출 스택에 들어간다.

  • 현재 스택: [ foo ]

2️⃣ console.log("foo")가 호출 스택에 들어간다.

  • 현재 스택: [ foo, console.log ]
  • "foo" 출력 후 제거됨
  • 현재 스택: [ foo ]

3️⃣ setTimeout(bar, 0) 실행

  • setTimeout 자체는 브라우저(Web API)에게 "bar를 0ms 후 실행해줘"라고 요청하는 역할만 함
  • setTimeout은 즉시 실행되고 호출 스택에서 제거됨
  • 현재 스택: [ foo ]
    (이때 bar는 태스크 큐(Task Queue)로 이동하지 않고, 브라우저 타이머가 먼저 담당)

4️⃣ baz()가 호출 스택에 들어간다.

  • 현재 스택: [ foo, baz ]

5️⃣ console.log("baz")가 호출 스택에 들어간다.

  • 현재 스택: [ foo, baz, console.log ]
  • "baz" 출력 후 제거
  • 현재 스택: [ foo, baz ]

6️⃣ baz() 실행이 끝났으므로 호출 스택에서 제거된다.

  • 현재 스택: [ foo ]

7️⃣ foo() 실행이 끝났으므로 호출 스택에서 제거된다.

  • 현재 스택: [] (완전히 비어 있음)

📌 이벤트 루프 시작
8️⃣ 이벤트 루프가 호출 스택이 비어 있는지 확인한다.

  • 현재 스택: [] (비어 있음)
  • 태스크 큐를 확인했을 때, bar() 실행 대기 중

9️⃣ bar()가 호출 스택에 들어간다.

  • 현재 스택: [ bar ]

🔟 console.log("bar")가 호출 스택에 들어간다.

  • 현재 스택: [ bar, console.log ]
  • "bar" 출력 후 제거됨
  • 현재 스택: [ bar ]

1️⃣1️⃣ bar() 실행이 끝났으므로 호출 스택에서 제거된다.

  • 현재 스택: [] (완전히 비어 있음)

1.5.3 태스크큐 / 마이크로태스트큐 / 동기 코드 랜더링 차이

태스크 큐는 setTimeout 같은 작업들이 들어가며, 각 콜백이 실행될 때마다 DOM을 렌더링하므로 하나씩 화면에 보인다.
마이크로태스크 큐는 queueMicrotask, Promise 등에서 작업을 처리하며, **모든 작업을 끝낸 후 한 번만 렌더링이 이루어진다.

[1] 태스크 큐

// setTimeout으로 태스크 큐에 작업을 넣어서 1부터 렌더링
macrotask.addEventListenerCclick', function () {
	for (let i = 0; i <= 100000; i++) {
		setTimeout(() => {
			macrotask.innerHTML = i
		}, 0)
	}
})

각 setTimeout 콜백은 태스크 큐에 추가되어 이벤트 루프에 의해 순차적으로 처리되고, 각 콜백이 실행될 때마다 렌더링이 발생합니다.

[2] 마이크로태스크 큐

// queueMicrotask로 마이크로 태스크 큐에 넣어서 1부터 렌더링
microtask.addEventListener('click’, function () {
	for (let i = 0; i <= 100000; i++) {
		queueMicrotask(() => {
			microtask.innerHTML = i
	})
})

마이크로태스크 큐 코드는, 100,000까지 숫자가 올라가기 전까지는 렌더링이 일어나지 않다가 for 문이 끝나야 비로소 렌더링 기회를 얻으며 100,000이라는 숫자가 한 번에 나타난다.

[3] 동기코드

// 동기 코드로 버튼에 1 부터 렌더링
sync.addEventListener('click', function () {
	for (let i = 0; i <= 100000; i++) {
		sync.innerHTML = i
	}
})

마이크로태스크 큐 코드와 마찬가지로 렌더링이 전혀 일어나지 않다가 100,000까지 다 끝난 이후에야 한 번에 렌더링이 일어난다.

profile
https://gonnn-i.tistory.com/

0개의 댓글