JavaScript Event Loop

진돌·2025년 2월 3일
0

javascript

목록 보기
1/1
post-thumbnail

안녕하세요. 프론트 개발자 진돌입니다.

처음 자바스크립트를 접하였을 때, 이해하기 어려웠던 내용이 있었습니다.

자바스크립트는 싱글 스레드 언어이며, 비동기 함수로 작업을 처리하여 오래걸리는 작업의 블로킹 현상을 막을 수 있습니다.

자바스크립트는 싱글 스레드인데 비동기 작업은 어디서 해주며 어떻게 관리를 해주는 것일까라는 의문이 있었습니다.

그 정답 중 하나는 이번 주제인 이벤트 루프입니다.

이벤트 루프 구성 요소

이벤트 루프 동작에 대해서 이해하기 앞서서 이벤트 루프가 관리하는 각각의 요소에 대해서 이해할 필요가 있습니다. (이미지 출처)

Call Stack

Call Stack은 자바스크립트 프로그램 실행 context를 관리합니다.
어떤 함수가 실행되면 Call Stack에 하나씩 쌓이며 실행 context가 만들어집니다.

console.log() 등 일반적인 자바스크립트 함수가 쌓이고 소모되는 곳입니다.

Web APIs

브라우저에서 활용하는 기능과 상호 작용하는 데 사용되는 브라우저에서 제공해주는 API들 입니다. 일부는 백그라운드에서 비동기 작업을 제공해주고 비동기 작업은 브라우저 자체에서 실행됩니다.

오늘은 이벤트 루프 설명에 필요한 비동기 Web APIs를 좀 더 자세히 살펴보겠습니다.

Web APIs 비동기 함수들은 크게 두가지 방식이 존재합니다.

1. call back을 실행하는 Web API

대표적으로 setTimeout 함수가 있습니다.

setTimeout(() => {
  console.log('2000ms');
},100);

setTimeout함수를 예로 보면 100ms 시간을 브라우저에서 비동기로 관리한 후 100ms 지나면 call back을 자바스크립트 엔진에서 실행시키도록 옮겨줄 것입니다.

자바스크립트 엔진에서 실행시키려면 Call Stack에 들어가야 되는데, 이 때 비동기 함수들이 Call Stack에 바로바로 계속 들어가면 충돌이 생길 것입니다.

이를 방지하기 위해 Task Queue라는 것이 있고 setTimeout에 넣어준 callback은 100ms가 지나면 Task Queue로 이동하는 방식으로 구성됩니다.

Task Queue는 뒤에서 설명드리도록 하겠습니다.

2. Promise를 반환하는 Web API

최신 Web API들은 대부분 Promise를 반환하는 방식을 제공해줍니다.
대표적으로는 fetch API가 있습니다.

fetch("...")
  .then(data => console.log(data))
  .catch(error => console.error(error));

해당 Web API의 경우는 브라우저에서 함수가 실행된 후 then, catch 등을 자바스크립트 엔진에서 실행될 수 있도록 옮겨줍니다.

Promise는 callback 방식의 Web API와는 조금 다르게 Microtask Queue로 이동하는 방식으로 구성됩니다.

Task Queue

Web API 콜백 및 이벤트 핸들러가 보관되는 Queue입니다.
(Web API의 callback을 넣어놓기에 Callback Queue 라고 불리기도 함)
Call Stack이 비워지면 이벤트 루프가 Task Queue에 있는 Task를 Call Stack으로 이동하여 실행하도록 하는 목적입니다.
(setTimeout, setInterval 등)

Microtask Queue

Promise Callback, async await, MutationObserver 등의 callback이 보관되는 Queue입니다.
마이크로 태스크큐는 이벤트 루프가 Call Stack이 비어져있으면 Task Queue 보다 먼저 소모하는 우선순위가 가장 높은 Queue입니다. (렌더링보다도 먼저 실행)
(Promise.then, catch, finally, fetch 등)

이벤트 루프 동작 과정

이제 본론으로 돌아와서 이벤트 루프에 대해서 살펴보겠습니다.

기본 동작

이벤트 루프는 위에서 설명한 Queue들의 작업을 CallStack으로 옮겨주는 역할을 합니다. (함수의 실행 처리는 자바스크립트 엔진, 브라우저가 처리)

