JS - 이벤트 루프

박상준

2025년 03월 15일

0

JavaScript

뜬금없는 자바스크립트

자바스크립트관련 포스트를 오랜만에 써본다. 그동안 리액트나 프로젝트 관련 포스팅을 하다보니 쓸 일이 별로 없었다. 하지만 최근 비동기 동작이 더 다양해지고 리액트와 Next.js에서도 비동기 동작을 효율적으로 하도록 기능을 확대하고 있기 때문에 이벤트 루프에 대해서도 한번 정리해보고자 한다.

Event Loop란?

우선 자바스크립트는 싱글 스레드 언어이다. 싱글 스레드 언어라는 것은 한번에 하나의 요청만 처리한다는 것이다. 하지만 한번에 하나만 처리하는 경우 발생하는 문제가 있다.

const fetchData = () => {
 // 데이터 패칭 로직
};
fetchData();
console.log('start');

만약 서버에서 데이터를 가져온다고 가정해보자. 한번에 한개의 동작만 처리한다면 데이터 패칭이 완료될 때까지 이후 코드들은 동작하지 않는다. 즉, 코드 실행이 멈추는 것과 같은 상황이다. 멈춘만큼 코드 실행 속도가 느려지고, 이는 사용성을 해치는 것으로 이어진다.

그래서 싱글 스레드인 자바스크립트를 멀티 스레드인 것처럼 동작하도록 하는 것이 비동기 동작이다. 이러한 비동기 동작은 자바스크립트에서 실행하는 것이 아니라, 브라우저 기능인 웹 API를 통해 실행되는 것이다.

그래서 일반 동기 동작들은 자바스크립트에서 실행하고, 비동기 동작들은 브라우저에서 별도로 실행되어 자바스크립트로 전달되는 것이다. 이를 조절하고 관리하는 시스템을 Event Loop라고 한다. 이미지설명 이벤트 루프를 그림으로 표현해보면 이러한 모습이다. 순환하면서 이벤트를 처리하기 때문에 loop라는 단어가 붙는 것이다. 이제 조금더 자세히 알아보겠다.

Event Loop의 동작 방식

function bar() {
  setTimeout(() => {
  	console.log("Second")
  }, 500);
}

function foo() {
  console.log("First");
}

function baz() {
  console.log("Third");
}

bar();
foo();
baz();

가장 기본적인 setTimeout으로 알아보겠다. 총 3개의 함수가 있고, bar()는 setTimeout으로 0.5초 후에 console이 출력된다. 이 함수의 console 출력 순서는 아래와 같다.

First -> Third -> Second

이 로직의 동작 방식을 이미지로 먼저 보겠다. 이미지설명 동작 방식을 순서대로 정리해보면

  1. bar() 함수가 호출되고 그안의 setTimeout() 함수가 호출되어 스택에 쌓인다.
  2. setTimeout() 함수의 매개변수에 할당된 콜백 함수를 Timer Web API에 전달한다. 그리고 Timer Web API 에서는 백그라운드로 500 밀리초를 셈한다.
  3. 다음 foo() 함수가 호출되고 콘솔창(output)에 "First" 가 출력된다.
  4. 이때 500 밀리초 대기 시간이 만료되면서, 이벤트 루프는Timer Web API에서 가지고 있던 콜백 함수를 Task Queue 로 옮긴다.
  5. 그다음 baz() 함수가 호출되고 콘솔창에 "Third" 가 출력된다.
  6. 스택에 있는 모든 메인 자바스크립트 코드가 실행 완료 되어 Call Stack이 비워지게 된다.
  7. 이벤트 루프는 Call Stack 이 비어있는 경우를 탐지하여, Task Queue 에 있는 콜백 함수를 Call Stack 으로 옮긴다.
  8. Call Stack 에서 콜백 함수 코드를 실행하게 되고 콜솔창에는 "Second" 가 출력된다.

콜 스택은 자바스크립트에서 직접 이벤트를 처리하는 곳이고, 비동기 동작을 처리하는 곳은 Web API, Web API에서 실행 완료된 동작이 콜 스택에 적용되기 전에 대기하는 곳이 테스크 큐이다. 그래서 여러개의 비동기 동작을 실행하게 되면 브라우저를 통해 비동기 동작을 하고, 테스크 큐에 하나씩 적립된다. 그리고 이후에 콜 스택이 비어있게 되면 하나씩 테스크 큐에서 콜 스택으로 이동되는 것이다.

여러개의 비동기 동작

그렇다면 여러개의 비동기 동작이 실행된다면 어떤 순서로 동작될까?

console.log('Start!');

setTimeout(() => {
	console.log('Timeout!');
}, 0);

Promise.resolve('Promise!').then(res => console.log(res));

console.log('End!');

setTimeout과 Promise를 사용한 로직이다. 실행 결과는 아래와 같다.

Start! -> End! -> Promise! -> Timeout!

콘솔이야 비동기 동작이 아니니까 다른 비동기 동작보다 우선적으로 실행되는 것을 우리는 이미 알고 있다. 하지만 비동기 동작에서도 순서가 존재한다는 사실을 알 수가 있다. 즉, 테스크 큐가 한개로 이뤄진 것이 아니라는 것이다. 이미지설명 테스크 큐는 두개로 이뤄져 있다. microtask queuemacrotask queue이다. 둘다 비동기 동작을 담는 큐이지만 각각 종류가 다르다.

  • microtask queue : Promise callback, async
  • macrotask queue : setTimeout, setInterval

