JS 브라우저 환경에서의 이벤트루프

2025. 2. 6. 22:58프론트엔드

자바스크립트의 런타임

런타임(runtime)은 컴퓨터 프로그램이 실행되는 환경이다. JS를 사용해서 개발하는 개발자라면 필수로 JS가 실행되는 동안 코드가 어떻게 돌아가는지 알고 있으면 좋을것 같다.

 

JS는 싱글스레드 언어다. 싱글 스레드란 한번에 한가지 작업만 처리할 수 있다는 것이다.

 

1인 1식당

예를 들어 당신이 식당에 갔는데 손님을 하나만 받을수 있는 식당에 가서 20명 이상 대기가 있는 상태이다.
근데 부엌을 슬쩍 보니 한사람이 식사가 끝나면 그릇이 없어서 식사가 끝나고 설거지를 하고 테이블 하나를 닦고 청소한 후 다음 손님이 들어가는 것이다. 이 답답한 식당에서 당신은 20명 이상 웨이팅할 자신이 있는가?

 

당연하게도 그럴 자신이 없는 JS를 만든 사람은 해결책을 만들어 뒀다. 그 해결책에 대해서 알아보자

 

대표적인 JS 의 실행환경

JS를 실행하는 환경은 대표적으로 2가지가 있다. 서버와 브라우저이다.

브라우저
Node.js

- Google Chrome의 V8 엔진 기반
- DOM, Web APIs 등을 포함해 사용자 인터페이스와 밀접하게 연결

- V8 엔진을 사용하며 서버 측에서 JavaScript 실행
- 파일 시스템과 네트워크 처리에 특화된 API 제공

 

 

실행환경이 각각 다르지만 NodeJS를 활용해서 서버환경에서 프론트를 개발할 수도 있기 때문에 둘의 동작원리를 알고 차이점을 인지해야 훌룡한 개발자가 될것이다.

 

 

Next.Js를 활용해서 SSR 개발을 하다보면 window가 undefinded라던가 localhost를 사용하지 못하는 경우가 있을것이다. 그것이 JS의 실행환경 차이가 있기 떄문이다.

 

JavaScript의 런타임 모델은 코드의 실행, 이벤트의 수집과 처리, 큐에 대기 중인 하위 작업을 처리하는 이벤트 루프에 기반하고 있으며, C 또는 Java 등 다른 언어가 가진 모델과는 상당히 다릅니다.

 

이러한 런타임은 환경에있는 이벤트 또는 작업(task)들을 각각에 맞는 연관된 장소에 수집해서 알잘딱으로 관리해주는 이벤트 루프가 존재한다.

이벤트 루프란?

이벤트, 사용자 상호작용, 스크립트, 렌더링, 네트워킹 등을 조정하기 위해 이벤트 루프를 사용해야 합니다. 각 `Agent`에는 해당하는 고유한 이벤트 루프가 있습니다.

Agent : 에이전트(Agent)는 ECMAScript 명세에서 정의된 개념으로, 실행 컨텍스트와 관련된 여러 요소를 포함하는 추상적인 실행 단위입니다. 
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

 

이벤트 루프는 자바스크립트 엔진이 작업을 관리하고 실행 순서를 조율하는 핵심 메커니즘으로, 다양한 에이전트와 작업 유형에 따라 다르게 동작합니다.

while (무한) {
    if (작업큐.비었는가()) {
        작업큐.빼내기().실행하기();
    }
}

 

이벤트루프는 실행할 이벤트가 없을 때 까지 계속 실행된다. 즉, 이벤트 루프가 살아있는 동안 활성화된 핸들이나 요청이 있는 한 멈추지 않고 계속 실행된다.

 

당연한 얘기지만 런타임이 다르면 이벤트루프의 구조도 다르다. 각각의 구조를 한번 살펴보자.

 

브라우저(Chrome)의 이벤트 루프

브라우저의 이벤트 루프에서는 다음과 같은 작업(task)이 있다.

1. `<script>` 태그
2. 타이머 작업 : `setTimeout`, `setInterval`, `requestIdleCallback`
3. browser API의 이벤트 핸들러들: `click`, `mousedown`, `input`, `blur,` and etc.
    1. Some of the events are user-initiated like clicks, tab switching, etc.
    2. `XmlHttpRequest` 응답 핸들러, `fetch` promise 객체, 기타 등등
