자바스크립트의 이벤트 루프

kimgho
JavaScript
자바스크립트의 이벤트 루프

JavaScript는 흔히들 **"싱글 스레드"**라고 합니다. 그런데 사용해보면 "싱글"이라는 말 치곤 동시에 여러 작업을 처리하는 것처럼 보입니다. 예를 들어 비동기 작업과 Promise, setTimeout같은 것들이 묶여있는 코드를 만나면 어떤 순서로 실행되는 건지 이해가 안 될때가 있습니다. 이런 상황을 해결하는 방법은 바로 이벤트 루프(Event Loop) 에 대해 아는 것입니다. 이번 글에서 Block/Non-Block, 이벤트 루프의 동작 방식에 대해 알아보겠습니다.


하지만 그 전에 **스레드(Thread)**가 무엇이고, 싱글 스레드(Single Thread)멀티 스레드(Multi Thread) 에 대해 간단하게 알아보겠습니다.

스레드(Thread)

**스레드(Thread)**란 프로세스(Process) 내에서 실행되는 작업의 최소 단위입니다. 하나의 프로세스는 최소 하나 이상의 스레드를 가지고 있으며, 여러 스레드가 프로세스의 자원을 공유하면서 독립적으로 실행됩니다.

즉, 스레드는 같은 프로세스 안에서 실행되는 독립적인 작업 흐름이며, 하나의 스레드만 사용해서 코드를 실행한다면 싱글 스레드, 여러 개의 스레드가 동시에 실행되면 멀티스레드(Multi-Thread) 환경이 되는 것이죠.

그런데 싱글 스레드라면 하나의 작업이 끝날 때까지 다른 작업은 기다려야 할 것 같은데, 우리가 웹 페이지에서 파일을 다운로드하면서도 버튼을 클릭하거나, 스크롤을 내릴 수 있는 이유는 무엇일까요?

이처럼 싱글 스레드 환경에서도 동시에 여러 작업을 처리하는 것처럼 보이게 하는 비밀이 바로 다음에 설명할 이벤트 루프(Event Loop) 덕분입니다.


블로킹-논블로킹(Blocking/Non-Blocking)

앞서 싱글 스레드 환경에서는 하나의 작업이 끝나야 다음 작업을 실행할 수 있다고 했습니다. 하지만 실제로는 파일을 다운로드하거나, 서버로부터 데이터를 가져오는 동안에도 버튼을 클릭하거나 스크롤을 내릴 수 있었죠.

이런 상황을 가능하게 하는 핵심 개념이 바로 블로킹(Blocking)논블로킹(Non-Blocking) 입니다. 이 개념을 먼저 이해해야 이벤트 루프(Event Loop) 가 왜 필요한지, 어떻게 동작하는지 자연스럽게 이해할 수 있습니다.

블로킹이란

어떤 작업을 요청했을 때, 해당 작업이 끝날 때까지 다음 코드를 실행하지 않고 기다리는 방식입니다. 파일 읽기를 예시로 들어보겠습니다.

  • 파일 읽기를 시작합니다.
  • 파일을 전부 읽을 때까지 다른 작업은 못하고 대기합니다.
  • 파일을 다 읽은 뒤에 다음 코드를 실행합니다.

즉, 이전 작업이 끝나야 다음 작업을 실행할 수 있습니다. 실생활에서 비유를 해보자면 카페에서 커피를 주문하고 커피가 나올 때까지 계속 카운터 앞에서 기다리는 것이라고 볼 수 있습니다.

기다리는 동안 다른 일을 전혀 할 수 없겠죠.

논블로킹이란

어떤 작업을 요청해도, 그 작업이 끝날 때까지 기다리지 않고 다음 코드를 바로 실행하는 방식입니다. 파일 읽기를 또 예시로 들어보겠습니다.

  • 파일 읽기를 시작합니다.
  • 하지만 파일을 전부 읽을 때까지 기다리지 않고 바로 다음 작업을 실행합니다.
  • 파일 읽기가 끝나면, 등록한 Callback함수Promise로 후속 작업을 합니다.

즉, 작업이 끝나는 것을 기다리지 않고 다음 작업으로 넘어갈 수 있습니다. 이는 카페에서 커피를 주문하고 커피가 나오기 전 자리를 잡고 할 일을 준비하고, 진동벨이 울리면 커피를 받으러 가는 것입니다.

기다리는 동안 자리잡고 세팅하는 등 다른 작업을 할 수 있죠.

흐름을 그림으로 나타내면 아래와 같습니다.

이제 스레드와 블로킹/논블로킹에 대해 알아봤으니 이제 이 개념들이 자바스크립트의 이벤트 루프와 어떻게 연결되는지 살펴보겠습니다.


