비동기 처리는 Javascript 입문자들에게 대단히 어려운 주제들 중 하나이다. 싱글 스레드, 이벤트 루프, microtask 등등 처음에 이 개념들을 잡기는 쉽지 않다.
자바스크립트의 이벤트 루프 방식은 다른 언어들과 확연한 차이가 있다. 자바스크립트는 브라우저라는 복잡한 프로그램 위에서 돌아가고, 브라우저는 이미 상당히 고도화되어 하나의 OS에 견줄 정도이다.
물론 이제는 많은 좋은 레퍼런스들이 있어 사람들이 비동기에 대해 잘 이해하고 있기 때문에 조금 식상한 주제이겠으나 나름의 정리를 위해 작성해 보려 한다.
알고 있다면 넘겨도 괜찮은 내용입니다.
요약
런타임이란 프로그램이 돌아가는 장소이다. 자바로 치면 JRE가 있다. 자바스크립트 런타임의 대표적인 예로는 브라우저와 NodeJS 가 있다. 런타임은 언어로 된 프로그램을 실제로 동작시키는 역할을 한다.
JavaScript (약칭 JS) 는 1995년 넷스케이프에 재직 중이던 브랜든 아이크 가 넷스케이프 네비게이터 라는 브라우저에서 돔을 조작하기 위해 개발한 언어이다.
지금이야 브라우저도 다양해졌고 NodeJS같은 비 브라우저 런타임도 생겨나며 JS가 거대한 생태계를 가진 Programming Language 의 역할을 하고 있으나, 분명 초기 JS의 목적은 "자기들이 만든 브라우저에서" "브라우저를 위해" 일하는 것이었다. 자연스레 JS는 브라우저 환경이라는 특별한 런타임에서 JS 엔진을 통해 돌아가는 언어로 시작하였고, 런타임과 엔진에 대단히 많은 의존성을 가지게 되었다.
이와 같은 특징 때문에 JS는 런타임과 엔진에 따라 서로 다른 동작을 한다. 가령 크롬 브라우저가 사용하는 JS 엔진인 V8
에서, 파이어폭스가 사용하는 JS 엔진인 SpiderMonkey
에서 동작이 다르고, 엔진이 같아도 런타임이 다른 NodeJS
와 크롬 브라우저 환경 에서도 동작이 다르다. 버그일 수도 있고, 실제로 다르게 구현된 거일 수도 있고.
본 포스트는 JS 엔진 중 가장 유명한 V8 엔진과 V8 엔진을 사용하는 런타임 환경을 가진 브라우저 중 가장 유명한 크롬 브라우저 환경을 가정하여 작동을 설명한다. 하지만 서술했듯이 JS 는 런타임의 영향을 많이 받기 때문에 다른 브라우저나 NodeJS에서는 본 포스트와는 다른 차이가 생길 수 있다. 특히 설명에 있어 NodeJS 환경은 전혀 고려하지 않았다.
서술했듯 자바스크립트는 런타임에 강하게 종속된다. 런타임이 자바스크립트 코드에 직접 큰 영향을 미치는 것 중 하나가 Web API
이며, 사실상 JS는 Web API를 이용하기 위해 탄생했다고도 볼 수 있다.
브라우저마다 내부 구현 방식은 각각 다르겠지만 JS 는 하나의 api를 이용해 브라우저를 제어할 수 있어야 한다. 가령 http 요청을 보내는 XMLHttpRequest
나 타이머 역할을 하는 setTimeout
, 브라우저 창에 접근하는 window
등이 있다. 이런 작업들은 JS가 요청하면 브라우저가 하는 일이다.
이 API들은 대부분의 상황에서 JS에 의해 이용되지만 꼭 JS만을 위한 api는 아니다.
Web API의 목록은 여기 서 확인할 수 있다.
여담으로 브라우저마다 제공하는 Web API 목록이 조금씩 차이가 있기 때문에 caniuse 같은 서비스도 생겨나게 되었다.
JavaScript 는 런타임 위에서 싱글 스레드 언어로 동작한다. 즉 하나의 런타임은 "하나의 시점"에 "하나의 동작" 만 할 수 있다.
물론 이는 JavaScript 런타임이 그렇다는 거지 브라우저 자체가 싱글 스레드로 되어 있다는 것은 아니다. 우리는 한 번에 여러 탭을 이용해서 인터넷 서핑을 할 수 있고, 자바스크립트가 돌아가는 중에 개발자 도구를 열 수도 있고, 등등 많은 작업을 할 수 있다. 다만 하나의 웹 어플리케이션을 돌리고 있는 하나의 JavaScript 런타임은 분명 싱글 스레드로 동작하므로 하나의 시점에 하나의 동작만 할 수 있다. 그러니까, 우리가 보고 있는 페이지의 자바스크립트는 분명 싱글 스레드로 돌아가고 있다.
JavaScript 가 싱글 스레드로 동작하는 이유는 "쉽기 때문" 이라는 게 정론이다. 동시성은 그 자체로도 어려운 주제이고 프로그래머에게 멀티 스레드는 race, deadlock 등 고려해야 할 사항이 많아지는 골치 아픈 친구다. 심지어 DOM만 조작하면 되므로 성능에 대한 부분도 아주 큰 이슈가 아니다. JavaScript 가 만들어질 당시의 배경을 생각해 보면, 싱글 스레드로 디자인된 이유가 납득이 된다.
이제 우리의 주제로 돌아와 보자. JavaScript는 비동기 처리를 어떻게 할까?
먼저 동시성에 대한 당연한 이야기를 정리하고 넘어가자. 사용자가 브라우저에서 인스타그램을 하고 있다고 가정하자. 브라우저는 서버로 홈 화면의 피드를 불러오는 request 를 보냈을 것이다. 이 request 에 대한 응답이 돌아오는 동안에도 사용자는 웹 페이지를 조작할 수 있다. 가령 원래 보고 있던 게시글에 댓글을 작성할 수 있고, 작성한 댓글을 또 보낼 수 있다.
멀티 스레드 언어라면 이 과정이 자연스러웠을 것이다. request를 보낸 다음 응답을 기다리는 스레드 하나, 댓글을 작성하는 동안 ui를 업데이트하는 스레드 하나, 작성한 댓글을 서버로 보내고 응답을 기다리는 스레드 하나. 하지만, 앞에서 분명 JavaScript는 싱글 스레드에서 돌아간다고 했다. 싱글 스레드 언어라면 request에 대한 응답이 돌아올 때까지 아무런 자바스크립트 코드가 돌아가지 않는 게 정상이다.
좀더 구체적인 예시를 위해 아래 코드를 보자.
console.log(1);
setTimeout(() => {
console.log(2);
}, 3000);
console.log(3);
위 코드를 실행해 보면 1과 3을 출력한 다음, 3초 후에 2를 출력한다. 그러니까 1
을 출력하고, setTimeout
을 실행했는데, 어떤 마법같은 일이 일어나서 3
이 먼저 출력이 되었다. 여기까진 납득 가능하다, 그런 다음에 2
는 3초 뒤에 대체 어떻게 출력되었을까? 외톨이 메인 스레드는 분명 console.log(3)
을 실행한 다음 쉬러 갔을 텐데, 대체 누가 메인 스레드한테 () => console.log(2)
라는 콜백을 실행하라고 전달해 준 걸까?
이제 JavaScript와 JavaScript 런타임이 어떤 꼼수를 썼길래 싱글 스레드인데도 동시성을 지원할 수 있는 건지 알아보자.
JavaScript 의 실행 구조는 크게 3개의 파트로 나눌 수 있다.
Call Stack
은 실제로 수행되는 코드들의 호출 스택이다. 자바스크립트 전체 실행, 함수 실행, 콜백 수행 등, 모든 소스코드는 이 Call Stack 의 top 에 있어야 수행된다.
다른 언어를 해 본 사람이라면 익숙한 용어와 익숙한 동작일 테다. 맞다, 여러분이 이미 아는 그 얘기 할 거다. 아래 코드를 보자.
const foo = () => {
console.log(1);
}
const bar = () => {
console.log(2);
foo();
console.log(3);
}
bar();
위 코드는 다음과 같이 동작한다.
foo
와 bar
을 선언한 다음 Call Stack에 bar
을 push한다.2
를 출력한 다음 foo
를 Call Stack에 push한다.1
을 출력한 다음, 할 게 끝났으므로 Call Stack에서 pop된다.3
을 출력한 다음 Call Stack에서 pop된다.설명이 길어서 오히려 어렵고 복잡해 보일 수도 있으나 매우 자연스럽고 익숙하고 흔히 보던 동작이다. 사실 Message Queue와 Event Loop에 대한 설명이 없기 때문에 정확한 설명은 아니다. 일단은 이렇게 읽어 두고, Call Stack 이 어떤 건지만 확실히 이해한 다음, 다음 파트로 넘어가도록 하자.
아무튼 여기까지는 아무 문제도 특별함도 없다. JS의 비동기 처리의 마법은 나머지 둘에 의해 완성된다.
엄밀히는 콜 스택을 이야기할 때 실행 컨텍스트에 대한 이야기를 빼놓을 수는 없습니다. 하지만 이번 포스트에서는 적당히 추상화해서 넘어가도록 하겠습니다.
Message Queue
는 처리할 일 (message
)의 목록이 보관되는 공간이다. 몇몇 작업들은 위의 예시처럼 즉시 Call Stack 에 올라가지 않고, 어떠한 과정을 통해 Message Queue 에 들어와 있다가 때가 되면 수행된다.
이렇게 message
의 형태로 Message Queue 에 들어가는 작업들은 크게 다음과 같은 종류가 있다.
즉, 일반적인 상황에서는 "오래 걸릴 것으로 예상되어 계속 Call Stack을 점유하며 대기하기는 부담스러운 일" 이 Message Queue 에 들어온다는 것을 알 수 있다. 이런 의미를 가지기 때문에, Message Queue는 Call Stack보다 우선순위가 무조건 낮다.
서술했듯 Message Queue에 message 를 넣는 주체는 대부분의 상황에서 브라우저이다. 즉 일반적인 flow는 (1) 소스코드가 브라우저한테 뭔가를 요청 -> (2) 브라우저가 수행 -> (3) 결과를 message queue 에 집어넣음 이다.
message
는 말 그대로 "처리할 일"이다. 작업을 처리하라는 말과 함께 (필요하다면) 필요한 자원이 들어 있을 것이다. message
가 정확히 어떻게 생겼는지는 아쉽게도 찾지 못했다. (혹시 아시는 분 계시다면 댓글로 알려주시면 감사하겠습니다)
사실 Message Queue 는 한 개의 큐가 아니다.
Task (또는 MacroTask) Queue
와Job (또는 MicroTask) Queue
두 개로 구성되며 Job Queue 가 Task Queue 보다 더 높은 우선순위를 가진다. Task Queue 에는setTimeout
이나XMLHttpReqeust
가 넣어주는 비교적 급하진 않은 작업이 들어가고, Job Queue 에는Promise
의 콜백이나Mutation Observer
가 넣어주는 Call Stack이 비면 바로 실행해야 할 비교적 급한 작업이 들어간다. 편의를 위해 이 포스트에서는 둘을 합쳐서Message Queue
혹은 간단하게Queue
라고 표현한다. 자세한 내용은 이 블로그 를 참고하자.
너무 개념적인 설명이었는데 이제 코드를 한번 보자.
console.log(1);
setTimeout(() => {
console.log(2);
}, 3000);
console.log(3);
이 코드가 실행되면 다음과 같은 일이 일어난다.
1
을 출력한다.setTimeout
을 호출하며 콜백을 넣어준다.3
을 출력한다.setTimeout
을 수행하도록 전달받았다. 브라우저가 이 API에 맞춰 3초를 기다린 다음 콜백을 Message Queue에 넣어준다.이 설명 역시 Event Loop 가 빠져 있었기 때문에 아직 정확한 설명은 아니다. 아까처럼, 일단은 이렇게 읽어 두고 다음으로 넘어가자.
이 Message Queue 개념은 조금 생소할 수 있다. 멀티 스레드 언어를 공부했던 사람이라면 나중에 수행해야 할 코드는 새 스레드를 파서 대기시키는 것이 일반적으로 느껴질 테다. 하지만 자바스크립트 엔진은 같은 스레드가 점유하는 공간의 큐에 이걸 넣는 방법을 채택했다.
사실 여기서 비동기 처리의 마법이 90% 이상 밝혀진다. 아하, 스레드를 파서 기다리고 받는 대신 Queue를 열어 놓고 Queue로 받는 거구나
Message Queue에 있는 걸 어떻게 다시 Call Stack으로 가져와서 수행하는지는 아직 설명하지 않았다. 이제 마지막, Event Loop 에 대한 설명으로 넘어가 보자.
이벤트 루프는 아래 코드처럼 구현되어 있다고 생각하면 된다. (출처: MDN)
while (queue.waitForMessage()) {
queue.processNextMessage()
}
실제 V8 엔진에는 이렇게 구현되어 있다.
일반적으로 "이벤트 큐는 콜 스택과 메세지 큐를 확인하며 콜 스택이 비어 있고 메세지 큐에 메세지가 있으면 메세지를 꺼내서 콜 스택에 넣는다" 라고 표현한다. 틀린 설명은 아니지만, 그렇게 보면 마치 이벤트 루프가 하나의 객체인 것처럼 읽힌다.
코드를 보면 알겠지만 이벤트 루프는 그냥 무한히 반복되는 반복문이다. 그러니까, 실제로는 따로 실체가 존재하는 객체가 아니고 JS 런타임이 하는 동작이며 JS 런타임의 구현 방식이다.
JavaScript 런타임은 "이벤트 루프 방식에 따라" 매 틱마다 큐를 확인한다. 큐에 작업이 없다면 대기하고, 큐에 작업이 있다면 콜 스택으로 끌고 와서 수행한다. (엄밀히는 수행하니까 콜 스택으로 끌고 와진다는 표현이 맞다)
이 매 틱마다 큐를 확인하고 작업이 있으면 콜 스택으로 데려오는 방식이 바로 이벤트 루프이다.
표현과 관점에 따라 다르겠지만, 실제로 Event Looper 같은 건 없다.
흔히 할 수 있는 오해가 있어 짚어보자면
아무튼 앞의 정보들을 종합하여 아래 코드가 어떻게 돌아가는지 정리해 보자. 기다리고 기다리던 "옳은 설명"이다.
console.log(1);
setTimeout(() => {
console.log(2);
}, 3000);
console.log(3);
1
을 출력한다.setTimeout
을 호출한다.3
을 출력한다.2
를 출력한다.아래 코드는 정확하게 어떻게 동작할까? 코드가 복잡한 만큼 위의 설명보다는 조금 더 약식으로 작성할 예정이다.
console.log(1);
const foo = () => {
console.log('foo');
}
console.log(2);
function printPromise() {
console.log('Promise');
}
Promise.resolve().then(printPromise);
const bar = () => {
consolg.log('bar');
foo();
}
bar();
function printSetTimeout() {
console.log('setTimeout');
}
setTimeout(printSetTimeout, 0);
console.log(3);
[코드]
[]
[]
1
출력, foo
정의, 2
출력, printPromise
정의Promise.resolve().then()
에 의해 Job Queue
에 printPromise
수행하라는 message가 가 push됨 (Promise.then
은 Microtask이다)[코드]
[printPromise]
[]
1 2
bar
정의bar
이 실행되어 Call Stack에 push됨[코드, bar]
[printPromise]
[]
1 2
bar
을 출력하고 foo
를 실행하여 Call Stack에 push[코드, bar, foo]
[printPromise]
[]
1 2 bar
foo
를 출력하고 pop되고, 이어서 bar
도 pop됨[코드]
[printPromise]
[]
1 2 bar foo
printSetTimeout
정의setTimeout
에 의해 브라우저가 0초 대기한 후 Task Queue
에 printSetTimeout
을 수행하라는 message를 푸시 (setTimeout
은 Macrotask이다)[코드]
[printPromise]
[printSetTimeout]
1 2 bar foo
3
출력 후 Call Stack pop[]
[printPromise]
[printSetTimeout]
1 2 bar foo 3
[printPromise]
[]
[printSetTimeout]
1 2 bar foo 3
[]
[]
[printSetTimeout]
1 2 bar foo 3 Promise
[printSetTimeout]
[]
[]
1 2 bar foo 3 Promise
[]
[]
[]
1 2 bar foo 3 Promise setTimeout
앞서 맨 처음에 말했던 런타임에 따른 사소한 동작 차이가 여기서도 등장한다. 브라우저들마다 Task Queue 와 Job Queue에 작업을 집어넣고 꺼내서 수행하는 순서들이 조금씩 차이가 생길 때가 있다고 한다. 물론 사용자에게 영향이 가는 큰 차이는 딱히 없는 듯 하다.
자바스크립트 런타임은 실제로 싱글 스레드로 동작하며 이벤트 루프를 돌린다. 이벤트 루프는 큐에 뭔가가 들어오기를 기다리다가, 뭔가 들어오면 수행하는 구조이다.
"느린 작업을 기다리는 문제"는 큐를 통해 해결된다. 느린 작업을 수행하는 주체는 일반적으로 자바스크립트가 아닌, 멀티 스레드가 가능한 브라우저 등 자바스크립트 밖의 것이다. (가령 리스폰스를 기다린다거나, 파일을 읽는다거나) 자바스크립트 비동기의 신비는 그 느린 작업을 어떻게 기다리고 있는가이며, 그 신비는 이벤트 루프를 통해 구현된다.
항상 헷갈리던 내용인데 이번 기회에 잘 정리할 수 있었네요 :) 이 글을 읽는 분들께 도움이 되길 바랍니다.
이벤트 루프가 실체가 존재하지 않았다니.. 그동안 완전 잘못 알고 있었네요 감사합니다!