4. promise 상태가 변경될 때,
5. 관찰자들 (`DOMMutationObserver`, `IntersectionObserver`)
6. `RequestAnimationFrame`
7.  `WebAPI` (or browserAPI)

브라우저는 WEB APIs 를 포함하고있다. WEB APIs는 각각의 기능들이 스레드로 할당되어 있고 그것이 모은 멀티 스레드이다.

WEB APIs의 대표적인 종류로는 다음과 같다.

  • DOM : HTML 문서의 구조와 내용을 표현하고 조작할 수 있는 객체
  • XMLHttpRequest: 서버와 비동기적으로 데이터를 교환할 수 있는 객체. AJAX기술의 핵심.
  • Timer API: 일정한 시간 간격으로 함수를 실행하거나 지연시키는 메소드들을 제공
  • Console API : 개발자 도구에서 콘솔 기능을 제공
  • Canvas API: canvas요소를 통해 그래픽을 그리거나 애니메이션을 만들 수 있는 메소드들을 제공
  • Geolocation API: 웹 브라우저에서 사용자의 현재 위치 정보를 얻을 수 있는 메소드들을 제공

 

Task queue and Micro Task Queue

작업(Task)을 어떻게 처리하는지 알아볼 차례다 태스크 큐마이크로 태스크 큐가 있다.

Browsers use 2 queues to execute our code:

  1. Task Queue or Macro Task Queue : 모든 이벤트와 타이머, 핸들러를 관리한다.
  2. Micro Task Queue : 프로미스 콜백들 관리 (resolved, rejected), MutationObserver,

 

Task queue

브라우저가 새로운 작업을 받을 때 이 작업은 Task Queue로 들어간다. 이 Task Queue는 순서를 보장하는 큐가 아니라 집합이다.
이벤트 루프 사이클 마다 Task Queue로에서 작업을 꺼내서 실행시킨다. 작업이 완료 되면 render Queue로 넘어가는데 render Queue에 작업이 없을 때 다른 Task를 Task Queue로에서 꺼내서 실행 시킨다.

 

예를 들어 ...

 

우리는 3개의 태스크(A, B, C)를 가지고 있습니다. 이벤트 루프는 첫 번째 태스크(A)를 가져와 실행하며, 이는 4ms가 소요됩니다.
이후 이벤트 루프는 마이크로태스크 큐와 렌더 큐를 확인하지만 비어 있습니다.
이벤트 루프는 두 번째 태스크(B)를 실행하며, 이는 12ms가 소요됩니다.
두 태스크(A와 B)가 총 16ms를 사용한 후, 브라우저는 렌더 큐에 작업을 추가하여 새 프레임을 그리기 시작합니다.
이벤트 루프는 렌더 큐를 확인하고 해당 작업을 실행하며, 이는 약 1ms가 소요됩니다.
이러한 작업이 완료된 후 이벤트 루프는 다시 태스크 큐로 돌아가 마지막 태스크(C)를 실행합니다.

 

이벤트 루프가 각 태스크가 얼마나 오래걸릴지 예측할 수 없는 한계가 있다. 브라우저 엔진은 Js 코드에서 변경된 내용이 최종 상태인지 준비단계인지 알 수없으므로 태스크를 중단하고 렌더링을 수행할 수 없다. 따라서 렌더링은 매크로 태스크와 모든 대기 중인 마이크로 태스크가 완료된 후에만 변경 사항이 반영된다.

 

Call Stack

Call Stack은 JS에서 실행되어야할 함수 목록을 저장하는 자료 구조이다.
현재 호출한 함수와 함수의 인자와 어디서 함수 실행이 마무리되는지 저장한다.

function findMinsu() {
  console.log('찾았다 민수');
  debugger;
}

function goToTheCave() {
  findMinsu();
}

function becomeAPrince() {
  goToTheCave();  
}

function findAFriend() {
   // ¯\_(ツ)_/¯
}

function 술래잡기_시작() {
  const friends = [];
  while (friends.length < 2) {
    friends.push(findAFriend());
  }
  becomeAPrince();
}

console.log(술래잡기_시작());

 


anonymous 는 console.log에 있는 함수 chrome에서는 (Inline)

콜스택이 다 비어 있으면 현재의 Task가 끝난것이다.

 

Macro Task Queue