이벤트 루프는 Call Stack이 비어있는지 확인하는 루프를 계속 돌고 있습니다.

루프중 CallStack이 비게 되면 현재 진행중인 작업이 없다 판단하고 Microtask Queue나 Task Queue(Microtask Queue가 우선순위가 높음)에서 대기중인 작업 중 실행가능한 가장 오래된 함수를 Call Stack으로 옮겨줍니다.

이벤트 루프는 위의 과정을 지속적으로 반복합니다.

아래 코드는 이해를 돕기 위해 javascript를 활용하여 야매로 다음 과정을 작성해보았습니다.

while(true){
  if(!콜스택이_비어있는가){
    return;
  }

  if(마이크로_태스크큐_대기중인_작업이_있는가){
    콜스택에_마이크로_태스크큐_작업_넣기();
    return;
  }
  
  render()

  if(태스크큐_대기중인_작업이_있는가){
    콜스택에_태스크큐_작업_넣기();
    return;
  }
}

Task Queue가 동작하는 과정

Callback 기반의 비동기 Web API는 Task Queue에서 관리한다고 하였습니다.
setTimeout 코드를 통해 해당 과정을 조금 더 자세히 살펴보도록 하겠습니다.

setTimeout(() => {
  console.log('2000ms');
},2000);

setTimeout(() => {
  console.log('100ms');
},100);

console.log('end');

위에서 설명한 Event loop에 따르면 다음과 같은 과정을 진행할 것입니다.

  1. setTimeout 함수가 호출되어 Call Stack에 쌓임
  2. 콜백 함수를 Timer Web API에 전달하고, 브라우저에서는 2000ms를 셈함
  3. setTimeout 함수 Call Stack에서 제거
  4. setTimeout 함수가 호출되어 Call Stack에 쌓임
  5. 콜백 함수를 Timer Web API에 전달하고,브라우저에서는 100ms 셈함
  6. setTimeout 함수 Call Stack에서 제거
  7. console.log('end')가 Call Stack에 쌓임
  8. console.log('end') Call Stack에서 제거
  9. 100ms callback Call Stack으로 이동
  10. console.log('100ms'); Call Stack에 쌓임
  11. Call Stack에서 console.log('100ms'); 제거
  12. Call Stack에서 2000ms callback 제거
  13. 100ms callback Call Stack으로 이동
  14. console.log('2000ms'); Call Stack에 쌓임
  15. Call Stack에서 console.log('2000ms'); 제거
  16. Call Stack에서 2000ms callback 제거

해당 과정을 설명하는 좋은 gif가 있어서 소개드립니다(출처)

MicroTask Queue가 동작하는 과정

MicroTask Queue도 Task Queue와 동일하게 동작을 하게 됩니다.
다른 점은 위에서 설명했던 것처럼 이벤트 루프가 가장 높은 우선순위로 처리를 하게 됩니다.

이번에도 해당 과정을 설명하는 좋은 gif가 있어서 소개드립니다 (출처)

주의해야할 점은 렌더링보다 우선순위에 있는 작업이라 microtask Queue에 작업 자체가 너무 많이 쌓이게 되면 렌더링 자체가 멈추는 현상이 생길 수 있습니다.

React를 통해서 살펴보기

React 개발자에게 친숙한 React 코드를 통해서 이벤트 루프 동작을 살펴보겠습니다.

문제 코드

다음 코드는 x축으로 스크롤되는 리스트와, 리스트에 아이템을 추가하고 추가된 아이템으로 scroll 해주는 button으로 구성되어 있습니다.

  const [listData, setListData] = React.useState(data);
  const containerRef = React.useRef<HTMLDivElement>(null);

  const handleAddItem = () => {
    setListData((prev) => [...prev, item]);
    
    containerRef.current?.scrollTo({
   	  left: ref.current?.scrollWidth,
      behavior: "smooth",
    });
  };


  return (
      <>
        <div
          ref={containerRef}
          style={{
            display: "flex",
            flexDirection: "row",
            gap: 10,
            width: "100%",
            overflowX: "scroll",
          }}
        >
          {listData.map((item) => (
            <div
              key={item}
              style={{
                backgroundColor: "tomato",
                width: 300,
                height: 300,
              }}
            >
              <div style={{ width: 300 }}>{item}</div>
            </div>
          ))}
        </div>
     
        <button
          type="button"
          onClick={handleAddItem}
        >
         +
        </button>
      </>
  );