이벤트 루프(Event Loop)

**이벤트 루프(Event Loop)**는 자바스크립트가 비동기 작업을 효율적으로 처리하기 위해 사용하는 메커니즘입니다.
자바스크립트는 싱글 스레드로 동작하기 때문에, 동시에 여러 작업을 처리하려면 작업들을 적절히 큐(Queue)에 쌓아두고, 하나씩 처리하는 방식을 사용합니다.

이때 이 큐를 관리하고, 작업을 처리할 타이밍을 결정해주는 것이 바로 이벤트 루프입니다.

간단하게 말하면

할 일이 있으면 처리하고, 없으면 대기하는 반복적인 루프
라고 생각하면 됩니다.

내부가 어떻게 구성되어 있는지 그림으로 살펴보겠습니다.

어려워 보이지만 하나씩 살펴보겠습니다.

메모리 힙(Memory Heap)

**메모리 힙(Memory Heap)**은 JavaScript 엔진이 사용하는 메모리 공간 중 하나로, 주로 동적으로 생성되는 객체(objects), 배열(arrays), 함수(functions - JavaScript에서는 함수도 객체입니다) 등의 데이터들을 저장하는 곳입니다. 창고로 비유하면 이해하기 쉽습니다.

프로그램이 실행되는 동안 필요에 따라 창고에 물건(데이터)를 넣고(할당) 필요 없어지면 꺼내는(해제) 방식으로 관리합니다.

콜 스택(Call Stack)

**콜 스택(Call Stack)**은 JavaScript 엔진이 현재 실행 중인 함수들을 관리하는 공간입니다.

함수들이 호출되고, 실행되고, 끝나는 과정을 추적하는 스택(Stack)구조입니다.

함수 호출(Push): 함수가 호출되면 해당 함수의 실행 컨텍스트가 콜 스택의 맨 위에 쌓이게 됩니다.

함수 실행: 콜 스택 맨 위에 있는 함수가 현재 실행 중인 함수입니다.

함수 종료(Pop): 함수가 실행을 완료하면(return 혹은 끝까지 실행) 해당 함수가 콜 스택에서 제거됩니다.

Web APIs

브라우저에서 제공하는 API들로, 비동기적으로 실행되는 작업들을 처리합니다. 대표적인 예로는

  • AJAX (fetch, XMLHttpRequest)
  • DOM 이벤트
  • setTimeout

등이 있습니다.

자바스크립트 엔진 자체는 싱글 스레드라 비동기 처리를 직접 할 수 없습니다. 하지만 Web APIs가 비동기 작업을 실행하고, 완료되면 콜백 큐로 넘겨주는 역할을 합니다.

이는 Web API는 브라우저에서 멀티 스레드로 구현되어 있기 때문입니다. 따라서 브라우저는 비동기 작업에 대해 메인 스레드를 차단하지 않고 다른 스레드를 사용하여 동시에 처리할 수 있는 것이죠.

쉽게 말해 자바스크립트가 Web API에게 처리해달라고 부탁하고, 완료되면 알려달라는 구조입니다.

단! 모든 Web API가 비동기적으로 동작하는 것은 아닙니다.

콜백 큐(Callback Queue)

**콜백 큐(Callback Queue)**는 JavaScript의 이벤트 루프에서 비동기 작업이 완료된 후 실행될 콜백 함수들이 대기하는 장소입니다. 때로는 "태스크 큐(Task Queue)"라고도 불립니다. 예를 들어

  • setTimeout
  • setInterval
  • DOM 이벤트

같은 작업들이 완료되면, 이들의 콜백 함수가 콜백 큐에 등록됩니다.

"태스크 큐"에 들어가는 태스크(Task)란 뭘까요?

Task는 프로그램 초기 시작, 이벤트 콜백 실행, 인터벌과 타임아웃 실행처럼 표준 방식에 의해 예약된 아무 JavaScript 코드입니다. - MDN Task

이러한 작업을 하는 콜백 큐에는 2가지의 큐가 있습니다.

Macro Task Queue

우리가 흔히 "Task Queue"라고 부르는 것이 바로 Macro Task Queue입니다. 여기에는 setTimeout,setInterval,fetch,addEventListener와 같이 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐입니다.

  • Micro Task Queue보다 우선 순위가 낮습니다.
  • 현재 실행 중인 태스크가 완료된 후 실행됩니다.
  • 각 Macro Task 사이에 Micro Task Queue가 비워질 때까지 기다립니다.
    • 콜 스택이 비었을 때 Micro Task Queue의 작업을 먼저 처리하게 됩니다.