새로운 태스크는 태스크가 실행되고 있을 때 추가될 수도 있다. ( 콜백함수 등 ) 이때 이 태스크는 태스크큐에 추가된다.
이렇게 태스크가 추가되는 큐는 V8 용어로 '매크로태스크 큐(macrotask queue)'라고 부른다.

이는 이전에 있는 Micro 태스크가 다 비워지고 순차적으로 실행된다.

(() => {
  console.log("시작");

  setTimeout(() => {
    console.log("콜백 1: 콜백 메시지");
  }); // 기본적으로 시간 값을 0으로 가집니다.
 // 시간 값이 0 이라도 바로 실행되지 않고

  console.log("this is just a message");

  setTimeout(() => {
    console.log("콜백 2: 콜백 메시지");
  }, 0);

  console.log("this is the end");
})();

// "시작"
// "평범한 메시지"
// "종료"
// "콜백 1: 콜백 메시지"
// "콜백 2: 콜백 메시지"

이러한 특성을 이용하면 큰작업을 쪼개서 사용자와의 상호작용을 원할하게 할 수도 막을 수도 있다.

 

Micro Task Queue

There are only 2 possible sources of micro tasks: Promise callbacks (onResolved/onRejected) and MutationObserver callbacks.

마이크로 태스크는 총 2가지가 있다.

  1. Promise 콜백
  2. MutationObserver 콜백

마이크로 태스크는 하나의 매그로 태스크가 완료되면 바로 실행 된다.

(4 microtasks 가 0.5초가 걸렸다. 그동안에 browser UI는 Blocked 되고 상호작용을 할 수 없다.)

 

마이크로 태스크가 쌓이면 렌더 큐에 작업이 있어도 마이크로 태스크 큐가 다 비어있지 않으면 실행이 되지 않는다.
이것은 장점이될 수도 단점이 될 수도 있는데 사용자에게 업데이트 하기전 UI 를 보여주고 싶지 않으면 작업을 일부러 크게 해서 렌더링을 지연시킬 수 있을것이다.

 

현재까지의 브라우저 이벤트 루프 구성도

 

매크로태스크와 마이크로태스크

태스크는 매크로 태스크와 마이크로 태스크로 나눌 수 있다.

마이크로태스크는 코드를 사용해서만 만들 수 있다.
주로 Promise, then, catch/finally, awiat를 사용하면 핸들러가 마이크로 태스크가 된다.
이 외에도 표준 API인 queueMicrotask(func)를 사용하면 함수 func를 마이크로태스크 큐에 넣어 처리할 수 있다.

자바스크립트 엔진은 _매크로태스크 하나_를 처리할 때마다 또 다른 매크로태스크나 렌더링 작업을 하기 전에 마이크로태스크 큐에 쌓인 _마이크로태스크 전부_를 처리합니다.

이처럼 마이크로태스크는 다른 이벤트 핸들러나 렌더링 작업, 혹은 다른 매크로태스크가 실행되기 전에 처리된다.

<div id="test"></div>

<script>
  setTimeout(() => alert("timeout"));

  Promise.resolve().then(() => alert("promise"));

  alert("code");

    // code
    // promise
    // timemout 
</script>
  1. code – 일반적인 동기 호출이므로 가장 먼저 매크로태스크 큐에 들어간 후 실행됩니다.
  2. promise.then은 마이크로태스크 큐에 들어가 처리되기 때문에, 현재 코드(alert("code"))가 실행되고 난 후에 실행됩니다.
  3. timeoutsetTimeout에서 설정한 시간이 끝난 후 콜백 함수를 실행하는 것은 매크로태스크이기 때문에 가장 마지막에 출력됩니다.

이런 처리순서가 아주 중요한 이유는 (마우스 좌표 변경이나 네트워크 통신에 의한 데이터 변경 같이 애플리케이션 환경에 변화를 주는 작업에 영향을 받지 않고) 모든 마이크로태스크를 동일한 환경에서 처리할 수 있기 때문이다.

 

그런데 개발을 하다 보면 직접 만든 함수현재 코드 실행이 끝난 후, 새로운 이벤트 핸들러가 처리되기 전이면서 렌더링이 실행되기 전에 비동기적으로 실행해야 하는 경우가 존재한다.


