브라우저 렌더링 및 JavaScript 이벤트 루프 이해하기

2025년 2월 15일

1. 서론

Vue3에서 개발을 하다 보면 nextTick 함수를 자주 사용하게 됩니다. 이 함수는 데이터 변경 후 DOM이 업데이트될 때까지 기다렸다가 특정 작업을 수행하게 해줍니다.

"브라우저는 정확히 언제, 어떤 순서로 HTML을 화면에 렌더링할까?", "왜 JavaScript는 DOM 업데이트를 기다려야 할까?" 이 질문들의 답을 다시 정리해 보고 싶었습니다.

2. 본론

🌳 브라우저의 렌더링 과정

브라우저가 웹 페이지를 로드하고 렌더링하는 과정은 크게 5단계로 이루어집니다.

1) HTML 파싱과 DOM 트리 생성

브라우저는 HTML 문서를 읽고 파싱하여 DOM(Document Object Model) 트리를 구축합니다. 이 과정은 HTML 태그를 위에서 아래로 순차적으로 처리하며, 중첩된 요소들은 트리 구조로 표현됩니다. 페이지의 구조를 표현하는 이 트리는 JavaScript가 문서를 조작할 수 있는 기반이 됩니다.

2) CSS 파싱과 CSSOM 생성

브라우저는 CSS 파일을 다운로드하고 파싱하여 CSSOM(CSS Object Model)을 만듭니다. CSSOM도 DOM과 유사한 트리 구조를 가지며, 각 노드에는 해당 요소에 적용될 모든 스타일 정보가 포함됩니다.

3) 렌더 트리 구축

DOM과 CSSOM을 결합하여 렌더 트리를 만듭니다. 렌더 트리는 실제로 화면에 표시될 요소들만 포함합니다. 예를 들어, display: none이 적용된 요소는 렌더 트리에 포함되지 않습니다. 반면 visibility: hidden이 적용된 요소는 공간을 차지하므로 렌더 트리에 포함됩니다.

4) 레이아웃(Reflow)

렌더 트리가 완성되면, 브라우저는 각 노드의 정확한 위치와 크기를 계산합니다. 이 과정을 레이아웃 또는 리플로우라고 합니다. 레이아웃 과정은 DOM의 구조와 스타일 속성에 의해 영향을 받습니다. 또한, 디바이스 회전이나 브라우저 사이즈 조정과 같이 화면 요소의 위치가 변경되면 이 과정이 다시 수행됩니다.

5) 페인팅

마지막으로, 계산된 레이아웃을 바탕으로 실제 픽셀을 화면에 그립니다.

⚒️ JavaScript와 브라우저 렌더링의 상호작용

웹 페이지가 로드되는 동안 브라우저는 <script> 태그를 만나면 HTML 파싱을 일시 중지하고 JavaScript를 실행합니다. 이때 JavaScript는 이미 생성된 DOM을 수정할 수 있습니다. 예를 들어, 요소를 추가하거나 삭제하고, 속성을 변경하거나 스타일을 조작하는 등의 작업을 수행할 수 있습니다.

이러한 JavaScript로 DOM과 CSSOM이 변경되면 레이아웃이 다시 그려지는 리플로우 또는 리페인팅이 일어나게 됩니다.

🔄 JavaScript와 렌더링의 관계: 마이크로태스크와 렌더링 큐

브라우저 성능을 최적화하기 위해 모든 JavaScript 작업을 즉시 DOM에 반영하지 않습니다. 대신, 변경사항을 일괄 처리하여 불필요한 렌더링 작업을 최소화합니다. JavaScript 엔진(V8)은 매크로 태스크 큐와 마이크로 태스크 큐라는 두 가지 주요 큐를 사용합니다.

큐(queue) : 선입선출(First In First Out—FIFO) 자료구조

  • 매크로 태스크 큐: 외부 스크립트(<script> 태그), 마우스 이벤트 핸들러, setTimeout, setInterval
  • 마이크로태스크 큐 : Promise의 .then()/.catch()/.finally(), queueMicrotask(), async/await 콜백

현재 실행 중인 스크립트가 완료된 후, 브라우저는 렌더링을 수행하기 전에 마이크로태스크 큐의 모든 작업을 처리합니다. 그런 다음, 매크로 태스크 큐에서 다음 작업을 가져와 실행합니다.

console.log("script start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve()
  .then(() => {
    console.log("promise1");
  })
  .then(() => {
    console.log("promise2");
  });

console.log("script end");
// 출력: script start, script end, promise1, promise2, setTimeout

📚 Vue와 React의 DOM 업데이트

Vue

  1. 상태가 변경되면, Vue는 이를 감지합니다.

  2. 변경사항을 즉시 DOM에 반영하지 않고, 마이크로 태스트 큐에 쌓아둡니다.

  3. 다음 "tick"에 변경사항을 일괄적으로 Virtual DOM에 적용합니다.

  4. 이전 Virtual DOM과 새 Virtual DOM을 비교하여 변경이 필요한 DOM 업데이트를 수행합니다.

<script setup>
import { ref, nextTick } from 'vue'

const count = ref(0)

async function increment() {
  count.value++

  // DOM 업데이트 전
  console.log(document.getElementById('counter').textContent) // 이전 값 (0)

  await nextTick()
  // DOM 업데이트 후
  console.log(document.getElementById('counter').textContent) // 업데이트된 값 (1)
}
</script>

<template>
  <button id="counter" @click="increment">{{ count }}</button>
</template>

마이크로태스크 큐에 등록된 nextTick의 콜백은, 현재 실행 중인 코드가 완료된 후 실행됩니다. 콜백 함수 실행전 Vue가 DOM 업데이트를 수행하므로, 콜백 내부에서는 업데이트된 DOM에 접근할 수 있게 됩니다.

React

  1. 상태가 변경되면(setState 호출 등), 새로운 Virtual DOM 트리를 생성합니다.

  2. 이전 Virtual DOM과 비교 후 필요한 부분만 업데이트합니다.

  3. useEffect 와 같은 메서드로 업데이트된 값에 접근할 수 있습니다.

function Counter() {
  const [count, setCount] = useState(0);
  const counterRef = useRef(null);

  useEffect(() => {
    // DOM 업데이트 후
    console.log(counterRef.current.textContent); // 업데이트된 값 (1)
  }, [count]);

  function increment() {
    setCount(count + 1);
    // DOM 업데이트 전
    console.log(counterRef.current.textContent); // 이전 값 (0)
  }

  return (
    <button ref={counterRef} onClick={increment}>
      {count}
    </button>
  );
}

Vue와 React의 차이점

  • 반응성 시스템
    • Vue: 양방향 바인딩이 가능하며, ref, reactive 등을 제공합니다.
    • React: 명시적으로 setState를 호출해야 변경이 감지되며, 상태 변경은 컴포넌트 단위에서만 이루어집니다.

3. 결론

Vue의 nextTick을 시작으로 DOM 생성과 업데이트 과정, JavaScript의 동작 구조, 그리고 Virtual DOM을 활용한 프레임워크들의 동작 방식까지 살펴봤습니다. 이번 글을 통해 브라우저, javascript, 프레임워크의 각 개념들을 유기적으로 연결할 수 있었습니다.

🙇‍♂️ 참고문헌

웹페이지를 표시한다는 것: 브라우저는 어떻게 동작하는가

이벤트 루프와 태스크 큐 (마이크로 태스크, 매크로 태스크)

javascript event loop