Micro Task Queue

Micro Task Queue는 Macro Task Queue보다 우선 순위가 높은 큐로, 비동기 작업 중에서도 더 빠르게 처리해야 하는 작업들의 콜백 함수가 들어갑니다. 여기에는 Promise.then, Promise.catch, Promise.finally, MutationObserver와 같이 우선적으로 처리되어야 하는 비동기 함수들의 콜백이 등록됩니다.

Macro Task Queue,Animation Frame보다 높은 우선 순위를 가졌습니다.

Animation Frame Queue

우리가 흔히 들어본 Task Queue와는 달리, Animation Frame Queue는 조금 생소할 수 있습니다. 자바스크립트에서 애니메이션을 효율적으로 처리하기 위해 사용하는 requestAnimationFrame 함수로 콜백을 등록하면, 이 콜백은 Animation Frame Queue에 등록됩니다.

이 큐에 쌓인 작업들은 브라우저가 렌더 트리를 계산하고 레이아웃 작업을 마친 후, 실제 화면을 그리기 직전(Repaint 직전)에 호출됩니다.

  • requestAnimationFrame으로 등록된 콜백은 다음 프레임이 그려지기 직전에 실행됩니다.
  • 일반적으로 초당 60프레임으로 화면이 갱신되며, 이 타이밍에 맞춰 콜백이 호출됩니다.

전체적인 우선 순위는 Micro Task Queue > Animation Frame > Macro Task Queue입니다.

동작 과정

각 요소들을 살펴봤다면 이제 동작 과정을 살펴볼 차례입니다.

  1. 콜 스택을 실행합니다.
  • 우선적으로 콜 스택에 쌓인 작업을 하나씩 처리합니다.
    • 만약 비동기 작업을 만나면, 해당 작업을 Web API에 넘기고 콜 스택을 비우게 됩니다.
  1. Web API처리
  • 비동기 작업은 Web API가 별도의 영역에서 처리하고, 작업이 끝나면 콜백을 적절한 큐로 넘깁니다.
    • setTimeout, fetch → Macro Task Queue
    • Promise.then, queueMicrotask → Micro Task Queue
    • requestAnimationFrame → Animation Frame Queue
  1. Micro Task Queue 실행
  • 콜 스택이 비워지면, 이벤트 루프는 Macro Task를 실행하기 전, Micro Task Queue를 먼저 확인합니다.
    • Micro Task Queue에 작업이 있다면, 등록된 순서로 실행합니다.
    • 만약 도중에 Micro Task가 추가된다면, 큐가 빌 때까지 계속 반복합니다
  1. Macro Task Queue 실행
  • Micro Task Queue가 완전히 비워지면 이제 Macro Task Queue에 있는 작업을 실행합니다.

말로만 설명하면 이해하기 힘드니 코드와 함께 보겠습니다.


1. setTimeout과 콜백 큐

function greet() {
  return "Hello";
}
function respond() {
  return setTimeout(() => {
    return "Hey!";
  }, 1000);
}
 
greet();
respond();

그림을 통해 이해해보겠습니다.

처음 greet()을 만나 실행하게 됩니다. 콜 스택은 비어있기 때문에 콜 스택에 greet()을 넣고, 바로 실행되어 결과가 출력됩니다. 이후 respond() 함수를 만나 콜 스택에 쌓고, 내부의 setTimeout을 실행합니다.

이전에 말했듯이 비동기 처리를 Web API에 위임하고 콜 스택에서 빠져나옵니다. 그리고 Web API에서는 Timer API를 통해 1초동안 수행합니다.

1초 후 타이머가 끝나면, 콜백 함수는 바로 콜 스택으로 들어가지 않고, 먼저 Macro Task Queue에 등록됩니다.

이벤트 루프가 콜 스택이 비었는지 확인 후, 콜 스택이 비었음을 확인했습니다. 콜백 큐에 있는 콜백 함수를 이제 콜 스택에 추가합니다.

이제 이벤트 루프가 콜백 큐에서 대기 중인 콜백을 콜 스택으로 옮겨 실행합니다. 해당 함수가 실행되어 결과를 출력하고, 작업이 끝나면 다시 콜 스택에서 제거됩니다.


2. setTimeout과 Promise

1번 코드의 예시는 단순하게 setTimeout하나만 존재했습니다. 하지만 이번에 살펴볼 코드는 Promise까지 추가된 코드입니다.

console.log("Start!");
 
setTimeout(() => {
  console.log("Timeout!");
}, 0);
 
Promise.resolve("Promise!").then((res) => console.log(res));
 
console.log("End!");

