![[JavaScript] 이벤트 루프(Event Loop)와 비동기 통신](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoddAw%2FbtsL3f5kEMY%2FQ8vfiTxnUiztyLyhW6gSlK%2Fimg.jpg)
브라우저에서 검색창에 키워드를 입력하면 어떤 일이 일어날까? 검색 결과를 가져오기 위해 서버에 요청을 보내고, 응답을 받아 화면에 표시하는 동안에도 우리는 다른 탭을 열거나 스크롤을 내릴 수 있다. 하지만 자바스크립트는 싱글 스레드 언어다. 그렇다면 한 번에 하나의 작업만 처리할 수 있지 않을까?
이런 동작이 어떻게 가능한 걸까? 동기$_{synchronous}$ 처리는 은행 창구에서 한 명의 고객이 업무를 마쳐야 다음 고객의 업무를 처리하는 것처럼 한 작업이 끝나야만 다음 작업을 시작하는 방식이다. 반면 비동기$_{asynchronous}$ 처리는 카페에서 주문을 받은 후 에스프레소 샷이 내려오는 동안 다음 손님의 주문을 받을 수 있는 것처럼 이전 작업이 끝나기를 기다리지 않고 다음 작업을 실행할 수 있다.
자바스크립트는 분명 싱글 스레드에서 동기 방식으로 작동한다. 하지만 모던 웹 애플리케이션에서는 많은 비동기 작업이 이루어지고 있다. 어떻게 이것이 가능한걸까?
싱글 스레드 자바스크립트
먼저 자바스크립트가 왜 싱글 스레드로 설계되었는지 이해하기 위해서는 프로세스와 스레드의 개념을 알아야 한다. 초기의 컴퓨터 프로그램은 프로세스$_{process}$라는 단위로 실행되었다. 프로세스는 프로그램이 실행되어 메모리에 올라간 상태를 의미하는데, 하나의 프로그램은 하나의 프로세스를 가지고 그 안에서 모든 작업을 처리했다.
하지만 프로그램이 점차 복잡해지면서 하나의 프로세스만으로는 다양한 작업을 처리하기 어려워졌다. 이런 문제를 해결하기 위해 등장한 것이 스레드$_{thread}$다. 스레드는 프로세스보다 더 작은 실행 단위로, 하나의 프로세스 안에서 여러 개의 스레드를 만들 수 있다. 스레드들은 프로세스의 메모리를 공유하면서 동시에 여러 작업을 처리할 수 있게 해준다.
그렇다면 자바스크립트는 왜 하나의 스레드만 사용하도록 설계됐을까? 멀티 스레드는 동시에 여러 작업을 처리할 수 있다는 장점이 있지만, 동시성 관리가 매우 까다롭다. 여러 스레드가 같은 메모리에 동시에 접근하다 보면 데이터가 오염되거나 예상치 못한 결과가 발생할 수 있기 때문이다. 게다가 하나의 스레드에서 문제가 발생하면 같은 메모리를 공유하는 다른 스레드들도 영향을 받을 수 있다.
이러한 복잡성을 이해하기 위해서는 자바스크립트의 탄생 배경을 알아야 한다. 자바스크립트는 브라우저에서 HTML을 동적으로 조작하는 보조적인 역할을 위해 만들어졌다.
자바스크립트가 처음 나왔을 때의 시대적 배경
자바스크립트는 1995년, 넷스케이프의 개발자 브렌던 아이크가 브라우저에서 간단한 스크립트 처리를 지원하기 위해 만든 LiveScript에서 시작됐다. 그 당시는 멀티 스레드라는 개념조차 대중화되지 않았던 시기였다. 자바스크립트의 첫 버전은 다른 프로그래밍 언어들을 참고해 단 10일 만에 만들어졌는데, 버튼 위에 이미지를 띄우거나 경고창을 표시하는 정도의 아주 기초적인 기능만을 제공했다.
당시에는 아무도 자바스크립트가 이렇게 발전할 것이라 예상하지 못했다. 지금처럼 복잡한 웹 애플리케이션을 만들고 서버까지 담당하게 될 줄은 꿈에도 몰랐을 것이다. 더군다나 DOM을 여러 스레드가 동시에 조작하게 된다면? 여러 스레드가 동시에 같은 DOM 요소를 수정하려 할 때 발생할 수 있는 충돌과 타이밍 이슈는 브라우저의 화면 표시에 심각한 문제를 일으킬 수 있었다.
이런 맥락에서 보면 자바스크립트를 싱글 스레드로 설계한 것은 당시로서는 합리적인 선택이었다. 오히려 지금의 상황이 더 특이하다고 할 수 있다. 단순한 스크립트 처리를 위해 만들어진 언어가 이제는 웹의 거의 모든 영역을 담당하고 있으니 말이다. 이런 이유로 일각에서는 자바스크립트의 근본적인 설계 자체를 다시 생각해볼 때가 되었다고 말하기도 한다.
한 번에 하나씩 순차적으로 실행
자바스크립트가 싱글 스레드라는 것은 코드가 하나의 스레드에서 순차적으로 실행된다는 의미다. 즉 한 번에 하나의 작업만 처리할 수 있고, 하나의 작업이 끝나기 전까지는 다음 작업을 시작할 수 없다. C언어 같은 다른 프로그래밍 언어들은 실행 중인 함수를 중단하고 다른 스레드의 코드를 실행할 수 있지만, 자바스크립트는 그렇지 않다. (Node.js의 Worker는 예외적으로 동시성을 지원한다)
이런 특징 때문에 자바스크립트에서 하나의 작업이 오래 걸리면 그 뒤의 모든 코드가 blocking된다. 이를 'run-to-completion'이라고 한다. 개발자가 동시성 문제를 신경 쓰지 않아도 된다는 장점이 있지만, 반대로 큰 단점이 될 수도 있다.
하지만 다음 코드를 보자.
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
setTimeout(() => {
console.log(3);
}, 100);
console.log(4);
자바스크립트가 싱글 스레드라면 이 코드는 1, 2, 3, 4 순서로 실행되어야 할 것 같다. 하지만 실제로는 1, 4, 2, 3 순서로 실행된다. 어떻게 이것이 가능할까? 자바스크립트가 싱글 스레드임에도 불구하고 어떻게 비동기 처리가 가능한 걸까?
이를 이해하기 위해서는 이벤트 루프라는 개념을 알아야 한다.
이벤트 루프란
이벤트 루프는 자바스크립트 런타임 외부에서 자바스크립트의 비동기 실행을 돕기 위해 만들어진 장치라고 볼 수 있다.
설명하기에 앞서
이벤트 루프를 포함한 자바스크립트 런타임 내부에서 일어나는 동작에 대한 시각화를 정말 잘 해주신 Lydia Hallie님의 자료를 사용해 설명을 할 예정이다. 설명하기에 앞서, 자바스크립트 런타임의 여러 구성 요소들을 먼저 이해할 필요가 있다. V8, Webkit, Spider Monkey 같은 자바스크립트 런타임 엔진에서는 자바스크립트 코드를 효과적으로 실행하기 위한 여러 가지 장치들이 마련되어 있다. 이 글에서는 V8 엔진을 기준으로 설명한다.
call stack과 이벤트 루프
call stack은 자바스크립트에서 실행할 함수들을 순차적으로 쌓아두는 스택이다.
함수가 호출되면 새로운 실행 컨텍스트가 생성되어 call stack에 추가되고, 함수의 실행이 완료되면 스택에서 제거된다.
실행 컨텍스트는 함수가 실행될 때 필요한 모든 정보를 가지고 있는 객체다. 여기에는 함수 내부의 변수들(변수 환경), this 값, 외부 환경에 대한 참조 등이 포함된다. call stack은 이러한 실행 컨텍스트들을 순서대로 관리하면서, 자바스크립트 코드의 실행 흐름을 제어한다.
자바스크립트는 싱글 스레드로 동작하기 때문에, call stack에 쌓인 작업들은 순차적으로 처리된다.
예를 들어 무거운 연산을 수행하는 longRunningTask
함수가 실행 중이라면, 그 뒤의 importantTask
는 아무리 중요한 작업이라도 이전 작업이 완료될 때까지 기다려야 한다.
그렇다면 네트워크 요청이나 타이머 같은 시간이 오래 걸리는 작업들은 실행할 때마다 전체 애플리케이션이 멈추게 될까?
fetch("https://website.com/api/posts")
// 서버에서 언제 데이터가 반환될지 알 수 없다.
// 그렇다면 데이터가 반환될 때까지 이 작업이 call stack을 점유하고 있을까?
다행히도 그렇지 않다. 이러한 작업들은 자바스크립트 엔진이 아닌 Web API를 통해 처리되기 때문이다.
Web API가 비동기 작업을 처리하는 방식
Web API는 브라우저의 기능을 활용할 수 있는 인터페이스를 제공한다. DOM$_{Document \space Object \space Model}$, fetch
, setTimeout
등 자바스크립트로 개발할 때 자주 사용하는 기능들이 여기에 포함된다.
브라우저는 콘텐츠를 표시하기 위한 렌더링 엔진이나 네트워크 요청을 처리하기 위한 네트워킹 스택처럼 실용적인 애플리케이션을 만드는 데 필수적인 기능들이 있다. 심지어 기기의 센서, 카메라, 위치 정보와 같은 low-level의 기능들까지도 사용할 수 있다.
Web API는 자바스크립트 런타임과 브라우저 기능 사이의 다리 역할을 한다. 덕분에 자바스크립트 자체의 능력을 넘어서는 정보와 기능에 접근할 수 있다.
좋다. 그러면 이게 non-blocking 작업과 무슨 관련이 있을까?
Web API 중 일부는 비동기 작업을 시작하고, 오래 걸리는 작업을 브라우저에 위임$_{offload}$할 수 있게 해준다.
이러한 API가 제공하는 메서드를 호출하는 것은 실제로 오래 걸리는 작업을 브라우저 환경에 위임하고, 해당 작업이 완료되었을 때를 처리하기 위한 핸들러를 설정하는 것이다.
비동기 작업을 시작한 후에는 (결과를 기다리지 않고) 실행 컨텍스트가 call stack에서 빠르게 제거된다. 바로 이것이 non-blocking이다!
비동기 기능을 제공하는 Web API는 콜백 기반 또는 프로미스 기반의 접근 방식을 사용한다.
먼저 콜백 방식에 대해 이야기해보자.
콜백 기반 API
Geolocation API를 예시로 살펴보자. 웹사이트에서 사용자의 위치 정보에 접근하고자 할 때, getCurrentPosition
메서드를 사용할 수 있다. 이 메서드는 두 개의 콜백을 받는다. 하나는 사용자의 위치 정보를 성공적으로 받았을 때 실행되는 successCallback
이고, 다른 하나는 문제가 발생했을 때 실행되는 선택적 errorCallback
이다.
이 함수를 호출하면 새로운 실행 컨텍스트가 call stack에 추가된다. 이는 단순히 콜백을 Web API에 '등록'하기 위한 것으로, 이후 실제 작업은 브라우저에 위임된다. 그 다음 함수는 call stack에서 제거되고, 이제 브라우저가 작업을 담당하게 된다. 브라우저는 백그라운드에서 사용자에게 위치 정보 접근 권한을 요청하는 프롬프트를 표시한다.
사용자가 언제 이 프롬프트에 응답할지는 알 수 없다. 어쩌면 다른 일에 정신이 팔려있거나, 팝업을 보지 못할 수도 있다. 하지만 상관 없다. 이 모든 과정이 백그라운드에서 실행되므로 call stack은 다른 작업을 계속해서 처리할 수 있다. 따라서 웹사이트는 계속해서 상호작용이 가능한 권한 알림 상태를 유지한다.
마침내 사용자가 위치 정보 접근을 허용하면, API는 브라우저로부터 데이터를 받고 successCallback
을 사용해 결과를 처리한다.
하지만 이 successCallback
을 곧바로 call stack에 추가할 수는 없다. 이미 실행 중인 작업이 있을 수 있기 때문에 예측할 수 없는 동작이나 충돌이 발생할 수 있기 때문이다. 자바스크립트 엔진은 한 번에 하나의 작업만 처리할 수 있고 이를 통해 예측 가능하고 체계적인 실행 환경을 보장한다.
태스크 큐
대신 successCallback
은 태스크 큐(이러한 이유로 콜백 큐라고도 불린다)에 추가된다. 태스크 큐는 미래의 특정 시점에 실행되기를 기다리는 Web API 콜백과 이벤트 핸들러들을 보관한다.
태스크 큐가 비동기 작업의 결과를 보관한다는 것은 알았다. 그렇다면 이제 남은 의문은 이것이다. 태스크 큐에 추가된 이 작업들은 도대체 언제 실행되는 걸까?
바로 이때 이벤트 루프가 등장한다. 이벤트 루프는 태스크 큐에 있는 작업들을 call stack으로 이동시키는 역할을 맡고 있다. 이 과정을 살펴보며 비동기 작업의 실행 시점에 대해 자세히 알아보자.
이벤트 루프
드디어 이벤트 루프를 살펴볼 차례다. 이벤트 루프는 call stack이 비어있는지 지속적으로 확인하는 역할을 한다. call stack이 비어있을 때, 즉 현재 실행 중인 작업이 없을 때 이벤트 루프는 태스크 큐에서 첫 번째로 사용 가능한 태스크를 가져와 call stack으로 이동시킨다. 이후 콜백이 실행된다.
이벤트 루프는 계속해서 call stack이 비어있는지 확인하고, 비어있다면 태스크 큐에서 첫 번째로 사용 가능한 태스크를 찾아 실행을 위해 call stack으로 이동시킨다.
한편, setTimeout
도 널리 사용되는 콜백 기반 Web API 중 하나다. setTimeout을 호출하면 함수 호출이 call stack에 추가되는데, 이는 단순히 지정된 지연 시간으로 타이머를 초기화하는 역할만 한다. 브라우저는 백그라운드에서 이러한 타이머들을 추적한다.
타이머가 만료되면 타이머의 콜백이 태스크 큐에 추가된다! 여기서 중요한 점은 지연 시간이 콜백이 call stack이 아닌 태스크 큐에 추가되는 시점을 지정한다는 것이다.
이는 실제 실행까지의 지연 시간이 setTimeout에 지정한 시간보다 더 길 수 있다는 것을 의미한다. call stack이 여전히 다른 작업을 처리하느라 바쁘다면, 콜백은 태스크 큐에서 대기해야 한다.
지금까지 콜백 기반 API가 어떻게 처리되는지 살펴봤다. 하지만 대부분의 최신 Web API는 프로미스 기반 방식을 사용하고, 예상했겠지만 이들은 다르게 처리된다.
여기서부터는 프로미스에 대한 기본적인 이해가 있다고 가정하고 진행하겠다. 프로미스에 대한 복습이 필요하다면 Lydia Hallie님의 프로미스 영상이 도움이 될 것이다.
마이크로태스크 큐
대부분의 최신 Web API는 콜백 대신 프로미스를 반환해 프로미스 체이닝(또는 await)을 통해 반환된 데이터를 처리할 수 있게 한다.
fetch("...")
.then(res => ...)
.catch(err => ...)
프로미스 핸들러로 데이터를 처리할 때는 마이크로태스크 큐를 사용한다!
마이크로태스크 큐$_{Microtask \space Queue}$는 런타임의 또 다른 큐로, 태스크 큐보다 더 높은 우선순위를 가진다. 이 큐는 다음과 같은 작업들을 전담한다.
- 프로미스 핸들러 콜백(
then(callback)
,catch(callback)
,finally(callback)
) await
이후async
함수 본문 실행MutationObserver
콜백queueMicrotask
콜백
call stack이 비어있을 때, 이벤트 루프는 태스크 큐로 넘어가기 전에 먼저 마이크로태스크 큐의 모든 마이크로태스크를 처리한다.
태스크 큐에서 하나의 태스크를 완료하고 call stack이 비워지면, 이벤트 루프는 다음 태스크로 넘어가기 전에 마이크로태스크 큐의 모든 마이크로태스크를 처리하면서 효과적으로 '새로 시작'한다.
이는 방금 완료된 태스크와 관련된 마이크로태스크가 즉시 처리되도록 보장해 프로그램의 응답성과 일관성을 유지한다.
마이크로태스크는 다른 마이크로태스크를 예약할 수도 있다. 이로 인해 무한한 마이크로태스크 루프가 생성되어 태스크 큐가 무기한 지연되고 프로그램의 나머지 부분이 멈출 수 있으므로 주의가 필요하다.
fetch
는 대표적인 프로미스 기반 API다. fetch
를 호출하면 실행 컨텍스트가 call stack에 추가된다.
fetch
를 호출하면 기본적으로 'pending' 상태인 프로미스 객체가 메모리에 생성된다. 네트워크 요청을 시작한 후 fetch
함수 호출은 call stack에서 제거된다.
엔진은 이제 연결된 then
핸들러를 만나는데, 이는 PromiseFulfillReactions
에 저장되는 PromiseReaction 레코드를 생성한다.
다음으로 console.log
가 call stack에 추가되어 'End of script'를 콘솔에 출력한다. 이 시점에서 네트워크 요청은 여전히 대기 중이다.
서버가 마침내 데이터를 반환하면 [[PromiseStatus]]
는 'fulfilled'로 설정되고 [[PromiseResult]]
는 Response
객체로 설정된다. 프로미스가 이행되면서 PromiseReaction이 마이크로태스크 큐에 추가된다.
call stack이 비어있을 때, 이벤트 루프는 마이크로태스크 큐에서 핸들러 콜백을 call stack으로 이동시키고, 여기서 실행되어 Response
객체를 로그로 출력한 후 최종적으로 call stack에서 제거된다.
모든 Web API가 비동기적으로 처리될까?
아니다. 비동기 작업을 시작하는 API만 그렇다.
`document.getElementById()`나 `localStorage.setItem()` 같은 다른 메서드들은 동기적으로 처리된다.
요약
지금까지 다룬 내용을 정리해보자.
- 자바스크립트는 싱글 스레드로, 한 번에 하나의 작업만 처리할 수 있다.
- Web API는 브라우저가 제공하는 기능들과 상호작용하는 데 사용된다. 이러한 API 중 일부는 백그라운드에서 비동기 작업을 시작할 수 있게 해준다.
- 비동기 작업을 시작하는 함수 호출은 Call stack에 추가되지만, 이는 단지 브라우저에 작업을 넘기기 위한 것이다. 실제 비동기 작업은 백그라운드에서 처리되며 Call stack에 남아있지 않는다.
- 태스크 큐는 콜백 기반 Web API가 비동기 작업이 완료되었을 때 콜백을 대기시키는 데 사용된다.
- 마이크로태스크 큐는 프로미스 핸들러,
await
이후의async
함수 본문,MutationObserver
콜백,queueMicrotask
콜백에 사용된다. 이 큐는 태스크 큐보다 우선순위가 높다. - Call stack이 비어있을 때 이벤트 루프는 먼저 마이크로태스크 큐가 완전히 비워질 때까지 작업을 이동시킨다. 그 다음 태스크 큐로 넘어가 첫 번째로 사용 가능한 태스크를 Call stack으로 이동시킨다. 첫 번째 사용 가능한 태스크를 처리한 후에는 다시 마이크로태스크 큐를 확인하며 '새로 시작'한다.
콜백 기반 API를 프로미스화하기
콜백 기반 Web API의 가독성을 높이고 비동기 작업의 흐름을 관리하기 위해, 이를 프로미스로 감싸는 방법을 사용할 수 있다.
예를 들어, Geolocation API의 콜백 기반 getCurrentPosition
메서드를 Promise
생성자로 감싸보자.
function getCurrentPosition() {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
}
이렇게 하면 더 나은 가독성과 async
/await
구문 사용 같은 프로미스의 모든 장점을 활용할 수 있다.
async function fetchAndLogCurrentPosition() {
try {
const position = await getCurrentPosition();
console.log(position);
} catch (error) {
console.log(error);
}
}
// function fetchAndLogCurrentPosition() {
// getCurrentPosition()
// .then((position) => console.log(position))
// .catch((error) => console.error(error));
// }
setTimeout을 사용해 타이머가 만료될 때까지 코드 블록의 실행을 지연시키는 프로미스 기반 타이머도 만들 수 있다.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function doStuff() {
// 작업 수행...
await delay(5000);
// 5초 후에 나머지 작업 수행
}
결론
이벤트 루프, 태스크 큐, 마이크로태스크 큐가 어떻게 함께 작동하는지 이해하는 것은 비동기적이고 non-blocking인 자바스크립트를 마스터하는 데 중요하다.
이벤트 루프는 작업의 실행을 조율하고 태스크 큐의 작업으로 넘어가기 전 마이크로태스크 큐를 우선시해 프로미스와 관련 작업들이 신속하게 해결되도록 보장한다. 이런 특성 덕분에 자바스크립트는 싱글 스레드 환경에서도 복잡한 비동기 동작을 처리할 수 있다.
references
https://www.yes24.com/Product/Goods/123161563
https://www.lydiahallie.com/blog/event-loop
https://www.youtube.com/watch?v=eiC58R16hb8&ab_channel=LydiaHallie
https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
'Web, Front-end > JavaScript' 카테고리의 다른 글
[JavaScript] V8 엔진 (1) | 2025.02.02 |
---|---|
[JavaScript] 함수 레벨 스코프와 블록 레벨 스코프 (1) | 2025.01.30 |
[JavaScript] 스코프 체인 (scope chain) (1) | 2025.01.26 |
[JavaScript] 스코프(scope)란 (0) | 2025.01.25 |
[Javascript] 이벤트 전파 (1) | 2024.08.27 |
컴퓨터 전공 관련, 프론트엔드 개발 지식들을 공유합니다. React, Javascript를 다룰 줄 알며 요즘에는 Typescript에도 관심이 생겨 공부하고 있습니다. 서로 소통하면서 프로젝트 하는 것을 즐기며 많은 대외활동으로 개발 능력과 소프트 스킬을 다듬어나가고 있습니다.
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!