이럴 때 queueMicrotask를 사용해 커스텀 함수를 스케줄링하면 MicroTask가 되므로 다른 매크로 작업이 실행되기 전에 즉시 실행하게 만들 수 있다. ( 사실 저도 아직 와닿지 않습니다. )

 

새로운 매크로태스크를 만드는 방법

지연시간이 0인 `setTimeout(f)` 사용하기
  • 이 방법을 사용하면 계산이 복잡한 큰 태스크 하나를 여러 개로 쪼갤 수 있다. (이 작업이 끝나고 렌더링을 변경하거나 마이크로 태스크를 실행한다거나 할 수 있음)
  • 태스크를 여러 개로 쪼개면 태스크 중간중간 사용자 이벤트에 반응할 수 있고, 작업 진척 상태를 화면에 표시해줄 수도 있다.

지연시간이 0인 setTimeout은 이벤트가 완전히 처리되고 난 후에 특정 작업을 수행하도록 스케줄링할 때도 사용된다 (이벤트가 항상 있다는걸 보장하기 위해)

 

새로운 마이크로태스크를 만드는 방법

- `queueMicrotask(f)` 사용하기
- 이외에도 프라미스 핸들러는 마이크로태스크 큐에 들어가 처리됩니다.

 

Render queue

(공식적인 명칭은 아니지만 이벤트 루프 이후로 렌더링이 수행되는건 맞음 > 할 필요가 없으면 렌더링을 하지 않음)

렌더링은 모든 자바스크립트의 작업이 끝난 후에 수행된다. 렌더링은 다음과 같은 프로세스가 있다.

  1. Request Animation Frame (RAF)
  2. 스타일 계산: CSS 스타일을 계산.
  3. 레이아웃 계산: 요소의 위치와 크기를 계산.
  4. 페인트(Paint): 요소를 픽셀로 변환.
  5. 합성(Composite): GPU를 사용해 최종 화면을 생성.

Request Animation Frame

  • requestAnimationFrame 함수는 시스템이 프레임을 그릴 준비가 되면 애니메이션 프레임을 호출하여 애니메이션 웹페이지를 보다 원활하고 효율적으로 생성할 수 있도록 해준다.
  • setInterval 같은 경우 브라우저의 다른 탭 화면을 보거나 브라우저가 최소화되어 있을 때 계속 타이머가 돌아 콜백을 호출하기 때문에 시스템 리소스 낭비를 초래하고 불필요한 전력을 소모하게 만든다. (Macro Task Queue에 포함되기 때문)
  • 반면 requestAnimationFrame는 페이지가 비활성화 된 상태이면 페이지 화면 그리기 작업도 브라우저에 의해 일시 중지됨으로 CPU 리소스나 배터리 수명을 낭비하지 않게 된다.

 

requestAnimationFrame(rAF) 함수도 setTimeout 이나 여타 이벤트 핸들러와 같이 "애니메이션 프레임"을 그리기 위한 콜백 함수를 등록하고 비동기 task로 분류하여 처리된다. 이때 중요한 특징은 rAF는 일반적인 task queue가 아니라 animation frame이라는 별개의 queue에서 처리된다는 점이다.

 

style

브라우저가 기하학적인 스타일이 변경을 재 계산해야할 때 동작한다.

a.styles.left = '10px'
element.classList.add('my-styles-class')

이러한 경우 두가지다 직접적인 스타일 변경이 있다. 그러면 CSSOM을 다시 만들어서 렌더트리에 적용하게 된다.

Layout

레이어 계산, element의 포지션 변경, size, 다양한 조작이 있을 경우 동작한다.

DOM 엘리먼트 추가, 제거 또는 변경
CSS 스타일 추가, 제거 또는 변경
CSS 스타일을 직접 변경하거나 클래스를 추가
(엘리먼트의 길이를 변경하면 DOM 트리에 있는 다른 노드에 영향을 줄 수 있다.)

브라우저 사이즈가 변할 때

레이아웃 작업은 다음과 같은 비용이 듭니다.
3. 레이아웃을 계산한다.
4. element를 레이어에 삽입한다.

레이아웃 과정(RAF또는 스타일을 포함할수도있음)은 JS가 element를 재조정하거나 속성을 읽을 때 발생하므로 이 과정을 `force layout`이라고 부릅니다.

force layout을 일으키는 속성들 : https://gist.github.com/paulirish/5d52fb081b3570c81e3a