아까보다 코드가 많아졌지만, 천천히 알아보겠습니다.

처음 콜 스택이 비어있기 때문에 console.log("Start!")를 콜 스택에 넣고 바로 실행합니다.

이후 setTimeout을 만나게 됩니다. setTimeout의 경우 1번에서 봤지만, 이번엔 0ms를 대기합니다. 따라서 바로 Macro Task Queue에 추가됩니다.

다음 실행할 코드는 Promise입니다. Promise.resolve가 콜 스택에 추가된 후 콜백 함수가 Micro Task Queue에 추가됩니다. 이후 Promise.resolve는 콜 스택에서 제거됩니다.

console.log를 만나 콜 스택에 추가됩니다. 이때 콜 스택이 비어있었기 때문에 바로 실행되고 콜 스택에서 제거됩니다.

다시 콜 스택이 비게 되었습니다. 이제 이벤트 루프는 다음 실행할 작업을 찾기 위해 큐를 확인합니다. 먼저 Micro Task Queue를 확인하고, 대기 중인 res => console.log(res) 콜백을 꺼내 콜 스택에 추가해 실행합니다. 실행이 끝나고 콜 스택에서 빠지면, 다시 Micro Task Queue에 남은 작업이 있는지 확인합니다. 더 이상 작업이 없음을 확인하면, 그 다음으로 Macro Task Queue를 확인합니다.

이제 Macro Task Queue를 확인하고 대기 중인 setTimeout의 콜백 함수를 큐에서 꺼내 실행합니다.


3.Async/Await

마지막으로 Async/Await을 살펴보겠습니다. Async/Await은 별도의 포스트로 다룰 예정입니다. 따라서 무엇인지에 대해서는 간단히 링크만 남기겠습니다.

const one = () => Promise.resolve("One!");
 
async function myFunc() {
  console.log("In function!");
  const res = await one();
  console.log(res);
}
 
console.log("Before function!");
myFunc();
console.log("After function!");

마찬가지로 그림으로 이해해보겠습니다.

처음 만난 console.log는 콜 스택에 바로 추가되어 실행되고 제거됩니다.

myFunc()가 콜 스택에 추가됩니다. myFunc()의 첫 줄에는 console.log가 있으니 바로 실행되고 제거됩니다.

one() 함수가 호출되면서 Promise.resolve("One!")를 반환합니다. 그러면 await은 이 Promise가 해결(resolve) 될 때까지 myFunc의 실행을 잠깐 멈추고, 이후 실행할 console.log(res) 부분을 Micro Task Queue에 등록해 둡니다. 그리고 myFunc는 여기서 잠시 멈춘 상태로 콜 스택에서 제거됩니다.

await을 만난 비동기 함수는 일시 중지되므로 myFunc에서 빠져나온 후 바로 다음 줄의 console.log를 실행합니다.

콜 스택이 비어있기 때문에 이벤트 루프는 Micro Task Queue를 확인하고 큐에 있는 myFunc를 꺼내 콜 스택에 추가합니다. 마지막으로 console.log를 콜 스택에 추가하고 res의 값을 출력합니다. 이후 콜 스택에서 제거된 후 코드의 실행이 종료됩니다.


마무리

이전에 동아리에서 진행한 스터디에서 같은 주제를 공부하며 정리했었습니다. 하지만 시간이 지나고 나니 가물가물해져 이번 기회에 다시 정리하게 되었습니다. 그래도 이전에 다뤘던 내용이라 막힘이 없을줄 알았지만, 생각보다 정리하면서 "이런 내용이 있었어?"라는 생각이 들어 부끄러웠습니다. 특히, Block/Non-Block, Promise, Async/Await 이 3가지 주제는 제대로 알지도 못한 채 사용해오고 있었습니다. ( 따로 공부하여 별도의 포스트로 찾아뵙겠습니다) 아직까지 완벽하게 이해한 것은 아니지만, 적어도 자바스크립트를 사용한다면 동작 원리는 알고 써야 하지 않을까 싶어 꾸준히 공부할 예정입니다.

읽어주셔서 감사합니다! 🙇‍♂️ 궁금한 점이나 틀린 부분이 있다면 언제든지 댓글로 알려주세요 :)


참고

이벤트 루프 - 리디아 할리 Promise & Async/Await - 리디아 할리 이벤트 루프 동작 원리 - Inpa 이벤트 루프 - MDN 동기/비동기 - khy__ 이벤트 루프 - 모던 자바스크립트 이벤트 루프 - 10분 테코톡 피터(Youtube) 이벤트 루프 - 가장 쉬운 웹 개발 with Boaz(Youtube)