해당 코드는 의도대로 동작하지 않습니다.
왜 동작안하는걸까요?

위에서 살펴본 Event Loop의 동작과정을 통해서 이해해보겠습니다.

  1. 버튼을 클릭하여 event handler onClick 함수가 Call Stack에 추가
  2. handleAddItem() Call Stack에 추가
  3. setListData() Call Stack에 추가
  4. React setState call back은 배치 처리 대기열에 추가됨 (Microtask Queue에서 처리됨)
  5. setListData() Call Stack에서 제거
  6. scrollTo() 함수 Call Stack 추가
  7. scrollTo() 호출(호출을 하였으나 아직 상태가 변경되지 않음)
  8. scrollTo() 함수 Call Stack에서 제거
  9. handleAddItem() Call Stack에서 제거
  10. event handler onClick 함수 Call Stack에서 제거
  11. Microtask Queue에서 React bacth 업데이트 함수 실행
  12. rendering

위에서 살펴본 Evnet Loop 과정으로 하나씩 짚어보니 어떤 문제가 있는지 알겠네요.

React에서 batch update 처리를 하면서 Microtask Queue에서 상태업데이틀 하였기 때문입니다.

태스크 큐를 이용해보자

이벤트 루프에서 Microtask Queue는 항상 먼저 실행되는 Queue라고 하였습니다.
의도대로 동작시키기 위해서 이벤트 루프의 동작 원리를 활용해보겠습니다.

...
  const handleAddItem = () => {
    setListData((prev) => [...prev, item]);
    
    setTimeout(() => {
      ref.current?.scrollTo({
        left: ref.current?.scrollWidth,
        behavior: "smooth",
      });
    }, 0);
  };

...

scrollTo 함수를 setTimeout Callback에 넣어주었습니다.

좋아요! 의도대로 아주 잘 동작하네요.

이번에도 이벤트 루프 동작 과정을 살펴보겠습니다.

  1. 버튼을 클릭하여 event handler onClick 함수가 Call Stack에 추가
  2. handleAddItem() Call Stack에 추가
  3. setListData() Call Stack에 추가
  4. React setState call back은 배치 처리 대기열에 추가됨 (Microtask Queue에서 처리됨)
  5. setListData() Call Stack에서 제거
  6. setTimeout 함수 Call Stack에 추가
  7. 콜백 함수를 Timer Web API에 전달하고,브라우저에서는 0ms 셈하고 Task Queue에 넣어줌
  8. setTimeout 함수 Call Stack에서 제거
  9. handleAddItem() Call Stack에서 제거
  10. event handler onClick 함수 Call Stack에서 제거
  11. Microtask Queue에서 React bacth 업데이트 함수 실행
  12. rendering
  13. Task Queue에서 setTimeout Callback Call Stack으로 이동
  14. scrollTo() 함수 Call Stack 추가
  15. scrollTo() 호출
  16. scrollTo() 함수 Call Stack에서 제거
  17. setTimeout Callback Call Stack에서 제거

여기까지 이벤트 루프의 동작을 통해서 보니 왜 동작하는지 명확히 알 수 있었습니다.

정리

  • 자바스크립트의 비동기처리는 브라우저의 이벤트 루프가 관리함
  • Web APIs에는 Callback 기반의 비동기 함수와 Promise 기반의 비동기 함수를 제공함
  • Callback 기반의 비동기 함수는 Task Queue에서 쌓이고 Promise 기반의 비동기 함수는 Microtask Queue에 쌓임
  • 이벤트 루프는 Call Stack이 비어있으면 Microtask Queue or Task Queue를 Call Stack으로 옮겨주며 비동기 작업을 관리 함
  • 이벤트 루프는 말 그대로 무한으로 루프하며 해당 동작을 반복 함

마무리

여기까지 자바스크립트 이벤트 루프 동작에 대해서 자세히 살펴보았습니다.
틀린 부분이 있으면 댓글로 자유롭게 피드백 부탁드리겠습니다.

감사합니다.

References

MDN
lydiahallie님의 Event Loop 설명
인파님의 Event Loop 설명
React Batch Update

0개의 댓글