force layout이 발생하면 브라우저는 콜스택이 비어있지 않아도 JS의 메인스레드를 멈춥니다.

Paint

  • css에 따라 색을 입히는과정
  • 그렇게 큰 비용은 들지 않음
  • css 요소의 color 가 변경되면 repaint가 발생한다.

 

Composition

컴포지션(Composition)의 역할

  • 컴포지션은 기본적으로 GPU에서 실행되는 유일한 단계다
  • 이 단계에서는 브라우저가 특정 CSS 스타일(예: transform)만 처리한다.

 

transform: translate는 GPU 렌더링을 활성화하지 않는다.
• 코드에 transform: translateZ(0)를 추가해 GPU 렌더링을 “강제”하려는 시도는 잘못된 개념이다.
• 이 방식은 GPU 렌더링을 보장하지 않습니다.

 

CSS 애니메이션과 transform의 장점
1. 복잡한 애니메이션에 적합:
- transform을 사용하면 각 프레임마다 레이아웃을 강제하지 않으므로 CPU 시간을 절약할 수 있다.

- 이는 애니메이션 성능을 크게 향상시킨다.
2. 부드러운 애니메이션:
- top, right, bottom, left 속성을 사용한 애니메이션은 작은 지연(일명 “soap” 현상)을 유발할 수 있다.
- 반면, transform 기반 애니메이션은 이러한 아티팩트 없이 부드럽게 작동함

 

그러면 렌더링 최적화는 어떻게 ??

렌더링 단계를 생략하는 방법

  1. 레이아웃 단계를 생략할 수 있는 경우
    • 색상(color), 배경 이미지(background-image) 등을 변경할 때는 레이아웃 단계가 필요하지 않습니다.
  2. 레이아웃과 페인트 단계를 생략하는 방법
    • transform 속성을 사용하고 DOM 요소의 속성을 읽지 않으면 레이아웃과 페인트 단계를 건너뛸 수 있습니다.
    • DOM 속성을 미리 캐싱하고 메모리에 저장하여 불필요한 계산을 줄입니다.

애니메이션을 JS에서 CSS로 이동:

  • JavaScript로 애니메이션을 실행하면 추가적인 CPU 리소스를 소모하므로, CSS 애니메이션을 사용하는 것이 더 효율적입니다. ( GPU를 사용하게 )

requestAnimationFrame 사용:

  • 다음 프레임에서 변경 사항을 계획하여 렌더링 타이밍을 최적화합니다.

레이아웃을 강제하는 속성에 주의: force update는 안돼 !!!

  • 레이아웃(Reflow)을 유발하는 속성(예: width, height, top, left) 대신 합성(Composite) 단계에서 처리되는 속성(예: transform, opacity)을 사용합니다.
  • CSS 애니메이션에서는 GPU 가속이 가능한 속성을 활용하여 성능을 최적화합니다.

물론 이는 근거있는 자료에 의해서 진행해야한다. 팀 내에서 근거없는 주장은 설득력이 없다. 적절한 성능 측정기를 사용해서 근거 데이터를 수집하여 필요한 경우에만 차례차례 적용해 나가보자

 

 

브라우저 최종 이벤트 루프

while (true) {
    const taskStartTime = performance.now();
    // It's unspecified where UI events fit in. Should each have their own task?
    const task = eventQueue.pop();
    if (task)
        task.run();
    if (performance.now() - taskStartTime > 50)
        reportLongTask();

    if (!hasRenderingOpportunity())
        continue;

    invokeAnimationFrameCallbacks();
    while (needsStyleAndLayout()) {
        styleAndLayout();
        invokeResizeObservers();
    }
    markPaintTiming();
    render();
}

https://github.com/w3c/long-animation-frames?tab=readme-ov-file#the-current-situation

 

참고

https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop
https://inpa.tistory.com/entry/🔄-자바스크립트-이벤트-루프-구조-동작-원리
https://inpa.tistory.com/entry/🌐-requestAnimationFrame-가이드
https://blog.xnim.me/event-loop-and-render-queue
https://ko.javascript.info/event-loop#ref-530

맺음말

Nodejs의 이벤트루프 까지 공부하려다가 브라우저의 이벤트루프에 렌더링로직 까지 포함되어 있는걸 알게 되어서.. 글을 2번에 나눠 쓰겠습니다.