브라우저에서 비동기 동작 과정

se-een·2023년 3월 27일
2

JavaScript 분석하기

목록 보기
2/2
post-thumbnail

비동기란?

사전적인 의미에서 비동기란 '동시에 일어나지 않는다.' 라는 뜻입니다. 즉 요청과 결과가 동시에 일어나지 않는다는 의미이죠.

비동기는 언제 사용될까요? 대표적인 예로 API 통신을 들 수 있겠습니다. 브라우저는 싱글 스레드이기 때문에 API 요청에 대한 응답을 받는 동안 동기(sync-blocking) 방식으로 작동한다면 매우 비효율적으로 동작할 것이라고 직감하실 수 있으실 것입니다.

그래서 이런 경우에 비동기로 동작합니다. API 요청을 보낸 뒤 다음 작업들을 완료하고, API 응답이 오면 그제서야 그와 관련된 로직들을 수행하죠.

이벤트 루프

브라우저의 비동기 처리 방식을 이해하려면 '이벤트 루프'에 대해 짚고 넘어가야 합니다. 이벤트 루프란 딥 다이브의 말을 빌려오자면 다음과 같습니다.

브라우저는 단일 쓰레드(single-thread)에서 이벤트 드리븐(event-driven) 방식으로 동작한다. 웹 애플리케이션은 단일 쓰레드인 것에 비해 동시에 여러 개의 task가 처리되는 것처럼 보이는데, 이를 동시성(Concurrency)라고 하며 이를 지원하는 것이 바로 이벤트 루프이다.

그림으로 보면 다음과 같습니다.

하나씩 그 역할에 대해서 설명해보자면,

  • Heap : 객체 인스턴스와 같은 것들이 저장되는 공간
  • Call Stack : 실행 컨텍스트가 쌓이는 공간
  • Web API : setTimeout과 같은 비동기 API가 실행되는 공간
  • Queue : 비동기 작업이 저장되어 있는 공간
    • Micro : 우선순위 1순위. 대표적으로 Promise가 담기는 공간. 그 외(process.nextTick, queueMicrotask(f), MutationObserver)
    • Animation : 우선순위 2순위. 브라우저 렌더링과 관련된 작업들이 담기는 공간.
    • Macro : 우선순위 3순위. 대표적으로 setTimeout과 같은 작업이 담기는 공간. 그 외(setInterval, setImmediate)

여기서 이벤트 루프는 상시에 콜 스택이 비어있는지 확인하고, 만약 비어있다면 우선순위에 따라 각각의 큐에 담겨있는 비동기 작업의 실행을 콜 스택에 옮기는 역할을 수행합니다.

특이한 점은, 마이크로 태스크 큐와 애니메이션 프레임을 방문할 때는 큐가 빌 때까지 큐 안의 모든 작업들을 한 번에 수행하지만, 매크로 태스크 큐를 방문할 때는 한 번에 하나의 작업만 수행합니다.

비동기 처리 과정

하나의 예시를 들어 비동기 처리 과정을 살펴보겠습니다.

console.log('1');

function foo() {
  setTimeout(() => console.log('2'), 0);
  goo();
}

function goo() {
  console.log('3');
  new Promise((resolve) => resolve()).then(() => console.log('4'));
}

foo();

console.log('5');

위 코드를 실행하면 어떤 결과가 도출될까요?

해당 코드를 실행하면 다음과 같이 콜 스택에 전역 실행 컨텍스트가 쌓이게(push) 됩니다. 보통 콜 스택에 anonymous 라고 써져 있는 그것이죠.

(설명에서 불필요한 요소는 그림에서 제거하였습니다.)

그리고 자바스크립트는 인터프리터 언어이므로 한 줄씩 읽어들여, 첫 줄의 console.log('1')이 실행 컨텍스트에 쌓이게 됩니다.

그리고 터미널에 1을 출력하면서 콜 스택에서 제거(pop) 되죠.

그 다음 함수 선언문을 읽은 이후, foo() 문을 읽어드리고 실행 컨텍스트에 foo 함수가 쌓입니다.

foo 함수의 내부 로직을 실행하면서 setTimeout을 읽어드리고 콜 스택에 쌓겠네요.