이렇게 queue에 따라 담기는 이벤트의 종류가 다르다. 종류만 다른것이 아니라 실행 순서도 차이가 있다. 이미지에서도 확인할 수 있듯이 microtask queue가 먼저 실행되고, microtask queue가 비워지게 되면 macrotask queue가 실행된다. 그렇다면 위의 로직의 순서가 이해된다.

  1. Call Stack에 console.log('Start!') 코드 부분이 쌓인 뒤 실행 되어 콘솔창에 "Start!" 가 출력
  2. setTimeout 코드가 콜 스택에 적재되고 실행되면, 그 안의 콜백 함수가 이벤트 루프에 의해 Web API로 옮겨지고 타이머가 작동(0초라서 사실상 바로 타이머는 종료)
  3. 타이머가 종료됨에 따라 setTimeout 의 콜백 함수는 MacroTask Queue에 이벤트 루프에 의해 적재
  4. Promise 코드가 콜스택에 적재 되어 실행되고 then 핸들러의 콜백 함수가 이벤트 루프에 의해 MicroTask Queue에 적재
  5. console.log('End!') 코드가 실행되고 "End!" 텍스트가 콘솔창에 출력
  6. 모든 메인 스레드의 자바스크립트 코드가 실행이되어 더이상 Call Stack엔 실행할 스택이 없어 비워짐
  7. 그러면 이벤트 핸들러가 이를 감지하여, Callback Queue에 남아있는 콜백 함수들을 빼와 Call Stack에 적재
  8. 이때 2종류의 Queue 중 MicroTask Queue에 남아있는 콜백이 우선적으로 처리 (만일 콜백이 여러개가 있다면 전부 처리)
  9. MicroTask Queue가 비어지면, 이제 이벤트 루프는 MacroTask Queue에 있는 콜백 함수를 Call Stack에 적재해 실행

비동기 컨트롤하는 법

비동기 동작은 위에 코드에서도 알수 있듯이 동기 코드에서 헷갈릴 우려가 있다. 그래서 비동기 동작을 동기 동작처럼 보이도록 만들어줄 수가 있다.

Promise.then()

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("📦 데이터 받아옴!");
    }, 2000);
  });
}

console.log("🟢 데이터 요청 중...");

fetchData()
  .then((result) => {
    console.log(result);
    return "✅ 처리 완료!";
  })
  .then((message) => {
    console.log(message);
  });

console.log("⏳ 다른 작업 진행 중...");

Promise.then()을 사용하면 비동기 동작이 완료된 이후에 실행될 코드를 동기적으로 표현할 수 있다. 동작 순서는 아래와 같다.

🟢 데이터 요청 중... -> ⏳ 다른 작업 진행 중... -> 📦 데이터 받아옴! -> ✅ 처리 완료!

데이터 요청 콘솔이 우선적으로 동작한다. 이후에 Promise를 리턴하는 함수를 실행하게되고, Promise내부에 setTimeout이라는 비동기 동작을 실행한다. 비동기 동작은 콜 스택에서 Web API로 이동되어 실행되고 테스크 큐에 적립된다. 그리고 진행중 콘솔이 동작한 이후에 then으로 체이닝된 로직들이 실행되는 것이다.

async await

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("📦 데이터 받아옴!");
    }, 2000);
  });
}

async function processData() {
  console.log("🟢 데이터 요청 중...");

  const result = await fetchData();
  console.log(result);

  console.log("✅ 처리 완료!");
}

processData();
console.log("⏳ 다른 작업 진행 중...");

이 코드는 위의 Promise로 작성된 코드를 async await으로 작성한 것이다. 콘솔 동작 순서는 기존과 동일하다. 하지만 Promise.then()으로 작성된 코드는 동작이 복잡할 수록 depth가 깊어질수 밖에 없다. 그래서 async await으로 보다 동기 코드처럼 만들수가 있다.

새로운 기능은 아니고 기존 Promise를 보기 좋게 만들어주는 것이다.

function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("📦 데이터 받아옴!");
    }, 2000);
  });
}

async function processData() {
  console.log("🟢 데이터 요청 중...");

  const result = await fetchData();
  console.log(result);

  console.log("✅ 처리 완료!");
}

await processData(); // await 추가
console.log("⏳ 다른 작업 진행 중...");

위에서 작성한 로직에 await을 하나 추가해줬다. 그러면 동작 순서는

🟢 데이터 요청 중... ->  📦 데이터 받아옴! -> ✅ 처리 완료! -> ⏳ 다른 작업 진행 중...

기존 동작 순서와 다르게 비동기 동작이 전부 마무리된 이후에 마지막 콘솔이 추가된다. 이게 어떻게 된일인지 async await을 기존 형태로 한번 바꿔보겠다.

processData().then(() => {
  console.log("⏳ 다른 작업 진행 중...");
});

사실상 async await도 Promise.then()으로 바꿔주는 기능이다. 그렇다 보니 await을 붙여주면 위와 같이 체이닝이 일어나 기존 동작과 다르게 실행되는 것이다. 그래서 어떤 데이터를 어떻게 가공해서 사용할지에 따라 비동기 동작을 실행하는 것이다.

마무리

비동기는 매번 다뤄도 좀 헷갈린다. 아무래도 순서대로 동작하는게 아니라서 더욱 조심해야할 기능이다. 그래도 이 기능 덕분에 더욱 다채롭고 빠르게 기술이 발전하고 있기 때문에 좋은 것들이 더 많다. 앞으로 비동기 동작에 대해서 더 많은 기능을 지원하게 될것으로 예상되는데 기초부터 다져놔야 나중에 헷갈리지 않을 듯하다.

개의 댓글