JavaScript 이벤트 루프와 비동기 처리

우현민·2022년 2월 16일
5

javascript

목록 보기
1/5
post-thumbnail

비동기 처리는 Javascript 입문자들에게 대단히 어려운 주제들 중 하나이다. 싱글 스레드, 이벤트 루프, microtask 등등 처음에 이 개념들을 잡기는 쉽지 않다.

자바스크립트의 이벤트 루프 방식은 다른 언어들과 확연한 차이가 있다. 자바스크립트는 브라우저라는 복잡한 프로그램 위에서 돌아가고, 브라우저는 이미 상당히 고도화되어 하나의 OS에 견줄 정도이다.

물론 이제는 많은 좋은 레퍼런스들이 있어 사람들이 비동기에 대해 잘 이해하고 있기 때문에 조금 식상한 주제이겠으나 나름의 정리를 위해 작성해 보려 한다.

배경지식

알고 있다면 넘겨도 괜찮은 내용입니다.

요약

  1. JavaScript 런타임이 JavaScript를 돌린다.
  2. JavaScript 는 복잡한 작업보다는 DOM 조작을 위한 언어로 탄생했다.
  3. JavaScript 는 싱글스레드 언어이다.

JavaScript 런타임

런타임이란 프로그램이 돌아가는 장소이다. 자바로 치면 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

서술했듯 자바스크립트는 런타임에 강하게 종속된다. 런타임이 자바스크립트 코드에 직접 큰 영향을 미치는 것 중 하나가 Web API이며, 사실상 JS는 Web API를 이용하기 위해 탄생했다고도 볼 수 있다.

브라우저마다 내부 구현 방식은 각각 다르겠지만 JS 는 하나의 api를 이용해 브라우저를 제어할 수 있어야 한다. 가령 http 요청을 보내는 XMLHttpRequest 나 타이머 역할을 하는 setTimeout, 브라우저 창에 접근하는 window 등이 있다. 이런 작업들은 JS가 요청하면 브라우저가 하는 일이다.

  • 갑자기 이 얘기를 왜 하나 싶겠지만 "JavaScript가 동시적으로 처리하는 거 같아 보이는 건 외부의 누군가가 도와주기 때문이라는 것"을 이해할 때 꼭 필요하다.

이 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 가 실행되는 구조

JavaScript 의 실행 구조는 크게 3개의 파트로 나눌 수 있다.

  • Call Stack
  • Message Queue
  • Event Loop

Call Stack

Call Stack 은 실제로 수행되는 코드들의 호출 스택이다. 자바스크립트 전체 실행, 함수 실행, 콜백 수행 등, 모든 소스코드는 이 Call Stack 의 top 에 있어야 수행된다.

다른 언어를 해 본 사람이라면 익숙한 용어와 익숙한 동작일 테다. 맞다, 여러분이 이미 아는 그 얘기 할 거다. 아래 코드를 보자.

const foo = () => {
  console.log(1);
}

const bar = () => {
  console.log(2);
  foo();
  console.log(3);
}

bar();

위 코드는 다음과 같이 동작한다.

  1. 스크립트가 Call Stack의 맨 위에 올라가고, 실행되어 foobar을 선언한 다음 Call Stack에 bar을 push한다.
  2. 이제 Call Stack 의 top에는 bar 가 있다. bar가 실행되어 2를 출력한 다음 foo를 Call Stack에 push한다.
  3. 이제 Call Stack 의 top에는 foo 가 있다. foo가 실행되어 1을 출력한 다음, 할 게 끝났으므로 Call Stack에서 pop된다.
  4. 이제 Call Stack 의 top에는 다시 bar 가 있다. bar는 아직 할 일이 남아 있다. 3을 출력한 다음 Call Stack에서 pop된다.
  5. 이제 Call Stack 의 top에는 스크립트만 남아 있다. 스크립트가 끝났으니, Call Stack은 비워지고 메인 스레드는 할 일을 마친다.

설명이 길어서 오히려 어렵고 복잡해 보일 수도 있으나 매우 자연스럽고 익숙하고 흔히 보던 동작이다. 사실 Message Queue와 Event Loop에 대한 설명이 없기 때문에 정확한 설명은 아니다. 일단은 이렇게 읽어 두고, Call Stack 이 어떤 건지만 확실히 이해한 다음, 다음 파트로 넘어가도록 하자.

아무튼 여기까지는 아무 문제도 특별함도 없다. JS의 비동기 처리의 마법은 나머지 둘에 의해 완성된다.