위에서도 언급했던 것과 같이 setTimeout은 비동기 함수이며 Web API가 설정한 타이머를 실행한다고 했습니다.

따라서 setTimeout은 0초(정확히는 4ms 내외라고 합니다.)동안 Web API에 머물고 콜백 함수가 매크로 태스크 큐로 이동하겠죠. 다음과 같습니다.

0초(약 4ms)간 머문 뒤 매크로 태스크 큐로 콜백 함수를 이동시킵니다.

콜 스택은 아직 비어있지 않으니 매크로 태스크 큐에 담긴 console.log('2')는 바로 실행되지 않습니다. 마저 foo 함수의 로직을 수행하면서 goo 함수를 호출합니다.

goo 함수를 보면 console.log('3')가 바로 처음으로 수행될 로직입니다. 따라서 콜 스택에 쌓였다가 터미널에 3을 찍고 바로 제거됩니다.

그리고 Promise 문을 만나게 되는데 Promise 자체는 동기이지만, then 메서드가 쓰였기 때문에 이 역시 위에서 언급했던 것처럼 비동기로 수행되고 매크로 태스크 큐가 아닌 마이크로 태스크 큐에 수행할 로직이 쌓이게 됩니다.

웹 API를 거쳐 마이크로 태스크 큐로 이동하는 모습입니다.

자 이제 거의 다 왔습니다. goo 함수와 foo 함수 모두 로직을 전부 수행 했으므로 콜 스택에서 제거 됩니다.

마지막으로 남은 console.log('5') 가 콜 스택에 쌓이고 터미널에 5를 출력한 후 제거되겠죠.

(콜 스택에 쌓는 과정은 중복되므로 생략했습니다.)

자, 이제 모든 코드를 수행했습니다. 남아 있는 코드가 없으므로 전역 실행 컨텍스트 역시 제거 됩니다.

이제서야 이벤트 루프는 각각의 태스크 큐에 쌓인 작업들을 우선순위에 따라 콜 스택으로 옮기는 작업을 수행합니다.

마이크로 태스크 큐가 매크로 태스크 큐보다 우선순위를 가지므로 마이크로 태스크 큐에 담겨 있는 console.log('4')가 우선적으로 콜 스택에 쌓이겠죠.

앞에서와 마찬가지로 터미널에 4를 출력한 뒤 콜 스택에서 제거될 것입니다.

이벤트 루프는 콜 스택이 또 비어있음을 확인했으므로 매크로 태스크 큐에 남아있는 작업을 콜 스택으로 옮기기 시작합니다.

(콜 스택에 쌓는 과정은 중복되므로 생략했습니다.)

이렇게 위 코드의 실행 결과는 다음과 같이 1 3 5 4 2가 되는 것입니다.

실행 환경은 어떻게 기억하지?

만약 위 코드에서 비동기 로직이 foo 함수나 goo 함수의 지역변수를 참조하고 있었다면 어떻게 되었을까요?

실행 컨텍스트는 모두 비어진 뒤에 비동기 로직이 수행되기 때문에 undefined가 떠야하지 않을까요? 🧐

실제로 해보시면 알겠지만 정상적으로 참조한 변수값을 불러옵니다. 그 변수는 자유 변수이기 때문이죠.

이 부분은 실행 컨텍스트의 환경 레코드(렉시컬 환경의 일부)와 클로저에 대해 알고 계신다면 어쩌면 당연한 부분이기도 합니다.

그러면 Node 환경에서는?

노드 환경에서는 Web API가 없는데 어떻게 브라우저 환경과 동일하게 동작할 수 있을까요?

Node.js는 비동기를 지원하기 위해 libuv 라이브러리를 사용한다고 합니다. 그리고 이 libuv 라이브러리가 이벤트 루프를 지원합니다.

이 부분은 학습해 본 후 추후 글로 정리해보겠습니다.


이렇게 이벤트 루프에 의해 비동기 로직이 수행되는 과정을 알아보았는데요. 그림 그리는게 정말 시간이 오래걸리네요.. 😅

긴 글 읽어주셔서 감사합니다! 잘못된 부분이 있다면 댓글로 지적 부탁드립니다!

profile
woowacourse 5th FE

0개의 댓글