사전적인 의미에서 비동기란 '동시에 일어나지 않는다.' 라는 뜻입니다. 즉 요청과 결과가 동시에 일어나지 않는다는 의미이죠.
비동기는 언제 사용될까요? 대표적인 예로 API 통신을 들 수 있겠습니다. 브라우저는 싱글 스레드이기 때문에 API 요청에 대한 응답을 받는 동안 동기(sync-blocking) 방식으로 작동한다면 매우 비효율적으로 동작할 것이라고 직감하실 수 있으실 것입니다.
그래서 이런 경우에 비동기로 동작합니다. API 요청을 보낸 뒤 다음 작업들을 완료하고, API 응답이 오면 그제서야 그와 관련된 로직들을 수행하죠.
브라우저의 비동기 처리 방식을 이해하려면 '이벤트 루프'에 대해 짚고 넘어가야 합니다. 이벤트 루프란 딥 다이브의 말을 빌려오자면 다음과 같습니다.
브라우저는 단일 쓰레드(single-thread)에서 이벤트 드리븐(event-driven) 방식으로 동작한다. 웹 애플리케이션은 단일 쓰레드인 것에 비해 동시에 여러 개의 task가 처리되는 것처럼 보이는데, 이를 동시성(Concurrency)라고 하며 이를 지원하는 것이 바로 이벤트 루프이다.
그림으로 보면 다음과 같습니다.
하나씩 그 역할에 대해서 설명해보자면,
setTimeout
과 같은 비동기 API가 실행되는 공간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
가 떠야하지 않을까요? 🧐
실제로 해보시면 알겠지만 정상적으로 참조한 변수값을 불러옵니다. 그 변수는 자유 변수이기 때문이죠.
이 부분은 실행 컨텍스트의 환경 레코드(렉시컬 환경의 일부)와 클로저에 대해 알고 계신다면 어쩌면 당연한 부분이기도 합니다.
노드 환경에서는 Web API가 없는데 어떻게 브라우저 환경과 동일하게 동작할 수 있을까요?
Node.js는 비동기를 지원하기 위해 libuv
라이브러리를 사용한다고 합니다. 그리고 이 libuv
라이브러리가 이벤트 루프를 지원합니다.
이 부분은 학습해 본 후 추후 글로 정리해보겠습니다.
이렇게 이벤트 루프에 의해 비동기 로직이 수행되는 과정을 알아보았는데요. 그림 그리는게 정말 시간이 오래걸리네요.. 😅
긴 글 읽어주셔서 감사합니다! 잘못된 부분이 있다면 댓글로 지적 부탁드립니다!