엄밀히는 콜 스택을 이야기할 때 실행 컨텍스트에 대한 이야기를 빼놓을 수는 없습니다. 하지만 이번 포스트에서는 적당히 추상화해서 넘어가도록 하겠습니다.

Message Queue

Message Queue 는 처리할 일 (message)의 목록이 보관되는 공간이다. 몇몇 작업들은 위의 예시처럼 즉시 Call Stack 에 올라가지 않고, 어떠한 과정을 통해 Message Queue 에 들어와 있다가 때가 되면 수행된다.

이렇게 message 의 형태로 Message Queue 에 들어가는 작업들은 크게 다음과 같은 종류가 있다.

  • 브라우저가 Web API 의 수행 결과 등으로 인해 넣을 경우
    • api 요청
    • 파일 읽기
  • Promise 등을 이용하여 소스코드에서 명시적으로 넣을 경우
    • 정상적인 상황에서 이런 경우는 흔하지 않다.

즉, 일반적인 상황에서는 "오래 걸릴 것으로 예상되어 계속 Call Stack을 점유하며 대기하기는 부담스러운 일" 이 Message Queue 에 들어온다는 것을 알 수 있다. 이런 의미를 가지기 때문에, Message Queue는 Call Stack보다 우선순위가 무조건 낮다.

서술했듯 Message Queue에 message 를 넣는 주체는 대부분의 상황에서 브라우저이다. 즉 일반적인 flow는 (1) 소스코드가 브라우저한테 뭔가를 요청 -> (2) 브라우저가 수행 -> (3) 결과를 message queue 에 집어넣음 이다.

message는 말 그대로 "처리할 일"이다. 작업을 처리하라는 말과 함께 (필요하다면) 필요한 자원이 들어 있을 것이다. message가 정확히 어떻게 생겼는지는 아쉽게도 찾지 못했다. (혹시 아시는 분 계시다면 댓글로 알려주시면 감사하겠습니다)

사실 Message Queue 는 한 개의 큐가 아니다. Task (또는 MacroTask) QueueJob (또는 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. Queue 에 위 코드를 수행하라는 message 가 들어간다.
  2. message가 수행되어 queue에서 빠져나오고, 코드가 실행된다.
    1. 1을 출력한다.
    2. setTimeout 을 호출하며 콜백을 넣어준다.
      • 이건 브라우저가 할 일이다.
    3. 3을 출력한다.
  3. 2-2번에서 브라우저가 Web API인 setTimeout을 수행하도록 전달받았다. 브라우저가 이 API에 맞춰 3초를 기다린 다음 콜백을 Message Queue에 넣어준다.
    • 서술했듯 자바스크립트 런타임이 싱글 스레드로 자바스크립트 코드를 돌리는 거지, 브라우저가 싱글 스레드로 돌아가는 건 아니다. 브라우저는 많은 스레드를 유지할 수 있기 때문에 자바스크립트 런타임과 별개로 타이머를 실행할 수 있다.

이 설명 역시 Event Loop 가 빠져 있었기 때문에 아직 정확한 설명은 아니다. 아까처럼, 일단은 이렇게 읽어 두고 다음으로 넘어가자.

이 Message Queue 개념은 조금 생소할 수 있다. 멀티 스레드 언어를 공부했던 사람이라면 나중에 수행해야 할 코드는 새 스레드를 파서 대기시키는 것이 일반적으로 느껴질 테다. 하지만 자바스크립트 엔진은 같은 스레드가 점유하는 공간의 큐에 이걸 넣는 방법을 채택했다.

사실 여기서 비동기 처리의 마법이 90% 이상 밝혀진다. 아하, 스레드를 파서 기다리고 받는 대신 Queue를 열어 놓고 Queue로 받는 거구나

Message Queue에 있는 걸 어떻게 다시 Call Stack으로 가져와서 수행하는지는 아직 설명하지 않았다. 이제 마지막, Event Loop 에 대한 설명으로 넘어가 보자.

Event Loop

이벤트 루프는 아래 코드처럼 구현되어 있다고 생각하면 된다. (출처: MDN)

while (queue.waitForMessage()) {
  queue.processNextMessage()
}

실제 V8 엔진에는 이렇게 구현되어 있다.

일반적으로 "이벤트 큐는 콜 스택과 메세지 큐를 확인하며 콜 스택이 비어 있고 메세지 큐에 메세지가 있으면 메세지를 꺼내서 콜 스택에 넣는다" 라고 표현한다. 틀린 설명은 아니지만, 그렇게 보면 마치 이벤트 루프가 하나의 객체인 것처럼 읽힌다.

코드를 보면 알겠지만 이벤트 루프는 그냥 무한히 반복되는 반복문이다. 그러니까, 실제로는 따로 실체가 존재하는 객체가 아니고 JS 런타임이 하는 동작이며 JS 런타임의 구현 방식이다.

JavaScript 런타임은 "이벤트 루프 방식에 따라" 매 틱마다 큐를 확인한다. 큐에 작업이 없다면 대기하고, 큐에 작업이 있다면 콜 스택으로 끌고 와서 수행한다. (엄밀히는 수행하니까 콜 스택으로 끌고 와진다는 표현이 맞다)

매 틱마다 큐를 확인하고 작업이 있으면 콜 스택으로 데려오는 방식이 바로 이벤트 루프이다.

표현과 관점에 따라 다르겠지만, 실제로 Event Looper 같은 건 없다.

흔히 할 수 있는 오해가 있어 짚어보자면

  • Queue에 있는 걸 Call Stack으로 끌고 와서 수행하는 건 당연하게도 이벤트 루프가 아닌 JavaScript 런타임이 가지고 있는 메인 스레드이다. Event Loop은 실체가 없고, JavaScript 런타임이 수행하는 반복적인 작업이다.
  • 많은 경우 JavaScript 런타임의 인생 대부분은 작업 수행시간이 아닌 이벤트 루프를 대기 상태로 반복하는 시간일 것이다. 사용자는 일반적으로 페이지를 읽고 있을 테고, 그동안 자바스크립트 코드가 계속 실행되고 있진 않다. 웬만하면 자바스크립트 코드는 이벤트 발생 시 눈 깜짝할 새 실행된다.
    • 물론 콜 스택이 오래 살아있을 수도 있다. n = 10000 으로 두고 O(n^3) 정도 연산을 시키면 콜 스택이 오랫동안 살아 있을 것이다. 그럼 브라우저는 그 동안 다른 어떤 JS 코드도 실행하지 못한다. 다시 말해 불가능한 건 아닌데, 그런 일이 일어났다면 그렇게 디자인한 개발자의 잘못이 아닐까?

아무튼 앞의 정보들을 종합하여 아래 코드가 어떻게 돌아가는지 정리해 보자. 기다리고 기다리던 "옳은 설명"이다.

console.log(1);

setTimeout(() => {
  console.log(2);
}, 3000);
           
console.log(3);
  1. JavaScript 런타임은 이벤트 루프를 돌리며, 처음에 Queue가 비어 있으니 "대기"하고 있다.
  2. 외부 요인에 의해 (아마도 이벤트 발생, javascript 초기 로딩 등) Message Queue 에 위 코드를 수행하라는 작업이 들어온다.
  3. JavaScript 런타임이 이벤트 루프를 통해 이를 인식하고 해당 작업을 수행한다. 이를 위해 Call Stack에 작업을 집어넣고 Queue에서 해당 작업을 pop한다.
  4. 런타임이 현재 Call Stack의 top에 있는 작업을 수행한다.
    1. 코드의 1번 줄에 따라 1을 출력한다.
    2. 코드의 3번 줄에 따라 Web API 인 setTimeout 을 호출한다.
    3. 코드의 7번 줄에 따라 3을 출력한다.
  5. 콜 스택에 있던 작업 수행을 끝냈고, 큐는 비어 있으니 이벤트 루프가 다시 대기 상태로 돌아간다.
  6. 4-3번과 5번이 실행되는 동안, 브라우저는 별도의 스레드에서 타이머를 돌리고 있었을 테다. 그리고 3초가 지나 Message Queue에 해당 콜백을 수행하라는 작업을 push한다.
  7. JavaScript 런타임이 이벤트 루프를 통해 이를 인식하고 해당 작업을 수행한다. 이를 위해 Call Stack에 작업을 집어넣고 Queue에서 해당 작업을 pop한다.
  8. 코드의 4번 줄에 따라 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. 이벤트 루프 대기 상태
  2. 코드 실행 명령이 Message Queue 로 들어와서 코드를 Call Stack에 올리고 실행
    • Call Stack 상태: [코드]
    • Job Queue 상태: []
    • Task Queue 상태: []
    • 콘솔 상태:
  3. 순서대로 1 출력, foo 정의, 2 출력, printPromise 정의
  4. Promise.resolve().then() 에 의해 Job QueueprintPromise 수행하라는 message가 가 push됨 (Promise.then은 Microtask이다)
    • Call Stack 상태: [코드]
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: []
    • 콘솔 상태: 1 2
  5. bar 정의
  6. bar이 실행되어 Call Stack에 push됨
    • Call Stack 상태: [코드, bar]
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: []
    • 콘솔 상태: 1 2
  7. bar을 출력하고 foo를 실행하여 Call Stack에 push
    • Call Stack 상태: [코드, bar, foo]
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: []
    • 콘솔 상태: 1 2 bar
  8. foo는 foo를 출력하고 pop되고, 이어서 bar도 pop됨
    • Call Stack 상태: [코드]
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: []
    • 콘솔 상태: 1 2 bar foo
  9. printSetTimeout 정의
  10. setTimeout 에 의해 브라우저가 0초 대기한 후 Task QueueprintSetTimeout을 수행하라는 message를 푸시 (setTimeout은 Macrotask이다)
    • Call Stack 상태: [코드]
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: [printSetTimeout]
    • 콘솔 상태: 1 2 bar foo
  11. 3 출력 후 Call Stack pop
    • Call Stack 상태: []
    • Job Queue 상태: [printPromise]
    • Task Queue 상태: [printSetTimeout]
    • 콘솔 상태: 1 2 bar foo 3
  12. 드디어 Call Stack이 비었으므로, 런타임은 우선순위가 높은 Job Queue를 먼저 확인한다. Job Queue에 message가 있으므로 Call Stack에 올린다.
    • Call Stack 상태: [printPromise]
    • Job Queue 상태: []
    • Task Queue 상태: [printSetTimeout]
    • 콘솔 상태: 1 2 bar foo 3
  13. Call Stack에 뭐가 있으므로 런타임은 Call Stack에 있는 걸 실행하고, 실행이 끝나면 pop한다.
    • Call Stack 상태: []
    • Job Queue 상태: []
    • Task Queue 상태: [printSetTimeout]
    • 콘솔 상태: 1 2 bar foo 3 Promise
  14. Call Stack이 비었으므로, 런타임은 우선순위가 높은 Job Queue를 먼저 확인한다. Jop Queue도 비었으므로 다음으로 Task Queue를 확인한다. message가 있으므로 Call Stack에 올린다.
    • Call Stack 상태: [printSetTimeout]
    • Job Queue 상태: []
    • Task Queue 상태: []
    • 콘솔 상태: 1 2 bar foo 3 Promise
  15. Call Stack에 뭐가 있으므로 런타임은 Call Stack에 있는 걸 실행하고, 실행이 끝나면 pop한다.
    • Call Stack 상태: []
    • Job Queue 상태: []
    • Task Queue 상태: []
    • 콘솔 상태: 1 2 bar foo 3 Promise setTimeout

앞서 맨 처음에 말했던 런타임에 따른 사소한 동작 차이가 여기서도 등장한다. 브라우저들마다 Task Queue 와 Job Queue에 작업을 집어넣고 꺼내서 수행하는 순서들이 조금씩 차이가 생길 때가 있다고 한다. 물론 사용자에게 영향이 가는 큰 차이는 딱히 없는 듯 하다.



결론

자바스크립트 런타임은 실제로 싱글 스레드로 동작하며 이벤트 루프를 돌린다. 이벤트 루프는 큐에 뭔가가 들어오기를 기다리다가, 뭔가 들어오면 수행하는 구조이다.

"느린 작업을 기다리는 문제"는 큐를 통해 해결된다. 느린 작업을 수행하는 주체는 일반적으로 자바스크립트가 아닌, 멀티 스레드가 가능한 브라우저 등 자바스크립트 밖의 것이다. (가령 리스폰스를 기다린다거나, 파일을 읽는다거나) 자바스크립트 비동기의 신비는 그 느린 작업을 어떻게 기다리고 있는가이며, 그 신비는 이벤트 루프를 통해 구현된다.

항상 헷갈리던 내용인데 이번 기회에 잘 정리할 수 있었네요 :) 이 글을 읽는 분들께 도움이 되길 바랍니다.

참고자료

profile
프론트엔드 개발자입니다

1개의 댓글

comment-user-thumbnail
2022년 7월 23일

이벤트 루프가 실체가 존재하지 않았다니.. 그동안 완전 잘못 알고 있었네요 감사합니다!

답글 달기